From f3faaebba11a9396e2b66b513d0f75dca8aed6ab Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 18 Feb 2026 15:24:35 +0100 Subject: [PATCH 1/2] Update fitters and add test that weights behave as expected --- .../fitting/minimizers/minimizer_bumps.py | 71 ++++-- .../fitting/minimizers/minimizer_dfo.py | 64 +++-- .../fitting/minimizers/minimizer_lmfit.py | 94 +++++--- .../integration_tests/fitting/test_fitter.py | 183 +++++++++++--- .../fitting/minimizers/test_minimizer_dfo.py | 226 +++++++++++------- 5 files changed, 441 insertions(+), 197 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index ac093cc6..d9582e15 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -26,7 +26,7 @@ FIT_AVAILABLE_IDS_FILTERED = copy.copy(FIT_AVAILABLE_IDS) # Considered experimental -FIT_AVAILABLE_IDS_FILTERED.remove('pt') +FIT_AVAILABLE_IDS_FILTERED.remove("pt") class Bumps(MinimizerBase): @@ -35,7 +35,7 @@ class Bumps(MinimizerBase): It allows for the Bumps fitting engine to use parameters declared in an `EasyScience.base_classes.ObjBase`. """ - package = 'bumps' + package = "bumps" def __init__( self, @@ -53,7 +53,9 @@ def __init__( keyword/value pairs :type fit_function: Callable """ - super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) + super().__init__( + obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum + ) self._p_0 = {} @staticmethod @@ -63,7 +65,7 @@ def all_methods() -> List[str]: @staticmethod def supported_methods() -> List[str]: # only a small subset - methods = ['amoeba', 'newton', 'lm'] + methods = ["amoeba", "newton", "lm"] return methods def fit( @@ -98,22 +100,27 @@ def fit( :type method: str :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. + """ method_dict = self._get_method_kwargs(method) x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') + raise ValueError("x and y must have the same shape.") if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') + raise ValueError("Weights must have the same shape as x and y.") if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') + raise ValueError("Weights cannot be NaN or infinite.") if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + raise ValueError("Weights must be strictly positive and non-zero.") if engine_kwargs is None: engine_kwargs = {} @@ -123,17 +130,23 @@ def fit( minimizer_kwargs.update(engine_kwargs) if tolerance is not None: - minimizer_kwargs['ftol'] = tolerance # tolerance for change in function value - minimizer_kwargs['xtol'] = tolerance # tolerance for change in parameter value, could be an independent value + minimizer_kwargs["ftol"] = ( + tolerance # tolerance for change in function value + ) + minimizer_kwargs["xtol"] = ( + tolerance # tolerance for change in parameter value, could be an independent value + ) if max_evaluations is not None: - minimizer_kwargs['steps'] = max_evaluations + minimizer_kwargs["steps"] = max_evaluations if model is None: model_function = self._make_model(parameters=parameters) model = model_function(x, y, weights) self._cached_model = model - self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} + self._p_0 = { + f"p{key}": self._cached_pars[key].value for key in self._cached_pars.keys() + } problem = FitProblem(model) # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime @@ -143,8 +156,12 @@ def fit( global_object.stack.enabled = False try: - model_results = bumps_fit(problem, **method_dict, **minimizer_kwargs, **kwargs) - self._set_parameter_fit_result(model_results, stack_status, problem._parameters) + model_results = bumps_fit( + problem, **method_dict, **minimizer_kwargs, **kwargs + ) + self._set_parameter_fit_result( + model_results, stack_status, problem._parameters + ) results = self._gen_fit_results(model_results) except Exception as e: for key in self._cached_pars.keys(): @@ -152,7 +169,9 @@ def fit( raise FitError(e) return results - def convert_to_pars_obj(self, par_list: Optional[List] = None) -> List[BumpsParameter]: + def convert_to_pars_obj( + self, par_list: Optional[List] = None + ) -> List[BumpsParameter]: """ Create a container with the `Parameters` converted from the base object. @@ -186,11 +205,15 @@ def convert_to_par_object(obj) -> BumpsParameter: fixed=obj.fixed, ) - def _make_model(self, parameters: Optional[List[BumpsParameter]] = None) -> Callable: + def _make_model( + self, parameters: Optional[List[BumpsParameter]] = None + ) -> Callable: """ Generate a bumps model from the supplied `fit_function` and parameters in the base object. Note that this makes a callable as it needs to be initialized with *x*, *y*, *weights* + Weights are converted to dy (standard deviation of y). + :return: Callable to make a bumps Curve model :rtype: Callable """ @@ -201,17 +224,23 @@ def _make_func(x, y, weights): bumps_pars = {} if not parameters: for name, par in obj._cached_pars.items(): - bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = obj.convert_to_par_object(par) + bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = ( + obj.convert_to_par_object(par) + ) else: for par in parameters: - bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = obj.convert_to_par_object(par) - return Curve(fit_func, x, y, dy=weights, **bumps_pars) + bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = ( + obj.convert_to_par_object(par) + ) + return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) return _make_func return _outer(self) - def _set_parameter_fit_result(self, fit_result, stack_status: bool, par_list: List[BumpsParameter]): + def _set_parameter_fit_result( + self, fit_result, stack_status: bool, par_list: List[BumpsParameter] + ): """ Update parameters to their final values and assign a std error to them. @@ -228,7 +257,7 @@ def _set_parameter_fit_result(self, fit_result, stack_status: bool, par_list: Li pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro('Fitting routine') + global_object.stack.beginMacro("Fitting routine") for index, name in enumerate([par.name for par in par_list]): dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :] diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 8f23d88d..bb566cf0 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -26,7 +26,7 @@ class DFO(MinimizerBase): This is a wrapper to Derivative Free Optimisation for Least Square: https://numericalalgorithmsgroup.github.io/dfols/ """ - package = 'dfo' + package = "dfo" def __init__( self, @@ -44,16 +44,18 @@ def __init__( keyword/value pairs :type fit_function: Callable """ - super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) + super().__init__( + obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum + ) self._p_0 = {} @staticmethod def supported_methods() -> List[str]: - return ['leastsq'] + return ["leastsq"] @staticmethod def all_methods() -> List[str]: - return ['leastsq'] + return ["leastsq"] def fit( self, @@ -74,7 +76,7 @@ def fit( :type x: np.ndarray :param y: measured points :type y: np.ndarray - :param weights: Weights for supplied measured points + :param weights: Weights for supplied measured points. :type weights: np.ndarray :param model: Optional Model which is being fitted to :type model: lmModel @@ -85,20 +87,24 @@ def fit( :type method: str :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') + raise ValueError("x and y must have the same shape.") if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') + raise ValueError("Weights must have the same shape as x and y.") if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') + raise ValueError("Weights cannot be NaN or infinite.") if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + raise ValueError("Weights must be strictly positive and non-zero.") if model is None: model_function = self._make_model(parameters=parameters) @@ -107,7 +113,9 @@ def fit( self._cached_model.x = x self._cached_model.y = y - self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} + self._p_0 = { + f"p{key}": self._cached_pars[key].value for key in self._cached_pars.keys() + } # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime from easyscience import global_object @@ -158,12 +166,14 @@ def _make_func(x, y, weights): dfo_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = par.value else: for par in parameters: - dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = par.value + dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = ( + par.value + ) def _residuals(pars_values: List[float]) -> np.ndarray: for idx, par_name in enumerate(dfo_pars.keys()): dfo_pars[par_name] = pars_values[idx] - return (y - fit_func(x, **dfo_pars)) / weights + return (y - fit_func(x, **dfo_pars)) * weights return _residuals @@ -171,7 +181,9 @@ def _residuals(pars_values: List[float]) -> np.ndarray: return _outer(self) - def _set_parameter_fit_result(self, fit_result, stack_status, ci: float = 0.95) -> None: + def _set_parameter_fit_result( + self, fit_result, stack_status, ci: float = 0.95 + ) -> None: """ Update parameters to their final values and assign a std error to them. @@ -188,9 +200,11 @@ def _set_parameter_fit_result(self, fit_result, stack_status, ci: float = 0.95) pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro('Fitting routine') + global_object.stack.beginMacro("Fitting routine") - error_matrix = self._error_from_jacobian(fit_result.jacobian, fit_result.resid, ci) + error_matrix = self._error_from_jacobian( + fit_result.jacobian, fit_result.resid, ci + ) for idx, par in enumerate(pars.values()): par.value = fit_result.x[idx] par.error = error_matrix[idx, idx] @@ -215,7 +229,7 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: pars = {} for p_name, par in self._cached_pars.items(): - pars[f'p{p_name}'] = par.value + pars[f"p{p_name}"] = par.value results.p = pars results.p0 = self._p_0 @@ -257,21 +271,25 @@ def _dfo_fit( # https://numericalalgorithmsgroup.github.io/dfols/build/html/userguide.html if not np.isinf(bounds).any(): # It is only possible to scale (normalize) variables if they are bound (different from inf) - kwargs['scaling_within_bounds'] = True + kwargs["scaling_within_bounds"] = True results = dfols.solve(model, pars_values, bounds=bounds, **kwargs) - if 'Success' not in results.msg: - raise FitError(f'Fit failed with message: {results.msg}') + if "Success" not in results.msg: + raise FitError(f"Fit failed with message: {results.msg}") return results @staticmethod - def _prepare_kwargs(tolerance: Optional[float] = None, max_evaluations: Optional[int] = None, **kwargs) -> dict[str:str]: + def _prepare_kwargs( + tolerance: Optional[float] = None, + max_evaluations: Optional[int] = None, + **kwargs, + ) -> dict[str:str]: if max_evaluations is not None: - kwargs['maxfun'] = max_evaluations # max number of function evaluations + kwargs["maxfun"] = max_evaluations # max number of function evaluations if tolerance is not None: if 0.1 < tolerance: # dfo module throws errer if larger value - raise ValueError('Tolerance must be equal or smaller than 0.1') - kwargs['rhoend'] = tolerance # size of the trust region + raise ValueError("Tolerance must be equal or smaller than 0.1") + kwargs["rhoend"] = tolerance # size of the trust region return kwargs diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 4a10993c..cb841c74 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -30,7 +30,7 @@ class LMFit(MinimizerBase): # noqa: S101 It allows for the lmfit fitting engine to use parameters declared in an `EasyScience.base_classes.ObjBase`. """ - package = 'lmfit' + package = "lmfit" def __init__( self, @@ -48,33 +48,35 @@ def __init__( :param method: Method to be used by the minimizer :type method: str """ - super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) + super().__init__( + obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum + ) @staticmethod def all_methods() -> List[str]: return [ - 'least_squares', - 'leastsq', - 'differential_evolution', - 'basinhopping', - 'ampgo', - 'nelder', - 'lbfgsb', - 'powell', - 'cg', - 'newton', - 'cobyla', - 'bfgs', + "least_squares", + "leastsq", + "differential_evolution", + "basinhopping", + "ampgo", + "nelder", + "lbfgsb", + "powell", + "cg", + "newton", + "cobyla", + "bfgs", ] @staticmethod def supported_methods() -> List[str]: return [ - 'least_squares', - 'leastsq', - 'differential_evolution', - 'powell', - 'cobyla', + "least_squares", + "leastsq", + "differential_evolution", + "powell", + "cobyla", ] def fit( @@ -111,20 +113,25 @@ def fit( :param kwargs: Additional arguments for the fitting function. :return: Fit results :rtype: ModelResult + + For standard least squares, the weights should be 1/sigma, where + sigma is the standard deviation of the measurement. For + unweighted least squares, these should be 1. + """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') + raise ValueError("x and y must have the same shape.") if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') + raise ValueError("Weights must have the same shape as x and y.") if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') + raise ValueError("Weights cannot be NaN or infinite.") if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + raise ValueError("Weights must be strictly positive and non-zero.") if engine_kwargs is None: engine_kwargs = {} @@ -160,17 +167,21 @@ def fit( raise FitError(e) return results - def _get_fit_kws(self, method: str, tolerance: float, minimizer_kwargs: dict[str:str]) -> dict[str:str]: + def _get_fit_kws( + self, method: str, tolerance: float, minimizer_kwargs: dict[str:str] + ) -> dict[str:str]: if minimizer_kwargs is None: minimizer_kwargs = {} if tolerance is not None: - if method in [None, 'least_squares', 'leastsq']: - minimizer_kwargs['ftol'] = tolerance - if method in ['differential_evolution', 'powell', 'cobyla']: - minimizer_kwargs['tol'] = tolerance + if method in [None, "least_squares", "leastsq"]: + minimizer_kwargs["ftol"] = tolerance + if method in ["differential_evolution", "powell", "cobyla"]: + minimizer_kwargs["tol"] = tolerance return minimizer_kwargs - def convert_to_pars_obj(self, parameters: Optional[List[Parameter]] = None) -> LMParameters: + def convert_to_pars_obj( + self, parameters: Optional[List[Parameter]] = None + ) -> LMParameters: """ Create an lmfit compatible container with the `Parameters` converted from the base object. @@ -180,7 +191,9 @@ def convert_to_pars_obj(self, parameters: Optional[List[Parameter]] = None) -> L if parameters is None: # Assume that we have a ObjBase for which we can obtain a list parameters = self._object.get_fit_parameters() - lm_parameters = LMParameters().add_many([self.convert_to_par_object(parameter) for parameter in parameters]) + lm_parameters = LMParameters().add_many( + [self.convert_to_par_object(parameter) for parameter in parameters] + ) return lm_parameters @staticmethod @@ -220,7 +233,7 @@ def _make_model(self, pars: Optional[LMParameters] = None) -> LMModel: # Create the model model = LMModel( fit_func, - independent_vars=['x'], + independent_vars=["x"], param_names=[MINIMIZER_PARAMETER_PREFIX + str(key) for key in pars.keys()], ) # Assign values from the `Parameter` to the model @@ -230,7 +243,12 @@ def _make_model(self, pars: Optional[LMParameters] = None) -> LMModel: else: value = item.value - model.set_param_hint(MINIMIZER_PARAMETER_PREFIX + str(name), value=value, min=item.min, max=item.max) + model.set_param_hint( + MINIMIZER_PARAMETER_PREFIX + str(name), + value=value, + min=item.min, + max=item.max, + ) # Cache the model for later reference self._cached_model = model @@ -252,11 +270,15 @@ def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool) pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro('Fitting routine') + global_object.stack.beginMacro("Fitting routine") for name in pars.keys(): - pars[name].value = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].value + pars[name].value = fit_result.params[ + MINIMIZER_PARAMETER_PREFIX + str(name) + ].value if fit_result.errorbars: - pars[name].error = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].stderr + pars[name].error = fit_result.params[ + MINIMIZER_PARAMETER_PREFIX + str(name) + ].stderr else: pars[name].error = 0.0 if stack_status: @@ -280,7 +302,7 @@ def _gen_fit_results(self, fit_results: ModelResult, **kwargs) -> FitResults: results.success = fit_results.success results.y_obs = fit_results.data # results.residual = fit_results.residual - results.x = fit_results.userkws['x'] + results.x = fit_results.userkws["x"] results.p = fit_results.values results.p0 = fit_results.init_values # results.goodness_of_fit = fit_results.chisqr diff --git a/tests/integration_tests/fitting/test_fitter.py b/tests/integration_tests/fitting/test_fitter.py index bfa3f237..d5c9b255 100644 --- a/tests/integration_tests/fitting/test_fitter.py +++ b/tests/integration_tests/fitting/test_fitter.py @@ -12,6 +12,7 @@ from easyscience.fitting.minimizers import FitError from easyscience.base_classes import ModelBase + # Model and container of parameters for tests class AbsSin(ObjBase): phase: Parameter @@ -36,20 +37,21 @@ def __init__(self, offset_val: float, phase_val: float): super().__init__("sin2D", offset=offset, phase=phase) def __call__(self, x): - X = x[:, :, 0] # x is a 2D array + X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] - return np.abs( - np.sin(self.phase.value * X + self.offset.value) - ) * np.abs(np.sin(self.phase.value * Y + self.offset.value)) + return np.abs(np.sin(self.phase.value * X + self.offset.value)) * np.abs( + np.sin(self.phase.value * Y + self.offset.value) + ) class AbsSin2DL(AbsSin2D): def __call__(self, x): - X = x[:, 0] # x is a 1D array + X = x[:, 0] # x is a 1D array Y = x[:, 1] - return np.abs( - np.sin(self.phase.value * X + self.offset.value) - ) * np.abs(np.sin(self.phase.value * Y + self.offset.value)) + return np.abs(np.sin(self.phase.value * X + self.offset.value)) * np.abs( + np.sin(self.phase.value * Y + self.offset.value) + ) + class StraightLine(ModelBase): def __init__(self, slope: float, intercept: float): @@ -60,7 +62,7 @@ def __init__(self, slope: float, intercept: float): @property def slope(self) -> Parameter: return self._slope - + @slope.setter def slope(self, value: float) -> None: self._slope.value = value @@ -100,7 +102,15 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): assert result.residual == pytest.approx(sp_sin(x) - y_calc_ref, abs=1e-2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_basic_fit(fit_engine: AvailableMinimizers): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -122,12 +132,22 @@ def test_basic_fit(fit_engine: AvailableMinimizers): result = f.fit(x=x, y=y, weights=weights) if fit_engine is not None: - assert result.minimizer_engine.package == fit_engine.name.lower() # Special case where minimizer matches package + assert ( + result.minimizer_engine.package == fit_engine.name.lower() + ) # Special case where minimizer matches package assert sp_sin.phase.value == pytest.approx(ref_sin.phase.value, rel=1e-3) assert sp_sin.offset.value == pytest.approx(ref_sin.offset.value, rel=1e-3) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_fit_result(fit_engine): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -160,7 +180,15 @@ def test_fit_result(fit_engine): check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_basic_max_evaluations(fit_engine): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -189,7 +217,15 @@ def test_basic_max_evaluations(fit_engine): assert "Objective has been called MAXFUN times" in str(e) -@pytest.mark.parametrize("fit_engine,tolerance", [(None, 10), (AvailableMinimizers.LMFit, 10), (AvailableMinimizers.Bumps, 10), (AvailableMinimizers.DFO, 0.1)]) +@pytest.mark.parametrize( + "fit_engine,tolerance", + [ + (None, 10), + (AvailableMinimizers.LMFit, 10), + (AvailableMinimizers.Bumps, 10), + (AvailableMinimizers.DFO, 0.1), + ], +) def test_basic_tolerance(fit_engine, tolerance): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -232,7 +268,7 @@ def test_lmfit_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) -#@pytest.mark.xfail(reason="known bumps issue") +# @pytest.mark.xfail(reason="known bumps issue") @pytest.mark.parametrize("fit_method", ["newton", "lm"]) def test_bumps_methods(fit_method): ref_sin = AbsSin(0.2, np.pi) @@ -252,7 +288,10 @@ def test_bumps_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) -@pytest.mark.parametrize("fit_engine", [AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO], +) def test_dependent_parameter(fit_engine): ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) sp_sin = AbsSin(1, 0.5) @@ -263,7 +302,9 @@ def test_dependent_parameter(fit_engine): f = Fitter(sp_sin, sp_sin) - sp_sin.offset.make_dependent_on(dependency_expression='2*phase', dependency_map={"phase": sp_sin.phase}) + sp_sin.offset.make_dependent_on( + dependency_expression="2*phase", dependency_map={"phase": sp_sin.phase} + ) if fit_engine is not None: try: @@ -274,13 +315,20 @@ def test_dependent_parameter(fit_engine): result = f.fit(x, y, weights=weights) check_fit_results(result, sp_sin, ref_sin, x) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_2D_vectorized(fit_engine): x = np.linspace(0, 5, 200) mm = AbsSin2D(0.3, 1.6) - m2 = AbsSin2D( - 0.1, 1.8 - ) # The fit is quite sensitive to the initial values :-( + m2 = AbsSin2D(0.1, 1.8) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) XY = np.stack((X, Y), axis=2) weights = np.ones_like(mm(XY)) @@ -306,13 +354,19 @@ def test_2D_vectorized(fit_engine): assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_2D_non_vectorized(fit_engine): x = np.linspace(0, 5, 200) mm = AbsSin2DL(0.3, 1.6) - m2 = AbsSin2DL( - 0.1, 1.8 - ) # The fit is quite sensitive to the initial values :-( + m2 = AbsSin2DL(0.1, 1.8) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) XY = np.stack((X, Y), axis=2) weights = np.ones_like(mm(XY.reshape(-1, 2))) @@ -323,7 +377,9 @@ def test_2D_non_vectorized(fit_engine): except AttributeError: pytest.skip(msg=f"{fit_engine} is not installed") try: - result = ff.fit(x=XY, y=mm(XY.reshape(-1, 2)), weights=weights, vectorized=False) + result = ff.fit( + x=XY, y=mm(XY.reshape(-1, 2)), weights=weights, vectorized=False + ) except FitError as e: if "Unable to allocate" in str(e): pytest.skip(msg="MemoryError - Matrix too large") @@ -339,7 +395,16 @@ def test_2D_non_vectorized(fit_engine): mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 ) -@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO]) + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) def test_fixed_parameter_does_not_change(fit_engine): # WHEN ref_sin = AbsSin(0.2, np.pi) @@ -365,12 +430,13 @@ def test_fixed_parameter_does_not_change(fit_engine): result = f.fit(x=x, y=y, weights=weights) - # EXPECT + # EXPECT # Offset should remain unchanged assert sp_sin.offset.value == pytest.approx(fixed_offset_before, abs=1e-12) # Phase should be optimized assert sp_sin.phase.value != pytest.approx(ref_sin.phase.value, rel=1e-3) + def test_fitter_new_model_base_integration(): # WHEN ground_truth = StraightLine(slope=2.0, intercept=1.0) @@ -388,4 +454,63 @@ def test_fitter_new_model_base_integration(): # EXPECT assert model.slope.value == pytest.approx(ground_truth.slope.value, rel=1e-3) - assert model.intercept.value == pytest.approx(ground_truth.intercept.value, rel=1e-3) \ No newline at end of file + assert model.intercept.value == pytest.approx( + ground_truth.intercept.value, rel=1e-3 + ) + + +@pytest.mark.parametrize( + "fit_engine", + [ + None, + AvailableMinimizers.LMFit, + AvailableMinimizers.Bumps, + AvailableMinimizers.DFO, + ], +) +def test_fitter_variable_weights(fit_engine): + # WHEN + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y_true = ref_sin(x) + + # Introduce bias in second half of data + y = y_true.copy() + y[100:] += 0.5 # Artificial distortion + + # Case 1: High weight on distorted region + weights_high = np.ones_like(x) + weights_high[100:] = 10.0 + + # Case 2: Low weight on distorted region + weights_low = np.ones_like(x) + weights_low[100:] = 0.1 + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + def run_fit(weights): + model = AbsSin(0.354, 3.05) + model.offset.fixed = False + model.phase.fixed = False + + f = Fitter(model, model) + if fit_engine is not None: + try: + f.switch_minimizer(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + + f.fit(x=x, y=y, weights=weights) + return model.offset.value, model.phase.value + + offset_high, phase_high = run_fit(weights_high) + offset_low, phase_low = run_fit(weights_low) + + # The fit should shift more toward the distorted region + # when it has higher weight + assert abs(offset_high - ref_sin.offset.value) > abs( + offset_low - ref_sin.offset.value + ) diff --git a/tests/unit_tests/fitting/minimizers/test_minimizer_dfo.py b/tests/unit_tests/fitting/minimizers/test_minimizer_dfo.py index 6d6b8f8b..439afea0 100644 --- a/tests/unit_tests/fitting/minimizers/test_minimizer_dfo.py +++ b/tests/unit_tests/fitting/minimizers/test_minimizer_dfo.py @@ -10,90 +10,104 @@ from easyscience.fitting.minimizers.utils import FitError -class TestDFOFit(): +class TestDFOFit: @pytest.fixture def minimizer(self) -> DFO: minimizer = DFO( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='dfo', method='leastsq') + obj="obj", + fit_function="fit_function", + minimizer_enum=MagicMock(package="dfo", method="leastsq"), ) return minimizer def test_init(self, minimizer: DFO) -> None: assert minimizer._p_0 == {} - assert minimizer.package == 'dfo' + assert minimizer.package == "dfo" def test_init_exception(self) -> None: with pytest.raises(FitError): DFO( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='dfo', method='not_leastsq') + obj="obj", + fit_function="fit_function", + minimizer_enum=MagicMock(package="dfo", method="not_leastsq"), ) def test_supported_methods(self, minimizer: DFO) -> None: # When Then Expect - assert minimizer.supported_methods() == ['leastsq'] + assert minimizer.supported_methods() == ["leastsq"] def test_supported_methods(self, minimizer: DFO) -> None: # When Then Expect - assert minimizer.supported_methods() == ['leastsq'] + assert minimizer.supported_methods() == ["leastsq"] def test_fit(self, minimizer: DFO) -> None: # When from easyscience import global_object + global_object.stack.enabled = False mock_model = MagicMock() mock_model_function = MagicMock(return_value=mock_model) minimizer._make_model = MagicMock(return_value=mock_model_function) - minimizer._dfo_fit = MagicMock(return_value='fit') + minimizer._dfo_fit = MagicMock(return_value="fit") minimizer._set_parameter_fit_result = MagicMock() - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') + minimizer._gen_fit_results = MagicMock(return_value="gen_fit_results") cached_par = MagicMock() cached_par.value = 1 - cached_pars = {'mock_parm_1': cached_par} + cached_pars = {"mock_parm_1": cached_par} minimizer._cached_pars = cached_pars # Then result = minimizer.fit(x=1.0, y=2.0, weights=1) # Expect - assert result == 'gen_fit_results' + assert result == "gen_fit_results" minimizer._dfo_fit.assert_called_once_with(cached_pars, mock_model) minimizer._make_model.assert_called_once_with(parameters=None) - minimizer._set_parameter_fit_result.assert_called_once_with('fit', False) - minimizer._gen_fit_results.assert_called_once_with('fit', 1) + minimizer._set_parameter_fit_result.assert_called_once_with("fit", False) + minimizer._gen_fit_results.assert_called_once_with("fit", 1) mock_model_function.assert_called_once_with(1.0, 2.0, 1) def test_generate_fit_function(self, minimizer: DFO) -> None: # When - minimizer._original_fit_function = MagicMock(return_value='fit_function_result') + minimizer._original_fit_function = MagicMock(return_value="fit_function_result") minimizer._object = MagicMock() mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' + mock_parm_1.unique_name = "mock_parm_1" mock_parm_1.value = 1.0 mock_parm_1.error = 0.1 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' + mock_parm_2.unique_name = "mock_parm_2" mock_parm_2.value = 2.0 mock_parm_2.error = 0.2 - minimizer._object.get_fit_parameters = MagicMock(return_value=[mock_parm_1, mock_parm_2]) + minimizer._object.get_fit_parameters = MagicMock( + return_value=[mock_parm_1, mock_parm_2] + ) # Then fit_function = minimizer._generate_fit_function() fit_function_result = fit_function([10.0]) # Expect - assert 'fit_function_result' == fit_function_result + assert "fit_function_result" == fit_function_result minimizer._original_fit_function.assert_called_once_with([10.0]) - assert minimizer._cached_pars['mock_parm_1'] == mock_parm_1 - assert minimizer._cached_pars['mock_parm_2'] == mock_parm_2 - - @pytest.mark.parametrize("weights", [np.array([1, 2, 3, 4]), np.array([[1, 2, 3], [4, 5, 6]]), np.repeat(np.nan,3), np.zeros(3), np.repeat(np.inf,3), -np.ones(3)], ids=["wrong_length", "multidimensional", "NaNs", "zeros", "Infs", "negative"]) + assert minimizer._cached_pars["mock_parm_1"] == mock_parm_1 + assert minimizer._cached_pars["mock_parm_2"] == mock_parm_2 + + @pytest.mark.parametrize( + "weights", + [ + np.array([1, 2, 3, 4]), + np.array([[1, 2, 3], [4, 5, 6]]), + np.repeat(np.nan, 3), + np.zeros(3), + np.repeat(np.inf, 3), + -np.ones(3), + ], + ids=["wrong_length", "multidimensional", "NaNs", "zeros", "Infs", "negative"], + ) def test_fit_weight_exceptions(self, minimizer: DFO, weights) -> None: # When Then Expect with pytest.raises(ValueError): @@ -105,87 +119,115 @@ def test_make_model(self, minimizer: DFO) -> None: minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function) mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' + mock_parm_1.unique_name = "mock_parm_1" mock_parm_1.value = 1000.0 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' + mock_parm_2.unique_name = "mock_parm_2" mock_parm_2.value = 2000.0 # Then model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2]) - residuals_for_model = model(x=np.array([1, 2]), y=np.array([10, 20]), weights=np.array([100, 200])) + residuals_for_model = model( + x=np.array([1, 2]), + y=np.array([10, 20]), + weights=np.array([1 / 100, 1 / 200]), + ) # Expect minimizer._generate_fit_function.assert_called_once_with() - assert all(np.array([-0.01, -0.01]) == residuals_for_model(np.array([1111, 2222]))) + assert all( + np.array([-0.01, -0.01]) == residuals_for_model(np.array([1111, 2222])) + ) assert all(mock_fit_function.call_args[0][0] == np.array([1, 2])) - assert mock_fit_function.call_args[1] == {'pmock_parm_1': 1111, 'pmock_parm_2': 2222} + assert mock_fit_function.call_args[1] == { + "pmock_parm_1": 1111, + "pmock_parm_2": 2222, + } def test_set_parameter_fit_result_no_stack_status(self, minimizer: DFO): # When minimizer._cached_pars = { - 'a': MagicMock(), - 'b': MagicMock(), + "a": MagicMock(), + "b": MagicMock(), } - minimizer._cached_pars['a'].value = 'a' - minimizer._cached_pars['b'].value = 'b' + minimizer._cached_pars["a"].value = "a" + minimizer._cached_pars["b"].value = "b" mock_fit_result = MagicMock() mock_fit_result.x = [1.0, 2.0] - mock_fit_result.jacobian = 'jacobian' - mock_fit_result.resid = 'resid' + mock_fit_result.jacobian = "jacobian" + mock_fit_result.resid = "resid" - minimizer._error_from_jacobian = MagicMock(return_value=np.array([[0.1, 0.0], [0.0, 0.2]])) + minimizer._error_from_jacobian = MagicMock( + return_value=np.array([[0.1, 0.0], [0.0, 0.2]]) + ) # Then minimizer._set_parameter_fit_result(mock_fit_result, False) # Expect - assert minimizer._cached_pars['a'].value == 1.0 - assert minimizer._cached_pars['a'].error == 0.1 - assert minimizer._cached_pars['b'].value == 2.0 - assert minimizer._cached_pars['b'].error == 0.2 - minimizer._error_from_jacobian.assert_called_once_with('jacobian', 'resid', 0.95) + assert minimizer._cached_pars["a"].value == 1.0 + assert minimizer._cached_pars["a"].error == 0.1 + assert minimizer._cached_pars["b"].value == 2.0 + assert minimizer._cached_pars["b"].error == 0.2 + minimizer._error_from_jacobian.assert_called_once_with( + "jacobian", "resid", 0.95 + ) def test_gen_fit_results(self, minimizer: DFO, monkeypatch): # When mock_domain_fit_results = MagicMock() mock_FitResults = MagicMock(return_value=mock_domain_fit_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "FitResults", mock_FitResults) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "FitResults", mock_FitResults + ) mock_fit_result = MagicMock() mock_fit_result.flag = False mock_cached_model = MagicMock() - mock_cached_model.x = 'x' - mock_cached_model.y = 'y' + mock_cached_model.x = "x" + mock_cached_model.y = "y" minimizer._cached_model = mock_cached_model mock_cached_par_1 = MagicMock() - mock_cached_par_1.value = 'par_value_1' + mock_cached_par_1.value = "par_value_1" mock_cached_par_2 = MagicMock() - mock_cached_par_2.value = 'par_value_2' - minimizer._cached_pars = {'par_1': mock_cached_par_1, 'par_2': mock_cached_par_2} + mock_cached_par_2.value = "par_value_2" + minimizer._cached_pars = { + "par_1": mock_cached_par_1, + "par_2": mock_cached_par_2, + } - minimizer._p_0 = 'p_0' - minimizer.evaluate = MagicMock(return_value='evaluate') + minimizer._p_0 = "p_0" + minimizer.evaluate = MagicMock(return_value="evaluate") # Then - domain_fit_results = minimizer._gen_fit_results(mock_fit_result, 'weights', **{'kwargs_set_key': 'kwargs_set_val'}) + domain_fit_results = minimizer._gen_fit_results( + mock_fit_result, "weights", **{"kwargs_set_key": "kwargs_set_val"} + ) # Expect assert domain_fit_results == mock_domain_fit_results - assert domain_fit_results.kwargs_set_key == 'kwargs_set_val' - assert domain_fit_results.success == True - assert domain_fit_results.y_obs == 'y' - assert domain_fit_results.x == 'x' - assert domain_fit_results.p == {'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} - assert domain_fit_results.p0 == 'p_0' - assert domain_fit_results.y_calc == 'evaluate' - assert domain_fit_results.y_err == 'weights' - assert str(domain_fit_results.minimizer_engine) == "" + assert domain_fit_results.kwargs_set_key == "kwargs_set_val" + assert domain_fit_results.success == True + assert domain_fit_results.y_obs == "y" + assert domain_fit_results.x == "x" + assert domain_fit_results.p == { + "ppar_1": "par_value_1", + "ppar_2": "par_value_2", + } + assert domain_fit_results.p0 == "p_0" + assert domain_fit_results.y_calc == "evaluate" + assert domain_fit_results.y_err == "weights" + assert ( + str(domain_fit_results.minimizer_engine) + == "" + ) assert domain_fit_results.fit_args is None - minimizer.evaluate.assert_called_once_with('x', minimizer_parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'}) + minimizer.evaluate.assert_called_once_with( + "x", minimizer_parameters={"ppar_1": "par_value_1", "ppar_2": "par_value_2"} + ) def test_dfo_fit(self, minimizer: DFO, monkeypatch): # When @@ -199,26 +241,28 @@ def test_dfo_fit(self, minimizer: DFO, monkeypatch): mock_parm_2.max = 20.0 pars = {1: mock_parm_1, 2: mock_parm_2} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Success' + mock_results.msg = "Success" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then - results = minimizer._dfo_fit(pars, 'model', **kwargs) - + results = minimizer._dfo_fit(pars, "model", **kwargs) + # Expect assert results == mock_results - assert mock_dfols.solve.call_args[0][0] == 'model' - assert all(mock_dfols.solve.call_args[0][1] == np.array([1., 2.])) - assert all(mock_dfols.solve.call_args[1]['bounds'][0] == np.array([0.1, 0.2])) - assert all(mock_dfols.solve.call_args[1]['bounds'][1] == np.array([10., 20.])) - assert mock_dfols.solve.call_args[1]['scaling_within_bounds'] is True - assert mock_dfols.solve.call_args[1]['kwargs_set_key'] == 'kwargs_set_val' + assert mock_dfols.solve.call_args[0][0] == "model" + assert all(mock_dfols.solve.call_args[0][1] == np.array([1.0, 2.0])) + assert all(mock_dfols.solve.call_args[1]["bounds"][0] == np.array([0.1, 0.2])) + assert all(mock_dfols.solve.call_args[1]["bounds"][1] == np.array([10.0, 20.0])) + assert mock_dfols.solve.call_args[1]["scaling_within_bounds"] is True + assert mock_dfols.solve.call_args[1]["kwargs_set_key"] == "kwargs_set_val" def test_dfo_fit_no_scaling(self, minimizer: DFO, monkeypatch): # When @@ -232,40 +276,46 @@ def test_dfo_fit_no_scaling(self, minimizer: DFO, monkeypatch): mock_parm_2.max = 20.0 pars = {1: mock_parm_1, 2: mock_parm_2} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Success' + mock_results.msg = "Success" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then - results = minimizer._dfo_fit(pars, 'model', **kwargs) - + results = minimizer._dfo_fit(pars, "model", **kwargs) + # Expect assert results == mock_results - assert mock_dfols.solve.call_args[0][0] == 'model' - assert all(mock_dfols.solve.call_args[0][1] == np.array([1., 2.])) - assert all(mock_dfols.solve.call_args[1]['bounds'][0] == np.array([-np.inf, 0.2])) - assert all(mock_dfols.solve.call_args[1]['bounds'][1] == np.array([10., 20.])) - assert not 'scaling_within_bounds' in list(mock_dfols.solve.call_args[1].keys()) - assert 'kwargs_set_key' in list(mock_dfols.solve.call_args[1].keys()) - assert mock_dfols.solve.call_args[1]['kwargs_set_key'] == 'kwargs_set_val' + assert mock_dfols.solve.call_args[0][0] == "model" + assert all(mock_dfols.solve.call_args[0][1] == np.array([1.0, 2.0])) + assert all( + mock_dfols.solve.call_args[1]["bounds"][0] == np.array([-np.inf, 0.2]) + ) + assert all(mock_dfols.solve.call_args[1]["bounds"][1] == np.array([10.0, 20.0])) + assert not "scaling_within_bounds" in list(mock_dfols.solve.call_args[1].keys()) + assert "kwargs_set_key" in list(mock_dfols.solve.call_args[1].keys()) + assert mock_dfols.solve.call_args[1]["kwargs_set_key"] == "kwargs_set_val" def test_dfo_fit_exception(self, minimizer: DFO, monkeypatch): # When pars = {1: MagicMock(Parameter)} - kwargs = {'kwargs_set_key': 'kwargs_set_val'} + kwargs = {"kwargs_set_key": "kwargs_set_val"} mock_dfols = MagicMock() mock_results = MagicMock() - mock_results.msg = 'Failed' + mock_results.msg = "Failed" mock_dfols.solve = MagicMock(return_value=mock_results) - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_dfo, "dfols", mock_dfols + ) # Then Expect with pytest.raises(FitError): - minimizer._dfo_fit(pars, 'model', **kwargs) \ No newline at end of file + minimizer._dfo_fit(pars, "model", **kwargs) From a99f0689dcaa214a6fa74e3c2428986c7b0abb00 Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Wed, 18 Feb 2026 15:40:53 +0100 Subject: [PATCH 2/2] linting --- .../fitting/minimizers/minimizer_bumps.py | 62 +++++--------- .../fitting/minimizers/minimizer_dfo.py | 50 +++++------ .../fitting/minimizers/minimizer_lmfit.py | 82 ++++++++----------- 3 files changed, 75 insertions(+), 119 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index d9582e15..3315d2eb 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -26,7 +26,7 @@ FIT_AVAILABLE_IDS_FILTERED = copy.copy(FIT_AVAILABLE_IDS) # Considered experimental -FIT_AVAILABLE_IDS_FILTERED.remove("pt") +FIT_AVAILABLE_IDS_FILTERED.remove('pt') class Bumps(MinimizerBase): @@ -35,7 +35,7 @@ class Bumps(MinimizerBase): It allows for the Bumps fitting engine to use parameters declared in an `EasyScience.base_classes.ObjBase`. """ - package = "bumps" + package = 'bumps' def __init__( self, @@ -53,9 +53,7 @@ def __init__( keyword/value pairs :type fit_function: Callable """ - super().__init__( - obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum - ) + super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) self._p_0 = {} @staticmethod @@ -65,7 +63,7 @@ def all_methods() -> List[str]: @staticmethod def supported_methods() -> List[str]: # only a small subset - methods = ["amoeba", "newton", "lm"] + methods = ['amoeba', 'newton', 'lm'] return methods def fit( @@ -111,16 +109,16 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError("x and y must have the same shape.") + raise ValueError('x and y must have the same shape.') if weights.shape != x.shape: - raise ValueError("Weights must have the same shape as x and y.") + raise ValueError('Weights must have the same shape as x and y.') if not np.isfinite(weights).all(): - raise ValueError("Weights cannot be NaN or infinite.") + raise ValueError('Weights cannot be NaN or infinite.') if (weights <= 0).any(): - raise ValueError("Weights must be strictly positive and non-zero.") + raise ValueError('Weights must be strictly positive and non-zero.') if engine_kwargs is None: engine_kwargs = {} @@ -130,23 +128,17 @@ def fit( minimizer_kwargs.update(engine_kwargs) if tolerance is not None: - minimizer_kwargs["ftol"] = ( - tolerance # tolerance for change in function value - ) - minimizer_kwargs["xtol"] = ( - tolerance # tolerance for change in parameter value, could be an independent value - ) + minimizer_kwargs['ftol'] = tolerance # tolerance for change in function value + minimizer_kwargs['xtol'] = tolerance # tolerance for change in parameter value, could be an independent value if max_evaluations is not None: - minimizer_kwargs["steps"] = max_evaluations + minimizer_kwargs['steps'] = max_evaluations if model is None: model_function = self._make_model(parameters=parameters) model = model_function(x, y, weights) self._cached_model = model - self._p_0 = { - f"p{key}": self._cached_pars[key].value for key in self._cached_pars.keys() - } + self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} problem = FitProblem(model) # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime @@ -156,12 +148,8 @@ def fit( global_object.stack.enabled = False try: - model_results = bumps_fit( - problem, **method_dict, **minimizer_kwargs, **kwargs - ) - self._set_parameter_fit_result( - model_results, stack_status, problem._parameters - ) + model_results = bumps_fit(problem, **method_dict, **minimizer_kwargs, **kwargs) + self._set_parameter_fit_result(model_results, stack_status, problem._parameters) results = self._gen_fit_results(model_results) except Exception as e: for key in self._cached_pars.keys(): @@ -169,9 +157,7 @@ def fit( raise FitError(e) return results - def convert_to_pars_obj( - self, par_list: Optional[List] = None - ) -> List[BumpsParameter]: + def convert_to_pars_obj(self, par_list: Optional[List] = None) -> List[BumpsParameter]: """ Create a container with the `Parameters` converted from the base object. @@ -205,9 +191,7 @@ def convert_to_par_object(obj) -> BumpsParameter: fixed=obj.fixed, ) - def _make_model( - self, parameters: Optional[List[BumpsParameter]] = None - ) -> Callable: + def _make_model(self, parameters: Optional[List[BumpsParameter]] = None) -> Callable: """ Generate a bumps model from the supplied `fit_function` and parameters in the base object. Note that this makes a callable as it needs to be initialized with *x*, *y*, *weights* @@ -224,23 +208,17 @@ def _make_func(x, y, weights): bumps_pars = {} if not parameters: for name, par in obj._cached_pars.items(): - bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = ( - obj.convert_to_par_object(par) - ) + bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = obj.convert_to_par_object(par) else: for par in parameters: - bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = ( - obj.convert_to_par_object(par) - ) + bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = obj.convert_to_par_object(par) return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) return _make_func return _outer(self) - def _set_parameter_fit_result( - self, fit_result, stack_status: bool, par_list: List[BumpsParameter] - ): + def _set_parameter_fit_result(self, fit_result, stack_status: bool, par_list: List[BumpsParameter]): """ Update parameters to their final values and assign a std error to them. @@ -257,7 +235,7 @@ def _set_parameter_fit_result( pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro("Fitting routine") + global_object.stack.beginMacro('Fitting routine') for index, name in enumerate([par.name for par in par_list]): dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :] diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index bb566cf0..dacef618 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -26,7 +26,7 @@ class DFO(MinimizerBase): This is a wrapper to Derivative Free Optimisation for Least Square: https://numericalalgorithmsgroup.github.io/dfols/ """ - package = "dfo" + package = 'dfo' def __init__( self, @@ -44,18 +44,16 @@ def __init__( keyword/value pairs :type fit_function: Callable """ - super().__init__( - obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum - ) + super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) self._p_0 = {} @staticmethod def supported_methods() -> List[str]: - return ["leastsq"] + return ['leastsq'] @staticmethod def all_methods() -> List[str]: - return ["leastsq"] + return ['leastsq'] def fit( self, @@ -95,16 +93,16 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError("x and y must have the same shape.") + raise ValueError('x and y must have the same shape.') if weights.shape != x.shape: - raise ValueError("Weights must have the same shape as x and y.") + raise ValueError('Weights must have the same shape as x and y.') if not np.isfinite(weights).all(): - raise ValueError("Weights cannot be NaN or infinite.") + raise ValueError('Weights cannot be NaN or infinite.') if (weights <= 0).any(): - raise ValueError("Weights must be strictly positive and non-zero.") + raise ValueError('Weights must be strictly positive and non-zero.') if model is None: model_function = self._make_model(parameters=parameters) @@ -113,9 +111,7 @@ def fit( self._cached_model.x = x self._cached_model.y = y - self._p_0 = { - f"p{key}": self._cached_pars[key].value for key in self._cached_pars.keys() - } + self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} # Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime from easyscience import global_object @@ -166,9 +162,7 @@ def _make_func(x, y, weights): dfo_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = par.value else: for par in parameters: - dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = ( - par.value - ) + dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = par.value def _residuals(pars_values: List[float]) -> np.ndarray: for idx, par_name in enumerate(dfo_pars.keys()): @@ -181,9 +175,7 @@ def _residuals(pars_values: List[float]) -> np.ndarray: return _outer(self) - def _set_parameter_fit_result( - self, fit_result, stack_status, ci: float = 0.95 - ) -> None: + def _set_parameter_fit_result(self, fit_result, stack_status, ci: float = 0.95) -> None: """ Update parameters to their final values and assign a std error to them. @@ -200,11 +192,9 @@ def _set_parameter_fit_result( pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro("Fitting routine") + global_object.stack.beginMacro('Fitting routine') - error_matrix = self._error_from_jacobian( - fit_result.jacobian, fit_result.resid, ci - ) + error_matrix = self._error_from_jacobian(fit_result.jacobian, fit_result.resid, ci) for idx, par in enumerate(pars.values()): par.value = fit_result.x[idx] par.error = error_matrix[idx, idx] @@ -229,7 +219,7 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: pars = {} for p_name, par in self._cached_pars.items(): - pars[f"p{p_name}"] = par.value + pars[f'p{p_name}'] = par.value results.p = pars results.p0 = self._p_0 @@ -271,12 +261,12 @@ def _dfo_fit( # https://numericalalgorithmsgroup.github.io/dfols/build/html/userguide.html if not np.isinf(bounds).any(): # It is only possible to scale (normalize) variables if they are bound (different from inf) - kwargs["scaling_within_bounds"] = True + kwargs['scaling_within_bounds'] = True results = dfols.solve(model, pars_values, bounds=bounds, **kwargs) - if "Success" not in results.msg: - raise FitError(f"Fit failed with message: {results.msg}") + if 'Success' not in results.msg: + raise FitError(f'Fit failed with message: {results.msg}') return results @@ -287,9 +277,9 @@ def _prepare_kwargs( **kwargs, ) -> dict[str:str]: if max_evaluations is not None: - kwargs["maxfun"] = max_evaluations # max number of function evaluations + kwargs['maxfun'] = max_evaluations # max number of function evaluations if tolerance is not None: if 0.1 < tolerance: # dfo module throws errer if larger value - raise ValueError("Tolerance must be equal or smaller than 0.1") - kwargs["rhoend"] = tolerance # size of the trust region + raise ValueError('Tolerance must be equal or smaller than 0.1') + kwargs['rhoend'] = tolerance # size of the trust region return kwargs diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index cb841c74..2c63cea3 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -30,7 +30,7 @@ class LMFit(MinimizerBase): # noqa: S101 It allows for the lmfit fitting engine to use parameters declared in an `EasyScience.base_classes.ObjBase`. """ - package = "lmfit" + package = 'lmfit' def __init__( self, @@ -48,35 +48,33 @@ def __init__( :param method: Method to be used by the minimizer :type method: str """ - super().__init__( - obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum - ) + super().__init__(obj=obj, fit_function=fit_function, minimizer_enum=minimizer_enum) @staticmethod def all_methods() -> List[str]: return [ - "least_squares", - "leastsq", - "differential_evolution", - "basinhopping", - "ampgo", - "nelder", - "lbfgsb", - "powell", - "cg", - "newton", - "cobyla", - "bfgs", + 'least_squares', + 'leastsq', + 'differential_evolution', + 'basinhopping', + 'ampgo', + 'nelder', + 'lbfgsb', + 'powell', + 'cg', + 'newton', + 'cobyla', + 'bfgs', ] @staticmethod def supported_methods() -> List[str]: return [ - "least_squares", - "leastsq", - "differential_evolution", - "powell", - "cobyla", + 'least_squares', + 'leastsq', + 'differential_evolution', + 'powell', + 'cobyla', ] def fit( @@ -122,16 +120,16 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) if y.shape != x.shape: - raise ValueError("x and y must have the same shape.") + raise ValueError('x and y must have the same shape.') if weights.shape != x.shape: - raise ValueError("Weights must have the same shape as x and y.") + raise ValueError('Weights must have the same shape as x and y.') if not np.isfinite(weights).all(): - raise ValueError("Weights cannot be NaN or infinite.") + raise ValueError('Weights cannot be NaN or infinite.') if (weights <= 0).any(): - raise ValueError("Weights must be strictly positive and non-zero.") + raise ValueError('Weights must be strictly positive and non-zero.') if engine_kwargs is None: engine_kwargs = {} @@ -167,21 +165,17 @@ def fit( raise FitError(e) return results - def _get_fit_kws( - self, method: str, tolerance: float, minimizer_kwargs: dict[str:str] - ) -> dict[str:str]: + def _get_fit_kws(self, method: str, tolerance: float, minimizer_kwargs: dict[str:str]) -> dict[str:str]: if minimizer_kwargs is None: minimizer_kwargs = {} if tolerance is not None: - if method in [None, "least_squares", "leastsq"]: - minimizer_kwargs["ftol"] = tolerance - if method in ["differential_evolution", "powell", "cobyla"]: - minimizer_kwargs["tol"] = tolerance + if method in [None, 'least_squares', 'leastsq']: + minimizer_kwargs['ftol'] = tolerance + if method in ['differential_evolution', 'powell', 'cobyla']: + minimizer_kwargs['tol'] = tolerance return minimizer_kwargs - def convert_to_pars_obj( - self, parameters: Optional[List[Parameter]] = None - ) -> LMParameters: + def convert_to_pars_obj(self, parameters: Optional[List[Parameter]] = None) -> LMParameters: """ Create an lmfit compatible container with the `Parameters` converted from the base object. @@ -191,9 +185,7 @@ def convert_to_pars_obj( if parameters is None: # Assume that we have a ObjBase for which we can obtain a list parameters = self._object.get_fit_parameters() - lm_parameters = LMParameters().add_many( - [self.convert_to_par_object(parameter) for parameter in parameters] - ) + lm_parameters = LMParameters().add_many([self.convert_to_par_object(parameter) for parameter in parameters]) return lm_parameters @staticmethod @@ -233,7 +225,7 @@ def _make_model(self, pars: Optional[LMParameters] = None) -> LMModel: # Create the model model = LMModel( fit_func, - independent_vars=["x"], + independent_vars=['x'], param_names=[MINIMIZER_PARAMETER_PREFIX + str(key) for key in pars.keys()], ) # Assign values from the `Parameter` to the model @@ -270,15 +262,11 @@ def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool) pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] global_object.stack.enabled = True - global_object.stack.beginMacro("Fitting routine") + global_object.stack.beginMacro('Fitting routine') for name in pars.keys(): - pars[name].value = fit_result.params[ - MINIMIZER_PARAMETER_PREFIX + str(name) - ].value + pars[name].value = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].value if fit_result.errorbars: - pars[name].error = fit_result.params[ - MINIMIZER_PARAMETER_PREFIX + str(name) - ].stderr + pars[name].error = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].stderr else: pars[name].error = 0.0 if stack_status: @@ -302,7 +290,7 @@ def _gen_fit_results(self, fit_results: ModelResult, **kwargs) -> FitResults: results.success = fit_results.success results.y_obs = fit_results.data # results.residual = fit_results.residual - results.x = fit_results.userkws["x"] + results.x = fit_results.userkws['x'] results.p = fit_results.values results.p0 = fit_results.init_values # results.goodness_of_fit = fit_results.chisqr