From 309e934bfc2f4489fd7212902449359428657269 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 4 Oct 2025 15:01:03 -0400 Subject: [PATCH 01/38] OTHER: Add 'tck' to output params for all pspline methods --- pybaselines/morphological.py | 2 +- pybaselines/spline.py | 30 ++++++++++++++------------ pybaselines/two_d/spline.py | 21 +++++++++--------- tests/test_api.py | 28 ++++++++++++++++++++++++ tests/test_morphological.py | 2 +- tests/test_spline.py | 6 +++--- tests/two_d/test_api.py | 42 ++++++++++++++++++++++++------------ tests/two_d/test_spline.py | 2 +- 8 files changed, 89 insertions(+), 44 deletions(-) diff --git a/pybaselines/morphological.py b/pybaselines/morphological.py index 0ca86f5b..0ed75faf 100644 --- a/pybaselines/morphological.py +++ b/pybaselines/morphological.py @@ -790,7 +790,7 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, pspline.penalty = (_check_lam(lam) / lam_smooth) * pspline.penalty baseline = pspline.solve_pspline(spline_fit, weight_array) - return baseline, {'half_window': half_window, 'weights': weight_array} + return baseline, {'half_window': half_window, 'weights': weight_array, 'tck': pspline.tck} @_Algorithm._register(sort_keys=('signal',)) def jbcd(self, data, half_window=None, alpha=0.1, beta=1e1, gamma=1., beta_mult=1.1, diff --git a/pybaselines/spline.py b/pybaselines/spline.py index 6f1c80c4..85e0d1d3 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -195,7 +195,7 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1] + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck } baseline = np.polynomial.polyutils.mapdomain(baseline, np.array([-1., 1.]), y_domain) @@ -283,7 +283,7 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, old_coef = pspline.coef weight_array = _weighting._quantile(y, baseline, quantile, eps) - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -451,7 +451,7 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -564,7 +564,7 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -648,7 +648,7 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params @@ -725,7 +725,7 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -832,7 +832,7 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params @@ -910,7 +910,7 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params @@ -1047,7 +1047,8 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde alpha_array = abs_d / abs_d.max() params = { - 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1] + 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1], + 'tck': pspline.tck } return baseline, params @@ -1146,7 +1147,7 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -1281,7 +1282,7 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -1417,7 +1418,7 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp ) baseline = pspline.solve_pspline(y, weight_array) - params = {'weights': weight_array, 'half_window': half_wind} + params = {'weights': weight_array, 'half_window': half_wind, 'tck': pspline.tck} return baseline, params @_Algorithm._register(sort_keys=('weights',)) @@ -1530,7 +1531,8 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde beta = 1 - weight_mean params = { - 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1] + 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], + 'tck': pspline.tck } return baseline, params @@ -1625,7 +1627,7 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index b79bd6cf..1b3cfb04 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -182,7 +182,7 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1] + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck } baseline = np.polynomial.polyutils.mapdomain(baseline, np.array([-1., 1.]), y_domain) @@ -273,7 +273,7 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, old_coef = pspline.coef weight_array = _weighting._quantile(y, baseline, quantile, eps) - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -364,7 +364,7 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -474,7 +474,7 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -561,7 +561,7 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params @@ -641,7 +641,7 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -722,7 +722,7 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params @@ -823,7 +823,7 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} return baseline, params @@ -940,7 +940,8 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order beta = 1 - weight_mean params = { - 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1] + 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], + 'tck': pspline.tck } return baseline, params @@ -1040,6 +1041,6 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} return baseline, params diff --git a/tests/test_api.py b/tests/test_api.py index 97442c23..fe7f1f7e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,6 +6,7 @@ """ +import inspect import pickle import numpy as np @@ -268,3 +269,30 @@ def test_ensure_pickleable(self, input_x): fitter.optimize_extended_range(self.y, method='asls') pickle_and_check(fitter, 1, fitter._polynomial, fitter._spline_basis, True) + + +@ensure_deprecation(1, 4) # remove the warnings filter once pspline_mpls is removed +@pytest.mark.filterwarnings('ignore:"pspline_mpls" is deprecated') +def test_tck(data_fixture): + """Ensures all penalized spline methods return 'tck' in the output params.""" + methods = [] + for (method_name, method) in inspect.getmembers(api.Baseline): + if ( + inspect.isfunction(method) + and not method_name.startswith('_') + and ( + 'num_knots' in inspect.signature(method).parameters.keys() + or 'spline_degree' in inspect.signature(method).parameters.keys() + ) + ): + methods.append(method_name) + x, y = data_fixture + fitter = api.Baseline(x) + failures = [] + for method in methods: + _, params = getattr(fitter, method)(y) + if 'tck' not in params: + failures.append(method) + + if failures: + raise AssertionError(f'"tck" not in output params for {failures}') diff --git a/tests/test_morphological.py b/tests/test_morphological.py index a6d7a707..f27a18fc 100644 --- a/tests/test_morphological.py +++ b/tests/test_morphological.py @@ -185,7 +185,7 @@ class TestMpspline(MorphologicalTester, InputWeightsMixin, RecreationMixin): """Class for testing mpspline baseline.""" func_name = 'mpspline' - checked_keys = ('half_window', 'weights') + checked_keys = ('half_window', 'weights', 'tck') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/test_spline.py b/tests/test_spline.py index 51461890..646203b8 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -68,7 +68,7 @@ def test_numba_implementation(self): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history') + checked_keys = ('weights', 'tol_history', 'tck') def test_tol_history(self): """Ensures the 'tol_history' item in the parameter output is correct.""" @@ -437,7 +437,7 @@ class TestPsplineAsPLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_aspls baseline.""" func_name = 'pspline_aspls' - checked_keys = ('weights', 'tol_history', 'alpha') + checked_keys = ('weights', 'tol_history', 'alpha', 'tck') weight_keys = ('weights', 'alpha') def test_wrong_alpha_shape(self): @@ -600,7 +600,7 @@ class TestPsplineMPLS(SplineTester, InputWeightsMixin, WhittakerComparisonMixin) """Class for testing pspline_mpls baseline.""" func_name = 'pspline_mpls' - checked_keys = ('half_window', 'weights') + checked_keys = ('half_window', 'weights', 'tck') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/two_d/test_api.py b/tests/two_d/test_api.py index ab54ff4b..78c65c5b 100644 --- a/tests/two_d/test_api.py +++ b/tests/two_d/test_api.py @@ -6,6 +6,7 @@ """ +import inspect import pickle import numpy as np @@ -14,7 +15,7 @@ from pybaselines.two_d import api, morphological, optimizers, polynomial, smooth, spline, whittaker -from ..base_tests import get_data2d +from ..base_tests import get_data2d, check_param_keys _ALL_CLASSES = ( @@ -166,19 +167,7 @@ def test_all_methods(self, method_and_class): )(fit_data, **kwargs) assert_allclose(api_baseline, class_baseline, rtol=1e-12, atol=1e-12) - assert len(api_params.keys()) == len(class_params.keys()) - for key, value in api_params.items(): - assert key in class_params - class_value = class_params[key] - if isinstance(value, (int, float, np.ndarray, list, tuple)): - assert_allclose(value, class_value, rtol=1e-12, atol=1e-12) - elif isinstance(value, dict): - # do not check values of the internal dictionary since the nested structure - # is no longer guaranteed to be the same shape for every value - for internal_key in value.keys(): - assert internal_key in class_value - else: - assert value == class_value + check_param_keys(api_params.keys(), class_params.keys()) def test_method_availability(self): """Ensures all public algorithms are available through the Baseline class.""" @@ -263,3 +252,28 @@ def test_ensure_pickleable(self, input_x, input_z): pickle_and_check( fitter, 1, fitter._polynomial, fitter._spline_basis, x_validated, z_validated ) + + +def test_tck(data_fixture2d): + """Ensures all penalized spline methods return 'tck' in the output params.""" + methods = [] + for (method_name, method) in inspect.getmembers(api.Baseline2D): + if ( + inspect.isfunction(method) + and not method_name.startswith('_') + and ( + 'num_knots' in inspect.signature(method).parameters.keys() + or 'spline_degree' in inspect.signature(method).parameters.keys() + ) + ): + methods.append(method_name) + x, z, y = data_fixture2d + fitter = api.Baseline2D(x_data=x, z_data=z) + failures = [] + for method in methods: + _, params = getattr(fitter, method)(y) + if 'tck' not in params: + failures.append(method) + + if failures: + raise AssertionError(f'"tck" not in output params for {failures}') diff --git a/tests/two_d/test_spline.py b/tests/two_d/test_spline.py index 53ae3c9d..d6769b55 100644 --- a/tests/two_d/test_spline.py +++ b/tests/two_d/test_spline.py @@ -56,7 +56,7 @@ class SplineTester(BaseTester2D): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history') + checked_keys = ('weights', 'tol_history', 'tck') @classmethod def setup_class(cls): From 8c7fb1a668ff4a71a0f843621043f0e1fbb24dc6 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:05:53 -0400 Subject: [PATCH 02/38] MAINT: Document tck in the output params dict --- pybaselines/_spline_utils.py | 26 ++++++++++++-- pybaselines/morphological.py | 4 +++ pybaselines/spline.py | 56 ++++++++++++++++++++++++++++++ pybaselines/two_d/_spline_utils.py | 21 +++++++++-- pybaselines/two_d/spline.py | 40 +++++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 017765cf..2baaebed 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -610,7 +610,18 @@ def same_basis(self, num_knots=100, spline_degree=3): @property def tk(self): - """The knots and spline degree for the spline.""" + """ + The knots and spline degree for the spline. + + Returns + ------- + knots : numpy.ndarray, shape (K,) + The knots for the spline. Has a shape of `K`, which is equal to + ``num_knots + 2 * spline_degree``. + spline_degree : int + The degree of the spline. + + """ return self.knots, self.spline_degree @@ -708,9 +719,20 @@ def tck(self): The knots, spline coefficients, and spline degree to reconstruct the spline. Convenience function for easily reconstructing the last solved spline with outside - modules, such as with SciPy's `BSpline`, to allow for other usages such as evaulating + modules, such as with SciPy's `BSpline`, to allow for other usages such as evaluating with different x-values. + Returns + ------- + knots : numpy.ndarray, shape (K,) + The knots for the spline. Has a shape of `K`, which is equal to + ``num_knots + 2 * spline_degree``. + coef : numpy.ndarray, shape (M,) + The spline coeffieicnts. Has a shape of `M`, which is the number of basis functions + (equal to ``K - spline_degree - 1`` or equivalently ``num_knots + spline_degree - 1``). + spline_degree : int + The degree of the spline. + Raises ------ ValueError diff --git a/pybaselines/morphological.py b/pybaselines/morphological.py index 0ed75faf..ffe0d52b 100644 --- a/pybaselines/morphological.py +++ b/pybaselines/morphological.py @@ -725,6 +725,10 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, The weight array used for fitting the data. * 'half_window': int The half window used for the morphological calculations. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ diff --git a/pybaselines/spline.py b/pybaselines/spline.py index 85e0d1d3..251bb8df 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -90,6 +90,10 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -253,6 +257,10 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -412,6 +420,10 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -504,6 +516,10 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -615,6 +631,10 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. See Also -------- @@ -695,6 +715,10 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. See Also -------- @@ -776,6 +800,10 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -879,6 +907,10 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. See Also -------- @@ -979,6 +1011,10 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -1106,6 +1142,10 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -1219,6 +1259,10 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -1355,6 +1399,10 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp The weight array used for fitting the data. * 'half_window': int The half window used for the morphological calculations. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. Raises ------ @@ -1474,6 +1522,10 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde `max_iter_2`, `tol_2`), and shape K is the maximum of the number of iterations for the threshold and the maximum number of iterations for all of the fits of the various threshold values (related to `max_iter` and `tol`). + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. See Also -------- @@ -1586,6 +1638,10 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] + The knots, spline coefficients, and spline degree for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for + other usages such as evaluating with different x-values. See Also -------- diff --git a/pybaselines/two_d/_spline_utils.py b/pybaselines/two_d/_spline_utils.py index 3b3cc7e3..3bb52cbe 100644 --- a/pybaselines/two_d/_spline_utils.py +++ b/pybaselines/two_d/_spline_utils.py @@ -174,6 +174,13 @@ def tk(self): """ The knots and spline degree for the spline. + Returns + ------- + knots : tuple[numpy.ndarray, numpy.ndarray] + The knots for the spline along the rows and columns. + spline_degree : numpy.ndarray([int, int]) + The degree of the spline for the rows and columns. + Notes ----- To compare with :class:`scipy.interpolate.NdBSpline`, the setup would look like: @@ -331,9 +338,19 @@ def tck(self): The knots, spline coefficients, and spline degree to reconstruct the spline. Convenience function for easily reconstructing the last solved spline with outside - modules, such as with SciPy's `NdBSpline`, to allow for other usages such as evaulating + modules, such as with SciPy's `NdBSpline`, to allow for other usages such as evaluating with different x- and z-values. + Returns + ------- + knots : tuple[numpy.ndarray, numpy.ndarray] + The knots for the spline along the rows and columns. + coef : numpy.ndarray, shape (M, N) + The spline coeffieicnts. Has a shape of (`M`, `N`), correspondong to the number + of basis functions along the rows and columns. + spline_degree : numpy.ndarray([int, int]) + The degree of the spline for the rows and columns. + Raises ------ ValueError @@ -348,7 +365,7 @@ def tck(self): pspline = Pspline2D(x, z, ...) pspline_fit = pspline.solve(...) XZ = np.array(np.meshgrid(x, z)).T # same as zipping the meshgrid and rearranging - fit = NdBSpline(pspline.tck)(XZ) # fit == pspline_fit + fit = NdBSpline(*pspline.tck)(XZ) # fit == pspline_fit """ if self.coef is None: diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index 1b3cfb04..cba19474 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -82,6 +82,10 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. Raises ------ @@ -243,6 +247,10 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. Raises ------ @@ -327,6 +335,10 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. Raises ------ @@ -422,6 +434,10 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. Raises ------ @@ -528,6 +544,10 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. See Also -------- @@ -611,6 +631,10 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. See Also -------- @@ -691,6 +715,10 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. See Also -------- @@ -782,6 +810,10 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. Raises ------ @@ -883,6 +915,10 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order `max_iter_2`, `tol_2`), and shape K is the maximum of the number of iterations for the threshold and the maximum number of iterations for all of the fits of the various threshold values (related to `max_iter` and `tol`). + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. See Also -------- @@ -998,6 +1034,10 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] + The knots, spline coefficients, and spline degrees for the fit baseline. + Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for + other usages such as evaluating with different x-values and z-values. See Also -------- From 85e31ddfb1df4e8d10b5768d224a447ded3260f9 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 12 Oct 2025 19:52:11 -0400 Subject: [PATCH 03/38] ENH: Add initial implementation of optimize_pls --- docs/algorithms/algorithms_1d/optimizers.rst | 271 ++++++++++++++++++- docs/api/Baseline.rst | 1 + pybaselines/optimizers.py | 247 ++++++++++++++++- 3 files changed, 516 insertions(+), 3 deletions(-) diff --git a/docs/algorithms/algorithms_1d/optimizers.rst b/docs/algorithms/algorithms_1d/optimizers.rst index b6ac5194..efd26b4b 100644 --- a/docs/algorithms/algorithms_1d/optimizers.rst +++ b/docs/algorithms/algorithms_1d/optimizers.rst @@ -13,13 +13,13 @@ Algorithms optimize_extended_range ~~~~~~~~~~~~~~~~~~~~~~~ -The :meth:`~.Baseline.optimize_extended_range` function is based on the `Extended Range +The :meth:`~.Baseline.optimize_extended_range` method is based on the `Extended Range Penalized Least Squares (erPLS) method `_, but extends its usage to all Whittaker-smoothing-based, polynomial, and spline algorithms. In this algorithm, a linear baseline is extrapolated from the left and/or right edges, Gaussian peaks are added to these baselines, and then the original -data plus the extensions are input into the indicated Whittaker or polynomial function. +data plus the extensions are input into the indicated Whittaker or polynomial method. An example of data with added baseline and Gaussian peaks is shown below. .. _extending-data-explanation: @@ -313,3 +313,270 @@ reducing the number of data points in regions where higher stiffness is required. There is no figure showing the fits for various baseline types for this method since it is more suited for hard-to-fit data; however, :ref:`an example ` showcases its use. + + +optimize_pls (Optimize Penalized Least Squares) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :meth:`~.Baseline.optimize_pls` method is based on the `method developed by the authors +of the arPLS algorithm `_, +but extends its usage to all Whittaker-smoothing and penalized spline algorithms. + +The method works by considering the general equation of penalized least squares given by + +.. math:: + F + \lambda R + +where :math:`F` is the fidelity of the fit and :math:`R` is the roughness term penalized by +:math:`\lambda`. In general, both Whittaker smoothing and penalized splines have a fidelity +given by: + +.. math:: + + F = \sum\limits_{i}^N w_i (y_i - v_i)^2 + +where :math:`y_i` is the measured data, :math:`v_i` is the calculated baseline, +and :math:`w_i` is the weight. The roughness for Whittaker smoothing is generally: + +.. math:: + + R = \sum\limits_{i}^{N - d} (\Delta^d v_i)^2 + +where :math:`\Delta^d` is the finite-difference operator of order d, and for penalized +splines the roughness is generally: + +.. math:: + + + R = \sum\limits_{i}^{M - d} (\Delta^d c_i)^2 + +where :math:`c` are the calculated spline coefficients. + +In either case, a range of ``lam`` values are tested, with the fidelity and roughness +values calculated for each fit. Then the array of fidelity and roughness values are +normalized by their minimum and maximum values: + +.. math:: + + F_{norm} = \frac{F - \min(F)}{\max(F) - \min(F)} + +.. math:: + + R_{norm} = \frac{R - \min(R)}{\max(R) - \min(R)} + + +The ``lam`` value that produces a minimum of the sum of normalized roughness and fidelity +values is then selected as the optimal value. An example demonstrating this process is shown below. + +.. note:: + Fun fact: this method was the reason that all baseline correction methods in pybaselines + output a parameter dictionary! Since the conception of pybaselines, the author has tried to + implement this method and only realized after ~5 years that the original publication had a + typo that prevented being able to replicate the publication's results. + +.. note:: + Interestingly, a very similar framework using the normalized fidelity and roughness + values was proposed by `Andriyana, Y., et al. `_ + for optimizing the smoothing parameter for quantile P-splines, although they minimized + the euclidean distance (:math:`\sqrt{F_{norm}^2 + R_{norm}^2}`) as a pseudo L-curve optimization. + + +.. plot:: + :align: center + :context: close-figs + :include-source: False + :show-source-link: True + + from math import ceil + + x = np.linspace(1, 1000, 500) + signal = ( + gaussian(x, 6, 180, 5) + + gaussian(x, 6, 550, 5) + + gaussian(x, 9, 800, 10) + + gaussian(x, 9, 100, 12) + + gaussian(x, 15, 400, 8) + + gaussian(x, 13, 700, 12) + + gaussian(x, 9, 880, 8) + ) + baseline = 5 + 15 * np.exp(-x / 800) + gaussian(x, 5, 700, 300) + noise = np.random.default_rng(1).normal(0, 0.2, x.size) + y = signal * 0.5 + baseline + noise + + min_value = 2 + max_value = 10 + step = 0.25 + lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) + + fit, params = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) + normed_sum = params['roughness'] + params['fidelity'] + + plt.figure() + plt.plot(lam_range, params['roughness'], '--', label='Roughness, $R_{norm}$') + plt.plot(lam_range, params['fidelity'], '--', label='Fidelity, $F_{norm}$') + plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') + index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) + plt.plot(lam_range[index], normed_sum[index], 'o', label='Optimal Value') + plt.xlabel('Log$_{10}$(lam)') + plt.ylabel('Normalized Value') + plt.legend() + + plt.figure() + plt.plot(x, y) + plt.plot( + x, baseline_fitter.arpls(y, lam=10**min_value)[0], label=f'lam=10$^{{{min_value}}}$' + ) + plt.plot( + x, baseline_fitter.arpls(y, lam=10**max_value)[0], label=f'lam=10$^{{{max_value}}}$' + ) + plt.plot(x, fit, label=f'lam=10$^{{{np.log10(params['optimal_parameter']):.1f}}}$') + plt.legend() + + +In general, this method is more sensitive to the minimum and maximum ``lam`` values used for +the fits compared to :meth:`~.Baseline.optimize_extended_range`. Further discussion on the +inherent drawbacks of this method and the "fix" are continued below the example plot. + +.. plot:: + :align: center + :context: close-figs + :include-source: False + :show-source-link: True + + # to see contents of create_data function, look at the top-most algorithm's code + figure, axes, handles = create_plots(data, baselines) + for i, (ax, y) in enumerate(zip(axes, data)): + baseline, params = baseline_fitter.optimize_pls(y, method='arpls', min_value=2.5, max_value=5) + ax.plot(baseline, 'g--') + + +As hinted at above, this method is inherently limited by its assumptions of the curvature of the +fidelity and roughness as ``lam`` varies. Although the authors did not state in detail their thought +process for arriving at this optimization beyond balancing fidelity and roughness (at least the +translation of their paper did not, apologies to the authors if the details were simply lost in translation). +However, this approach is very similar to another optimization technique for finding the optimal regularization +parameter for regularized least squares called L-curve optimization +(see G. Frasso's thesis "Smoothing parameter selection using the L-curve" for more details on L-curve optimization). + +.. note:: + Note that other methods such as generalized cross validation (GCV) can be used to + calculate the optimal regularization parameter, such as recently added to SciPy + as :func:`scipy.interpolate.make_smoothing_spline`. + +For L-curve optimization, the optimal parameter is selected where the curvature of the roughness vs +fidelity plot (or log(roughness) vs log(fidelity)) is maximized. In the case that the minimum and maximum +values of lam are selected correctly, the L-curve will be setup where the maximum curvature of the L-curve +occurs at close to the origin on the normalized fidelity vs roughness curve and this +method reaches the correct optimal value. + +.. plot:: + :align: center + :context: close-figs + :include-source: False + :show-source-link: True + + x = np.linspace(1, 1000, 500) + signal = ( + gaussian(x, 6, 180, 5) + + gaussian(x, 6, 550, 5) + + gaussian(x, 9, 800, 10) + + gaussian(x, 9, 100, 12) + + gaussian(x, 15, 400, 8) + + gaussian(x, 13, 700, 12) + + gaussian(x, 9, 880, 8) + ) + baseline = 5 + 15 * np.exp(-x / 800) + gaussian(x, 5, 700, 300) + noise = np.random.default_rng(1).normal(0, 0.2, x.size) + y = signal * 0.5 + baseline + noise + + min_value = 2 + max_value = 10 + step = 0.25 + lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) + + fit, params = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) + normed_sum = params['roughness'] + params['fidelity'] + + plt.figure() + plt.plot(lam_range, params['roughness'], '--', label='Roughness, $R_{norm}$') + plt.plot(lam_range, params['fidelity'], '--', label='Fidelity, $F_{norm}$') + plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') + plt.plot(lam_range[index], normed_sum[index], 'o', label='optimal value') + plt.xlabel('Log$_{10}$(lam)') + plt.ylabel('Normalized Value') + plt.legend() + + plt.figure() + # plot a line connecting points for visibility; have to set zorder since matplotlib places + # lines above scatter points by default + plt.plot(params['fidelity'], params['roughness'], 'k', zorder=0) + scatter_plot = plt.scatter(params['fidelity'], params['roughness'], c=lam_range) + optimal_index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) + plt.annotate( + 'Selected Optimal lam', + xy=(params['fidelity'][optimal_index], params['roughness'][optimal_index]), + xytext=(20, 30), textcoords='offset points', arrowprops={'arrowstyle': '->'} + ) + + plt.xlabel('Normalized Fidelity') + plt.ylabel('Normalized Roughness') + colorbar = plt.colorbar(scatter_plot, orientation='horizontal') + + colorbar.set_label('log$_{10}$(lam)') + +However, if the minimum and maximum lam values are selected incorrectly, the L-curve does not +follow this simplified case and has additional points that drive the point of maximum curvature +away from the origin. In that case, this method will fail. + +.. plot:: + :align: center + :context: close-figs + :include-source: False + :show-source-link: True + + min_value = -3 + max_value = 10 + step = 0.25 + lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) + + fit2, params2 = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) + normed_sum = params2['roughness'] + params2['fidelity'] + + plt.figure() + plt.plot(lam_range, params2['roughness'], '--', label='Roughness, $R_{norm}$') + plt.plot(lam_range, params2['fidelity'], '--', label='Fidelity, $F_{norm}$') + plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') + new_index = np.argmin(abs(lam_range - np.log10(params2['optimal_parameter']))) + plt.plot(lam_range[new_index], normed_sum[new_index], 'o', label='selected optimal value') + plt.xlabel('Log$_{10}$(lam)') + plt.ylabel('Normalized Value') + plt.legend() + + plt.figure() + # plot a line connecting points for visibility; have to set zorder since matplotlib places + # lines above scatter points by default + plt.plot(params2['fidelity'], params2['roughness'], 'k', zorder=0) + scatter_plot = plt.scatter(params2['fidelity'], params2['roughness'], c=lam_range) + plt.annotate( + 'Selected Optimal lam', + xy=(params2['fidelity'][new_index], params2['roughness'][new_index]), + xytext=(10, 30), textcoords='offset points', arrowprops={'arrowstyle': '->'} + ) + # now refer back to the old optimal + optimal_index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) + plt.annotate( + 'Previous Optimal lam', + xy=(params2['fidelity'][optimal_index], params2['roughness'][optimal_index]), + xytext=(50, 15), textcoords='offset points', arrowprops={'arrowstyle': '->'} + ) + plt.xlabel('Normalized Fidelity') + plt.ylabel('Normalized Roughness') + colorbar = plt.colorbar(scatter_plot, orientation='horizontal') + + colorbar.set_label('log$_{10}$(lam)') + + plt.figure() + plt.plot(x, y) + plt.plot(x, fit, label=f"lam range=[2, 10], $lam_{{opt}}=10^{{{np.log10(params['optimal_parameter']):.1f}}}$") + plt.plot(x, fit2, label=f"lam range=[-3, 10], $lam_{{opt}}=10^{{{np.log10(params2['optimal_parameter']):.1f}}}$") + plt.legend() diff --git a/docs/api/Baseline.rst b/docs/api/Baseline.rst index 4738c3e3..f7783ef6 100644 --- a/docs/api/Baseline.rst +++ b/docs/api/Baseline.rst @@ -127,6 +127,7 @@ Optimizing Algorithms Baseline.optimize_extended_range Baseline.adaptive_minmax Baseline.custom_bc + Baseline.optimize_pls Miscellaneous Algorithms ------------------------ diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 38a59a6d..3efb09f8 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -10,15 +10,17 @@ """ from collections import defaultdict +import inspect import itertools from math import ceil +import warnings import numpy as np from . import classification, misc, morphological, polynomial, smooth, spline, whittaker from ._algorithm_setup import _Algorithm, _class_wrapper from ._validation import _check_optional_array -from .utils import _check_scalar, _get_edges, _sort_array, gaussian +from .utils import ParameterWarning, _check_scalar, _get_edges, _sort_array, gaussian class _Optimizers(_Algorithm): @@ -686,10 +688,253 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la return baseline, params + @_Algorithm._register(skip_sorting=True) + def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, max_value=7, + step=0.5, method_kwargs=None, euclidean=False): + """ + Optimizes the regularization parameter for penalized least squares methods. + + Parameters + ---------- + data : array-like, shape (N,) + The y-values of the measured data, with N data points. + method : str, optional + A string indicating the Whittaker-smoothing or spline method + to use for fitting the baseline. Default is 'arpls'. + opt_method : str, optional + The optimization method used to optimize `lam`. Supported methods are: + + * 'erPLS' + * 'U-curve' + * 'gcv' + + Details on each optimization method are in the Notes section below. + min_value : int or float, optional + The minimum value for the `lam` value to use with the indicated method. Should + be the exponent to raise to the power of 10 (eg. a `min_value` value of 2 + designates a `lam` value of 10**2). Default is 4. + max_value : int or float, optional + The maximum value for the `lam` value to use with the indicated method. Should + be the exponent to raise to the power of 10 (eg. a `max_value` value of 3 + designates a `lam` value of 10**3). Default is 7. + step : int or float, optional + The step size for iterating the parameter value from `min_value` to `max_value`. + Should be the exponent to raise to the power of 10 (eg. a `step` value of 1 + designates a `lam` value of 10**1). Default is 0.5. + method_kwargs : dict, optional + A dictionary of keyword arguments to pass to the selected `method` function. + Default is None, which will use an empty dictionary. + euclidean : bool, optional + Only used if `opt_method` is 'U-curve'. If False (default), the optimization metric + is the minimum of the sum of the normalized fidelity and roughness values, which is + equivalent to the minimum graph distance from the origin. If True, the metric is the + euclidean distance from the origin + + Returns + ------- + baseline : numpy.ndarray, shape (N,) + The baseline calculated with the optimum parameter. + method_params : dict + A dictionary with the following items: + + * 'optimal_parameter': float + The `lam` value that minimized the computed metric. + * 'metric': numpy.ndarray[float] + The computed metric for each `lam` value tested. + * 'method_params': dict + A dictionary containing the output parameters for the optimal fit. + Items will depend on the selected method. + * 'fidelity': numpy.ndarray[float] + Only returned if `opt_method` is 'U-curve'. The computed normalized fidelity + values for each `lam` value tested. + * 'roughness': numpy.ndarray[float] + Only returned if `opt_method` is 'U-curve'. The computed normalized roughness + values for each `lam` value tested. + + Raises + ------ + ValueError + _description_ + NotImplementedError + _description_ + + Notes + ----- + This method requires that the sum of the normalized roughness and fidelity values is + roughly 'U' shaped (see Figure 5 in [1]_), which depends on appropriate selection of + `min_value` and `max_value` such that roughness continually decreases and fidelity + continually increases as `lam` increases. + + References + ---------- + .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction + using Asymmetrically Reweighted Penalized Least Squares. Journal of the Institute + of Electronics and Information Engineers, 2016, 53(3), 124-131. + + """ + if opt_method is None: + # TODO once all methods are added, pick a good ordering, pick a default, and remove this + raise NotImplementedError('solver order needs determining') + y, baseline_func, _, method_kws, fitting_object = self._setup_optimizer( + data, method, (whittaker, morphological, spline, classification, misc), + method_kwargs, copy_kwargs=False + ) + method = method.lower() + if 'lam' in method_kws: + # TODO maybe just warn and pop out instead? Would need to copy input kwargs in that + # case so that the original input is not modified + raise ValueError('lam must not be specified within method_kwargs') + + if any(val > 15 for val in (min_value, max_value, step)): + raise ValueError(( + 'min_value, max_value, and step should be the power of 10 to use ' + '(eg. min_value=2 denotes 10**2), not the actual "lam" value, and ' + 'thus should not be greater than 15' + )) + + if step == 0 or min_value == max_value: + do_optimization = False + else: + do_optimization = True + if max_value < min_value and step > 0: + step = -step + if do_optimization: + lam_range = np.logspace( + min_value, max_value, ceil((max_value - min_value) / step), base=10.0 + ) + # double check that variables has at least two items; otherwise skip the optimization + if lam_range.size < 2: + do_optimization = False + if not do_optimization: + lam_range = np.array([10.0**min_value]) + warnings.warn( + ('min_value, max_value, and step were set such that only a single "lam" value ' + 'was fit'), ParameterWarning, stacklevel=2 + ) + + selected_method = opt_method.lower().replace('-', '_') + if selected_method == 'u_curve': + params = _optimize_ucurve( + y, selected_method, method, method_kws, baseline_func, fitting_object, lam_range, + euclidean + ) + else: + raise ValueError(f'{opt_method} is not a supported opt_method input') + + baseline, final_params = baseline_func(y, lam=params['optimal_parameter'], **method_kws) + params['method_params'] = final_params + + return baseline, params + _optimizers_wrapper = _class_wrapper(_Optimizers) +def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_obj: _Algorithm, + lam_range, euclidean=False): + """ + Performs U-curve optimization based on the fit fidelity and roughness. + + Parameters + ---------- + y : _type_ + _description_ + opt_method : _type_ + _description_ + method : _type_ + _description_ + method_kws : _type_ + _description_ + baseline_func : _type_ + _description_ + baseline_obj : _Algorithm + _description_ + lam_range : _type_ + _description_ + euclidean : bool, optional + _description_. Default is False. + + Returns + ------- + _type_ + _description_ + + Raises + ------ + NotImplementedError + _description_ + + + References + ---------- + .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction using + Asymmetrically Reweighted Penalized Least Squares. Journal of the Institute of + Electronics and Information Engineers, 2016, 53(3), 124-131. + .. [2] Andriyana, Y., et al. P-splines quantile regression estimation in varying coefficient + models. TEST, 2014, 23(1), 153-194. + + """ + if 'pspline' in method or method in ('mixture_model', 'irsqr'): + spline_fit = True + else: + spline_fit = False + + using_aspls = 'aspls' in method + using_drpls = 'drpls' in method + using_iasls = 'iasls' in method + if any((using_aspls, using_drpls, using_iasls)): + raise NotImplementedError(f'{method} method is not currently supported') + + method_signature = inspect.signature(baseline_func).parameters + if 'diff_order' in method_kws: + diff_order = method_kws['diff_order'] + else: + # some methods have a different default diff_order, so have to inspect them + diff_order = method_signature['diff_order'].default + + roughness = [] + fidelity = [] + for lam in lam_range: + fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) + if spline_fit: + penalized_object = fit_params['tck'][1] + else: + penalized_object = fit_baseline + # Park, et al. multiplied the roughness by lam (Equation 8), but I think that may have + # been a typo since it otherwise favors low lam values and does not produce a + # roughness plot shown in Figure 4 in the Park, et al. reference + partial_roughness = np.diff(penalized_object, diff_order) + fit_roughness = partial_roughness.dot(partial_roughness) + + residual = y - fit_baseline + if 'weights' in fit_params: + fit_fidelity = fit_params['weights'] @ residual**2 + else: + fit_fidelity = residual @ residual + + roughness.append(fit_roughness) + fidelity.append(fit_fidelity) + + roughness = np.array(roughness) + fidelity = np.array(fidelity) + + if lam_range.size > 1: + roughness = (roughness - roughness.min()) / (roughness.max() - roughness.min()) + fidelity = (fidelity - fidelity.min()) / (fidelity.max() - fidelity.min()) + if euclidean: + metric = np.sqrt(fidelity**2 + roughness**2) + else: # graph distance from the origin, ie. only travelling along x and y axes + metric = fidelity + roughness + + best_lam = lam_range[np.argmin(metric)] + params = { + 'optimal_parameter': best_lam, 'metric': metric, + 'fidelity': fidelity, 'roughness': roughness, + } + + return params + + @_optimizers_wrapper def collab_pls(data, average_dataset=True, method='asls', method_kwargs=None, x_data=None): """ From cd691694bf9db6423713776757caf5ddb9a5eea0 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 18 Oct 2025 13:19:20 -0400 Subject: [PATCH 04/38] MAINT: Add banded to sparse conversion and lam updater --- pybaselines/_banded_utils.py | 59 +++++++++++++++++++++++- pybaselines/morphological.py | 2 +- tests/test_banded_utils.py | 89 +++++++++++++++++++++++++++++++++++- tests/test_spline_utils.py | 48 +++++++++++++++++++ 4 files changed, 195 insertions(+), 3 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index d119b82d..dd07ec7d 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -224,7 +224,7 @@ def _sparse_to_banded(matrix): ): # offsets are typically negative to positive, but can get flipped during conversion # between sparse formats; LAPACK's banded format matches SciPy's diagonal data format - # when offsets are from positve to negative + # when offsets are from positive to negative if upper == diag_matrix.offsets[0]: ab = diag_matrix.data else: @@ -236,6 +236,47 @@ def _sparse_to_banded(matrix): return ab, (abs(lower), upper) +def _banded_to_sparse(ab, lower=True, sparse_format='csc'): + """ + Converts a banded matrix into its square sparse version. + + Parameters + ---------- + ab : numpy.ndarray, shape (M, N) + The banded matrix in LAPACK format, either with its full band structure (ie. + ``M == 2 * num_bands + 1``) or only the lower bands (``M == num_bands + 1``). + lower : bool, optional + False indicates that `ab` represents the full band structure, + while True (default) indicates `ab` is in lower format. + sparse_format : str, optional + The format for the output sparse array or matrix. Default is 'csc'. + + Returns + ------- + matrix : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (N, N) + The sparse, banded matrix. + + Notes + ----- + The input `ab` banded matrix is assumed to have equal numbers of upper + and lower diagonals. + + """ + rows, columns = ab.shape + if lower: + ab_full = _lower_to_full(ab) + bands = rows - 1 + else: + ab_full = ab + bands = rows // 2 + + matrix = dia_object( + (ab_full, np.arange(bands, -bands - 1, -1)), shape=(columns, columns), + ).asformat(sparse_format) + + return matrix + + def difference_matrix(data_size, diff_order=2, diff_format=None): """ Creates an n-th order finite-difference matrix. @@ -862,3 +903,19 @@ def reverse_penalty(self): self.penalty = self.penalty[::-1] self.original_diagonals = self.original_diagonals[::-1] self.reversed = not self.reversed + + def update_lam(self, lam): + """ + Updates the penalty with a new regularization parameter. + + Parameters + ---------- + lam : float + The new regularization parameter to apply to the penalty. + + """ + new_lam = _check_lam(lam, allow_zero=False) + self.penalty *= (new_lam / self.lam) + self.main_diagonal = self.penalty[self.main_diagonal_index].copy() + self.lam = new_lam + diff --git a/pybaselines/morphological.py b/pybaselines/morphological.py index ffe0d52b..4a5d6b86 100644 --- a/pybaselines/morphological.py +++ b/pybaselines/morphological.py @@ -791,7 +791,7 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, # TODO should this use np.isclose instead? weight_array = np.where(spline_fit == optimal_opening, 1 - p, p) - pspline.penalty = (_check_lam(lam) / lam_smooth) * pspline.penalty + pspline.update_lam(lam) baseline = pspline.solve_pspline(spline_fit, weight_array) return baseline, {'half_window': half_window, 'weights': weight_array, 'tck': pspline.tck} diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index da3fed6f..942ab896 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -898,6 +898,45 @@ def test_penalized_system_add_diagonal_after_penalty(data_size, diff_order, allo ) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('allow_lower', (True, False)) +def test_penalized_system_update_lam(diff_order, allow_lower): + """Tests updating the lam value for PenalizedSystem.""" + data_size = 100 + lam_init = 5 + penalized_system = _banded_utils.PenalizedSystem( + data_size, lam=lam_init, diff_order=diff_order, allow_lower=allow_lower + ) + expected_penalty = lam_init * _banded_utils.diff_penalty_diagonals( + data_size, diff_order=diff_order, lower_only=penalized_system.lower + ) + diag_index = penalized_system.main_diagonal_index + + assert_allclose(penalized_system.penalty, expected_penalty, rtol=1e-14, atol=1e-14) + assert_allclose( + penalized_system.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 + ) + for lam in (1e3, 5.2e1): + expected_penalty = lam * _banded_utils.diff_penalty_diagonals( + data_size, diff_order=diff_order, lower_only=penalized_system.lower + ) + penalized_system.update_lam(lam) + + assert_allclose(penalized_system.penalty, expected_penalty, rtol=1e-14, atol=1e-14) + assert_allclose( + penalized_system.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 + ) + + +def test_penalized_system_update_lam_invalid_lam(): + """Ensures PenalizedSystem.update_lam throws an exception when given a non-positive lam.""" + penalized_system = _banded_utils.PenalizedSystem(100) + with pytest.raises(ValueError): + penalized_system.update_lam(-1.) + with pytest.raises(ValueError): + penalized_system.update_lam(0) + + @pytest.mark.parametrize('dtype', (float, np.float32)) def test_sparse_to_banded(dtype): """Tests basic functionality of _sparse_to_banded.""" @@ -1053,7 +1092,7 @@ def test_sparse_to_banded_truncation(): # sanity check assert_array_equal(matrix.toarray(), data) - # ensure that the first column isn't truncted by SciPy's sparse conversion + # ensure that the first column isn't truncated by SciPy's sparse conversion assert_array_equal(matrix.data[::-1], expected_data) assert_array_equal(banded_data, out) @@ -1223,3 +1262,51 @@ def test_sparse_to_banded_ragged_truncated(): assert_array_equal(banded_data, out2) assert lower2 == 3 assert upper2 == 1 + + +def test_banded_to_sparse_simple(): + """Basic test of functionality for _banded_to_sparse.""" + full_matrix = np.array([ + [1, 2, 3, 0, 0], + [2, 2, 3, 4, 0], + [3, 3, 3, 4, 5], + [0, 4, 4, 4, 5], + [0, 0, 5, 5, 5] + ]) + banded_matrix = np.array([ + [0, 0, 3, 4, 5], + [0, 2, 3, 4, 5], + [1, 2, 3, 4, 5], + [2, 3, 4, 5, 0], + [3, 4, 5, 0, 0] + ]) + + output_full = _banded_utils._banded_to_sparse(banded_matrix, lower=False) + assert_allclose(output_full.toarray(), full_matrix, rtol=1e-14, atol=1e-14) + + output_lower = _banded_utils._banded_to_sparse(banded_matrix[2:], lower=True) + assert_allclose(output_lower.toarray(), full_matrix, rtol=1e-14, atol=1e-14) + + +@pytest.mark.parametrize('lower', (True, False)) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('size', (100, 1001)) +def test_banded_to_sparse(lower, diff_order, size): + """Ensures proper functionality of _banded_to_sparse.""" + expected_matrix = _banded_utils.diff_penalty_matrix(size, diff_order=diff_order) + banded_matrix = _banded_utils.diff_penalty_diagonals( + size, diff_order=diff_order, lower_only=lower + ) + + output = _banded_utils._banded_to_sparse(banded_matrix, lower=lower) + assert_allclose(output.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) + + +@pytest.mark.parametrize('form', ('dia', 'csc', 'csr')) +@pytest.mark.parametrize('lower', (True, False)) +def test_banded_to_sparse_formats(form, lower): + """Ensures that the sparse format is correctly passed to the constructor.""" + banded_matrix = _banded_utils.diff_penalty_diagonals(200, diff_order=2, lower_only=lower) + + output = _banded_utils._banded_to_sparse(banded_matrix, lower=lower, sparse_format=form) + assert output.format == form diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 06a6c410..19985454 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -455,6 +455,54 @@ def test_pspline_tck_readonly(data_fixture): pspline.tck = (1, 2, 3) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('num_knots', (50, 101)) +@pytest.mark.parametrize('spline_degree', (1, 2, 3)) +def test_pspline_update_lam(data_fixture, diff_order, allow_lower, num_knots, spline_degree): + """Tests updating the lam value for PSpline.""" + x, y = data_fixture + lam_init = 5 + basis = _spline_utils.SplineBasis(x, num_knots=num_knots, spline_degree=spline_degree) + pspline = _spline_utils.PSpline( + basis, diff_order=diff_order, lam=lam_init, allow_lower=allow_lower + ) + data_size = pspline._num_bases + + expected_penalty = lam_init * _banded_utils.diff_penalty_diagonals( + data_size, diff_order=diff_order, lower_only=pspline.lower, + padding=spline_degree - diff_order + ) + diag_index = pspline.main_diagonal_index + + assert_allclose(pspline.penalty, expected_penalty, rtol=1e-14, atol=1e-14) + assert_allclose( + pspline.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 + ) + for lam in (1e3, 5.2e1): + expected_penalty = lam * _banded_utils.diff_penalty_diagonals( + data_size, diff_order=diff_order, lower_only=pspline.lower, + padding=spline_degree - diff_order + ) + pspline.update_lam(lam) + + assert_allclose(pspline.penalty, expected_penalty, rtol=1e-14, atol=1e-14) + assert_allclose( + pspline.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 + ) + + +def test_pspline_update_lam_invalid_lam(data_fixture): + """Ensures PSpline.update_lam throws an exception when given a non-positive lam.""" + x, y = data_fixture + basis = _spline_utils.SplineBasis(x) + pspline = _spline_utils.PSpline(basis) + with pytest.raises(ValueError): + pspline.update_lam(-1.) + with pytest.raises(ValueError): + pspline.update_lam(0) + + def test_spline_basis_tk_readonly(data_fixture): """Ensures the tk attribute is read-only.""" x, y = data_fixture From cc7cd368c670a2429dd330054ac64e5d8a135715 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 19 Oct 2025 13:27:30 -0400 Subject: [PATCH 05/38] BREAK: Change how optimize_extended_range selects variables Directly use arange with the input min, max, and step values to simplify behavior for both polynomials and lam-using methods. Max values are no longer included, so breaking behavior. --- pybaselines/optimizers.py | 188 ++++++++++++++++++++++++-------------- tests/test_optimizers.py | 152 +++++++++++++++++++++++++++--- 2 files changed, 258 insertions(+), 82 deletions(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 3efb09f8..d8a366ca 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -143,7 +143,7 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No @_Algorithm._register(skip_sorting=True) def optimize_extended_range(self, data, method='asls', side='both', width_scale=0.1, - height_scale=1., sigma_scale=1 / 12, min_value=2, max_value=8, + height_scale=1., sigma_scale=1 / 12, min_value=2, max_value=9, step=1, pad_kwargs=None, method_kwargs=None): """ Extends data and finds the best parameter value for the given baseline method. @@ -179,11 +179,11 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= be the exponent to raise to the power of 10 (eg. a `min_value` value of 2 designates a `lam` value of 10**2). Default is 2. max_value : int or float, optional - The maximum value for the `lam` or `poly_order` value to use with the + The maximum value for the `lam` or `poly_order` value to potentially use with the indicated method. If using a polynomial method, `max_value` must be an integer. If using a Whittaker-smoothing-based method, `max_value` should be the exponent to raise to the power of 10 (eg. a `max_value` value of 3 - designates a `lam` value of 10**3). Default is 8. + designates a `lam` value of 10**3). Default is 9. step : int or float, optional The step size for iterating the parameter value from `min_value` to `max_value`. If using a polynomial method, `step` must be an integer. If using a @@ -215,9 +215,12 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= dictionary in pybaselines version 1.4.0 in favor of the new 'rmse' key which returns all root-mean-squared-error values. - * 'rmse' : numpy.ndarray + * 'rmse': numpy.ndarray, shape (P,) The array of the calculated root-mean-squared-error for each of the fits. + + .. versionadded:: 1.2.0 + * 'method_params': dict A dictionary containing the output parameters for the optimal fit. Items will depend on the selected method. @@ -225,13 +228,16 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= Raises ------ ValueError - Raised if `side` is not 'left', 'right', or 'both'. + Raised if `side` is not 'left', 'right', or 'both'. Also raised if using a + non-polynomial method and `min_value`, `max_value`, or `step` is + greater than 15. TypeError Raised if using a polynomial method and `min_value`, `max_value`, or `step` is not an integer. - ValueError - Raised if using a Whittaker-smoothing-based method and `min_value`, - `max_value`, or `step` is greater than 100. + + See Also + -------- + Baseline.optimize_pls Notes ----- @@ -249,6 +255,10 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= value for fitting non-padded data will be slightly lower than the optimal value obtained from :meth:`~.Baseline.optimize_extended_range`. + The range of values to test is generated using + ``numpy.arange(min_value, max_value, step)``, so `max_value` is likely not included in + the range of tested values. + References ---------- .. [1] Zhang, F., et al. An Automatic Baseline Correction Method Based on @@ -268,26 +278,12 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= ) method = method.lower() if func_module == 'polynomial' or method in ('dietrich', 'cwt_br'): - if any(not isinstance(val, int) for val in (min_value, max_value, step)): - raise TypeError(( - 'min_value, max_value, and step must all be integers when' - ' using a polynomial method' - )) param_name = 'poly_order' else: - if any(val > 15 for val in (min_value, max_value, step)): - raise ValueError(( - 'min_value, max_value, and step should be the power of 10 to use ' - '(eg. min_value=2 denotes 10**2), not the actual "lam" value, and ' - 'thus should not be greater than 15' - )) param_name = 'lam' - if step == 0 or min_value == max_value: - do_optimization = False - else: - do_optimization = True - if max_value < min_value and step > 0: - step = -step + variables = _param_grid( + min_value, max_value, step, polynomial_fit=param_name == 'poly_order' + ) added_window = int(self._size * width_scale) for key in ('weights', 'alpha'): @@ -354,22 +350,7 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= new_fitter = fit_object._override_x(fit_x_data, new_sort_order=new_sort_order) baseline_func = getattr(new_fitter, method) - if do_optimization: - if param_name == 'poly_order': - variables = np.arange(min_value, max_value + step, step) - else: - # use linspace for floats since it ensures endpoints are included; use - # logspace to skip having to do 10.0**linspace(...) - variables = np.logspace( - min_value, max_value, ceil((max_value - min_value) / step), base=10.0 - ) - # double check that variables has at least one item; otherwise skip the optimization - if variables.size == 0: - do_optimization = False - if not do_optimization: - variables = np.array([min_value]) - if param_name == 'lam': - variables = 10.0**variables + upper_idx = len(fit_data) - upper_bound min_sum_squares = np.inf best_idx = 0 @@ -758,6 +739,10 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, NotImplementedError _description_ + See Also + -------- + Baseline.optimize_extended_range + Notes ----- This method requires that the sum of the normalized roughness and fidelity values is @@ -765,6 +750,17 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, `min_value` and `max_value` such that roughness continually decreases and fidelity continually increases as `lam` increases. + Uses a grid search for optimization since the objective functions for all supported + `opt_method` inputs are highly non-smooth (ie. many local minima) when performing + baseline correction, due to the reliance of calculated weights on the input `lam`. + Scalar minimization using :func:`scipy.optimize.minimize_scalar` was found to + perform okay in most cases, but it would also not allow some methods like 'U-Curve' + which requires normalization for computing the objective. + + The range of values to test is generated using + ``numpy.arange(min_value, max_value, step)``, so `max_value` is likely not included in + the range of tested values. + References ---------- .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction @@ -785,33 +781,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, # case so that the original input is not modified raise ValueError('lam must not be specified within method_kwargs') - if any(val > 15 for val in (min_value, max_value, step)): - raise ValueError(( - 'min_value, max_value, and step should be the power of 10 to use ' - '(eg. min_value=2 denotes 10**2), not the actual "lam" value, and ' - 'thus should not be greater than 15' - )) - - if step == 0 or min_value == max_value: - do_optimization = False - else: - do_optimization = True - if max_value < min_value and step > 0: - step = -step - if do_optimization: - lam_range = np.logspace( - min_value, max_value, ceil((max_value - min_value) / step), base=10.0 - ) - # double check that variables has at least two items; otherwise skip the optimization - if lam_range.size < 2: - do_optimization = False - if not do_optimization: - lam_range = np.array([10.0**min_value]) - warnings.warn( - ('min_value, max_value, and step were set such that only a single "lam" value ' - 'was fit'), ParameterWarning, stacklevel=2 - ) - + lam_range = _param_grid(min_value, max_value, step, polynomial_fit=False) selected_method = opt_method.lower().replace('-', '_') if selected_method == 'u_curve': params = _optimize_ucurve( @@ -830,6 +800,86 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, _optimizers_wrapper = _class_wrapper(_Optimizers) +def _param_grid(min_value, max_value, step, polynomial_fit=False): + """ + Creates a range of parameters to use for grid optimization. + + Parameters + ---------- + min_value : int or float + The minimum parameter value. If `polynomial_fit` is True, `min_value` must be an + integer. Otherwise, `min_value` should be the exponent to raise to the power of + 10 (eg. a `min_value` value of 2 designates a `lam` value of 10**2). + max_value : int or float, optional + The maximum parameter value for the range. If `polynomial_fit` is True, `max_value` + must be an integer. Otherwise, `min_value` should be the exponent to raise to the + power of 10 (eg. a `max_value` value of 5 designates a `lam` value of 10**5). + step : int or float + If `polynomial_fit` is True, `step` must be an integer. Otherwise, `step` should + be the exponent to raise to the power of 10 (eg. a `step` value of 1 + designates a `lam` value of 10**1). + polynomial_fit : bool, optional + Whether the parameters define polynomial degrees. Default is False. + + Returns + ------- + values : numpy.ndarray + The range of parameters. + + Raises + ------ + TypeError + Raised if using a polynomial method and `min_value`, `max_value`, or + `step` is not an integer. + ValueError + Raised if using a Whittaker-smoothing-based method and `min_value`, + `max_value`, or `step` is greater than 15. + + Notes + ----- + The complete range of values for the grid is generated using + ``numpy.arange(min_value, max_value, step)``, so `max_value` is likely not included. + + """ + if polynomial_fit: + if any(not isinstance(val, int) for val in (min_value, max_value, step)): + raise TypeError(( + 'min_value, max_value, and step must all be integers when' + ' using a polynomial method' + )) + else: + if any(val > 15 for val in (min_value, max_value, step)): + raise ValueError(( + 'min_value, max_value, and step should be the power of 10 to use ' + '(eg. min_value=2 denotes 10**2), not the actual "lam" value, and ' + 'thus should not be greater than 15' + )) + + if step == 0 or min_value == max_value: + do_optimization = False + else: + do_optimization = True + if polynomial_fit: + values = np.arange(min_value, max_value, step) + else: + # explicitly set float dtype so that input dtypes are uninportant for arange step size + values = 10.0**np.arange(min_value, max_value, step, dtype=float) + # double check that values has at least two items; otherwise skip the optimization + if values.size < 2: + do_optimization = False + + if not do_optimization: + warnings.warn( + ('min_value, max_value, and step were set such that only a single value ' + 'was fit'), ParameterWarning, stacklevel=2 + ) + values = np.array([min_value]) + if not polynomial_fit: + values = 10.0**values + + return values + + def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_obj: _Algorithm, lam_range, euclidean=False): """ @@ -995,7 +1045,7 @@ def collab_pls(data, average_dataset=True, method='asls', method_kwargs=None, x_ @_optimizers_wrapper def optimize_extended_range(data, x_data=None, method='asls', side='both', width_scale=0.1, - height_scale=1., sigma_scale=1. / 12., min_value=2, max_value=8, + height_scale=1., sigma_scale=1. / 12., min_value=2, max_value=9, step=1, pad_kwargs=None, method_kwargs=None): """ Extends data and finds the best parameter value for the given baseline method. diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index fadeb941..fb4154c1 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -180,7 +180,7 @@ def test_all_methods(self, method): """Tests all methods that should work with optimize_extended_range.""" if method == 'loess': # reduce number of calculations for loess since it is much slower - kwargs = {'min_value': 1, 'max_value': 2} + kwargs = {'min_value': 1, 'max_value': 3} else: kwargs = {} # use height_scale=0.1 to avoid exponential overflow warning for arpls and aspls @@ -214,7 +214,7 @@ def test_whittaker_high_value_fails(self, key): Ensures function fails when using a Whittaker method and input lambda exponent is too high. Since the function uses 10**exponent, do not want to allow a high exponent to be used, - since the user probably thought the actual lam value had to be specficied rather than + since the user probably thought the actual lam value had to be specifiied rather than just the exponent. """ @@ -223,7 +223,7 @@ def test_whittaker_high_value_fails(self, key): @pytest.mark.parametrize('side', ('left', 'right', 'both')) def test_aspls_alpha_ordering(self, side): - """Ensures the `alpha` array for the aspls method is currectly processed.""" + """Ensures the `alpha` array for the aspls method is correctly processed.""" alpha = np.random.default_rng(0).normal(0.8, 0.05, len(self.x)) alpha = np.clip(alpha, 0, 1).astype(float, copy=False) @@ -280,9 +280,10 @@ def test_min_max_ordering(self, method): fit_1, params_1 = self.class_func( self.y, method=method, min_value=min_value, max_value=max_value ) - # should simply do the fittings in the reversed order + # should simply do the fittings in the reversed order; subtract 1 from + # min and max values since np.arange(min, max) == np.arange(min - 1, max - 1, -1)[::-1] fit_2, params_2 = self.class_func( - self.y, method=method, min_value=max_value, max_value=min_value + self.y, method=method, min_value=max_value - 1, max_value=min_value - 1, step=-1 ) # fits and optimal parameter should be the same @@ -298,22 +299,147 @@ def test_no_step(self, method): """Ensures a fit is still done if step is zero or min and max values are equal.""" min_value = 2 # case 1: step == 0 - fit_1, params_1 = self.class_func( - self.y, method=method, min_value=min_value, max_value=min_value + 5, step=0 - ) + with pytest.warns(utils.ParameterWarning): + fit_1, params_1 = self.class_func( + self.y, method=method, min_value=min_value, max_value=min_value + 5, step=0 + ) # case 2: min and max value are equal - fit_2, params_2 = self.class_func( - self.y, method=method, min_value=min_value, max_value=min_value - ) + with pytest.warns(utils.ParameterWarning): + fit_2, params_2 = self.class_func( + self.y, method=method, min_value=min_value, max_value=min_value + ) + # case 3: step is too large + with pytest.warns(utils.ParameterWarning): + fit_3, params_3 = self.class_func( + self.y, method=method, min_value=min_value, max_value=min_value + 1, step=5 + ) # fits, optimal parameter, and rmse should all be the same assert_allclose(fit_2, fit_1, rtol=1e-12, atol=1e-12) + assert_allclose(fit_3, fit_1, rtol=1e-12, atol=1e-12) assert_allclose( - params_1['optimal_parameter'], params_2['optimal_parameter'], rtol=1e-12, atol=1e-12 + params_2['optimal_parameter'], params_1['optimal_parameter'], rtol=1e-12, atol=1e-12 ) - assert_allclose(params_1['rmse'], params_2['rmse'], rtol=1e-8, atol=1e-12) + assert_allclose( + params_3['optimal_parameter'], params_1['optimal_parameter'], rtol=1e-12, atol=1e-12 + ) + assert_allclose(params_2['rmse'], params_1['rmse'], rtol=1e-8, atol=1e-12) + assert_allclose(params_3['rmse'], params_1['rmse'], rtol=1e-8, atol=1e-12) assert len(params_1['rmse']) == 1 assert len(params_2['rmse']) == 1 + assert len(params_3['rmse']) == 1 + + def test_value_range(self): + """Ensures the correct number of parameters to fit are generated.""" + min_value = 2 + max_value = 6 + for step in (1, 2, 3): + expected_tested_values = np.arange(min_value, max_value, step) + _, params_1 = self.class_func( + self.y, method='modpoly', min_value=min_value, max_value=max_value, step=step + ) + _, params_2 = self.class_func( + self.y, method='asls', min_value=min_value, max_value=max_value, step=step + ) + # both methods should have the same number of tested values + assert len(params_1['rmse']) == len(expected_tested_values) + assert len(params_2['rmse']) == len(expected_tested_values) + + # also test float inputs for lam-based methods + min_value = 1. + max_value = 5.5 + step = 0.5 + expected_tested_values = [1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5] + _, params_3 = self.class_func( + self.y, method='asls', min_value=min_value, max_value=max_value, step=step + ) + # both methods should have the same number of tested values + assert len(params_3['rmse']) == len(expected_tested_values) + + +def test_param_grid(): + """Ensures basic functionality of _param_grid.""" + min_value = 1 + max_value = 5 + step = 1 + + expected_values = np.arange(min_value, max_value, step) + output = optimizers._param_grid(min_value, max_value, step, polynomial_fit=True) + + assert_array_equal(output, expected_values) + + output2 = optimizers._param_grid(min_value, max_value, step, polynomial_fit=False) + assert_allclose(output2, 10**expected_values, rtol=1e-15, atol=1e-15) + + # also ensure floats are properly handled + step = 0.5 + expected_values = 10**np.array([1, 1.5, 2, 2.5, 3, 3.5, 4, 4.5]) + output3 = optimizers._param_grid(min_value, max_value, step, polynomial_fit=False) + assert_allclose(output3, expected_values, rtol=1e-15, atol=1e-15) + + +@pytest.mark.parametrize('polynomial_fit', (True, False)) +def test_param_grid_no_step(polynomial_fit): + """Ensures a single param is still output if step is zero or min and max values are equal.""" + min_value = 2 + expected_value = np.array([min_value]) + if not polynomial_fit: + expected_value = 10.0**expected_value + + # case 1: step == 0 + with pytest.warns(utils.ParameterWarning): + output1 = optimizers._param_grid( + min_value=min_value, max_value=min_value + 5, step=0, polynomial_fit=polynomial_fit + ) + # case 2: min and max value are equal + with pytest.warns(utils.ParameterWarning): + output2 = optimizers._param_grid( + min_value=min_value, max_value=min_value, step=1, polynomial_fit=polynomial_fit + ) + # case 3: step is too large + with pytest.warns(utils.ParameterWarning): + output3 = optimizers._param_grid( + min_value=min_value, max_value=min_value + 1, step=5, polynomial_fit=polynomial_fit + ) + + assert_allclose(output1, expected_value, rtol=1e-15, atol=1e-15) + assert_allclose(output2, expected_value, rtol=1e-15, atol=1e-15) + assert_allclose(output3, expected_value, rtol=1e-15, atol=1e-15) + + +@pytest.mark.parametrize('key', ('min_value', 'max_value', 'step')) +def test_param_grid_float_poly_fails(key): + """Ensures non-integer values raise an error for polynomial fits.""" + if key == 'min_value': + kwargs = {'max_value': 5, 'step': 1} + elif key == 'max_value': + kwargs = {'min_value': 1, 'step': 1} + else: + kwargs = {'min_value': 1, 'max_value': 5} + kwargs[key] = 2.5 + with pytest.raises(TypeError): + optimizers._param_grid(**kwargs, polynomial_fit=True) + + +@pytest.mark.parametrize('key', ('min_value', 'max_value', 'step')) +def test_param_grid_nonpoly_fails(key): + """ + Ensures function fails when using a Whittaker method and input lambda exponent is too high. + + Since the function uses 10**exponent, do not want to allow a high exponent to be used, + since the user probably thought the actual lam value had to be specifiied rather than + just the exponent. + + """ + if key == 'min_value': + kwargs = {'max_value': 5, 'step': 1} + elif key == 'max_value': + kwargs = {'min_value': 1, 'step': 1} + else: + kwargs = {'min_value': 1, 'max_value': 5} + kwargs[key] = 16 + with pytest.raises(ValueError): + optimizers._param_grid(**kwargs, polynomial_fit=False) @pytest.mark.parametrize( From 85032406fc94b1e7590488cec8639a8ead315d94 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 19 Oct 2025 16:53:50 -0400 Subject: [PATCH 06/38] MAINT: Add factorize and factorized_solve to PenalizedSystem --- pybaselines/_banded_utils.py | 73 +++++++++++++++++++++++++++- tests/test_banded_utils.py | 93 +++++++++++++++++++++++++++++++++--- tests/test_spline_utils.py | 89 +++++++++++++++++++++++----------- 3 files changed, 219 insertions(+), 36 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index dd07ec7d..ccae6c08 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -7,9 +7,11 @@ """ import numpy as np -from scipy.linalg import solve_banded, solveh_banded +from scipy.linalg import cholesky_banded, cho_solve_banded, solve_banded, solveh_banded -from ._banded_solvers import solve_banded_penta +from scipy.sparse.linalg import factorized + +from ._banded_solvers import penta_factorize, penta_factorize_solve, solve_banded_penta from ._compat import _HAS_NUMBA, dia_object, diags, identity from ._validation import _check_lam @@ -919,3 +921,70 @@ def update_lam(self, lam): self.main_diagonal = self.penalty[self.main_diagonal_index].copy() self.lam = new_lam + def factorize(self, lhs, overwrite_ab=False, check_finite=False): + """ + Calculates the factorization of ``A`` for the linear equation ``A x = b``. + + Parameters + ---------- + lhs : array-like, shape (M, N) + The left-hand side of the equation, in banded format. `lhs` is assumed to be + some slight modification of `self.penalty` in the same format (reversed, lower, + number of bands, etc. are all the same). + overwrite_ab : bool, optional + Whether to overwrite `lhs` during factorization. Default is False. + check_finite : bool, optional + Whether to check if the inputs are finite. Default is False. + + Returns + ------- + factorization : numpy.ndarray or Callable + The factorization of `lhs`. + + """ + if self.using_penta: + factorization = penta_factorize( + lhs, solver=self.penta_solver, overwrite_ab=overwrite_ab + ) + elif self.lower: + factorization = cholesky_banded( + lhs, lower=True, overwrite_ab=overwrite_ab, check_finite=check_finite + ) + else: + factorization = factorized(_banded_to_sparse(lhs, self.lower, sparse_format='csc')) + + return factorization + + def factorized_solve(self, factorization, rhs, overwrite_b=False, check_finite=False): + """ + Solves ``A x = b`` given the factorization of ``A``. + + Parameters + ---------- + factorization : numpy.ndarray or Callable + The factorization of ``A``, output by :meth:`PenalizedSystem.factorize`. + rhs : array-like, shape (N,) or (N, M) + The right-hand side of the equation. + overwrite_b : bool, optional + Whether to overwrite `rhs` when using any of the solvers. Default is False. + check_finite : bool, optional + Whether to check if the inputs are finite. Default is False. + + Returns + ------- + output : numpy.ndarray, shape (N,) or (N, M) + The solution to the linear system, `x`. + + """ + if self.using_penta: + output = penta_factorize_solve( + factorization, rhs, solver=self.penta_solver, overwrite_b=overwrite_b + ) + elif self.lower: + output = cho_solve_banded( + (factorization, True), rhs, overwrite_b=overwrite_b, check_finite=check_finite + ) + else: + output = factorization(rhs) + + return output diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index 942ab896..bde24724 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -11,9 +11,11 @@ import numpy as np from numpy.testing import assert_allclose, assert_array_equal import pytest +from scipy.linalg import cholesky_banded from scipy.sparse.linalg import spsolve from pybaselines import _banded_utils, _spline_utils +from pybaselines._banded_solvers import penta_factorize from pybaselines._compat import dia_object, diags, identity @@ -649,12 +651,15 @@ def test_penalized_system_solve(data_fixture, diff_order, allow_lower, allow_pen """ Tests the solve method of a PenalizedSystem object. - Solves the equation ``(I + lam * D.T @ D) x = y``, where `I` is the identity + Solves the equation ``(W + lam * D.T @ D) x = W @ y``, where `W` is the weight matrix, and ``D.T @ D`` is the penalty. """ x, y = data_fixture data_size = len(y) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] expected_penalty = _banded_utils.diff_penalty_diagonals( data_size, diff_order=diff_order, lower_only=False @@ -663,14 +668,14 @@ def test_penalized_system_solve(data_fixture, diff_order, allow_lower, allow_pen (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), shape=(data_size, data_size) ).tocsr() - expected_solution = spsolve(identity(data_size, format='csr') + sparse_penalty, y) + expected_solution = spsolve(diags(weights, format='csr') + sparse_penalty, weights * y) penalized_system = _banded_utils.PenalizedSystem( data_size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, reverse_diags=False, allow_penta=allow_penta ) - penalized_system.penalty[penalized_system.main_diagonal_index] += 1 - output = penalized_system.solve(penalized_system.penalty, y) + penalized_system.add_diagonal(weights) + output = penalized_system.solve(penalized_system.penalty, weights * y) assert_allclose(output, expected_solution, 1e-6, 1e-10) @@ -916,6 +921,7 @@ def test_penalized_system_update_lam(diff_order, allow_lower): assert_allclose( penalized_system.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 ) + assert_allclose(penalized_system.lam, lam_init, rtol=1e-15, atol=1e-15) for lam in (1e3, 5.2e1): expected_penalty = lam * _banded_utils.diff_penalty_diagonals( data_size, diff_order=diff_order, lower_only=penalized_system.lower @@ -926,6 +932,7 @@ def test_penalized_system_update_lam(diff_order, allow_lower): assert_allclose( penalized_system.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 ) + assert_allclose(penalized_system.lam, lam, rtol=1e-15, atol=1e-15) def test_penalized_system_update_lam_invalid_lam(): @@ -937,6 +944,55 @@ def test_penalized_system_update_lam_invalid_lam(): penalized_system.update_lam(0) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('allow_penta', (True, False)) +def test_penalized_system_factorize_solve(data_fixture, diff_order, allow_lower, allow_penta): + """ + Tests the factorize and factorized_solve methods of a PenalizedSystem object. + + Solves the equation ``(W + lam * D.T @ D) x = W @ y``, where `W` is the weight + matrix, and ``D.T @ D`` is the penalty. + + """ + x, y = data_fixture + data_size = len(y) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] + expected_penalty = _banded_utils.diff_penalty_diagonals( + data_size, diff_order=diff_order, lower_only=False + ) + sparse_penalty = dia_object( + (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), + shape=(data_size, data_size) + ).tocsr() + expected_solution = spsolve(diags(weights, format='csr') + sparse_penalty, weights * y) + + penalized_system = _banded_utils.PenalizedSystem( + data_size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + penalized_system.add_diagonal(weights) + + output_factorization = penalized_system.factorize(penalized_system.penalty) + if allow_lower or (allow_penta and diff_order == 2): + if allow_penta and diff_order == 2: + expected_factorization = penta_factorize( + penalized_system.penalty, solver=penalized_system.penta_solver + ) + else: + expected_factorization = cholesky_banded(penalized_system.penalty, lower=True) + + assert_allclose(output_factorization, expected_factorization, rtol=1e-14, atol=1e-14) + else: + assert callable(output_factorization) + + output = penalized_system.factorized_solve(output_factorization, weights * y) + assert_allclose(output, expected_solution, rtol=1e-7, atol=1e-10) + + @pytest.mark.parametrize('dtype', (float, np.float32)) def test_sparse_to_banded(dtype): """Tests basic functionality of _sparse_to_banded.""" @@ -1291,8 +1347,8 @@ def test_banded_to_sparse_simple(): @pytest.mark.parametrize('lower', (True, False)) @pytest.mark.parametrize('diff_order', (1, 2, 3)) @pytest.mark.parametrize('size', (100, 1001)) -def test_banded_to_sparse(lower, diff_order, size): - """Ensures proper functionality of _banded_to_sparse.""" +def test_banded_to_sparse_symmetric(lower, diff_order, size): + """Ensures proper functionality of _banded_to_sparse for symmetric matrices.""" expected_matrix = _banded_utils.diff_penalty_matrix(size, diff_order=diff_order) banded_matrix = _banded_utils.diff_penalty_diagonals( size, diff_order=diff_order, lower_only=lower @@ -1302,6 +1358,31 @@ def test_banded_to_sparse(lower, diff_order, size): assert_allclose(output.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('size', (100, 1001)) +def test_banded_to_sparse_nonsymmetric(diff_order, size): + """Ensures proper functionality of _banded_to_sparse for non symmetric matrices.""" + multiplier = np.random.default_rng(123).uniform(0, 1, size) + multiplier_matrix = diags(multiplier) + penalty_matrix = _banded_utils.diff_penalty_matrix(size, diff_order=diff_order) + banded_penalty = _banded_utils.diff_penalty_diagonals( + size, diff_order=diff_order, lower_only=False + ) + + expected_matrix = multiplier_matrix @ penalty_matrix + banded_matrix = _banded_utils._shift_rows( + banded_penalty[::-1] * multiplier, diff_order, diff_order + ) + # sanity check that the banded multiplication resulted in the correct LAPACK banded format + for i in range(diff_order): + for j in range(diff_order - i): + assert_allclose(banded_matrix[i, j], 0., rtol=1e-16, atol=1e-16) + assert_allclose(banded_matrix[-(i + 1), -(j + 1)], 0., rtol=1e-16, atol=1e-16) + + output = _banded_utils._banded_to_sparse(banded_matrix, lower=False) + assert_allclose(output.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) + + @pytest.mark.parametrize('form', ('dia', 'csc', 'csr')) @pytest.mark.parametrize('lower', (True, False)) def test_banded_to_sparse_formats(form, lower): diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 19985454..84ae9de1 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -12,6 +12,7 @@ from numpy.testing import assert_allclose, assert_array_equal import pytest from scipy.interpolate import BSpline +from scipy.linalg import cholesky_banded from scipy.sparse import issparse from scipy.sparse.linalg import spsolve @@ -245,38 +246,70 @@ def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower basis.T @ (weights * y) ) expected_spline = basis @ expected_coeffs + for has_numba in (True, False): + with mock.patch.object(_spline_utils, '_HAS_NUMBA', has_numba): + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + assert_allclose( + pspline.solve_pspline(y, weights=weights, penalty=penalty), + expected_spline, 1e-10, 1e-12 + ) + assert_allclose( + pspline.coef, expected_coeffs, 1e-10, 1e-12 + ) - with mock.patch.object(_spline_utils, '_HAS_NUMBA', False): - # use sparse calculation - spline_basis = _spline_utils.SplineBasis( - x, num_knots=num_knots, spline_degree=spline_degree - ) - pspline = _spline_utils.PSpline( - spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only - ) - assert_allclose( - pspline.solve_pspline(y, weights=weights, penalty=penalty), - expected_spline, 1e-10, 1e-12 - ) - assert_allclose( - pspline.coef, expected_coeffs, 1e-10, 1e-12 - ) - with mock.patch.object(_spline_utils, '_HAS_NUMBA', True): - # should use the numba calculation - spline_basis = _spline_utils.SplineBasis( - x, num_knots=num_knots, spline_degree=spline_degree - ) - pspline = _spline_utils.PSpline( - spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only - ) - assert_allclose( - pspline.solve_pspline(y, weights=weights, penalty=penalty), - expected_spline, 1e-10, 1e-12 - ) +@pytest.mark.parametrize('num_knots', (100, 1000)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) +@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) +@pytest.mark.parametrize('lower_only', (True, False)) +def test_pspline_factorize_solve(data_fixture, num_knots, spline_degree, diff_order, lower_only): + """Tests the factorize and factorized_solve methods of a PSpline object.""" + x, y = data_fixture + # ensure x and y are floats + x = x.astype(float) + y = y.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) + basis = _spline_utils._spline_basis(x, knots, spline_degree) + num_bases = basis.shape[1] + penalty_matrix = dia_object( + (_banded_utils.diff_penalty_diagonals(num_bases, diff_order, False), + np.arange(diff_order, -(diff_order + 1), -1)), shape=(num_bases, num_bases) + ).tocsr() + + lhs_sparse = basis.T @ diags(weights, format='csr') @ basis + penalty_matrix + rhs = basis.T @ (weights * y) + expected_coeffs = spsolve(lhs_sparse, rhs) + + lhs_banded = _banded_utils._sparse_to_banded(lhs_sparse)[0] + if lower_only: + lhs_banded = lhs_banded[len(lhs_banded) // 2:] + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + output_factorization = pspline.factorize(lhs_banded) + if lower_only: + expected_factorization = cholesky_banded(lhs_banded, lower=True) + assert_allclose( - pspline.coef, expected_coeffs, 1e-10, 1e-12 + output_factorization, expected_factorization, rtol=1e-14, atol=1e-14 ) + else: + assert callable(output_factorization) + + output = pspline.factorized_solve(output_factorization, rhs) + assert_allclose(output, expected_coeffs, rtol=1e-10, atol=1e-12) def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, From 817cdf7182119918e817cc329f6e9b4f5c96742e Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 19 Oct 2025 17:30:04 -0400 Subject: [PATCH 07/38] MAINT: Add btwb calc for PSpline --- pybaselines/_spline_utils.py | 111 +++++++++++++++++++++++++++++++++-- tests/test_spline_utils.py | 80 ++++++++++++++++++++----- 2 files changed, 171 insertions(+), 20 deletions(-) diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 2baaebed..ee0360f3 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -501,6 +501,55 @@ def _numba_btb_bty(x, knots, spline_degree, y, weights, ab, rhs, basis_data): rhs[initial_idx + j] += work_val * y_val # B.T @ W @ y +# adapted from scipy (scipy/interpolate/_bspl.pyx/_norm_eq_lsq); see license above +@jit(nopython=True, cache=True) +def _numba_btb(x, knots, spline_degree, weights, ab, basis_data): + """ + Computes ``B.T @ W @ B`` for a spline. + + The result of ``B.T @ W @ B`` is stored in LAPACK's lower banded format (see + :func:`scipy.linalg.solveh_banded`). + + Parameters + ---------- + x : numpy.ndarray, shape (N,) + The x-values for the spline. + knots : numpy.ndarray, shape (K,) + The array of knots for the spline. Should be padded on each end with + `spline_degree` extra knots. + spline_degree : int + The degree of the spline. + y : numpy.ndarray, shape (N,) + The y-values for fitting the spline. + weights : numpy.ndarray, shape(N,) + The weights for each y-value. + ab : numpy.ndarray, shape (`spline_degree` + 1, N) + An array of zeros that will be modified inplace to contain ``B.T @ W @ B`` in + lower banded format. + basis_data : numpy.ndarray, shape (``N * (spline_degree + 1)``,) + The data for all of the basis functions. The basis for each `x[i]` value is represented + by ``basis_data[i * (spline_degree + 1):(i + 1) * (spline_degree + 1)]``. If the basis, + `B` is a sparse matrix, then `basis_data` can be gotten using `B.tocsr().data`. + + """ + spline_order = spline_degree + 1 + num_bases = len(knots) - spline_order + + left_knot_idx = spline_degree + idx = 0 + for i in range(len(x)): + weight_val = weights[i] + left_knot_idx = _find_interval(knots, spline_degree, x[i], left_knot_idx, num_bases) + next_idx = idx + spline_order + work = basis_data[idx:next_idx] + idx = next_idx + initial_idx = left_knot_idx - spline_degree + for j in range(spline_order): + work_val = work[j] * weight_val # B.T @ W + for k in range(j + 1): + ab[j - k, initial_idx + k] += work_val * work[k] # B.T @ W @ B + + def _basis_midpoints(knots, spline_degree): """ Calculates the midpoint x-values of spline basis functions assuming evenly spaced knots. @@ -773,7 +822,7 @@ def reset_penalty_diagonals(self, lam=1, diff_order=2, allow_lower=True, reverse common setup, cubic splines, have a bandwidth of 3 and would not use the pentadiagonal solvers anyway. - Adds padding to the penalty diagonals to accomodate the different shapes of the spline + Adds padding to the penalty diagonals to accommodate the different shapes of the spline basis and the penalty to speed up calculations when the two are added. """ @@ -799,10 +848,11 @@ def solve_pspline(self, y, weights, penalty=None, rhs_extra=None): The y-values for fitting the spline. weights : numpy.ndarray, shape (N,) The weights for each y-value. - penalty : numpy.ndarray, shape (D, N) + penalty : numpy.ndarray, shape (M, N), optional The finite difference penalty matrix, in LAPACK's lower banded format (see - :func:`scipy.linalg.solveh_banded`) if `lower_only` is True or the full banded - format (see :func:`scipy.linalg.solve_banded`) if `lower_only` is False. + :func:`scipy.linalg.solveh_banded`) if `self.lower` is True or the full banded + format (see :func:`scipy.linalg.solve_banded`) if `self.lower` is False. Default + is None, which uses the object's penalty. rhs_extra : float or numpy.ndarray, shape (N,), optional If supplied, `rhs_extra` will be added to the right hand side (``B.T @ W @ y``) of the equation before solving. Default is None, which adds nothing. @@ -864,3 +914,56 @@ def solve_pspline(self, y, weights, penalty=None, rhs_extra=None): ) return self.basis.basis @ self.coef + + def _make_btwb(self, weights): + """ + Calculates the result of ``B.T @ W @ B``. + + Parameters + ---------- + weights : numpy.ndarray, shape (N,) + The weights for each y-value. + + Returns + ------- + btwb : numpy.ndarray, shape (L, N) + The calculation of ``B.T @ W @ B`` in the same banded format as the PSpline object. + + """ + use_backup = True + # prefer numba version since it directly uses the basis + if self._use_numba: + basis_data = self.basis.basis.tocsr().data + # TODO if using the numba version, does fortran ordering speed up the calc? or + # can btwb just be c ordered? + + # create btwb array outside of numba function since numba's implementation + # of np.zeros is slower than numpy's (https://github.com/numba/numba/issues/7259) + btwb = np.zeros((self.basis.spline_degree + 1, self.basis._num_bases), order='F') + _numba_btb( + self.basis.x, self.basis.knots, self.basis.spline_degree, weights, btwb, + basis_data + ) + # TODO can probably make the full matrix directly within the numba + # btb calculation + if not self.lower: + btwb = _lower_to_full(btwb) + use_backup = False + + if use_backup: + # worst case scenario; have to convert weights to a sparse diagonal matrix, + # do B.T @ W @ B, and convert back to lower banded + full_matrix = ( + self.basis.basis.T + @ dia_object((weights, 0), shape=(self.basis._x_len, self.basis._x_len)).tocsr() + @ self.basis.basis + ) + btwb = _sparse_to_banded(full_matrix)[0] + + # take only the lower diagonals of the symmetric ab; cannot just do + # ab[spline_degree:] since some diagonals become fully 0 and are truncated from + # the data attribute, so have to calculate the number of bands first + if self.lower: + btwb = btwb[len(btwb) // 2:] + + return btwb diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 84ae9de1..3fdfe33e 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -214,7 +214,8 @@ def test_numba_basis_len(data_fixture, num_knots, spline_degree): @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) @pytest.mark.parametrize('lower_only', (True, False)) -def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower_only): +@pytest.mark.parametrize('has_numba', (True, False)) +def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower_only, has_numba): """ Tests the accuracy of the penalized spline solvers. @@ -246,21 +247,20 @@ def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower basis.T @ (weights * y) ) expected_spline = basis @ expected_coeffs - for has_numba in (True, False): - with mock.patch.object(_spline_utils, '_HAS_NUMBA', has_numba): - spline_basis = _spline_utils.SplineBasis( - x, num_knots=num_knots, spline_degree=spline_degree - ) - pspline = _spline_utils.PSpline( - spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only - ) - assert_allclose( - pspline.solve_pspline(y, weights=weights, penalty=penalty), - expected_spline, 1e-10, 1e-12 - ) - assert_allclose( - pspline.coef, expected_coeffs, 1e-10, 1e-12 - ) + with mock.patch.object(_spline_utils, '_HAS_NUMBA', has_numba): + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + assert_allclose( + pspline.solve_pspline(y, weights=weights, penalty=penalty), + expected_spline, 1e-10, 1e-12 + ) + assert_allclose( + pspline.coef, expected_coeffs, 1e-10, 1e-12 + ) @pytest.mark.parametrize('num_knots', (100, 1000)) @@ -312,6 +312,54 @@ def test_pspline_factorize_solve(data_fixture, num_knots, spline_degree, diff_or assert_allclose(output, expected_coeffs, rtol=1e-10, atol=1e-12) +@pytest.mark.parametrize('num_knots', (100, 1000)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) +@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) +@pytest.mark.parametrize('lower_only', (True, False)) +@pytest.mark.parametrize('has_numba', (True, False)) +def test_pspline_make_btwb(data_fixture, num_knots, spline_degree, diff_order, lower_only, + has_numba): + """ + Tests the accuracy of the the PSpline ``B.T @ W @ B`` calculation. + + The PSpline has two routes: + 1) use the custom numba function (preferred if numba is installed) + 2) compute ``B.T @ W @ B`` using the sparse system (last resort) + + Both are tested here. + + """ + x, y = data_fixture + # ensure x and y are floats + x = x.astype(float) + y = y.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) + basis = _spline_utils._spline_basis(x, knots, spline_degree) + + sparse_calc = basis.T @ diags(weights, format='csr') @ basis + expected_output = _banded_utils._sparse_to_banded(sparse_calc)[0] + if has_numba and len(expected_output) != 2 * spline_degree + 1: + # the sparse calculation can truncate rows of just zeros, so refill them + zeros = np.zeros(expected_output.shape[1]) + expected_output = np.vstack((zeros, expected_output, zeros)) + if lower_only: + expected_output = expected_output[len(expected_output) // 2:] + + with mock.patch.object(_spline_utils, '_HAS_NUMBA', has_numba): + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + assert_allclose( + pspline._make_btwb(weights=weights), expected_output, 1e-14, 1e-14 + ) + + def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, allow_lower, reverse_diags, spline_degree, num_knots, data_size): From 673cec1e595fcde8634d25e513caf42ecf92f09b Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 19 Oct 2025 18:11:01 -0400 Subject: [PATCH 08/38] MAINT: Add effective dimension calc for PenalizedSystem and PSpline --- pybaselines/_banded_utils.py | 64 +++++++++++++++++++++++++++++ pybaselines/_spline_utils.py | 59 +++++++++++++++++++++++++-- tests/test_banded_solvers.py | 4 +- tests/test_banded_utils.py | 43 +++++++++++++++++++- tests/test_spline_utils.py | 79 ++++++++++++++++++++++++++---------- 5 files changed, 221 insertions(+), 28 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index ccae6c08..fa64de03 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -988,3 +988,67 @@ def factorized_solve(self, factorization, rhs, overwrite_b=False, check_finite=F output = factorization(rhs) return output + + def effective_dimension(self, weights=None, penalty=None): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical Whittaker smoothing, the linear equation would be + ``(W + lam * P) x = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. + The effective dimension for the system can be estimated as the trace + of the hat matrix. + + Parameters + ---------- + weights : numpy.ndarray, shape (N,), optional + The weights. Default is None, which will use an array of ones. + penalty : numpy.ndarray, shape (N, N), optional + The finite difference penalty matrix, in LAPACK's lower banded format if + `self.lower` is True or the full banded if `self.lower` is False. Default + is None, which uses the object's penalty. + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + References + ---------- + Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. + + """ + if weights is None: + weights = np.ones(self._num_bases) + + reset_penalty = False + if penalty is None: + reset_penalty = True + self.add_diagonal(weights) + penalty = self.penalty + + # TODO if diff_order is 2 and matrix is symmetric, could use the fast trace calculation from + # Frasso G, Eilers PH. L- and V-curves for optimal smoothing. Statistical Modelling. + # 2014;15(1):91-111. https://doi.org/10.1177/1471082X14549288, which is in turn based on + # Craven, P., Wahba, G. Smoothing noisy data with spline functions. Numerische Mathematik. + # 31, 377–403 (1978). https://doi.org/10.1007/BF01404567 + # -> worth the effort? Could it be extended to work for any diff_order as long as the + # matrix is symmetric? + + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + # note to self: sparse factorization is the worst case scenario (non-symmetric lhs and + # diff_order != 2), but it is still much faster than individual solves through + # solve_banded + eye = np.zeros(self._num_bases) + trace = 0 + factorization = self.factorize(penalty) + for i in range(self._num_bases): + eye[i] = weights[i] + trace += self.factorized_solve(factorization, eye)[i] + eye[i] = 0 + + if reset_penalty: # remove the weights + self.penalty[self.main_diagonal_index] = self.main_diagonal + + return trace diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index ee0360f3..6fa63eb3 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -48,7 +48,9 @@ import numpy as np from scipy.interpolate import BSpline -from ._banded_utils import PenalizedSystem, _add_diagonals, _lower_to_full, _sparse_to_banded +from ._banded_utils import ( + PenalizedSystem, _add_diagonals, _banded_to_sparse, _lower_to_full, _sparse_to_banded +) from ._compat import _HAS_NUMBA, csr_object, dia_object, jit from ._validation import _check_array @@ -519,8 +521,6 @@ def _numba_btb(x, knots, spline_degree, weights, ab, basis_data): `spline_degree` extra knots. spline_degree : int The degree of the spline. - y : numpy.ndarray, shape (N,) - The y-values for fitting the spline. weights : numpy.ndarray, shape(N,) The weights for each y-value. ab : numpy.ndarray, shape (`spline_degree` + 1, N) @@ -967,3 +967,56 @@ def _make_btwb(self, weights): btwb = btwb[len(btwb) // 2:] return btwb + + def effective_dimension(self, weights=None, penalty=None): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical P-spline smoothing, the linear equation would be + ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``z = B @ c``. Then the hat matrix + would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + weights : numpy.ndarray, shape (N,), optional + The weights. Default is None, which will use an array of ones. + penalty : numpy.ndarray, shape (M, N), optional + The finite difference penalty matrix, in LAPACK's lower banded format if + `self.lower` is True or the full banded if `self.lower` is False. Default + is None, which uses the object's penalty. + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + References + ---------- + Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical Science, + 1996, 11(2), 89-121. + + """ + if weights is None: + weights = np.ones(self._num_bases) + + btwb = self._make_btwb(weights) + if penalty is None: + penalty = self.penalty + + lhs = _add_diagonals(btwb, penalty, self.lower) + + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format='csc') + factorization = self.factorize(lhs, overwrite_ab=True) + for i in range(self._num_bases): + trace += self.factorized_solve( + factorization, btwb_matrix[:, i].toarray(), overwrite_b=True + )[i] + + return trace diff --git a/tests/test_banded_solvers.py b/tests/test_banded_solvers.py index 06e4f36f..f856892d 100644 --- a/tests/test_banded_solvers.py +++ b/tests/test_banded_solvers.py @@ -53,7 +53,7 @@ def pentapy_ptrans1(mat_flat, rhs): Parameters ---------- - lhs : numpy.ndarray, shape (5, M) + mat_flat : numpy.ndarray, shape (5, M) The pentadiagonal matrix `A` in row-wise banded format (see https://geostat-framework.readthedocs.io/projects/pentapy/en/stable/examples/index.html). rhs : numpy.ndarray, shape (M,) or (M, N) @@ -148,7 +148,7 @@ def pentapy_ptrans2(mat_flat, rhs): Parameters ---------- - lhs : numpy.ndarray, shape (5, M) + mat_flat : numpy.ndarray, shape (5, M) The pentadiagonal matrix `A` in row-wise banded format (see https://geostat-framework.readthedocs.io/projects/pentapy/en/stable/examples/index.html). rhs : numpy.ndarray, shape (M,) or (M, N) diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index bde24724..bd4e9e15 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -12,7 +12,7 @@ from numpy.testing import assert_allclose, assert_array_equal import pytest from scipy.linalg import cholesky_banded -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve from pybaselines import _banded_utils, _spline_utils from pybaselines._banded_solvers import penta_factorize @@ -677,7 +677,7 @@ def test_penalized_system_solve(data_fixture, diff_order, allow_lower, allow_pen penalized_system.add_diagonal(weights) output = penalized_system.solve(penalized_system.penalty, weights * y) - assert_allclose(output, expected_solution, 1e-6, 1e-10) + assert_allclose(output, expected_solution, rtol=1e-6, atol=1e-10) @pytest.mark.parametrize('diff_order', (1, 2, 3)) @@ -993,6 +993,45 @@ def test_penalized_system_factorize_solve(data_fixture, diff_order, allow_lower, assert_allclose(output, expected_solution, rtol=1e-7, atol=1e-10) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('allow_penta', (True, False)) +@pytest.mark.parametrize('size', (100, 501)) +def test_penalized_system_effective_dimension(diff_order, allow_lower, allow_penta, size): + """ + Tests the effective_dimension method of a PenalizedSystem object. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, size) + weights = np.clip(weights, 0, 1).astype(float) + + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] + expected_penalty = _banded_utils.diff_penalty_diagonals( + size, diff_order=diff_order, lower_only=False + ) + sparse_penalty = dia_object( + (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), + shape=(size, size) + ).tocsr() + weights_matrix = diags(weights, format='csc') + factorization = factorized(weights_matrix + sparse_penalty) + expected_ed = 0 + for i in range(size): + expected_ed += factorization(weights_matrix[:, i].toarray())[i] + + penalized_system = _banded_utils.PenalizedSystem( + size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + output = penalized_system.effective_dimension(weights) + + assert_allclose(output, expected_ed, rtol=1e-7, atol=1e-10) + + @pytest.mark.parametrize('dtype', (float, np.float32)) def test_sparse_to_banded(dtype): """Tests basic functionality of _sparse_to_banded.""" diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 3fdfe33e..5d61019d 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -14,10 +14,10 @@ from scipy.interpolate import BSpline from scipy.linalg import cholesky_banded from scipy.sparse import issparse -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve from pybaselines import _banded_utils, _spline_utils -from pybaselines._compat import dia_object, diags, _HAS_NUMBA +from pybaselines._compat import diags, _HAS_NUMBA def _nieve_basis_matrix(x, knots, spline_degree): @@ -68,7 +68,7 @@ def test_spline_knots_too_few_knots(num_knots): _spline_utils._spline_knots(np.arange(10), num_knots) -@pytest.mark.parametrize('num_knots', (2, 20, 1001)) +@pytest.mark.parametrize('num_knots', (2, 20, 201)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('penalized', (True, False)) def test_spline_knots(data_fixture, num_knots, spline_degree, penalized): @@ -99,7 +99,7 @@ def test_spline_knots(data_fixture, num_knots, spline_degree, penalized): assert np.all(np.diff(knots) >= 0) -@pytest.mark.parametrize('num_knots', (2, 20, 1001)) +@pytest.mark.parametrize('num_knots', (2, 20, 201)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('source', ('simple', 'numba', 'scipy')) def test_spline_basis(data_fixture, num_knots, spline_degree, source): @@ -128,7 +128,7 @@ def test_spline_basis(data_fixture, num_knots, spline_degree, source): assert issparse(basis) assert_allclose(basis.toarray(), expected_basis, 1e-14, 1e-14) # also test the main interface for the spline basis; only test for one - # source to avoid unnecessary repitition + # source to avoid unnecessary repetition if source == 'simple': basis_2 = _spline_utils._spline_basis(x, knots, spline_degree) assert issparse(basis_2) @@ -187,7 +187,7 @@ def test_bspline_has_extrapolate(): assert _spline_utils._bspline_has_extrapolate.cache_info().misses == 1 -@pytest.mark.parametrize('num_knots', (2, 20, 1001)) +@pytest.mark.parametrize('num_knots', (2, 20, 201)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) def test_numba_basis_len(data_fixture, num_knots, spline_degree): """ @@ -210,7 +210,7 @@ def test_numba_basis_len(data_fixture, num_knots, spline_degree): assert len(basis.tocsr().data) == len(x) * (spline_degree + 1) -@pytest.mark.parametrize('num_knots', (100, 1000)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) @pytest.mark.parametrize('lower_only', (True, False)) @@ -237,10 +237,7 @@ def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower basis = _spline_utils._spline_basis(x, knots, spline_degree) num_bases = basis.shape[1] penalty = _banded_utils.diff_penalty_diagonals(num_bases, diff_order, lower_only) - penalty_matrix = dia_object( - (_banded_utils.diff_penalty_diagonals(num_bases, diff_order, False), - np.arange(diff_order, -(diff_order + 1), -1)), shape=(num_bases, num_bases) - ).tocsr() + penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) expected_coeffs = spsolve( basis.T @ diags(weights, format='csr') @ basis + penalty_matrix, @@ -263,7 +260,7 @@ def test_pspline_solve(data_fixture, num_knots, spline_degree, diff_order, lower ) -@pytest.mark.parametrize('num_knots', (100, 1000)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) @pytest.mark.parametrize('lower_only', (True, False)) @@ -279,10 +276,7 @@ def test_pspline_factorize_solve(data_fixture, num_knots, spline_degree, diff_or knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) basis = _spline_utils._spline_basis(x, knots, spline_degree) num_bases = basis.shape[1] - penalty_matrix = dia_object( - (_banded_utils.diff_penalty_diagonals(num_bases, diff_order, False), - np.arange(diff_order, -(diff_order + 1), -1)), shape=(num_bases, num_bases) - ).tocsr() + penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) lhs_sparse = basis.T @ diags(weights, format='csr') @ basis + penalty_matrix rhs = basis.T @ (weights * y) @@ -312,7 +306,7 @@ def test_pspline_factorize_solve(data_fixture, num_knots, spline_degree, diff_or assert_allclose(output, expected_coeffs, rtol=1e-10, atol=1e-12) -@pytest.mark.parametrize('num_knots', (100, 1000)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) @pytest.mark.parametrize('lower_only', (True, False)) @@ -360,6 +354,47 @@ def test_pspline_make_btwb(data_fixture, num_knots, spline_degree, diff_order, l ) +@pytest.mark.parametrize('num_knots', (20, 101)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) +@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) +@pytest.mark.parametrize('lower_only', (True, False)) +def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, + lower_only): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) + basis = _spline_utils._spline_basis(x, knots, spline_degree) + num_bases = basis.shape[1] + penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) + + btwb = basis.T @ diags(weights, format='csr') @ basis + factorization = factorized(btwb + penalty_matrix) + expected_ed = 0 + for i in range(num_bases): + expected_ed += factorization(btwb[:, i].toarray())[i] + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + + output = pspline.effective_dimension(weights) + assert_allclose(output, expected_ed, rtol=1e-10, atol=1e-12) + + def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, allow_lower, reverse_diags, spline_degree, num_knots, data_size): @@ -408,7 +443,7 @@ def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, @pytest.mark.parametrize('spline_degree', (1, 2, 3)) -@pytest.mark.parametrize('num_knots', (10, 100)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('diff_order', (1, 2, 3)) @pytest.mark.parametrize('allow_lower', (True, False)) @pytest.mark.parametrize('reverse_diags', (True, False)) @@ -491,7 +526,7 @@ def test_spline_basis_negative_spline_degree_fails(data_fixture, spline_degree): @pytest.mark.parametrize('spline_degree', (1, 2, 3)) -@pytest.mark.parametrize('num_knots', (10, 100)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('diff_order', (1, 2)) @pytest.mark.parametrize('lam', (1e-2, 1e2)) def test_pspline_tck(data_fixture, num_knots, spline_degree, diff_order, lam): @@ -538,7 +573,7 @@ def test_pspline_tck_readonly(data_fixture): @pytest.mark.parametrize('diff_order', (1, 2, 3)) @pytest.mark.parametrize('allow_lower', (True, False)) -@pytest.mark.parametrize('num_knots', (50, 101)) +@pytest.mark.parametrize('num_knots', (20, 101)) @pytest.mark.parametrize('spline_degree', (1, 2, 3)) def test_pspline_update_lam(data_fixture, diff_order, allow_lower, num_knots, spline_degree): """Tests updating the lam value for PSpline.""" @@ -560,6 +595,7 @@ def test_pspline_update_lam(data_fixture, diff_order, allow_lower, num_knots, sp assert_allclose( pspline.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 ) + assert_allclose(pspline.lam, lam_init, rtol=1e-15, atol=1e-15) for lam in (1e3, 5.2e1): expected_penalty = lam * _banded_utils.diff_penalty_diagonals( data_size, diff_order=diff_order, lower_only=pspline.lower, @@ -571,6 +607,7 @@ def test_pspline_update_lam(data_fixture, diff_order, allow_lower, num_knots, sp assert_allclose( pspline.main_diagonal, expected_penalty[diag_index], rtol=1e-14, atol=1e-14 ) + assert_allclose(pspline.lam, lam, rtol=1e-15, atol=1e-15) def test_pspline_update_lam_invalid_lam(data_fixture): @@ -593,7 +630,7 @@ def test_spline_basis_tk_readonly(data_fixture): @pytest.mark.parametrize('spline_degree', (1, 2, 3)) -@pytest.mark.parametrize('num_knots', (10, 100)) +@pytest.mark.parametrize('num_knots', (20, 101)) def test_spline_basis_tk(data_fixture, num_knots, spline_degree): """Ensures the tk attribute can correctly recreate the solved spline.""" x, y = data_fixture From eb99e5ff77292f0a90e887187107de385a7d7813 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 19 Oct 2025 19:06:42 -0400 Subject: [PATCH 09/38] OTHER: Add stochastic trace estimation for faster evaluation --- pybaselines/_banded_utils.py | 193 ++++++++++++++++++++++++++++++++--- pybaselines/_spline_utils.py | 69 ++++++++++--- tests/test_banded_utils.py | 67 ++++++++++++ tests/test_spline_utils.py | 56 ++++++++++ 4 files changed, 359 insertions(+), 26 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index fa64de03..c3074c8a 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -7,7 +7,9 @@ """ import numpy as np -from scipy.linalg import cholesky_banded, cho_solve_banded, solve_banded, solveh_banded +from scipy.linalg import ( + cholesky_banded, cho_solve_banded, get_blas_funcs, solve_banded, solveh_banded +) from scipy.sparse.linalg import factorized @@ -279,6 +281,128 @@ def _banded_to_sparse(ab, lower=True, sparse_format='csc'): return matrix +def _banded_dot_vector_full(ab, x, ab_lu, a_full_shape): + """ + Computes the dot product of the matrix `a` in banded format (`ab`) with the vector `x`. + + Parameters + ---------- + ab : array-like, shape (`n_lower` + `n_upper` + 1, N) + The banded matrix. + x : array-like, shape (N,) + The vector. + ab_lu : Container[int, int] + The number of lower (`n_lower`) and upper (`n_upper`) diagonals in `ab`. + a_full_shape : Container[int, int] + The number of rows and columns in the full `a` matrix. + + Returns + ------- + output : numpy.ndarray, shape (N,) + The dot product of `ab` and `x`. + + Notes + ----- + The function is faster if the input `ab` matrix is Fortran-ordered (has the + F_CONTIGUOUS numpy flag), since the underlying 'gbmv' BLAS function is + implemented in Fortran. + + """ + matrix = np.asarray(ab) + vector = np.asarray(x) + + gbmv = get_blas_funcs(['gbmv'], (matrix, vector))[0] + # gbmv computes y = alpha * a * x + beta * y where a is the banded matrix + # (in compressed form), x is the input vector, y is the output vector, and alpha + # and beta are scalar multipliers + output = gbmv( + m=a_full_shape[0], # number of rows of `a` matrix in full form + n=a_full_shape[1], # number of columns of `a` matrix in full form + kl=ab_lu[0], # sub-diagonals + ku=ab_lu[1], # super-diagonals + alpha=1.0, # alpha, required + a=matrix, # `a` matrix in compressed form + x=vector, # `x` vector + # trans=False, # transpose a, optional; may allow later + ) + + return output + + +def _banded_dot_vector_lower(ab, x): + """ + Computes the dot product of the matrix `a` in lower banded format (`ab`) with the vector `x`. + + Parameters + ---------- + ab : array-like, shape (`n_lower` + 1, N) + The banded matrix. + x : array-like, shape (N,) + The vector. + + Returns + ------- + output : numpy.ndarray, shape (N,) + The dot product of `ab` and `x`. + + Notes + ----- + The function is faster if the input `ab` matrix is Fortran-ordered (has the + F_CONTIGUOUS numpy flag), since the underlying 'sbmv' BLAS function is + implemented in Fortran. + + """ + matrix = np.asarray(ab) + vector = np.asarray(x) + + sbmv = get_blas_funcs(['sbmv'], (matrix, vector))[0] + # sbmv computes y = alpha * a * x + beta * y where a is the symmetric banded matrix + # (in compressed lower form), x is the input vector, y is the output vector, and alpha + # and beta are scalar multipliers + output = sbmv( + k=matrix.shape[0] - 1, # number of bands + alpha=1.0, # alpha, required + a=matrix, # `a` matrix in compressed form + x=vector, # `x` vector + lower=1, # using lower banded format + ) + + return output + + +def _banded_dot_vector(ab, x, lower=True): + """ + Computes ``A @ x`` given `A` in banded format (`ab`) with the vector `x`. + + Parameters + ---------- + ab : array-like, shape (`n_lower` + `n_upper` + 1, N) or (`n_lower` + 1, N) + The banded matrix. Can either be in lower format or full. If given a full + banded matrix, assumes the number of lower (`n_lower`) and upper (`n_upper`) + diagonals are the same. If that assumption does not hold true, then must use + ``_banded_dot_vector_full`` directly. + x : array-like, shape (N,) + The vector. + lower : bool, optional + False indicates that `ab` represents the full band structure, + while True (default) indicates `ab` is in lower format. + + Returns + ------- + output : numpy.ndarray, shape (N,) + The dot product of `ab` and `x`. + + """ + if lower: + output = _banded_dot_vector_lower(ab, x) + else: + rows, columns = ab.shape + bands = rows // 2 + output = _banded_dot_vector_full(ab, x, (bands, bands), (columns, columns)) + + return output + + def difference_matrix(data_size, diff_order=2, diff_format=None): """ Creates an n-th order finite-difference matrix. @@ -989,7 +1113,7 @@ def factorized_solve(self, factorization, rhs, overwrite_b=False, check_finite=F return output - def effective_dimension(self, weights=None, penalty=None): + def effective_dimension(self, weights=None, penalty=None, n_samples=0): """ Calculates the effective dimension from the trace of the hat matrix. @@ -1006,6 +1130,10 @@ def effective_dimension(self, weights=None, penalty=None): The finite difference penalty matrix, in LAPACK's lower banded format if `self.lower` is True or the full banded if `self.lower` is False. Default is None, which uses the object's penalty. + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (N, `n_samples`) Rademacher random variables + (eg. either -1 or 1). Returns ------- @@ -1013,10 +1141,22 @@ def effective_dimension(self, weights=None, penalty=None): The trace of the hat matrix, denoting the effective dimension for the system. + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + References ---------- Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + """ if weights is None: weights = np.ones(self._num_bases) @@ -1035,18 +1175,43 @@ def effective_dimension(self, weights=None, penalty=None): # -> worth the effort? Could it be extended to work for any diff_order as long as the # matrix is symmetric? - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - # note to self: sparse factorization is the worst case scenario (non-symmetric lhs and - # diff_order != 2), but it is still much faster than individual solves through - # solve_banded - eye = np.zeros(self._num_bases) - trace = 0 - factorization = self.factorize(penalty) - for i in range(self._num_bases): - eye[i] = weights[i] - trace += self.factorized_solve(factorization, eye)[i] - eye[i] = 0 + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on data size; data size > 1000 use stochastic with default + # n_samples = 100? + if n_samples == 0: + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a positive integer') + use_analytic = False + + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + # note to self: sparse factorization is the worst case scenario (non-symmetric lhs and + # diff_order != 2), but it is still much faster than individual solves through + # solve_banded + eye = np.zeros(self._num_bases) + trace = 0 + factorization = self.factorize(penalty) + for i in range(self._num_bases): + eye[i] = weights[i] + trace += self.factorized_solve(factorization, eye)[i] + eye[i] = 0 + + else: + # TODO should the rng seed be settable? Maybe a Baseline property + rng_samples = np.random.default_rng(1234).choice( + [-1., 1.], size=(self._num_bases, n_samples) + ) + # H @ u == (W + lam * P)^-1 @ (w * u) + hat_u = self.solve(penalty, weights[:, None] * rng_samples, overwrite_b=True) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(A.T @ B) == (A * B).sum() (see + # https://en.wikipedia.org/wiki/Trace_(linear_algebra)#Trace_of_a_product ), + # with the latter using less memory and being much faster to compute; for future + # reference: einsum('ij,ij->', A, B) == (A * B).sum(), but is typically faster + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples if reset_penalty: # remove the weights self.penalty[self.main_diagonal_index] = self.main_diagonal diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 6fa63eb3..990b2714 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -49,7 +49,8 @@ from scipy.interpolate import BSpline from ._banded_utils import ( - PenalizedSystem, _add_diagonals, _banded_to_sparse, _lower_to_full, _sparse_to_banded + PenalizedSystem, _add_diagonals, _banded_dot_vector, _banded_to_sparse, _lower_to_full, + _sparse_to_banded ) from ._compat import _HAS_NUMBA, csr_object, dia_object, jit from ._validation import _check_array @@ -968,7 +969,7 @@ def _make_btwb(self, weights): return btwb - def effective_dimension(self, weights=None, penalty=None): + def effective_dimension(self, weights=None, penalty=None, n_samples=0): """ Calculates the effective dimension from the trace of the hat matrix. @@ -987,6 +988,10 @@ def effective_dimension(self, weights=None, penalty=None): The finite difference penalty matrix, in LAPACK's lower banded format if `self.lower` is True or the full banded if `self.lower` is False. Default is None, which uses the object's penalty. + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (M, `n_samples`) Rademacher random variables + (eg. either -1 or 1). Returns ------- @@ -994,11 +999,23 @@ def effective_dimension(self, weights=None, penalty=None): The trace of the hat matrix, denoting the effective dimension for the system. + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + References ---------- Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical Science, 1996, 11(2), 89-121. + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + """ if weights is None: weights = np.ones(self._num_bases) @@ -1008,15 +1025,43 @@ def effective_dimension(self, weights=None, penalty=None): penalty = self.penalty lhs = _add_diagonals(btwb, penalty, self.lower) - - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - trace = 0 - btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format='csc') - factorization = self.factorize(lhs, overwrite_ab=True) - for i in range(self._num_bases): - trace += self.factorized_solve( - factorization, btwb_matrix[:, i].toarray(), overwrite_b=True - )[i] + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on data size; data size > 1000 use stochastic with default + # n_samples = 100? + if n_samples == 0: + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a positive integer') + use_analytic = False + + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format='csc') + factorization = self.factorize(lhs, overwrite_ab=True) + for i in range(self._num_bases): + trace += self.factorized_solve( + factorization, btwb_matrix[:, i].toarray(), overwrite_b=True + )[i] + else: + # TODO should the rng seed be settable? Maybe a Baseline property + rng_samples = np.random.default_rng(1234).choice( + [-1., 1.], size=(self._num_bases, n_samples) + ) + # (B.T @ W @ B) @ u + btwb_u = np.empty((self._num_bases, n_samples)) + for i in range(n_samples): + btwb_u[:, i] = _banded_dot_vector(btwb, rng_samples[:, i], lower=self.lower) + # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + hat_u = self.solve(lhs, btwb_u, overwrite_ab=True, overwrite_b=True) + # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(A.T @ B) == (A * B).sum() (see + # https://en.wikipedia.org/wiki/Trace_(linear_algebra)#Trace_of_a_product ), + # with the latter using less memory and being much faster to compute; for future + # reference: einsum('ij,ij->', A, B) == (A * B).sum(), but is typically faster + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples return trace diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index bd4e9e15..df679081 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -1032,6 +1032,59 @@ def test_penalized_system_effective_dimension(diff_order, allow_lower, allow_pen assert_allclose(output, expected_ed, rtol=1e-7, atol=1e-10) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('allow_penta', (True, False)) +@pytest.mark.parametrize('size', (100, 501)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_penalized_system_effective_dimension_stochastic(diff_order, allow_lower, allow_penta, + size, n_samples): + """ + Tests the stochastic effective_dimension calculation of a PenalizedSystem object. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, size) + weights = np.clip(weights, 0, 1).astype(float) + + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] + expected_penalty = _banded_utils.diff_penalty_diagonals( + size, diff_order=diff_order, lower_only=False + ) + sparse_penalty = dia_object( + (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), + shape=(size, size) + ).tocsr() + weights_matrix = diags(weights, format='csc') + factorization = factorized(weights_matrix + sparse_penalty) + expected_ed = 0 + for i in range(size): + expected_ed += factorization(weights_matrix[:, i].toarray())[i] + + penalized_system = _banded_utils.PenalizedSystem( + size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + output = penalized_system.effective_dimension(weights, n_samples=n_samples) + + assert_allclose(output, expected_ed, rtol=5e-1, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_penalized_system_effective_dimension_stochastic_invalid_samples(data_fixture, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, y = data_fixture + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + penalized_system = _banded_utils.PenalizedSystem(x.size) + with pytest.raises(TypeError): + penalized_system.effective_dimension(weights, n_samples=n_samples) + + @pytest.mark.parametrize('dtype', (float, np.float32)) def test_sparse_to_banded(dtype): """Tests basic functionality of _sparse_to_banded.""" @@ -1430,3 +1483,17 @@ def test_banded_to_sparse_formats(form, lower): output = _banded_utils._banded_to_sparse(banded_matrix, lower=lower, sparse_format=form) assert output.format == form + + +@pytest.mark.parametrize('lower', (True, False)) +@pytest.mark.parametrize('size', (50, 201)) +def test_banded_dot_vector(lower, size): + """Ensures correctness of the dot product of banded matrices with a vector.""" + matrix = _banded_utils.diff_penalty_matrix(size, diff_order=2) + banded_matrix = _banded_utils.diff_penalty_diagonals(size, diff_order=2, lower_only=lower) + vector = np.random.default_rng(123).normal(10, 5, size) + + expected = matrix @ vector + output = _banded_utils._banded_dot_vector(banded_matrix, vector, lower=lower) + + assert_allclose(output, expected, rtol=5e-14, atol=1e-14) diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 5d61019d..5916e583 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -395,6 +395,62 @@ def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, dif assert_allclose(output, expected_ed, rtol=1e-10, atol=1e-12) +@pytest.mark.parametrize('num_knots', (20, 101)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) +@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) +@pytest.mark.parametrize('lower_only', (True, False)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_pspline_stochastic_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, + lower_only, n_samples): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) + basis = _spline_utils._spline_basis(x, knots, spline_degree) + num_bases = basis.shape[1] + penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) + + btwb = basis.T @ diags(weights, format='csr') @ basis + factorization = factorized(btwb + penalty_matrix) + expected_ed = 0 + for i in range(num_bases): + expected_ed += factorization(btwb[:, i].toarray())[i] + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + + output = pspline.effective_dimension(weights, n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=5e-2, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_pspline_stochastic_effective_dimension_invalid_samples(data_fixture, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) + weights = np.clip(weights, 0, 1).astype(float) + + spline_basis = _spline_utils.SplineBasis(x) + pspline = _spline_utils.PSpline(spline_basis) + with pytest.raises(TypeError): + pspline.effective_dimension(weights, n_samples=n_samples) + + def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, allow_lower, reverse_diags, spline_degree, num_knots, data_size): From 5448e57c066c3907f9785ed118e77e414b08bbb9 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Mon, 20 Oct 2025 18:54:56 -0400 Subject: [PATCH 10/38] TEST: Fix test logic for penalized system factorized_solve --- tests/test_banded_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index df679081..dd6e0c34 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -977,8 +977,10 @@ def test_penalized_system_factorize_solve(data_fixture, diff_order, allow_lower, penalized_system.add_diagonal(weights) output_factorization = penalized_system.factorize(penalized_system.penalty) - if allow_lower or (allow_penta and diff_order == 2): - if allow_penta and diff_order == 2: + + using_penta = allow_penta and diff_order == 2 and _banded_utils._HAS_NUMBA + if allow_lower or using_penta: + if using_penta: expected_factorization = penta_factorize( penalized_system.penalty, solver=penalized_system.penta_solver ) From eafbf709b5afba99a763b64b7cd9d3877d43800c Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:47:24 -0400 Subject: [PATCH 11/38] OTHER: Return fidelity and penalty terms from beads Allows beads to be incorporated into optimization frameworks. Also updated the beads documentation to add more details. --- docs/algorithms/algorithms_1d/misc.rst | 68 +++++++++++++++++------ pybaselines/misc.py | 74 +++++++++++++++++++++----- tests/test_misc.py | 2 +- 3 files changed, 112 insertions(+), 32 deletions(-) diff --git a/docs/algorithms/algorithms_1d/misc.rst b/docs/algorithms/algorithms_1d/misc.rst index 08b8f3f3..614bea79 100644 --- a/docs/algorithms/algorithms_1d/misc.rst +++ b/docs/algorithms/algorithms_1d/misc.rst @@ -78,7 +78,45 @@ beads (Baseline Estimation And Denoising with Sparsity) :meth:`~.Baseline.beads` decomposes the input data into baseline and pure, noise-free signal by modeling the baseline as a low pass filter and by considering the signal and its derivatives -as sparse. +as sparse. The minimized equation for calculating the pure signal is given as: + +.. math:: + + \frac{1}{2} ||H(y - s)||_2^2 + + \lambda_0 \sum\limits_{i}^{N} \theta(s_i) + + \lambda_1 \sum\limits_{i}^{N - 1} \phi(\Delta^1 s_i) + + \lambda_2 \sum\limits_{i}^{N - 2} \phi(\Delta^2 s_i) + +where :math:`y` is the measured data, :math:`s` is the calculated pure signal, +:math:`H` is a high pass filter, :math:`\theta()` is a differentiable, symmetric +or asymmetric penalty function on the calculated signal, :math:`\Delta^1` and :math:`\Delta^2` +are :ref:`finite-difference operators ` of order 1 +and 2, respectively, and :math:`\phi()` is a differentiable, symmetric penalty function +approximating the L1 loss (mean absolute error) applied to the first and second derivatives +of the calculated signal. + +The calculated baseline, :math:`v`, upon convergence of calculating the pure signal is given by: + +.. math:: + + v = y - s - H(y - s) + +pybaselines version 1.3.0 introduced an optional simplification of the :math:`\lambda_0`, +:math:`\lambda_1`, :math:`\lambda_2` regularization parameter selection using the procedure +recommended by the BEADS manuscript through the addition of the parameter :math:`\alpha`. +Briefly, it is assumed that each :math:`\lambda_d` value is approximately proportional to some +constant :math:`\alpha` divided by the L1 norm of the d'th derivative of the input data such +that: + +.. math:: + + \lambda_0 = \frac{\alpha}{||y||_1}, + \lambda_1 = \frac{\alpha}{||y'||_1}, + \lambda_2 = \frac{\alpha}{||y''||_1} + +Such a parametrization allows varying just :math:`\alpha`, as well as simplified usage +within optimization frameworks to find the best value, as shown by +`Bosten, et al. `_ .. plot:: :align: center @@ -189,22 +227,23 @@ as sparse. x, data, baselines = create_data() baseline_fitter = Baseline(x, check_finite=False) - fit_params = [(3, 3), (0.15, 8), (0.1, 6), (0.25, 8), (0.1, 0.6)] - + fit_params = [ + (500, 6, 0.01), + (0.01, 8, 0.08), + (80, 8, 0.01), + (0.2, 6, 0.04), + (100, 1, 0.01) + ] figure, axes, handles = create_plots(data, baselines) for i, (ax, y) in enumerate(zip(axes, data)): - if i == 0: - freq_cutoff = 0.002 - else: - freq_cutoff = 0.005 - lam_0, asymmetry = fit_params[i] + alpha, asymmetry, freq_cutoff = fit_params[i] baseline, params = baseline_fitter.beads( - y, freq_cutoff=freq_cutoff, lam_0=lam_0, lam_1=0.05, lam_2=0.2, asymmetry=asymmetry + y, freq_cutoff=freq_cutoff, alpha=alpha, asymmetry=asymmetry, tol=1e-3 ) ax.plot(baseline, 'g--') The signal with both noise and baseline removed can also be obtained from the output -of the beads function. +of the beads method by accessing the 'signal' key in the output parameters. .. plot:: :align: center @@ -214,15 +253,10 @@ of the beads function. # to see contents of create_data function, look at the second-to-top-most algorithm's code figure, axes, handles = create_plots(data, baselines) - fit_params = [(3, 3), (0.15, 8), (0.1, 6), (0.25, 8), (0.1, 0.6)] for i, (ax, y) in enumerate(zip(axes, data)): - if i == 0: - freq_cutoff = 0.002 - else: - freq_cutoff = 0.005 - lam_0, asymmetry = fit_params[i] + alpha, asymmetry, freq_cutoff = fit_params[i] baseline, params = baseline_fitter.beads( - y, freq_cutoff=freq_cutoff, lam_0=lam_0, lam_1=0.05, lam_2=0.2, asymmetry=asymmetry + y, freq_cutoff=freq_cutoff, alpha=alpha, asymmetry=asymmetry, tol=1e-3 ) ax.clear() # remove the old plots in the axis diff --git a/pybaselines/misc.py b/pybaselines/misc.py index 493fb827..d6545827 100644 --- a/pybaselines/misc.py +++ b/pybaselines/misc.py @@ -157,7 +157,25 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy Decomposes the input data into baseline and pure, noise-free signal by modeling the baseline as a low pass filter and by considering the signal and its derivatives - as sparse [1]_. + as sparse. The minimized equation for calculating the pure signal is given as: + + .. math:: + + 0.5 * ||H(y - s)||_2^2 + + \lambda_0 \sum\limits_{i}^{N} \theta(s_i) + + \lambda_1 \sum\limits_{i}^{N - 1} \phi(\Delta^1 s_i) + + \lambda_2 \sum\limits_{i}^{N - 2} \phi(\Delta^2 s_i) + + where y is the measured data, s is the calculated pure signal, + :math:`H` is a high pass filter, :math:`\theta()` is a differentiable, symmetric + or asymmetric penalty function on the calculated signal, :math:`\Delta^1` and + :math:`\Delta^2` are finite-difference operators of order 1 and 2, respectively, + and :math:`\phi()` is a differentiable, symmetric penalty function approximating the + L1 loss (mean absolute error) applied to the first and second derivatives of the + calculated signal. + + The calculated baseline, v, upon convergence of calculating the pure signal is + given by :math:`v = y - s - H(y - s)`. Parameters ---------- @@ -200,10 +218,10 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy An integer describing the high pass filter type. The order of the high pass filter is ``2 * filter_type``. Default is 1 (second order filter). cost_function : {2, 1, "l1_v1", "l1_v2"}, optional - An integer or string indicating which approximation of the l1 (absolute value) - penalty to use. 1 or "l1_v1" will use :math:`l(x) = \sqrt{x^2 + \text{eps_1}}` + An integer or string indicating which approximation of the L1 (absolute value) + penalty to use. 1 or "l1_v1" will use :math:`\phi(x) = \sqrt{x^2 + \text{eps_1}}` and 2 (default) or "l1_v2" will use - :math:`l(x) = |x| - \text{eps_1}\log{(|x| + \text{eps_1})}`. + :math:`\phi(x) = |x| - \text{eps_1}\log{(|x| + \text{eps_1})}`. max_iter : int, optional The maximum number of iterations. Default is 50. tol : float, optional @@ -246,13 +264,25 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'fidelity': float + The fidelity term of the final fit, given as :math:`0.5 * ||H(y - s)||_2^2`. + + .. versionadded:: 1.3.0 + + * 'penalty' : tuple[float, float, float] + The penalty terms of the final fit before multiplication with the `lam_d` + terms. These correspond to :math:`\sum\limits_{i}^{N} \theta(s_i)`, + :math:`\sum\limits_{i}^{N - 1} \phi(\Delta^1 s_i)`, and + :math:`\sum\limits_{i}^{N - 2} \phi(\Delta^2 s_i)`, respectively. + + .. versionadded:: 1.3.0 Notes ----- If any of `lam_0`, `lam_1`, or `lam_2` are None, uses the proceedure recommended in [1]_ to base the `lam_d` values on the inverse of the L1 norm values of the `d'th` derivative - of `data`. In detail, it is assumed that `lam_0`, `lam_1`, and `lam_2` are related by - some constant `alpha` such that ``lam_0 = alpha / ||data||_1``, + of `data`. In detail, it is assumed that `lam_0`, `lam_1`, and `lam_2` are approximately + related by some constant `alpha` such that ``lam_0 = alpha / ||data||_1``, ``lam_1 = alpha / ||data'||_1``, and ``lam_2 = alpha / ||data''||_1``. When finding the best parameters for fitting, it is usually best to find the optimal @@ -1036,11 +1066,14 @@ def _sparse_beads(y, freq_cutoff=0.005, lam_0=1.0, lam_1=1.0, lam_2=1.0, asymmet h = B.dot(A_factor.solve(y - x)) d1_x, d2_x = _abs_diff(x, smooth_half_window) abs_x, big_x, theta = _beads_theta(x, asymmetry, eps_0) + fidelity = 0.5 * (h @ h) + d1_loss = _beads_loss(d1_x, use_v2_loss, eps_1).sum() + d2_loss = _beads_loss(d2_x, use_v2_loss, eps_1).sum() cost = ( - 0.5 * h.dot(h) + fidelity + lam_0 * theta - + lam_1 * _beads_loss(d1_x, use_v2_loss, eps_1).sum() - + lam_2 * _beads_loss(d2_x, use_v2_loss, eps_1).sum() + + lam_1 * d1_loss + + lam_2 * d2_loss ) cost_difference = relative_difference(cost_old, cost) tol_history[i] = cost_difference @@ -1051,7 +1084,12 @@ def _sparse_beads(y, freq_cutoff=0.005, lam_0=1.0, lam_1=1.0, lam_2=1.0, asymmet diff = y - x baseline = diff - B.dot(A_factor.solve(diff)) - return baseline, {'signal': x, 'tol_history': tol_history[:i + 1]} + params = { + 'signal': x, 'tol_history': tol_history[:i + 1], 'fidelity': fidelity, + 'penalty': (theta, d1_loss, d2_loss) + } + + return baseline, params def _process_lams(y, alpha, lam_0, lam_1, lam_2): @@ -1273,11 +1311,14 @@ def _banded_beads(y, freq_cutoff=0.005, lam_0=1.0, lam_1=1.0, lam_2=1.0, asymmet solveh_banded(A_lower, y - x, check_finite=False, overwrite_b=True, lower=True), ab_lu, full_shape ) + fidelity = 0.5 * (h @ h) + d1_loss = _beads_loss(d1_x, use_v2_loss, eps_1).sum() + d2_loss = _beads_loss(d2_x, use_v2_loss, eps_1).sum() cost = ( - 0.5 * h.dot(h) + fidelity + lam_0 * theta - + lam_1 * _beads_loss(d1_x, use_v2_loss, eps_1).sum() - + lam_2 * _beads_loss(d2_x, use_v2_loss, eps_1).sum() + + lam_1 * d1_loss + + lam_2 * d2_loss ) cost_difference = relative_difference(cost_old, cost) tol_history[i] = cost_difference @@ -1295,7 +1336,12 @@ def _banded_beads(y, freq_cutoff=0.005, lam_0=1.0, lam_1=1.0, lam_2=1.0, asymmet ) ) - return baseline, {'signal': x, 'tol_history': tol_history[:i + 1]} + params = { + 'signal': x, 'tol_history': tol_history[:i + 1], 'fidelity': fidelity, + 'penalty': (theta, d1_loss, d2_loss) + } + + return baseline, params @_misc_wrapper diff --git a/tests/test_misc.py b/tests/test_misc.py index a1068799..2fc2ddce 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -82,7 +82,7 @@ class TestBeads(MiscTester): """Class for testing beads baseline.""" func_name = 'beads' - checked_keys = ('signal', 'tol_history') + checked_keys = ('signal', 'tol_history', 'fidelity', 'penalty') @pytest.mark.parametrize('use_class', (True, False)) @pytest.mark.parametrize('cost_function', (1, 2, 'l1_v1', 'l1_v2', 'L1_V1')) From 9797132b318a1ac454f6f892d184c73ff5b5f0c7 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:20:49 -0400 Subject: [PATCH 12/38] OTHER: Add effective dimension calc for 2D Whittaker and PSpline Includes both true and stochastic calculations. --- pybaselines/_banded_utils.py | 6 +- pybaselines/_spline_utils.py | 2 +- pybaselines/two_d/_spline_utils.py | 112 +++++++++++-- pybaselines/two_d/_whittaker_utils.py | 226 ++++++++++++++++++++++++-- tests/test_banded_utils.py | 18 +- tests/test_spline_utils.py | 16 +- tests/two_d/test_algorithm_setup.py | 2 +- tests/two_d/test_spline_utils.py | 115 ++++++++++++- tests/two_d/test_whittaker_utils.py | 199 ++++++++++++++++++++++- 9 files changed, 633 insertions(+), 63 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index c3074c8a..3ee7f662 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -734,7 +734,7 @@ def diff_penalty_matrix(data_size, diff_order=2, diff_format='csr'): Raises ------ ValueError - Raised if `diff_order` is greater or equal to `data_size`. + Raised if `diff_order` is not greater than `data_size`. Notes ----- @@ -749,7 +749,7 @@ def diff_penalty_matrix(data_size, diff_order=2, diff_format='csr'): """ if data_size <= diff_order: - raise ValueError('data size must be greater than or equal to the difference order.') + raise ValueError('data size must be greater than the difference order.') penalty_bands = diff_penalty_diagonals(data_size, diff_order, lower_only=False) penalty_matrix = dia_object( (penalty_bands, np.arange(diff_order, -diff_order - 1, -1)), shape=(data_size, data_size), @@ -1118,7 +1118,7 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): Calculates the effective dimension from the trace of the hat matrix. For typical Whittaker smoothing, the linear equation would be - ``(W + lam * P) x = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. + ``(W + lam * P) v = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. The effective dimension for the system can be estimated as the trace of the hat matrix. diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 990b2714..3de6bf41 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -974,7 +974,7 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): Calculates the effective dimension from the trace of the hat matrix. For typical P-spline smoothing, the linear equation would be - ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``z = B @ c``. Then the hat matrix + ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred since it reduces the dimensionality. The effective dimension for the system diff --git a/pybaselines/two_d/_spline_utils.py b/pybaselines/two_d/_spline_utils.py index 3bb52cbe..b8ced22c 100644 --- a/pybaselines/two_d/_spline_utils.py +++ b/pybaselines/two_d/_spline_utils.py @@ -8,7 +8,7 @@ import numpy as np from scipy.sparse import kron -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve from .._compat import csr_object from .._spline_utils import _spline_basis, _spline_knots @@ -281,9 +281,7 @@ def solve(self, y, weights, penalty=None, rhs_extra=None): Solves the linear equation ``(B.T @ W @ B + P) c = B.T @ W @ y`` for the spline coefficients, `c`, given the spline basis, `B`, the weights (diagonal of `W`), the - penalty `P`, and `y`, and returns the resulting spline, ``B @ c``. Attempts to - calculate ``B.T @ W @ B`` and ``B.T @ W @ y`` as a banded system to speed up - the calculation. + penalty `P`, and `y`, and returns the resulting spline, ``B @ c``. Parameters ---------- @@ -291,10 +289,9 @@ def solve(self, y, weights, penalty=None, rhs_extra=None): The y-values for fitting the spline. weights : numpy.ndarray, shape (M, N) The weights for each y-value. - penalty : numpy.ndarray, shape (``M * N``, ``M * N``) - The finite difference penalty matrix, in LAPACK's lower banded format (see - :func:`scipy.linalg.solveh_banded`) if `lower_only` is True or the full banded - format (see :func:`scipy.linalg.solve_banded`) if `lower_only` is False. + penalty : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (``P * Q``, ``P * Q``) + The finite difference penalty matrix. Default is None, which will use the + object's penalty. rhs_extra : float or numpy.ndarray, shape (``M * N``,), optional If supplied, `rhs_extra` will be added to the right hand side (``B.T @ W @ y``) of the equation before solving. Default is None, which adds nothing. @@ -307,10 +304,11 @@ def solve(self, y, weights, penalty=None, rhs_extra=None): Notes ----- - Uses the more efficient algorithm from Eilers's paper, although the memory usage - is higher than the straigtforward method when the number of knots is high; however, - it is significantly faster and memory efficient when the number of knots is lower, - which will be the more typical use case. + Uses the more efficient algorithm from Eilers's paper, as a generalized linear array + model, although the memory usage is higher than the straightforward method when the + number of knots is high since reshaping and transposing cannot be done in sparse format; + however, it is significantly faster and memory efficient when the number of knots is + lower, which will be the more typical use case. References ---------- @@ -372,3 +370,93 @@ def tck(self): raise ValueError('No spline coefficients, need to call "solve_pspline" first.') knots, degree = self.basis.tk return knots, self.coef.reshape(self.basis._num_bases), degree + + def effective_dimension(self, weights=None, penalty=None, n_samples=0): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical P-spline smoothing, the linear equation would be + ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix + would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional + The weights. Default is None, which will use ones. + penalty : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (``P * Q``, ``P * Q``) + The finite difference penalty matrix. Default is None, which will use the + object's penalty. + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables + (eg. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + + References + ---------- + Eilers, P., et al. Fast and compact smoothing on large multidimensional grids. Computational + Statistics and Data Analysis, 2006, 50(1), 61-76. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + if weights is None: + weights = np.ones(self._num_bases) + elif weights.ndim == 1: + weights = weights.reshape((len(self.basis.x), len(self.basis.z))) + + if penalty is None: + penalty = self.penalty + + btwb = self.basis._make_btwb(weights) + + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on data size; data size > 1000 use stochastic with default + # n_samples = 100? + if n_samples == 0: + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a positive integer') + use_analytic = False + + lhs = (btwb + penalty).tocsc(copy=False) + tot_bases = np.prod(self._num_bases) + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + factorization = factorized(lhs) + btwb = btwb.tocsc(copy=False) + for i in range(tot_bases): + trace += factorization(btwb[:, i].toarray())[i] + else: + # TODO should the rng seed be settable? Maybe a Baseline2D property + rng_samples = np.random.default_rng(1234).choice( + [-1., 1.], size=(tot_bases, n_samples) + ) + # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + hat_u = self.direct_solve(lhs, btwb @ rng_samples) + # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + # stochastic trace is the average of the trace of u.T @ H @ u; + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace diff --git a/pybaselines/two_d/_whittaker_utils.py b/pybaselines/two_d/_whittaker_utils.py index f77bcd6b..e1cc86f6 100644 --- a/pybaselines/two_d/_whittaker_utils.py +++ b/pybaselines/two_d/_whittaker_utils.py @@ -11,7 +11,7 @@ import numpy as np from scipy.linalg import eig_banded, eigh_tridiagonal, solve from scipy.sparse import kron -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve from .._banded_utils import diff_penalty_diagonals, diff_penalty_matrix from .._compat import identity @@ -173,7 +173,7 @@ def solve(self, y, weights, penalty=None, rhs_extra=None): weights : numpy.ndarray The weights for each y-value. Will also be added to the diagonal of the penalty. - penalty : numpy.ndarray + penalty : scipy.sparse.spmatrix or scipy.sparse.sparray The penalty to use for solving. Default is None which uses the object's penalty. rhs_extra : float or numpy.ndarray, optional @@ -198,6 +198,22 @@ def solve(self, y, weights, penalty=None, rhs_extra=None): return self.direct_solve(lhs, rhs) def direct_solve(self, lhs, rhs): + """ + Solves the linear system ``lhs @ x = rhs``. + + Parameters + ---------- + lhs : scipy.sparse.spmatrix or scipy.sparse.sparray + The left hand side of the equation. + rhs : numpy.ndarray or scipy.sparse.spmatrix or scipy.sparse.sparray + The right hand side of the equation. + + Returns + ------- + scipy.sparse.spmatrix or scipy.sparse.sparray + The solution to the linear system, with the same shape as `rhs`. + + """ return spsolve(lhs, rhs) def add_diagonal(self, value): @@ -211,7 +227,7 @@ def add_diagonal(self, value): Returns ------- - scipy.sparse.spmatrix + scipy.sparse.spmatrix or scipy.sparse.sparray The penalty matrix with the main diagonal updated. """ @@ -222,6 +238,100 @@ def reset_diagonal(self): """Sets the main diagonal of the penalty matrix back to its original value.""" self.penalty.setdiag(self.main_diagonal) + def effective_dimension(self, weights=None, penalty=None, n_samples=0): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical Whittaker smoothing, the linear equation would be + ``(W + lam * P) x = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. + The effective dimension for the system can be estimated as the trace + of the hat matrix. + + Parameters + ---------- + weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional + The weights. Default is None, which will use ones. + penalty : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (``M * N``, ``M * N``) + The finite difference penalty matrix. Default is None, which will use the + object's penalty. + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables + (eg. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + + References + ---------- + Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on tot_bases; tot_bases > 1000 use stochastic with default + # n_samples = 200? + tot_bases = np.prod(self._num_bases) + if n_samples == 0: + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a positive integer') + use_analytic = False + # TODO should the rng seed be settable? Maybe a Baseline2D property + rng_samples = np.random.default_rng(1234).choice( + [-1., 1.], size=(tot_bases, n_samples) + ) + + if weights is None: + weights = np.ones(tot_bases) + elif weights.ndim == 2: + weights = weights.ravel() + + reset_penalty = False + if penalty is None: + lhs = self.add_diagonal(weights) + reset_penalty = True + else: + penalty.setdiag(penalty.diagonal() + weights) + lhs = penalty + + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + eye = np.zeros(tot_bases) + trace = 0 + factorization = factorized(lhs.tocsc()) + for i in range(tot_bases): + eye[i] = weights[i] + trace += factorization(eye)[i] + eye[i] = 0 + + else: + # H @ u == (W + lam * P)^-1 @ (w * u) + hat_u = self.direct_solve(lhs, weights[:, None] * rng_samples) + # stochastic trace is the average of the trace of u.T @ H @ u; + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + if reset_penalty: + self.add_diagonal(0) + + return trace + class WhittakerSystem2D(PenalizedSystem2D): """ @@ -561,10 +671,9 @@ def solve(self, y, weights, penalty=None, rhs_extra=None, assume_a='pos'): The y-values for fitting the spline. weights : numpy.ndarray, shape (M, N) The weights for each y-value. - penalty : numpy.ndarray, shape (``M * N``, ``M * N``) - The finite difference penalty matrix, in LAPACK's lower banded format (see - :func:`scipy.linalg.solveh_banded`) if `lower_only` is True or the full banded - format (see :func:`scipy.linalg.solve_banded`) if `lower_only` is False. + penalty : numpy.ndarray or scipy.sparse.spmatrix or scipy.sparse.sparray + The finite difference penalty matrix with shape (``M * N``, ``M * N``). Default + is None, which will use the object's penalty. rhs_extra : float or numpy.ndarray, shape (``M * N``,), optional If supplied, `rhs_extra` will be added to the right hand side (``B.T @ W @ y``) of the equation before solving. Default is None, which adds nothing. @@ -577,10 +686,10 @@ def solve(self, y, weights, penalty=None, rhs_extra=None, assume_a='pos'): Notes ----- - Uses the more efficient algorithm from Eilers's paper, although the memory usage - is higher than the straigtforward method when the number of eigenvalues is high; however, - it is significantly faster and memory efficient when the number of eigenvalues is lower, - which will be the more typical use case. + Uses the more efficient algorithm from Eilers's paper, as a generalized linear array + model, although the memory usage is higher than the straightforward method when the + number of eigenvalues is high; however, it is significantly faster and memory efficient + when the number of eigenvalues is lower, which will be the more typical use case. References ---------- @@ -667,7 +776,6 @@ def _calc_dof(self, weights, assume_a='pos'): """ if not self._using_svd: - # Could maybe just output a matrix of ones? raise ValueError( 'Cannot calculate degrees of freedom when not using eigendecomposition' ) @@ -680,3 +788,97 @@ def _calc_dof(self, weights, assume_a='pos'): ) return dof.diagonal().reshape(self._num_bases) + + def effective_dimension(self, weights=None, penalty=None, n_samples=0): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical Whittaker smoothing, the linear equation would be + ``(W + lam * P) v = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. + If using SVD, the linear equation is ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and + ``v = B @ c``. Then the hat matrix would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` + or, equivalently ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression + is preferred since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional + The weights. Default is None, which will use ones. + penalty : numpy.ndarray or scipy.sparse.spmatrix or scipy.sparse.sparray + The finite difference penalty matrix with shape (``M * N``, ``M * N``). Default + is None, which will use the object's penalty. + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables + (eg. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + + Notes + ----- + If using SVD, the trace will be lower than the actual analytical trace. The relative + difference is reduced as the number of eigenvalues selected approaches the data + size. + + References + ---------- + Biessy, G. Whittaker-Henderson smoothing revisited: A modern statistical framework for + practical use. ASTIN Bulletin, 2025, 1-31. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + if not self._using_svd: + return super().effective_dimension(weights, penalty, n_samples) + + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on tot_bases; tot_bases > 1000 use stochastic with default + # n_samples = 200? + tot_bases = np.prod(self._num_bases) + if n_samples == 0: + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a positive integer') + use_analytic = False + # TODO should the rng seed be settable? Maybe a Baseline2D property + rng_samples = np.random.default_rng(1234).choice( + [-1., 1.], size=(tot_bases, n_samples) + ) + + if weights is None: + weights = np.ones(self._num_bases) + elif weights.ndim == 1: + weights = weights.reshape(self._num_points) + + if use_analytic: + trace = self._calc_dof(weights).sum() + else: + btwb = self._make_btwb(weights) + lhs = btwb.copy() + np.fill_diagonal(lhs, lhs.diagonal() + self.penalty) + # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + hat_u = solve( + lhs, btwb @ rng_samples, overwrite_a=True, overwrite_b=True, + check_finite=False, assume_a='pos' + ) + # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u + # stochastic trace is the average of the trace of u.T @ H @ u; + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index dd6e0c34..75c6eba5 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -1029,7 +1029,7 @@ def test_penalized_system_effective_dimension(diff_order, allow_lower, allow_pen size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, reverse_diags=False, allow_penta=allow_penta ) - output = penalized_system.effective_dimension(weights) + output = penalized_system.effective_dimension(weights, n_samples=0) assert_allclose(output, expected_ed, rtol=1e-7, atol=1e-10) @@ -1053,23 +1053,15 @@ def test_penalized_system_effective_dimension_stochastic(diff_order, allow_lower weights = np.clip(weights, 0, 1).astype(float) lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] - expected_penalty = _banded_utils.diff_penalty_diagonals( - size, diff_order=diff_order, lower_only=False - ) - sparse_penalty = dia_object( - (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), - shape=(size, size) - ).tocsr() - weights_matrix = diags(weights, format='csc') - factorization = factorized(weights_matrix + sparse_penalty) - expected_ed = 0 - for i in range(size): - expected_ed += factorization(weights_matrix[:, i].toarray())[i] penalized_system = _banded_utils.PenalizedSystem( size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, reverse_diags=False, allow_penta=allow_penta ) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + expected_ed = penalized_system.effective_dimension(weights, n_samples=0) + output = penalized_system.effective_dimension(weights, n_samples=n_samples) assert_allclose(output, expected_ed, rtol=5e-1, atol=1e-5) diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 5916e583..852d7a85 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -391,7 +391,7 @@ def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, dif spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only ) - output = pspline.effective_dimension(weights) + output = pspline.effective_dimension(weights, n_samples=0) assert_allclose(output, expected_ed, rtol=1e-10, atol=1e-12) @@ -415,23 +415,15 @@ def test_pspline_stochastic_effective_dimension(data_fixture, num_knots, spline_ weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) weights = np.clip(weights, 0, 1).astype(float) - knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) - basis = _spline_utils._spline_basis(x, knots, spline_degree) - num_bases = basis.shape[1] - penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) - - btwb = basis.T @ diags(weights, format='csr') @ basis - factorization = factorized(btwb + penalty_matrix) - expected_ed = 0 - for i in range(num_bases): - expected_ed += factorization(btwb[:, i].toarray())[i] - spline_basis = _spline_utils.SplineBasis( x, num_knots=num_knots, spline_degree=spline_degree ) pspline = _spline_utils.PSpline( spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only ) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + expected_ed = pspline.effective_dimension(weights, n_samples=0) output = pspline.effective_dimension(weights, n_samples=n_samples) assert_allclose(output, expected_ed, rtol=5e-2, atol=1e-5) diff --git a/tests/two_d/test_algorithm_setup.py b/tests/two_d/test_algorithm_setup.py index 6423b35e..3fb29ea3 100644 --- a/tests/two_d/test_algorithm_setup.py +++ b/tests/two_d/test_algorithm_setup.py @@ -391,7 +391,7 @@ def test_setup_spline_spline_basis(data_fixture2d, num_knots, spline_degree): @pytest.mark.parametrize('lam', (1, 20, (3, 10))) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4, (2, 3))) @pytest.mark.parametrize('spline_degree', (1, 2, 3, 4, (2, 3))) -@pytest.mark.parametrize('num_knots', (20, 51, (20, 30))) +@pytest.mark.parametrize('num_knots', (20, (21, 30))) def test_setup_spline_diff_matrix(data_fixture2d, lam, diff_order, spline_degree, num_knots): """Ensures output difference matrix diagonal data is in desired format.""" x, z, y = data_fixture2d diff --git a/tests/two_d/test_spline_utils.py b/tests/two_d/test_spline_utils.py index 7cf66c06..622a36ca 100644 --- a/tests/two_d/test_spline_utils.py +++ b/tests/two_d/test_spline_utils.py @@ -11,19 +11,19 @@ import pytest from scipy import interpolate from scipy.sparse import issparse, kron -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve from pybaselines._compat import identity +from pybaselines._banded_utils import difference_matrix, diff_penalty_matrix from pybaselines.two_d import _spline_utils -from pybaselines.utils import difference_matrix from ..base_tests import get_2dspline_inputs -@pytest.mark.parametrize('num_knots', (10, 40, (10, 20))) +@pytest.mark.parametrize('num_knots', (10, (11, 20))) @pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5, (2, 3))) @pytest.mark.parametrize('diff_order', (1, 2, 3, 4, (2, 3))) -@pytest.mark.parametrize('lam', (1e-2, 1e2, (1e1, 1e2))) +@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) def test_solve_psplines(data_fixture2d, num_knots, spline_degree, diff_order, lam): """ Tests the accuracy of the penalized spline solvers. @@ -87,7 +87,7 @@ def test_solve_psplines(data_fixture2d, num_knots, spline_degree, diff_order, la @pytest.mark.parametrize('spline_degree', (1, 2, 3, [2, 3])) -@pytest.mark.parametrize('num_knots', (10, 50, [20, 30])) +@pytest.mark.parametrize('num_knots', (16, [21, 30])) @pytest.mark.parametrize('diff_order', (1, 2, 3, [1, 3])) @pytest.mark.parametrize('lam', (5, (3, 5))) def test_pspline_setup(data_fixture2d, num_knots, spline_degree, diff_order, lam): @@ -271,7 +271,7 @@ def test_pspline_tck_none(data_fixture2d): def test_pspline_tck_readonly(data_fixture2d): """Ensures the tck attribute is read-only.""" x, z, y = data_fixture2d - spline_basis = _spline_utils.SplineBasis2D(x, z) + spline_basis = _spline_utils.SplineBasis2D(x, z, num_knots=10) pspline = _spline_utils.PSpline2D(spline_basis) with pytest.raises(AttributeError): @@ -325,3 +325,106 @@ def test_spline_basis_tk_readonly(data_fixture2d): with pytest.raises(AttributeError): spline_basis.tk = (1, 2) + + +@pytest.mark.parametrize('num_knots', (10, (11, 20))) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, (2, 3))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) +def test_pspline_effective_dimension(data_fixture2d, num_knots, spline_degree, diff_order, lam): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, z, y = data_fixture2d + ( + num_knots_r, num_knots_c, spline_degree_r, spline_degree_c, + lam_r, lam_c, diff_order_r, diff_order_c + ) = get_2dspline_inputs(num_knots, spline_degree, lam, diff_order) + + knots_r = _spline_utils._spline_knots(x, num_knots_r, spline_degree_r, True) + basis_r = _spline_utils._spline_basis(x, knots_r, spline_degree_r) + + knots_c = _spline_utils._spline_knots(z, num_knots_c, spline_degree_c, True) + basis_c = _spline_utils._spline_basis(z, knots_c, spline_degree_c) + + num_bases = (basis_r.shape[1], basis_c.shape[1]) + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = _spline_utils.SplineBasis2D( + x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False + ) + # make B.T @ W @ B using generalized linear array model since it's much faster and + # already verified in other tests + btwb = spline_basis._make_btwb(weights).tocsc() + P_r = kron(diff_penalty_matrix(num_bases[0], diff_order=diff_order_r), identity(num_bases[1])) + P_c = kron(identity(num_bases[0]), diff_penalty_matrix(num_bases[1], diff_order=diff_order_c)) + penalty = lam_r * P_r + lam_c * P_c + + lhs = btwb + penalty + factorization = factorized(lhs.tocsc()) + expected_ed = 0 + for i in range(np.prod(num_bases)): + expected_ed += factorization(btwb[:, i].toarray())[i] + + pspline = _spline_utils.PSpline2D(spline_basis, lam=lam, diff_order=diff_order) + + # ensure weights work both raveled and unraveled + output = pspline.effective_dimension(weights, n_samples=0) + assert_allclose(output, expected_ed, rtol=1e-14, atol=1e-10) + + output2 = pspline.effective_dimension(weights.ravel(), n_samples=0) + assert_allclose(output2, expected_ed, rtol=1e-14, atol=1e-10) + + +@pytest.mark.parametrize('num_knots', (10, (11, 20))) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, (2, 3))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_pspline_effective_dimension_stochastic(data_fixture2d, num_knots, spline_degree, + diff_order, lam, n_samples): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = _spline_utils.SplineBasis2D( + x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False + ) + pspline = _spline_utils.PSpline2D(spline_basis, lam=lam, diff_order=diff_order) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + expected_ed = pspline.effective_dimension(weights, n_samples=0) + + # ensure weights work both raveled and unraveled + output = pspline.effective_dimension(weights, n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=1e-1, atol=1e-5) + + output2 = pspline.effective_dimension(weights.ravel(), n_samples=n_samples) + assert_allclose(output2, expected_ed, rtol=1e-1, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_pspline_stochastic_effective_dimension_invalid_samples(data_fixture2d, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.size) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = _spline_utils.SplineBasis2D(x, z, num_knots=10) + pspline = _spline_utils.PSpline2D(spline_basis) + with pytest.raises(TypeError): + pspline.effective_dimension(weights, n_samples=n_samples) diff --git a/tests/two_d/test_whittaker_utils.py b/tests/two_d/test_whittaker_utils.py index f8dbaf72..4f1fe306 100644 --- a/tests/two_d/test_whittaker_utils.py +++ b/tests/two_d/test_whittaker_utils.py @@ -11,10 +11,10 @@ import pytest from scipy.linalg import eig_banded, solve from scipy.sparse import issparse, kron -from scipy.sparse.linalg import spsolve +from scipy.sparse.linalg import factorized, spsolve -from pybaselines._banded_utils import diff_penalty_diagonals -from pybaselines._compat import dia_object, identity +from pybaselines._banded_utils import diff_penalty_diagonals, diff_penalty_matrix +from pybaselines._compat import dia_object, diags, identity from pybaselines.two_d import _spline_utils, _whittaker_utils from pybaselines.utils import difference_matrix @@ -588,3 +588,196 @@ def test_whittaker_system_update_penalty(data_fixture2d, num_eigens, diff_order, whittaker_system.penalty, (new_penalty_rows + new_penalty_cols).diagonal(), rtol=1e-12, atol=1e-12 ) + + +@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +def test_penalized_system_effective_dimension(shape, diff_order, lam): + """ + Tests the effective_dimension method of PenalizedSystem2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + *_, lam_r, lam_c, diff_order_r, diff_order_c = get_2dspline_inputs( + lam=lam, diff_order=diff_order + ) + + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + P_r = lam_r * kron(diff_penalty_matrix(shape[0], diff_order=diff_order_r), identity(shape[1])) + P_c = lam_c * kron(identity(shape[0]), diff_penalty_matrix(shape[1], diff_order=diff_order_c)) + penalty = P_r + P_c + + weights_matrix = diags(weights.ravel(), format='csc') + factorization = factorized(weights_matrix + penalty) + expected_ed = 0 + for i in range(np.prod(shape)): + expected_ed += factorization(weights_matrix[:, i].toarray())[i] + + penalized_system = _whittaker_utils.PenalizedSystem2D(shape, lam=lam, diff_order=diff_order) + + # ensure weights work both raveled and unraveled + output = penalized_system.effective_dimension(weights, n_samples=0) + assert_allclose(output, expected_ed, rtol=1e-14, atol=1e-10) + + output2 = penalized_system.effective_dimension(weights.ravel(), n_samples=0) + assert_allclose(output2, expected_ed, rtol=1e-14, atol=1e-10) + + +@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_penalized_system_effective_dimension_stochastic(shape, diff_order, lam, + n_samples): + """ + Tests the stochastic effective_dimension method of PenalizedSystem2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + whittaker_system = _whittaker_utils.PenalizedSystem2D(shape, lam=lam, diff_order=diff_order) + + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + expected_ed = whittaker_system.effective_dimension(weights, n_samples=0) + + # ensure weights work both raveled and unraveled + output = whittaker_system.effective_dimension(weights, n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=1e-1, atol=1e-4) + + output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=n_samples) + assert_allclose(output2, expected_ed, rtol=1e-1, atol=1e-4) + + +@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +@pytest.mark.parametrize('use_svd', (True, False)) +def test_whittaker_system_effective_dimension(shape, diff_order, lam, use_svd): + """ + Tests the effective_dimension method of WhittakerSystem2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + Tests both the analytic and SVD-based solutions. + + """ + *_, lam_r, lam_c, diff_order_r, diff_order_c = get_2dspline_inputs( + lam=lam, diff_order=diff_order + ) + + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + P_r = lam_r * kron(diff_penalty_matrix(shape[0], diff_order=diff_order_r), identity(shape[1])) + P_c = lam_c * kron(identity(shape[0]), diff_penalty_matrix(shape[1], diff_order=diff_order_c)) + penalty = P_r + P_c + + weights_matrix = diags(weights.ravel(), format='csc') + factorization = factorized(weights_matrix + penalty) + expected_ed = 0 + for i in range(np.prod(shape)): + expected_ed += factorization(weights_matrix[:, i].toarray())[i] + + # the relative error on the trace when using SVD decreases as the number of + # eigenvalues approaches the data size, so just test with a value very close + if use_svd: + num_eigens = (shape[0] - 1, shape[1] - 1) + atol = 1e-1 + rtol = 5e-2 + else: + num_eigens = None + atol = 1e-10 + rtol = 1e-14 + whittaker_system = _whittaker_utils.WhittakerSystem2D( + shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens + ) + + # ensure weights work both raveled and unraveled + output = whittaker_system.effective_dimension(weights, n_samples=0) + assert_allclose(output, expected_ed, rtol=rtol, atol=atol) + + output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=0) + assert_allclose(output2, expected_ed, rtol=rtol, atol=atol) + + +@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +@pytest.mark.parametrize('use_svd', (True, False)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_whittaker_system_effective_dimension_stochastic(shape, diff_order, lam, use_svd, + n_samples): + """ + Tests the stochastic effective_dimension method of WhittakerSystem2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + Tests both the analytic and SVD-based solutions. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + if use_svd: + num_eigens = (shape[0] - 1, shape[1] - 1) + rtol = 1e-2 + else: + num_eigens = None + rtol = 1e-1 + + whittaker_system = _whittaker_utils.WhittakerSystem2D( + shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens + ) + + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + expected_ed = whittaker_system.effective_dimension(weights, n_samples=0) + + # ensure weights work both raveled and unraveled + output = whittaker_system.effective_dimension(weights, n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-4) + + output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=n_samples) + assert_allclose(output2, expected_ed, rtol=rtol, atol=1e-4) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_penalized_system_effective_dimension_stochastic_invalid_samples(small_data2d, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + weights = np.random.default_rng(0).normal(0.8, 0.05, small_data2d.shape) + weights = np.clip(weights, 0, 1).astype(float) + + penalized_system = _whittaker_utils.PenalizedSystem2D(small_data2d.shape) + with pytest.raises(TypeError): + penalized_system.effective_dimension(weights, n_samples=n_samples) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_whittaker_system_effective_dimension_stochastic_invalid_samples(small_data2d, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + weights = np.random.default_rng(0).normal(0.8, 0.05, small_data2d.shape) + weights = np.clip(weights, 0, 1).astype(float) + + penalized_system = _whittaker_utils.WhittakerSystem2D(small_data2d.shape, num_eigens=None) + with pytest.raises(TypeError): + penalized_system.effective_dimension(weights, n_samples=n_samples) + + penalized_system = _whittaker_utils.WhittakerSystem2D(small_data2d.shape, num_eigens=5) + with pytest.raises(TypeError): + penalized_system.effective_dimension(weights, n_samples=n_samples) From 8008c6278e70e8e58a509d113cc41575a6b8d8f3 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:52:26 -0500 Subject: [PATCH 13/38] MAINT: Ensure banded to sparse calc work for 1d array --- pybaselines/_banded_utils.py | 19 ++++++++++++++----- tests/test_banded_utils.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index 3ee7f662..e862f4f0 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -87,6 +87,7 @@ def _lower_to_full(ab): The full, symmetric banded array. """ + ab = np.atleast_2d(ab) ab_rows, ab_columns = ab.shape ab_full = np.concatenate((np.zeros((ab_rows - 1, ab_columns)), ab)) ab_full[:ab_rows - 1] = ab[1:][::-1] @@ -266,6 +267,7 @@ def _banded_to_sparse(ab, lower=True, sparse_format='csc'): and lower diagonals. """ + ab = np.atleast_2d(ab) rows, columns = ab.shape if lower: ab_full = _lower_to_full(ab) @@ -1169,11 +1171,18 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): # TODO if diff_order is 2 and matrix is symmetric, could use the fast trace calculation from # Frasso G, Eilers PH. L- and V-curves for optimal smoothing. Statistical Modelling. - # 2014;15(1):91-111. https://doi.org/10.1177/1471082X14549288, which is in turn based on - # Craven, P., Wahba, G. Smoothing noisy data with spline functions. Numerische Mathematik. - # 31, 377–403 (1978). https://doi.org/10.1007/BF01404567 - # -> worth the effort? Could it be extended to work for any diff_order as long as the - # matrix is symmetric? + # (2014), 15(1), 91-111. https://doi.org/10.1177/1471082X14549288, which is in turn based on + # Hutchinson, M, et al. Smoothing noisy data with spline functions. Numerische Mathematik. + # (1985), 47, 99-106. https://doi.org/10.1007/BF01389878 + # For non-symmetric matrices, can use the slightly more involved algorithm from: + # Erisman, A., et al. On Computing Certain Elements of the Inverse of a Sparse Matrix. + # Communication of the ACM. (1975) 18(3), 177-179. https://doi.org/10.1145/360680.360704 + # -> worth the effort? -> maybe...? For diff_order=2 and symmetric lhs, the timing is + # much faster than even the stochastic calculation and does not increase much with data + # size, and it provides the exact trace rather than an estimate -> however, this is only + # useful for GCV/BIC calculations atm, which are going to be very very rarely used -> could + # allow calculating the full inverse hat diagonal to allow calculating the baseline fit + # errors, but that's still incredibly niche... # TODO could maybe make default n_samples to None and decide to use analytical or # stochastic trace based on data size; data size > 1000 use stochastic with default diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index 75c6eba5..339e8fe1 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -276,6 +276,22 @@ def test_lower_to_full(data_fixture, num_knots, spline_degree): assert_allclose(_banded_utils._lower_to_full(BTWB_lower), BTWB_full, 1e-10, 1e-14) +@pytest.mark.parametrize('size', (100, 1001)) +def test_lower_to_full_diagonal(size): + """Ensures correct usage for a matrix with only a diagonal.""" + # test both a 1d and 2d input + array = np.linspace(-1, 1, size) + array_2d = array.reshape((1, size)) + + expected = array_2d.copy() + + output = _banded_utils._lower_to_full(array) + output_2d = _banded_utils._lower_to_full(array_2d) + + assert_allclose(output, expected, rtol=1e-14, atol=1e-14) + assert_allclose(output_2d, expected, rtol=1e-14, atol=1e-14) + + @pytest.mark.parametrize('padding', (-1, 0, 1, 2)) @pytest.mark.parametrize('lower_only', (True, False)) def test_pad_diagonals(padding, lower_only): @@ -1469,6 +1485,23 @@ def test_banded_to_sparse_nonsymmetric(diff_order, size): assert_allclose(output.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) +@pytest.mark.parametrize('size', (100, 1001)) +@pytest.mark.parametrize('lower', (True, False)) +def test_banded_to_sparse_diagonal(size, lower): + """Ensures correct conversion for a matrix with only a diagonal.""" + # test both a 1d and 2d input + array = np.linspace(-1, 1, size) + array_2d = array.reshape((1, size)) + + expected_matrix = diags(array) + + output = _banded_utils._banded_to_sparse(array, lower=lower) + output_2d = _banded_utils._banded_to_sparse(array_2d, lower=lower) + + assert_allclose(output.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) + assert_allclose(output_2d.toarray(), expected_matrix.toarray(), rtol=1e-14, atol=1e-14) + + @pytest.mark.parametrize('form', ('dia', 'csc', 'csr')) @pytest.mark.parametrize('lower', (True, False)) def test_banded_to_sparse_formats(form, lower): From ae31efa2df93dd40bcaebc01b5fc6a89c023e80a Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:53:50 -0500 Subject: [PATCH 14/38] MAINT: Faster stochastic pspline trace calc --- pybaselines/_spline_utils.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 3de6bf41..fcc23963 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -49,8 +49,7 @@ from scipy.interpolate import BSpline from ._banded_utils import ( - PenalizedSystem, _add_diagonals, _banded_dot_vector, _banded_to_sparse, _lower_to_full, - _sparse_to_banded + PenalizedSystem, _add_diagonals, _banded_to_sparse, _lower_to_full, _sparse_to_banded ) from ._compat import _HAS_NUMBA, csr_object, dia_object, jit from ._validation import _check_array @@ -1030,16 +1029,18 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): # n_samples = 100? if n_samples == 0: use_analytic = True + btwb_format = 'csc' else: if n_samples < 0 or not isinstance(n_samples, int): raise TypeError('n_samples must be a positive integer') use_analytic = False + btwb_format = 'csr' + btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format=btwb_format) if use_analytic: # compute each diagonal of the hat matrix separately so that the full # hat matrix does not need to be stored in memory trace = 0 - btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format='csc') factorization = self.factorize(lhs, overwrite_ab=True) for i in range(self._num_bases): trace += self.factorized_solve( @@ -1050,12 +1051,10 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): rng_samples = np.random.default_rng(1234).choice( [-1., 1.], size=(self._num_bases, n_samples) ) - # (B.T @ W @ B) @ u - btwb_u = np.empty((self._num_bases, n_samples)) - for i in range(n_samples): - btwb_u[:, i] = _banded_dot_vector(btwb, rng_samples[:, i], lower=self.lower) # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - hat_u = self.solve(lhs, btwb_u, overwrite_ab=True, overwrite_b=True) + hat_u = self.solve( + lhs, btwb_matrix @ rng_samples, overwrite_ab=True, overwrite_b=True + ) # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u # stochastic trace is the average of the trace of u.T @ H @ u; # trace(A.T @ B) == (A * B).sum() (see From 35bdad7325c32d287c0435d1342ce9760284d54b Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 16 Nov 2025 12:56:37 -0500 Subject: [PATCH 15/38] DOCS: Fix typo in pspline_iasls equation --- docs/algorithms/algorithms_1d/spline.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/algorithms/algorithms_1d/spline.rst b/docs/algorithms/algorithms_1d/spline.rst index 312f7679..efd7eef5 100644 --- a/docs/algorithms/algorithms_1d/spline.rst +++ b/docs/algorithms/algorithms_1d/spline.rst @@ -328,7 +328,7 @@ Linear system: .. math:: (B^{\mathsf{T}} W^{\mathsf{T}} W B + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1 B + \lambda D_d^{\mathsf{T}} D_d) c - = (B^{\mathsf{T}} W^{\mathsf{T}} W B + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1) y + = (B^{\mathsf{T}} W^{\mathsf{T}} W + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1) y Weighting: From ef2c1d19a3c62005b5f9339dbb8d26f2a635caa2 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 29 Nov 2025 13:16:00 -0500 Subject: [PATCH 16/38] MAINT: Fix sparse indexing for older scipy versions Versions prior to 1.15.0 did not allow 1d indexing for the sparse arrays. --- pybaselines/_compat.py | 60 ++++++++++++++++++++++++++++- pybaselines/_spline_utils.py | 6 +-- pybaselines/two_d/_spline_utils.py | 4 +- tests/test_banded_utils.py | 4 +- tests/test_compat.py | 37 ++++++++++++++++++ tests/test_spline_utils.py | 4 +- tests/two_d/test_spline_utils.py | 4 +- tests/two_d/test_whittaker_utils.py | 6 +-- 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/pybaselines/_compat.py b/pybaselines/_compat.py index 206edd36..2317beb8 100644 --- a/pybaselines/_compat.py +++ b/pybaselines/_compat.py @@ -52,7 +52,7 @@ def wrapper(*args, **kwargs): trapezoid = integrate.trapz -@lru_cache(maxsize=1) +@lru_cache(maxsize=None) def _use_sparse_arrays(): """ Checks that the installed SciPy version is new enough to use sparse arrays. @@ -84,7 +84,7 @@ def _use_sparse_arrays(): return _scipy_version[0] > 1 or (_scipy_version[0] == 1 and _scipy_version[1] >= 12) -@lru_cache(maxsize=1) +@lru_cache(maxsize=None) def _np_ge_2(): """ Checks that the installed NumPy version is version 2.0 or later. @@ -222,3 +222,59 @@ def diags(data, offsets=0, dtype=None, **kwargs): return sparse.diags_array(data, offsets=offsets, dtype=dtype, **kwargs) else: return sparse.diags(data, offsets=offsets, dtype=dtype, **kwargs) + + +@lru_cache(maxsize=None) +def _allows_1d_slice(): + """ + Determines whether 1d slicing is allowed for SciPy's sparse arrays. + + Returns + ------- + can_1d_slice : bool + Whether 1d slicing is allowed for the SciPy version being used. + + Notes + ----- + An equivalent function would be checking that the SciPy version is at least 1.15.0. + + """ + try: + diags([1]).tocsc()[:, 0] + can_1d_slice = True + except NotImplementedError: + can_1d_slice = False + + return can_1d_slice + + +def _sparse_col_index(matrix, index): + """ + Indexes a column within a sparse matrix or array. + + Parameters + ---------- + matrix : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (M, N) + The sparse object to index. + index : int + The column to select. + + Returns + ------- + output : numpy.ndarray, shape (M,) + The selected column from the sparse object. + + """ + if not matrix.format == 'csc': + matrix = matrix.tocsc() + + if sparse.isspmatrix(matrix) or not _allows_1d_slice(): + # for sparse matrices, both matrix[:, [i]] and matrix[:, i] produce the + # same 2d output + output = matrix[:, [index]].toarray().ravel() + else: + # when allowed, want to use non-fancy indexing since it should be faster + # for sparse arrays + output = matrix[:, index].toarray() + + return output diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index fcc23963..76474fbe 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -51,7 +51,7 @@ from ._banded_utils import ( PenalizedSystem, _add_diagonals, _banded_to_sparse, _lower_to_full, _sparse_to_banded ) -from ._compat import _HAS_NUMBA, csr_object, dia_object, jit +from ._compat import _HAS_NUMBA, _sparse_col_index, csr_object, dia_object, jit from ._validation import _check_array @@ -348,7 +348,7 @@ def _spline_knots(x, num_knots=10, spline_degree=3, penalized=True): return knots -@lru_cache(maxsize=1) +@lru_cache(maxsize=None) def _bspline_has_extrapolate(): """ Checks if ``scipy.interpolate.BSpline.design_matrix`` has the `extrapolate` keyword. @@ -1044,7 +1044,7 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): factorization = self.factorize(lhs, overwrite_ab=True) for i in range(self._num_bases): trace += self.factorized_solve( - factorization, btwb_matrix[:, i].toarray(), overwrite_b=True + factorization, _sparse_col_index(btwb_matrix, i), overwrite_b=True )[i] else: # TODO should the rng seed be settable? Maybe a Baseline property diff --git a/pybaselines/two_d/_spline_utils.py b/pybaselines/two_d/_spline_utils.py index b8ced22c..92f39c81 100644 --- a/pybaselines/two_d/_spline_utils.py +++ b/pybaselines/two_d/_spline_utils.py @@ -10,7 +10,7 @@ from scipy.sparse import kron from scipy.sparse.linalg import factorized, spsolve -from .._compat import csr_object +from .._compat import _sparse_col_index, csr_object from .._spline_utils import _spline_basis, _spline_knots from .._validation import _check_array, _check_scalar_variable from ._whittaker_utils import PenalizedSystem2D, _face_splitting @@ -447,7 +447,7 @@ def effective_dimension(self, weights=None, penalty=None, n_samples=0): factorization = factorized(lhs) btwb = btwb.tocsc(copy=False) for i in range(tot_bases): - trace += factorization(btwb[:, i].toarray())[i] + trace += factorization(_sparse_col_index(btwb, i))[i] else: # TODO should the rng seed be settable? Maybe a Baseline2D property rng_samples = np.random.default_rng(1234).choice( diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index 339e8fe1..761c3e3d 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -16,7 +16,7 @@ from pybaselines import _banded_utils, _spline_utils from pybaselines._banded_solvers import penta_factorize -from pybaselines._compat import dia_object, diags, identity +from pybaselines._compat import _sparse_col_index, dia_object, diags, identity @pytest.mark.parametrize('data_size', (10, 1001)) @@ -1039,7 +1039,7 @@ def test_penalized_system_effective_dimension(diff_order, allow_lower, allow_pen factorization = factorized(weights_matrix + sparse_penalty) expected_ed = 0 for i in range(size): - expected_ed += factorization(weights_matrix[:, i].toarray())[i] + expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] penalized_system = _banded_utils.PenalizedSystem( size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, diff --git a/tests/test_compat.py b/tests/test_compat.py index 276e3dd5..ac493faf 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -381,3 +381,40 @@ def test_diags(sparse_format, dtype): assert sparse.isspmatrix(output) else: assert not sparse.isspmatrix(output) + + +def test_allow_1d_slice(): + """Uses version checking rather than brute force to ensure sparse slicing is available. + + The actual implementation in pybaselines directly checks if 1d slicing can be done on + sparse matrices, which should be slightly more robust than a simple version check, but + they should match regardless. + + """ + try: + _scipy_version = [int(val) for val in scipy.__version__.lstrip('v').split('.')[:2]] + except Exception as e: + # raise the exception so that version parsing can be changed if needed + raise ValueError('Issue parsing SciPy version') from e + + # sparse 1d slicing was first available in version 1.15.0 + expected = (_scipy_version[0] > 1 or (_scipy_version[0] == 1 and _scipy_version[1] >= 15)) + output = _compat._allows_1d_slice() + + assert expected == output + + +@pytest.mark.parametrize('sparse_format', ('csc', 'csr', 'dia')) +def test_sparse_col_index(sparse_format): + """Ensures sparse matrix column indexing works as expected.""" + matrix = np.arange(20, dtype=float).reshape(5, 4) + sparse_matrix = _compat.csr_object(matrix).asformat(sparse_format) + + expected_shape = (matrix.shape[0],) + for col_index in range(matrix.shape[1]): + expected_col = matrix[:, col_index] + output = _compat._sparse_col_index(sparse_matrix, col_index) + + assert_allclose(output, expected_col, rtol=1e-15, atol=1e-15) + assert output.shape == expected_shape + assert isinstance(output, np.ndarray) diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index 852d7a85..d8d4db36 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -17,7 +17,7 @@ from scipy.sparse.linalg import factorized, spsolve from pybaselines import _banded_utils, _spline_utils -from pybaselines._compat import diags, _HAS_NUMBA +from pybaselines._compat import diags, _HAS_NUMBA, _sparse_col_index def _nieve_basis_matrix(x, knots, spline_degree): @@ -382,7 +382,7 @@ def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, dif factorization = factorized(btwb + penalty_matrix) expected_ed = 0 for i in range(num_bases): - expected_ed += factorization(btwb[:, i].toarray())[i] + expected_ed += factorization(_sparse_col_index(btwb, i))[i] spline_basis = _spline_utils.SplineBasis( x, num_knots=num_knots, spline_degree=spline_degree diff --git a/tests/two_d/test_spline_utils.py b/tests/two_d/test_spline_utils.py index 622a36ca..a4b57e38 100644 --- a/tests/two_d/test_spline_utils.py +++ b/tests/two_d/test_spline_utils.py @@ -13,7 +13,7 @@ from scipy.sparse import issparse, kron from scipy.sparse.linalg import factorized, spsolve -from pybaselines._compat import identity +from pybaselines._compat import _sparse_col_index, identity from pybaselines._banded_utils import difference_matrix, diff_penalty_matrix from pybaselines.two_d import _spline_utils @@ -370,7 +370,7 @@ def test_pspline_effective_dimension(data_fixture2d, num_knots, spline_degree, d factorization = factorized(lhs.tocsc()) expected_ed = 0 for i in range(np.prod(num_bases)): - expected_ed += factorization(btwb[:, i].toarray())[i] + expected_ed += factorization(_sparse_col_index(btwb, i))[i] pspline = _spline_utils.PSpline2D(spline_basis, lam=lam, diff_order=diff_order) diff --git a/tests/two_d/test_whittaker_utils.py b/tests/two_d/test_whittaker_utils.py index 4f1fe306..d2ad4149 100644 --- a/tests/two_d/test_whittaker_utils.py +++ b/tests/two_d/test_whittaker_utils.py @@ -14,7 +14,7 @@ from scipy.sparse.linalg import factorized, spsolve from pybaselines._banded_utils import diff_penalty_diagonals, diff_penalty_matrix -from pybaselines._compat import dia_object, diags, identity +from pybaselines._compat import _sparse_col_index, dia_object, diags, identity from pybaselines.two_d import _spline_utils, _whittaker_utils from pybaselines.utils import difference_matrix @@ -617,7 +617,7 @@ def test_penalized_system_effective_dimension(shape, diff_order, lam): factorization = factorized(weights_matrix + penalty) expected_ed = 0 for i in range(np.prod(shape)): - expected_ed += factorization(weights_matrix[:, i].toarray())[i] + expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] penalized_system = _whittaker_utils.PenalizedSystem2D(shape, lam=lam, diff_order=diff_order) @@ -690,7 +690,7 @@ def test_whittaker_system_effective_dimension(shape, diff_order, lam, use_svd): factorization = factorized(weights_matrix + penalty) expected_ed = 0 for i in range(np.prod(shape)): - expected_ed += factorization(weights_matrix[:, i].toarray())[i] + expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] # the relative error on the trace when using SVD decreases as the number of # eigenvalues approaches the data size, so just test with a value very close From 79790501b9fac5976bfe27c635c754f15c1b05af Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:00:47 -0500 Subject: [PATCH 17/38] ENH: Add GCV and BIC as options for optmize_pls --- pybaselines/optimizers.py | 231 +++++++++++++++++++++++++++++++------- 1 file changed, 190 insertions(+), 41 deletions(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index d8a366ca..c7495e70 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -670,8 +670,8 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la return baseline, params @_Algorithm._register(skip_sorting=True) - def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, max_value=7, - step=0.5, method_kwargs=None, euclidean=False): + def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, max_value=7, + step=0.5, method_kwargs=None, euclidean=False, rho=None): """ Optimizes the regularization parameter for penalized least squares methods. @@ -685,9 +685,9 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, opt_method : str, optional The optimization method used to optimize `lam`. Supported methods are: - * 'erPLS' - * 'U-curve' - * 'gcv' + * 'U-Curve' + * 'GCV' + * 'BIC' Details on each optimization method are in the Notes section below. min_value : int or float, optional @@ -707,9 +707,16 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, Default is None, which will use an empty dictionary. euclidean : bool, optional Only used if `opt_method` is 'U-curve'. If False (default), the optimization metric - is the minimum of the sum of the normalized fidelity and roughness values, which is + is the minimum of the sum of the normalized fidelity and penalty values, which is equivalent to the minimum graph distance from the origin. If True, the metric is the - euclidean distance from the origin + euclidean distance from the origin. + rho : float, optional + Only used if `opt_method` is 'GCV'. The stabilization parameter for the modified + generalized cross validation (GCV) criteria. A value of 1 defines normal GCV, while + higher values of `rho` stabilize the scores to make a single, global minima value + more likely (when applied to smoothing). If None (default), the value of `rho` will + be selected following [2]_, with the value being 1.3 if ``len(data)`` is less than + 100, otherwise 2. Returns ------- @@ -720,22 +727,29 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, * 'optimal_parameter': float The `lam` value that minimized the computed metric. - * 'metric': numpy.ndarray[float] + * 'metric': numpy.ndarray, shape (P,) The computed metric for each `lam` value tested. * 'method_params': dict A dictionary containing the output parameters for the optimal fit. Items will depend on the selected method. - * 'fidelity': numpy.ndarray[float] - Only returned if `opt_method` is 'U-curve'. The computed normalized fidelity + * 'fidelity': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'U-curve'. The computed non-normalized fidelity values for each `lam` value tested. - * 'roughness': numpy.ndarray[float] - Only returned if `opt_method` is 'U-curve'. The computed normalized roughness + * 'penalty': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'U-curve'. The computed non-normalized penalty values for each `lam` value tested. + * 'rss': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'GCV' or 'BIC'. The weighted residual sum of + squares (eg. ``(w * (y - baseline)**2).sum()`` for each `lam` value tested. + * 'trace': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'GCV' or 'BIC. The computed trace of the smoother + matrix for each `lam` value tested, which signifies the effective dimension + for the system. Raises ------ ValueError - _description_ + Raised if `opt_method` is 'GCV' and the input `rho` is less than 1. NotImplementedError _description_ @@ -745,9 +759,9 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, Notes ----- - This method requires that the sum of the normalized roughness and fidelity values is + This method requires that the sum of the normalized penalty and fidelity values is roughly 'U' shaped (see Figure 5 in [1]_), which depends on appropriate selection of - `min_value` and `max_value` such that roughness continually decreases and fidelity + `min_value` and `max_value` such that penalty continually decreases and fidelity continually increases as `lam` increases. Uses a grid search for optimization since the objective functions for all supported @@ -766,11 +780,10 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction using Asymmetrically Reweighted Penalized Least Squares. Journal of the Institute of Electronics and Information Engineers, 2016, 53(3), 124-131. + .. [2] Lukas, M., et al. Practical use of robust GCV and modified GCV for spline + smoothing. Computational Statistics, 2016, 31, 269-289. """ - if opt_method is None: - # TODO once all methods are added, pick a good ordering, pick a default, and remove this - raise NotImplementedError('solver order needs determining') y, baseline_func, _, method_kws, fitting_object = self._setup_optimizer( data, method, (whittaker, morphological, spline, classification, misc), method_kwargs, copy_kwargs=False @@ -782,18 +795,20 @@ def optimize_pls(self, data, method='arpls', opt_method='U-curve', min_value=4, raise ValueError('lam must not be specified within method_kwargs') lam_range = _param_grid(min_value, max_value, step, polynomial_fit=False) - selected_method = opt_method.lower().replace('-', '_') - if selected_method == 'u_curve': - params = _optimize_ucurve( + selected_method = opt_method.lower().replace('-', '_').replace('_', '') + if selected_method in ('ucurve',): + baseline, params = _optimize_ucurve( y, selected_method, method, method_kws, baseline_func, fitting_object, lam_range, euclidean ) + elif selected_method in ('gcv', 'bic'): + baseline, params = _optimize_ed( + y, selected_method, method, method_kws, baseline_func, fitting_object, lam_range, + rho + ) else: raise ValueError(f'{opt_method} is not a supported opt_method input') - baseline, final_params = baseline_func(y, lam=params['optimal_parameter'], **method_kws) - params['method_params'] = final_params - return baseline, params @@ -880,10 +895,10 @@ def _param_grid(min_value, max_value, step, polynomial_fit=False): return values -def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_obj: _Algorithm, - lam_range, euclidean=False): +def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_obj, + lam_range, euclidean): """ - Performs U-curve optimization based on the fit fidelity and roughness. + Performs U-curve optimization based on the fit fidelity and penalty. Parameters ---------- @@ -942,7 +957,7 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ # some methods have a different default diff_order, so have to inspect them diff_order = method_signature['diff_order'].default - roughness = [] + penalty = [] fidelity = [] for lam in lam_range: fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) @@ -950,11 +965,11 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ penalized_object = fit_params['tck'][1] else: penalized_object = fit_baseline - # Park, et al. multiplied the roughness by lam (Equation 8), but I think that may have + # Park, et al. multiplied the penalty by lam (Equation 8), but I think that may have # been a typo since it otherwise favors low lam values and does not produce a - # roughness plot shown in Figure 4 in the Park, et al. reference - partial_roughness = np.diff(penalized_object, diff_order) - fit_roughness = partial_roughness.dot(partial_roughness) + # penalty plot shown in Figure 4 in the Park, et al. reference + partial_penalty = np.diff(penalized_object, diff_order) + fit_penalty = partial_penalty.dot(partial_penalty) residual = y - fit_baseline if 'weights' in fit_params: @@ -962,27 +977,156 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ else: fit_fidelity = residual @ residual - roughness.append(fit_roughness) + penalty.append(fit_penalty) fidelity.append(fit_fidelity) - roughness = np.array(roughness) + penalty = np.array(penalty) fidelity = np.array(fidelity) - + # add fidelity and penalty to params before potentially normalizing + params = {'fidelity': fidelity, 'penalty': penalty} if lam_range.size > 1: - roughness = (roughness - roughness.min()) / (roughness.max() - roughness.min()) + penalty = (penalty - penalty.min()) / (penalty.max() - penalty.min()) fidelity = (fidelity - fidelity.min()) / (fidelity.max() - fidelity.min()) if euclidean: - metric = np.sqrt(fidelity**2 + roughness**2) + metric = np.sqrt(fidelity**2 + penalty**2) else: # graph distance from the origin, ie. only travelling along x and y axes - metric = fidelity + roughness + metric = fidelity + penalty best_lam = lam_range[np.argmin(metric)] + baseline, best_params = baseline_func(y, lam=best_lam, **method_kws) + params.update({'optimal_parameter': best_lam, 'metric': metric, 'method_params': best_params}) + + return baseline, params + + +def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, + lam_range, rho): + """ + Optimizes the regularization coefficient using criteria based on the effective dimension. + + Parameters + ---------- + y : _type_ + _description_ + opt_method : {'gcv', 'bic'} + _description_ + method : _type_ + _description_ + method_kws : _type_ + _description_ + baseline_func : _type_ + _description_ + baseline_obj : _Algorithm + _description_ + lam_range : _type_ + _description_ + rho : _type_, optional + _description_. Default is None. + + Returns + ------- + _type_ + _description_ + + Raises + ------ + ValueError + _description_ + NotImplementedError + _description_ + + """ + use_gcv = opt_method == 'gcv' + if use_gcv: + if rho is None: + # selection of rho based on https://doi.org/10.1007/s00180-015-0577-7 + rho = 1.3 if baseline_obj._size < 100 else 2. + else: + if rho < 1: + raise ValueError('rho must be >= 1') + if 'pspline' in method or method in ('mixture_model', 'irsqr'): + spline_fit = True + else: + spline_fit = False + + using_aspls = 'aspls' in method + using_drpls = 'drpls' in method + using_iasls = 'iasls' in method + if any((using_aspls, using_drpls, using_iasls)): + raise NotImplementedError(f'{method} method is not currently supported') + elif method == 'beads': # only supported for L-curve-based optimization options + raise NotImplementedError( + f'optimize_pls does not support the beads method for {opt_method}' + ) + + # some methods have different defaults, so have to inspect them + method_signature = inspect.signature(baseline_func).parameters + penalty_kwargs = {} + penalty_kwargs['diff_order'] = method_kws.get( + 'diff_order', method_signature['diff_order'].default + ) + if not spline_fit: + _, _, penalized_system = baseline_obj._setup_whittaker(y, **penalty_kwargs) + else: + for key in ('spline_degree', 'num_knots'): + penalty_kwargs[key] = method_kws.get(key, method_signature[key].default) + _, _, penalized_system = baseline_obj._setup_spline(y, **penalty_kwargs) + + n_lams = len(lam_range) + min_metric = np.inf + metrics = np.empty(n_lams) + traces = np.empty(n_lams) + resid_sum_sqs = np.empty(n_lams) + for i, lam in enumerate(lam_range): + fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) + + penalized_system.update_lam(lam) + # have to ensure weights are sorted to match how their ordering during fitting + # for the effective dimension calc + trace = penalized_system.effective_dimension( + _sort_array(fit_params['weights'], baseline_obj._sort_order) + ) + # TODO should just combine the rss calc with optimize_lcurve; should all terms + # that do not depend on lam be added to rss/fidelity?? Or just ignore? Affected + # methods are drpls and iasls + if using_iasls: + resid_sum_sq = ((fit_params['weights'] * (y - fit_baseline))**2).sum() + else: + resid_sum_sq = fit_params['weights'] @ (y - fit_baseline)**2 + if use_gcv: + # GCV = (1/N) * RSS / (1 - rho * trace / N)**2 == RSS * N / (N - rho * trace)**2 + # Note that some papers use different terms for fidelity (eg. RSS / N vs just RSS), + # within the actual minimized equation, but both Woltring + # (https://doi.org/10.1016/0141-1195(86)90098-7) and Eilers + # (https://doi.org/10.1021/ac034173t) uses the same GCV score + # formulation for penalized splines and Whittaker smoothing, respectively (using a + # fidelity term of just RSS), so this should be correct + metric = resid_sum_sq * baseline_obj._size / (baseline_obj._size - rho * trace)**2 + else: + # BIC = -2 * l + ln(N) * ED, where l == log likelihood and + # ED == effective dimension ~ trace + # For Gaussian errors: BIC ~ N * ln(RSS / N) + ln(N) * trace + metric = ( + baseline_obj._size * np.log(resid_sum_sq / baseline_obj._size) + + np.log(baseline_obj._size) * trace + ) + + if metric < min_metric: + min_metric = metric + best_lam = lam + baseline = fit_baseline + best_params = fit_params + + metrics[i] = metric + traces[i] = trace + resid_sum_sqs[i] = resid_sum_sq + params = { - 'optimal_parameter': best_lam, 'metric': metric, - 'fidelity': fidelity, 'roughness': roughness, + 'optimal_parameter': best_lam, 'metric': metrics, 'trace': traces, + 'rss': resid_sum_sqs, 'method_params': best_params } - return params + return baseline, params @_optimizers_wrapper @@ -1351,3 +1495,8 @@ def custom_bc(data, x_data=None, method='asls', regions=((None, None),), samplin Intelligent Laboratory Systems, 2011, 109(1), 51-56. """ + + +@_optimizers_wrapper +def optimize_pls(data, x_data=None, **kwargs): + pass From 76c82b5d90ebff56399d94808dc8996493d5b873 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 17 Jan 2026 18:05:34 -0500 Subject: [PATCH 18/38] TEST: Add basic tests for optmize_pls --- tests/test_optimizers.py | 47 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index fb4154c1..0101e44e 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -147,7 +147,7 @@ def test_output_alpha(self, average_dataset): class TestOptimizeExtendedRange(OptimizersTester, OptimizerInputWeightsMixin): - """Class for testing collab_pls baseline.""" + """Class for testing optimize_extended_range baseline.""" func_name = "optimize_extended_range" checked_keys = ('optimal_parameter', 'min_rmse', 'rmse') @@ -670,3 +670,48 @@ def test_overlapping_regions_fails(self): """Ensures an exception is raised if regions overlap.""" with pytest.raises(ValueError): self.class_func(self.y, regions=((0, 10), (9, 13))) + + +class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): + """Class for testing optimize_pls baseline.""" + + func_name = "optimize_pls" + checked_keys = ('optimal_parameter', 'metric') + # will need to change checked_keys if default method is changed + checked_method_keys = ('weights', 'tol_history') + + @pytest.mark.parametrize('opt_method', ('U-curve', 'GCV', 'BIC')) + def test_output(self, opt_method): + """Ensures correct output parameters for different optimization methods.""" + if opt_method in ('GCV', 'BIC'): + additional_keys = ('rss', 'trace') + else: + additional_keys = ('fidelity', 'penalty') + super().test_output(additional_keys=additional_keys, opt_method=opt_method) + + @pytest.mark.parametrize( + 'method', + ( + 'asls', 'iasls', 'airpls', 'mpls', 'arpls', 'drpls', 'iarpls', 'aspls', 'psalsa', + 'derpsalsa', 'mpspline', 'mixture_model', 'irsqr', 'fabc', + 'pspline_asls', 'pspline_iasls', 'pspline_airpls', 'pspline_arpls', 'pspline_drpls', + 'pspline_iarpls', 'pspline_aspls', 'pspline_psalsa', 'pspline_derpsalsa' + ) + ) + def test_all_methods(self, method): + """Tests most methods that should work with optimize_pls.""" + output = self.class_func(self.y, method=method, **self.kwargs) + if 'weights' in output[1]['method_params']: + assert self.y.shape == output[1]['method_params']['weights'].shape + elif 'alpha' in output[1]['method_params']: + assert self.y.shape == output[1]['method_params']['alpha'].shape + + def test_unknown_method_fails(self): + """Ensures method fails when an unknown baseline method is given.""" + with pytest.raises(AttributeError): + self.class_func(self.y, method='aaaaa') + + def test_unknown_opt_method_fails(self): + """Ensures method fails when an unknown opt_method is given.""" + with pytest.raises(ValueError): + self.class_func(self.y, opt_method='aaaaa') From 7d915b9bea79a79eaaf0922288a89195981f821a Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:32:39 -0500 Subject: [PATCH 19/38] MAINT: Fix sparse indexing test logic --- pybaselines/_compat.py | 3 ++- tests/test_compat.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pybaselines/_compat.py b/pybaselines/_compat.py index 2317beb8..8b256566 100644 --- a/pybaselines/_compat.py +++ b/pybaselines/_compat.py @@ -236,7 +236,8 @@ def _allows_1d_slice(): Notes ----- - An equivalent function would be checking that the SciPy version is at least 1.15.0. + An equivalent function would be checking that the SciPy version is at least 1.15.0 or + that the output of `diags` is a sparse matrix. """ try: diff --git a/tests/test_compat.py b/tests/test_compat.py index ac493faf..9199b647 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -387,7 +387,7 @@ def test_allow_1d_slice(): """Uses version checking rather than brute force to ensure sparse slicing is available. The actual implementation in pybaselines directly checks if 1d slicing can be done on - sparse matrices, which should be slightly more robust than a simple version check, but + sparse matrices/arrays, which should be slightly more robust than a simple version check, but they should match regardless. """ @@ -397,8 +397,13 @@ def test_allow_1d_slice(): # raise the exception so that version parsing can be changed if needed raise ValueError('Issue parsing SciPy version') from e - # sparse 1d slicing was first available in version 1.15.0 - expected = (_scipy_version[0] > 1 or (_scipy_version[0] == 1 and _scipy_version[1] >= 15)) + # sparse matrices always supported making 1d slices, while sparse arrays didn't support + # 1d slicing until scipy version 1.15.0 + if sparse.isspmatrix(_compat.diags(np.ones(5))): + expected = True + else: + expected = (_scipy_version[0] > 1 or (_scipy_version[0] == 1 and _scipy_version[1] >= 15)) + output = _compat._allows_1d_slice() assert expected == output From 99eef75d3bc0540606b93a0c0dde3c758c64d56e Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:07:31 -0500 Subject: [PATCH 20/38] OTH: Create result objects for Whittaker and P-splines Will be returned from relevant algorithms to more easily calculate the effective dimensions for optimize_pls. --- pybaselines/results.py | 919 ++++++++++++++++++++++++++++++++++++++++ pybaselines/utils.py | 26 ++ tests/test_results.py | 498 ++++++++++++++++++++++ tests/test_utils.py | 27 ++ tests/test_whittaker.py | 2 +- 5 files changed, 1471 insertions(+), 1 deletion(-) create mode 100644 pybaselines/results.py create mode 100644 tests/test_results.py diff --git a/pybaselines/results.py b/pybaselines/results.py new file mode 100644 index 00000000..8e123701 --- /dev/null +++ b/pybaselines/results.py @@ -0,0 +1,919 @@ +# -*- coding: utf-8 -*- +"""Objects for calculating additional terms from results of analytical baseline correction methods. + +Created on November 15, 2025 +@author: Donald Erb + +""" + +import numpy as np +from scipy.linalg import solve +from scipy.sparse import issparse +from scipy.sparse.linalg import factorized + +from ._banded_utils import _banded_to_sparse, _add_diagonals +from ._compat import diags, _sparse_col_index +from .utils import _get_rng + + +class WhittakerResult: + """ + Represents the result of Whittaker smoothing. + + Provides methods for extending the solution obtained from baseline algorithms that use + Whittaker smoothing. This class should not be initialized by external users. + + """ + + def __init__(self, penalized_object, weights=None, lhs=None, rhs_extra=None): + """ + Initializes the result object. + + In the most basic formulation, Whittaker smoothing solves ``(W + P) @ v = W @ y``. + Then the hat matrix would be ``(W + P)^-1 @ W``. For more complex usages, the + equation can be expressed as ``lhs @ v = (W + rhs_extra) @ y`` with a corresponding + hat matrix of ``lhs^-1 @ (W + rhs_extra)``. + + Parameters + ---------- + penalized_object : pybaselines._banded_utils.PenalizedSystem + The penalized system object used for solving. + weights : numpy.ndarray, shape (N,) optional + The weights used to solve the system. Default is None, which will set + all weights to 1. + lhs : numpy.ndarray, optional + The left hand side of the normal equation. Default is None, which will assume that + `lhs` is the addition of ``diags(weights)`` and ``pentalized_object.penalty``. + rhs_extra : numpy.ndarray or scipy.sparse.sparray or scipy.sparse.spmatrix, optional + Additional terms besides the weights within the right hand side of the hat matrix. + Default is None. + + """ + self._penalized_object = penalized_object + self._hat_lhs = lhs + self._hat_rhs = None + self._rhs_extra = rhs_extra + self._trace = None + if weights is None: + weights = np.ones(self._shape) + self._weights = weights + + @property + def _shape(self): + """The shape of the penalized system. + + Returns + ------- + tuple[int, int] + The penalized system's shape. + + """ + # TODO need to add an attribute to join 1D and 2D PenalizedSystem and PSpline objects + # so that this can just access that attribute rather than having to modify for each + # subclass + return self._basis_shape + + @property + def _size(self): + """The total size of the penalized system. + + Returns + ------- + int + The penalized system's size. + + """ + return np.prod(self._shape) + + @property + def _basis_shape(self): + """The shape of the system's basis matrix. + + Returns + ------- + tuple[int, int] + The penalized system's basis shape. + + """ + return self._penalized_object._num_bases + + @property + def _basis_size(self): + """The total size of the system's basis matrix. + + Returns + ------- + int + The system's basis matrix size. + + """ + return np.prod(self._basis_shape) + + @property + def _lhs(self): + """ + The left hand side of the hat matrix in banded format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + numpy.ndarray + The array representing the left hand side of the hat matrix. + + """ + if self._hat_lhs is None: + self._hat_lhs = self._penalized_object.add_diagonal(self._weights) + return self._hat_lhs + + @property + def _rhs(self): + """ + The right hand side of the hat matrix in sparse format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.sparray or scipy.sparse.spmatrix + The sparse object representing the right hand side of the hat matrix. + + """ + if self._hat_rhs is None: + if self._rhs_extra is None: + self._hat_rhs = diags(self._weights) + else: + if not issparse(self._rhs_extra): + self._rhs_extra = _banded_to_sparse( + self._rhs_extra, lower=self._penalized_object.lower + ) + self._rhs_extra.setdiag(self._rhs_extra.diagonal() + self._weights) + self._hat_rhs = self._rhs_extra + return self._hat_rhs + + def effective_dimension(self, n_samples=0, rng=1234): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical Whittaker smoothing, the linear equation would be + ``(W + P) v = W @ y``. Then the hat matrix would be ``(W + P)^-1 @ W``. + The effective dimension for the system can be estimated as the trace + of the hat matrix. + + Parameters + ---------- + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (N, `n_samples`) Rademacher random variables + (ie. either -1 or 1). + rng : int or numpy.random.Generator or numpy.random.RandomState + The integer for the seed of the random number generator or an existing generating + object to use for the stochastic trace estimation. + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not an integer greater than or equal to 0. + + Notes + ----- + For systems larger than ~1000 data points, it is heavily suggested to use stochastic + trace estimation since the time required for the analytical solution calculation scales + poorly with size. + + References + ---------- + Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + # NOTE if diff_order is 2 and matrix is symmetric, could use the fast trace calculation from + # Frasso G, Eilers PH. L- and V-curves for optimal smoothing. Statistical Modelling. + # (2014), 15(1), 91-111. https://doi.org/10.1177/1471082X14549288, which is in turn based on + # Hutchinson, M, et al. Smoothing noisy data with spline functions. Numerische Mathematik. + # (1985), 47, 99-106. https://doi.org/10.1007/BF01389878 + # For non-symmetric matrices, can use the slightly more involved algorithm from: + # Erisman, A., et al. On Computing Certain Elements of the Inverse of a Sparse Matrix. + # Communication of the ACM. (1975) 18(3), 177-179. https://doi.org/10.1145/360680.360704 + # -> worth the effort? -> maybe...? For diff_order=2 and symmetric lhs, the timing is + # much faster than even the stochastic calculation and does not increase much with data + # size, and it provides the exact trace rather than an estimate -> however, this is only + # useful for GCV/BIC calculations atm, which are going to be very very rarely used -> could + # allow calculating the full inverse hat diagonal to allow calculating the baseline fit + # errors, but that's still incredibly niche... + # Also note that doing so would require performing inv(lhs) @ rhs, which is typically less + # numerically stable than solve(lhs, rhs) and would be complicated for non diagonal rhs; + # as such, I'd rather not implement it and just leave the above for reference. + + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on data size; data size > 1000 use stochastic with default + # n_samples = 100? + if n_samples == 0: + if self._trace is not None: + return self._trace + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a non-negative integer') + use_analytic = False + + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + # note to self: sparse factorization is the worst case scenario (non-symmetric lhs and + # diff_order != 2), but it is still much faster than individual solves through + # solve_banded + factorization = self._penalized_object.factorize(self._lhs) + trace = 0 + if self._rhs_extra is None: + # note: about an order of magnitude faster to omit the sparse rhs for the simple + # case of lhs @ v = w * y + eye = np.zeros(self._size) + for i in range(self._size): + eye[i] = self._weights[i] + trace += self._penalized_object.factorized_solve(factorization, eye)[i] + eye[i] = 0 + else: + rhs = self._rhs.tocsc() + for i in range(self._basis_size): + trace += self._penalized_object.factorized_solve( + factorization, _sparse_col_index(rhs, i) + )[i] + + # prevent needing to calculate analytical solution again + self._trace = trace + else: + rng_samples = _get_rng(rng).choice([-1., 1.], size=(self._basis_size, n_samples)) + if self._rhs_extra is None: + rhs_u = self._weights[:, None] * rng_samples + else: + rhs_u = self._rhs.tocsr() @ rng_samples + # H @ u == (W + P)^-1 @ (W @ u) + hat_u = self._penalized_object.solve(self._lhs, rhs_u, overwrite_b=True) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(A.T @ B) == (A * B).sum() (see + # https://en.wikipedia.org/wiki/Trace_(linear_algebra)#Trace_of_a_product ), + # with the latter using less memory and being much faster to compute; for future + # reference: einsum('ij,ij->', A, B) == (A * B).sum(), but is typically faster + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace + + +class PSplineResult(WhittakerResult): + """ + Represents the result of penalized spline (P-Spline) smoothing. + + Provides methods for extending the solution obtained from baseline algorithms that use + P-Spline smoothing. This class should not be initialized by external users. + + """ + + def __init__(self, penalized_object, weights=None, rhs_extra=None): + """ + Initializes the result object. + + In the most basic formulation, the linear equation for P-spline smoothing + is ``(B.T @ W @ B + P) c = B.T @ W @ y`` and ``v = B @ c``. + ``(W + P) @ v = W @ y``. Then the hat matrix would be + ``B @ (B.T @ W @ B + P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality of intermediate calculations. + + For more complex usages, the equation can be expressed as: + ``(B.T @ W @ B + P) @ c = (B.T @ W + rhs_partial) @ y``, such that the hat is given as: + ``B @ (B.T @ W @ B + P)^-1 @ (B.T @ W + rhs_partial)``, or equivalently + ``(B.T @ W @ B + P)^-1 @ (B.T @ W + rhs_partial) @ B``. Simplifying leads to + ``(B.T @ W @ B + P)^-1 @ (B.T @ W @ B + rhs_extra)``. + + Parameters + ---------- + penalized_object : pybaselines._spline_utils.PSpline + The penalized system object used for solving. + weights : numpy.ndarray, shape (N,) optional + The weights used to solve the system. Default is None, which will set + all weights to 1. + rhs_extra : numpy.ndarray or scipy.sparse.sparray or scipy.sparse.spmatrix, optional + Additional terms besides ``B.T @ W @ B`` within the right hand side of the hat + matrix. Default is None. + + """ + super().__init__(penalized_object, weights=weights, rhs_extra=rhs_extra) + self._btwb_ = None + + @property + def _shape(self): + """The shape of the penalized system. + + Returns + ------- + tuple[int, int] + The penalized system's shape. + + """ + return (len(self._penalized_object.basis.x),) + + @property + def _lhs(self): + """ + The left hand side of the hat matrix in banded format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + numpy.ndarray + The array representing the left hand side of the hat matrix. + + """ + if self._hat_lhs is None: + self._hat_lhs = _add_diagonals( + self._btwb, self._penalized_object.penalty, self._penalized_object.lower + ) + return self._hat_lhs + + @property + def _rhs(self): + """ + The right hand side of the hat matrix in sparse format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.sparray or scipy.sparse.spmatrix + The sparse object representing the right hand side of the hat matrix. + + """ + if self._hat_rhs is None: + btwb = _banded_to_sparse(self._btwb, lower=self._penalized_object.lower) + if self._rhs_extra is None: + self._hat_rhs = btwb + else: + if not issparse(self._rhs_extra): + self._rhs_extra = _banded_to_sparse( + self._rhs_extra, lower=self._penalized_object.lower + ) + self._hat_rhs = self.rhs_extra + btwb + return self._hat_rhs + + @property + def _btwb(self): + """ + The matrix multiplication of ``B.T @ W @ B`` in banded format. + + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + numpy.ndarray + The array representing the matrix multiplication of ``B.T @ W @ B``. + + """ + if self._btwb_ is None: + self._btwb_ = self._penalized_object._make_btwb(self._weights) + return self._btwb_ + + def effective_dimension(self, n_samples=0, rng=1234): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical P-spline smoothing, the linear equation would be + ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix + would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (N, `n_samples`) Rademacher random variables + (ie. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not an integer greater than or equal to 0. + + References + ---------- + Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical Science, + 1996, 11(2), 89-121. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + # TODO could maybe make default n_samples to None and decide to use analytical or + # stochastic trace based on data size; data size > 1000 use stochastic with default + # n_samples = 100? + if n_samples == 0: + if self._trace is not None: + return self._trace + use_analytic = True + rhs_format = 'csc' + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a non-negative integer') + use_analytic = False + rhs_format = 'csr' + + rhs = self._rhs.asformat(rhs_format) + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + factorization = self._penalized_object.factorize(self._lhs) + for i in range(self._basis_size): + trace += self._penalized_object.factorized_solve( + factorization, _sparse_col_index(rhs, i) + )[i] + # prevent needing to calculate analytical solution again + self._trace = trace + else: + rng_samples = _get_rng(rng).choice([-1., 1.], size=(self._basis_size, n_samples)) + # H @ u == (B.T @ W @ B + P)^-1 @ (B.T @ W @ B) @ u + hat_u = self._penalized_object.solve(self._lhs, rhs @ rng_samples, overwrite_b=True) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(u.T @ H @ u) == sum(u * (H @ u)) + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace + + +class PSplineResult2D(PSplineResult): + """ + Represents the result of 2D penalized spline (P-Spline) smoothing. + + Provides methods for extending the solution obtained from baseline algorithms that use + P-Spline smoothing. This class should not be initialized by external users. + + """ + + def __init__(self, penalized_object, weights=None, rhs_extra=None): + """ + Initializes the result object. + + In the most basic formulation, the linear equation for P-spline smoothing + is ``(B.T @ W @ B + P) c = B.T @ W @ y`` and ``v = B @ c``. + ``(W + P) @ v = W @ y``. Then the hat matrix would be + ``B @ (B.T @ W @ B + P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality of intermediate calculations. + + For more complex usages, the equation can be expressed as: + ``(B.T @ W @ B + P) @ c = (B.T @ W + rhs_partial) @ y``, such that the hat is given as: + ``B @ (B.T @ W @ B + P)^-1 @ (B.T @ W + rhs_partial)``, or equivalently + ``(B.T @ W @ B + P)^-1 @ (B.T @ W + rhs_partial) @ B``. Simplifying leads to + ``(B.T @ W @ B + P)^-1 @ (B.T @ W @ B + rhs_extra)``. + + Parameters + ---------- + penalized_object : pybaselines.two_d._spline_utils.PSpline2D + The penalized system object used for solving. + weights : numpy.ndarray, shape (M, N) or shape (``M * N``,) optional + The weights used to solve the system. Default is None, which will set + all weights to 1. + rhs_extra : numpy.ndarray or scipy.sparse.sparray or scipy.sparse.spmatrix, optional + Additional terms besides ``B.T @ W @ B`` within the right hand side of the hat + matrix. Default is None. + + """ + super().__init__(penalized_object, weights, rhs_extra) + if self._weights.ndim == 1: + self._weights = self._weights.reshape(self._shape) + + @property + def _shape(self): + """The shape of the penalized system. + + Returns + ------- + tuple[int, int] + The penalized system's shape. + + """ + return (len(self._penalized_object.basis.x), len(self._penalized_object.basis.z)) + + @property + def _lhs(self): + """ + The left hand side of the hat matrix in banded format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.csc_array or scipy.sparse.csc_matrix + The left hand side of the hat matrix. + + """ + if self._hat_lhs is None: + self._hat_lhs = (self._btwb + self._penalized_object.penalty).tocsc() + return self._hat_lhs + + @property + def _rhs(self): + """ + The right hand side of the hat matrix in sparse format. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.sparray or scipy.sparse.spmatrix + The sparse object representing the right hand side of the hat matrix. + + """ + if self._hat_rhs is None: + if self._rhs_extra is None: + self._hat_rhs = self._btwb + else: + self._hat_rhs = self._rhs_extra + self._btwb + return self._hat_rhs + + @property + def _btwb(self): + """ + The matrix multiplication of ``B.T @ W @ B`` in full, sparse format. + + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.sparray or scipy.sparse.spmatrix + The sparse object representing the matrix multiplication of ``B.T @ W @ B``. + + """ + # TODO can remove once PSpline and PSpline2D unify their btwb method calls; or + # just keep the docstring since the types are different + if self._btwb_ is None: + self._btwb_ = self._penalized_object.basis._make_btwb(self._weights) + return self._btwb_ + + def effective_dimension(self, n_samples=0, rng=1234): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical P-spline smoothing, the linear equation would be + ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix + would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently + ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred + since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables + (eg. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not an integer greater than or equal to 0. + + References + ---------- + Eilers, P., et al. Fast and compact smoothing on large multidimensional grids. Computational + Statistics and Data Analysis, 2006, 50(1), 61-76. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + # TODO unify the PSpline and PSpline2D method namings and availability for factorization + # and solving so that this can be directly inherited from the PSplineResult object + if n_samples == 0: + if self._trace is not None: + return self._trace + use_analytic = True + rhs_format = 'csc' + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a non-negative integer') + use_analytic = False + rhs_format = 'csr' + + rhs = self._rhs.asformat(rhs_format) + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + factorization = factorized(self._lhs) + for i in range(self._basis_size): + trace += factorization(_sparse_col_index(rhs, i))[i] + # prevent needing to calculate analytical solution again + self._trace = trace + else: + rng_samples = _get_rng(rng).choice([-1., 1.], size=(self._basis_size, n_samples)) + # H @ u == (B.T @ W @ B + P)^-1 @ (B.T @ W @ B) @ u + hat_u = self._penalized_object.direct_solve(self._lhs, rhs @ rng_samples) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(u.T @ H @ u) == sum(u * (H @ u)) + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace + + +class WhittakerResult2D(WhittakerResult): + """ + Represents the result of 2D Whittaker smoothing. + + Provides methods for extending the solution obtained from baseline algorithms that use + Whittaker smoothing. This class should not be initialized by external users. + + """ + + def __init__(self, penalized_object, weights=None, lhs=None, rhs_extra=None): + """ + Initializes the result object. + + In the most basic formulation, Whittaker smoothing solves ``(W + P) @ v = W @ y``. + Then the hat matrix would be ``(W + P)^-1 @ W``. For more complex usages, the + equation can be expressed as ``lhs @ v = (W + rhs_extra) @ y`` with a corresponding + hat matrix of ``lhs^-1 @ (W + rhs_extra)``. + + Parameters + ---------- + penalized_object : pybaselines.two_d._whittaker_utils.WhittakerSystem2D + The penalized system object used for solving. + weights : numpy.ndarray, shape (M, N) or shape (``M * N``,) optional + The weights used to solve the system. Default is None, which will set + all weights to 1. + lhs : scipy.sparse.sparray or scipy.sparse.spmatrix, optional + The left hand side of the hat matrix. Default is None, which will assume that + `lhs` is the addition of ``diags(weights)`` and ``pentalized_object.penalty``. + rhs_extra : scipy.sparse.sparray or scipy.sparse.spmatrix, optional + Additional terms besides the weights within the right hand side of the hat matrix. + Default is None. + + """ + super().__init__(penalized_object, weights=weights, lhs=lhs, rhs_extra=rhs_extra) + self._btwb_ = None + if self._penalized_object._using_svd and self._weights.ndim == 1: + self._weights = self._weights.reshape(self._shape) + elif not self._penalized_object._using_svd and self._weights.ndim == 2: + self._weights = self._weights.ravel() + + @property + def _shape(self): + """The shape of the penalized system. + + Returns + ------- + tuple[int, int] + The penalized system's shape. + + """ + # TODO replace/remove once PenalizedSystem2D and WhittakerSystem2D are unified + if hasattr(self._penalized_object, '_num_points'): + shape = self._penalized_object._num_points + else: + shape = self._penalized_object._num_bases + return shape + + @property + def _btwb(self): + """ + The matrix multiplication of ``B.T @ W @ B`` in full, dense format. + + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + numpy.ndarray + The array representing the matrix multiplication of ``B.T @ W @ B``. + + """ + if self._btwb_ is None: + self._btwb_ = self._penalized_object._make_btwb(self._weights) + return self._btwb_ + + @property + def _lhs(self): + """ + The left hand side of the hat matrix. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + numpy.ndarray or scipy.sparse.csc_array or scipy.sparse.csc_matrix + The left hand side of the hat matrix. If using SVD, then the output is a numpy + array; otherwise, it is a sparse object wit CSC format. + + """ + if self._hat_lhs is None: + if self._penalized_object._using_svd: + lhs = self._btwb.copy() + np.fill_diagonal(lhs, lhs.diagonal() + self._penalized_object.penalty) + self._hat_lhs = lhs + else: + return super()._lhs.tocsc() + + return self._hat_lhs + + @property + def _rhs(self): + """ + The right hand side of the hat matrix. + + Given the linear system ``lhs @ v = rhs @ y``, the hat matrix is given as ``lhs^-1 @ rhs. + Lazy implementation so that the calculation is only performed when needed. + + Returns + ------- + scipy.sparse.sparray or scipy.sparse.spmatrix + The sparse object representing the right hand side of the hat matrix. + + """ + if self._hat_rhs is None: + if self._penalized_object._using_svd: + self._hat_rhs = self._btwb + else: + return super()._rhs + + return self._hat_rhs + + def relative_dof(self): + """ + Calculates the relative effective degrees of freedom for each eigenvector. + + Returns + ------- + dof : numpy.ndarray, shape (P, Q) + The relative effective degrees of freedom associated with each eigenvector + used for the fit. Each individual effective degree of freedom value is between + 0 and 1, with lower values signifying that the eigenvector was less important + for the fit. + + Raises + ------ + ValueError + Raised if the system was solved analytically rather than using eigendecomposition, + ie. ``num_eigens`` was set to None. + + """ + if not self._penalized_object._using_svd: + raise ValueError( + 'Cannot calculate degrees of freedom when not using eigendecomposition' + ) + dof = solve(self._lhs, self._btwb, check_finite=False, assume_a='pos') + return dof.diagonal().reshape(self._basis_shape) + + def effective_dimension(self, n_samples=0, rng=1234): + """ + Calculates the effective dimension from the trace of the hat matrix. + + For typical Whittaker smoothing, the linear equation would be + ``(W + lam * P) v = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. + If using SVD, the linear equation is ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and + ``v = B @ c``. Then the hat matrix would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` + or, equivalently ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression + is preferred since it reduces the dimensionality. The effective dimension for the system + can be estimated as the trace of the hat matrix. + + Parameters + ---------- + n_samples : int, optional + If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic + trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables + (eg. either -1 or 1). + + Returns + ------- + trace : float + The trace of the hat matrix, denoting the effective dimension for + the system. + + Raises + ------ + TypeError + Raised if `n_samples` is not 0 and a non-positive integer. + + Notes + ----- + If using SVD, the trace will be lower than the actual analytical trace. The relative + difference is reduced as the number of eigenvalues selected approaches the data + size. + + References + ---------- + Biessy, G. Whittaker-Henderson smoothing revisited: A modern statistical framework for + practical use. ASTIN Bulletin, 2025, 1-31. + + Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian + smoothing splines. Communications in Statistics - Simulation and Computation, (1990), + 19(2), 433-450. + + Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on + Simplicity in Algorithms (SOSA), (2021), 142-155. + + """ + if n_samples == 0: + if self._trace is not None: + return self._trace + use_analytic = True + else: + if n_samples < 0 or not isinstance(n_samples, int): + raise TypeError('n_samples must be a non-negative integer') + use_analytic = False + rng_samples = _get_rng(rng).choice([-1., 1.], size=(self._basis_size, n_samples)) + + if self._penalized_object._using_svd: + # NOTE the only Whittaker-based algorithms that allow performing SVD for solving + # all use the simple (W + P) v = w * y formulation, so no need to implement for + # rhs_extra + if self._rhs_extra is not None: + raise NotImplementedError( + 'rhs_extra is not supported when using eigendecomposition' + ) + if use_analytic: + trace = self.relative_dof().sum() + self._trace = trace + else: + # H @ u == (B.T @ W @ B + P)^-1 @ (B.T @ W @ B) @ u + hat_u = solve( + self._lhs, self._rhs @ rng_samples, overwrite_b=True, + check_finite=False, assume_a='pos' + ) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(u.T @ H @ u) == sum(u * (H @ u)) + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + else: + # TODO unify PenalizedSystem and PenalizedSystem2D methods so that this can be + # directly inherited from WhittakerResult + if use_analytic: + # compute each diagonal of the hat matrix separately so that the full + # hat matrix does not need to be stored in memory + trace = 0 + factorization = factorized(self._lhs) + if self._rhs_extra is None: + # note: about an order of magnitude faster to omit the sparse rhs for the simple + # case of lhs @ v = w * y + eye = np.zeros(self._size) + for i in range(self._size): + eye[i] = self._weights[i] + trace += factorization(eye)[i] + eye[i] = 0 + else: + rhs = self._rhs.tocsc() + for i in range(self._basis_size): + trace += factorization(_sparse_col_index(rhs, i))[i] + self._trace = trace + + else: + if self._rhs_extra is None: + rhs_u = self._weights[:, None] * rng_samples + else: + rhs_u = self._rhs.tocsr() @ rng_samples + # H @ u == (W + P)^-1 @ (W @ u) + hat_u = self._penalized_object.direct_solve(self._lhs, rhs_u) + # stochastic trace is the average of the trace of u.T @ H @ u; + # trace(u.T @ H @ u) == sum(u * (H @ u)) + trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples + + return trace diff --git a/pybaselines/utils.py b/pybaselines/utils.py index 4ae5c10a..1649d84f 100644 --- a/pybaselines/utils.py +++ b/pybaselines/utils.py @@ -1302,3 +1302,29 @@ def estimate_polyorder(data, x_data=None, min_value=1, max_value=10): max_skew = residual_skew return best_order + + +def _get_rng(rng): + """ + Generates or returns a random number generator from the given input. + + Parameters + ---------- + rng : int or numpy.random.Generator or numpy.random.RandomState + The integer for the seed of the random number generator or an existing generating + object. + + Returns + ------- + output : numpy.random.Generator or numpy.random.RandomState + The random number generator corresponding to the input `rng`. If `rng` was an existing + RandomState or Generator object, it is returned; otherwise, `output` is + a Generator object. + + """ + if isinstance(rng, (np.random.Generator, np.random.RandomState)): + output = rng + else: + output = np.random.default_rng(rng) + + return output diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 00000000..bba78187 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,498 @@ +# -*- coding: utf-8 -*- +"""Tests for pybaselines.results. + +@author: Donald Erb +Created on January 8, 2026 + +""" + +import numpy as np +from numpy.testing import assert_allclose +import pytest +from scipy.sparse import kron +from scipy.sparse.linalg import factorized + +from pybaselines import _banded_utils, _spline_utils, results +from pybaselines.two_d._spline_utils import PSpline2D, SplineBasis2D +from pybaselines.two_d._whittaker_utils import WhittakerSystem2D +from pybaselines._compat import _sparse_col_index, dia_object, diags, identity + +from .base_tests import get_2dspline_inputs + + +@pytest.mark.parametrize('diff_order', (1, 2)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('allow_penta', (True, False)) +@pytest.mark.parametrize('size', (100, 401)) +def test_whittaker_effective_dimension(diff_order, allow_lower, allow_penta, size): + """ + Tests the effective_dimension method of a WhittakerResult object. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, size) + weights = np.clip(weights, 0, 1).astype(float) + + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] + expected_penalty = _banded_utils.diff_penalty_diagonals( + size, diff_order=diff_order, lower_only=False + ) + sparse_penalty = dia_object( + (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), + shape=(size, size) + ).tocsr() + weights_matrix = diags(weights, format='csc') + factorization = factorized(weights_matrix + sparse_penalty) + expected_ed = 0 + for i in range(size): + expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] + + penalized_system = _banded_utils.PenalizedSystem( + size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + result_obj = results.WhittakerResult(penalized_system, weights=weights) + output = result_obj.effective_dimension(n_samples=0) + + assert_allclose(output, expected_ed, rtol=1e-7, atol=1e-10) + + +@pytest.mark.parametrize('diff_order', (1, 2)) +@pytest.mark.parametrize('allow_lower', (True, False)) +@pytest.mark.parametrize('allow_penta', (True, False)) +@pytest.mark.parametrize('size', (100, 401)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_whittaker_effective_dimension_stochastic(diff_order, allow_lower, allow_penta, size, + n_samples): + """ + Tests the stochastic effective_dimension calculation of a WhittakerResult object. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, size) + weights = np.clip(weights, 0, 1).astype(float) + + lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] + + penalized_system = _banded_utils.PenalizedSystem( + size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + result_obj = results.WhittakerResult(penalized_system, weights=weights) + expected_ed = result_obj.effective_dimension(n_samples=0) + + output = result_obj.effective_dimension(n_samples=n_samples) + + assert_allclose(output, expected_ed, rtol=5e-1, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_whittaker_effective_dimension_stochastic_invalid_samples(data_fixture, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, y = data_fixture + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1).astype(float) + + penalized_system = _banded_utils.PenalizedSystem(x.size) + result_obj = results.WhittakerResult(penalized_system, weights=weights) + with pytest.raises(TypeError): + result_obj.effective_dimension(n_samples=n_samples) + + +def test_whittaker_result_no_weights(data_fixture): + """Ensures weights are initialized as ones if not given to WhittakerResult.""" + x, y = data_fixture + + penalized_system = _banded_utils.PenalizedSystem(x.size) + result_obj = results.WhittakerResult(penalized_system) + + assert_allclose(result_obj._weights, np.ones(y.shape), rtol=1e-16, atol=0) + + +@pytest.mark.parametrize('num_knots', (20, 51)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3)) +@pytest.mark.parametrize('diff_order', (1, 2)) +@pytest.mark.parametrize('lower_only', (True, False)) +def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, + lower_only): + """ + Tests the effective_dimension method of a PSplineResult object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1).astype(float) + + knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) + basis = _spline_utils._spline_basis(x, knots, spline_degree) + num_bases = basis.shape[1] + penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) + + btwb = basis.T @ diags(weights, format='csr') @ basis + factorization = factorized(btwb + penalty_matrix) + expected_ed = 0 + for i in range(num_bases): + expected_ed += factorization(_sparse_col_index(btwb, i))[i] + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + result_obj = results.PSplineResult(pspline, weights) + output = result_obj.effective_dimension(n_samples=0) + assert_allclose(output, expected_ed, rtol=1e-10, atol=1e-12) + + +@pytest.mark.parametrize('num_knots', (20, 51)) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3)) +@pytest.mark.parametrize('diff_order', (1, 2)) +@pytest.mark.parametrize('lower_only', (True, False)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_pspline_stochastic_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, + lower_only, n_samples): + """ + Tests the effective_dimension method of a PSplineResult object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1).astype(float) + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only + ) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + result_obj = results.PSplineResult(pspline, weights) + expected_ed = result_obj.effective_dimension(n_samples=0) + + output = result_obj.effective_dimension(n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=5e-2, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_pspline_stochastic_effective_dimension_invalid_samples(data_fixture, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, y = data_fixture + x = x.astype(float) + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1).astype(float) + + spline_basis = _spline_utils.SplineBasis(x) + pspline = _spline_utils.PSpline(spline_basis) + result_obj = results.PSplineResult(pspline, weights) + with pytest.raises(TypeError): + result_obj.effective_dimension(n_samples=n_samples) + + +def test_pspline_result_no_weights(data_fixture): + """Ensures weights are initialized as ones if not given to PSplineResult.""" + x, y = data_fixture + + spline_basis = _spline_utils.SplineBasis(x) + pspline = _spline_utils.PSpline(spline_basis) + result_obj = results.PSplineResult(pspline) + + assert_allclose(result_obj._weights, np.ones(y.shape), rtol=1e-16, atol=0) + + +@pytest.mark.parametrize('num_knots', (10, (11, 20))) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, (2, 3))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) +def test_pspline_two_d_effective_dimension(data_fixture2d, num_knots, spline_degree, diff_order, + lam): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, z, y = data_fixture2d + ( + num_knots_r, num_knots_c, spline_degree_r, spline_degree_c, + lam_r, lam_c, diff_order_r, diff_order_c + ) = get_2dspline_inputs(num_knots, spline_degree, lam, diff_order) + + knots_r = _spline_utils._spline_knots(x, num_knots_r, spline_degree_r, True) + basis_r = _spline_utils._spline_basis(x, knots_r, spline_degree_r) + + knots_c = _spline_utils._spline_knots(z, num_knots_c, spline_degree_c, True) + basis_c = _spline_utils._spline_basis(z, knots_c, spline_degree_c) + + num_bases = (basis_r.shape[1], basis_c.shape[1]) + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = SplineBasis2D( + x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False + ) + # make B.T @ W @ B using generalized linear array model since it's much faster and + # already verified in other tests + btwb = spline_basis._make_btwb(weights).tocsc() + P_r = kron( + _banded_utils.diff_penalty_matrix(num_bases[0], diff_order=diff_order_r), + identity(num_bases[1]) + ) + P_c = kron( + identity(num_bases[0]), + _banded_utils.diff_penalty_matrix(num_bases[1], diff_order=diff_order_c) + ) + penalty = lam_r * P_r + lam_c * P_c + + lhs = btwb + penalty + factorization = factorized(lhs.tocsc()) + expected_ed = 0 + for i in range(np.prod(num_bases)): + expected_ed += factorization(_sparse_col_index(btwb, i))[i] + + pspline = PSpline2D(spline_basis, lam=lam, diff_order=diff_order) + + result_obj = results.PSplineResult2D(pspline, weights) + output = result_obj.effective_dimension(n_samples=0) + + assert_allclose(output, expected_ed, rtol=1e-14, atol=1e-10) + + +@pytest.mark.parametrize('num_knots', (10, (11, 20))) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, (2, 3))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_pspline_two_d_effective_dimension_stochastic(data_fixture2d, num_knots, spline_degree, + diff_order, lam, n_samples): + """ + Tests the effective_dimension method of a PSpline object. + + The effective dimension for penalized spline smoothing should be + ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, + ``D.T @ D`` is the penalty, and `B` is the spline basis. + + """ + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = SplineBasis2D( + x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False + ) + pspline = PSpline2D(spline_basis, lam=lam, diff_order=diff_order) + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + result_obj = results.PSplineResult2D(pspline, weights) + expected_ed = result_obj.effective_dimension(n_samples=0) + + output = result_obj.effective_dimension(n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=1e-1, atol=1e-5) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +def test_pspline_two_d_stochastic_effective_dimension_invalid_samples(data_fixture2d, n_samples): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = SplineBasis2D(x, z, num_knots=10) + pspline = PSpline2D(spline_basis) + result_obj = results.PSplineResult2D(pspline, weights) + with pytest.raises(TypeError): + result_obj.effective_dimension(n_samples=n_samples) + + +def test_pspline_result_two_d_weights(data_fixture2d): + """Ensures both 1D and 2D weights are handled correctly by PSplineResult2D.""" + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + spline_basis = SplineBasis2D(x, z, num_knots=10) + pspline = PSpline2D(spline_basis) + + result_obj = results.PSplineResult2D(pspline, weights) + result_obj_1d = results.PSplineResult2D(pspline, weights.ravel()) + + assert_allclose(result_obj._weights, result_obj_1d._weights, rtol=1e-16, atol=0) + + +def test_pspline_result_two_d_no_weights(data_fixture2d): + """Ensures weights are initialized as ones if not given to PSplineResult2D.""" + x, z, y = data_fixture2d + + spline_basis = SplineBasis2D(x, z, num_knots=10) + pspline = PSpline2D(spline_basis) + result_obj = results.PSplineResult2D(pspline) + + assert_allclose(result_obj._weights, np.ones(y.shape), rtol=1e-16, atol=0) + + +@pytest.mark.parametrize('shape', ((20, 23), (51, 6))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +@pytest.mark.parametrize('use_svd', (True, False)) +def test_whittaker_two_d_effective_dimension(shape, diff_order, lam, use_svd): + """ + Tests the effective_dimension method of WhittakerResult2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + Tests both the analytic and SVD-based solutions. + + """ + *_, lam_r, lam_c, diff_order_r, diff_order_c = get_2dspline_inputs( + lam=lam, diff_order=diff_order + ) + + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + P_r = kron( + _banded_utils.diff_penalty_matrix(shape[0], diff_order=diff_order_r), + identity(shape[1]) + ) + P_c = kron( + identity(shape[0]), + _banded_utils.diff_penalty_matrix(shape[1], diff_order=diff_order_c) + ) + penalty = lam_r * P_r + lam_c * P_c + + weights_matrix = diags(weights.ravel(), format='csc') + factorization = factorized(weights_matrix + penalty) + expected_ed = 0 + for i in range(np.prod(shape)): + expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] + + # the relative error on the trace when using SVD decreases as the number of + # eigenvalues approaches the data size, so just test with a value very close + if use_svd: + num_eigens = (shape[0] - 1, shape[1] - 1) + atol = 1e-1 + rtol = 5e-2 + else: + num_eigens = None + atol = 1e-10 + rtol = 1e-14 + whittaker_system = WhittakerSystem2D( + shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens + ) + result_obj = results.WhittakerResult2D(whittaker_system, weights=weights) + output = result_obj.effective_dimension(n_samples=0) + + assert_allclose(output, expected_ed, rtol=rtol, atol=atol) + + +@pytest.mark.parametrize('shape', ((20, 23), (51, 6))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) +@pytest.mark.parametrize('use_svd', (True, False)) +@pytest.mark.parametrize('n_samples', (100, 201)) +def test_whittaker_two_d_effective_dimension_stochastic(shape, diff_order, lam, use_svd, + n_samples): + """ + Tests the stochastic effective_dimension method of WhittakerResult2D objects. + + The effective dimension for Whittaker smoothing should be + ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and + ``D.T @ D`` is the penalty. + + Tests both the analytic and SVD-based solutions. + + """ + weights = np.random.default_rng(0).normal(0.8, 0.05, shape) + weights = np.clip(weights, 0, 1).astype(float) + + if use_svd: + num_eigens = (shape[0] - 1, shape[1] - 1) + rtol = 1e-2 + else: + num_eigens = None + rtol = 1e-1 + + whittaker_system = WhittakerSystem2D( + shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens + ) + + # true solution is already verified by other tests, so use that as "known" in + # this test to only examine the relative difference from using stochastic estimation + result_obj = results.WhittakerResult2D(whittaker_system, weights=weights) + expected_ed = result_obj.effective_dimension(n_samples=0) + + output = result_obj.effective_dimension(n_samples=n_samples) + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-4) + + +@pytest.mark.parametrize('n_samples', (-1, 50.5)) +@pytest.mark.parametrize('num_eigens', (None, 5)) +def test_whittaker_two_d_effective_dimension_stochastic_invalid_samples(small_data2d, n_samples, + num_eigens): + """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" + weights = np.random.default_rng(0).normal(0.8, 0.05, small_data2d.shape) + weights = np.clip(weights, 0, 1).astype(float) + + penalized_system = WhittakerSystem2D(small_data2d.shape, num_eigens=num_eigens) + result_obj = results.WhittakerResult2D(penalized_system, weights) + with pytest.raises(TypeError): + result_obj.effective_dimension(n_samples=n_samples) + + +@pytest.mark.parametrize('num_eigens', (None, 5)) +def test_whittaker_result_two_d_weights(data_fixture2d, num_eigens): + """Ensures both 1D and 2D weights are handled correctly by WhittakerResult2D.""" + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + penalized_system = WhittakerSystem2D(y.shape, num_eigens=num_eigens) + + result_obj = results.WhittakerResult2D(penalized_system, weights) + result_obj_1d = results.WhittakerResult2D(penalized_system, weights.ravel()) + + if num_eigens is None: + expected_shape = (y.size,) + else: + expected_shape = y.shape + assert result_obj._weights.shape == expected_shape + assert result_obj_1d._weights.shape == expected_shape + assert_allclose(result_obj._weights, result_obj_1d._weights, rtol=1e-16, atol=0) + + +@pytest.mark.parametrize('num_eigens', (None, 5)) +def test_whittaker_result_two_d_no_weights(data_fixture2d, num_eigens): + """Ensures weights are initialized as ones if not given to WhittakerResult2D.""" + x, z, y = data_fixture2d + if num_eigens is None: + expected_shape = (y.size,) + else: + expected_shape = y.shape + + penalized_system = WhittakerSystem2D(y.shape, num_eigens=num_eigens) + result_obj = results.WhittakerResult2D(penalized_system) + + assert_allclose(result_obj._weights, np.ones(expected_shape), rtol=1e-16, atol=0) diff --git a/tests/test_utils.py b/tests/test_utils.py index f5610f95..7be585f5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -958,3 +958,30 @@ def test_estimate_polyorder_failures(data_fixture): utils.estimate_polyorder(y, x, min_value=5, max_value=5) with pytest.raises(ValueError): utils.estimate_polyorder(y, x, min_value=5, max_value=4) + + +def test_get_rng(): + """Ensures _get_rng works with integers or existing generators.""" + seed = 123 + + assert_allclose( + utils._get_rng(seed).normal(0.5, 0.5, 5), + np.random.default_rng(seed).normal(0.5, 0.5, 5), rtol=1e-16, atol=1e-16 + ) + + expected_rng = np.random.default_rng(seed) + output_rng = utils._get_rng(expected_rng) + assert output_rng is expected_rng + # call order matters, so create new generator within accuracy test + assert_allclose( + output_rng.normal(0.5, 0.5, 5), + np.random.default_rng(seed).normal(0.5, 0.5, 5), rtol=1e-16, atol=1e-16 + ) + + expected_rng2 = np.random.RandomState(seed) + output_rng2 = utils._get_rng(expected_rng2) + assert output_rng2 is expected_rng2 + assert_allclose( + output_rng2.normal(0.5, 0.5, 5), + np.random.RandomState(seed).normal(0.5, 0.5, 5), rtol=1e-16, atol=1e-16 + ) diff --git a/tests/test_whittaker.py b/tests/test_whittaker.py index 791d15a6..eda65640 100644 --- a/tests/test_whittaker.py +++ b/tests/test_whittaker.py @@ -458,7 +458,7 @@ def test_sparse_comparison(self, diff_order, asymmetric_coef, alternate_weightin asymmetric_coef=asymmetric_coef, alternate_weighting=alternate_weighting )[0] - rtol = {2: 1.5e-4, 3: 3e-4}[diff_order] + rtol = {2: 1.5e-4, 3: 5e-4}[diff_order] assert_allclose(banded_output, sparse_output, rtol=rtol, atol=1e-8) From c3209db2cae2a0855904aaa0c9b0279bc905a7c6 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:31:13 -0500 Subject: [PATCH 21/38] MAINT: Remove effective dimension calcs from penalty objects --- pybaselines/_banded_utils.py | 112 --------------- pybaselines/_spline_utils.py | 101 +------------ pybaselines/optimizers.py | 73 ++++------ pybaselines/two_d/_spline_utils.py | 101 ++----------- pybaselines/two_d/_whittaker_utils.py | 190 +----------------------- tests/test_banded_utils.py | 90 +----------- tests/test_optimizers.py | 6 +- tests/test_spline_utils.py | 93 +----------- tests/two_d/test_spline_utils.py | 109 +------------- tests/two_d/test_whittaker_utils.py | 199 +------------------------- 10 files changed, 52 insertions(+), 1022 deletions(-) diff --git a/pybaselines/_banded_utils.py b/pybaselines/_banded_utils.py index e862f4f0..e9d8946f 100644 --- a/pybaselines/_banded_utils.py +++ b/pybaselines/_banded_utils.py @@ -1114,115 +1114,3 @@ def factorized_solve(self, factorization, rhs, overwrite_b=False, check_finite=F output = factorization(rhs) return output - - def effective_dimension(self, weights=None, penalty=None, n_samples=0): - """ - Calculates the effective dimension from the trace of the hat matrix. - - For typical Whittaker smoothing, the linear equation would be - ``(W + lam * P) v = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. - The effective dimension for the system can be estimated as the trace - of the hat matrix. - - Parameters - ---------- - weights : numpy.ndarray, shape (N,), optional - The weights. Default is None, which will use an array of ones. - penalty : numpy.ndarray, shape (N, N), optional - The finite difference penalty matrix, in LAPACK's lower banded format if - `self.lower` is True or the full banded if `self.lower` is False. Default - is None, which uses the object's penalty. - n_samples : int, optional - If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic - trace estimation with a matrix of (N, `n_samples`) Rademacher random variables - (eg. either -1 or 1). - - Returns - ------- - trace : float - The trace of the hat matrix, denoting the effective dimension for - the system. - - Raises - ------ - TypeError - Raised if `n_samples` is not 0 and a non-positive integer. - - References - ---------- - Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. - - Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian - smoothing splines. Communications in Statistics - Simulation and Computation, (1990), - 19(2), 433-450. - - Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on - Simplicity in Algorithms (SOSA), (2021), 142-155. - - """ - if weights is None: - weights = np.ones(self._num_bases) - - reset_penalty = False - if penalty is None: - reset_penalty = True - self.add_diagonal(weights) - penalty = self.penalty - - # TODO if diff_order is 2 and matrix is symmetric, could use the fast trace calculation from - # Frasso G, Eilers PH. L- and V-curves for optimal smoothing. Statistical Modelling. - # (2014), 15(1), 91-111. https://doi.org/10.1177/1471082X14549288, which is in turn based on - # Hutchinson, M, et al. Smoothing noisy data with spline functions. Numerische Mathematik. - # (1985), 47, 99-106. https://doi.org/10.1007/BF01389878 - # For non-symmetric matrices, can use the slightly more involved algorithm from: - # Erisman, A., et al. On Computing Certain Elements of the Inverse of a Sparse Matrix. - # Communication of the ACM. (1975) 18(3), 177-179. https://doi.org/10.1145/360680.360704 - # -> worth the effort? -> maybe...? For diff_order=2 and symmetric lhs, the timing is - # much faster than even the stochastic calculation and does not increase much with data - # size, and it provides the exact trace rather than an estimate -> however, this is only - # useful for GCV/BIC calculations atm, which are going to be very very rarely used -> could - # allow calculating the full inverse hat diagonal to allow calculating the baseline fit - # errors, but that's still incredibly niche... - - # TODO could maybe make default n_samples to None and decide to use analytical or - # stochastic trace based on data size; data size > 1000 use stochastic with default - # n_samples = 100? - if n_samples == 0: - use_analytic = True - else: - if n_samples < 0 or not isinstance(n_samples, int): - raise TypeError('n_samples must be a positive integer') - use_analytic = False - - if use_analytic: - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - # note to self: sparse factorization is the worst case scenario (non-symmetric lhs and - # diff_order != 2), but it is still much faster than individual solves through - # solve_banded - eye = np.zeros(self._num_bases) - trace = 0 - factorization = self.factorize(penalty) - for i in range(self._num_bases): - eye[i] = weights[i] - trace += self.factorized_solve(factorization, eye)[i] - eye[i] = 0 - - else: - # TODO should the rng seed be settable? Maybe a Baseline property - rng_samples = np.random.default_rng(1234).choice( - [-1., 1.], size=(self._num_bases, n_samples) - ) - # H @ u == (W + lam * P)^-1 @ (w * u) - hat_u = self.solve(penalty, weights[:, None] * rng_samples, overwrite_b=True) - # stochastic trace is the average of the trace of u.T @ H @ u; - # trace(A.T @ B) == (A * B).sum() (see - # https://en.wikipedia.org/wiki/Trace_(linear_algebra)#Trace_of_a_product ), - # with the latter using less memory and being much faster to compute; for future - # reference: einsum('ij,ij->', A, B) == (A * B).sum(), but is typically faster - trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples - - if reset_penalty: # remove the weights - self.penalty[self.main_diagonal_index] = self.main_diagonal - - return trace diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index 76474fbe..e0b0a167 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -49,9 +49,9 @@ from scipy.interpolate import BSpline from ._banded_utils import ( - PenalizedSystem, _add_diagonals, _banded_to_sparse, _lower_to_full, _sparse_to_banded + PenalizedSystem, _add_diagonals, _lower_to_full, _sparse_to_banded ) -from ._compat import _HAS_NUMBA, _sparse_col_index, csr_object, dia_object, jit +from ._compat import _HAS_NUMBA, csr_object, dia_object, jit from ._validation import _check_array @@ -967,100 +967,3 @@ def _make_btwb(self, weights): btwb = btwb[len(btwb) // 2:] return btwb - - def effective_dimension(self, weights=None, penalty=None, n_samples=0): - """ - Calculates the effective dimension from the trace of the hat matrix. - - For typical P-spline smoothing, the linear equation would be - ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix - would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently - ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred - since it reduces the dimensionality. The effective dimension for the system - can be estimated as the trace of the hat matrix. - - Parameters - ---------- - weights : numpy.ndarray, shape (N,), optional - The weights. Default is None, which will use an array of ones. - penalty : numpy.ndarray, shape (M, N), optional - The finite difference penalty matrix, in LAPACK's lower banded format if - `self.lower` is True or the full banded if `self.lower` is False. Default - is None, which uses the object's penalty. - n_samples : int, optional - If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic - trace estimation with a matrix of (M, `n_samples`) Rademacher random variables - (eg. either -1 or 1). - - Returns - ------- - trace : float - The trace of the hat matrix, denoting the effective dimension for - the system. - - Raises - ------ - TypeError - Raised if `n_samples` is not 0 and a non-positive integer. - - References - ---------- - Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical Science, - 1996, 11(2), 89-121. - - Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian - smoothing splines. Communications in Statistics - Simulation and Computation, (1990), - 19(2), 433-450. - - Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on - Simplicity in Algorithms (SOSA), (2021), 142-155. - - """ - if weights is None: - weights = np.ones(self._num_bases) - - btwb = self._make_btwb(weights) - if penalty is None: - penalty = self.penalty - - lhs = _add_diagonals(btwb, penalty, self.lower) - # TODO could maybe make default n_samples to None and decide to use analytical or - # stochastic trace based on data size; data size > 1000 use stochastic with default - # n_samples = 100? - if n_samples == 0: - use_analytic = True - btwb_format = 'csc' - else: - if n_samples < 0 or not isinstance(n_samples, int): - raise TypeError('n_samples must be a positive integer') - use_analytic = False - btwb_format = 'csr' - - btwb_matrix = _banded_to_sparse(btwb, lower=self.lower, sparse_format=btwb_format) - if use_analytic: - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - trace = 0 - factorization = self.factorize(lhs, overwrite_ab=True) - for i in range(self._num_bases): - trace += self.factorized_solve( - factorization, _sparse_col_index(btwb_matrix, i), overwrite_b=True - )[i] - else: - # TODO should the rng seed be settable? Maybe a Baseline property - rng_samples = np.random.default_rng(1234).choice( - [-1., 1.], size=(self._num_bases, n_samples) - ) - # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - hat_u = self.solve( - lhs, btwb_matrix @ rng_samples, overwrite_ab=True, overwrite_b=True - ) - # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - # stochastic trace is the average of the trace of u.T @ H @ u; - # trace(A.T @ B) == (A * B).sum() (see - # https://en.wikipedia.org/wiki/Trace_(linear_algebra)#Trace_of_a_product ), - # with the latter using less memory and being much faster to compute; for future - # reference: einsum('ij,ij->', A, B) == (A * B).sum(), but is typically faster - trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples - - return trace diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 9fd47fca..095ea21d 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -671,7 +671,7 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la @_Algorithm._register(skip_sorting=True) def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, max_value=7, - step=0.5, method_kwargs=None, euclidean=False, rho=None): + step=0.5, method_kwargs=None, euclidean=False, rho=None, n_samples=0): """ Optimizes the regularization parameter for penalized least squares methods. @@ -682,7 +682,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, method : str, optional A string indicating the Whittaker-smoothing or spline method to use for fitting the baseline. Default is 'arpls'. - opt_method : str, optional + opt_method : {'U-Curve', 'GCV', 'BIC'}, optional The optimization method used to optimize `lam`. Supported methods are: * 'U-Curve' @@ -712,11 +712,15 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, euclidean distance from the origin. rho : float, optional Only used if `opt_method` is 'GCV'. The stabilization parameter for the modified - generalized cross validation (GCV) criteria. A value of 1 defines normal GCV, while + generalized cross validation (mGCV) criteria. A value of 1 defines normal GCV, while higher values of `rho` stabilize the scores to make a single, global minima value more likely (when applied to smoothing). If None (default), the value of `rho` will be selected following [2]_, with the value being 1.3 if ``len(data)`` is less than 100, otherwise 2. + n_samples : int, optional + Only used if `opt_method` is 'GCV' or 'BIC'. If 0 (default), will calculate the + analytical trace. Otherwise, will use stochastic trace estimation with a matrix of + (N, `n_samples`) Rademacher random variables (eg. either -1 or 1). Returns ------- @@ -733,14 +737,12 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, A dictionary containing the output parameters for the optimal fit. Items will depend on the selected method. * 'fidelity': numpy.ndarray, shape (P,) - Only returned if `opt_method` is 'U-curve'. The computed non-normalized fidelity - values for each `lam` value tested. + The computed non-normalized fidelity term for each `lam` value tested. For + most algorithms within pybaselines, this corresponds to + ``sum(weights * (data - baseline)**2)``. * 'penalty': numpy.ndarray, shape (P,) Only returned if `opt_method` is 'U-curve'. The computed non-normalized penalty values for each `lam` value tested. - * 'rss': numpy.ndarray, shape (P,) - Only returned if `opt_method` is 'GCV' or 'BIC'. The weighted residual sum of - squares (eg. ``(w * (y - baseline)**2).sum()`` for each `lam` value tested. * 'trace': numpy.ndarray, shape (P,) Only returned if `opt_method` is 'GCV' or 'BIC. The computed trace of the smoother matrix for each `lam` value tested, which signifies the effective dimension @@ -804,7 +806,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, elif selected_method in ('gcv', 'bic'): baseline, params = _optimize_ed( y, selected_method, method, method_kws, baseline_func, fitting_object, lam_range, - rho + rho, n_samples ) else: raise ValueError(f'{opt_method} is not a supported opt_method input') @@ -1000,7 +1002,7 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, - lam_range, rho): + lam_range, rho, n_samples): """ Optimizes the regularization coefficient using criteria based on the effective dimension. @@ -1022,6 +1024,8 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, _description_ rho : _type_, optional _description_. Default is None. + n_samples : _type_, optional + _description_. Returns ------- @@ -1044,55 +1048,32 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, else: if rho < 1: raise ValueError('rho must be >= 1') - if 'pspline' in method or method in ('mixture_model', 'irsqr'): - spline_fit = True - else: - spline_fit = False - using_aspls = 'aspls' in method - using_drpls = 'drpls' in method - using_iasls = 'iasls' in method - if any((using_aspls, using_drpls, using_iasls)): - raise NotImplementedError(f'{method} method is not currently supported') - elif method == 'beads': # only supported for L-curve-based optimization options + if method == 'beads': # only supported for L-curve-based optimization options raise NotImplementedError( f'optimize_pls does not support the beads method for {opt_method}' ) - # some methods have different defaults, so have to inspect them - method_signature = inspect.signature(baseline_func).parameters - penalty_kwargs = {} - penalty_kwargs['diff_order'] = method_kws.get( - 'diff_order', method_signature['diff_order'].default - ) - if not spline_fit: - _, _, penalized_system = baseline_obj._setup_whittaker(y, **penalty_kwargs) - else: - for key in ('spline_degree', 'num_knots'): - penalty_kwargs[key] = method_kws.get(key, method_signature[key].default) - _, _, penalized_system = baseline_obj._setup_spline(y, **penalty_kwargs) - + using_iasls = 'iasls' in method n_lams = len(lam_range) min_metric = np.inf metrics = np.empty(n_lams) traces = np.empty(n_lams) - resid_sum_sqs = np.empty(n_lams) + fidelity = np.empty(n_lams) for i, lam in enumerate(lam_range): fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) - penalized_system.update_lam(lam) - # have to ensure weights are sorted to match how their ordering during fitting - # for the effective dimension calc - trace = penalized_system.effective_dimension( - _sort_array(fit_params['weights'], baseline_obj._sort_order) - ) + trace = fit_params['result'].effective_dimension(n_samples) # TODO should just combine the rss calc with optimize_lcurve; should all terms # that do not depend on lam be added to rss/fidelity?? Or just ignore? Affected # methods are drpls and iasls + # TODO should fidelity calc be directly added to the result objects so that this + # can be handled directly? if using_iasls: - resid_sum_sq = ((fit_params['weights'] * (y - fit_baseline))**2).sum() + weights = fit_params['weights']**2 else: - resid_sum_sq = fit_params['weights'] @ (y - fit_baseline)**2 + weights = fit_params['weights'] + fit_fidelity = weights @ (y - fit_baseline)**2 if use_gcv: # GCV = (1/N) * RSS / (1 - rho * trace / N)**2 == RSS * N / (N - rho * trace)**2 # Note that some papers use different terms for fidelity (eg. RSS / N vs just RSS), @@ -1101,13 +1082,13 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, # (https://doi.org/10.1021/ac034173t) uses the same GCV score # formulation for penalized splines and Whittaker smoothing, respectively (using a # fidelity term of just RSS), so this should be correct - metric = resid_sum_sq * baseline_obj._size / (baseline_obj._size - rho * trace)**2 + metric = fit_fidelity * baseline_obj._size / (baseline_obj._size - rho * trace)**2 else: # BIC = -2 * l + ln(N) * ED, where l == log likelihood and # ED == effective dimension ~ trace # For Gaussian errors: BIC ~ N * ln(RSS / N) + ln(N) * trace metric = ( - baseline_obj._size * np.log(resid_sum_sq / baseline_obj._size) + baseline_obj._size * np.log(fit_fidelity / baseline_obj._size) + np.log(baseline_obj._size) * trace ) @@ -1119,11 +1100,11 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, metrics[i] = metric traces[i] = trace - resid_sum_sqs[i] = resid_sum_sq + fidelity[i] = fit_fidelity params = { 'optimal_parameter': best_lam, 'metric': metrics, 'trace': traces, - 'rss': resid_sum_sqs, 'method_params': best_params + 'fidelity': fidelity, 'method_params': best_params } return baseline, params diff --git a/pybaselines/two_d/_spline_utils.py b/pybaselines/two_d/_spline_utils.py index 92f39c81..437594f7 100644 --- a/pybaselines/two_d/_spline_utils.py +++ b/pybaselines/two_d/_spline_utils.py @@ -8,9 +8,9 @@ import numpy as np from scipy.sparse import kron -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve -from .._compat import _sparse_col_index, csr_object +from .._compat import csr_object from .._spline_utils import _spline_basis, _spline_knots from .._validation import _check_array, _check_scalar_variable from ._whittaker_utils import PenalizedSystem2D, _face_splitting @@ -144,7 +144,7 @@ def basis(self): return self._basis def _make_btwb(self, weights): - """Computes ``Basis.T @ Weights @ Basis`` using a more efficient method. + """Computes ``Basis.T @ Weights @ Basis`` as a generalized linear array model. Returns ------- @@ -158,6 +158,11 @@ def _make_btwb(self, weights): """ # do not save intermediate results since they are memory intensive for high number of bases + # Note to self: F is fully dense, such that B.T @ W @ B + P is also fully dense; it is + # still kept as a sparse system since solving the dense system is slower and + # significantly more memory intensive, even with scipy.linalg.solve with assume_a='sym' or + # 'pos'; using solve[h]_banded also offers no significant speed up, although memory usage + # is comparable to spsolve F = csr_object( np.transpose( (self._G_r.T @ weights @ self._G_c).reshape(( @@ -370,93 +375,3 @@ def tck(self): raise ValueError('No spline coefficients, need to call "solve_pspline" first.') knots, degree = self.basis.tk return knots, self.coef.reshape(self.basis._num_bases), degree - - def effective_dimension(self, weights=None, penalty=None, n_samples=0): - """ - Calculates the effective dimension from the trace of the hat matrix. - - For typical P-spline smoothing, the linear equation would be - ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and ``v = B @ c``. Then the hat matrix - would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` or, equivalently - ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression is preferred - since it reduces the dimensionality. The effective dimension for the system - can be estimated as the trace of the hat matrix. - - Parameters - ---------- - weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional - The weights. Default is None, which will use ones. - penalty : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (``P * Q``, ``P * Q``) - The finite difference penalty matrix. Default is None, which will use the - object's penalty. - n_samples : int, optional - If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic - trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables - (eg. either -1 or 1). - - Returns - ------- - trace : float - The trace of the hat matrix, denoting the effective dimension for - the system. - - Raises - ------ - TypeError - Raised if `n_samples` is not 0 and a non-positive integer. - - References - ---------- - Eilers, P., et al. Fast and compact smoothing on large multidimensional grids. Computational - Statistics and Data Analysis, 2006, 50(1), 61-76. - - Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian - smoothing splines. Communications in Statistics - Simulation and Computation, (1990), - 19(2), 433-450. - - Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on - Simplicity in Algorithms (SOSA), (2021), 142-155. - - """ - if weights is None: - weights = np.ones(self._num_bases) - elif weights.ndim == 1: - weights = weights.reshape((len(self.basis.x), len(self.basis.z))) - - if penalty is None: - penalty = self.penalty - - btwb = self.basis._make_btwb(weights) - - # TODO could maybe make default n_samples to None and decide to use analytical or - # stochastic trace based on data size; data size > 1000 use stochastic with default - # n_samples = 100? - if n_samples == 0: - use_analytic = True - else: - if n_samples < 0 or not isinstance(n_samples, int): - raise TypeError('n_samples must be a positive integer') - use_analytic = False - - lhs = (btwb + penalty).tocsc(copy=False) - tot_bases = np.prod(self._num_bases) - if use_analytic: - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - trace = 0 - factorization = factorized(lhs) - btwb = btwb.tocsc(copy=False) - for i in range(tot_bases): - trace += factorization(_sparse_col_index(btwb, i))[i] - else: - # TODO should the rng seed be settable? Maybe a Baseline2D property - rng_samples = np.random.default_rng(1234).choice( - [-1., 1.], size=(tot_bases, n_samples) - ) - # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - hat_u = self.direct_solve(lhs, btwb @ rng_samples) - # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - # stochastic trace is the average of the trace of u.T @ H @ u; - trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples - - return trace diff --git a/pybaselines/two_d/_whittaker_utils.py b/pybaselines/two_d/_whittaker_utils.py index 9b429125..a5283c7e 100644 --- a/pybaselines/two_d/_whittaker_utils.py +++ b/pybaselines/two_d/_whittaker_utils.py @@ -11,7 +11,7 @@ import numpy as np from scipy.linalg import eig_banded, eigh_tridiagonal, solve from scipy.sparse import kron -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve from .._banded_utils import diff_penalty_diagonals, diff_penalty_matrix from .._compat import identity @@ -238,100 +238,6 @@ def reset_diagonal(self): """Sets the main diagonal of the penalty matrix back to its original value.""" self.penalty.setdiag(self.main_diagonal) - def effective_dimension(self, weights=None, penalty=None, n_samples=0): - """ - Calculates the effective dimension from the trace of the hat matrix. - - For typical Whittaker smoothing, the linear equation would be - ``(W + lam * P) x = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. - The effective dimension for the system can be estimated as the trace - of the hat matrix. - - Parameters - ---------- - weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional - The weights. Default is None, which will use ones. - penalty : scipy.sparse.spmatrix or scipy.sparse.sparray, shape (``M * N``, ``M * N``) - The finite difference penalty matrix. Default is None, which will use the - object's penalty. - n_samples : int, optional - If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic - trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables - (eg. either -1 or 1). - - Returns - ------- - trace : float - The trace of the hat matrix, denoting the effective dimension for - the system. - - Raises - ------ - TypeError - Raised if `n_samples` is not 0 and a non-positive integer. - - References - ---------- - Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. - - Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian - smoothing splines. Communications in Statistics - Simulation and Computation, (1990), - 19(2), 433-450. - - Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on - Simplicity in Algorithms (SOSA), (2021), 142-155. - - """ - # TODO could maybe make default n_samples to None and decide to use analytical or - # stochastic trace based on tot_bases; tot_bases > 1000 use stochastic with default - # n_samples = 200? - tot_bases = np.prod(self._num_bases) - if n_samples == 0: - use_analytic = True - else: - if n_samples < 0 or not isinstance(n_samples, int): - raise TypeError('n_samples must be a positive integer') - use_analytic = False - # TODO should the rng seed be settable? Maybe a Baseline2D property - rng_samples = np.random.default_rng(1234).choice( - [-1., 1.], size=(tot_bases, n_samples) - ) - - if weights is None: - weights = np.ones(tot_bases) - elif weights.ndim == 2: - weights = weights.ravel() - - reset_penalty = False - if penalty is None: - lhs = self.add_diagonal(weights) - reset_penalty = True - else: - penalty.setdiag(penalty.diagonal() + weights) - lhs = penalty - - if use_analytic: - # compute each diagonal of the hat matrix separately so that the full - # hat matrix does not need to be stored in memory - eye = np.zeros(tot_bases) - trace = 0 - factorization = factorized(lhs.tocsc()) - for i in range(tot_bases): - eye[i] = weights[i] - trace += factorization(eye)[i] - eye[i] = 0 - - else: - # H @ u == (W + lam * P)^-1 @ (w * u) - hat_u = self.direct_solve(lhs, weights[:, None] * rng_samples) - # stochastic trace is the average of the trace of u.T @ H @ u; - trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples - - if reset_penalty: - self.add_diagonal(0) - - return trace - class WhittakerSystem2D(PenalizedSystem2D): """ @@ -788,97 +694,3 @@ def _calc_dof(self, weights, assume_a='pos'): ) return dof.diagonal().reshape(self._num_bases) - - def effective_dimension(self, weights=None, penalty=None, n_samples=0): - """ - Calculates the effective dimension from the trace of the hat matrix. - - For typical Whittaker smoothing, the linear equation would be - ``(W + lam * P) v = W @ y``. Then the hat matrix would be ``(W + lam * P)^-1 @ W``. - If using SVD, the linear equation is ``(B.T @ W @ B + lam * P) c = B.T @ W @ y`` and - ``v = B @ c``. Then the hat matrix would be ``B @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W)`` - or, equivalently ``(B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B)``. The latter expression - is preferred since it reduces the dimensionality. The effective dimension for the system - can be estimated as the trace of the hat matrix. - - Parameters - ---------- - weights : numpy.ndarray, shape (``M * N``,) or shape (M, N), optional - The weights. Default is None, which will use ones. - penalty : numpy.ndarray or scipy.sparse.spmatrix or scipy.sparse.sparray - The finite difference penalty matrix with shape (``M * N``, ``M * N``). Default - is None, which will use the object's penalty. - n_samples : int, optional - If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic - trace estimation with a matrix of (``M * N``, `n_samples`) Rademacher random variables - (eg. either -1 or 1). - - Returns - ------- - trace : float - The trace of the hat matrix, denoting the effective dimension for - the system. - - Raises - ------ - TypeError - Raised if `n_samples` is not 0 and a non-positive integer. - - Notes - ----- - If using SVD, the trace will be lower than the actual analytical trace. The relative - difference is reduced as the number of eigenvalues selected approaches the data - size. - - References - ---------- - Biessy, G. Whittaker-Henderson smoothing revisited: A modern statistical framework for - practical use. ASTIN Bulletin, 2025, 1-31. - - Hutchinson, M. A stochastic estimator of the trace of the influence matrix for laplacian - smoothing splines. Communications in Statistics - Simulation and Computation, (1990), - 19(2), 433-450. - - Meyer, R., et al. Hutch++: Optimal Stochastic Trace Estimation. 2021 Symposium on - Simplicity in Algorithms (SOSA), (2021), 142-155. - - """ - if not self._using_svd: - return super().effective_dimension(weights, penalty, n_samples) - - # TODO could maybe make default n_samples to None and decide to use analytical or - # stochastic trace based on tot_bases; tot_bases > 1000 use stochastic with default - # n_samples = 200? - tot_bases = np.prod(self._num_bases) - if n_samples == 0: - use_analytic = True - else: - if n_samples < 0 or not isinstance(n_samples, int): - raise TypeError('n_samples must be a positive integer') - use_analytic = False - # TODO should the rng seed be settable? Maybe a Baseline2D property - rng_samples = np.random.default_rng(1234).choice( - [-1., 1.], size=(tot_bases, n_samples) - ) - - if weights is None: - weights = np.ones(self._num_bases) - elif weights.ndim == 1: - weights = weights.reshape(self._num_points) - - if use_analytic: - trace = self._calc_dof(weights).sum() - else: - btwb = self._make_btwb(weights) - lhs = btwb.copy() - np.fill_diagonal(lhs, lhs.diagonal() + self.penalty) - # H @ u == (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - hat_u = solve( - lhs, btwb @ rng_samples, overwrite_a=True, overwrite_b=True, - check_finite=False, assume_a='pos' - ) - # u.T @ H @ u -> u.T @ (B.T @ W @ B + lam * P)^-1 @ (B.T @ W @ B) @ u - # stochastic trace is the average of the trace of u.T @ H @ u; - trace = np.einsum('ij,ij->', rng_samples, hat_u) / n_samples - - return trace diff --git a/tests/test_banded_utils.py b/tests/test_banded_utils.py index 761c3e3d..383d2b17 100644 --- a/tests/test_banded_utils.py +++ b/tests/test_banded_utils.py @@ -12,11 +12,11 @@ from numpy.testing import assert_allclose, assert_array_equal import pytest from scipy.linalg import cholesky_banded -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve from pybaselines import _banded_utils, _spline_utils from pybaselines._banded_solvers import penta_factorize -from pybaselines._compat import _sparse_col_index, dia_object, diags, identity +from pybaselines._compat import dia_object, diags, identity @pytest.mark.parametrize('data_size', (10, 1001)) @@ -1008,91 +1008,7 @@ def test_penalized_system_factorize_solve(data_fixture, diff_order, allow_lower, assert callable(output_factorization) output = penalized_system.factorized_solve(output_factorization, weights * y) - assert_allclose(output, expected_solution, rtol=1e-7, atol=1e-10) - - -@pytest.mark.parametrize('diff_order', (1, 2, 3)) -@pytest.mark.parametrize('allow_lower', (True, False)) -@pytest.mark.parametrize('allow_penta', (True, False)) -@pytest.mark.parametrize('size', (100, 501)) -def test_penalized_system_effective_dimension(diff_order, allow_lower, allow_penta, size): - """ - Tests the effective_dimension method of a PenalizedSystem object. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - """ - weights = np.random.default_rng(0).normal(0.8, 0.05, size) - weights = np.clip(weights, 0, 1).astype(float) - - lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] - expected_penalty = _banded_utils.diff_penalty_diagonals( - size, diff_order=diff_order, lower_only=False - ) - sparse_penalty = dia_object( - (lam * expected_penalty, np.arange(diff_order, -(diff_order + 1), -1)), - shape=(size, size) - ).tocsr() - weights_matrix = diags(weights, format='csc') - factorization = factorized(weights_matrix + sparse_penalty) - expected_ed = 0 - for i in range(size): - expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] - - penalized_system = _banded_utils.PenalizedSystem( - size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, - reverse_diags=False, allow_penta=allow_penta - ) - output = penalized_system.effective_dimension(weights, n_samples=0) - - assert_allclose(output, expected_ed, rtol=1e-7, atol=1e-10) - - -@pytest.mark.parametrize('diff_order', (1, 2, 3)) -@pytest.mark.parametrize('allow_lower', (True, False)) -@pytest.mark.parametrize('allow_penta', (True, False)) -@pytest.mark.parametrize('size', (100, 501)) -@pytest.mark.parametrize('n_samples', (100, 201)) -def test_penalized_system_effective_dimension_stochastic(diff_order, allow_lower, allow_penta, - size, n_samples): - """ - Tests the stochastic effective_dimension calculation of a PenalizedSystem object. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - """ - weights = np.random.default_rng(0).normal(0.8, 0.05, size) - weights = np.clip(weights, 0, 1).astype(float) - - lam = {1: 1e2, 2: 1e5, 3: 1e8}[diff_order] - - penalized_system = _banded_utils.PenalizedSystem( - size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, - reverse_diags=False, allow_penta=allow_penta - ) - # true solution is already verified by other tests, so use that as "known" in - # this test to only examine the relative difference from using stochastic estimation - expected_ed = penalized_system.effective_dimension(weights, n_samples=0) - - output = penalized_system.effective_dimension(weights, n_samples=n_samples) - - assert_allclose(output, expected_ed, rtol=5e-1, atol=1e-5) - - -@pytest.mark.parametrize('n_samples', (-1, 50.5)) -def test_penalized_system_effective_dimension_stochastic_invalid_samples(data_fixture, n_samples): - """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" - x, y = data_fixture - weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) - weights = np.clip(weights, 0, 1).astype(float) - - penalized_system = _banded_utils.PenalizedSystem(x.size) - with pytest.raises(TypeError): - penalized_system.effective_dimension(weights, n_samples=n_samples) + assert_allclose(output, expected_solution, rtol=2e-7, atol=1e-10) @pytest.mark.parametrize('dtype', (float, np.float32)) diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index e52b268c..578edd4a 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -675,7 +675,7 @@ class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): """Class for testing optimize_pls baseline.""" func_name = "optimize_pls" - checked_keys = ('optimal_parameter', 'metric') + checked_keys = ('optimal_parameter', 'metric', 'fidelity') # will need to change checked_keys if default method is changed checked_method_keys = ('weights', 'tol_history') @@ -683,9 +683,9 @@ class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): def test_output(self, opt_method): """Ensures correct output parameters for different optimization methods.""" if opt_method in ('GCV', 'BIC'): - additional_keys = ('rss', 'trace') + additional_keys = ['trace'] else: - additional_keys = ('fidelity', 'penalty') + additional_keys = ['penalty'] super().test_output(additional_keys=additional_keys, opt_method=opt_method) @pytest.mark.parametrize( diff --git a/tests/test_spline_utils.py b/tests/test_spline_utils.py index f3e31e9e..f5ce204d 100644 --- a/tests/test_spline_utils.py +++ b/tests/test_spline_utils.py @@ -14,10 +14,10 @@ from scipy.interpolate import BSpline from scipy.linalg import cholesky_banded from scipy.sparse import issparse -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve from pybaselines import _banded_utils, _spline_utils -from pybaselines._compat import diags, _HAS_NUMBA, _sparse_col_index +from pybaselines._compat import diags, _HAS_NUMBA def _nieve_basis_matrix(x, knots, spline_degree): @@ -356,95 +356,6 @@ def test_pspline_make_btwb(data_fixture, num_knots, spline_degree, diff_order, l ) -@pytest.mark.parametrize('num_knots', (20, 101)) -@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) -@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) -@pytest.mark.parametrize('lower_only', (True, False)) -def test_pspline_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, - lower_only): - """ - Tests the effective_dimension method of a PSpline object. - - The effective dimension for penalized spline smoothing should be - ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, - ``D.T @ D`` is the penalty, and `B` is the spline basis. - - """ - x, y = data_fixture - x = x.astype(float) - weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) - weights = np.clip(weights, 0, 1).astype(float) - - knots = _spline_utils._spline_knots(x, num_knots, spline_degree, True) - basis = _spline_utils._spline_basis(x, knots, spline_degree) - num_bases = basis.shape[1] - penalty_matrix = _banded_utils.diff_penalty_matrix(num_bases, diff_order=diff_order) - - btwb = basis.T @ diags(weights, format='csr') @ basis - factorization = factorized(btwb + penalty_matrix) - expected_ed = 0 - for i in range(num_bases): - expected_ed += factorization(_sparse_col_index(btwb, i))[i] - - spline_basis = _spline_utils.SplineBasis( - x, num_knots=num_knots, spline_degree=spline_degree - ) - pspline = _spline_utils.PSpline( - spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only - ) - - output = pspline.effective_dimension(weights, n_samples=0) - assert_allclose(output, expected_ed, rtol=1e-10, atol=1e-12) - - -@pytest.mark.parametrize('num_knots', (20, 101)) -@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, 4, 5)) -@pytest.mark.parametrize('diff_order', (1, 2, 3, 4)) -@pytest.mark.parametrize('lower_only', (True, False)) -@pytest.mark.parametrize('n_samples', (100, 201)) -def test_pspline_stochastic_effective_dimension(data_fixture, num_knots, spline_degree, diff_order, - lower_only, n_samples): - """ - Tests the effective_dimension method of a PSpline object. - - The effective dimension for penalized spline smoothing should be - ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, - ``D.T @ D`` is the penalty, and `B` is the spline basis. - - """ - x, y = data_fixture - x = x.astype(float) - weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) - weights = np.clip(weights, 0, 1).astype(float) - - spline_basis = _spline_utils.SplineBasis( - x, num_knots=num_knots, spline_degree=spline_degree - ) - pspline = _spline_utils.PSpline( - spline_basis, lam=1, diff_order=diff_order, allow_lower=lower_only - ) - # true solution is already verified by other tests, so use that as "known" in - # this test to only examine the relative difference from using stochastic estimation - expected_ed = pspline.effective_dimension(weights, n_samples=0) - - output = pspline.effective_dimension(weights, n_samples=n_samples) - assert_allclose(output, expected_ed, rtol=5e-2, atol=1e-5) - - -@pytest.mark.parametrize('n_samples', (-1, 50.5)) -def test_pspline_stochastic_effective_dimension_invalid_samples(data_fixture, n_samples): - """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" - x, y = data_fixture - x = x.astype(float) - weights = np.random.default_rng(0).normal(0.8, 0.05, x.size) - weights = np.clip(weights, 0, 1).astype(float) - - spline_basis = _spline_utils.SplineBasis(x) - pspline = _spline_utils.PSpline(spline_basis) - with pytest.raises(TypeError): - pspline.effective_dimension(weights, n_samples=n_samples) - - def check_penalized_spline(penalized_system, expected_penalty, lam, diff_order, allow_lower, reverse_diags, spline_degree, num_knots, data_size): diff --git a/tests/two_d/test_spline_utils.py b/tests/two_d/test_spline_utils.py index a4b57e38..b6cdafd6 100644 --- a/tests/two_d/test_spline_utils.py +++ b/tests/two_d/test_spline_utils.py @@ -11,10 +11,10 @@ import pytest from scipy import interpolate from scipy.sparse import issparse, kron -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve -from pybaselines._compat import _sparse_col_index, identity -from pybaselines._banded_utils import difference_matrix, diff_penalty_matrix +from pybaselines._compat import identity +from pybaselines._banded_utils import difference_matrix from pybaselines.two_d import _spline_utils from ..base_tests import get_2dspline_inputs @@ -325,106 +325,3 @@ def test_spline_basis_tk_readonly(data_fixture2d): with pytest.raises(AttributeError): spline_basis.tk = (1, 2) - - -@pytest.mark.parametrize('num_knots', (10, (11, 20))) -@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, (2, 3))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) -def test_pspline_effective_dimension(data_fixture2d, num_knots, spline_degree, diff_order, lam): - """ - Tests the effective_dimension method of a PSpline object. - - The effective dimension for penalized spline smoothing should be - ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, - ``D.T @ D`` is the penalty, and `B` is the spline basis. - - """ - x, z, y = data_fixture2d - ( - num_knots_r, num_knots_c, spline_degree_r, spline_degree_c, - lam_r, lam_c, diff_order_r, diff_order_c - ) = get_2dspline_inputs(num_knots, spline_degree, lam, diff_order) - - knots_r = _spline_utils._spline_knots(x, num_knots_r, spline_degree_r, True) - basis_r = _spline_utils._spline_basis(x, knots_r, spline_degree_r) - - knots_c = _spline_utils._spline_knots(z, num_knots_c, spline_degree_c, True) - basis_c = _spline_utils._spline_basis(z, knots_c, spline_degree_c) - - num_bases = (basis_r.shape[1], basis_c.shape[1]) - weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) - weights = np.clip(weights, 0, 1, dtype=float) - - spline_basis = _spline_utils.SplineBasis2D( - x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False - ) - # make B.T @ W @ B using generalized linear array model since it's much faster and - # already verified in other tests - btwb = spline_basis._make_btwb(weights).tocsc() - P_r = kron(diff_penalty_matrix(num_bases[0], diff_order=diff_order_r), identity(num_bases[1])) - P_c = kron(identity(num_bases[0]), diff_penalty_matrix(num_bases[1], diff_order=diff_order_c)) - penalty = lam_r * P_r + lam_c * P_c - - lhs = btwb + penalty - factorization = factorized(lhs.tocsc()) - expected_ed = 0 - for i in range(np.prod(num_bases)): - expected_ed += factorization(_sparse_col_index(btwb, i))[i] - - pspline = _spline_utils.PSpline2D(spline_basis, lam=lam, diff_order=diff_order) - - # ensure weights work both raveled and unraveled - output = pspline.effective_dimension(weights, n_samples=0) - assert_allclose(output, expected_ed, rtol=1e-14, atol=1e-10) - - output2 = pspline.effective_dimension(weights.ravel(), n_samples=0) - assert_allclose(output2, expected_ed, rtol=1e-14, atol=1e-10) - - -@pytest.mark.parametrize('num_knots', (10, (11, 20))) -@pytest.mark.parametrize('spline_degree', (0, 1, 2, 3, (2, 3))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e-2, (1e1, 1e2))) -@pytest.mark.parametrize('n_samples', (100, 201)) -def test_pspline_effective_dimension_stochastic(data_fixture2d, num_knots, spline_degree, - diff_order, lam, n_samples): - """ - Tests the effective_dimension method of a PSpline object. - - The effective dimension for penalized spline smoothing should be - ``trace((B.T @ W @ B + lam * D.T @ D)^-1 @ B.T @ W @ B)``, where `W` is the weight matrix, - ``D.T @ D`` is the penalty, and `B` is the spline basis. - - """ - x, z, y = data_fixture2d - weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) - weights = np.clip(weights, 0, 1, dtype=float) - - spline_basis = _spline_utils.SplineBasis2D( - x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False - ) - pspline = _spline_utils.PSpline2D(spline_basis, lam=lam, diff_order=diff_order) - # true solution is already verified by other tests, so use that as "known" in - # this test to only examine the relative difference from using stochastic estimation - expected_ed = pspline.effective_dimension(weights, n_samples=0) - - # ensure weights work both raveled and unraveled - output = pspline.effective_dimension(weights, n_samples=n_samples) - assert_allclose(output, expected_ed, rtol=1e-1, atol=1e-5) - - output2 = pspline.effective_dimension(weights.ravel(), n_samples=n_samples) - assert_allclose(output2, expected_ed, rtol=1e-1, atol=1e-5) - - -@pytest.mark.parametrize('n_samples', (-1, 50.5)) -def test_pspline_stochastic_effective_dimension_invalid_samples(data_fixture2d, n_samples): - """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" - x, z, y = data_fixture2d - weights = np.random.default_rng(0).normal(0.8, 0.05, y.size) - weights = np.clip(weights, 0, 1, dtype=float) - - spline_basis = _spline_utils.SplineBasis2D(x, z, num_knots=10) - pspline = _spline_utils.PSpline2D(spline_basis) - with pytest.raises(TypeError): - pspline.effective_dimension(weights, n_samples=n_samples) diff --git a/tests/two_d/test_whittaker_utils.py b/tests/two_d/test_whittaker_utils.py index d2ad4149..f8dbaf72 100644 --- a/tests/two_d/test_whittaker_utils.py +++ b/tests/two_d/test_whittaker_utils.py @@ -11,10 +11,10 @@ import pytest from scipy.linalg import eig_banded, solve from scipy.sparse import issparse, kron -from scipy.sparse.linalg import factorized, spsolve +from scipy.sparse.linalg import spsolve -from pybaselines._banded_utils import diff_penalty_diagonals, diff_penalty_matrix -from pybaselines._compat import _sparse_col_index, dia_object, diags, identity +from pybaselines._banded_utils import diff_penalty_diagonals +from pybaselines._compat import dia_object, identity from pybaselines.two_d import _spline_utils, _whittaker_utils from pybaselines.utils import difference_matrix @@ -588,196 +588,3 @@ def test_whittaker_system_update_penalty(data_fixture2d, num_eigens, diff_order, whittaker_system.penalty, (new_penalty_rows + new_penalty_cols).diagonal(), rtol=1e-12, atol=1e-12 ) - - -@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) -def test_penalized_system_effective_dimension(shape, diff_order, lam): - """ - Tests the effective_dimension method of PenalizedSystem2D objects. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - """ - *_, lam_r, lam_c, diff_order_r, diff_order_c = get_2dspline_inputs( - lam=lam, diff_order=diff_order - ) - - weights = np.random.default_rng(0).normal(0.8, 0.05, shape) - weights = np.clip(weights, 0, 1).astype(float) - - P_r = lam_r * kron(diff_penalty_matrix(shape[0], diff_order=diff_order_r), identity(shape[1])) - P_c = lam_c * kron(identity(shape[0]), diff_penalty_matrix(shape[1], diff_order=diff_order_c)) - penalty = P_r + P_c - - weights_matrix = diags(weights.ravel(), format='csc') - factorization = factorized(weights_matrix + penalty) - expected_ed = 0 - for i in range(np.prod(shape)): - expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] - - penalized_system = _whittaker_utils.PenalizedSystem2D(shape, lam=lam, diff_order=diff_order) - - # ensure weights work both raveled and unraveled - output = penalized_system.effective_dimension(weights, n_samples=0) - assert_allclose(output, expected_ed, rtol=1e-14, atol=1e-10) - - output2 = penalized_system.effective_dimension(weights.ravel(), n_samples=0) - assert_allclose(output2, expected_ed, rtol=1e-14, atol=1e-10) - - -@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) -@pytest.mark.parametrize('n_samples', (100, 201)) -def test_penalized_system_effective_dimension_stochastic(shape, diff_order, lam, - n_samples): - """ - Tests the stochastic effective_dimension method of PenalizedSystem2D objects. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - """ - weights = np.random.default_rng(0).normal(0.8, 0.05, shape) - weights = np.clip(weights, 0, 1).astype(float) - - whittaker_system = _whittaker_utils.PenalizedSystem2D(shape, lam=lam, diff_order=diff_order) - - # true solution is already verified by other tests, so use that as "known" in - # this test to only examine the relative difference from using stochastic estimation - expected_ed = whittaker_system.effective_dimension(weights, n_samples=0) - - # ensure weights work both raveled and unraveled - output = whittaker_system.effective_dimension(weights, n_samples=n_samples) - assert_allclose(output, expected_ed, rtol=1e-1, atol=1e-4) - - output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=n_samples) - assert_allclose(output2, expected_ed, rtol=1e-1, atol=1e-4) - - -@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) -@pytest.mark.parametrize('use_svd', (True, False)) -def test_whittaker_system_effective_dimension(shape, diff_order, lam, use_svd): - """ - Tests the effective_dimension method of WhittakerSystem2D objects. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - Tests both the analytic and SVD-based solutions. - - """ - *_, lam_r, lam_c, diff_order_r, diff_order_c = get_2dspline_inputs( - lam=lam, diff_order=diff_order - ) - - weights = np.random.default_rng(0).normal(0.8, 0.05, shape) - weights = np.clip(weights, 0, 1).astype(float) - - P_r = lam_r * kron(diff_penalty_matrix(shape[0], diff_order=diff_order_r), identity(shape[1])) - P_c = lam_c * kron(identity(shape[0]), diff_penalty_matrix(shape[1], diff_order=diff_order_c)) - penalty = P_r + P_c - - weights_matrix = diags(weights.ravel(), format='csc') - factorization = factorized(weights_matrix + penalty) - expected_ed = 0 - for i in range(np.prod(shape)): - expected_ed += factorization(_sparse_col_index(weights_matrix, i))[i] - - # the relative error on the trace when using SVD decreases as the number of - # eigenvalues approaches the data size, so just test with a value very close - if use_svd: - num_eigens = (shape[0] - 1, shape[1] - 1) - atol = 1e-1 - rtol = 5e-2 - else: - num_eigens = None - atol = 1e-10 - rtol = 1e-14 - whittaker_system = _whittaker_utils.WhittakerSystem2D( - shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens - ) - - # ensure weights work both raveled and unraveled - output = whittaker_system.effective_dimension(weights, n_samples=0) - assert_allclose(output, expected_ed, rtol=rtol, atol=atol) - - output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=0) - assert_allclose(output2, expected_ed, rtol=rtol, atol=atol) - - -@pytest.mark.parametrize('shape', ((20, 23), (5, 50), (50, 5))) -@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) -@pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) -@pytest.mark.parametrize('use_svd', (True, False)) -@pytest.mark.parametrize('n_samples', (100, 201)) -def test_whittaker_system_effective_dimension_stochastic(shape, diff_order, lam, use_svd, - n_samples): - """ - Tests the stochastic effective_dimension method of WhittakerSystem2D objects. - - The effective dimension for Whittaker smoothing should be - ``trace((W + lam * D.T @ D)^-1 @ W)``, where `W` is the weight matrix, and - ``D.T @ D`` is the penalty. - - Tests both the analytic and SVD-based solutions. - - """ - weights = np.random.default_rng(0).normal(0.8, 0.05, shape) - weights = np.clip(weights, 0, 1).astype(float) - - if use_svd: - num_eigens = (shape[0] - 1, shape[1] - 1) - rtol = 1e-2 - else: - num_eigens = None - rtol = 1e-1 - - whittaker_system = _whittaker_utils.WhittakerSystem2D( - shape, lam=lam, diff_order=diff_order, num_eigens=num_eigens - ) - - # true solution is already verified by other tests, so use that as "known" in - # this test to only examine the relative difference from using stochastic estimation - expected_ed = whittaker_system.effective_dimension(weights, n_samples=0) - - # ensure weights work both raveled and unraveled - output = whittaker_system.effective_dimension(weights, n_samples=n_samples) - assert_allclose(output, expected_ed, rtol=rtol, atol=1e-4) - - output2 = whittaker_system.effective_dimension(weights.ravel(), n_samples=n_samples) - assert_allclose(output2, expected_ed, rtol=rtol, atol=1e-4) - - -@pytest.mark.parametrize('n_samples', (-1, 50.5)) -def test_penalized_system_effective_dimension_stochastic_invalid_samples(small_data2d, n_samples): - """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" - weights = np.random.default_rng(0).normal(0.8, 0.05, small_data2d.shape) - weights = np.clip(weights, 0, 1).astype(float) - - penalized_system = _whittaker_utils.PenalizedSystem2D(small_data2d.shape) - with pytest.raises(TypeError): - penalized_system.effective_dimension(weights, n_samples=n_samples) - - -@pytest.mark.parametrize('n_samples', (-1, 50.5)) -def test_whittaker_system_effective_dimension_stochastic_invalid_samples(small_data2d, n_samples): - """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" - weights = np.random.default_rng(0).normal(0.8, 0.05, small_data2d.shape) - weights = np.clip(weights, 0, 1).astype(float) - - penalized_system = _whittaker_utils.WhittakerSystem2D(small_data2d.shape, num_eigens=None) - with pytest.raises(TypeError): - penalized_system.effective_dimension(weights, n_samples=n_samples) - - penalized_system = _whittaker_utils.WhittakerSystem2D(small_data2d.shape, num_eigens=5) - with pytest.raises(TypeError): - penalized_system.effective_dimension(weights, n_samples=n_samples) From b3cbcc4ffa4fb22d040b65dda82aca9ab4e29400 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:33:42 -0500 Subject: [PATCH 22/38] DOCS: Add results module to docs --- docs/_templates/autosummary/class.rst | 32 ++++++++++++++++++++++++++ docs/_templates/autosummary/module.rst | 1 + docs/api/index.rst | 1 + docs/api/results.rst | 12 ++++++++++ 4 files changed, 46 insertions(+) create mode 100644 docs/_templates/autosummary/class.rst create mode 100644 docs/api/results.rst diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 100644 index 00000000..34e512ab --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1,32 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + + {% block methods %} + {% if methods %} + .. rubric:: {{ _('Methods') }} + + .. autosummary:: + :template: autosummary/method.rst + :toctree: + :nosignatures: + {% for item in methods %} + {%- if not item.startswith('_') or not item in ['__init__'] %} + ~{{ name }}.{{ item }} + {%- endif -%} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} diff --git a/docs/_templates/autosummary/module.rst b/docs/_templates/autosummary/module.rst index e2c3c4da..846b30d8 100644 --- a/docs/_templates/autosummary/module.rst +++ b/docs/_templates/autosummary/module.rst @@ -19,6 +19,7 @@ .. rubric:: {{ _('Classes') }} .. autosummary:: + :template: autosummary/class.rst :toctree: :nosignatures: {% for item in classes %} diff --git a/docs/api/index.rst b/docs/api/index.rst index a6dc6264..2307b239 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -7,4 +7,5 @@ API Reference Baseline Baseline2D utils + results functional_interface \ No newline at end of file diff --git a/docs/api/results.rst b/docs/api/results.rst new file mode 100644 index 00000000..9b2c03d7 --- /dev/null +++ b/docs/api/results.rst @@ -0,0 +1,12 @@ +Objects for Examining Results +============================= + +.. currentmodule:: pybaselines + +.. autosummary:: + :toctree: ../generated/api/ + :template: autosummary/module.rst + :nosignatures: + :recursive: + + results From 1dd98516e1e678fd35a49aab0a7a1921f06522e2 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:40:18 -0500 Subject: [PATCH 23/38] DOCS: Add extension to add cross references for param dict items Also fixed polynomial coefficient shapes so they are not an incorrect cross reference. --- docs/conf.py | 15 ++++- docs/viewcode_inherit_methods.py | 9 ++- docs/xref_param_dict.py | 110 +++++++++++++++++++++++++++++++ pybaselines/polynomial.py | 28 ++++---- 4 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 docs/xref_param_dict.py diff --git a/docs/conf.py b/docs/conf.py index 09a23f7d..54b89ded 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -52,6 +52,7 @@ 'sphinx_gallery.gen_gallery', 'viewcode_inherit_methods', # custom extension to allow viewcode with inherited methods 'modify_module_docstring', # custom extension to modify module docstrings + 'xref_param_dict', # custom extension to add cross references for parameter dictionary typings ] autosummary_generate = True # enables autosummary extension @@ -173,7 +174,19 @@ # creates cross references for types in docstrings numpydoc_xref_param_type = True -numpydoc_xref_ignore = {'optional', 'shape', 'K', 'L', 'M', 'N', 'P', 'Q', 'or', 'deprecated'} +numpydoc_xref_ignore = { + 'optional', + 'shape', + 'J', + 'K', + 'L', + 'M', + 'N', + 'P', + 'Q', + 'or', + 'deprecated', +} numpydoc_xref_aliases = { 'Sequence': ':term:`python:sequence`', 'Callable': ':term:`python:callable`', diff --git a/docs/viewcode_inherit_methods.py b/docs/viewcode_inherit_methods.py index 329d1eab..67e421c1 100644 --- a/docs/viewcode_inherit_methods.py +++ b/docs/viewcode_inherit_methods.py @@ -53,6 +53,11 @@ def find_super_method(app, modname): For an example of what ``ModuleAnalyzer`` tags and what viewcode expects, see the following issue from Sphinx: https://github.com/sphinx-doc/sphinx/issues/11279. + Raises + ------ + RuntimeError + Raised if an issue occurred when retrieving the tags from `modname`. + """ if modname is None: return @@ -74,8 +79,8 @@ def find_super_method(app, modname): analyzer.find_tags() code = analyzer.code tags = analyzer.tags - except Exception: - return + except Exception as e: + raise RuntimeError('Error within custom viewcode-inherit-methods extension') from e if 'two_d' in modname: new_obj = 'Baseline2D' diff --git a/docs/xref_param_dict.py b/docs/xref_param_dict.py new file mode 100644 index 00000000..4283b804 --- /dev/null +++ b/docs/xref_param_dict.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""Plugin to add cross references for types within returned parameter dictionaries. + +Created on January 25, 2026 +@author: Donald Erb + +""" + +from numpydoc.xref import make_xref + + +def xref_parameter_dict(app, what, name, obj, options, lines): + """ + Adds cross references for items within the parameter dictionaries for baseline methods. + + Makes it so that the types for items within the parameter dictionary are also cross + referenced just like the inputs and returns are. See + https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#event-autodoc-process-docstring + for more details on the Sphinx event. This should be ran after numpydoc has ran since it + expects numpydoc formatting within the docstrings. + + Parameters + ---------- + app : sphinx.application.Sphinx + The Sphinx application object. + what : str + The type of object; can be 'module', 'class', etc. + name : str + The full name of the object being documented. + obj : object + The actual object, eg. the class, function, module, etc. being documented. + options : sphinx.ext.autodoc.Options + The options for the current object's directive. + lines : list[str] + The list of strings for the docstring. Must be modified in-place in order + to change the value. + + Raises + ------ + ValueError + Raised if the formatting for a parameter item is incorrect. + + Notes + ----- + Relies on numpydoc formatting within the sections, so this may break for future numpydoc + releases. For reference, tested on numpydoc versions 1.8.0, 1.9.0, and 1.10.0. + + """ + # parameter dictionary items are only within Baseline[2D] methods or the functional + # interface + if what not in ('method', 'function'): + return + + # if using napoleon instead of numpydoc, this should raise an error, which is desired behavior + # since docstring formatting will not be the same otherwise + xref_aliases = app.config.numpydoc_xref_aliases_complete + xref_ignore = app.config.numpydoc_xref_ignore + + key_type_separator = ': ' + in_return_section = False + for i, line in enumerate(lines): + # NOTE this could be made more robust using regular expressions, but the formatting + # should be kept simple since this is just for internal formatting + # + # Assumes formatting within the Returns sections is like: + # + # params : dict + # * 'key' : value_typing + # Description of value. + if in_return_section and line.startswith(' *'): + key, *value_typing = line.split(key_type_separator) + if not value_typing: + # in case something is incorrectly formatted as "name type" rather than + # "name : type" + raise ValueError( + f'Incorrect parameter dictionary format for {name} at line: "{line}"' + ) + # could split value_typing[0] using ',' to separate things like + # "numpy.ndarray, shape (N,)", but that fails for others like "dict[str, list]", + # so just pass the full typing reference to numpydoc and let it process accordingly + xref = make_xref(value_typing[0], xref_aliases=xref_aliases, xref_ignore=xref_ignore) + lines[i] = key_type_separator.join([key, xref]) + elif in_return_section and (line.startswith(':') or line.startswith('..')): + # other docstring sections after Returns start with things like '.. rubric:: References' + # or ':Raises:' after numpydoc formatting + break + elif line == ':Returns:': + in_return_section = True + + +def setup(app): + """Connects the xref_parameter_dict to the autodoc-process-docstring event. + + Returns + ------- + dict + Relevant information about the extension to pass to Sphinx. See + https://www.sphinx-doc.org/en/master/extdev/index.html for metadata fields. + + """ + # Add a high priority so that numpydoc should process the docstrings first + app.connect('autodoc-process-docstring', xref_parameter_dict, priority=10000) + # according to https://www.sphinx-doc.org/en/master/extdev/index.html, since this + # extension does not store data in the Sphinx environment, and it is not doing anything + # that is not parallel safe, then parallel_read_safe can be set to True; plus + # numpydoc is set as True for parallel_read_safe, so the same logic should apply + return { + 'version': '0.0.1', + 'parallel_read_safe': True, + } diff --git a/pybaselines/polynomial.py b/pybaselines/polynomial.py index d9d7ff4a..979bf3d6 100644 --- a/pybaselines/polynomial.py +++ b/pybaselines/polynomial.py @@ -115,7 +115,7 @@ def poly(self, data, poly_order=2, weights=None, return_coef=False): * 'weights': numpy.ndarray, shape (N,) The weight array used for fitting the data. - * 'coef': numpy.ndarray, shape (poly_order,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -186,7 +186,7 @@ def modpoly(self, data, poly_order=2, tol=1e-3, max_iter=250, weights=None, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -288,7 +288,7 @@ def imodpoly(self, data, poly_order=2, tol=1e-3, max_iter=250, weights=None, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -421,7 +421,7 @@ def penalized_poly(self, data, poly_order=2, tol=1e-3, max_iter=250, weights=Non each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -573,7 +573,7 @@ def loess(self, data, fraction=0.2, total_points=None, poly_order=1, scale=3.0, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (N, poly_order + 1) + * 'coef': numpy.ndarray, shape (N, ``poly_order + 1``) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. If `delta` is > 0, @@ -751,7 +751,7 @@ def quant_reg(self, data, poly_order=2, quantile=0.05, tol=1e-6, max_iter=250, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -881,7 +881,7 @@ def goldindec(self, data, poly_order=2, tol=1e-3, max_iter=250, weights=None, * 'threshold' : float The optimal threshold value. Could be used in :meth:`~.Baseline.penalized_poly` for fitting other similar data. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -1030,7 +1030,7 @@ def poly(data, x_data=None, poly_order=2, weights=None, return_coef=False): * 'weights': numpy.ndarray, shape (N,) The weight array used for fitting the data. - * 'coef': numpy.ndarray, shape (poly_order,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -1091,7 +1091,7 @@ def modpoly(data, x_data=None, poly_order=2, tol=1e-3, max_iter=250, weights=Non each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -1166,7 +1166,7 @@ def imodpoly(data, x_data=None, poly_order=2, tol=1e-3, max_iter=250, weights=No each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -1464,7 +1464,7 @@ def penalized_poly(data, x_data=None, poly_order=2, tol=1e-3, max_iter=250, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -2036,7 +2036,7 @@ def loess(data, x_data=None, fraction=0.2, total_points=None, poly_order=1, scal each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (N, poly_order + 1) + * 'coef': numpy.ndarray, shape (N, ``poly_order + 1``) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. If `delta` is > 0, @@ -2131,7 +2131,7 @@ def quant_reg(data, x_data=None, poly_order=2, quantile=0.05, tol=1e-6, max_iter each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. @@ -2237,7 +2237,7 @@ def goldindec(data, x_data=None, poly_order=2, tol=1e-3, max_iter=250, weights=N * 'threshold' : float The optimal threshold value. Could be used in :meth:`~.Baseline.penalized_poly` for fitting other similar data. - * 'coef': numpy.ndarray, shape (poly_order + 1,) + * 'coef': numpy.ndarray, shape (``poly_order + 1``,) Only if `return_coef` is True. The array of polynomial parameters for the baseline, in increasing order. Can be used to create a polynomial using :class:`numpy.polynomial.polynomial.Polynomial`. From 270b121c95fb2c8730a0f66b4cfc4310c5d12d1a Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:08:27 -0500 Subject: [PATCH 24/38] ENH: Return result objects for all relevant 1d methods --- docs/conf.py | 6 +- pybaselines/classification.py | 31 +++++++-- pybaselines/morphological.py | 18 +++++- pybaselines/results.py | 18 ++++-- pybaselines/spline.py | 117 ++++++++++++++++++++++++++++------ pybaselines/whittaker.py | 99 +++++++++++++++++++++++----- tests/test_classification.py | 10 ++- tests/test_morphological.py | 4 +- tests/test_optimizers.py | 21 +++--- tests/test_spline.py | 6 +- tests/test_whittaker.py | 4 +- 11 files changed, 267 insertions(+), 67 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 54b89ded..99a4be18 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -191,7 +191,11 @@ 'Sequence': ':term:`python:sequence`', 'Callable': ':term:`python:callable`', 'Iterable': ':term:`python:iterable`', - 'ParameterWarning': ':class:`pybaselines.utils.ParameterWarning`', + 'ParameterWarning': ':class:`~pybaselines.utils.ParameterWarning`', + 'WhittakerResult': ':class:`~pybaselines.results.WhittakerResult`', + 'WhittakerResult2D': ':class:`~pybaselines.results.WhittakerResult2D`', + 'PSplineResult': ':class:`~pybaselines.results.PSplineResult`', + 'PSplineResult2D': ':class:`~pybaselines.results.PSplineResult2D`', } # have to set numpydoc_class_members_toctree to False to work well with autosummary; # otherwise, duplicate objects are added to the toctree diff --git a/pybaselines/classification.py b/pybaselines/classification.py index fc589fb4..80725f37 100644 --- a/pybaselines/classification.py +++ b/pybaselines/classification.py @@ -57,6 +57,7 @@ from ._compat import jit, trapezoid from ._validation import _check_scalar, _check_scalar_variable from ._weighting import _safe_std_mean +from .results import WhittakerResult from .utils import ( _MIN_FLOAT, ParameterWarning, _convert_coef, _interp_inplace, gaussian, estimate_window, pad_edges, relative_difference @@ -794,6 +795,9 @@ def fabc(self, data, lam=1e6, scale=None, num_std=3.0, diff_order=2, min_length= as False. * 'weights': numpy.ndarray, shape (N,) The weight array used for fitting the data. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Notes ----- @@ -839,7 +843,10 @@ def fabc(self, data, lam=1e6, scale=None, num_std=3.0, diff_order=2, min_length= whittaker_system.add_diagonal(whittaker_weights), whittaker_weights * y, overwrite_b=True, overwrite_ab=True ) - params = {'mask': mask, 'weights': whittaker_weights} + params = { + 'mask': mask, 'weights': whittaker_weights, + 'result': WhittakerResult(whittaker_system, whittaker_weights) + } return baseline, params @@ -890,12 +897,18 @@ def rubberband(self, data, segments=1, lam=None, diff_order=2, weights=None, ------- baseline : numpy.ndarray, shape (N,) The calculated baseline. - dict + params : dict A dictionary with the following items: * 'mask': numpy.ndarray, shape (N,) The boolean array designating baseline points as True and peak points as False. + * 'weights': numpy.ndarray, shape (N,) + The weight array used for fitting the data. Only returned if `lam` is a + positive value. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Only returned if `lam` is a positive value. Raises ------ @@ -948,16 +961,24 @@ def rubberband(self, data, segments=1, lam=None, diff_order=2, weights=None, mask = np.zeros(self._shape, dtype=bool) mask[np.unique(total_vertices)] = True np.logical_and(mask, weight_array, out=mask) + + params = {'mask': mask} if lam is not None and lam != 0: - _, _, whittaker_system = self._setup_whittaker(y, lam, diff_order, mask) + _, whittaker_weights, whittaker_system = self._setup_whittaker( + y, lam, diff_order, mask + ) baseline = whittaker_system.solve( - whittaker_system.add_diagonal(mask), mask * y, + whittaker_system.add_diagonal(whittaker_weights), whittaker_weights * y, overwrite_b=True, overwrite_ab=True ) + params.update({ + 'weights': whittaker_weights, + 'result': WhittakerResult(whittaker_system, whittaker_weights) + }) else: baseline = np.interp(self.x, self.x[mask], y[mask]) - return baseline, {'mask': mask} + return baseline, params _classification_wrapper = _class_wrapper(_Classification) diff --git a/pybaselines/morphological.py b/pybaselines/morphological.py index 4a5d6b86..abfe60e5 100644 --- a/pybaselines/morphological.py +++ b/pybaselines/morphological.py @@ -13,6 +13,7 @@ from ._algorithm_setup import _Algorithm, _class_wrapper from ._validation import _check_lam, _check_half_window +from .results import PSplineResult, WhittakerResult from .utils import _mollifier_kernel, _sort_array, pad_edges, padded_convolve, relative_difference @@ -88,6 +89,9 @@ def mpls(self, data, half_window=None, lam=1e6, p=0.0, diff_order=2, tol=None, m The weight array used for fitting the data. * 'half_window': int The half window used for the morphological calculations. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -179,7 +183,10 @@ def mpls(self, data, half_window=None, lam=1e6, p=0.0, diff_order=2, tol=None, m overwrite_ab=True, overwrite_b=True ) - params = {'weights': weight_array, 'half_window': half_wind} + params = { + 'weights': weight_array, 'half_window': half_wind, + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @_Algorithm._register @@ -729,6 +736,9 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -793,8 +803,12 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, pspline.update_lam(lam) baseline = pspline.solve_pspline(spline_fit, weight_array) + params = { + 'half_window': half_window, 'weights': weight_array, 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } - return baseline, {'half_window': half_window, 'weights': weight_array, 'tck': pspline.tck} + return baseline, params @_Algorithm._register(sort_keys=('signal',)) def jbcd(self, data, half_window=None, alpha=0.1, beta=1e1, gamma=1., beta_mult=1.1, diff --git a/pybaselines/results.py b/pybaselines/results.py index 8e123701..4e549e7d 100644 --- a/pybaselines/results.py +++ b/pybaselines/results.py @@ -283,7 +283,7 @@ class PSplineResult(WhittakerResult): """ - def __init__(self, penalized_object, weights=None, rhs_extra=None): + def __init__(self, penalized_object, weights=None, rhs_extra=None, penalty=None): """ Initializes the result object. @@ -310,10 +310,16 @@ def __init__(self, penalized_object, weights=None, rhs_extra=None): rhs_extra : numpy.ndarray or scipy.sparse.sparray or scipy.sparse.spmatrix, optional Additional terms besides ``B.T @ W @ B`` within the right hand side of the hat matrix. Default is None. + penalty : numpy.ndarray, optional + The penalty `P` for the system, in the same banded format as used by + `penalized_object`. If None (default), will use ``penalized_object.penalty``. + If given, will overwrite ``penalized_object.penalty`` with the given penalty. """ super().__init__(penalized_object, weights=weights, rhs_extra=rhs_extra) self._btwb_ = None + if penalty is not None: + self._penalized_object.penalty = penalty @property def _shape(self): @@ -370,7 +376,7 @@ def _rhs(self): self._rhs_extra = _banded_to_sparse( self._rhs_extra, lower=self._penalized_object.lower ) - self._hat_rhs = self.rhs_extra + btwb + self._hat_rhs = self._rhs_extra + btwb return self._hat_rhs @property @@ -478,7 +484,7 @@ class PSplineResult2D(PSplineResult): """ - def __init__(self, penalized_object, weights=None, rhs_extra=None): + def __init__(self, penalized_object, weights=None, rhs_extra=None, penalty=None): """ Initializes the result object. @@ -505,9 +511,13 @@ def __init__(self, penalized_object, weights=None, rhs_extra=None): rhs_extra : numpy.ndarray or scipy.sparse.sparray or scipy.sparse.spmatrix, optional Additional terms besides ``B.T @ W @ B`` within the right hand side of the hat matrix. Default is None. + penalty : scipy.sparse.sparray or scipy.sparse.spmatrix, optional + The penalty `P` for the system in full, sparse format. If None (default), will use + ``penalized_object.penalty``. If given, will overwrite ``penalized_object.penalty`` + with the given penalty. """ - super().__init__(penalized_object, weights, rhs_extra) + super().__init__(penalized_object, weights=weights, rhs_extra=rhs_extra, penalty=penalty) if self._weights.ndim == 1: self._weights = self._weights.reshape(self._shape) diff --git a/pybaselines/spline.py b/pybaselines/spline.py index 251bb8df..193db91c 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -17,6 +17,7 @@ from ._compat import dia_object, jit, trapezoid from ._spline_utils import _basis_midpoints from ._validation import _check_lam, _check_optional_array, _check_scalar_variable +from .results import PSplineResult from .utils import ( ParameterWarning, _mollifier_kernel, _sort_array, gaussian, pad_edges, padded_convolve, relative_difference, _MIN_FLOAT @@ -94,6 +95,9 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -199,7 +203,8 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) } baseline = np.polynomial.polyutils.mapdomain(baseline, np.array([-1., 1.]), y_domain) @@ -261,6 +266,9 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -291,7 +299,10 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, old_coef = pspline.coef weight_array = _weighting._quantile(y, baseline, quantile, eps) - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -424,6 +435,9 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -463,7 +477,10 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -520,6 +537,9 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -572,7 +592,8 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - baseline = pspline.solve_pspline(y, weight_array**2, rhs_extra=partial_rhs) + weight_squared = weight_array**2 + baseline = pspline.solve_pspline(y, weight_squared, rhs_extra=partial_rhs) new_weights = _weighting._asls(y, baseline, p) calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference @@ -580,7 +601,10 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_squared, rhs_extra=d1_penalty) + } return baseline, params @@ -635,6 +659,9 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -668,7 +695,10 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -719,6 +749,9 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -749,7 +782,10 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -804,6 +840,9 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -845,10 +884,8 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, diff_n_diagonals * np.interp(interp_pts, self.x, weight_array), pspline.num_bands, pspline.num_bands ) - baseline = pspline.solve_pspline( - y, weight_array, - penalty=_add_diagonals(pspline.penalty, diff_n_w_diagonals, lower_only=False) - ) + penalty = _add_diagonals(pspline.penalty, diff_n_w_diagonals, lower_only=False) + baseline = pspline.solve_pspline(y, weight_array, penalty=penalty) new_weights, exit_early = _weighting._drpls(y, baseline, i) if exit_early: i -= 1 # reduce i so that output tol_history indexing is correct @@ -860,7 +897,10 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array, penalty=penalty) + } return baseline, params @@ -911,6 +951,9 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -942,7 +985,10 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -1015,6 +1061,9 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -1067,7 +1116,7 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde pspline.penalty * np.interp(interp_pts, self.x, alpha_array), pspline.num_bands, pspline.num_bands ) - baseline = pspline.solve_pspline(y, weight_array, alpha_penalty) + baseline = pspline.solve_pspline(y, weight_array, penalty=alpha_penalty) new_weights, residual, exit_early = _weighting._aspls( y, baseline, asymmetric_coef, alternate_weighting ) @@ -1084,7 +1133,8 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde params = { 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1], - 'tck': pspline.tck + 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array, penalty=alpha_penalty) } return baseline, params @@ -1146,6 +1196,9 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -1187,7 +1240,10 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -1263,6 +1319,9 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -1326,7 +1385,10 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @@ -1403,6 +1465,9 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -1466,7 +1531,10 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp ) baseline = pspline.solve_pspline(y, weight_array) - params = {'weights': weight_array, 'half_window': half_wind, 'tck': pspline.tck} + params = { + 'weights': weight_array, 'half_window': half_wind, 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params @_Algorithm._register(sort_keys=('weights',)) @@ -1526,6 +1594,9 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -1584,7 +1655,7 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde params = { 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], - 'tck': pspline.tck + 'tck': pspline.tck, 'result': PSplineResult(pspline, baseline_weights) } return baseline, params @@ -1642,6 +1713,9 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord The knots, spline coefficients, and spline degree for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for other usages such as evaluating with different x-values. + * 'result': PSplineResult + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -1683,7 +1757,10 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult(pspline, weight_array) + } return baseline, params diff --git a/pybaselines/whittaker.py b/pybaselines/whittaker.py index 610dd73c..16ce20c9 100644 --- a/pybaselines/whittaker.py +++ b/pybaselines/whittaker.py @@ -12,6 +12,7 @@ from ._algorithm_setup import _Algorithm, _class_wrapper from ._banded_utils import _shift_rows, diff_penalty_diagonals from ._validation import _check_lam, _check_optional_array, _check_scalar_variable +from .results import WhittakerResult from .utils import _mollifier_kernel, pad_edges, padded_convolve, relative_difference @@ -74,6 +75,9 @@ def asls(self, data, lam=1e6, p=1e-2, diff_order=2, max_iter=50, tol=1e-3, weigh each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input ``tol`` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -152,7 +156,10 @@ def asls(self, data, lam=1e6, p=1e-2, diff_order=2, max_iter=50, tol=1e-3, weigh break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -223,6 +230,9 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -317,8 +327,10 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, y, weight_array, whittaker_system = self._setup_whittaker(data, lam, diff_order, weights) lambda_1 = _check_lam(lam_1) - diff_1_diags = diff_penalty_diagonals(self._size, 1, whittaker_system.lower, 1) - whittaker_system.add_penalty(lambda_1 * diff_1_diags) + residual_penalty = lambda_1 * diff_penalty_diagonals( + self._size, 1, whittaker_system.lower, padding=diff_order - 1 + ) + whittaker_system.add_penalty(residual_penalty) # fast calculation of lam_1 * (D_1.T @ D_1) @ y d1_y = y.copy() @@ -340,7 +352,12 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult( + whittaker_system, weight_squared, rhs_extra=residual_penalty + ) + } return baseline, params @@ -404,6 +421,9 @@ def airpls(self, data, lam=1e6, diff_order=2, max_iter=50, tol=1e-3, weights=Non each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Notes ----- @@ -469,7 +489,10 @@ def airpls(self, data, lam=1e6, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -530,6 +553,9 @@ def arpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=None each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -572,7 +598,10 @@ def arpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=None break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -617,6 +646,9 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -650,9 +682,9 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif penalty_with_weights = _shift_rows( diff_n_diagonals * weight_array, diff_order, diff_order ) + lhs = whittaker_system.penalty + penalty_with_weights baseline = whittaker_system.solve( - whittaker_system.penalty + penalty_with_weights, weight_array * y, - overwrite_ab=True, overwrite_b=True, l_and_u=lower_upper_bands + lhs, weight_array * y, overwrite_b=True, l_and_u=lower_upper_bands ) new_weights, exit_early = _weighting._drpls(y, baseline, i) if exit_early: @@ -665,7 +697,10 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], + 'result': WhittakerResult(whittaker_system, weight_array, lhs=lhs) + } return baseline, params @@ -706,6 +741,9 @@ def iarpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -731,7 +769,10 @@ def iarpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -794,6 +835,9 @@ def aspls(self, data, lam=1e5, diff_order=2, max_iter=100, tol=1e-3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -837,7 +881,7 @@ def aspls(self, data, lam=1e5, diff_order=2, max_iter=100, tol=1e-3, lhs = whittaker_system.penalty * alpha_array lhs[whittaker_system.main_diagonal_index] += weight_array baseline = whittaker_system.solve( - _shift_rows(lhs, diff_order, diff_order), weight_array * y, overwrite_ab=True, + _shift_rows(lhs, diff_order, diff_order), weight_array * y, overwrite_b=True, l_and_u=lower_upper_bands ) new_weights, residual, exit_early = _weighting._aspls( @@ -855,7 +899,8 @@ def aspls(self, data, lam=1e5, diff_order=2, max_iter=100, tol=1e-3, alpha_array = abs_d / abs_d.max() params = { - 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1] + 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult(whittaker_system, weight_array, lhs=lhs) } return baseline, params @@ -912,6 +957,9 @@ def psalsa(self, data, lam=1e5, p=0.5, k=None, diff_order=2, max_iter=50, tol=1e each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -953,7 +1001,10 @@ def psalsa(self, data, lam=1e5, p=0.5, k=None, diff_order=2, max_iter=50, tol=1e break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -1020,6 +1071,9 @@ def derpsalsa(self, data, lam=1e6, p=0.01, k=None, diff_order=2, max_iter=50, to each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -1077,7 +1131,10 @@ def derpsalsa(self, data, lam=1e6, p=0.01, k=None, diff_order=2, max_iter=50, to break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params @@ -1129,6 +1186,9 @@ def brpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, max_iter_2=5 `max_iter_2`, `tol_2`), and shape K is the maximum of the number of iterations for the threshold and the maximum number of iterations for all of the fits of the various threshold values (related to `max_iter` and `tol`). + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -1180,7 +1240,8 @@ def brpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, max_iter_2=5 beta = 1 - weight_mean params = { - 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1] + 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], + 'result': WhittakerResult(whittaker_system, weight_array) } return baseline, params @@ -1229,6 +1290,9 @@ def lsrpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult + An object that can use the results of the fit to perform additional + calculations. Notes ----- @@ -1267,7 +1331,10 @@ def lsrpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], + 'result': WhittakerResult(whittaker_system, weight_array) + } return baseline, params diff --git a/tests/test_classification.py b/tests/test_classification.py index 514d21e8..896392ce 100644 --- a/tests/test_classification.py +++ b/tests/test_classification.py @@ -389,7 +389,7 @@ class TestFabc(ClassificationTester): """Class for testing fabc baseline.""" func_name = 'fabc' - checked_keys = ('mask', 'weights') + checked_keys = ('mask', 'weights', 'result') weight_keys = ('mask', 'weights') requires_unique_x = False @@ -573,3 +573,11 @@ def test_kwargs_deprecation(self, smooth_half_window): self.y, smooth_half_window=smooth_half_window, pad_kwargs={'mode': 'extrapolate'}, mode='extrapolate' ) + + @pytest.mark.parametrize('lam', (0, 1e4)) + def test_output(self, lam): + """Ensures that the output has the desired format.""" + additional_keys = [] + if lam > 0: + additional_keys.extend(['weights', 'result']) + super().test_output(additional_keys=additional_keys, lam=lam) diff --git a/tests/test_morphological.py b/tests/test_morphological.py index e467acfc..1126cd0d 100644 --- a/tests/test_morphological.py +++ b/tests/test_morphological.py @@ -51,7 +51,7 @@ class TestMPLS(MorphologicalTester, InputWeightsMixin, RecreationMixin): """Class for testing mpls baseline.""" func_name = 'mpls' - checked_keys = ('half_window', 'weights') + checked_keys = ('half_window', 'weights', 'result') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): @@ -184,7 +184,7 @@ class TestMpspline(MorphologicalTester, InputWeightsMixin, RecreationMixin): """Class for testing mpspline baseline.""" func_name = 'mpspline' - checked_keys = ('half_window', 'weights', 'tck') + checked_keys = ('half_window', 'weights', 'tck', 'result') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index 578edd4a..209f5b27 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -92,7 +92,7 @@ class TestCollabPLS(OptimizersTester, OptimizerInputWeightsMixin): func_name = "collab_pls" checked_keys = ('average_weights',) # will need to change checked_keys if default method is changed - checked_method_keys = ('weights', 'tol_history') + checked_method_keys = ('weights', 'tol_history', 'result') two_d = True weight_keys = ('average_weights', 'weights') @@ -151,7 +151,7 @@ class TestOptimizeExtendedRange(OptimizersTester, OptimizerInputWeightsMixin): func_name = "optimize_extended_range" checked_keys = ('optimal_parameter', 'min_rmse', 'rmse') # will need to change checked_keys if default method is changed - checked_method_keys = ('weights', 'tol_history') + checked_method_keys = ('weights', 'tol_history', 'result') required_kwargs = {'pad_kwargs': {'extrapolate_window': 100}} @pytest.mark.parametrize('use_class', (True, False)) @@ -172,16 +172,13 @@ def test_input_weights(self, side): 'poly', 'modpoly', 'imodpoly', 'penalized_poly', 'loess', 'quant_reg', 'goldindec', 'derpsalsa', 'mpspline', 'mixture_model', 'irsqr', 'dietrich', 'cwt_br', 'fabc', 'pspline_asls', 'pspline_iasls', 'pspline_airpls', 'pspline_arpls', 'pspline_drpls', - 'pspline_iarpls', 'pspline_aspls', 'pspline_psalsa', 'pspline_derpsalsa' + 'pspline_iarpls', 'pspline_aspls', 'pspline_psalsa', 'pspline_derpsalsa', 'rubberband', ) ) def test_all_methods(self, method): """Tests all methods that should work with optimize_extended_range.""" - if method == 'loess': - # reduce number of calculations for loess since it is much slower - kwargs = {'min_value': 1, 'max_value': 3} - else: - kwargs = {} + # reduce number of calculations since this is just checking that calling works + kwargs = {'min_value': 1, 'max_value': 3} # use height_scale=0.1 to avoid exponential overflow warning for arpls and aspls output = self.class_func( self.y, method=method, height_scale=0.1, **kwargs, **self.kwargs @@ -581,7 +578,7 @@ class TestCustomBC(OptimizersTester): func_name = 'custom_bc' checked_keys = ('y_fit', 'x_fit', 'baseline_fit') # will need to change checked_keys if default method is changed - checked_method_keys = ('weights', 'tol_history') + checked_method_keys = ('weights', 'tol_history', 'result') required_kwargs = {'sampling': 5} @pytest.mark.parametrize( @@ -677,7 +674,9 @@ class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): func_name = "optimize_pls" checked_keys = ('optimal_parameter', 'metric', 'fidelity') # will need to change checked_keys if default method is changed - checked_method_keys = ('weights', 'tol_history') + checked_method_keys = ('weights', 'tol_history', 'result') + # by default only run a few optimization steps + required_kwargs = {'min_value': 2, 'max_value': 3} @pytest.mark.parametrize('opt_method', ('U-curve', 'GCV', 'BIC')) def test_output(self, opt_method): @@ -692,7 +691,7 @@ def test_output(self, opt_method): 'method', ( 'asls', 'iasls', 'airpls', 'mpls', 'arpls', 'drpls', 'iarpls', 'aspls', 'psalsa', - 'derpsalsa', 'mpspline', 'mixture_model', 'irsqr', 'fabc', + 'derpsalsa', 'mpspline', 'mixture_model', 'irsqr', 'fabc', 'rubberband', 'pspline_asls', 'pspline_iasls', 'pspline_airpls', 'pspline_arpls', 'pspline_drpls', 'pspline_iarpls', 'pspline_aspls', 'pspline_psalsa', 'pspline_derpsalsa' ) diff --git a/tests/test_spline.py b/tests/test_spline.py index 1d1c2432..c2bfc37c 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -67,7 +67,7 @@ def test_numba_implementation(self): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history', 'tck') + checked_keys = ('weights', 'tol_history', 'tck', 'result') def test_tol_history(self): """Ensures the 'tol_history' item in the parameter output is correct.""" @@ -436,7 +436,7 @@ class TestPsplineAsPLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_aspls baseline.""" func_name = 'pspline_aspls' - checked_keys = ('weights', 'tol_history', 'alpha', 'tck') + checked_keys = ('weights', 'tol_history', 'alpha', 'tck', 'result') weight_keys = ('weights', 'alpha') def test_wrong_alpha_shape(self): @@ -599,7 +599,7 @@ class TestPsplineMPLS(SplineTester, InputWeightsMixin, WhittakerComparisonMixin) """Class for testing pspline_mpls baseline.""" func_name = 'pspline_mpls' - checked_keys = ('half_window', 'weights', 'tck') + checked_keys = ('half_window', 'weights', 'tck', 'result') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/test_whittaker.py b/tests/test_whittaker.py index eda65640..eeb4816a 100644 --- a/tests/test_whittaker.py +++ b/tests/test_whittaker.py @@ -92,7 +92,7 @@ class WhittakerTester(BaseTester, InputWeightsMixin, RecreationMixin): """Base testing class for whittaker functions.""" module = whittaker - checked_keys = ('weights', 'tol_history') + checked_keys = ('weights', 'tol_history', 'result') @pytest.mark.parametrize('diff_order', (2, 3)) def test_scipy_solvers(self, diff_order): @@ -380,7 +380,7 @@ class TestAsPLS(WhittakerTester): """Class for testing aspls baseline.""" func_name = 'aspls' - checked_keys = ('weights', 'alpha', 'tol_history') + checked_keys = ('weights', 'alpha', 'tol_history', 'result') weight_keys = ('weights', 'alpha') @pytest.mark.parametrize('diff_order', (1, 3)) From 3b863761b35ea079dab2ac7df382d5d532bb065f Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:49:09 -0500 Subject: [PATCH 25/38] ENH: Return result objects for all relevant 2D methods --- pybaselines/results.py | 16 +++++- pybaselines/two_d/spline.py | 86 ++++++++++++++++++++++++++------ pybaselines/two_d/whittaker.py | 89 ++++++++++++++++++++++++++++------ tests/test_results.py | 15 ++++++ tests/two_d/test_optimizers.py | 2 +- tests/two_d/test_spline.py | 2 +- tests/two_d/test_whittaker.py | 4 +- 7 files changed, 181 insertions(+), 33 deletions(-) diff --git a/pybaselines/results.py b/pybaselines/results.py index 4e549e7d..5aba5184 100644 --- a/pybaselines/results.py +++ b/pybaselines/results.py @@ -676,7 +676,7 @@ class WhittakerResult2D(WhittakerResult): """ - def __init__(self, penalized_object, weights=None, lhs=None, rhs_extra=None): + def __init__(self, penalized_object, weights=None, lhs=None, rhs_extra=None, penalty=None): """ Initializes the result object. @@ -698,10 +698,24 @@ def __init__(self, penalized_object, weights=None, lhs=None, rhs_extra=None): rhs_extra : scipy.sparse.sparray or scipy.sparse.spmatrix, optional Additional terms besides the weights within the right hand side of the hat matrix. Default is None. + penalty : scipy.sparse.sparray or scipy.sparse.spmatrix, optional + The penalty `P` for the system in full, sparse format. If None (default), will use + ``penalized_object.penalty``. If given, will overwrite ``penalized_object.penalty`` + with the given penalty. + + Raises + ------ + ValueError + Raised if both `penalty` and `lhs` are not None. """ super().__init__(penalized_object, weights=weights, lhs=lhs, rhs_extra=rhs_extra) self._btwb_ = None + if penalty is not None: + if lhs is not None: + raise ValueError('both `lhs` and `penalty` cannot be supplied') + self._penalized_object.penalty = penalty + if self._penalized_object._using_svd and self._weights.ndim == 1: self._weights = self._weights.reshape(self._shape) elif not self._penalized_object._using_svd and self._weights.ndim == 2: diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index fff6af3d..93865b7e 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -12,6 +12,7 @@ from .. import _weighting from .._validation import _check_scalar_variable +from ..results import PSplineResult2D from ..utils import ParameterWarning, gaussian, relative_difference, _MIN_FLOAT from ._algorithm_setup import _Algorithm2D from ._whittaker_utils import PenalizedSystem2D @@ -86,6 +87,9 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -186,7 +190,8 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) } baseline = np.polynomial.polyutils.mapdomain(baseline, np.array([-1., 1.]), y_domain) @@ -251,6 +256,9 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -281,7 +289,10 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, old_coef = pspline.coef weight_array = _weighting._quantile(y, baseline, quantile, eps) - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -339,6 +350,9 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -376,7 +390,10 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -438,6 +455,9 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -475,14 +495,16 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, # B.T @ P_1 @ B and B.T @ P_1 @ y penalized_system_1 = PenalizedSystem2D(self._shape, lam_1, diff_order=1) - p1_partial_penalty = pspline.basis.basis.T @ penalized_system_1.penalty + d1_penalty = pspline.basis.basis.T @ penalized_system_1.penalty - partial_rhs = p1_partial_penalty @ y.ravel() - pspline.add_penalty(p1_partial_penalty @ pspline.basis.basis) + partial_rhs = d1_penalty @ y.ravel() + d1_penalty = d1_penalty @ pspline.basis.basis + pspline.add_penalty(d1_penalty) tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - baseline = pspline.solve(y, weight_array**2, rhs_extra=partial_rhs) + weight_squared = weight_array**2 + baseline = pspline.solve(y, weight_squared, rhs_extra=partial_rhs) new_weights = _weighting._asls(y, baseline, p) calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference @@ -490,7 +512,10 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_squared, rhs_extra=d1_penalty) + } return baseline, params @@ -548,6 +573,9 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -581,7 +609,10 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -635,6 +666,9 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -665,7 +699,10 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -719,6 +756,9 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -750,7 +790,10 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -814,6 +857,9 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -855,7 +901,10 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params @@ -919,6 +968,9 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -977,7 +1029,7 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order params = { 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], - 'tck': pspline.tck + 'tck': pspline.tck, 'result': PSplineResult2D(pspline, weight_array) } return baseline, params @@ -1038,6 +1090,9 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde The knots, spline coefficients, and spline degrees for the fit baseline. Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for other usages such as evaluating with different x-values and z-values. + * 'result': PSplineResult2D + An object that can use the results of the fit to perform additional + calculations. See Also -------- @@ -1081,6 +1136,9 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'result': PSplineResult2D(pspline, weight_array) + } return baseline, params diff --git a/pybaselines/two_d/whittaker.py b/pybaselines/two_d/whittaker.py index 5871cdf2..7a2dd956 100644 --- a/pybaselines/two_d/whittaker.py +++ b/pybaselines/two_d/whittaker.py @@ -11,6 +11,7 @@ from .. import _weighting from .._compat import diags from .._validation import _check_optional_array, _check_scalar_variable +from ..results import WhittakerResult2D from ..utils import _MIN_FLOAT, relative_difference from ._algorithm_setup import _Algorithm2D from ._whittaker_utils import PenalizedSystem2D @@ -77,6 +78,9 @@ def asls(self, data, lam=1e6, p=1e-2, diff_order=2, max_iter=50, tol=1e-3, weigh Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -109,7 +113,10 @@ def asls(self, data, lam=1e6, p=1e-2, diff_order=2, max_iter=50, tol=1e-3, weigh break weight_array = new_weights - params = {'tol_history': tol_history[:i + 1]} + params = { + 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: @@ -171,6 +178,9 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -203,7 +213,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, p1_y = penalized_system_1.penalty @ y tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - baseline = whittaker_system.solve(y, weight_array**2, rhs_extra=p1_y) + weight_squared = weight_array**2 + baseline = whittaker_system.solve(y, weight_squared, rhs_extra=p1_y) new_weights = _weighting._asls(y, baseline, p) calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference @@ -211,7 +222,12 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, break weight_array = new_weights - params = {'weights': weight_array, 'tol_history': tol_history[:i + 1]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult2D( + whittaker_system, weight_squared, rhs_extra=penalized_system_1.penalty + ) + } return baseline, params @@ -273,6 +289,9 @@ def airpls(self, data, lam=1e6, diff_order=2, max_iter=50, tol=1e-3, weights=Non Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -303,7 +322,10 @@ def airpls(self, data, lam=1e6, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'tol_history': tol_history[:i]} + params = { + 'tol_history': tol_history[:i], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: @@ -368,6 +390,9 @@ def arpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, weights=None Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -394,7 +419,10 @@ def arpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, weights=None break weight_array = new_weights - params = {'tol_history': tol_history[:i + 1]} + params = { + 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: @@ -450,6 +478,9 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -476,9 +507,8 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif weight_matrix = diags(weight_array, format='csr') tol_history = np.empty(max_iter + 1) for i in range(1, max_iter + 2): - baseline = whittaker_system.direct_solve( - partial_penalty + weight_matrix @ partial_penalty_2, weight_array * y - ) + lhs = partial_penalty + weight_matrix @ partial_penalty_2 + baseline = whittaker_system.direct_solve(lhs, weight_array * y) new_weights, exit_early = _weighting._drpls(y, baseline, i) if exit_early: i -= 1 # reduce i so that output tol_history indexing is correct @@ -491,7 +521,10 @@ def drpls(self, data, lam=1e5, eta=0.5, max_iter=50, tol=1e-3, weights=None, dif weight_array = new_weights weight_matrix.setdiag(weight_array) - params = {'weights': weight_array, 'tol_history': tol_history[:i]} + params = { + 'weights': weight_array, 'tol_history': tol_history[:i], + 'result': WhittakerResult2D(whittaker_system, weight_array, lhs=lhs) + } return baseline, params @@ -549,6 +582,9 @@ def iarpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -576,7 +612,10 @@ def iarpls(self, data, lam=1e5, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'tol_history': tol_history[:i]} + params = { + 'tol_history': tol_history[:i], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: @@ -650,6 +689,9 @@ def aspls(self, data, lam=1e5, diff_order=2, max_iter=100, tol=1e-3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -712,7 +754,8 @@ def aspls(self, data, lam=1e5, diff_order=2, max_iter=100, tol=1e-3, alpha_matrix.setdiag(alpha_array) params = { - 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1] + 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult2D(whittaker_system, weight_array, penalty=penalty) } return baseline, params @@ -785,6 +828,9 @@ def psalsa(self, data, lam=1e5, p=0.5, k=None, diff_order=2, max_iter=50, tol=1e Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Raises ------ @@ -830,7 +876,10 @@ def psalsa(self, data, lam=1e5, p=0.5, k=None, diff_order=2, max_iter=50, tol=1e break weight_array = new_weights - params = {'tol_history': tol_history[:i + 1]} + params = { + 'tol_history': tol_history[:i + 1], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: @@ -905,6 +954,9 @@ def brpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, max_iter_2=5 Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. References ---------- @@ -957,7 +1009,10 @@ def brpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, max_iter_2=5 break beta = 1 - weight_mean - params = {'tol_history': tol_history[:i + 2, :max(i, j_max) + 1]} + params = { + 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = baseline_weights if return_dof: @@ -1028,6 +1083,9 @@ def lsrpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, weights=Non Only if `return_dof` is True. The effective degrees of freedom associated with each eigenvector. Lower values signify that the eigenvector was less important for the fit. + * 'result': WhittakerResult2D + An object that can use the results of the fit to perform additional + calculations. Notes ----- @@ -1067,7 +1125,10 @@ def lsrpls(self, data, lam=1e3, diff_order=2, max_iter=50, tol=1e-3, weights=Non break weight_array = new_weights - params = {'tol_history': tol_history[:i]} + params = { + 'tol_history': tol_history[:i], + 'result': WhittakerResult2D(whittaker_system, weight_array) + } if whittaker_system._using_svd: params['weights'] = weight_array if return_dof: diff --git a/tests/test_results.py b/tests/test_results.py index bba78187..0c40a7e7 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -496,3 +496,18 @@ def test_whittaker_result_two_d_no_weights(data_fixture2d, num_eigens): result_obj = results.WhittakerResult2D(penalized_system) assert_allclose(result_obj._weights, np.ones(expected_shape), rtol=1e-16, atol=0) + + +def test_whittaker_result_two_d_lhs_penalty_raises(data_fixture2d): + """Ensures an exception is raised if both `lhs` and `penalty` are supplied.""" + x, z, y = data_fixture2d + weights = np.random.default_rng(0).normal(0.8, 0.05, y.shape) + weights = np.clip(weights, 0, 1, dtype=float) + + penalized_system = WhittakerSystem2D(y.shape) + + with pytest.raises(ValueError, match='both `lhs` and `penalty` cannot'): + results.WhittakerResult2D( + penalized_system, weights, lhs=penalized_system.penalty, + penalty=penalized_system.penalty + ) diff --git a/tests/two_d/test_optimizers.py b/tests/two_d/test_optimizers.py index e89983cb..d3fe60e6 100644 --- a/tests/two_d/test_optimizers.py +++ b/tests/two_d/test_optimizers.py @@ -92,7 +92,7 @@ class TestCollabPLS(OptimizersTester, OptimizerInputWeightsMixin): func_name = "collab_pls" checked_keys = ('average_weights',) # will need to change checked_keys if default method is changed - checked_method_keys = ('weights', 'tol_history') + checked_method_keys = ('weights', 'tol_history', 'result') three_d = True weight_keys = ('average_weights', 'weights') diff --git a/tests/two_d/test_spline.py b/tests/two_d/test_spline.py index b8ac877f..e3f58142 100644 --- a/tests/two_d/test_spline.py +++ b/tests/two_d/test_spline.py @@ -55,7 +55,7 @@ class SplineTester(BaseTester2D): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history', 'tck') + checked_keys = ('weights', 'tol_history', 'tck', 'result') @classmethod def setup_class(cls): diff --git a/tests/two_d/test_whittaker.py b/tests/two_d/test_whittaker.py index 3f6488af..0fcb09fd 100644 --- a/tests/two_d/test_whittaker.py +++ b/tests/two_d/test_whittaker.py @@ -18,7 +18,7 @@ class WhittakerTester(BaseTester2D, InputWeightsMixin, RecreationMixin): """Base testing class for whittaker functions.""" module = whittaker - checked_keys = ('weights', 'tol_history') + checked_keys = ('weights', 'tol_history', 'result') def test_tol_history(self): """Ensures the 'tol_history' item in the parameter output is correct.""" @@ -174,7 +174,7 @@ class TestAsPLS(WhittakerTester): """Class for testing aspls baseline.""" func_name = 'aspls' - checked_keys = ('weights', 'alpha', 'tol_history') + checked_keys = ('weights', 'alpha', 'tol_history', 'result') weight_keys = ('weights', 'alpha') required_repeated_kwargs = {'lam': 1e2, 'tol': 1e-1} From 408f8585337f72cb03da739dc79d05fced5faacd Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:10:01 -0500 Subject: [PATCH 26/38] MAINT: Move `tck` from params to PSplineResult[2D] property Not a deprecation since `tck` was only added to the params dict during v1.3.0 development. --- pybaselines/morphological.py | 6 +-- pybaselines/optimizers.py | 2 +- pybaselines/results.py | 45 ++++++++++++++++ pybaselines/spline.py | 83 +++++------------------------- pybaselines/two_d/_spline_utils.py | 2 +- pybaselines/two_d/spline.py | 60 ++++----------------- tests/test_api.py | 28 ---------- tests/test_morphological.py | 2 +- tests/test_spline.py | 6 +-- tests/two_d/test_api.py | 26 ---------- tests/two_d/test_spline.py | 2 +- 11 files changed, 76 insertions(+), 186 deletions(-) diff --git a/pybaselines/morphological.py b/pybaselines/morphological.py index abfe60e5..83e5873b 100644 --- a/pybaselines/morphological.py +++ b/pybaselines/morphological.py @@ -732,10 +732,6 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, The weight array used for fitting the data. * 'half_window': int The half window used for the morphological calculations. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -804,7 +800,7 @@ def mpspline(self, data, half_window=None, lam=1e4, lam_smooth=1e-2, p=0.0, pspline.update_lam(lam) baseline = pspline.solve_pspline(spline_fit, weight_array) params = { - 'half_window': half_window, 'weights': weight_array, 'tck': pspline.tck, + 'half_window': half_window, 'weights': weight_array, 'result': PSplineResult(pspline, weight_array) } diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 095ea21d..b6ead342 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -964,7 +964,7 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ for lam in lam_range: fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) if spline_fit: - penalized_object = fit_params['tck'][1] + penalized_object = fit_params['result'].tck[1] # the spline coefficients else: penalized_object = fit_baseline # Park, et al. multiplied the penalty by lam (Equation 8), but I think that may have diff --git a/pybaselines/results.py b/pybaselines/results.py index 5aba5184..85cac65c 100644 --- a/pybaselines/results.py +++ b/pybaselines/results.py @@ -396,6 +396,28 @@ def _btwb(self): self._btwb_ = self._penalized_object._make_btwb(self._weights) return self._btwb_ + @property + def tck(self): + """ + The knots, spline coefficients, and spline degree to reconstruct the fit baseline. + + Can be used with SciPy's :class:`scipy.interpolate.BSpline`, to allow for reconstructing + the fit baseline to allow for other usages such as evaluating with different x-values. + + Returns + ------- + numpy.ndarray, shape (K,) + The knots for the spline. Has a shape of `K`, which is equal to + ``num_knots + 2 * spline_degree``. + numpy.ndarray, shape (M,) + The spline coeffieicnts. Has a shape of `M`, which is the number of basis functions + (equal to ``K - spline_degree - 1`` or equivalently ``num_knots + spline_degree - 1``). + int + The degree of the spline. + + """ + return self._penalized_object.tck + def effective_dimension(self, n_samples=0, rng=1234): """ Calculates the effective dimension from the trace of the hat matrix. @@ -591,6 +613,29 @@ def _btwb(self): self._btwb_ = self._penalized_object.basis._make_btwb(self._weights) return self._btwb_ + @property + def tck(self): + """ + The knots, spline coefficients, and spline degree to reconstruct the fit baseline. + + Can be used with SciPy's :class:`scipy.interpolate.NdBSpline`, to allow for reconstructing + the fit baseline to allow for other usages such as evaluating with different x- and + z-values. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + The knots for the spline along the rows and columns. + numpy.ndarray, shape (M, N) + The spline coeffieicnts. Has a shape of (`M`, `N`), corresponding to the number + of basis functions along the rows and columns. + numpy.ndarray([int, int]) + The degree of the spline for the rows and columns. + + """ + # method only added to document differing output types compared to PSplineResult.tck + return super().tck + def effective_dimension(self, n_samples=0, rng=1234): """ Calculates the effective dimension from the trace of the hat matrix. diff --git a/pybaselines/spline.py b/pybaselines/spline.py index 193db91c..bed47b2c 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -91,10 +91,6 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -203,7 +199,7 @@ def mixture_model(self, data, lam=1e5, p=1e-2, num_knots=100, spline_degree=3, d residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -262,10 +258,6 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -300,7 +292,7 @@ def irsqr(self, data, lam=100, quantile=0.05, num_knots=100, spline_degree=3, weight_array = _weighting._quantile(y, baseline, quantile, eps) params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -431,10 +423,6 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -478,7 +466,7 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=100, spline_degree=3, di weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -533,10 +521,6 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -602,7 +586,7 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_squared, rhs_extra=d1_penalty) } @@ -655,10 +639,6 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -696,7 +676,7 @@ def pspline_airpls(self, data, lam=1e3, num_knots=100, spline_degree=3, weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult(pspline, weight_array) } @@ -745,10 +725,6 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -783,7 +759,7 @@ def pspline_arpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -836,10 +812,6 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -898,7 +870,7 @@ def pspline_drpls(self, data, lam=1e3, eta=0.5, num_knots=100, spline_degree=3, weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult(pspline, weight_array, penalty=penalty) } @@ -947,10 +919,6 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -986,7 +954,7 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult(pspline, weight_array) } @@ -1057,10 +1025,6 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1133,7 +1097,6 @@ def pspline_aspls(self, data, lam=1e4, num_knots=100, spline_degree=3, diff_orde params = { 'weights': weight_array, 'alpha': alpha_array, 'tol_history': tol_history[:i + 1], - 'tck': pspline.tck, 'result': PSplineResult(pspline, weight_array, penalty=alpha_penalty) } @@ -1192,10 +1155,6 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1241,7 +1200,7 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=100, spline_deg weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -1315,10 +1274,6 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1386,7 +1341,7 @@ def pspline_derpsalsa(self, data, lam=1e2, p=1e-2, k=None, num_knots=100, spline weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult(pspline, weight_array) } @@ -1461,10 +1416,6 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp The weight array used for fitting the data. * 'half_window': int The half window used for the morphological calculations. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1532,7 +1483,7 @@ def pspline_mpls(self, data, half_window=None, lam=1e3, p=0.0, num_knots=100, sp baseline = pspline.solve_pspline(y, weight_array) params = { - 'weights': weight_array, 'half_window': half_wind, 'tck': pspline.tck, + 'weights': weight_array, 'half_window': half_wind, 'result': PSplineResult(pspline, weight_array) } return baseline, params @@ -1590,10 +1541,6 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde `max_iter_2`, `tol_2`), and shape K is the maximum of the number of iterations for the threshold and the maximum number of iterations for all of the fits of the various threshold values (related to `max_iter` and `tol`). - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1655,7 +1602,7 @@ def pspline_brpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_orde params = { 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], - 'tck': pspline.tck, 'result': PSplineResult(pspline, baseline_weights) + 'result': PSplineResult(pspline, baseline_weights) } return baseline, params @@ -1709,10 +1656,6 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[numpy.ndarray, numpy.ndarray, int] - The knots, spline coefficients, and spline degree for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.BSpline`, to allow for - other usages such as evaluating with different x-values. * 'result': PSplineResult An object that can use the results of the fit to perform additional calculations. @@ -1758,7 +1701,7 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=100, spline_degree=3, diff_ord weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult(pspline, weight_array) } diff --git a/pybaselines/two_d/_spline_utils.py b/pybaselines/two_d/_spline_utils.py index 437594f7..86097bcc 100644 --- a/pybaselines/two_d/_spline_utils.py +++ b/pybaselines/two_d/_spline_utils.py @@ -349,7 +349,7 @@ def tck(self): knots : tuple[numpy.ndarray, numpy.ndarray] The knots for the spline along the rows and columns. coef : numpy.ndarray, shape (M, N) - The spline coeffieicnts. Has a shape of (`M`, `N`), correspondong to the number + The spline coeffieicnts. Has a shape of (`M`, `N`), corresponding to the number of basis functions along the rows and columns. spline_degree : numpy.ndarray([int, int]) The degree of the spline for the rows and columns. diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index 93865b7e..210b9033 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -83,10 +83,6 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -190,7 +186,7 @@ def mixture_model(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, di residual = y - baseline params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_array) } @@ -252,10 +248,6 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -290,7 +282,7 @@ def irsqr(self, data, lam=1e3, quantile=0.05, num_knots=25, spline_degree=3, weight_array = _weighting._quantile(y, baseline, quantile, eps) params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_array) } @@ -346,10 +338,6 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -391,7 +379,7 @@ def pspline_asls(self, data, lam=1e3, p=1e-2, num_knots=25, spline_degree=3, dif weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_array) } @@ -451,10 +439,6 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -513,7 +497,7 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_squared, rhs_extra=d1_penalty) } @@ -569,10 +553,6 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -610,7 +590,7 @@ def pspline_airpls(self, data, lam=1e3, num_knots=25, spline_degree=3, weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult2D(pspline, weight_array) } @@ -662,10 +642,6 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -700,7 +676,7 @@ def pspline_arpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_array) } @@ -752,10 +728,6 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -791,7 +763,7 @@ def pspline_iarpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult2D(pspline, weight_array) } @@ -853,10 +825,6 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -902,7 +870,7 @@ def pspline_psalsa(self, data, lam=1e3, p=0.5, k=None, num_knots=25, spline_degr weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': PSplineResult2D(pspline, weight_array) } @@ -964,10 +932,6 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order `max_iter_2`, `tol_2`), and shape K is the maximum of the number of iterations for the threshold and the maximum number of iterations for all of the fits of the various threshold values (related to `max_iter` and `tol`). - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -1029,7 +993,7 @@ def pspline_brpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_order params = { 'weights': baseline_weights, 'tol_history': tol_history[:i + 2, :max(i, j_max) + 1], - 'tck': pspline.tck, 'result': PSplineResult2D(pspline, weight_array) + 'result': PSplineResult2D(pspline, weight_array) } return baseline, params @@ -1086,10 +1050,6 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde each iteration. The length of the array is the number of iterations completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. - * 'tck': tuple[tuple[numpy.ndarray, numpy.ndarray], numpy.ndarray, numpy.ndarray] - The knots, spline coefficients, and spline degrees for the fit baseline. - Can be used with SciPy's :class:`~scipy.interpolate.NdBSpline`, to allow for - other usages such as evaluating with different x-values and z-values. * 'result': PSplineResult2D An object that can use the results of the fit to perform additional calculations. @@ -1137,7 +1097,7 @@ def pspline_lsrpls(self, data, lam=1e3, num_knots=25, spline_degree=3, diff_orde weight_array = new_weights params = { - 'weights': weight_array, 'tol_history': tol_history[:i], 'tck': pspline.tck, + 'weights': weight_array, 'tol_history': tol_history[:i], 'result': PSplineResult2D(pspline, weight_array) } diff --git a/tests/test_api.py b/tests/test_api.py index fe7f1f7e..97442c23 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,7 +6,6 @@ """ -import inspect import pickle import numpy as np @@ -269,30 +268,3 @@ def test_ensure_pickleable(self, input_x): fitter.optimize_extended_range(self.y, method='asls') pickle_and_check(fitter, 1, fitter._polynomial, fitter._spline_basis, True) - - -@ensure_deprecation(1, 4) # remove the warnings filter once pspline_mpls is removed -@pytest.mark.filterwarnings('ignore:"pspline_mpls" is deprecated') -def test_tck(data_fixture): - """Ensures all penalized spline methods return 'tck' in the output params.""" - methods = [] - for (method_name, method) in inspect.getmembers(api.Baseline): - if ( - inspect.isfunction(method) - and not method_name.startswith('_') - and ( - 'num_knots' in inspect.signature(method).parameters.keys() - or 'spline_degree' in inspect.signature(method).parameters.keys() - ) - ): - methods.append(method_name) - x, y = data_fixture - fitter = api.Baseline(x) - failures = [] - for method in methods: - _, params = getattr(fitter, method)(y) - if 'tck' not in params: - failures.append(method) - - if failures: - raise AssertionError(f'"tck" not in output params for {failures}') diff --git a/tests/test_morphological.py b/tests/test_morphological.py index 1126cd0d..2d7178c6 100644 --- a/tests/test_morphological.py +++ b/tests/test_morphological.py @@ -184,7 +184,7 @@ class TestMpspline(MorphologicalTester, InputWeightsMixin, RecreationMixin): """Class for testing mpspline baseline.""" func_name = 'mpspline' - checked_keys = ('half_window', 'weights', 'tck', 'result') + checked_keys = ('half_window', 'weights', 'result') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/test_spline.py b/tests/test_spline.py index c2bfc37c..8b5b515a 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -67,7 +67,7 @@ def test_numba_implementation(self): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history', 'tck', 'result') + checked_keys = ('weights', 'tol_history', 'result') def test_tol_history(self): """Ensures the 'tol_history' item in the parameter output is correct.""" @@ -436,7 +436,7 @@ class TestPsplineAsPLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_aspls baseline.""" func_name = 'pspline_aspls' - checked_keys = ('weights', 'tol_history', 'alpha', 'tck', 'result') + checked_keys = ('weights', 'tol_history', 'alpha', 'result') weight_keys = ('weights', 'alpha') def test_wrong_alpha_shape(self): @@ -599,7 +599,7 @@ class TestPsplineMPLS(SplineTester, InputWeightsMixin, WhittakerComparisonMixin) """Class for testing pspline_mpls baseline.""" func_name = 'pspline_mpls' - checked_keys = ('half_window', 'weights', 'tck', 'result') + checked_keys = ('half_window', 'weights', 'result') @pytest.mark.parametrize('diff_order', (1, 3)) def test_diff_orders(self, diff_order): diff --git a/tests/two_d/test_api.py b/tests/two_d/test_api.py index 78c65c5b..3b12dcd4 100644 --- a/tests/two_d/test_api.py +++ b/tests/two_d/test_api.py @@ -6,7 +6,6 @@ """ -import inspect import pickle import numpy as np @@ -252,28 +251,3 @@ def test_ensure_pickleable(self, input_x, input_z): pickle_and_check( fitter, 1, fitter._polynomial, fitter._spline_basis, x_validated, z_validated ) - - -def test_tck(data_fixture2d): - """Ensures all penalized spline methods return 'tck' in the output params.""" - methods = [] - for (method_name, method) in inspect.getmembers(api.Baseline2D): - if ( - inspect.isfunction(method) - and not method_name.startswith('_') - and ( - 'num_knots' in inspect.signature(method).parameters.keys() - or 'spline_degree' in inspect.signature(method).parameters.keys() - ) - ): - methods.append(method_name) - x, z, y = data_fixture2d - fitter = api.Baseline2D(x_data=x, z_data=z) - failures = [] - for method in methods: - _, params = getattr(fitter, method)(y) - if 'tck' not in params: - failures.append(method) - - if failures: - raise AssertionError(f'"tck" not in output params for {failures}') diff --git a/tests/two_d/test_spline.py b/tests/two_d/test_spline.py index e3f58142..edea82ca 100644 --- a/tests/two_d/test_spline.py +++ b/tests/two_d/test_spline.py @@ -55,7 +55,7 @@ class SplineTester(BaseTester2D): class IterativeSplineTester(SplineTester, InputWeightsMixin, RecreationMixin): """Base testing class for iterative spline functions.""" - checked_keys = ('weights', 'tol_history', 'tck', 'result') + checked_keys = ('weights', 'tol_history', 'result') @classmethod def setup_class(cls): From 72fbbe1e151c2768edc092d7de2d754ffa71bc48 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Mar 2026 10:35:51 -0500 Subject: [PATCH 27/38] DOC: Add class attributes to toctree --- docs/_templates/autosummary/class.rst | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst index 34e512ab..d9a71f2a 100644 --- a/docs/_templates/autosummary/class.rst +++ b/docs/_templates/autosummary/class.rst @@ -4,6 +4,18 @@ .. autoclass:: {{ objname }} + {% block attributes %} + {% if attributes %} + .. rubric:: {{ _('Attributes') }} + + .. autosummary:: + :toctree: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + {% block methods %} {% if methods %} .. rubric:: {{ _('Methods') }} @@ -19,14 +31,3 @@ {%- endfor %} {% endif %} {% endblock %} - - {% block attributes %} - {% if attributes %} - .. rubric:: {{ _('Attributes') }} - - .. autosummary:: - {% for item in attributes %} - ~{{ name }}.{{ item }} - {%- endfor %} - {% endif %} - {% endblock %} From 7d226a770f0833cac5f739b439571387d6b7156f Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:21:09 -0500 Subject: [PATCH 28/38] MAINT: Ensure beads works with optimize_pls when using l-curve --- pybaselines/misc.py | 4 +- pybaselines/optimizers.py | 93 +++++++++++++++++++++++---------------- tests/test_optimizers.py | 17 ++++++- 3 files changed, 72 insertions(+), 42 deletions(-) diff --git a/pybaselines/misc.py b/pybaselines/misc.py index d6545827..24eb15f7 100644 --- a/pybaselines/misc.py +++ b/pybaselines/misc.py @@ -295,12 +295,12 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy any of the regularization parameters within beads seems to increase the curvature of the baseline, the actual effect is due to the increased penalty on the signal. This can be readily observed by looking at the 'signal' key within the output parameter dictionary - with varying `lam_0`, `lam_1`, are `lam_2` values. + with varying `lam_0`, `lam_1`, `lam_2`, or `alpha` values. Raises ------ ValueError - Raised if `asymmetry`, `lam_0`, `lam_1`, or `lam_2` is less than 0. + Raised if `asymmetry`, `lam_0`, `lam_1`, `lam_2`, or `alpha` is less than 0. References ---------- diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index b6ead342..14d8c53d 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -65,7 +65,7 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No :meth:`~.Baseline.aspls` or :meth:`~.Baseline.pspline_aspls` methods. * 'method_params': dict[str, list] A dictionary containing the output parameters for each individual fit. - Keys will depend on the selected method and will have a list of values, + Keys will depend on the selected `method` and will have a list of values, with each item corresponding to a fit. Raises @@ -202,16 +202,18 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= ------- baseline : numpy.ndarray, shape (N,) The baseline calculated with the optimum parameter. - method_params : dict + params : dict A dictionary with the following items: - * 'optimal_parameter': int or float + * 'optimal_parameter': float or int The `lam` or `poly_order` value that produced the lowest root-mean-squared-error. * 'min_rmse': float + The minimum root-mean-squared-error obtained when using + the optimal parameter. .. deprecated:: 1.2.0 - The 'min_rmse' key will be removed from the ``method_params`` + The 'min_rmse' key will be removed from the `params` dictionary in pybaselines version 1.4.0 in favor of the new 'rmse' key which returns all root-mean-squared-error values. @@ -223,7 +225,7 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= * 'method_params': dict A dictionary containing the output parameters for the optimal fit. - Items will depend on the selected method. + Items will depend on the selected `method`. Raises ------ @@ -445,7 +447,7 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, An array of the two polynomial orders used for the fitting. * 'method_params': dict[str, list] A dictionary containing the output parameters for each individual fit. - Keys will depend on the selected method and will have a list of values, + Keys will depend on the selected `method` and will have a list of values, with each item corresponding to a fit. Raises @@ -567,7 +569,7 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la The truncated baseline before interpolating from `P` points to `N` points. * 'method_params': dict A dictionary containing the output parameters for the fit using the selected - method. + `method`. Raises ------ @@ -726,7 +728,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, ------- baseline : numpy.ndarray, shape (N,) The baseline calculated with the optimum parameter. - method_params : dict + params : dict A dictionary with the following items: * 'optimal_parameter': float @@ -735,7 +737,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, The computed metric for each `lam` value tested. * 'method_params': dict A dictionary containing the output parameters for the optimal fit. - Items will depend on the selected method. + Items will depend on the selected `method`. * 'fidelity': numpy.ndarray, shape (P,) The computed non-normalized fidelity term for each `lam` value tested. For most algorithms within pybaselines, this corresponds to @@ -949,41 +951,56 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ using_aspls = 'aspls' in method using_drpls = 'drpls' in method using_iasls = 'iasls' in method + using_beads = method == 'beads' if any((using_aspls, using_drpls, using_iasls)): raise NotImplementedError(f'{method} method is not currently supported') - method_signature = inspect.signature(baseline_func).parameters - if 'diff_order' in method_kws: - diff_order = method_kws['diff_order'] + if using_beads: + param_key = 'alpha' else: - # some methods have a different default diff_order, so have to inspect them - diff_order = method_signature['diff_order'].default + param_key = 'lam' + # some methods have different defaults, so have to inspect them + method_signature = inspect.signature(baseline_func).parameters + diff_order = method_kws.get( + 'diff_order', method_signature['diff_order'].default + ) - penalty = [] - fidelity = [] - for lam in lam_range: - fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) - if spline_fit: - penalized_object = fit_params['result'].tck[1] # the spline coefficients - else: - penalized_object = fit_baseline - # Park, et al. multiplied the penalty by lam (Equation 8), but I think that may have - # been a typo since it otherwise favors low lam values and does not produce a - # penalty plot shown in Figure 4 in the Park, et al. reference - partial_penalty = np.diff(penalized_object, diff_order) - fit_penalty = partial_penalty.dot(partial_penalty) - - residual = y - fit_baseline - if 'weights' in fit_params: - fit_fidelity = fit_params['weights'] @ residual**2 + n_lams = len(lam_range) + penalty = np.empty(n_lams) + fidelity = np.empty(n_lams) + for i, lam in enumerate(lam_range): + fit_baseline, fit_params = baseline_func(y, **{param_key: lam}, **method_kws) + if using_beads: + fit_penalty = sum(fit_params['penalty']) + fit_fidelity = fit_params['fidelity'] else: - fit_fidelity = residual @ residual + if spline_fit: + penalized_object = fit_params['result'].tck[1] # the spline coefficients + else: + # have to ensure sort order of the fit baseline since + # diff(y_ordered) != diff(y_disordered); spline coefficients are always + # sorted since they correspond to sorted x-values + penalized_object = _sort_array(fit_baseline, baseline_obj._sort_order) + + # Park, et al. multiplied the penalty by lam (Equation 8), but I think that may have + # been a typo since it otherwise favors low lam values and does not produce a + # penalty plot shown in Figure 4 in the Park, et al. reference + partial_penalty = np.diff(penalized_object, diff_order) + fit_penalty = partial_penalty @ partial_penalty + + residual = y - fit_baseline + if 'weights' in fit_params: + if using_iasls: + weights = fit_params['weights']**2 + else: + weights = fit_params['weights'] + fit_fidelity = weights @ residual**2 + else: + fit_fidelity = residual @ residual - penalty.append(fit_penalty) - fidelity.append(fit_fidelity) + penalty[i] = fit_penalty + fidelity[i] = fit_fidelity - penalty = np.array(penalty) - fidelity = np.array(fidelity) # add fidelity and penalty to params before potentially normalizing params = {'fidelity': fidelity, 'penalty': penalty} if lam_range.size > 1: @@ -995,7 +1012,7 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ metric = fidelity + penalty best_lam = lam_range[np.argmin(metric)] - baseline, best_params = baseline_func(y, lam=best_lam, **method_kws) + baseline, best_params = baseline_func(y, **{param_key: best_lam}, **method_kws) params.update({'optimal_parameter': best_lam, 'metric': metric, 'method_params': best_params}) return baseline, params @@ -1051,7 +1068,7 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, if method == 'beads': # only supported for L-curve-based optimization options raise NotImplementedError( - f'optimize_pls does not support the beads method for {opt_method}' + 'optimize_pls does not support the beads method for GCV or BIC opt_method inputs' ) using_iasls = 'iasls' in method diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index 209f5b27..e4c3d00c 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -696,14 +696,27 @@ def test_output(self, opt_method): 'pspline_iarpls', 'pspline_aspls', 'pspline_psalsa', 'pspline_derpsalsa' ) ) - def test_all_methods(self, method): + @pytest.mark.parametrize('opt_method', ('U-Curve', 'GCV')) + def test_all_methods(self, method, opt_method): """Tests most methods that should work with optimize_pls.""" - output = self.class_func(self.y, method=method, **self.kwargs) + output = self.class_func(self.y, method=method, opt_method=opt_method, **self.kwargs) if 'weights' in output[1]['method_params']: assert self.y.shape == output[1]['method_params']['weights'].shape elif 'alpha' in output[1]['method_params']: assert self.y.shape == output[1]['method_params']['alpha'].shape + @pytest.mark.parametrize('opt_method', ('U-Curve', 'GCV', 'BIC')) + def test_beads(self, opt_method): + """Ensures beads is also supported for L-curve based optimization methods.""" + if opt_method in ('GCV', 'BIC'): + with pytest.raises( + NotImplementedError, match='optimize_pls does not support the beads method' + ): + self.class_func(self.y, method='beads', opt_method=opt_method, **self.kwargs) + else: + # just ensure calling does not produce errors + self.class_func(self.y, method='beads', opt_method=opt_method, **self.kwargs) + def test_unknown_method_fails(self): """Ensures method fails when an unknown baseline method is given.""" with pytest.raises(AttributeError): From d2dc247cc1af268f700c9fedc0cb7876ed81af02 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:23:52 -0500 Subject: [PATCH 29/38] DOC: Add discussion on beads simplification --- docs/algorithms/algorithms_1d/misc.rst | 105 ++++++++++++++++++++----- 1 file changed, 86 insertions(+), 19 deletions(-) diff --git a/docs/algorithms/algorithms_1d/misc.rst b/docs/algorithms/algorithms_1d/misc.rst index 614bea79..1da18950 100644 --- a/docs/algorithms/algorithms_1d/misc.rst +++ b/docs/algorithms/algorithms_1d/misc.rst @@ -83,13 +83,14 @@ as sparse. The minimized equation for calculating the pure signal is given as: .. math:: \frac{1}{2} ||H(y - s)||_2^2 - + \lambda_0 \sum\limits_{i}^{N} \theta(s_i) + + \lambda_0 \sum\limits_{i}^{N} \theta(s_i, r) + \lambda_1 \sum\limits_{i}^{N - 1} \phi(\Delta^1 s_i) + \lambda_2 \sum\limits_{i}^{N - 2} \phi(\Delta^2 s_i) where :math:`y` is the measured data, :math:`s` is the calculated pure signal, :math:`H` is a high pass filter, :math:`\theta()` is a differentiable, symmetric -or asymmetric penalty function on the calculated signal, :math:`\Delta^1` and :math:`\Delta^2` +or asymmetric penalty function on the calculated signal, :math:`r` is the asymmetry +of :math:`\theta()` by which negative values are penalized more, :math:`\Delta^1` and :math:`\Delta^2` are :ref:`finite-difference operators ` of order 1 and 2, respectively, and :math:`\phi()` is a differentiable, symmetric penalty function approximating the L1 loss (mean absolute error) applied to the first and second derivatives @@ -101,23 +102,6 @@ The calculated baseline, :math:`v`, upon convergence of calculating the pure sig v = y - s - H(y - s) -pybaselines version 1.3.0 introduced an optional simplification of the :math:`\lambda_0`, -:math:`\lambda_1`, :math:`\lambda_2` regularization parameter selection using the procedure -recommended by the BEADS manuscript through the addition of the parameter :math:`\alpha`. -Briefly, it is assumed that each :math:`\lambda_d` value is approximately proportional to some -constant :math:`\alpha` divided by the L1 norm of the d'th derivative of the input data such -that: - -.. math:: - - \lambda_0 = \frac{\alpha}{||y||_1}, - \lambda_1 = \frac{\alpha}{||y'||_1}, - \lambda_2 = \frac{\alpha}{||y''||_1} - -Such a parametrization allows varying just :math:`\alpha`, as well as simplified usage -within optimization frameworks to find the best value, as shown by -`Bosten, et al. `_ - .. plot:: :align: center :context: reset @@ -268,3 +252,86 @@ of the beads method by accessing the 'signal' key in the output parameters. (data_handle[0], signal_handle[0]), ('data', 'signal from beads'), loc='center', frameon=False ) + +pybaselines version 1.3.0 introduced an optional simplification of the :math:`\lambda_0`, +:math:`\lambda_1`, :math:`\lambda_2` regularization parameter selection using the procedure +recommended by the BEADS manuscript through the addition of the parameter :math:`\alpha`. +In detail, it is assumed that each :math:`\lambda_d` value is approximately proportional to some +constant :math:`\alpha` divided by the L1 norm of the d'th derivative of the input data +such that: + +.. math:: + :label: lam_eq + + \lambda_0 = \frac{\alpha}{||y||_1}, + \lambda_1 = \frac{\alpha}{||y'||_1}, + \lambda_2 = \frac{\alpha}{||y''||_1} + +Such a parametrization allows varying just :math:`\alpha`, which simplifies basic usage and +allows for easier integration within optimization frameworks to find the best regularization +parameter, as demonstrated by `Bosten, et al. `_ + +At first glance, Eq. :eq:`lam_eq` seems to have an issue in that the penalties within the BEADS +algorithm are applied to the calculated signal, while the estimated :math:`\lambda_d` values +will be based on the input data, which is composed of the signal, baseline, and noise. +Due to this, the estimate for :math:`\lambda_0` is affected by an overestimation of the +signal's L1 norm, while also not accounting for the asymmetry of the penalty function +:math:`\theta()`. Further, while the estimates for :math:`\lambda_1` and :math:`\lambda_2` +are less affected by the baseline since taking the derivatives eliminates some +contributions from the baseline, the influence of noise is amplified, as demonstrated +in the figure below, also resulting in an overestimation of their L1 norms. In practice, +however, this systematic norm overestimation can be accounted for by simply selecting a larger +:math:`\alpha`, and Eq. :eq:`lam_eq` ends up being a fairly good first approximation to +allow one value to determine all three regularization terms. + +.. plot:: + :align: center + :context: close-figs + :include-source: False + :show-source-link: True + + from pybaselines.utils import make_data + + x, y_no_noise, known_baseline = make_data( + return_baseline=True, noise_std=0.001, bkg_type='sine' + ) + true_y = y_no_noise - known_baseline + x, y = make_data(noise_std=0.1, bkg_type='sine') + # subtract the minimum so it plots nicer with the pure signal + y -= y.min() + + _, (ax1, ax2, ax3) = plt.subplots(nrows=3, sharex=True) + dy = np.gradient(y) + d2y = np.gradient(dy) + true_dy = np.gradient(true_y) + true_d2y = np.gradient(true_dy) + ax1.plot(x, y, label='data') + ax1.plot(x, true_y, '--', label='true signal') + ax2.plot(x, dy) + ax2.plot(x, true_dy, '--') + ax3.plot(x, d2y, label='data') + ax3.plot(x, true_d2y, '--', label='true signal') + + ax1.legend() + ax1.set_ylabel('0th Derivative') + ax2.set_ylabel('1st Derivative') + ax3.set_ylabel('2nd Derivative') + + plt.figure() + deriv_values = np.arange(3, dtype=int) + plt.plot( + deriv_values, + [abs(y).sum(), abs(dy).sum(), abs(d2y).sum()], + 'o-', label='data' + ) + plt.plot( + deriv_values, + [abs(true_y).sum(), abs(true_dy).sum(), abs(true_d2y).sum()], + 'o--', label='true signal' + ) + + plt.xlabel('Derivative Order') + plt.xticks(deriv_values) + plt.ylabel('L1 Norm') + plt.semilogy() + plt.legend() From 7d8e72b17a4f28794d293ba0aee44ba371c1bb20 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 7 Mar 2026 19:35:08 -0500 Subject: [PATCH 30/38] TST: Add tests for effective dimension limits in 1D --- tests/test_results.py | 106 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/test_results.py b/tests/test_results.py index 0c40a7e7..ba2b6ecc 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -94,6 +94,112 @@ def test_whittaker_effective_dimension_stochastic(diff_order, allow_lower, allow assert_allclose(output, expected_ed, rtol=5e-1, atol=1e-5) +@pytest.mark.parametrize('size', (100, 401)) +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('large_lam', (True, False)) +@pytest.mark.parametrize('solver', (0, 1, 2)) +def test_whittaker_effective_dimension_lam_extremes(size, diff_order, large_lam, solver): + """ + Tests the effective dimension of Whittaker smoothing for high and low limits of ``lam``. + + When `lam` is ~infinite, the fit should approximate a polynomial of degree + ``diff_order - 1``, so the effective dimension should be `diff_order` sinc ED for a + polynomial is ``degree + 1``. Likewise, as `lam` approaches 0, the solution should + be the same as an interpolating spline of the same spline degree, so its effective + dimension should be the number of basis functions, i.e. the number of data points + when using Whittaker smoothing. + + References + ---------- + Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical + Science, 1996, 11(2), 89-121. + + Eilers, P. A Perfect Smoother. Analytical Chemistry, 2003, 75(14), 3631-3636. + + """ + if solver == 0: # use full banded with solve_banded + allow_lower = False + allow_penta = False + elif solver == 1: # use lower banded with solveh_banded + allow_lower = True + allow_penta = False + else: # use full banded with pentadiagonal solver if diff_order=2 + allow_lower = False + allow_penta = True + + if large_lam: + lam = 1e14 + expected_ed = diff_order + # limited by how close to infinity lam can get before it causes numerical instability, + # and larger diff_orders need larger lam for it to be a polynomial, so have to reduce the + # relative tolerance as diff_order increases + rtol = {1: 8e-3, 2: 5e-3, 3: 3e-2}[diff_order] + else: + lam = 1e-16 + expected_ed = size + rtol = 1e-15 + + penalized_system = _banded_utils.PenalizedSystem( + size, lam=lam, diff_order=diff_order, allow_lower=allow_lower, + reverse_diags=False, allow_penta=allow_penta + ) + result_obj = results.WhittakerResult(penalized_system) + output = result_obj.effective_dimension(n_samples=0) + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-11) + + +@pytest.mark.parametrize('diff_order', (1, 2, 3)) +@pytest.mark.parametrize('spline_degree', (1, 2, 3)) +@pytest.mark.parametrize('num_knots', (20, 101)) +@pytest.mark.parametrize('large_lam', (True, False)) +@pytest.mark.parametrize('allow_lower', (True, False)) +def test_pspline_effective_dimension_lam_extremes(data_fixture, diff_order, spline_degree, + num_knots, large_lam, allow_lower): + """ + Tests the effective dimension of P-spline smoothing for high and low limits of ``lam``. + + When `lam` is ~infinite, the spline fit should approximate a polynomial of degree + ``diff_order - 1``, so the effective dimension should be `diff_order` sinc ED for a + polynomial is ``degree + 1``. Likewise, as ``lam`` approaches 0, the solution should + be the same as an interpolating spline of the same spline degree, so its effective + dimension should be the number of basis functions. + + References + ---------- + Eilers, P., et al. Flexible Smoothing with B-splines and Penalties. Statistical + Science, 1996, 11(2), 89-121. + + """ + x, y = data_fixture + if large_lam: + lam = 1e14 + expected_ed = diff_order + # limited by how close to infinity lam can get before it causes numerical instability, + # and both larger num_knots and larger diff_orders need larger lam for it to be a + # polynomial, so have to reduce the relative tolerance; num_knots has a larger effect + # than diff_order, so base the rtol on it + if spline_degree >= diff_order: # can approximate a polynomial + rtol = {20: 6e-3, 101: 5e-2}[num_knots] + else: + # technically should skip since the spline cannot approximate the polynomial, + # but just increase rtol instead + rtol = {20: 1e-2, 101: 1e-1}[num_knots] + else: + lam = 1e-16 + expected_ed = num_knots + spline_degree - 1 + rtol = 1e-15 + + spline_basis = _spline_utils.SplineBasis( + x, num_knots=num_knots, spline_degree=spline_degree + ) + pspline = _spline_utils.PSpline( + spline_basis, lam=lam, diff_order=diff_order, allow_lower=allow_lower + ) + result_obj = results.PSplineResult(pspline) + output = result_obj.effective_dimension(n_samples=0) + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-11) + + @pytest.mark.parametrize('n_samples', (-1, 50.5)) def test_whittaker_effective_dimension_stochastic_invalid_samples(data_fixture, n_samples): """Ensures a non-zero, non-positive `n_samples` input raises an exception.""" From dca91f2a5ea8eb8bc32548380b42cc62cbdc302f Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:25:13 -0500 Subject: [PATCH 31/38] TST: Add tests for effective dimension limits in 2D --- tests/test_results.py | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/test_results.py b/tests/test_results.py index ba2b6ecc..9f5367e1 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -457,6 +457,47 @@ def test_pspline_result_two_d_no_weights(data_fixture2d): assert_allclose(result_obj._weights, np.ones(y.shape), rtol=1e-16, atol=0) +@pytest.mark.parametrize('num_knots', (10, (11, 20))) +@pytest.mark.parametrize('spline_degree', (0, 1, 2, (2, 3))) +@pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) +@pytest.mark.parametrize('large_lam', (True, False)) +def test_pspline_two_d_effective_dimension_lam_extremes(data_fixture2d, diff_order, spline_degree, + num_knots, large_lam): + """ + Tests the effective dimension of 2D P-spline smoothing for high and low limits of ``lam``. + + Same reasoning as for `test_pspline_effective_dimension_lam_extremes`, except the total + effective dimension is just the product of the row and column effective dimensions. + + """ + x, z, y = data_fixture2d + ( + num_knots_r, num_knots_c, spline_degree_r, spline_degree_c, + lam_r, lam_c, diff_order_r, diff_order_c + ) = get_2dspline_inputs(num_knots, spline_degree, lam=1, diff_order=diff_order) + + if large_lam: + lam = 1e14 + expected_ed = diff_order_r * diff_order_c + # limited by how close to infinity lam can get before it causes numerical instability; + # just picked rtol that passes all conditions + rtol = 4e-2 + else: + lam = 1e-16 + expected_ed = (num_knots_r + spline_degree_r - 1) * (num_knots_c + spline_degree_c - 1) + rtol = 1e-13 + + spline_basis = SplineBasis2D( + x, z, num_knots=num_knots, spline_degree=spline_degree, check_finite=False + ) + pspline = PSpline2D(spline_basis, lam=lam, diff_order=diff_order) + result_obj = results.PSplineResult2D(pspline) + + output = result_obj.effective_dimension(n_samples=0) + + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-11) + + @pytest.mark.parametrize('shape', ((20, 23), (51, 6))) @pytest.mark.parametrize('diff_order', (1, 2, (2, 3))) @pytest.mark.parametrize('lam', (1e2, (1e1, 1e2))) @@ -617,3 +658,41 @@ def test_whittaker_result_two_d_lhs_penalty_raises(data_fixture2d): penalized_system, weights, lhs=penalized_system.penalty, penalty=penalized_system.penalty ) + + +@pytest.mark.parametrize('shape', ((30, 21), (15, 40))) +@pytest.mark.parametrize('diff_order', (1, 2, 3, (1, 2))) +@pytest.mark.parametrize('large_lam', (True, False)) +def test_whittaker_two_d_effective_dimension_lam_extremes(shape, diff_order, large_lam): + """ + Tests the effective dimension of 2D Whittaker smoothing for high and low limits of ``lam``. + + Same reasoning as for `test_whittaker_effective_dimension_lam_extremes`, except the total + effective dimension is just the product of the row and column effective dimensions. + + """ + if large_lam: + lam = 1e14 + if isinstance(diff_order, int): + expected_ed = diff_order**2 + max_diff_order = diff_order + else: + expected_ed = np.prod(diff_order) + max_diff_order = max(diff_order) + # limited by how close to infinity lam can get before it causes numerical instability, + # and larger diff_orders need larger lam for it to be a polynomial, so have to reduce the + # relative tolerance as diff_order increases + rtol = {1: 8e-3, 2: 2e-2, 3: 3e-2}[max_diff_order] + else: + lam = 1e-16 + expected_ed = np.prod(shape) + rtol = 1e-15 + + whittaker_system = WhittakerSystem2D( + shape, lam=lam, diff_order=diff_order, num_eigens=None + ) + result_obj = results.WhittakerResult2D(whittaker_system) + + output = result_obj.effective_dimension(n_samples=0) + + assert_allclose(output, expected_ed, rtol=rtol, atol=1e-11) From a0c27a02641b66d386e543dba0ebd559745e8e0a Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 8 Mar 2026 12:33:15 -0400 Subject: [PATCH 32/38] TST: Add tests for algorithms with binary weighting --- tests/test_morphological.py | 20 ++++++++++++++++++++ tests/test_spline.py | 20 ++++++++++++++++++++ tests/test_whittaker.py | 20 ++++++++++++++++++++ tests/two_d/test_spline.py | 20 ++++++++++++++++++++ tests/two_d/test_whittaker.py | 20 ++++++++++++++++++++ 5 files changed, 100 insertions(+) diff --git a/tests/test_morphological.py b/tests/test_morphological.py index 2d7178c6..9d1a8291 100644 --- a/tests/test_morphological.py +++ b/tests/test_morphological.py @@ -99,6 +99,16 @@ def test_recreation(self): with pytest.warns(DeprecationWarning): super().test_recreation() + @pytest.mark.parametrize('p', (0, 0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestMor(MorphologicalTester): """Class for testing mor baseline.""" @@ -203,6 +213,16 @@ def test_outside_p_fails(self, p): with pytest.raises(ValueError): self.class_func(self.y, p=p) + @pytest.mark.parametrize('p', (0, 0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestJBCD(MorphologicalTester): """Class for testing jbcd baseline.""" diff --git a/tests/test_spline.py b/tests/test_spline.py index 8b5b515a..f48e615d 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -211,6 +211,16 @@ def test_whittaker_comparison(self, lam, p, diff_order): """Ensures the P-spline version is the same as the Whittaker version.""" super().test_whittaker_comparison(lam=lam, p=p, diff_order=diff_order) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestPsplineIAsLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_iasls baseline.""" @@ -246,6 +256,16 @@ def test_whittaker_comparison(self, lam, lam_1, p, diff_order): """Ensures the P-spline version is the same as the Whittaker version.""" super().test_whittaker_comparison(lam=lam, lam_1=lam_1, p=p, diff_order=diff_order) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestPsplineAirPLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_airpls baseline.""" diff --git a/tests/test_whittaker.py b/tests/test_whittaker.py index eeb4816a..07b90b00 100644 --- a/tests/test_whittaker.py +++ b/tests/test_whittaker.py @@ -153,6 +153,16 @@ def test_diff_orders(self, diff_order): lam = {1: 1e2, 3: 1e10}[diff_order] self.class_func(self.y, lam=lam, diff_order=diff_order) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestIAsLS(WhittakerTester): """Class for testing iasls baseline.""" @@ -198,6 +208,16 @@ def test_sparse_comparison(self, diff_order, lam_1, p): assert_allclose(banded_output, sparse_output, rtol=5e-4, atol=1e-8) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestAirPLS(WhittakerTester): """Class for testing airpls baseline.""" diff --git a/tests/two_d/test_spline.py b/tests/two_d/test_spline.py index edea82ca..8de65289 100644 --- a/tests/two_d/test_spline.py +++ b/tests/two_d/test_spline.py @@ -164,6 +164,16 @@ def test_whittaker_comparison(self, lam, p, diff_order): """Ensures the P-spline version is the same as the Whittaker version.""" super().test_whittaker_comparison(lam=lam, p=p, diff_order=diff_order) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestPsplineIAsLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_iasls baseline.""" @@ -208,6 +218,16 @@ def test_whittaker_comparison(self, lam, lam_1, p, diff_order): lam=lam, lam_1=lam_1, p=p, diff_order=diff_order, uses_eigenvalues=False, test_rtol=1e-5 ) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestPsplineAirPLS(IterativeSplineTester, WhittakerComparisonMixin): """Class for testing pspline_airpls baseline.""" diff --git a/tests/two_d/test_whittaker.py b/tests/two_d/test_whittaker.py index 0fcb09fd..6f5f488a 100644 --- a/tests/two_d/test_whittaker.py +++ b/tests/two_d/test_whittaker.py @@ -75,6 +75,16 @@ def test_diff_orders(self, diff_order): """Ensure that other difference orders work.""" self.class_func(self.y, diff_order=diff_order) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestIAsLS(WhittakerTester): """Class for testing iasls baseline.""" @@ -104,6 +114,16 @@ def test_diff_order_one_fails(self): with pytest.raises(ValueError): self.class_func(self.y, lam=1e2, diff_order=[2, 1]) + @pytest.mark.parametrize('p', (0.01, 0.2)) + def test_output_binary_weights(self, p): + """Ensures all weights are either ``p`` or ``1 - p``.""" + _, params = self.class_func(self.y, p=p) + weights = params['weights'] + assert ( + np.isclose(weights, p, atol=1e-15, rtol=0) + | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + ).all() + class TestAirPLS(EigenvalueMixin, WhittakerTester): """Class for testing airpls baseline.""" From 789ed2c5cd1d271520d77864cc0721b2d2dc6354 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 8 Mar 2026 13:56:32 -0400 Subject: [PATCH 33/38] BREAK: iasls returns squared weights Address issue #63. Only a breaking change if inputting weights or using the output weights. --- docs/algorithms/algorithms_1d/spline.rst | 35 ++++++++++++++++++--- docs/algorithms/algorithms_1d/whittaker.rst | 33 ++++++++++++++++--- pybaselines/optimizers.py | 12 ++----- pybaselines/spline.py | 23 ++++++++++---- pybaselines/two_d/spline.py | 24 ++++++++++---- pybaselines/two_d/whittaker.py | 22 +++++++++---- pybaselines/whittaker.py | 31 +++++++++--------- tests/test_spline.py | 6 ++-- tests/test_whittaker.py | 6 ++-- tests/two_d/test_spline.py | 6 ++-- tests/two_d/test_whittaker.py | 6 ++-- 11 files changed, 140 insertions(+), 64 deletions(-) diff --git a/docs/algorithms/algorithms_1d/spline.rst b/docs/algorithms/algorithms_1d/spline.rst index 60db6af9..0cacb08f 100644 --- a/docs/algorithms/algorithms_1d/spline.rst +++ b/docs/algorithms/algorithms_1d/spline.rst @@ -319,7 +319,7 @@ Minimized function: .. math:: - \sum\limits_{i}^N (w_i (y_i - v(x_i)))^2 + \sum\limits_{i}^N w_i (y_i - v(x_i))^2 + \lambda \sum\limits_{i}^{M - d} (\Delta^d c_i)^2 + \lambda_1 \sum\limits_{i}^{N - 1} (\Delta^1 (y_i - v(x_i)))^2 @@ -327,19 +327,44 @@ Linear system: .. math:: - (B^{\mathsf{T}} W^{\mathsf{T}} W B + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1 B + \lambda D_d^{\mathsf{T}} D_d) c - = (B^{\mathsf{T}} W^{\mathsf{T}} W + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1) y + (B^{\mathsf{T}} W B + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1 B + \lambda D_d^{\mathsf{T}} D_d) c + = (B^{\mathsf{T}} W + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1) y Weighting: .. math:: w_i = \left\{\begin{array}{cr} - p & y_i > v_i \\ - 1 - p & y_i \le v_i + p^2 & y_i > v_i \\ + (1 - p)^2 & y_i \le v_i \end{array}\right. +.. note:: + + Using the literature implementation of IAsLs, its equivalent P-Spline linear equation would + be + + .. math:: + + (B^{\mathsf{T}} W^{\mathsf{T}} W B + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1 B + \lambda D_d^{\mathsf{T}} D_d) c + = (B^{\mathsf{T}} W^{\mathsf{T}} W + \lambda_1 B^{\mathsf{T}} D_1^{\mathsf{T}} D_1) y + + with the weighting scheme + + .. math:: + + w_i = \left\{\begin{array}{cr} + p & y_i > v_i \\ + 1 - p & y_i \le v_i + \end{array}\right. + + These are equivalent to the linear equation and weighting scheme listed above when + incorporating the squaring of the weights directly within the weighting scheme. The + simplified functional form using squared weights is used in pybaselines for consistency + with all other algorithms. + + .. plot:: :align: center :context: close-figs diff --git a/docs/algorithms/algorithms_1d/whittaker.rst b/docs/algorithms/algorithms_1d/whittaker.rst index e1ca4804..700e34af 100644 --- a/docs/algorithms/algorithms_1d/whittaker.rst +++ b/docs/algorithms/algorithms_1d/whittaker.rst @@ -343,7 +343,7 @@ Minimized function: .. math:: - \sum\limits_{i}^N (w_i (y_i - v_i))^2 + \sum\limits_{i}^N w_i (y_i - v_i)^2 + \lambda \sum\limits_{i}^{N - d} (\Delta^d v_i)^2 + \lambda_1 \sum\limits_{i}^{N - 1} (\Delta^1 (y_i - v_i))^2 @@ -351,18 +351,41 @@ Linear system: .. math:: - (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1 + \lambda D_d^{\mathsf{T}} D_d) v - = (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1) y + (W + \lambda_1 D_1^{\mathsf{T}} D_1 + \lambda D_d^{\mathsf{T}} D_d) v + = (W + \lambda_1 D_1^{\mathsf{T}} D_1) y Weighting: .. math:: w_i = \left\{\begin{array}{cr} - p & y_i > v_i \\ - 1 - p & y_i \le v_i + p^2 & y_i > v_i \\ + (1 - p)^2 & y_i \le v_i \end{array}\right. +.. note:: + + Within literature, IAsLs uses the linear equation + + .. math:: + + (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1 + \lambda D_d^{\mathsf{T}} D_d) v + = (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1) y + + with the weighting scheme + + .. math:: + + w_i = \left\{\begin{array}{cr} + p & y_i > v_i \\ + 1 - p & y_i \le v_i + \end{array}\right. + + These are equivalent to the linear equation and weighting scheme listed above when + incorporating the squaring of the weights directly within the weighting scheme. The + simplified functional form using squared weights is used in pybaselines for consistency + with all other algorithms. + .. plot:: :align: center diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 14d8c53d..55991899 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -990,11 +990,7 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ residual = y - fit_baseline if 'weights' in fit_params: - if using_iasls: - weights = fit_params['weights']**2 - else: - weights = fit_params['weights'] - fit_fidelity = weights @ residual**2 + fit_fidelity = fit_params['weights'] @ residual**2 else: fit_fidelity = residual @ residual @@ -1086,11 +1082,7 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, # methods are drpls and iasls # TODO should fidelity calc be directly added to the result objects so that this # can be handled directly? - if using_iasls: - weights = fit_params['weights']**2 - else: - weights = fit_params['weights'] - fit_fidelity = weights @ (y - fit_baseline)**2 + fit_fidelity = fit_params['weights'] @ (y - fit_baseline)**2 if use_gcv: # GCV = (1/N) * RSS / (1 - rho * trace / N)**2 == RSS * N / (N - rho * trace)**2 # Note that some papers use different terms for fidelity (eg. RSS / N vs just RSS), diff --git a/pybaselines/spline.py b/pybaselines/spline.py index bed47b2c..9a650b08 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -488,8 +488,8 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, Default is 1e1. p : float, optional The penalizing weighting factor. Must be between 0 and 1. Values greater - than the baseline will be given `p` weight, and values less than the baseline - will be given `1 - p` weight. Default is 1e-2. + than the baseline will be given ``p**2`` weight, and values less than the baseline + will be given ``(1 - p)**2`` weight. Default is 1e-2. lam_1 : float, optional The smoothing parameter for the first derivative of the residual. Default is 1e-4. num_knots : int, optional @@ -516,6 +516,11 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, * 'weights': numpy.ndarray, shape (N,) The weight array used for fitting the data. + + .. versionchanged:: 1.3.0 + Prior to version 1.3.0, the returned weights were the non-squared + values (ie. ``p`` or ``1 - p``). + * 'tol_history': numpy.ndarray An array containing the calculated tolerance values for each iteration. The length of the array is the number of iterations @@ -530,6 +535,13 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, ValueError Raised if `p` is not between 0 and 1 or if `diff_order` is less than 2. + Notes + ----- + Although both ``pspline_iasls`` and :meth:`~.Baseline.pspline_asls` use `p` for defining + the weights, the appropriate `p` value for ``pspline_iasls`` will be approximately equal + to the square root of the value used for ``pspline_asls`` when `p` is small since + ``pspline_iasls`` uses squared weights. + See Also -------- Baseline.iasls @@ -576,9 +588,8 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - weight_squared = weight_array**2 - baseline = pspline.solve_pspline(y, weight_squared, rhs_extra=partial_rhs) - new_weights = _weighting._asls(y, baseline, p) + baseline = pspline.solve_pspline(y, weight_array, rhs_extra=partial_rhs) + new_weights = _weighting._asls(y, baseline, p)**2 calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference if calc_difference < tol: @@ -587,7 +598,7 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, params = { 'weights': weight_array, 'tol_history': tol_history[:i + 1], - 'result': PSplineResult(pspline, weight_squared, rhs_extra=d1_penalty) + 'result': PSplineResult(pspline, weight_array, rhs_extra=d1_penalty) } return baseline, params diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index 210b9033..30519220 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -401,8 +401,8 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, baselines. Default is 1e3. p : float, optional The penalizing weighting factor. Must be between 0 and 1. Values greater - than the baseline will be given `p` weight, and values less than the baseline - will be given `1 - p` weight. Default is 1e-2. + than the baseline will be given ``p**2`` weight, and values less than the baseline + will be given ``(1 - p)**2`` weight. Default is 1e-2. lam_1 : float or Sequence[float, float], optional The smoothing parameter for the rows and columns, respectively, of the first derivative of the residual. If a single value is given, both will use the same @@ -434,6 +434,11 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, * 'weights': numpy.ndarray, shape (M, N) The weight array used for fitting the data. + + .. versionchanged:: 1.3.0 + Prior to version 1.3.0, the returned weights were the non-squared + values (ie. ``p`` or ``1 - p``). + * 'tol_history': numpy.ndarray An array containing the calculated tolerance values for each iteration. The length of the array is the number of iterations @@ -448,6 +453,14 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, ValueError Raised if `p` is not between 0 and 1 or if `diff_order` is less than 2. + Notes + ----- + Although both ``pspline_iasls`` and :meth:`~.Baseline2D.pspline_asls` use `p` for defining + the weights, the appropriate `p` value for ``pspline_iasls`` will be approximately equal + to the square root of the value used for ``pspline_asls`` when `p` is small since + ``pspline_iasls`` uses squared weights. + + See Also -------- Baseline2D.iasls @@ -487,9 +500,8 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - weight_squared = weight_array**2 - baseline = pspline.solve(y, weight_squared, rhs_extra=partial_rhs) - new_weights = _weighting._asls(y, baseline, p) + baseline = pspline.solve(y, weight_array, rhs_extra=partial_rhs) + new_weights = _weighting._asls(y, baseline, p)**2 calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference if calc_difference < tol: @@ -498,7 +510,7 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, params = { 'weights': weight_array, 'tol_history': tol_history[:i + 1], - 'result': PSplineResult2D(pspline, weight_squared, rhs_extra=d1_penalty) + 'result': PSplineResult2D(pspline, weight_array, rhs_extra=d1_penalty) } return baseline, params diff --git a/pybaselines/two_d/whittaker.py b/pybaselines/two_d/whittaker.py index 7a2dd956..34fbd37e 100644 --- a/pybaselines/two_d/whittaker.py +++ b/pybaselines/two_d/whittaker.py @@ -147,8 +147,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, baselines. Default is 1e6. p : float, optional The penalizing weighting factor. Must be between 0 and 1. Values greater - than the baseline will be given `p` weight, and values less than the baseline - will be given `1 - p` weight. Default is 1e-2. + than the baseline will be given ``p**2`` weight, and values less than the baseline + will be given ``(1 - p)**2`` weight. Default is 1e-2. lam_1 : float or Sequence[float, float], optional The smoothing parameter for the rows and columns, respectively, of the first derivative of the residual. Default is 1e-4. @@ -173,6 +173,11 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, * 'weights': numpy.ndarray, shape (M, N) The weight array used for fitting the data. + + .. versionchanged:: 1.3.0 + Prior to version 1.3.0, the returned weights were the non-squared + values (ie. ``p`` or ``1 - p``). + * 'tol_history': numpy.ndarray An array containing the calculated tolerance values for each iteration. The length of the array is the number of iterations @@ -187,6 +192,12 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, ValueError Raised if `p` is not between 0 and 1 or if `diff_order` is less than 2. + Notes + ----- + Although both ``iasls`` and :meth:`~.Baseline2D.asls` use `p` for defining the weights, + the appropriate `p` value for ``iasls`` will be approximately equal to the square root + of the value used for ``asls`` when `p` is small since ``iasls`` uses squared weights. + References ---------- He, S., et al. Baseline correction for raman spectra using an improved @@ -213,9 +224,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, p1_y = penalized_system_1.penalty @ y tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - weight_squared = weight_array**2 - baseline = whittaker_system.solve(y, weight_squared, rhs_extra=p1_y) - new_weights = _weighting._asls(y, baseline, p) + baseline = whittaker_system.solve(y, weight_array, rhs_extra=p1_y) + new_weights = _weighting._asls(y, baseline, p)**2 calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference if calc_difference < tol: @@ -225,7 +235,7 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, params = { 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': WhittakerResult2D( - whittaker_system, weight_squared, rhs_extra=penalized_system_1.penalty + whittaker_system, weight_array, rhs_extra=penalized_system_1.penalty ) } diff --git a/pybaselines/whittaker.py b/pybaselines/whittaker.py index 16ce20c9..621484fa 100644 --- a/pybaselines/whittaker.py +++ b/pybaselines/whittaker.py @@ -175,8 +175,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, .. math:: - (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1 + \lambda D_d^{\mathsf{T}} D_d) v - = (W^{\mathsf{T}} W + \lambda_1 D_1^{\mathsf{T}} D_1) y + (W + \lambda_1 D_1^{\mathsf{T}} D_1 + \lambda D_d^{\mathsf{T}} D_d) v + = (W + \lambda_1 D_1^{\mathsf{T}} D_1) y where y is the input data, :math:`D_d` is the finite difference matrix of order d, :math:`D_1` is the first-order finite difference matrix, W is the diagonal matrix @@ -188,8 +188,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, .. math:: w_i = \left\{\begin{array}{cr} - p & y_i > v_i \\ - 1 - p & y_i \le v_i + p^2 & y_i > v_i \\ + (1 - p)^2 & y_i \le v_i \end{array}\right. Parameters @@ -201,8 +201,8 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, Default is 1e6. p : float, optional The penalizing weighting factor. Must be between 0 and 1. Values greater - than the baseline will be given `p` weight, and values less than the baseline - will be given `1 - p` weight. Default is 1e-2. + than the baseline will be given ``p**2`` weight, and values less than the baseline + will be given ``(1 - p)**2`` weight. Default is 1e-2. lam_1 : float, optional The smoothing parameter for the first derivative of the residual. Default is 1e-4. max_iter : int, optional @@ -225,6 +225,11 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, * 'weights': numpy.ndarray, shape (N,) The weight array used for fitting the data. + + .. versionchanged:: 1.3.0 + Prior to version 1.3.0, the returned weights were the non-squared + values (ie. ``p`` or ``1 - p``). + * 'tol_history': numpy.ndarray An array containing the calculated tolerance values for each iteration. The length of the array is the number of iterations @@ -241,10 +246,9 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, Notes ----- - Although both ``iasls`` and :meth:`~.Baseline.asls` use ``p`` for defining the weights, - the appropriate ``p`` value for ``iasls`` will be approximately equal to the square root - of the value used for ``asls`` since ``iasls`` squares the weights within its linear - equation. + Although both ``iasls`` and :meth:`~.Baseline.asls` use `p` for defining the weights, + the appropriate `p` value for ``iasls`` will be approximately equal to the square root + of the value used for ``asls`` when `p` is small since ``iasls`` uses squared weights. Omits the outer loop described by the reference implementation of the IAsLs algorithm, in which the baseline fitting is repeated after subtracting the baseline from the data. @@ -340,12 +344,11 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, d1_y = lambda_1 * d1_y tol_history = np.empty(max_iter + 1) for i in range(max_iter + 1): - weight_squared = weight_array**2 baseline = whittaker_system.solve( - whittaker_system.add_diagonal(weight_squared), weight_squared * y + d1_y, + whittaker_system.add_diagonal(weight_array), weight_array * y + d1_y, overwrite_b=True ) - new_weights = _weighting._asls(y, baseline, p) + new_weights = _weighting._asls(y, baseline, p)**2 calc_difference = relative_difference(weight_array, new_weights) tol_history[i] = calc_difference if calc_difference < tol: @@ -355,7 +358,7 @@ def iasls(self, data, lam=1e6, p=1e-2, lam_1=1e-4, max_iter=50, tol=1e-3, params = { 'weights': weight_array, 'tol_history': tol_history[:i + 1], 'result': WhittakerResult( - whittaker_system, weight_squared, rhs_extra=residual_penalty + whittaker_system, weight_array, rhs_extra=residual_penalty ) } diff --git a/tests/test_spline.py b/tests/test_spline.py index f48e615d..887c1c1e 100644 --- a/tests/test_spline.py +++ b/tests/test_spline.py @@ -258,12 +258,12 @@ def test_whittaker_comparison(self, lam, lam_1, p, diff_order): @pytest.mark.parametrize('p', (0.01, 0.2)) def test_output_binary_weights(self, p): - """Ensures all weights are either ``p`` or ``1 - p``.""" + """Ensures all weights are either ``p**2`` or ``(1 - p)**2``.""" _, params = self.class_func(self.y, p=p) weights = params['weights'] assert ( - np.isclose(weights, p, atol=1e-15, rtol=0) - | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + np.isclose(weights, p**2, atol=1e-15, rtol=0) + | np.isclose(weights, (1 - p)**2, atol=1e-15, rtol=0) ).all() diff --git a/tests/test_whittaker.py b/tests/test_whittaker.py index 07b90b00..edbcb810 100644 --- a/tests/test_whittaker.py +++ b/tests/test_whittaker.py @@ -210,12 +210,12 @@ def test_sparse_comparison(self, diff_order, lam_1, p): @pytest.mark.parametrize('p', (0.01, 0.2)) def test_output_binary_weights(self, p): - """Ensures all weights are either ``p`` or ``1 - p``.""" + """Ensures all weights are either ``p**2`` or ``(1 - p)**2``.""" _, params = self.class_func(self.y, p=p) weights = params['weights'] assert ( - np.isclose(weights, p, atol=1e-15, rtol=0) - | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + np.isclose(weights, p**2, atol=1e-15, rtol=0) + | np.isclose(weights, (1 - p)**2, atol=1e-15, rtol=0) ).all() diff --git a/tests/two_d/test_spline.py b/tests/two_d/test_spline.py index 8de65289..9abb5a9e 100644 --- a/tests/two_d/test_spline.py +++ b/tests/two_d/test_spline.py @@ -220,12 +220,12 @@ def test_whittaker_comparison(self, lam, lam_1, p, diff_order): @pytest.mark.parametrize('p', (0.01, 0.2)) def test_output_binary_weights(self, p): - """Ensures all weights are either ``p`` or ``1 - p``.""" + """Ensures all weights are either ``p**2`` or ``(1 - p)**2``.""" _, params = self.class_func(self.y, p=p) weights = params['weights'] assert ( - np.isclose(weights, p, atol=1e-15, rtol=0) - | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + np.isclose(weights, p**2, atol=1e-15, rtol=0) + | np.isclose(weights, (1 - p)**2, atol=1e-15, rtol=0) ).all() diff --git a/tests/two_d/test_whittaker.py b/tests/two_d/test_whittaker.py index 6f5f488a..ff4b0a01 100644 --- a/tests/two_d/test_whittaker.py +++ b/tests/two_d/test_whittaker.py @@ -116,12 +116,12 @@ def test_diff_order_one_fails(self): @pytest.mark.parametrize('p', (0.01, 0.2)) def test_output_binary_weights(self, p): - """Ensures all weights are either ``p`` or ``1 - p``.""" + """Ensures all weights are either ``p**2`` or ``(1 - p)**2``.""" _, params = self.class_func(self.y, p=p) weights = params['weights'] assert ( - np.isclose(weights, p, atol=1e-15, rtol=0) - | np.isclose(weights, 1 - p, atol=1e-15, rtol=0) + np.isclose(weights, p**2, atol=1e-15, rtol=0) + | np.isclose(weights, (1 - p)**2, atol=1e-15, rtol=0) ).all() From c53b5333430a183cabbbe7011b7dd2dc51d8edef Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:03:39 -0400 Subject: [PATCH 34/38] DOC: Simplify optimize_pls docs in algorithms section Will expand the documentation in a separate location in the docs. --- docs/algorithms/algorithms_1d/optimizers.rst | 193 +++---------------- pybaselines/optimizers.py | 16 +- 2 files changed, 39 insertions(+), 170 deletions(-) diff --git a/docs/algorithms/algorithms_1d/optimizers.rst b/docs/algorithms/algorithms_1d/optimizers.rst index 8184282b..ea626d4c 100644 --- a/docs/algorithms/algorithms_1d/optimizers.rst +++ b/docs/algorithms/algorithms_1d/optimizers.rst @@ -325,35 +325,35 @@ but extends its usage to all Whittaker-smoothing and penalized spline algorithms The method works by considering the general equation of penalized least squares given by .. math:: - F + \lambda R + F + \lambda P -where :math:`F` is the fidelity of the fit and :math:`R` is the roughness term penalized by -:math:`\lambda`. In general, both Whittaker smoothing and penalized splines have a fidelity -given by: +where :math:`F` is the fidelity of the fit and :math:`P` is the penalty term whose contribution +is controlled by the regularization parameter :math:`\lambda`. In general, both Whittaker +smoothing and penalized splines have a fidelity given by: .. math:: F = \sum\limits_{i}^N w_i (y_i - v_i)^2 where :math:`y_i` is the measured data, :math:`v_i` is the calculated baseline, -and :math:`w_i` is the weight. The roughness for Whittaker smoothing is generally: +and :math:`w_i` is the weight. The penalty for Whittaker smoothing is generally: .. math:: - R = \sum\limits_{i}^{N - d} (\Delta^d v_i)^2 + P = \sum\limits_{i}^{N - d} (\Delta^d v_i)^2 -where :math:`\Delta^d` is the finite-difference operator of order d, and for penalized -splines the roughness is generally: +where :math:`\Delta^d` is the finite-difference operator of order d. For penalized +splines the penalty is generally: .. math:: - R = \sum\limits_{i}^{M - d} (\Delta^d c_i)^2 + P = \sum\limits_{i}^{M - d} (\Delta^d c_i)^2 where :math:`c` are the calculated spline coefficients. -In either case, a range of ``lam`` values are tested, with the fidelity and roughness -values calculated for each fit. Then the array of fidelity and roughness values are +In either case, a range of ``lam`` values are tested, with the fidelity and penalty +values calculated for each fit. Then the array of fidelity and penalty values are normalized by their minimum and maximum values: .. math:: @@ -362,24 +362,18 @@ normalized by their minimum and maximum values: .. math:: - R_{norm} = \frac{R - \min(R)}{\max(R) - \min(R)} + P_{norm} = \frac{P - \min(P)}{\max(P) - \min(P)} -The ``lam`` value that produces a minimum of the sum of normalized roughness and fidelity +The ``lam`` value that produces a minimum of the sum of normalized penalty and fidelity values is then selected as the optimal value. An example demonstrating this process is shown below. .. note:: Fun fact: this method was the reason that all baseline correction methods in pybaselines - output a parameter dictionary! Since the conception of pybaselines, the author has tried to + output a parameter dictionary. Since the conception of pybaselines, the author had tried to implement this method and only realized after ~5 years that the original publication had a typo that prevented being able to replicate the publication's results. -.. note:: - Interestingly, a very similar framework using the normalized fidelity and roughness - values was proposed by `Andriyana, Y., et al. `_ - for optimizing the smoothing parameter for quantile P-splines, although they minimized - the euclidean distance (:math:`\sqrt{F_{norm}^2 + R_{norm}^2}`) as a pseudo L-curve optimization. - .. plot:: :align: center @@ -387,7 +381,8 @@ values is then selected as the optimal value. An example demonstrating this proc :include-source: False :show-source-link: True - from math import ceil + def normalize(values): + return (values - values.min()) / (values.max() - values.min()) x = np.linspace(1, 1000, 500) signal = ( @@ -406,18 +401,19 @@ values is then selected as the optimal value. An example demonstrating this proc min_value = 2 max_value = 10 step = 0.25 - lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) + lam_range = np.arange(min_value, max_value, step) - fit, params = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) - normed_sum = params['roughness'] + params['fidelity'] + fit, params = baseline_fitter.optimize_pls( + y, opt_method='u-curve', min_value=min_value, max_value=max_value, step=step + ) plt.figure() - plt.plot(lam_range, params['roughness'], '--', label='Roughness, $R_{norm}$') - plt.plot(lam_range, params['fidelity'], '--', label='Fidelity, $F_{norm}$') - plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') + plt.plot(lam_range, normalize(params['penalty']), label='Penalty, $P_{norm}$') + plt.plot(lam_range, normalize(params['fidelity']), label='Fidelity, $F_{norm}$') + plt.plot(lam_range, params['metric'], '--', label='$F_{norm} + P_{norm}$') index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) - plt.plot(lam_range[index], normed_sum[index], 'o', label='Optimal Value') - plt.xlabel('Log$_{10}$(lam)') + plt.plot(lam_range[index], params['metric'][index], 'o', label='Optimal Value') + plt.xlabel('log$_{10}$(lam)') plt.ylabel('Normalized Value') plt.legend() @@ -434,8 +430,7 @@ values is then selected as the optimal value. An example demonstrating this proc In general, this method is more sensitive to the minimum and maximum ``lam`` values used for -the fits compared to :meth:`~.Baseline.optimize_extended_range`. Further discussion on the -inherent drawbacks of this method and the "fix" are continued below the example plot. +the fits compared to :meth:`~.Baseline.optimize_extended_range`. .. plot:: :align: center @@ -446,137 +441,7 @@ inherent drawbacks of this method and the "fix" are continued below the example # to see contents of create_data function, look at the top-most algorithm's code figure, axes, handles = create_plots(data, baselines) for i, (ax, y) in enumerate(zip(axes, data)): - baseline, params = baseline_fitter.optimize_pls(y, method='arpls', min_value=2.5, max_value=5) + baseline, params = baseline_fitter.optimize_pls( + y, method='arpls', opt_method='u-curve', min_value=2.5, max_value=5 + ) ax.plot(baseline, 'g--') - - -As hinted at above, this method is inherently limited by its assumptions of the curvature of the -fidelity and roughness as ``lam`` varies. Although the authors did not state in detail their thought -process for arriving at this optimization beyond balancing fidelity and roughness (at least the -translation of their paper did not, apologies to the authors if the details were simply lost in translation). -However, this approach is very similar to another optimization technique for finding the optimal regularization -parameter for regularized least squares called L-curve optimization -(see G. Frasso's thesis "Smoothing parameter selection using the L-curve" for more details on L-curve optimization). - -.. note:: - Note that other methods such as generalized cross validation (GCV) can be used to - calculate the optimal regularization parameter, such as recently added to SciPy - as :func:`scipy.interpolate.make_smoothing_spline`. - -For L-curve optimization, the optimal parameter is selected where the curvature of the roughness vs -fidelity plot (or log(roughness) vs log(fidelity)) is maximized. In the case that the minimum and maximum -values of lam are selected correctly, the L-curve will be setup where the maximum curvature of the L-curve -occurs at close to the origin on the normalized fidelity vs roughness curve and this -method reaches the correct optimal value. - -.. plot:: - :align: center - :context: close-figs - :include-source: False - :show-source-link: True - - x = np.linspace(1, 1000, 500) - signal = ( - gaussian(x, 6, 180, 5) - + gaussian(x, 6, 550, 5) - + gaussian(x, 9, 800, 10) - + gaussian(x, 9, 100, 12) - + gaussian(x, 15, 400, 8) - + gaussian(x, 13, 700, 12) - + gaussian(x, 9, 880, 8) - ) - baseline = 5 + 15 * np.exp(-x / 800) + gaussian(x, 5, 700, 300) - noise = np.random.default_rng(1).normal(0, 0.2, x.size) - y = signal * 0.5 + baseline + noise - - min_value = 2 - max_value = 10 - step = 0.25 - lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) - - fit, params = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) - normed_sum = params['roughness'] + params['fidelity'] - - plt.figure() - plt.plot(lam_range, params['roughness'], '--', label='Roughness, $R_{norm}$') - plt.plot(lam_range, params['fidelity'], '--', label='Fidelity, $F_{norm}$') - plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') - plt.plot(lam_range[index], normed_sum[index], 'o', label='optimal value') - plt.xlabel('Log$_{10}$(lam)') - plt.ylabel('Normalized Value') - plt.legend() - - plt.figure() - # plot a line connecting points for visibility; have to set zorder since matplotlib places - # lines above scatter points by default - plt.plot(params['fidelity'], params['roughness'], 'k', zorder=0) - scatter_plot = plt.scatter(params['fidelity'], params['roughness'], c=lam_range) - optimal_index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) - plt.annotate( - 'Selected Optimal lam', - xy=(params['fidelity'][optimal_index], params['roughness'][optimal_index]), - xytext=(20, 30), textcoords='offset points', arrowprops={'arrowstyle': '->'} - ) - - plt.xlabel('Normalized Fidelity') - plt.ylabel('Normalized Roughness') - colorbar = plt.colorbar(scatter_plot, orientation='horizontal') - - colorbar.set_label('log$_{10}$(lam)') - -However, if the minimum and maximum lam values are selected incorrectly, the L-curve does not -follow this simplified case and has additional points that drive the point of maximum curvature -away from the origin. In that case, this method will fail. - -.. plot:: - :align: center - :context: close-figs - :include-source: False - :show-source-link: True - - min_value = -3 - max_value = 10 - step = 0.25 - lam_range = np.linspace(min_value, max_value, ceil((max_value - min_value) / step)) - - fit2, params2 = baseline_fitter.optimize_pls(y, min_value=min_value, max_value=max_value, step=step) - normed_sum = params2['roughness'] + params2['fidelity'] - - plt.figure() - plt.plot(lam_range, params2['roughness'], '--', label='Roughness, $R_{norm}$') - plt.plot(lam_range, params2['fidelity'], '--', label='Fidelity, $F_{norm}$') - plt.plot(lam_range, normed_sum, '-', label='$F_{norm} + R_{norm}$') - new_index = np.argmin(abs(lam_range - np.log10(params2['optimal_parameter']))) - plt.plot(lam_range[new_index], normed_sum[new_index], 'o', label='selected optimal value') - plt.xlabel('Log$_{10}$(lam)') - plt.ylabel('Normalized Value') - plt.legend() - - plt.figure() - # plot a line connecting points for visibility; have to set zorder since matplotlib places - # lines above scatter points by default - plt.plot(params2['fidelity'], params2['roughness'], 'k', zorder=0) - scatter_plot = plt.scatter(params2['fidelity'], params2['roughness'], c=lam_range) - plt.annotate( - 'Selected Optimal lam', - xy=(params2['fidelity'][new_index], params2['roughness'][new_index]), - xytext=(10, 30), textcoords='offset points', arrowprops={'arrowstyle': '->'} - ) - # now refer back to the old optimal - optimal_index = np.argmin(abs(lam_range - np.log10(params['optimal_parameter']))) - plt.annotate( - 'Previous Optimal lam', - xy=(params2['fidelity'][optimal_index], params2['roughness'][optimal_index]), - xytext=(50, 15), textcoords='offset points', arrowprops={'arrowstyle': '->'} - ) - plt.xlabel('Normalized Fidelity') - plt.ylabel('Normalized Roughness') - colorbar = plt.colorbar(scatter_plot, orientation='horizontal') - - colorbar.set_label('log$_{10}$(lam)') - - plt.figure() - plt.plot(x, y) - plt.plot(x, fit, label=f"lam range=[2, 10], $lam_{{opt}}=10^{{{np.log10(params['optimal_parameter']):.1f}}}$") - plt.plot(x, fit2, label=f"lam range=[-3, 10], $lam_{{opt}}=10^{{{np.log10(params2['optimal_parameter']):.1f}}}$") - plt.legend() diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 55991899..371fd5f0 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -709,20 +709,20 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, Default is None, which will use an empty dictionary. euclidean : bool, optional Only used if `opt_method` is 'U-curve'. If False (default), the optimization metric - is the minimum of the sum of the normalized fidelity and penalty values, which is + is the minimum of the sum of the normalized fidelity and penalty values [1]_, which is equivalent to the minimum graph distance from the origin. If True, the metric is the - euclidean distance from the origin. + euclidean distance from the origin, similar to [2]_ and [3]_. rho : float, optional Only used if `opt_method` is 'GCV'. The stabilization parameter for the modified generalized cross validation (mGCV) criteria. A value of 1 defines normal GCV, while higher values of `rho` stabilize the scores to make a single, global minima value more likely (when applied to smoothing). If None (default), the value of `rho` will - be selected following [2]_, with the value being 1.3 if ``len(data)`` is less than + be selected following [4]_, with the value being 1.3 if ``len(data)`` is less than 100, otherwise 2. n_samples : int, optional Only used if `opt_method` is 'GCV' or 'BIC'. If 0 (default), will calculate the analytical trace. Otherwise, will use stochastic trace estimation with a matrix of - (N, `n_samples`) Rademacher random variables (eg. either -1 or 1). + (N, `n_samples`) Rademacher random variables (ie. either -1 or 1). Returns ------- @@ -773,7 +773,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, baseline correction, due to the reliance of calculated weights on the input `lam`. Scalar minimization using :func:`scipy.optimize.minimize_scalar` was found to perform okay in most cases, but it would also not allow some methods like 'U-Curve' - which requires normalization for computing the objective. + which requires calculating with all `lam` values before computing the objective. The range of values to test is generated using ``numpy.arange(min_value, max_value, step)``, so `max_value` is likely not included in @@ -784,7 +784,11 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction using Asymmetrically Reweighted Penalized Least Squares. Journal of the Institute of Electronics and Information Engineers, 2016, 53(3), 124-131. - .. [2] Lukas, M., et al. Practical use of robust GCV and modified GCV for spline + .. [2] Belge, M., et al. Efficient determination of multiple regularization parameters in + a generalized L-curve framework. Inverse Problems, 2002, 18, 1161-1183. + .. [3] Andriyana, Y., et al. P-splines quantile regression estimation in varying + coefficient models. TEST, 2014, 23, 153-194. + .. [4] Lukas, M., et al. Practical use of robust GCV and modified GCV for spline smoothing. Computational Statistics, 2016, 31, 269-289. """ From ca7e424246deb82f1853d3fb14f22d2dfb0715f1 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:15:32 -0400 Subject: [PATCH 35/38] MAINT: Allow iasls, drpls, and aspls for U-curve optimization Mostly just needed to document omitted terms, and re-split output fidelity and weighted sum of squared residual param items. --- pybaselines/optimizers.py | 66 ++++++++++++++++++++++----------------- tests/test_optimizers.py | 6 ++-- 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 371fd5f0..af1376f0 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -739,12 +739,16 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, A dictionary containing the output parameters for the optimal fit. Items will depend on the selected `method`. * 'fidelity': numpy.ndarray, shape (P,) - The computed non-normalized fidelity term for each `lam` value tested. For - most algorithms within pybaselines, this corresponds to - ``sum(weights * (data - baseline)**2)``. + Only returned if `opt_method` is 'U-curve'. The computed non-normalized + fidelity term for each `lam` value tested. For + most algorithms within pybaselines, this is equivalent to the weighted residual + sum of squares (eg. ``sum(weights * (data - baseline)**2)``) * 'penalty': numpy.ndarray, shape (P,) Only returned if `opt_method` is 'U-curve'. The computed non-normalized penalty values for each `lam` value tested. + * 'wrss': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'GCV' or 'BIC'. The weighted residual sum of + squares (eg. ``sum(weights * (data - baseline)**2)``) for each `lam` value tested. * 'trace': numpy.ndarray, shape (P,) Only returned if `opt_method` is 'GCV' or 'BIC. The computed trace of the smoother matrix for each `lam` value tested, which signifies the effective dimension @@ -755,7 +759,7 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, ValueError Raised if `opt_method` is 'GCV' and the input `rho` is less than 1. NotImplementedError - _description_ + Raised if `method` is 'beads' and `opt_method` is 'GCV' or 'BIC'. See Also -------- @@ -763,11 +767,18 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, Notes ----- - This method requires that the sum of the normalized penalty and fidelity values is - roughly 'U' shaped (see Figure 5 in [1]_), which depends on appropriate selection of + `opt_method` 'U-Curve' requires that the sum of the normalized penalty and fidelity values + is roughly 'U' shaped (see Figure 5 in [1]_), which depends on appropriate selection of `min_value` and `max_value` such that penalty continually decreases and fidelity continually increases as `lam` increases. + For `opt_method` 'U-Curve', the multipliers on `lam` used in methods `drpls` or `aspls`, + ``(1 - eta * weights)`` and ``alpha``, respectively, are omitted from the penalty term. + Otherwise, the penalty term shows little change with varying `lam` and gives bad results. + Likewise, for method='iasls', the penalty term from `lam_1` is omitted since its gradient + with respect to `lam` is assumed to be 0. More advanced optimization varying both `lam` + and `lam_1` is possible, but not supported within this method. + Uses a grid search for optimization since the objective functions for all supported `opt_method` inputs are highly non-smooth (ie. many local minima) when performing baseline correction, due to the reliance of calculated weights on the input `lam`. @@ -932,12 +943,6 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ _type_ _description_ - Raises - ------ - NotImplementedError - _description_ - - References ---------- .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction using @@ -952,13 +957,8 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ else: spline_fit = False - using_aspls = 'aspls' in method using_drpls = 'drpls' in method - using_iasls = 'iasls' in method using_beads = method == 'beads' - if any((using_aspls, using_drpls, using_iasls)): - raise NotImplementedError(f'{method} method is not currently supported') - if using_beads: param_key = 'alpha' else: @@ -998,6 +998,15 @@ def _optimize_ucurve(y, opt_method, method, method_kws, baseline_func, baseline_ else: fit_fidelity = residual @ residual + if using_drpls: + if spline_fit: # still need to sort the baseline + additional_fidelity = np.diff( + _sort_array(fit_baseline, baseline_obj._sort_order), 1 + ) + else: + additional_fidelity = np.diff(penalized_object, 1) + fit_fidelity += additional_fidelity @ additional_fidelity + penalty[i] = fit_penalty fidelity[i] = fit_fidelity @@ -1071,37 +1080,35 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, 'optimize_pls does not support the beads method for GCV or BIC opt_method inputs' ) - using_iasls = 'iasls' in method n_lams = len(lam_range) min_metric = np.inf metrics = np.empty(n_lams) traces = np.empty(n_lams) - fidelity = np.empty(n_lams) + wrss = np.empty(n_lams) for i, lam in enumerate(lam_range): fit_baseline, fit_params = baseline_func(y, lam=lam, **method_kws) trace = fit_params['result'].effective_dimension(n_samples) - # TODO should just combine the rss calc with optimize_lcurve; should all terms - # that do not depend on lam be added to rss/fidelity?? Or just ignore? Affected - # methods are drpls and iasls # TODO should fidelity calc be directly added to the result objects so that this # can be handled directly? - fit_fidelity = fit_params['weights'] @ (y - fit_baseline)**2 + fit_wrss = fit_params['weights'] @ (y - fit_baseline)**2 if use_gcv: # GCV = (1/N) * RSS / (1 - rho * trace / N)**2 == RSS * N / (N - rho * trace)**2 # Note that some papers use different terms for fidelity (eg. RSS / N vs just RSS), # within the actual minimized equation, but both Woltring # (https://doi.org/10.1016/0141-1195(86)90098-7) and Eilers - # (https://doi.org/10.1021/ac034173t) uses the same GCV score + # (https://doi.org/10.1021/ac034173t) use the same GCV score # formulation for penalized splines and Whittaker smoothing, respectively (using a # fidelity term of just RSS), so this should be correct - metric = fit_fidelity * baseline_obj._size / (baseline_obj._size - rho * trace)**2 + metric = fit_wrss * baseline_obj._size / (baseline_obj._size - rho * trace)**2 else: # BIC = -2 * l + ln(N) * ED, where l == log likelihood and # ED == effective dimension ~ trace + # log likelhood of Whittaker/P-Spline smoothing can be approximated from the result + # of fitting with lam=0, ie. RSS / N (see Eilers's original 1996 P-Spline paper) # For Gaussian errors: BIC ~ N * ln(RSS / N) + ln(N) * trace metric = ( - baseline_obj._size * np.log(fit_fidelity / baseline_obj._size) + baseline_obj._size * np.log(fit_wrss / baseline_obj._size) + np.log(baseline_obj._size) * trace ) @@ -1113,11 +1120,11 @@ def _optimize_ed(y, opt_method, method, method_kws, baseline_func, baseline_obj, metrics[i] = metric traces[i] = trace - fidelity[i] = fit_fidelity + wrss[i] = fit_wrss params = { 'optimal_parameter': best_lam, 'metric': metrics, 'trace': traces, - 'fidelity': fidelity, 'method_params': best_params + 'wrss': wrss, 'method_params': best_params } return baseline, params @@ -1492,5 +1499,6 @@ def custom_bc(data, x_data=None, method='asls', regions=((None, None),), samplin @_optimizers_wrapper -def optimize_pls(data, x_data=None, **kwargs): +def optimize_pls(data, method='arpls', opt_method='U-Curve', min_value=4, max_value=7, step=0.5, + method_kwargs=None, euclidean=False, rho=None, n_samples=0, x_data=None): pass diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index e4c3d00c..bcae6c83 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -672,7 +672,7 @@ class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): """Class for testing optimize_pls baseline.""" func_name = "optimize_pls" - checked_keys = ('optimal_parameter', 'metric', 'fidelity') + checked_keys = ('optimal_parameter', 'metric') # will need to change checked_keys if default method is changed checked_method_keys = ('weights', 'tol_history', 'result') # by default only run a few optimization steps @@ -682,9 +682,9 @@ class TestOptimizePLS(OptimizersTester, OptimizerInputWeightsMixin): def test_output(self, opt_method): """Ensures correct output parameters for different optimization methods.""" if opt_method in ('GCV', 'BIC'): - additional_keys = ['trace'] + additional_keys = ['trace', 'wrss'] else: - additional_keys = ['penalty'] + additional_keys = ['penalty', 'fidelity'] super().test_output(additional_keys=additional_keys, opt_method=opt_method) @pytest.mark.parametrize( From 974e5438360b74d93f3bf3b92efa64f20050c769 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:39:42 -0400 Subject: [PATCH 36/38] MAINT: Add docstring for optimize_pls functional interface --- pybaselines/optimizers.py | 136 +++++++++++++++++++++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index af1376f0..c0e50d98 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -1434,6 +1434,9 @@ def custom_bc(data, x_data=None, method='asls', regions=((None, None),), samplin ---------- data : array-like, shape (N,) The y-values of the measured data, with N data points. + x_data : array-like, shape (N,), optional + The x-values of the measured data. Default is None, which will create an + array from -1 to 1 with N points. method : str, optional A string indicating the algorithm to use for fitting the baseline; can be any non-optimizer algorithm in pybaselines. Default is 'asls'. @@ -1501,4 +1504,135 @@ def custom_bc(data, x_data=None, method='asls', regions=((None, None),), samplin @_optimizers_wrapper def optimize_pls(data, method='arpls', opt_method='U-Curve', min_value=4, max_value=7, step=0.5, method_kwargs=None, euclidean=False, rho=None, n_samples=0, x_data=None): - pass + """ + Optimizes the regularization parameter for penalized least squares methods. + + Parameters + ---------- + data : array-like, shape (N,) + The y-values of the measured data, with N data points. + method : str, optional + A string indicating the Whittaker-smoothing or spline method + to use for fitting the baseline. Default is 'arpls'. + opt_method : {'U-Curve', 'GCV', 'BIC'}, optional + The optimization method used to optimize `lam`. Supported methods are: + + * 'U-Curve' + * 'GCV' + * 'BIC' + + Details on each optimization method are in the Notes section below. + min_value : int or float, optional + The minimum value for the `lam` value to use with the indicated method. Should + be the exponent to raise to the power of 10 (eg. a `min_value` value of 2 + designates a `lam` value of 10**2). Default is 4. + max_value : int or float, optional + The maximum value for the `lam` value to use with the indicated method. Should + be the exponent to raise to the power of 10 (eg. a `max_value` value of 3 + designates a `lam` value of 10**3). Default is 7. + step : int or float, optional + The step size for iterating the parameter value from `min_value` to `max_value`. + Should be the exponent to raise to the power of 10 (eg. a `step` value of 1 + designates a `lam` value of 10**1). Default is 0.5. + method_kwargs : dict, optional + A dictionary of keyword arguments to pass to the selected `method` function. + Default is None, which will use an empty dictionary. + euclidean : bool, optional + Only used if `opt_method` is 'U-curve'. If False (default), the optimization metric + is the minimum of the sum of the normalized fidelity and penalty values [1]_, which is + equivalent to the minimum graph distance from the origin. If True, the metric is the + euclidean distance from the origin, similar to [2]_ and [3]_. + rho : float, optional + Only used if `opt_method` is 'GCV'. The stabilization parameter for the modified + generalized cross validation (mGCV) criteria. A value of 1 defines normal GCV, while + higher values of `rho` stabilize the scores to make a single, global minima value + more likely (when applied to smoothing). If None (default), the value of `rho` will + be selected following [4]_, with the value being 1.3 if ``len(data)`` is less than + 100, otherwise 2. + n_samples : int, optional + Only used if `opt_method` is 'GCV' or 'BIC'. If 0 (default), will calculate the + analytical trace. Otherwise, will use stochastic trace estimation with a matrix of + (N, `n_samples`) Rademacher random variables (ie. either -1 or 1). + x_data : array-like, shape (N,), optional + The x-values of the measured data. Default is None, which will create an + array from -1 to 1 with N points. + + Returns + ------- + baseline : numpy.ndarray, shape (N,) + The baseline calculated with the optimum parameter. + params : dict + A dictionary with the following items: + + * 'optimal_parameter': float + The `lam` value that minimized the computed metric. + * 'metric': numpy.ndarray, shape (P,) + The computed metric for each `lam` value tested. + * 'method_params': dict + A dictionary containing the output parameters for the optimal fit. + Items will depend on the selected `method`. + * 'fidelity': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'U-curve'. The computed non-normalized + fidelity term for each `lam` value tested. For + most algorithms within pybaselines, this is equivalent to the weighted residual + sum of squares (eg. ``sum(weights * (data - baseline)**2)``) + * 'penalty': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'U-curve'. The computed non-normalized penalty + values for each `lam` value tested. + * 'wrss': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'GCV' or 'BIC'. The weighted residual sum of + squares (eg. ``sum(weights * (data - baseline)**2)``) for each `lam` value tested. + * 'trace': numpy.ndarray, shape (P,) + Only returned if `opt_method` is 'GCV' or 'BIC. The computed trace of the smoother + matrix for each `lam` value tested, which signifies the effective dimension + for the system. + + Raises + ------ + ValueError + Raised if `opt_method` is 'GCV' and the input `rho` is less than 1. + NotImplementedError + Raised if `method` is 'beads' and `opt_method` is 'GCV' or 'BIC'. + + See Also + -------- + pybaselines.optimizers.optimize_extended_range + + Notes + ----- + `opt_method` 'U-Curve' requires that the sum of the normalized penalty and fidelity values + is roughly 'U' shaped (see Figure 5 in [1]_), which depends on appropriate selection of + `min_value` and `max_value` such that penalty continually decreases and fidelity + continually increases as `lam` increases. + + For `opt_method` 'U-Curve', the multipliers on `lam` used in methods `drpls` or `aspls`, + ``(1 - eta * weights)`` and ``alpha``, respectively, are omitted from the penalty term. + Otherwise, the penalty term shows little change with varying `lam` and gives bad results. + Likewise, for method='iasls', the penalty term from `lam_1` is omitted since its gradient + with respect to `lam` is assumed to be 0. More advanced optimization varying both `lam` + and `lam_1` is possible, but not supported within this method. + + Uses a grid search for optimization since the objective functions for all supported + `opt_method` inputs are highly non-smooth (ie. many local minima) when performing + baseline correction, due to the reliance of calculated weights on the input `lam`. + Scalar minimization using :func:`scipy.optimize.minimize_scalar` was found to + perform okay in most cases, but it would also not allow some methods like 'U-Curve' + which requires calculating with all `lam` values before computing the objective. + + The range of values to test is generated using + ``numpy.arange(min_value, max_value, step)``, so `max_value` is likely not included in + the range of tested values. + + References + ---------- + .. [1] Park, A., et al. Automatic Selection of Optimal Parameter for Baseline Correction + using Asymmetrically Reweighted Penalized Least Squares. Journal of the Institute + of Electronics and Information Engineers, 2016, 53(3), 124-131. + .. [2] Belge, M., et al. Efficient determination of multiple regularization parameters in + a generalized L-curve framework. Inverse Problems, 2002, 18, 1161-1183. + .. [3] Andriyana, Y., et al. P-splines quantile regression estimation in varying + coefficient models. TEST, 2014, 23, 153-194. + .. [4] Lukas, M., et al. Practical use of robust GCV and modified GCV for spline + smoothing. Computational Statistics, 2016, 31, 269-289. + + """ From 6ff38e419df8d4cdc5c28adce3739357962e710c Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:59:12 -0400 Subject: [PATCH 37/38] TST: Fix flaky test failures --- tests/test_results.py | 2 +- tests/test_whittaker.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_results.py b/tests/test_results.py index 9f5367e1..d0c647b0 100644 --- a/tests/test_results.py +++ b/tests/test_results.py @@ -682,7 +682,7 @@ def test_whittaker_two_d_effective_dimension_lam_extremes(shape, diff_order, lar # limited by how close to infinity lam can get before it causes numerical instability, # and larger diff_orders need larger lam for it to be a polynomial, so have to reduce the # relative tolerance as diff_order increases - rtol = {1: 8e-3, 2: 2e-2, 3: 3e-2}[max_diff_order] + rtol = {1: 8e-3, 2: 2e-2, 3: 7e-2}[max_diff_order] else: lam = 1e-16 expected_ed = np.prod(shape) diff --git a/tests/test_whittaker.py b/tests/test_whittaker.py index edbcb810..61017669 100644 --- a/tests/test_whittaker.py +++ b/tests/test_whittaker.py @@ -104,7 +104,7 @@ def test_scipy_solvers(self, diff_order): self.algorithm.banded_solver = 4 # force use solve_banded solve_output = self.class_func(self.y, diff_order=diff_order)[0] - assert_allclose(solveh_output, solve_output, rtol=1e-6, atol=1e-8) + assert_allclose(solveh_output, solve_output, rtol=5e-6, atol=1e-8) finally: self.algorithm.banded_solver = original_solver @@ -478,7 +478,7 @@ def test_sparse_comparison(self, diff_order, asymmetric_coef, alternate_weightin asymmetric_coef=asymmetric_coef, alternate_weighting=alternate_weighting )[0] - rtol = {2: 1.5e-4, 3: 5e-4}[diff_order] + rtol = {2: 2e-4, 3: 5e-4}[diff_order] assert_allclose(banded_output, sparse_output, rtol=rtol, atol=1e-8) From 47d728d12fed0a78e1eb0e98337a217897864769 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:05:49 -0400 Subject: [PATCH 38/38] MAINT: Fix docstring section ordering --- docs/viewcode_inherit_methods.py | 10 +++++----- pybaselines/_spline_utils.py | 10 +++++----- pybaselines/_weighting.py | 10 +++++----- pybaselines/classification.py | 10 +++++----- pybaselines/misc.py | 32 ++++++++++++++++---------------- pybaselines/spline.py | 8 ++++---- pybaselines/two_d/spline.py | 9 ++++----- pybaselines/utils.py | 16 ++++++++-------- tests/test_weighting.py | 10 +++++----- 9 files changed, 57 insertions(+), 58 deletions(-) diff --git a/docs/viewcode_inherit_methods.py b/docs/viewcode_inherit_methods.py index 67e421c1..ad768ec2 100644 --- a/docs/viewcode_inherit_methods.py +++ b/docs/viewcode_inherit_methods.py @@ -35,6 +35,11 @@ def find_super_method(app, modname): have a value resembling `('def', 90, 140))`, and the key "Baseline" would have a value resembling `('class', 14, 80)`. + Raises + ------ + RuntimeError + Raised if an issue occurred when retrieving the tags from `modname`. + Notes ----- The "viewcode_follow_imported_members" toggle for sphinx.ext.viewcode can find the @@ -53,11 +58,6 @@ def find_super_method(app, modname): For an example of what ``ModuleAnalyzer`` tags and what viewcode expects, see the following issue from Sphinx: https://github.com/sphinx-doc/sphinx/issues/11279. - Raises - ------ - RuntimeError - Raised if an issue occurred when retrieving the tags from `modname`. - """ if modname is None: return diff --git a/pybaselines/_spline_utils.py b/pybaselines/_spline_utils.py index e0b0a167..d2473707 100644 --- a/pybaselines/_spline_utils.py +++ b/pybaselines/_spline_utils.py @@ -298,6 +298,11 @@ def _spline_knots(x, num_knots=10, spline_degree=3, penalized=True): knots : numpy.ndarray, shape (``num_knots + 2 * spline_degree``,) The array of knots for the spline, properly padded on each side. + Raises + ------ + ValueError + Raised if `num_knots` is less than 2. + Notes ----- If `penalized` is True, makes the knots uniformly spaced to create penalized @@ -307,11 +312,6 @@ def _spline_knots(x, num_knots=10, spline_degree=3, penalized=True): The knots are padded on each end with `spline_degree` extra knots to provide proper support for the outermost inner knots. - Raises - ------ - ValueError - Raised if `num_knots` is less than 2. - References ---------- Eilers, P., et al. Twenty years of P-splines. SORT: Statistics and Operations Research diff --git a/pybaselines/_weighting.py b/pybaselines/_weighting.py index db0c8fcb..17ffa496 100644 --- a/pybaselines/_weighting.py +++ b/pybaselines/_weighting.py @@ -76,16 +76,16 @@ def _airpls(y, baseline, iteration, normalize_weights=False): Designates if there is a potential error with the calculation such that no further iterations should be performed. - References - ---------- - Zhang, Z.M., et al. Baseline correction using adaptive iteratively - reweighted penalized least squares. Analyst, 2010, 135(5), 1138-1146. - Notes ----- Equation 9 in the original algorithm was misprinted according to the author (https://github.com/zmzhang/airPLS/issues/8), so the correct weighting is used here. + References + ---------- + Zhang, Z.M., et al. Baseline correction using adaptive iteratively + reweighted penalized least squares. Analyst, 2010, 135(5), 1138-1146. + """ residual = y - baseline neg_mask = residual < 0 diff --git a/pybaselines/classification.py b/pybaselines/classification.py index 80725f37..93d578d6 100644 --- a/pybaselines/classification.py +++ b/pybaselines/classification.py @@ -1704,6 +1704,11 @@ def _haar(num_points, scale=2): wavelet : numpy.ndarray The Haar wavelet. + Raises + ------ + TypeError + Raised if `scale` is not an integer. + Notes ----- This implementation is only designed to work for integer scales. @@ -1711,11 +1716,6 @@ def _haar(num_points, scale=2): Matches pywavelets's Haar implementation after applying patches from pywavelets issue #365 and pywavelets pull request #580. - Raises - ------ - TypeError - Raised if `scale` is not an integer. - References ---------- https://wikipedia.org/wiki/Haar_wavelet diff --git a/pybaselines/misc.py b/pybaselines/misc.py index 24eb15f7..799aac41 100644 --- a/pybaselines/misc.py +++ b/pybaselines/misc.py @@ -277,6 +277,11 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy .. versionadded:: 1.3.0 + Raises + ------ + ValueError + Raised if `asymmetry`, `lam_0`, `lam_1`, `lam_2`, or `alpha` is less than 0. + Notes ----- If any of `lam_0`, `lam_1`, or `lam_2` are None, uses the proceedure recommended in [1]_ @@ -297,11 +302,6 @@ def beads(self, data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asy readily observed by looking at the 'signal' key within the output parameter dictionary with varying `lam_0`, `lam_1`, `lam_2`, or `alpha` values. - Raises - ------ - ValueError - Raised if `asymmetry`, `lam_0`, `lam_1`, `lam_2`, or `alpha` is less than 0. - References ---------- .. [1] Ning, X., et al. Chromatogram baseline estimation and denoising using sparsity @@ -1114,6 +1114,12 @@ def _process_lams(y, alpha, lam_0, lam_1, lam_2): output_lams : list[float, float, float] The `lam_0`, `lam_1`, and `lam_2` values. + Raises + ------ + ValueError + Raised if `alpha` is not positive or if all three of `lam_0`, `lam_1`, and `lam_2` + are 0. + Notes ----- Follows the proceedure recommended in [1]_ to base the `lam_d` values on the inverse of @@ -1124,12 +1130,6 @@ def _process_lams(y, alpha, lam_0, lam_1, lam_2): values are calculated assuming `alpha` is 1. If only `lam_0` is not None, the corresponding `alpha` value is calculated and `lam_1` and `lam_2` are set accordingly. - Raises - ------ - ValueError - Raised if `alpha` is not positive or if all three of `lam_0`, `lam_1`, and `lam_2` - are 0. - References ---------- .. [1] Ning, X., et al. Chromatogram baseline estimation and denoising using sparsity @@ -1434,6 +1434,11 @@ def beads(data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asymmetry completed. If the last value in the array is greater than the input `tol` value, then the function did not converge. + Raises + ------ + ValueError + Raised if `asymmetry` is less than 0. + Notes ----- If any of `lam_0`, `lam_1`, or `lam_2` are None, uses the proceedure recommended in [4]_ @@ -1446,11 +1451,6 @@ def beads(data, freq_cutoff=0.005, lam_0=None, lam_1=None, lam_2=None, asymmetry `freq_cutoff` for the noise in the data before adjusting any other parameters since it has the largest effect [5]_. - Raises - ------ - ValueError - Raised if `asymmetry` is less than 0. - References ---------- .. [4] Ning, X., et al. Chromatogram baseline estimation and denoising using sparsity diff --git a/pybaselines/spline.py b/pybaselines/spline.py index 9a650b08..4960bd87 100644 --- a/pybaselines/spline.py +++ b/pybaselines/spline.py @@ -535,6 +535,10 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, ValueError Raised if `p` is not between 0 and 1 or if `diff_order` is less than 2. + See Also + -------- + Baseline.iasls + Notes ----- Although both ``pspline_iasls`` and :meth:`~.Baseline.pspline_asls` use `p` for defining @@ -542,10 +546,6 @@ def pspline_iasls(self, data, lam=1e1, p=1e-2, lam_1=1e-4, num_knots=100, to the square root of the value used for ``pspline_asls`` when `p` is small since ``pspline_iasls`` uses squared weights. - See Also - -------- - Baseline.iasls - References ---------- He, S., et al. Baseline correction for raman spectra using an improved diff --git a/pybaselines/two_d/spline.py b/pybaselines/two_d/spline.py index 30519220..38d071bd 100644 --- a/pybaselines/two_d/spline.py +++ b/pybaselines/two_d/spline.py @@ -453,6 +453,10 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, ValueError Raised if `p` is not between 0 and 1 or if `diff_order` is less than 2. + See Also + -------- + Baseline2D.iasls + Notes ----- Although both ``pspline_iasls`` and :meth:`~.Baseline2D.pspline_asls` use `p` for defining @@ -460,11 +464,6 @@ def pspline_iasls(self, data, lam=1e3, p=1e-2, lam_1=1e-4, num_knots=25, to the square root of the value used for ``pspline_asls`` when `p` is small since ``pspline_iasls`` uses squared weights. - - See Also - -------- - Baseline2D.iasls - References ---------- He, S., et al. Baseline correction for raman spectra using an improved diff --git a/pybaselines/utils.py b/pybaselines/utils.py index 1649d84f..c0c41af3 100644 --- a/pybaselines/utils.py +++ b/pybaselines/utils.py @@ -950,15 +950,15 @@ def _sort_array2d(array, sort_order=None): output : numpy.ndarray The input array after optionally sorting. - Notes - ----- - For all inputs, assumes the last 2 axes correspond to the data that needs sorted. - Raises ------ ValueError Raised if the input array is not two or three dimensional. + Notes + ----- + For all inputs, assumes the last 2 axes correspond to the data that needs sorted. + """ if sort_order is None: output = array @@ -997,15 +997,15 @@ def _sort_array(array, sort_order=None): output : numpy.ndarray The input array after optionally sorting. - Notes - ----- - For all inputs, assumes the last axis corresponds to the data that needs sorted. - Raises ------ ValueError Raised if the input array has more than two dimensions. + Notes + ----- + For all inputs, assumes the last axis corresponds to the data that needs sorted. + """ if sort_order is None: output = array diff --git a/tests/test_weighting.py b/tests/test_weighting.py index be9a8732..026e3027 100644 --- a/tests/test_weighting.py +++ b/tests/test_weighting.py @@ -255,16 +255,16 @@ def expected_airpls(y, baseline, iteration, normalize_weights): weights : numpy.ndarray, shape (N,) The calculated weights. - References - ---------- - Zhang, Z.M., et al. Baseline correction using adaptive iteratively - reweighted penalized least squares. Analyst, 2010, 135(5), 1138-1146. - Notes ----- Equation 9 in the original algorithm was misprinted according to the author (https://github.com/zmzhang/airPLS/issues/8), so the correct weighting is used here. + References + ---------- + Zhang, Z.M., et al. Baseline correction using adaptive iteratively + reweighted penalized least squares. Analyst, 2010, 135(5), 1138-1146. + """ residual = y - baseline neg_mask = residual < 0