From c39df65c10f827c534e76c3f7e1fce39eaaff602 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:58:50 -0400 Subject: [PATCH 1/6] MAINT: Add _OptimizerHelper obj for easier optimizer usage --- pybaselines/_algorithm_setup.py | 118 ++++++++++--------- pybaselines/_nd/__init__.py | 2 +- pybaselines/_nd/optimizers.py | 139 +++++++++++++++++++++++ pybaselines/two_d/_algorithm_setup.py | 134 +++++++++++----------- tests/base_tests.py | 64 ++++++----- tests/nd/test_optimizers.py | 157 ++++++++++++++++++++++++++ tests/test_algorithm_setup.py | 65 ++++------- tests/two_d/test_algorithm_setup.py | 66 +++++------ 8 files changed, 504 insertions(+), 241 deletions(-) create mode 100644 pybaselines/_nd/optimizers.py create mode 100644 tests/nd/test_optimizers.py diff --git a/pybaselines/_algorithm_setup.py b/pybaselines/_algorithm_setup.py index 5e06429..f8e9be3 100644 --- a/pybaselines/_algorithm_setup.py +++ b/pybaselines/_algorithm_setup.py @@ -20,6 +20,7 @@ import numpy as np from ._banded_utils import PenalizedSystem +from ._nd.optimizers import _OptimizerHelper from ._spline_utils import PSpline, SplineBasis from ._validation import ( _check_array, _check_half_window, _check_optional_array, _check_scalar_variable, @@ -857,75 +858,61 @@ def _setup_classification(self, y, weights=None, **kwargs): return y, weight_array - def _get_function(self, method, modules, ensure_new=False): + def _spawn_fitter(self, method, ensure_new=False): """ - Tries to retrieve the indicated function from a list of modules. + Creates an appropriate fitting object for the indicated method. Parameters ---------- method : str - The string name of the desired function. Case does not matter. - modules : Sequence - A sequence of modules in which to look for the method. + The string name of the desired method. ensure_new : bool, optional - If True, will ensure that the output `class_object` and `func` + If True, will ensure that the output `class_object` correspond to a new object rather than `self`. Returns ------- - func : Callable - The corresponding function. - func_module : str - The module that `func` belongs to. class_object : pybaselines._algorithm_setup._Algorithm The `_Algorithm` object which will be used for fitting. Raises ------ AttributeError - Raised if no matching function is found within the modules. + Raised if `method` is not an available Baseline method. """ - function_string = method.lower() - self_has = hasattr(self, function_string) - for module in modules: - if hasattr(module, function_string): - func_module = module.__name__.split('.')[-1] - # if self is a Baseline class, can just use its method - if self_has and not ensure_new: - func = getattr(self, function_string) - class_object = self - else: - if self_has: - klass = self.__class__ - else: - klass = getattr(module, '_' + func_module.capitalize()) - # have to reset x ordering so that all outputs and parameters are - # correctly sorted - if self._sort_order is not None: - x = self.x[self._inverted_order] - assume_sorted = False - else: - x = self.x - assume_sorted = True - class_object = klass( - x, check_finite=self._check_finite, assume_sorted=assume_sorted, - output_dtype=self._dtype - ) - class_object.banded_solver = self.banded_solver - func = getattr(class_object, function_string) - break - else: # in case no break - mod_names = [module.__name__ for module in modules] - raise AttributeError(( - f'unknown method "{method}" or method is not within the allowed ' - f'modules: {mod_names}' - )) - - return func, func_module, class_object - - def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=True, - ensure_new=False): + self_has = hasattr(self, method) + + # if self is a Baseline class, can just use its method + if self_has and not ensure_new: + class_object = self + else: + if self_has: + klass = self.__class__ + else: + # just directly use Baseline rather than the individual private classes + from .api import Baseline + if not hasattr(Baseline, method): + raise AttributeError(f'{method} is not a valid method') + klass = Baseline + # have to reset x ordering so that all outputs and parameters are + # correctly sorted + if self._sort_order is not None: + x = self.x[self._inverted_order] + assume_sorted = False + else: + x = self.x + assume_sorted = True + class_object = klass( + x, check_finite=self._check_finite, assume_sorted=assume_sorted, + output_dtype=self._dtype + ) + class_object.banded_solver = self.banded_solver + + return class_object + + def _setup_optimizer(self, y, method, method_param=None, method_kwargs=None, copy_kwargs=True, + ensure_new=False, needed_params=None): """ Sets the starting parameters for doing optimizer algorithms. @@ -936,8 +923,13 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T array by :meth:`~._Algorithm._handle_io`. method : str The string name of the desired function, like 'asls'. Case does not matter. - modules : Sequence[module, ...] - The modules to search for the indicated `method` function. + method_param : dict, optional + A dictionary indicating potential parameter keys to use, with the default having + a key of None. For example, a `method_param` of {'method1': 'a', None: ('b', 'c')} + would specify that parameter 'a' should be used for `method`='method1'; otherwise, + either 'b' or 'c' could be potential parameters, which would then be filtered by + looking at the signature of the indicated method. Default is None, which indicates + that the optimizer method being used does not require any parameter key. method_kwargs : dict, optional A dictionary of keyword arguments to pass to the fitting function. Default is None, which uses an empty dictionary. @@ -950,19 +942,20 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T thread safety for methods which would modify internal state not typically assumed to change when using threading, such as changing polynomial degrees. Default is False. + needed_params : Iterable, optional + An iterature of other necessary parameter keys that the method must have in its + signature. For example ['weights', 'tol'] would error if either 'weights' or 'tol' + are not valid inputs. Default is None. Returns ------- y : numpy.ndarray, shape (N,) The y-values of the measured data, converted to a numpy array. - baseline_func : Callable - The function for fitting the baseline. - func_module : str - The string name of the module that contained `fit_func`. + optimizer_obj : _OptimizerHelper + The object containing the fitting object to use and all relevant fields + for optimizer-type methods. method_kws : dict A dictionary of keyword arguments to pass to `fit_func`. - class_object : pybaselines._algorithm_setup._Algorithm - The `_Algorithm` object which will be used for fitting. Raises ------ @@ -970,7 +963,10 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T Raised if method_kwargs has the 'x_data' key. """ - baseline_func, func_module, class_object = self._get_function(method, modules, ensure_new) + optimizer_obj = _OptimizerHelper( + method, self, ensure_new=ensure_new, method_param=method_param, + needed_params=needed_params + ) if method_kwargs is None: method_kws = {} elif copy_kwargs: @@ -981,7 +977,7 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T if 'x_data' in method_kws: raise KeyError('"x_data" should not be within the method keyword arguments') - return y, baseline_func, func_module, method_kws, class_object + return y, optimizer_obj, method_kws def _setup_misc(self, y): """ diff --git a/pybaselines/_nd/__init__.py b/pybaselines/_nd/__init__.py index b9815a5..5bfa416 100644 --- a/pybaselines/_nd/__init__.py +++ b/pybaselines/_nd/__init__.py @@ -13,4 +13,4 @@ """ -from . import morphological, pls, polynomial +from . import morphological, optimizers, pls, polynomial diff --git a/pybaselines/_nd/optimizers.py b/pybaselines/_nd/optimizers.py new file mode 100644 index 0000000..7125ead --- /dev/null +++ b/pybaselines/_nd/optimizers.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +"""High level methods for making better use of baseline algorithms. + +Created on March 28, 2026 +@author: Donald Erb + +""" + +import inspect + + +class _OptimizerHelper: + """An object for optimizer-type methods to use for simplified usage. + + Attributes + ---------- + fitter : _Algorithm or _Algorithm2D + The object to use for fitting. + method : str + The method being used, in lowercase. + method_call : Callable + The actual method to use for fitting. + method_param : str or None + The parameter key that is used by the optimizer method. Is None if no + key is required for the optimizer method being used. + + """ + + def __init__(self, method, current_fitter, ensure_new=False, method_param=None, + needed_params=None): + """ + Initializes the object. + + Parameters + ---------- + method : str + The string name of the desired function, like 'asls'. Case does not matter. + current_fitter : _Algorithm or _Algorithm2D + The current object used for fitting. May or may not be used for the actual + fitting depending on the indicated `method` and `ensure_new` inputs. + ensure_new : bool, optional + If True, will ensure that the `fitter` and `method_call` attributes + correspond to a new object rather than `current_fitter`. This is to ensure + thread safety for methods which would modify internal state not typically + assumed to change when using threading, such as changing polynomial degrees. + Default is False. + method_param : dict, optional + A dictionary indicating potential parameter keys to use, with the default having + a key of None. For example, a `method_param` of {'method1': 'a', None: ('b', 'c')} + would specify that parameter 'a' should be used for a `method` of'method1'; otherwise, + either 'b' or 'c' could be potential parameters, which would then be filtered by + looking at the signature of the indicated method. Default is None, which indicates + that the optimizer method being used does not require any parameter key. + needed_params : Iterable, optional + An iterature of other necessary parameter keys that the method must have in its + signature. For example `['weights', 'tol']` would error if either 'weights' or 'tol' + are not valid inputs for the specified `method`. Default is None. + + Raises + ------ + ValueError + Raised if the indicated `method` does not contain the appropriate parameters + specified in `method_param` and `needed_params`. + TypeError + Raised if `method_param` gives more than one parameter for the given `method`, + which indicates an internal issue. + + """ + self.method = method.lower() + self.fitter = current_fitter._spawn_fitter(self.method, ensure_new=ensure_new) + self.method_call = getattr(self.fitter, self.method) + self._method_signature = None + self.method_param = None + + if method_param is not None: + param = method_param[self.method if self.method in method_param else None] + signature_params = self.method_signature.parameters + if isinstance(param, str): + if param not in signature_params: + raise ValueError(( + f'{method} is not a supported method because it is missing the ' + f'required parameter: {param}' + )) + self.method_param = param + else: # multiple valid keys + possible_params = [key for key in param if key in signature_params] + if not possible_params: + raise ValueError(( + f'{method} is not a supported method because it is missing the ' + f'required parameter: {" or ".join(param)}' + )) + elif len(possible_params) > 1: # something internally set wrong + raise TypeError(( + f'expected one parameter key for {method}, but instead ' + f'got {" and ".join(possible_params)}' + )) + self.method_param = possible_params[0] + + if needed_params is not None: + missing = [ + key for key in needed_params if key not in self.method_signature.parameters + ] + if missing: + raise ValueError(( + f'{method} is not a supported method because it is missing the ' + f'required parameters: {", ".join(missing)}' + )) + + @property + def module(self): + """ + The module the method is defined in. + + Returns + ------- + str + The method's module, not including the full path. For example, + `method` 'modpoly' would give a `module` of 'polynomial' rather + than 'pybaselines.polynomial' or 'pybaselines.two_d.polynomial'. + + """ + return inspect.getmodule(self.method_call).__name__.split('.')[-1] + + @property + def method_signature(self): + """ + The signature of the corresponding method. + + Lazy call since this is not always needed. + + Returns + ------- + inspect.Signature + The method's signature. + + """ + if self._method_signature is None: + self._method_signature = inspect.signature(self.method_call) + return self._method_signature diff --git a/pybaselines/two_d/_algorithm_setup.py b/pybaselines/two_d/_algorithm_setup.py index 4776587..168a761 100644 --- a/pybaselines/two_d/_algorithm_setup.py +++ b/pybaselines/two_d/_algorithm_setup.py @@ -13,6 +13,7 @@ import numpy as np from ..results import PSplineResult2D, WhittakerResult2D +from .._nd.optimizers import _OptimizerHelper from .._validation import ( _check_array, _check_half_window, _check_optional_array, _check_scalar_variable, _check_sized_array, _yxz_arrays @@ -911,84 +912,72 @@ def _setup_classification(self, y, weights=None): return y, weight_array - def _get_function(self, method, modules, ensure_new=False): + def _spawn_fitter(self, method, ensure_new=False): """ - Tries to retrieve the indicated function from a list of modules. + Creates an appropriate fitting object for the indicated method. Parameters ---------- method : str - The string name of the desired function. Case does not matter. - modules : Sequence - A sequence of modules in which to look for the method. + The string name of the desired method. ensure_new : bool, optional - If True, will ensure that the output `class_object` and `func` + If True, will ensure that the output `class_object` correspond to a new object rather than `self`. Returns ------- - func : Callable - The corresponding function. - func_module : str - The module that `func` belongs to. class_object : pybaselines.two_d_algorithm_setup._Algorithm2D The `_Algorithm2D` object which will be used for fitting. Raises ------ AttributeError - Raised if no matching function is found within the modules. + Raised if `method` is not an available Baseline2D method. """ - function_string = method.lower() - self_has = hasattr(self, function_string) - for module in modules: - func_module = module.__name__.split('.')[-1] - module_class = getattr(module, '_' + func_module.capitalize()) - if hasattr(module_class, function_string): - # if self is a Baseline2D class, can just use its method - if self_has and not ensure_new: - func = getattr(self, function_string) - class_object = self - else: - klass = self.__class__ if self_has else module_class - # have to reset x and z ordering so that all outputs and parameters are - # correctly sorted - if self._sort_order is None: + self_has = hasattr(self, method) + + # if self is a Baseline2D class, can just use its method + if self_has and not ensure_new: + class_object = self + else: + if self_has: + klass = self.__class__ + else: + # just directly use Baseline2D rather than the individual private classes + from .api import Baseline2D + if not hasattr(Baseline2D, method): + raise AttributeError(f'{method} is not a valid method') + klass = Baseline2D + # have to reset x and z ordering so that all outputs and parameters are + # correctly sorted + if self._sort_order is None: + x = self.x + z = self.z + assume_sorted = True + else: + assume_sorted = False + if isinstance(self._sort_order, tuple): + if self._sort_order[0] is Ellipsis: x = self.x - z = self.z - assume_sorted = True + z = self.z[self._inverted_order[1]] else: - assume_sorted = False - if isinstance(self._sort_order, tuple): - if self._sort_order[0] is Ellipsis: - x = self.x - z = self.z[self._inverted_order[1]] - else: - x = self.x[self._inverted_order[0][:, 0]] - z = self.z[self._inverted_order[1][0]] - else: - x = self.x[self._inverted_order] - z = self.z - - class_object = klass( - x, z, check_finite=self._check_finite, assume_sorted=assume_sorted, - output_dtype=self._dtype - ) - class_object.banded_solver = self.banded_solver - func = getattr(class_object, function_string) - break - else: # in case no break - mod_names = [module.__name__ for module in modules] - raise AttributeError(( - f'unknown method "{method}" or method is not within the allowed ' - f'modules: {mod_names}' - )) - - return func, func_module, class_object - - def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=True, - ensure_new=False): + x = self.x[self._inverted_order[0][:, 0]] + z = self.z[self._inverted_order[1][0]] + else: + x = self.x[self._inverted_order] + z = self.z + + class_object = klass( + x, z, check_finite=self._check_finite, assume_sorted=assume_sorted, + output_dtype=self._dtype + ) + class_object.banded_solver = self.banded_solver + + return class_object + + def _setup_optimizer(self, y, method, method_param=None, method_kwargs=None, copy_kwargs=True, + ensure_new=False, needed_params=None): """ Sets the starting parameters for doing optimizer algorithms. @@ -999,8 +988,13 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T array by :meth:`~._Algorithm2D._handle_io`. method : str The string name of the desired function, like 'asls'. Case does not matter. - modules : Sequence[module, ...] - The modules to search for the indicated `method` function. + method_param : dict, optional + A dictionary indicating potential parameter keys to use, with the default having + a key of None. For example, a `method_param` of {'method1': 'a', None: ('b', 'c')} + would specify that parameter 'a' should be used for `method`='method1'; otherwise, + either 'b' or 'c' could be potential parameters, which would then be filtered by + looking at the signature of the indicated method. Default is None, which indicates + that the optimizer method being used does not require any parameter key. method_kwargs : dict, optional A dictionary of keyword arguments to pass to the fitting function. Default is None, which uses an empty dictionary. @@ -1013,22 +1007,26 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T thread safety for methods which would modify internal state not typically assumed to change when using threading, such as changing polynomial degrees. Default is False. + needed_params : Iterable, optional + An iterature of other necessary parameter keys that the method must have in its + signature. For example ['weights', 'tol'] would error if either 'weights' or 'tol' + are not valid inputs. Default is None. Returns ------- y : numpy.ndarray The y-values of the measured data. - baseline_func : Callable - The function for fitting the baseline. - func_module : str - The string name of the module that contained `fit_func`. + optimizer_obj : _OptimizerHelper + The object containing the fitting object to use and all relevant fields + for optimizer-type methods. method_kws : dict A dictionary of keyword arguments to pass to `fit_func`. - class_object : pybaselines.two_d._algorithm_setup._Algorithm2D - The `_Algorithm2D` object which will be used for fitting. """ - baseline_func, func_module, class_object = self._get_function(method, modules, ensure_new) + optimizer_obj = _OptimizerHelper( + method, self, ensure_new=ensure_new, method_param=method_param, + needed_params=needed_params + ) if method_kwargs is None: method_kws = {} elif copy_kwargs: @@ -1036,7 +1034,7 @@ def _setup_optimizer(self, y, method, modules, method_kwargs=None, copy_kwargs=T else: method_kws = method_kwargs - return y, baseline_func, func_module, method_kws, class_object + return y, optimizer_obj, method_kws def _setup_misc(self, y): """ diff --git a/tests/base_tests.py b/tests/base_tests.py index d73d1c3..0d34f2b 100644 --- a/tests/base_tests.py +++ b/tests/base_tests.py @@ -469,18 +469,19 @@ def test_ensure_wrapped(self): if hasattr(_nd, mod_name) or pls_module: nd_module = 'pls' if pls_module else mod_name cls_name = '_PLSNDMixin' if pls_module else f'_{mod_name.capitalize()}NDMixin' - nd_mixin = getattr(getattr(_nd, nd_module), cls_name) - if mod_name == 'spline': - method_name = f'_{self.func_name.removeprefix("pspline_")}' - elif pls_module: - method_name = f'_{self.func_name}' - else: - method_name = self.func_name - if hasattr(nd_mixin, method_name): - # method should not be wrapped, only the ND method - assert not hasattr(self.class_func, '__wrapped__') - assert hasattr(getattr(nd_mixin, method_name), '__wrapped__') - return + if hasattr(getattr(_nd, nd_module), cls_name): + nd_mixin = getattr(getattr(_nd, nd_module), cls_name) + if mod_name == 'spline': + method_name = f'_{self.func_name.removeprefix("pspline_")}' + elif pls_module: + method_name = f'_{self.func_name}' + else: + method_name = self.func_name + if hasattr(nd_mixin, method_name): + # method should not be wrapped, only the ND method + assert not hasattr(self.class_func, '__wrapped__') + assert hasattr(getattr(nd_mixin, method_name), '__wrapped__') + return assert hasattr(self.class_func, '__wrapped__') @@ -857,25 +858,26 @@ def test_ensure_wrapped(self): if hasattr(_nd, mod_name) or pls_module: nd_module = 'pls' if pls_module else mod_name cls_name_nd = '_PLSNDMixin' if pls_module else f'_{mod_name.capitalize()}NDMixin' - nd_mixin = getattr(getattr(_nd, nd_module), cls_name_nd) - if mod_name == 'spline': - method_name = f'_{self.func_name.removeprefix("pspline_")}' - elif pls_module: - method_name = f'_{self.func_name}' - else: - method_name = self.func_name - if hasattr(nd_mixin, method_name): - assert hasattr(getattr(nd_mixin, method_name), '__wrapped__') - # some 2D methods are directly inherited without subclassing - cls_name_2d = f'_{mod_name.capitalize()}' - class_2d = getattr(self.module, cls_name_2d) - if ( - hasattr(class_2d, method_name) - and inspect.getmodule(getattr(class_2d, method_name)) is self.module - ): - # method should not be wrapped, only the ND method - assert not hasattr(self.class_func, '__wrapped__') - return + if hasattr(getattr(_nd, nd_module), cls_name_nd): + nd_mixin = getattr(getattr(_nd, nd_module), cls_name_nd) + if mod_name == 'spline': + method_name = f'_{self.func_name.removeprefix("pspline_")}' + elif pls_module: + method_name = f'_{self.func_name}' + else: + method_name = self.func_name + if hasattr(nd_mixin, method_name): + assert hasattr(getattr(nd_mixin, method_name), '__wrapped__') + # some 2D methods are directly inherited without subclassing + cls_name_2d = f'_{mod_name.capitalize()}' + class_2d = getattr(self.module, cls_name_2d) + if ( + hasattr(class_2d, method_name) + and inspect.getmodule(getattr(class_2d, method_name)) is self.module + ): + # method should not be wrapped, only the ND method + assert not hasattr(self.class_func, '__wrapped__') + return assert hasattr(self.class_func, '__wrapped__') diff --git a/tests/nd/test_optimizers.py b/tests/nd/test_optimizers.py new file mode 100644 index 0000000..40daba6 --- /dev/null +++ b/tests/nd/test_optimizers.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- +"""Tests for pybaselines._nd.optimizers. + +Created on March 30, 2026 +@author: Donald Erb + +""" + +import inspect + +import pytest + +from pybaselines import Baseline, Baseline2D +from pybaselines._nd import optimizers + + +@pytest.mark.parametrize( + 'method_and_outputs', ( + ('collab_pls', 'collab_pls', 'optimizers'), + ('COLLAB_pls', 'collab_pls', 'optimizers'), + ('modpoly', 'modpoly', 'polynomial'), + ('asls', 'asls', 'whittaker'), + ('AsLS', 'asls', 'whittaker') + ) +) +@pytest.mark.parametrize('ensure_new', (True, False)) +@pytest.mark.parametrize('two_d', (True, False)) +def test_optimizer_helper_method(method_and_outputs, ensure_new, two_d): + """Ensures _OptimizerHelper recognizes the correct methods.""" + method, expected_method, expected_module = method_and_outputs + + if two_d: + current_baseline = Baseline2D() + else: + current_baseline = Baseline() + optimizer_helper = optimizers._OptimizerHelper( + method, current_fitter=current_baseline, ensure_new=ensure_new + ) + + assert optimizer_helper.method == expected_method + assert optimizer_helper.module == expected_module + assert callable(optimizer_helper.method_call) + assert optimizer_helper.method_call.__name__ == expected_method + + if ensure_new: + assert optimizer_helper.fitter is not current_baseline + if two_d: + assert isinstance(optimizer_helper.fitter, Baseline2D) + else: + assert isinstance(optimizer_helper.fitter, Baseline) + else: + assert optimizer_helper.fitter is current_baseline + + +def test_optimizer_helper_params(): + """Tests basic method parameter handling for _OptimizerHelper.""" + + class Dummy: + def func(self, data, a, b, c): + return data, {} + + def func2(self, data, a, b): + return data, {} + + def func3(self, data, a): + return data, {} + + def func4(self, data, b): + return data, {} + + def _spawn_fitter(self, method, ensure_new): + if ensure_new: + return self.__class__ + else: + return self + + fitter = Dummy() + + helper = optimizers._OptimizerHelper('func', fitter) + assert helper.method == 'func' + assert callable(helper.method_call) + assert helper.method_call.__name__ == 'func' + assert helper.module == 'test_optimizers' + assert helper.method_param is None + assert isinstance(helper.method_signature, inspect.Signature) + + method_params = {'func': 'a', 'func2': 'b', None: ('a', 'b')} + expected = {'func': 'a', 'func2': 'b', 'func3': 'a', 'func4': 'b'} + for method, expected_param in expected.items(): + helper = optimizers._OptimizerHelper(method, fitter, method_param=method_params) + assert helper.method == method + assert callable(helper.method_call) + assert helper.method_call.__name__ == method + assert helper.module == 'test_optimizers' + assert helper.method_param == expected_param + assert isinstance(helper.method_signature, inspect.Signature) + + +def test_optimizer_helper_failures(): + """Tests errors raised for _OptimizerHelper.""" + + class Dummy: + def func(self, data, a, b, c): + return data, {} + + def func2(self, data, a, b): + return data, {} + + def func3(self, data, a): + return data, {} + + def func4(self, data, b): + return data, {} + + def _spawn_fitter(self, method, ensure_new): + if ensure_new: + return self.__class__ + else: + return self + + fitter = Dummy() + with pytest.raises( + TypeError, + match='expected one parameter key for func, but instead got b and a' + ): + optimizers._OptimizerHelper('func', fitter, method_param={None: ('b', 'a')}) + with pytest.raises( + ValueError, + match=( + 'func2 is not a supported method because it is missing the required ' + 'parameter: c or d' + ) + ): + optimizers._OptimizerHelper('func2', fitter, method_param={None: ('c', 'd')}) + with pytest.raises( + ValueError, + match=( + 'func2 is not a supported method because it is missing the required ' + 'parameter: c' + ) + ): + optimizers._OptimizerHelper('func2', fitter, method_param={None: 'c'}) + + with pytest.raises( + ValueError, + match=( + 'func3 is not a supported method because it is missing the required ' + 'parameters: c, d' + ) + ): + optimizers._OptimizerHelper( + 'func3', fitter, method_param=None, needed_params=['a', 'c', 'd'] + ) + + # internal issue, didn't set the default key + with pytest.raises(KeyError): + optimizers._OptimizerHelper('func3', fitter, method_param={'func': 'a'}) diff --git a/tests/test_algorithm_setup.py b/tests/test_algorithm_setup.py index e0613fd..a9b9971 100644 --- a/tests/test_algorithm_setup.py +++ b/tests/test_algorithm_setup.py @@ -10,7 +10,7 @@ from numpy.testing import assert_allclose, assert_array_equal import pytest -from pybaselines import Baseline, _algorithm_setup, optimizers, polynomial, whittaker +from pybaselines import Baseline, _algorithm_setup from pybaselines._banded_utils import PenalizedSystem from pybaselines._compat import dia_object from pybaselines._spline_utils import PSpline @@ -574,26 +574,12 @@ def test_setup_misc(small_data, algorithm): assert out is small_data -@pytest.mark.parametrize( - 'method_and_outputs', ( - ('collab_pls', 'collab_pls', 'optimizers'), - ('COLLAB_pls', 'collab_pls', 'optimizers'), - ('modpoly', 'modpoly', 'polynomial'), - ('asls', 'asls', 'whittaker') - ) -) +@pytest.mark.parametrize('method', ('collab_pls', 'modpoly', 'asls')) @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function(method_and_outputs, ensure_new): - """Ensures _get_function gets the correct method, regardless of case.""" - method, expected_func, expected_module = method_and_outputs - tested_modules = [optimizers, polynomial, whittaker] - +def test_spawn_fitter(method, ensure_new): + """Ensures _spawn_fitter gets the correct method and creates new object when appropriate.""" algorithm = Baseline(np.arange(10), assume_sorted=False) - selected_func, module, class_object = algorithm._get_function( - method, tested_modules, ensure_new=ensure_new - ) - assert selected_func.__name__ == expected_func - assert module == expected_module + class_object = algorithm._spawn_fitter(method, ensure_new=ensure_new) assert isinstance(class_object, _algorithm_setup._Algorithm) if ensure_new: assert class_object is not algorithm @@ -601,28 +587,20 @@ def test_get_function(method_and_outputs, ensure_new): assert class_object is algorithm -def test_get_function_fails_wrong_method(algorithm): - """Ensures _get_function fails when an no function with the input name is available.""" - with pytest.raises(AttributeError): - algorithm._get_function('unknown function', [optimizers]) - - -def test_get_function_fails_no_module(algorithm): - """Ensures _get_function fails when not given any modules to search.""" +def test_spawn_fitter_fails_wrong_method(algorithm): + """Ensures _spawn_fitter fails when an no method with the input name is available.""" with pytest.raises(AttributeError): - algorithm._get_function('collab_pls', []) + algorithm._spawn_fitter('unknown function') @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function_sorting(ensure_new): +def test_spawn_fitter_sorting(ensure_new): """Ensures the sort order is correct for the output class object.""" num_points = 10 x = np.arange(num_points) ordering = np.arange(num_points) algorithm = Baseline(x[::-1], assume_sorted=False) - func, func_module, class_object = algorithm._get_function( - 'asls', [whittaker], ensure_new=ensure_new - ) + class_object = algorithm._spawn_fitter('asls', ensure_new=ensure_new) assert_array_equal(class_object.x, x) assert_array_equal(class_object._sort_order, ordering[::-1]) @@ -640,28 +618,33 @@ def test_get_function_sorting(ensure_new): def test_setup_optimizer(small_data, method_kwargs, ensure_new): """Ensures output of _setup_optimizer is correct.""" algorithm = Baseline(np.arange(len(small_data))) - y, fit_func, func_module, output_kwargs, class_object = algorithm._setup_optimizer( - small_data, 'asls', [whittaker], method_kwargs, ensure_new=ensure_new + y, optimizer_obj, output_kwargs = algorithm._setup_optimizer( + small_data, 'asls', method_kwargs=method_kwargs, ensure_new=ensure_new ) assert isinstance(y, np.ndarray) assert_allclose(y, small_data) - assert fit_func.__name__ == 'asls' - assert func_module == 'whittaker' + assert callable(optimizer_obj.method_call) + assert optimizer_obj.method_call.__name__ == 'asls' + assert optimizer_obj.module == 'whittaker' assert isinstance(output_kwargs, dict) - assert isinstance(class_object, _algorithm_setup._Algorithm) + if method_kwargs is not None: + assert output_kwargs == method_kwargs + else: + assert output_kwargs == {} + assert isinstance(optimizer_obj.fitter, _algorithm_setup._Algorithm) if ensure_new: - assert class_object is not algorithm + assert optimizer_obj.fitter is not algorithm else: - assert class_object is algorithm + assert optimizer_obj.fitter is algorithm @pytest.mark.parametrize('copy_kwargs', (True, False)) def test_setup_optimizer_copy_kwargs(small_data, algorithm, copy_kwargs): """Ensures the copy behavior of the input keyword argument dictionary.""" input_kwargs = {'a': 1} - y, _, _, output_kwargs, _ = algorithm._setup_optimizer( - small_data, 'asls', [whittaker], input_kwargs, copy_kwargs + _, _, output_kwargs = algorithm._setup_optimizer( + small_data, 'asls', method_kwargs=input_kwargs, copy_kwargs=copy_kwargs ) output_kwargs['a'] = 2 diff --git a/tests/two_d/test_algorithm_setup.py b/tests/two_d/test_algorithm_setup.py index a883e3f..600c78d 100644 --- a/tests/two_d/test_algorithm_setup.py +++ b/tests/two_d/test_algorithm_setup.py @@ -12,10 +12,7 @@ from scipy.sparse import kron from pybaselines._compat import identity -from pybaselines.two_d import ( - Baseline2D, _algorithm_setup, optimizers, polynomial, whittaker, - _spline_utils, _whittaker_utils -) +from pybaselines.two_d import Baseline2D, _algorithm_setup, _spline_utils, _whittaker_utils from pybaselines.results import PSplineResult2D, WhittakerResult2D from pybaselines.utils import ParameterWarning, SortingWarning, difference_matrix, estimate_window from pybaselines._validation import _check_scalar @@ -1147,28 +1144,14 @@ def test_override_x(algorithm): new_algorithm = algorithm._override_x(new_x) -@pytest.mark.parametrize( - 'method_and_outputs', ( - ('collab_pls', 'collab_pls', 'optimizers'), - ('COLLAB_pls', 'collab_pls', 'optimizers'), - ('modpoly', 'modpoly', 'polynomial'), - ('asls', 'asls', 'whittaker') - ) -) +@pytest.mark.parametrize('method', ('collab_pls', 'modpoly', 'asls')) @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function(method_and_outputs, ensure_new): - """Ensures _get_function gets the correct method, regardless of case.""" - method, expected_func, expected_module = method_and_outputs - tested_modules = [optimizers, polynomial, whittaker] - +def test_spawn_fitter(method, ensure_new): + """Ensures _spawn_fitter gets the correct method and creates new object when appropriate.""" algorithm = Baseline2D( x_data=np.arange(10), z_data=np.arange(20), assume_sorted=True, check_finite=False ) - selected_func, module, class_object = algorithm._get_function( - method, tested_modules, ensure_new - ) - assert selected_func.__name__ == expected_func - assert module == expected_module + class_object = algorithm._spawn_fitter(method, ensure_new=ensure_new) assert isinstance(class_object, _algorithm_setup._Algorithm2D) if ensure_new: assert class_object is not algorithm @@ -1176,10 +1159,10 @@ def test_get_function(method_and_outputs, ensure_new): assert class_object is algorithm -def test_get_function_fails_wrong_method(algorithm): +def test_spawn_fitter_fails_wrong_method(algorithm): """Ensures _get_function fails when an no function with the input name is available.""" with pytest.raises(AttributeError): - algorithm._get_function('unknown function', [optimizers]) + algorithm._spawn_fitter('unknown function') def test_get_function_fails_no_module(algorithm): @@ -1189,13 +1172,13 @@ def test_get_function_fails_no_module(algorithm): @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function_sorting_x(ensure_new): +def test_spawn_fitter_sorting_x(ensure_new): """Ensures the sort order is correct for the output class object when x is reversed.""" num_points = 10 x = np.arange(num_points) ordering = np.arange(num_points) algorithm = Baseline2D(x[::-1], assume_sorted=False) - func, func_module, class_object = algorithm._get_function('asls', [whittaker], ensure_new) + class_object = algorithm._spawn_fitter('asls', ensure_new=ensure_new) assert_array_equal(class_object.x, x) assert_array_equal(class_object._sort_order, ordering[::-1]) @@ -1209,13 +1192,13 @@ def test_get_function_sorting_x(ensure_new): @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function_sorting_z(ensure_new): +def test_spawn_fitter_sorting_z(ensure_new): """Ensures the sort order is correct for the output class object when z is reversed.""" num_points = 10 z = np.arange(num_points) ordering = np.arange(num_points) algorithm = Baseline2D(None, z[::-1], assume_sorted=False) - func, func_module, class_object = algorithm._get_function('asls', [whittaker], ensure_new) + class_object = algorithm._spawn_fitter('asls', ensure_new=ensure_new) assert_array_equal(class_object.z, z) assert class_object._sort_order[0] is Ellipsis @@ -1233,7 +1216,7 @@ def test_get_function_sorting_z(ensure_new): @pytest.mark.parametrize('ensure_new', (True, False)) -def test_get_function_sorting_xz(ensure_new): +def test_spawn_fitter_sorting_xz(ensure_new): """Ensures the sort order is correct for the output class object when x and z are reversed.""" num_x_points = 10 num_z_points = 11 @@ -1243,7 +1226,7 @@ def test_get_function_sorting_xz(ensure_new): z_ordering = np.arange(num_z_points) algorithm = Baseline2D(x[::-1], z[::-1], assume_sorted=False) - func, func_module, class_object = algorithm._get_function('asls', [whittaker], ensure_new) + class_object = algorithm._spawn_fitter('asls', ensure_new=ensure_new) assert_array_equal(class_object.x, x) assert_array_equal(class_object.z, z) @@ -1269,28 +1252,33 @@ def test_setup_optimizer(small_data2d, method_kwargs, ensure_new): algorithm = Baseline2D( x_data=np.arange(num_x), z_data=np.arange(num_z), assume_sorted=True, check_finite=False ) - y, fit_func, func_module, output_kwargs, class_object = algorithm._setup_optimizer( - small_data2d, 'asls', [whittaker], method_kwargs, ensure_new=ensure_new + y, optimizer_obj, output_kwargs = algorithm._setup_optimizer( + small_data2d, 'asls', method_kwargs=method_kwargs, ensure_new=ensure_new ) assert isinstance(y, np.ndarray) assert_allclose(y, small_data2d) - assert fit_func.__name__ == 'asls' - assert func_module == 'whittaker' + assert callable(optimizer_obj.method_call) + assert optimizer_obj.method_call.__name__ == 'asls' + assert optimizer_obj.module == 'whittaker' assert isinstance(output_kwargs, dict) - assert isinstance(class_object, _algorithm_setup._Algorithm2D) + if method_kwargs is not None: + assert output_kwargs == method_kwargs + else: + assert output_kwargs == {} + assert isinstance(optimizer_obj.fitter, _algorithm_setup._Algorithm2D) if ensure_new: - assert class_object is not algorithm + assert optimizer_obj.fitter is not algorithm else: - assert class_object is algorithm + assert optimizer_obj.fitter is algorithm @pytest.mark.parametrize('copy_kwargs', (True, False)) def test_setup_optimizer_copy_kwargs(small_data2d, algorithm, copy_kwargs): """Ensures the copy behavior of the input keyword argument dictionary.""" input_kwargs = {'a': 1} - y, _, _, output_kwargs, _ = algorithm._setup_optimizer( - small_data2d, 'asls', [whittaker], input_kwargs, copy_kwargs + _, _, output_kwargs = algorithm._setup_optimizer( + small_data2d, 'asls', method_kwargs=input_kwargs, copy_kwargs=copy_kwargs ) output_kwargs['a'] = 2 From 270d8f2c450d9582e687b7d309a86b7414660428 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:04:55 -0400 Subject: [PATCH 2/6] MAINT: Update optimizer methods for the new setup --- pybaselines/optimizers.py | 98 +++++++++++++++++---------------- pybaselines/two_d/optimizers.py | 33 +++++------ 2 files changed, 65 insertions(+), 66 deletions(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 476be71..db21bf0 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -17,7 +17,6 @@ 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 ParameterWarning, _check_scalar, _get_edges, _sort_array, gaussian @@ -85,9 +84,9 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No in Chemistry, 2018, 2018. """ - dataset, baseline_func, _, method_kws, _ = self._setup_optimizer( - data, method, (whittaker, morphological, classification, spline), method_kwargs, - True + dataset, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={None: 'weights'}, method_kwargs=method_kwargs, + copy_kwargs=True ) data_shape = dataset.shape if len(data_shape) != 2: @@ -95,14 +94,13 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No 'the input data must have a shape of (number of measurements, number of points), ' f'but instead has a shape of {data_shape}' )) - method = method.lower() # if using aspls or pspline_aspls, also need to calculate the alpha array # for the entire dataset - calc_alpha = method in ('aspls', 'pspline_aspls') + calc_alpha = optimizer_obj.method in ('aspls', 'pspline_aspls') # step 1: calculate weights for the entire dataset if average_dataset: - _, fit_params = baseline_func(np.mean(dataset, axis=0), **method_kws) + _, fit_params = optimizer_obj.method_call(np.mean(dataset, axis=0), **method_kws) method_kws['weights'] = fit_params['weights'] if calc_alpha: method_kws['alpha'] = fit_params['alpha'] @@ -111,7 +109,9 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No if calc_alpha: alpha = np.empty(data_shape) for i, entry in enumerate(dataset): - _, fit_params = baseline_func(entry, **method_kws) + _, fit_params = optimizer_obj.method_call(entry, **method_kws) + # TODO should this also try looking at mask? Does this work + # well for classifiers outside of fabc? weights[i] = fit_params['weights'] if calc_alpha: alpha[i] = fit_params['alpha'] @@ -122,20 +122,23 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No # step 2: use the dataset weights from step 1 (stored in method_kws['weights']) # to fit each individual data entry; set tol to infinity so that only one # iteration is done and new weights are not calculated - if method not in ('mpls', 'pspline_mpls', 'fabc'): + if ( + 'tol' in optimizer_obj.method_signature.parameters + and optimizer_obj.method not in ('mpls', 'pspline_mpls') + ): method_kws['tol'] = np.inf - if method in ('brpls', 'pspline_brpls'): + if 'tol_2' in optimizer_obj.method_signature.parameters: # brpls method_kws['tol_2'] = np.inf baselines = np.empty(data_shape) params = {'average_weights': method_kws['weights'], 'method_params': defaultdict(list)} if calc_alpha: params['average_alpha'] = method_kws['alpha'] - if method == 'fabc': + if optimizer_obj.method == 'fabc': # set weights as mask so it just fits the data method_kws['weights_as_mask'] = True for i, entry in enumerate(dataset): - baselines[i], param = baseline_func(entry, **method_kws) + baselines[i], param = optimizer_obj.method_call(entry, **method_kws) for key, value in param.items(): params['method_params'][key].append(value) @@ -274,17 +277,13 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= if side not in ('left', 'right', 'both'): raise ValueError('side must be "left", "right", or "both"') - y, _, func_module, method_kws, fit_object = self._setup_optimizer( - data, method, (whittaker, polynomial, morphological, spline, classification), - method_kwargs, True + y, optimizer_obj, method_kws = self._setup_optimizer( + data, method, + method_param={None: ('lam', 'poly_order')}, + method_kwargs=method_kwargs, copy_kwargs=True ) - method = method.lower() - if func_module == 'polynomial' or method in ('dietrich', 'cwt_br'): - param_name = 'poly_order' - else: - param_name = 'lam' variables = _param_grid( - min_value, max_value, step, polynomial_fit=param_name == 'poly_order' + min_value, max_value, step, polynomial_fit=optimizer_obj.method_param == 'poly_order' ) added_window = int(self._size * width_scale) @@ -350,15 +349,15 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= np.arange(self._size + added_window, self._size + added_len, dtype=np.intp) ), dtype=np.intp) - new_fitter = fit_object._override_x(fit_x_data, new_sort_order=new_sort_order) - baseline_func = getattr(new_fitter, method) + new_fitter = optimizer_obj.fitter._override_x(fit_x_data, new_sort_order=new_sort_order) + baseline_func = getattr(new_fitter, optimizer_obj.method) upper_idx = len(fit_data) - upper_bound min_sum_squares = np.inf best_idx = 0 sum_squares_tot = np.zeros_like(variables) for i, var in enumerate(variables): - method_kws[param_name] = var + method_kws[optimizer_obj.method_param] = var fit_baseline, fit_params = baseline_func(fit_data, **method_kws) # TODO change the known baseline so that np.roll does not have to be # calculated each time, since it requires additional time @@ -375,16 +374,21 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= min_sum_squares = sum_squares sum_squares_tot = np.sqrt(sum_squares_tot / added_len) + # trim method_params items like weights to the length of the original data + for key, value in method_params.items(): + if ( + isinstance(value, np.ndarray) + and key != 'tol_history' and len(value) == len(fit_x_data) + ): + method_params[key] = value[ + 0 if side == 'right' else added_window: + None if side == 'left' else -added_window + ] + params = { 'optimal_parameter': variables[best_idx], 'min_rmse': sum_squares_tot[best_idx], 'rmse': sum_squares_tot, 'method_params': method_params } - for key in ('weights', 'alpha'): - if key in params['method_params']: - params['method_params'][key] = params['method_params'][key][ - 0 if side == 'right' else added_window: - None if side == 'left' else -added_window - ] return baseline, params @@ -462,14 +466,15 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, 1199-1205. """ - y, baseline_func, _, method_kws, _ = self._setup_optimizer( - data, method, [polynomial], method_kwargs, False, ensure_new=True + y, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={None: 'poly_order'}, method_kwargs=method_kwargs, + copy_kwargs=False, ensure_new=True, needed_params=('weights',) ) sort_weights = weights is not None weight_array = _check_optional_array(self._size, weights, check_finite=self._check_finite) if poly_order is None: poly_orders = _determine_polyorders( - y, estimation_poly_order, weight_array, baseline_func, **method_kws + y, estimation_poly_order, weight_array, optimizer_obj.method_call, **method_kws ) else: poly_orders, scalar_poly_order = _check_scalar(poly_order, 2, True, dtype=int) @@ -509,7 +514,7 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, for i, (p_order, weight) in enumerate( itertools.product(poly_orders, (weight_array, constrained_weights)) ): - baselines[i], method_params = baseline_func( + baselines[i], method_params = optimizer_obj.method_call( data=y, poly_order=p_order, weights=weight, **method_kws ) for key, value in method_params.items(): @@ -593,10 +598,8 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la Intelligent Laboratory Systems, 2011, 109(1), 51-56. """ - y, _, _, method_kws, fitting_object = self._setup_optimizer( - data, method, - (classification, misc, morphological, polynomial, smooth, spline, whittaker), - method_kwargs, True + y, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_kwargs=method_kwargs, copy_kwargs=True ) roi = np.atleast_2d(regions) roi_shape = roi.shape @@ -655,8 +658,8 @@ def custom_bc(self, data, method='asls', regions=((None, None),), sampling=1, la # param sorting will be wrong, but most params that need sorting will have # no meaning since they correspond to a truncated dataset params = {'x_fit': x_fit, 'y_fit': y_fit} - new_fitter = fitting_object._override_x(x_fit) - baseline_fit, params['method_params'] = getattr(new_fitter, method.lower())( + new_fitter = optimizer_obj.fitter._override_x(x_fit) + baseline_fit, params['method_params'] = getattr(new_fitter, optimizer_obj.method)( y_fit, **method_kws ) @@ -800,11 +803,10 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, smoothing. Computational Statistics, 2016, 31, 269-289. """ - y, baseline_func, _, method_kws, fitting_object = self._setup_optimizer( - data, method, (whittaker, morphological, spline, classification, misc), - method_kwargs, copy_kwargs=False + y, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={'beads': 'alpha', None: 'lam'}, + method_kwargs=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 @@ -814,13 +816,13 @@ def optimize_pls(self, data, method='arpls', opt_method='U-Curve', min_value=4, 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 + y, selected_method, optimizer_obj.method, method_kws, optimizer_obj.method_call, + optimizer_obj.fitter, 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, n_samples + y, selected_method, optimizer_obj.method, method_kws, optimizer_obj.method_call, + optimizer_obj.fitter, lam_range, rho, n_samples ) else: raise ValueError(f'{opt_method} is not a supported opt_method input') diff --git a/pybaselines/two_d/optimizers.py b/pybaselines/two_d/optimizers.py index f88ab31..38ad2f1 100644 --- a/pybaselines/two_d/optimizers.py +++ b/pybaselines/two_d/optimizers.py @@ -16,7 +16,6 @@ import numpy as np -from . import morphological, polynomial, spline, whittaker from .._validation import _check_optional_array, _get_row_col_values from ..api import Baseline from ..utils import _check_scalar, _sort_array2d @@ -85,9 +84,9 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No in Chemistry, 2018, 2018. """ - dataset, baseline_func, _, method_kws, _ = self._setup_optimizer( - data, method, (whittaker, morphological, spline), method_kwargs, - True + dataset, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={None: 'weights'}, method_kwargs=method_kwargs, + copy_kwargs=True ) data_shape = dataset.shape if len(data_shape) != 3: @@ -95,14 +94,13 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No 'the input data must have a shape of (number of measurements, number of x points,' f' number of y points), but instead has a shape of {data_shape}' )) - method = method.lower() # if using aspls or pspline_aspls, also need to calculate the alpha array # for the entire dataset - calc_alpha = method in ('aspls', 'pspline_aspls') + calc_alpha = optimizer_obj.method in ('aspls', 'pspline_aspls') # step 1: calculate weights for the entire dataset if average_dataset: - _, fit_params = baseline_func(np.mean(dataset, axis=0), **method_kws) + _, fit_params = optimizer_obj.method_call(np.mean(dataset, axis=0), **method_kws) method_kws['weights'] = fit_params['weights'] if calc_alpha: method_kws['alpha'] = fit_params['alpha'] @@ -111,7 +109,7 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No if calc_alpha: alpha = np.empty(data_shape) for i, entry in enumerate(dataset): - _, fit_params = baseline_func(entry, **method_kws) + _, fit_params = optimizer_obj.method_call(entry, **method_kws) weights[i] = fit_params['weights'] if calc_alpha: alpha[i] = fit_params['alpha'] @@ -122,19 +120,17 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No # step 2: use the dataset weights from step 1 (stored in method_kws['weights']) # to fit each individual data entry; set tol to infinity so that only one # iteration is done and new weights are not calculated - method_kws['tol'] = np.inf - if method in ('brpls', 'pspline_brpls'): + if 'tol' in optimizer_obj.method_signature.parameters: + method_kws['tol'] = np.inf + if 'tol_2' in optimizer_obj.method_signature.parameters: # brpls method_kws['tol_2'] = np.inf baselines = np.empty(data_shape) params = {'average_weights': method_kws['weights'], 'method_params': defaultdict(list)} if calc_alpha: params['average_alpha'] = method_kws['alpha'] - if method == 'fabc': - # set weights as mask so it just fits the data - method_kws['weights_as_mask'] = True for i, entry in enumerate(dataset): - baselines[i], param = baseline_func(entry, **method_kws) + baselines[i], param = optimizer_obj.method_call(entry, **method_kws) for key, value in param.items(): params['method_params'][key].append(value) @@ -214,8 +210,9 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, 1199-1205. """ - y, baseline_func, _, method_kws, _ = self._setup_optimizer( - data, method, [polynomial], method_kwargs, False, ensure_new=True + y, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={None: 'poly_order'}, method_kwargs=method_kwargs, + copy_kwargs=False, ensure_new=True, needed_params=('weights',) ) sort_weights = weights is not None weight_array = _check_optional_array( @@ -223,7 +220,7 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, ) if poly_order is None: poly_orders = _determine_polyorders( - y, estimation_poly_order, weight_array, baseline_func, **method_kws + y, estimation_poly_order, weight_array, optimizer_obj.method_call, **method_kws ) else: poly_orders, scalar_poly_order = _check_scalar(poly_order, 2, True, dtype=int) @@ -266,7 +263,7 @@ def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, for i, (p_order, weight) in enumerate( itertools.product(poly_orders, (weight_array, constrained_weights)) ): - baselines[i], method_params = baseline_func( + baselines[i], method_params = optimizer_obj.method_call( data=y, poly_order=p_order, weights=weight, **method_kws ) for key, value in method_params.items(): From e22d6c6991fcfc92daf008076b8de71d9df6faf4 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:05:48 -0400 Subject: [PATCH 3/6] OTH: Allow optimize_extended_range to use beads and jbcd --- pybaselines/optimizers.py | 2 +- tests/test_optimizers.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index db21bf0..586d6f8 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -279,7 +279,7 @@ def optimize_extended_range(self, data, method='asls', side='both', width_scale= y, optimizer_obj, method_kws = self._setup_optimizer( data, method, - method_param={None: ('lam', 'poly_order')}, + method_param={'beads': 'alpha', 'jbcd': 'beta', None: ('lam', 'poly_order')}, method_kwargs=method_kwargs, copy_kwargs=True ) variables = _param_grid( diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index e33bab2..35ec49f 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -173,6 +173,7 @@ def test_input_weights(self, side): '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', 'rubberband', + 'beads', 'jbcd', ) ) def test_all_methods(self, method): @@ -180,13 +181,17 @@ def test_all_methods(self, method): # 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( + output, params = self.class_func( self.y, method=method, height_scale=0.1, **kwargs, **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 + for key in ('weights', 'alpha', 'signal', 'mask', 'opening'): + if key in params['method_params']: + assert self.y.shape == params['method_params'][key].shape + + # more general check than above in case other methods add array-like keys later + for key, value in params['method_params'].items(): + if isinstance(value, np.ndarray) and key != 'tol_history': + assert self.y.shape == value.shape def test_unknown_method_fails(self): """Ensures function fails when an unknown function is given.""" From bd3005a92194766c5f59bbadb3e1056c059f4daa Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:35:58 -0400 Subject: [PATCH 4/6] MAINT: Move collab_pls to an nd pls mixin method --- pybaselines/_nd/optimizers.py | 131 ++++++++++++++++++++++++++++++++ pybaselines/optimizers.py | 68 ++--------------- pybaselines/two_d/optimizers.py | 114 +-------------------------- 3 files changed, 139 insertions(+), 174 deletions(-) diff --git a/pybaselines/_nd/optimizers.py b/pybaselines/_nd/optimizers.py index 7125ead..295dd1c 100644 --- a/pybaselines/_nd/optimizers.py +++ b/pybaselines/_nd/optimizers.py @@ -6,8 +6,13 @@ """ +from collections import defaultdict import inspect +import numpy as np + +from ._algorithm_setup import _handle_io + class _OptimizerHelper: """An object for optimizer-type methods to use for simplified usage. @@ -137,3 +142,129 @@ def method_signature(self): if self._method_signature is None: self._method_signature = inspect.signature(self.method_call) return self._method_signature + + +class _OptimizersNDMixin: + """A mixin class for providing optimizer methods for 1D and 2D.""" + + @_handle_io(ensure_dims=False, skip_sorting=True) + def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=None): + """ + Collaborative Penalized Least Squares (collab-PLS). + + Averages the data or the fit weights for an entire dataset to get more + optimal results. Uses any Whittaker-smoothing-based or weighted spline algorithm. + + Parameters + ---------- + data : array-like, shape (L, M, N) + An array with shape (L, M, N) where L is the number of entries in + the dataset and (M, N) is the shape of each data entry. + average_dataset : bool, optional + If True (default) will average the dataset before fitting to get the + weighting. If False, will fit each individual entry in the dataset and + then average the weights to get the weighting for the dataset. + method : str, optional + A string indicating the Whittaker-smoothing-based or weighted spline method to + use for fitting the baseline. Default is 'asls'. + 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. + + Returns + ------- + baselines : np.ndarray, shape (L, M, N) + An array of all of the baselines. + params : dict + A dictionary with the following items: + + * 'average_weights': numpy.ndarray, shape (M, N) + The weight array used to fit all of the baselines. + * 'average_alpha': numpy.ndarray, shape (M, N) + Only returned if `method` is 'aspls'. The + `alpha` array used to fit all of the baselines for the + :meth:`~.Baseline2D.aspls`. + * '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, + with each item corresponding to a fit. + + Raises + ------ + ValueError + Raised if the input data is not three dimensional. + + Notes + ----- + If `method` is 'aspls', `collab_pls` will also calculate + the `alpha` array for the entire dataset in the same manner as the weights. + + References + ---------- + Chen, L., et al. Collaborative Penalized Least Squares for Background + Correction of Multiple Raman Spectra. Journal of Analytical Methods + in Chemistry, 2018, 2018. + + """ + dataset, optimizer_obj, method_kws = self._setup_optimizer( + data, method, method_param={None: 'weights'}, method_kwargs=method_kwargs, + copy_kwargs=True + ) + if dataset.ndim != len(self._shape) + 1: + if len(self._shape) == 1: + expected_shape = '(number of measurements, number of points in "data")' + else: + expected_shape = '(number of measurements, rows of "data", columns of "data")' + raise ValueError(( + f'the input data must have a shape of {expected_shape}, but instead has a shape ' + f'of {dataset.shape}' + )) + # if using aspls or pspline_aspls, also need to calculate the alpha array + # for the entire dataset + calc_alpha = optimizer_obj.method in ('aspls', 'pspline_aspls') + + # step 1: calculate weights for the entire dataset + if average_dataset: + _, fit_params = optimizer_obj.method_call(np.mean(dataset, axis=0), **method_kws) + method_kws['weights'] = fit_params['weights'] + if calc_alpha: + method_kws['alpha'] = fit_params['alpha'] + else: + weights = np.empty(dataset.shape) + if calc_alpha: + alpha = np.empty(dataset.shape) + for i, entry in enumerate(dataset): + _, fit_params = optimizer_obj.method_call(entry, **method_kws) + # TODO should this also try looking at mask? Does this work + # well for classifiers outside of fabc? + weights[i] = fit_params['weights'] + if calc_alpha: + alpha[i] = fit_params['alpha'] + method_kws['weights'] = np.mean(weights, axis=0) + if calc_alpha: + method_kws['alpha'] = np.mean(alpha, axis=0) + + # step 2: use the dataset weights from step 1 (stored in method_kws['weights']) + # to fit each individual data entry; set tol to infinity so that only one + # iteration is done and new weights are not calculated + if ( + 'tol' in optimizer_obj.method_signature.parameters + and optimizer_obj.method not in ('mpls', 'pspline_mpls') + ): + method_kws['tol'] = np.inf + if 'tol_2' in optimizer_obj.method_signature.parameters: # brpls + method_kws['tol_2'] = np.inf + baselines = np.empty(dataset.shape) + params = {'average_weights': method_kws['weights'], 'method_params': defaultdict(list)} + if calc_alpha: + params['average_alpha'] = method_kws['alpha'] + if optimizer_obj.method == 'fabc': + # set weights as mask so it just fits the data + method_kws['weights_as_mask'] = True + + for i, entry in enumerate(dataset): + baselines[i], param = optimizer_obj.method_call(entry, **method_kws) + for key, value in param.items(): + params['method_params'][key].append(value) + + return baselines, params diff --git a/pybaselines/optimizers.py b/pybaselines/optimizers.py index 586d6f8..9264d59 100644 --- a/pybaselines/optimizers.py +++ b/pybaselines/optimizers.py @@ -18,14 +18,14 @@ import numpy as np from ._algorithm_setup import _Algorithm, _class_wrapper +from ._nd.optimizers import _OptimizersNDMixin from ._validation import _check_optional_array from .utils import ParameterWarning, _check_scalar, _get_edges, _sort_array, gaussian -class _Optimizers(_Algorithm): +class _Optimizers(_Algorithm, _OptimizersNDMixin): """A base class for all optimizer algorithms.""" - @_Algorithm._handle_io(ensure_dims=False, skip_sorting=True) def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=None): """ Collaborative Penalized Least Squares (collab-PLS). @@ -51,9 +51,9 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No Returns ------- - baselines : np.ndarray, shape (M, N) + np.ndarray, shape (M, N) An array of all of the baselines. - params : dict + dict A dictionary with the following items: * 'average_weights': numpy.ndarray, shape (N,) @@ -84,65 +84,9 @@ def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=No in Chemistry, 2018, 2018. """ - dataset, optimizer_obj, method_kws = self._setup_optimizer( - data, method, method_param={None: 'weights'}, method_kwargs=method_kwargs, - copy_kwargs=True + return super().collab_pls( + data, average_dataset=average_dataset, method=method, method_kwargs=method_kwargs ) - data_shape = dataset.shape - if len(data_shape) != 2: - raise ValueError(( - 'the input data must have a shape of (number of measurements, number of points), ' - f'but instead has a shape of {data_shape}' - )) - # if using aspls or pspline_aspls, also need to calculate the alpha array - # for the entire dataset - calc_alpha = optimizer_obj.method in ('aspls', 'pspline_aspls') - - # step 1: calculate weights for the entire dataset - if average_dataset: - _, fit_params = optimizer_obj.method_call(np.mean(dataset, axis=0), **method_kws) - method_kws['weights'] = fit_params['weights'] - if calc_alpha: - method_kws['alpha'] = fit_params['alpha'] - else: - weights = np.empty(data_shape) - if calc_alpha: - alpha = np.empty(data_shape) - for i, entry in enumerate(dataset): - _, fit_params = optimizer_obj.method_call(entry, **method_kws) - # TODO should this also try looking at mask? Does this work - # well for classifiers outside of fabc? - weights[i] = fit_params['weights'] - if calc_alpha: - alpha[i] = fit_params['alpha'] - method_kws['weights'] = np.mean(weights, axis=0) - if calc_alpha: - method_kws['alpha'] = np.mean(alpha, axis=0) - - # step 2: use the dataset weights from step 1 (stored in method_kws['weights']) - # to fit each individual data entry; set tol to infinity so that only one - # iteration is done and new weights are not calculated - if ( - 'tol' in optimizer_obj.method_signature.parameters - and optimizer_obj.method not in ('mpls', 'pspline_mpls') - ): - method_kws['tol'] = np.inf - if 'tol_2' in optimizer_obj.method_signature.parameters: # brpls - method_kws['tol_2'] = np.inf - baselines = np.empty(data_shape) - params = {'average_weights': method_kws['weights'], 'method_params': defaultdict(list)} - if calc_alpha: - params['average_alpha'] = method_kws['alpha'] - if optimizer_obj.method == 'fabc': - # set weights as mask so it just fits the data - method_kws['weights_as_mask'] = True - - for i, entry in enumerate(dataset): - baselines[i], param = optimizer_obj.method_call(entry, **method_kws) - for key, value in param.items(): - params['method_params'][key].append(value) - - return baselines, params @_Algorithm._handle_io(skip_sorting=True) def optimize_extended_range(self, data, method='asls', side='both', width_scale=0.1, diff --git a/pybaselines/two_d/optimizers.py b/pybaselines/two_d/optimizers.py index 38ad2f1..d773c8b 100644 --- a/pybaselines/two_d/optimizers.py +++ b/pybaselines/two_d/optimizers.py @@ -16,126 +16,16 @@ import numpy as np +from .._nd.optimizers import _OptimizersNDMixin from .._validation import _check_optional_array, _get_row_col_values from ..api import Baseline from ..utils import _check_scalar, _sort_array2d from ._algorithm_setup import _Algorithm2D -class _Optimizers(_Algorithm2D): +class _Optimizers(_Algorithm2D, _OptimizersNDMixin): """A base class for all optimizer algorithms.""" - @_Algorithm2D._handle_io(ensure_dims=False, skip_sorting=True) - def collab_pls(self, data, average_dataset=True, method='asls', method_kwargs=None): - """ - Collaborative Penalized Least Squares (collab-PLS). - - Averages the data or the fit weights for an entire dataset to get more - optimal results. Uses any Whittaker-smoothing-based or weighted spline algorithm. - - Parameters - ---------- - data : array-like, shape (L, M, N) - An array with shape (L, M, N) where L is the number of entries in - the dataset and (M, N) is the shape of each data entry. - average_dataset : bool, optional - If True (default) will average the dataset before fitting to get the - weighting. If False, will fit each individual entry in the dataset and - then average the weights to get the weighting for the dataset. - method : str, optional - A string indicating the Whittaker-smoothing-based or weighted spline method to - use for fitting the baseline. Default is 'asls'. - 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. - - Returns - ------- - baselines : np.ndarray, shape (L, M, N) - An array of all of the baselines. - params : dict - A dictionary with the following items: - - * 'average_weights': numpy.ndarray, shape (M, N) - The weight array used to fit all of the baselines. - * 'average_alpha': numpy.ndarray, shape (M, N) - Only returned if `method` is 'aspls'. The - `alpha` array used to fit all of the baselines for the - :meth:`~.Baseline2D.aspls`. - * '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, - with each item corresponding to a fit. - - Raises - ------ - ValueError - Raised if the input data is not three dimensional. - - Notes - ----- - If `method` is 'aspls', `collab_pls` will also calculate - the `alpha` array for the entire dataset in the same manner as the weights. - - References - ---------- - Chen, L., et al. Collaborative Penalized Least Squares for Background - Correction of Multiple Raman Spectra. Journal of Analytical Methods - in Chemistry, 2018, 2018. - - """ - dataset, optimizer_obj, method_kws = self._setup_optimizer( - data, method, method_param={None: 'weights'}, method_kwargs=method_kwargs, - copy_kwargs=True - ) - data_shape = dataset.shape - if len(data_shape) != 3: - raise ValueError(( - 'the input data must have a shape of (number of measurements, number of x points,' - f' number of y points), but instead has a shape of {data_shape}' - )) - # if using aspls or pspline_aspls, also need to calculate the alpha array - # for the entire dataset - calc_alpha = optimizer_obj.method in ('aspls', 'pspline_aspls') - - # step 1: calculate weights for the entire dataset - if average_dataset: - _, fit_params = optimizer_obj.method_call(np.mean(dataset, axis=0), **method_kws) - method_kws['weights'] = fit_params['weights'] - if calc_alpha: - method_kws['alpha'] = fit_params['alpha'] - else: - weights = np.empty(data_shape) - if calc_alpha: - alpha = np.empty(data_shape) - for i, entry in enumerate(dataset): - _, fit_params = optimizer_obj.method_call(entry, **method_kws) - weights[i] = fit_params['weights'] - if calc_alpha: - alpha[i] = fit_params['alpha'] - method_kws['weights'] = np.mean(weights, axis=0) - if calc_alpha: - method_kws['alpha'] = np.mean(alpha, axis=0) - - # step 2: use the dataset weights from step 1 (stored in method_kws['weights']) - # to fit each individual data entry; set tol to infinity so that only one - # iteration is done and new weights are not calculated - if 'tol' in optimizer_obj.method_signature.parameters: - method_kws['tol'] = np.inf - if 'tol_2' in optimizer_obj.method_signature.parameters: # brpls - method_kws['tol_2'] = np.inf - baselines = np.empty(data_shape) - params = {'average_weights': method_kws['weights'], 'method_params': defaultdict(list)} - if calc_alpha: - params['average_alpha'] = method_kws['alpha'] - - for i, entry in enumerate(dataset): - baselines[i], param = optimizer_obj.method_call(entry, **method_kws) - for key, value in param.items(): - params['method_params'][key].append(value) - - return baselines, params - @_Algorithm2D._handle_io(skip_sorting=True) def adaptive_minmax(self, data, poly_order=None, method='modpoly', weights=None, constrained_fraction=0.01, constrained_weight=1e5, From b4988e9c15dfeb597e28c3c9b4f7594eb830fd3b Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:43:40 -0400 Subject: [PATCH 5/6] TST: Add tests to ensure optimizers raise for disallowed methods --- tests/test_optimizers.py | 24 ++++++++++++++++++++++++ tests/two_d/test_optimizers.py | 12 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/test_optimizers.py b/tests/test_optimizers.py index 35ec49f..a7e5ac1 100644 --- a/tests/test_optimizers.py +++ b/tests/test_optimizers.py @@ -117,6 +117,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='unknown function') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'snip', 'beads')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + def test_single_dataset_fails(self): """Ensures an error is raised if the input has the shape (N,).""" with pytest.raises(ValueError, match='the input data must'): @@ -198,6 +204,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='unknown function') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'snip')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + def test_unknown_side_fails(self): """Ensures function fails when the input side is not 'left', 'right', or 'both'.""" with pytest.raises(ValueError): @@ -509,6 +521,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='unknown') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'snip', 'arpls', 'mixture_model')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + @pytest.mark.parametrize('poly_order', (None, 0, [0], (0, 1))) def test_polyorder_inputs(self, poly_order): """Tests valid inputs for poly_order.""" @@ -727,6 +745,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='aaaaa') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'snip')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + def test_unknown_opt_method_fails(self): """Ensures method fails when an unknown opt_method is given.""" with pytest.raises(ValueError): diff --git a/tests/two_d/test_optimizers.py b/tests/two_d/test_optimizers.py index d3fe60e..fabc457 100644 --- a/tests/two_d/test_optimizers.py +++ b/tests/two_d/test_optimizers.py @@ -115,6 +115,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='unknown function') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'noise_median')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + def test_single_dataset_fails(self): """Ensures an error is raised if the input has the shape (M, N).""" with pytest.raises(ValueError, match='the input data must'): @@ -189,6 +195,12 @@ def test_unknown_method_fails(self): with pytest.raises(AttributeError): self.class_func(self.y, method='unknown') + @pytest.mark.parametrize('method', ('mor', 'rolling_ball', 'noise_median')) + def test_disallowed_method_fails(self, method): + """Ensures function fails when a method that does not work is given.""" + with pytest.raises(ValueError, match=f'{method} is not a supported method'): + self.class_func(self.y, method=method) + @pytest.mark.parametrize('poly_order', (None, 0, [0], (0, 1))) def test_polyorder_inputs(self, poly_order): """Tests valid inputs for poly_order.""" From 813201014e251d73f358181c61fdc751b54cbce2 Mon Sep 17 00:00:00 2001 From: Donnie Erb <55961724+derb12@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:02:52 -0400 Subject: [PATCH 6/6] MAINT: Update init now that optimizers don't import modules --- pybaselines/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pybaselines/__init__.py b/pybaselines/__init__.py index 4143456..82ea506 100644 --- a/pybaselines/__init__.py +++ b/pybaselines/__init__.py @@ -18,7 +18,7 @@ __version__ = '1.2.1.post1.dev0' # import utils first since it is imported by other modules; likewise, import -# optimizers and api last since they import the other modules +# api last since it imports the other modules from . import ( utils, classification, misc, morphological, polynomial, spline, whittaker, smooth, optimizers, api