From 4c11409e73b301b6cdbc64bf7f264f641902cc20 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Wed, 5 Jun 2024 13:49:17 +0200 Subject: [PATCH 01/10] minimizers to sub dir --- src/easyscience/Datasets/xarray.py | 2 +- src/easyscience/Fitting/Fitting.py | 17 +- src/easyscience/Fitting/__init__.py | 36 +-- .../Fitting/{ => minimizers}/DFO_LS.py | 47 ++- .../Fitting/minimizers/__init__.py | 37 +++ .../Fitting/{ => minimizers}/bumps.py | 53 ++-- .../{ => minimizers}/fitting_template.py | 6 +- .../Fitting/{ => minimizers}/lmfit.py | 78 +++-- tests/unit_tests/Fitting/test_fitting.py | 284 +++++++----------- 9 files changed, 238 insertions(+), 322 deletions(-) rename src/easyscience/Fitting/{ => minimizers}/DFO_LS.py (88%) create mode 100644 src/easyscience/Fitting/minimizers/__init__.py rename src/easyscience/Fitting/{ => minimizers}/bumps.py (87%) rename src/easyscience/Fitting/{ => minimizers}/fitting_template.py (98%) rename src/easyscience/Fitting/{ => minimizers}/lmfit.py (85%) diff --git a/src/easyscience/Datasets/xarray.py b/src/easyscience/Datasets/xarray.py index 3971444f..36633213 100644 --- a/src/easyscience/Datasets/xarray.py +++ b/src/easyscience/Datasets/xarray.py @@ -20,7 +20,7 @@ import xarray as xr from easyscience import ureg -from easyscience.Fitting.fitting_template import FitResults +from easyscience.Fitting import FitResults T_ = TypeVar('T_') diff --git a/src/easyscience/Fitting/Fitting.py b/src/easyscience/Fitting/Fitting.py index 7cb8ac10..27714a16 100644 --- a/src/easyscience/Fitting/Fitting.py +++ b/src/easyscience/Fitting/Fitting.py @@ -18,16 +18,15 @@ import numpy as np -import easyscience.Fitting as Fitting -from easyscience import borg +import easyscience.Fitting.minimizers as minimizers from easyscience import default_fitting_engine from easyscience.Objects.Groups import BaseCollection _C = TypeVar('_C', bound=ABCMeta) -_M = TypeVar('_M', bound=Fitting.FittingTemplate) +_M = TypeVar('_M', bound=minimizers.FittingTemplate) if TYPE_CHECKING: - from easyscience.Fitting.fitting_template import FitResults as FR + from easyscience.Fitting.minimizers.fitting_template import FitResults as FR from easyscience.Utils.typing import B @@ -36,8 +35,6 @@ class Fitter: Wrapper to the fitting engines """ - _borg = borg - def __init__(self, fit_object: Optional[B] = None, fit_function: Optional[Callable] = None): self._fit_object = fit_object self._fit_function = fit_function @@ -51,7 +48,7 @@ def __init__(self, fit_object: Optional[B] = None, fit_function: Optional[Callab if (fit_object is not None) or (fit_function is not None): raise AttributeError - self._engines: List[_C] = Fitting.engines + self._engines: List[_C] = minimizers.engines self._current_engine: _C = None self.__engine_obj: _M = None self._is_initialized: bool = False @@ -59,7 +56,7 @@ def __init__(self, fit_object: Optional[B] = None, fit_function: Optional[Callab fit_methods = [ x - for x, y in Fitting.FittingTemplate.__dict__.items() + for x, y in minimizers.FittingTemplate.__dict__.items() if (isinstance(y, FunctionType) and not x.startswith('_')) and x != 'fit' ] for method_name in fit_methods: @@ -145,9 +142,9 @@ def available_engines(self) -> List[str]: :return: List of available fitting engines :rtype: List[str] """ - if Fitting.engines is None: + if minimizers.engines is None: raise ImportError('There are no available fitting engines. Install `lmfit` and/or `bumps`') - return [engine.name for engine in Fitting.engines] + return [engine.name for engine in minimizers.engines] @property def can_fit(self) -> bool: diff --git a/src/easyscience/Fitting/__init__.py b/src/easyscience/Fitting/__init__.py index 4a2a542b..9f816dbd 100644 --- a/src/easyscience/Fitting/__init__.py +++ b/src/easyscience/Fitting/__init__.py @@ -1,35 +1 @@ -# SPDX-FileCopyrightText: 2023 EasyScience contributors -# SPDX-License-Identifier: BSD-3-Clause -# © 2021-2023 Contributors to the EasyScience project np.ndarray: for idx, par_name in enumerate(par.keys()): par[par_name] = x0[idx] return (y - fit_func(x, **par)) / weights - setattr(residuals, "x", x) - setattr(residuals, "y", y) + setattr(residuals, 'x', x) + setattr(residuals, 'y', y) return residuals return make_func @@ -157,7 +157,7 @@ def fit( default_method = {} if method is not None and method in self.available_methods(): - default_method["method"] = method + default_method['method'] = method if weights is None: weights = np.sqrt(np.abs(y)) @@ -166,10 +166,7 @@ def fit( model = self.make_model(pars=parameters) model = model(x, y, weights) self._cached_model = model - self.p_0 = { - f"p{key}": self._cached_pars[key].raw_value - for key in self._cached_pars.keys() - } + self.p_0 = {f'p{key}': self._cached_pars[key].raw_value for key in self._cached_pars.keys()} # Why do we do this? Because a fitting template has to have borg instantiated outside pre-runtime from easyscience import borg @@ -202,9 +199,7 @@ def convert_to_par_object(obj) -> None: """ pass - 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. @@ -221,11 +216,9 @@ def _set_parameter_fit_result( pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] borg.stack.enabled = True - borg.stack.beginMacro("Fitting routine") + borg.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] @@ -250,7 +243,7 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: pars = self._cached_pars item = {} for p_name, par in pars.items(): - item[f"p{p_name}"] = par.raw_value + item[f'p{p_name}'] = par.raw_value results.p0 = self.p_0 results.p = item results.x = self._cached_model.x @@ -267,7 +260,7 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: return results def available_methods(self) -> List[str]: - return ["leastsq"] + return ['leastsq'] def dfols_fit(self, model: Callable, **kwargs): """ diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py new file mode 100644 index 00000000..e284e640 --- /dev/null +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2023 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2023 Contributors to the EasyScience project 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. @@ -245,7 +234,7 @@ def convert_to_par_object(obj) -> bumpsParameter: :rtype: bumpsParameter """ return bumpsParameter( - name="p" + str(NameConverter().get_key(obj)), + name='p' + str(NameConverter().get_key(obj)), value=obj.raw_value, bounds=[obj.min, obj.max], fixed=obj.fixed, @@ -268,7 +257,7 @@ def _set_parameter_fit_result(self, fit_result, stack_status: bool): pars[name].value = self._cached_pars_vals[name][0] pars[name].error = self._cached_pars_vals[name][1] borg.stack.enabled = True - borg.stack.beginMacro("Fitting routine") + borg.stack.beginMacro('Fitting routine') for index, name in enumerate(self._cached_model._pnames): dict_name = int(name[1:]) diff --git a/src/easyscience/Fitting/fitting_template.py b/src/easyscience/Fitting/minimizers/fitting_template.py similarity index 98% rename from src/easyscience/Fitting/fitting_template.py rename to src/easyscience/Fitting/minimizers/fitting_template.py index 4e10e399..22d11c99 100644 --- a/src/easyscience/Fitting/fitting_template.py +++ b/src/easyscience/Fitting/minimizers/fitting_template.py @@ -77,9 +77,9 @@ def fit( x: np.ndarray, y: np.ndarray, weights: Optional[Union[np.ndarray]] = None, - model: Optional = None, - parameters: Optional = None, - method: Optional = None, + model=None, + parameters=None, + method=None, **kwargs, ): """ diff --git a/src/easyscience/Fitting/lmfit.py b/src/easyscience/Fitting/minimizers/lmfit.py similarity index 85% rename from src/easyscience/Fitting/lmfit.py rename to src/easyscience/Fitting/minimizers/lmfit.py index c77e6036..7bac002e 100644 --- a/src/easyscience/Fitting/lmfit.py +++ b/src/easyscience/Fitting/minimizers/lmfit.py @@ -2,8 +2,8 @@ # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project lmModel: # Create the model model = lmModel( fit_func, - independent_vars=["x"], - param_names=["p" + str(key) for key in pars.keys()], + independent_vars=['x'], + param_names=['p' + str(key) for key in pars.keys()], ) # Assign values from the `Parameter` to the model for name, item in pars.items(): @@ -69,9 +69,7 @@ def make_model(self, pars: Optional[lmParameters] = None) -> lmModel: value = item.value else: value = item.raw_value - model.set_param_hint( - "p" + str(name), value=value, min=item.min, max=item.max - ) + model.set_param_hint('p' + str(name), value=value, min=item.min, max=item.max) # Cache the model for later reference self._cached_model = model @@ -127,12 +125,10 @@ def fit_function(x: np.ndarray, **kwargs): # f = (x, a=1, b=2)... # Where we need to be generic. Note that this won't hold for much outside of this scope. params = [ - inspect.Parameter( - "x", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=inspect._empty - ), + inspect.Parameter('x', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=inspect._empty), *[ inspect.Parameter( - "p" + str(name), + 'p' + str(name), inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=inspect._empty, default=parameter.raw_value, @@ -180,7 +176,7 @@ def fit( """ default_method = {} if method is not None and method in self.available_methods(): - default_method["method"] = method + default_method['method'] = method if weights is None: weights = 1 / np.sqrt(np.abs(y)) @@ -191,7 +187,7 @@ def fit( if minimizer_kwargs is None: minimizer_kwargs = {} else: - minimizer_kwargs = {"fit_kws": minimizer_kwargs} + minimizer_kwargs = {'fit_kws': minimizer_kwargs} minimizer_kwargs.update(engine_kwargs) # Why do we do this? Because a fitting template has to have borg instantiated outside pre-runtime @@ -204,9 +200,7 @@ def fit( if model is None: model = self.make_model() - model_results = model.fit( - y, x=x, weights=weights, **default_method, **minimizer_kwargs, **kwargs - ) + model_results = model.fit(y, x=x, weights=weights, **default_method, **minimizer_kwargs, **kwargs) self._set_parameter_fit_result(model_results, stack_status) results = self._gen_fit_results(model_results) except Exception as e: @@ -227,9 +221,7 @@ def convert_to_pars_obj(self, par_list: Optional[List] = None) -> lmParameters: if par_list is None: # Assume that we have a BaseObj for which we can obtain a list par_list = self._object.get_fit_parameters() - pars_obj = lmParameters().add_many( - [self.__class__.convert_to_par_object(obj) for obj in par_list] - ) + pars_obj = lmParameters().add_many([self.__class__.convert_to_par_object(obj) for obj in par_list]) return pars_obj @staticmethod @@ -241,7 +233,7 @@ def convert_to_par_object(obj) -> lmParameter: :rtype: lmParameter """ return lmParameter( - "p" + str(NameConverter().get_key(obj)), + 'p' + str(NameConverter().get_key(obj)), value=obj.raw_value, vary=not obj.fixed, min=obj.min, @@ -266,11 +258,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] borg.stack.enabled = True - borg.stack.beginMacro("Fitting routine") + borg.stack.beginMacro('Fitting routine') for name in pars.keys(): - pars[name].value = fit_result.params["p" + str(name)].value + pars[name].value = fit_result.params['p' + str(name)].value if fit_result.errorbars: - pars[name].error = fit_result.params["p" + str(name)].stderr + pars[name].error = fit_result.params['p' + str(name)].stderr else: pars[name].error = 0.0 if stack_status: @@ -294,7 +286,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 @@ -309,16 +301,16 @@ def _gen_fit_results(self, fit_results: ModelResult, **kwargs) -> FitResults: def available_methods(self) -> 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', ] diff --git a/tests/unit_tests/Fitting/test_fitting.py b/tests/unit_tests/Fitting/test_fitting.py index fba4fd06..8aa2c907 100644 --- a/tests/unit_tests/Fitting/test_fitting.py +++ b/tests/unit_tests/Fitting/test_fitting.py @@ -5,90 +5,75 @@ __author__ = "github.com/wardsimon" __version__ = "0.0.1" -from typing import ClassVar - import pytest import numpy as np from easyscience.Fitting.Constraints import ObjConstraint from easyscience.Fitting.Fitting import Fitter from easyscience.Fitting.Fitting import MultiFitter -from easyscience.Fitting.fitting_template import FitError +from easyscience.Fitting.minimizers import FitError from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter -class AbsSin(BaseObj): - phase: ClassVar[Parameter] - offset: ClassVar[Parameter] - - def __init__(self, offset: Parameter, phase: Parameter): - super(AbsSin, self).__init__("sin", offset=offset, phase=phase) - - @classmethod - def from_pars(cls, offset, phase): - offset = Parameter("offset", offset) - phase = Parameter("phase", phase) - return cls(offset=offset, phase=phase) - - def __call__(self, x): - return np.abs(np.sin(self.phase.raw_value * x + self.offset.raw_value)) - class Line(BaseObj): - m: ClassVar[Parameter] - c: ClassVar[Parameter] + m: Parameter + c: Parameter - def __init__(self, m: Parameter, c: Parameter): + def __init__(self, m_val: float, c_val: float): + m = Parameter("m", m_val) + c = Parameter("c", c_val) super(Line, self).__init__("line", m=m, c=c) - @classmethod - def from_pars(cls, m, c): - m = Parameter("m", m) - c = Parameter("c", c) - return cls(m=m, c=c) - def __call__(self, x): return self.m.raw_value * x + self.c.raw_value + +class AbsSin(BaseObj): + phase: Parameter + offset: Parameter -@pytest.fixture -def genObjs(): - ref_sin = AbsSin.from_pars(0.2, np.pi) - sp_sin = AbsSin.from_pars(0.354, 3.05) - return ref_sin, sp_sin - - -@pytest.fixture -def genObjs2(): - ref_sin = AbsSin.from_pars(np.pi * 0.45, 0.45 * np.pi * 0.5) - sp_sin = AbsSin.from_pars(1, 0.5) - return ref_sin, sp_sin + def __init__(self, offset_val: float, phase_val: float): + offset = Parameter("offset", offset_val) + phase = Parameter("phase", phase_val) + super().__init__("sin", offset=offset, phase=phase) + def __call__(self, x): + return np.abs(np.sin(self.phase.raw_value * x + self.offset.raw_value)) -def test_basic_fit(genObjs): - ref_sin = genObjs[0] - sp_sin = genObjs[1] - x = np.linspace(0, 5, 200) - y = ref_sin(x) +class AbsSin2D(BaseObj): + phase: Parameter + offset: Parameter - sp_sin.offset.fixed = False - sp_sin.phase.fixed = False + def __init__(self, offset_val: float, phase_val: float): + offset = Parameter("offset", offset_val) + phase = Parameter("phase", phase_val) + super().__init__("sin2D", offset=offset, phase=phase) - f = Fitter(sp_sin, sp_sin) + def __call__(self, x): + X = x[:, :, 0] # x is a 2D array + Y = x[:, :, 1] + return np.abs( + np.sin(self.phase.raw_value * X + self.offset.raw_value) + ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) - _ = f.fit(x, y) - assert sp_sin.phase.raw_value == pytest.approx(ref_sin.phase.raw_value, rel=1e-3) - assert sp_sin.offset.raw_value == pytest.approx(ref_sin.offset.raw_value, rel=1e-3) +class AbsSin2DL(AbsSin2D): + def __call__(self, x): + X = x[:, 0] # x is a 1D array + Y = x[:, 1] + return np.abs( + np.sin(self.phase.raw_value * X + self.offset.raw_value) + ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_basic_fit(genObjs, fit_engine, with_errors): - ref_sin = genObjs[0] - sp_sin = genObjs[1] +def test_basic_fit(fit_engine, with_errors): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) x = np.linspace(0, 5, 200) y = ref_sin(x) @@ -138,9 +123,9 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_fit_result(genObjs, fit_engine): - ref_sin = genObjs[0] - sp_sin = genObjs[1] +def test_fit_result(fit_engine): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) x = np.linspace(0, 5, 200) y = ref_sin(x) @@ -170,9 +155,9 @@ def test_fit_result(genObjs, fit_engine): @pytest.mark.parametrize("fit_method", ["leastsq", "powell", "cobyla"]) -def test_lmfit_methods(genObjs, fit_method): - ref_sin = genObjs[0] - sp_sin = genObjs[1] +def test_lmfit_methods(fit_method): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) x = np.linspace(0, 5, 200) y = ref_sin(x) @@ -188,9 +173,9 @@ def test_lmfit_methods(genObjs, fit_method): @pytest.mark.xfail(reason="known bumps issue") @pytest.mark.parametrize("fit_method", ["newton", "lm"]) -def test_bumps_methods(genObjs, fit_method): - ref_sin = genObjs[0] - sp_sin = genObjs[1] +def test_bumps_methods(fit_method): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) x = np.linspace(0, 5, 200) y = ref_sin(x) @@ -206,9 +191,9 @@ def test_bumps_methods(genObjs, fit_method): @pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "DFO_LS"]) -def test_fit_constraints(genObjs2, fit_engine): - ref_sin = genObjs2[0] - sp_sin = genObjs2[1] +def test_fit_constraints(fit_engine): + ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) + sp_sin = AbsSin(1, 0.5) x = np.linspace(0, 5, 200) y = ref_sin(x) @@ -234,51 +219,35 @@ def test_fit_constraints(genObjs2, fit_engine): assert len(f.fit_constraints()) == 0 -# def test_fit_makeModel(genObjs): -# ref_sin = genObjs[0] -# sp_sin = genObjs[1] -# -# x = np.linspace(0, 5, 200) -# y = ref_sin(x) -# -# sp_sin.offset.fixed = False -# sp_sin.phase.fixed = False -# -# f = Fitter(sp_sin, sp_sin) -# model = f.make_model() -# result = f.fit(x, y, model=model) -# check_fit_results(result, sp_sin, ref_sin, x) - @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_multi_fit(genObjs, genObjs2, fit_engine, with_errors): - ref_sin1 = genObjs[0] - ref_sin2 = genObjs2[0] - - ref_sin1.offset.user_constraints["ref_sin2"] = ObjConstraint( - ref_sin2.offset, "", ref_sin1.offset +def test_multi_fit(fit_engine, with_errors): + ref_sin_1 = AbsSin(0.2, np.pi) + sp_sin_1 = AbsSin(0.354, 3.05) + ref_sin_2 = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) + sp_sin_2 = AbsSin(1, 0.5) + + ref_sin_1.offset.user_constraints["ref_sin2"] = ObjConstraint( + ref_sin_2.offset, "", ref_sin_1.offset ) - ref_sin1.offset.user_constraints["ref_sin2"]() + ref_sin_1.offset.user_constraints["ref_sin2"]() - sp_sin1 = genObjs[1] - sp_sin2 = genObjs2[1] - - sp_sin1.offset.user_constraints["sp_sin2"] = ObjConstraint( - sp_sin2.offset, "", sp_sin1.offset + sp_sin_1.offset.user_constraints["sp_sin2"] = ObjConstraint( + sp_sin_2.offset, "", sp_sin_1.offset ) - sp_sin1.offset.user_constraints["sp_sin2"]() + sp_sin_1.offset.user_constraints["sp_sin2"]() x1 = np.linspace(0, 5, 200) - y1 = ref_sin1(x1) + y1 = ref_sin_1(x1) x2 = np.copy(x1) - y2 = ref_sin2(x2) + y2 = ref_sin_2(x2) - sp_sin1.offset.fixed = False - sp_sin1.phase.fixed = False - sp_sin2.phase.fixed = False + sp_sin_1.offset.fixed = False + sp_sin_1.phase.fixed = False + sp_sin_2.phase.fixed = False - f = MultiFitter([sp_sin1, sp_sin2], [sp_sin1, sp_sin2]) + f = MultiFitter([sp_sin_1, sp_sin_2], [sp_sin_1, sp_sin_2]) if fit_engine is not None: try: f.switch_engine(fit_engine) @@ -293,11 +262,11 @@ def test_multi_fit(genObjs, genObjs2, fit_engine, with_errors): results = f.fit(*args, **kwargs) X = [x1, x2] Y = [y1, y2] - F_ref = [ref_sin1, ref_sin2] - F_real = [sp_sin1, sp_sin2] + F_ref = [ref_sin_1, ref_sin_2] + F_real = [sp_sin_1, sp_sin_2] for idx, result in enumerate(results): - assert result.n_pars == len(sp_sin1.get_fit_parameters()) + len( - sp_sin2.get_fit_parameters() + assert result.n_pars == len(sp_sin_1.get_fit_parameters()) + len( + sp_sin_2.get_fit_parameters() ) assert result.chi2 == pytest.approx( 0, abs=1.5e-3 * (len(result.x) - result.n_pars) @@ -314,46 +283,46 @@ def test_multi_fit(genObjs, genObjs2, fit_engine, with_errors): @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_multi_fit2(genObjs, genObjs2, fit_engine, with_errors): - ref_sin1 = genObjs[0] - ref_sin2 = genObjs2[0] - ref_line = Line.from_pars(1, 4.6) - - ref_sin1.offset.user_constraints["ref_sin2"] = ObjConstraint( - ref_sin2.offset, "", ref_sin1.offset +def test_multi_fit2(fit_engine, with_errors): + ref_sin_1 = AbsSin(0.2, np.pi) + sp_sin_1 = AbsSin(0.354, 3.05) + ref_sin_2 = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) + sp_sin_2 = AbsSin(1, 0.5)# ref_sin_1_obj = genObjs[0] + ref_line_obj = Line(1, 4.6) + + ref_sin_1.offset.user_constraints["ref_sin2"] = ObjConstraint( + ref_sin_2.offset, "", ref_sin_1.offset ) - ref_sin1.offset.user_constraints["ref_line"] = ObjConstraint( - ref_line.m, "", ref_sin1.offset + ref_sin_1.offset.user_constraints["ref_line"] = ObjConstraint( + ref_line_obj.m, "", ref_sin_1.offset ) - ref_sin1.offset.user_constraints["ref_sin2"]() - ref_sin1.offset.user_constraints["ref_line"]() + ref_sin_1.offset.user_constraints["ref_sin2"]() + ref_sin_1.offset.user_constraints["ref_line"]() - sp_sin1 = genObjs[1] - sp_sin2 = genObjs2[1] - sp_line = Line.from_pars(0.43, 6.1) + sp_line = Line(0.43, 6.1) - sp_sin1.offset.user_constraints["sp_sin2"] = ObjConstraint( - sp_sin2.offset, "", sp_sin1.offset + sp_sin_1.offset.user_constraints["sp_sin2"] = ObjConstraint( + sp_sin_2.offset, "", sp_sin_1.offset ) - sp_sin1.offset.user_constraints["sp_line"] = ObjConstraint( - sp_line.m, "", sp_sin1.offset + sp_sin_1.offset.user_constraints["sp_line"] = ObjConstraint( + sp_line.m, "", sp_sin_1.offset ) - sp_sin1.offset.user_constraints["sp_sin2"]() - sp_sin1.offset.user_constraints["sp_line"]() + sp_sin_1.offset.user_constraints["sp_sin2"]() + sp_sin_1.offset.user_constraints["sp_line"]() x1 = np.linspace(0, 5, 200) - y1 = ref_sin1(x1) + y1 = ref_sin_1(x1) x3 = np.copy(x1) - y3 = ref_sin2(x3) + y3 = ref_sin_2(x3) x2 = np.copy(x1) - y2 = ref_line(x2) + y2 = ref_line_obj(x2) - sp_sin1.offset.fixed = False - sp_sin1.phase.fixed = False - sp_sin2.phase.fixed = False + sp_sin_1.offset.fixed = False + sp_sin_1.phase.fixed = False + sp_sin_2.phase.fixed = False sp_line.c.fixed = False - f = MultiFitter([sp_sin1, sp_line, sp_sin2], [sp_sin1, sp_line, sp_sin2]) + f = MultiFitter([sp_sin_1, sp_line, sp_sin_2], [sp_sin_1, sp_line, sp_sin_2]) if fit_engine is not None: try: f.switch_engine(fit_engine) @@ -368,14 +337,14 @@ def test_multi_fit2(genObjs, genObjs2, fit_engine, with_errors): results = f.fit(*args, **kwargs) X = [x1, x2, x3] Y = [y1, y2, y3] - F_ref = [ref_sin1, ref_line, ref_sin2] - F_real = [sp_sin1, sp_line, sp_sin2] + F_ref = [ref_sin_1, ref_line_obj, ref_sin_2] + F_real = [sp_sin_1, sp_line, sp_sin_2] assert len(results) == len(X) for idx, result in enumerate(results): - assert result.n_pars == len(sp_sin1.get_fit_parameters()) + len( - sp_sin2.get_fit_parameters() + assert result.n_pars == len(sp_sin_1.get_fit_parameters()) + len( + sp_sin_2.get_fit_parameters() ) + len(sp_line.get_fit_parameters()) assert result.chi2 == pytest.approx( 0, abs=1.5e-3 * (len(result.x) - result.n_pars) @@ -390,39 +359,12 @@ def test_multi_fit2(genObjs, genObjs2, fit_engine, with_errors): ) -class AbsSin2D(BaseObj): - def __init__(self, offset: Parameter, phase: Parameter): - super(AbsSin2D, self).__init__("sin2D", offset=offset, phase=phase) - - @classmethod - def from_pars(cls, offset, phase): - offset = Parameter("offset", offset) - phase = Parameter("phase", phase) - return cls(offset=offset, phase=phase) - - def __call__(self, x): - X = x[:, :, 0] - Y = x[:, :, 1] - return np.abs( - np.sin(self.phase.raw_value * X + self.offset.raw_value) - ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) - - -class AbsSin2DL(AbsSin2D): - def __call__(self, x): - X = x[:, 0] - Y = x[:, 1] - return np.abs( - np.sin(self.phase.raw_value * X + self.offset.raw_value) - ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) - - @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) def test_2D_vectorized(fit_engine, with_errors): x = np.linspace(0, 5, 200) - mm = AbsSin2D.from_pars(0.3, 1.6) - m2 = AbsSin2D.from_pars( + mm = AbsSin2D(0.3, 1.6) + m2 = AbsSin2D( 0.1, 1.8 ) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) @@ -457,8 +399,8 @@ def test_2D_vectorized(fit_engine, with_errors): @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) def test_2D_non_vectorized(fit_engine, with_errors): x = np.linspace(0, 5, 200) - mm = AbsSin2DL.from_pars(0.3, 1.6) - m2 = AbsSin2DL.from_pars( + mm = AbsSin2DL(0.3, 1.6) + m2 = AbsSin2DL( 0.1, 1.8 ) # The fit is quite sensitive to the initial values :-( X, Y = np.meshgrid(x, x) @@ -493,13 +435,13 @@ def test_2D_non_vectorized(fit_engine, with_errors): @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_multi_fit_1D_2D(genObjs, fit_engine, with_errors): +def test_multi_fit_1D_2D(fit_engine, with_errors): # Generate fit and reference objects - ref_sin1D = genObjs[0] - sp_sin1D = genObjs[1] + ref_sin1D = AbsSin(0.2, np.pi) + sp_sin1D = AbsSin(0.354, 3.05) - ref_sin2D = AbsSin2D.from_pars(0.3, 1.6) - sp_sin2D = AbsSin2D.from_pars( + ref_sin2D = AbsSin2D(0.3, 1.6) + sp_sin2D = AbsSin2D( 0.1, 1.75 ) # The fit is VERY sensitive to the initial values :-( From 0c381617bf346f8967df0c94a085dbd9907dfa06 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Wed, 5 Jun 2024 14:28:28 +0200 Subject: [PATCH 02/10] adjustments to new Fitting folder --- examples_old/example1.py | 2 +- examples_old/example1_dream.py | 2 +- examples_old/example2.py | 2 +- examples_old/example3.py | 2 +- examples_old/example4.py | 2 +- examples_old/example5_broken.py | 2 +- examples_old/example6_broken.py | 2 +- examples_old/example_dataset2.py | 2 +- examples_old/example_dataset2pt2.py | 2 +- examples_old/example_dataset2pt2_broken.py | 2 +- examples_old/example_dataset3.py | 2 +- examples_old/example_dataset3pt2.py | 2 +- examples_old/example_dataset4.py | 2 +- examples_old/example_dataset4_2.py | 2 +- src/easyscience/Fitting/__init__.py | 3 + .../Fitting/{Fitting.py => fitter.py} | 132 +-------------- src/easyscience/Fitting/multi_fitter.py | 153 ++++++++++++++++++ src/easyscience/Objects/Inferface.py | 34 ++-- tests/integration_tests/test_undoRedo.py | 2 +- tests/unit_tests/Fitting/test_fitting.py | 4 +- 20 files changed, 194 insertions(+), 162 deletions(-) rename src/easyscience/Fitting/{Fitting.py => fitter.py} (70%) create mode 100644 src/easyscience/Fitting/multi_fitter.py diff --git a/examples_old/example1.py b/examples_old/example1.py index 867c288c..18ca3b52 100644 --- a/examples_old/example1.py +++ b/examples_old/example1.py @@ -3,7 +3,7 @@ import numpy as np -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example1_dream.py b/examples_old/example1_dream.py index c0e0e2c3..f485d058 100644 --- a/examples_old/example1_dream.py +++ b/examples_old/example1_dream.py @@ -3,7 +3,7 @@ import numpy as np -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example2.py b/examples_old/example2.py index 07cc0097..db1228b6 100644 --- a/examples_old/example2.py +++ b/examples_old/example2.py @@ -3,7 +3,7 @@ import numpy as np -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example3.py b/examples_old/example3.py index ef69429e..51916122 100644 --- a/examples_old/example3.py +++ b/examples_old/example3.py @@ -5,7 +5,7 @@ import numpy as np -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example4.py b/examples_old/example4.py index f5858f69..7b6e6c98 100644 --- a/examples_old/example4.py +++ b/examples_old/example4.py @@ -10,7 +10,7 @@ import numpy as np from easyscience import borg -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.core import ComponentSerializer from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example5_broken.py b/examples_old/example5_broken.py index 3ac38a9d..17abd5d9 100644 --- a/examples_old/example5_broken.py +++ b/examples_old/example5_broken.py @@ -9,7 +9,7 @@ import numpy as np from easyscience import borg -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.Base import BaseObj from easyscience.Objects.Base import Parameter from easyscience.Objects.core import ComponentSerializer diff --git a/examples_old/example6_broken.py b/examples_old/example6_broken.py index e3a1183f..0c2b9c0d 100644 --- a/examples_old/example6_broken.py +++ b/examples_old/example6_broken.py @@ -9,7 +9,7 @@ import numpy as np from easyscience import borg -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.Base import BaseObj from easyscience.Objects.Base import Parameter from easyscience.Objects.core import ComponentSerializer diff --git a/examples_old/example_dataset2.py b/examples_old/example_dataset2.py index 791f76c9..e0667a19 100644 --- a/examples_old/example_dataset2.py +++ b/examples_old/example_dataset2.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example_dataset2pt2.py b/examples_old/example_dataset2pt2.py index 49c64c5b..1c3d737a 100644 --- a/examples_old/example_dataset2pt2.py +++ b/examples_old/example_dataset2pt2.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example_dataset2pt2_broken.py b/examples_old/example_dataset2pt2_broken.py index 7c569d72..1dee00af 100644 --- a/examples_old/example_dataset2pt2_broken.py +++ b/examples_old/example_dataset2pt2_broken.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.Base import BaseObj from easyscience.Objects.Base import Parameter diff --git a/examples_old/example_dataset3.py b/examples_old/example_dataset3.py index ad0658f4..ec1420d1 100644 --- a/examples_old/example_dataset3.py +++ b/examples_old/example_dataset3.py @@ -7,7 +7,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example_dataset3pt2.py b/examples_old/example_dataset3pt2.py index 060a0a65..3ad168b0 100644 --- a/examples_old/example_dataset3pt2.py +++ b/examples_old/example_dataset3pt2.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example_dataset4.py b/examples_old/example_dataset4.py index 374ae46b..a1465017 100644 --- a/examples_old/example_dataset4.py +++ b/examples_old/example_dataset4.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/examples_old/example_dataset4_2.py b/examples_old/example_dataset4_2.py index 6ebb4cbf..63ecfe40 100644 --- a/examples_old/example_dataset4_2.py +++ b/examples_old/example_dataset4_2.py @@ -5,7 +5,7 @@ import numpy as np from easyscience.Datasets.xarray import xr -from easyscience.Fitting.Fitting import Fitter +from easyscience.Fitting import Fitter from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter diff --git a/src/easyscience/Fitting/__init__.py b/src/easyscience/Fitting/__init__.py index 9f816dbd..1eb2f1a9 100644 --- a/src/easyscience/Fitting/__init__.py +++ b/src/easyscience/Fitting/__init__.py @@ -1 +1,4 @@ +from .fitter import Fitter # noqa: F401, E402 from .minimizers.fitting_template import FitResults # noqa: F401, E402 +# Causes a circular import +# from .multi_fitter import MultiFitter # noqa: F401, E402 diff --git a/src/easyscience/Fitting/Fitting.py b/src/easyscience/Fitting/fitter.py similarity index 70% rename from src/easyscience/Fitting/Fitting.py rename to src/easyscience/Fitting/fitter.py index 27714a16..59904b2d 100644 --- a/src/easyscience/Fitting/Fitting.py +++ b/src/easyscience/Fitting/fitter.py @@ -20,15 +20,15 @@ import easyscience.Fitting.minimizers as minimizers from easyscience import default_fitting_engine -from easyscience.Objects.Groups import BaseCollection _C = TypeVar('_C', bound=ABCMeta) _M = TypeVar('_M', bound=minimizers.FittingTemplate) if TYPE_CHECKING: - from easyscience.Fitting.minimizers.fitting_template import FitResults as FR from easyscience.Utils.typing import B + from .minimizers.fitting_template import FitResults + class Fitter: """ @@ -245,7 +245,7 @@ def inner_fit_callable( weights: Optional[np.ndarray] = None, vectorized: bool = False, **kwargs, - ) -> FR: + ) -> FitResults: """ This is a wrapped callable which performs the actual fitting. It is split into 3 sections, PRE/ FIT/ POST. @@ -331,7 +331,7 @@ def _precompute_reshaping( return x_for_fit, x_new, y_new, weights, x_shape, kwargs @staticmethod - def _post_compute_reshaping(fit_result: FR, x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> FR: + def _post_compute_reshaping(fit_result: FitResults, x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> FitResults: """ Reshape the output of the fitter into the correct dimensions. :param fit_result: Output from the fitter @@ -344,127 +344,3 @@ def _post_compute_reshaping(fit_result: FR, x: np.ndarray, y: np.ndarray, weight setattr(fit_result, 'y_calc', np.reshape(fit_result.y_calc, y.shape)) setattr(fit_result, 'y_err', np.reshape(fit_result.y_err, y.shape)) return fit_result - - -class MultiFitter(Fitter): - """ - Extension of Fitter to enable multiple dataset/fit function fitting. We can fit these types of data simultaneously: - - Multiple models on multiple datasets. - """ - - def __init__( - self, - fit_objects: Optional[List[B]] = None, - fit_functions: Optional[List[Callable]] = None, - ): - # Create a dummy core object to hold all the fit objects. - self._fit_objects = BaseCollection('multi', *fit_objects) - self._fit_functions = fit_functions - # Initialize with the first of the fit_functions, without this it is - # not possible to change the fitting engine. - super().__init__(self._fit_objects, self._fit_functions[0]) - - def _fit_function_wrapper(self, real_x=None, flatten: bool = True) -> Callable: - """ - Simple fit function which injects the N real X (independent) values into the - optimizer function. This will also flatten the results if needed. - :param real_x: List of independent x parameters to be injected - :param flatten: Should the result be a flat 1D array? - :return: Wrapped optimizer function. - """ - # Extract of a list of callable functions - wrapped_fns = [] - for this_x, this_fun in zip(real_x, self._fit_functions): - self._fit_function = this_fun - wrapped_fns.append(Fitter._fit_function_wrapper(self, this_x, flatten=flatten)) - - def wrapped_fun(x, **kwargs): - # Generate an empty Y based on x - y = np.zeros_like(x) - i = 0 - # Iterate through wrapped functions, passing the WRONG x, the correct - # x was injected in the step above. - for idx, dim in enumerate(self._dependent_dims): - ep = i + np.prod(dim) - y[i:ep] = wrapped_fns[idx](x, **kwargs) - i = ep - return y - - return wrapped_fun - - @staticmethod - def _precompute_reshaping( - x: List[np.ndarray], - y: List[np.ndarray], - weights: Optional[List[np.ndarray]], - vectorized: bool, - kwargs, - ): - """ - Convert an array of X's and Y's to an acceptable shape for fitting. - :param x: List of independent variables. - :param y: List of dependent variables. - :param vectorized: Is the fn input vectorized or point based? - :param kwargs: Additional kwy words. - :return: Variables for optimization - """ - if weights is None: - weights = [None] * len(x) - _, _x_new, _y_new, _weights, _dims, kwargs = Fitter._precompute_reshaping(x[0], y[0], weights[0], vectorized, kwargs) - x_new = [_x_new] - y_new = [_y_new] - w_new = [_weights] - dims = [_dims] - for _x, _y, _w in zip(x[1::], y[1::], weights[1::]): - _, _x_new, _y_new, _weights, _dims, _ = Fitter._precompute_reshaping(_x, _y, _w, vectorized, kwargs) - x_new.append(_x_new) - y_new.append(_y_new) - w_new.append(_weights) - dims.append(_dims) - y_new = np.hstack(y_new) - if w_new[0] is None: - w_new = None - else: - w_new = np.hstack(w_new) - x_fit = np.linspace(0, y_new.size - 1, y_new.size) - return x_fit, x_new, y_new, w_new, dims, kwargs - - def _post_compute_reshaping( - self, - fit_result_obj: FR, - x: List[np.ndarray], - y: List[np.ndarray], - weights: List[np.ndarray], - ) -> List[FR]: - """ - Take a fit results object and split it into n chuncks based on the size of the x, y inputs - :param fit_result_obj: Result from a multifit - :param x: List of X co-ords - :param y: List of Y co-ords - :return: List of fit results - """ - - cls = fit_result_obj.__class__ - sp = 0 - fit_results_list = [] - for idx, this_x in enumerate(x): - # Create a new Results obj - current_results = cls() - ep = sp + int(np.array(self._dependent_dims[idx]).prod()) - - # Fill out the new result obj (see EasyScience.Fitting.Fitting_template.FitResults) - current_results.success = fit_result_obj.success - current_results.fitting_engine = fit_result_obj.fitting_engine - current_results.p = fit_result_obj.p - current_results.p0 = fit_result_obj.p0 - current_results.x = this_x - current_results.y_obs = y[idx] - current_results.y_calc = np.reshape(fit_result_obj.y_calc[sp:ep], current_results.y_obs.shape) - current_results.y_err = np.reshape(fit_result_obj.y_err[sp:ep], current_results.y_obs.shape) - current_results.engine_result = fit_result_obj.engine_result - - # Attach an additional field for the un-modified results - current_results.total_results = fit_result_obj - fit_results_list.append(current_results) - sp = ep - return fit_results_list diff --git a/src/easyscience/Fitting/multi_fitter.py b/src/easyscience/Fitting/multi_fitter.py new file mode 100644 index 00000000..4683a19f --- /dev/null +++ b/src/easyscience/Fitting/multi_fitter.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +__author__ = 'github.com/wardsimon' +__version__ = '0.0.1' + +# SPDX-FileCopyrightText: 2023 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2023 Contributors to the EasyScience project Callable: + """ + Simple fit function which injects the N real X (independent) values into the + optimizer function. This will also flatten the results if needed. + :param real_x: List of independent x parameters to be injected + :param flatten: Should the result be a flat 1D array? + :return: Wrapped optimizer function. + """ + # Extract of a list of callable functions + wrapped_fns = [] + for this_x, this_fun in zip(real_x, self._fit_functions): + self._fit_function = this_fun + wrapped_fns.append(Fitter._fit_function_wrapper(self, this_x, flatten=flatten)) + + def wrapped_fun(x, **kwargs): + # Generate an empty Y based on x + y = np.zeros_like(x) + i = 0 + # Iterate through wrapped functions, passing the WRONG x, the correct + # x was injected in the step above. + for idx, dim in enumerate(self._dependent_dims): + ep = i + np.prod(dim) + y[i:ep] = wrapped_fns[idx](x, **kwargs) + i = ep + return y + + return wrapped_fun + + @staticmethod + def _precompute_reshaping( + x: List[np.ndarray], + y: List[np.ndarray], + weights: Optional[List[np.ndarray]], + vectorized: bool, + kwargs, + ): + """ + Convert an array of X's and Y's to an acceptable shape for fitting. + :param x: List of independent variables. + :param y: List of dependent variables. + :param vectorized: Is the fn input vectorized or point based? + :param kwargs: Additional kwy words. + :return: Variables for optimization + """ + if weights is None: + weights = [None] * len(x) + _, _x_new, _y_new, _weights, _dims, kwargs = Fitter._precompute_reshaping(x[0], y[0], weights[0], vectorized, kwargs) + x_new = [_x_new] + y_new = [_y_new] + w_new = [_weights] + dims = [_dims] + for _x, _y, _w in zip(x[1::], y[1::], weights[1::]): + _, _x_new, _y_new, _weights, _dims, _ = Fitter._precompute_reshaping(_x, _y, _w, vectorized, kwargs) + x_new.append(_x_new) + y_new.append(_y_new) + w_new.append(_weights) + dims.append(_dims) + y_new = np.hstack(y_new) + if w_new[0] is None: + w_new = None + else: + w_new = np.hstack(w_new) + x_fit = np.linspace(0, y_new.size - 1, y_new.size) + return x_fit, x_new, y_new, w_new, dims, kwargs + + def _post_compute_reshaping( + self, + fit_result_obj: FitResults, + x: List[np.ndarray], + y: List[np.ndarray], + weights: List[np.ndarray], + ) -> List[FitResults]: + """ + Take a fit results object and split it into n chuncks based on the size of the x, y inputs + :param fit_result_obj: Result from a multifit + :param x: List of X co-ords + :param y: List of Y co-ords + :return: List of fit results + """ + + cls = fit_result_obj.__class__ + sp = 0 + fit_results_list = [] + for idx, this_x in enumerate(x): + # Create a new Results obj + current_results = cls() + ep = sp + int(np.array(self._dependent_dims[idx]).prod()) + + # Fill out the new result obj (see EasyScience.Fitting.Fitting_template.FitResults) + current_results.success = fit_result_obj.success + current_results.fitting_engine = fit_result_obj.fitting_engine + current_results.p = fit_result_obj.p + current_results.p0 = fit_result_obj.p0 + current_results.x = this_x + current_results.y_obs = y[idx] + current_results.y_calc = np.reshape(fit_result_obj.y_calc[sp:ep], current_results.y_obs.shape) + current_results.y_err = np.reshape(fit_result_obj.y_err[sp:ep], current_results.y_obs.shape) + current_results.engine_result = fit_result_obj.engine_result + + # Attach an additional field for the un-modified results + current_results.total_results = fit_result_obj + fit_results_list.append(current_results) + sp = ep + return fit_results_list diff --git a/src/easyscience/Objects/Inferface.py b/src/easyscience/Objects/Inferface.py index bc9c3eb0..2ff89dd0 100644 --- a/src/easyscience/Objects/Inferface.py +++ b/src/easyscience/Objects/Inferface.py @@ -4,8 +4,8 @@ # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project 0: # Fallback name interface_name = self.return_name(self._interfaces[0]) else: raise NotImplementedError else: - interface_name = kwargs.pop("interface_name") + interface_name = kwargs.pop('interface_name') interfaces = self.available_interfaces if interface_name in interfaces: self._current_interface = self._interfaces[interfaces.index(interface_name)] @@ -74,20 +74,20 @@ def switch(self, new_interface: str, fitter: Optional[Type[Fitter]] = None): self._current_interface = self._interfaces[interfaces.index(new_interface)] self.__interface_obj = self._current_interface() else: - raise AttributeError("The user supplied interface is not valid.") + raise AttributeError('The user supplied interface is not valid.') if fitter is not None: - if hasattr(fitter, "_fit_object"): - obj = getattr(fitter, "_fit_object") + if hasattr(fitter, '_fit_object'): + obj = getattr(fitter, '_fit_object') try: - if hasattr(obj, "update_bindings"): + if hasattr(obj, 'update_bindings'): obj.update_bindings() except Exception as e: - print(f"Unable to auto generate bindings.\n{e}") - elif hasattr(fitter, "generate_bindings"): + print(f'Unable to auto generate bindings.\n{e}') + elif hasattr(fitter, 'generate_bindings'): try: fitter.generate_bindings() except Exception as e: - print(f"Unable to auto generate bindings.\n{e}") + print(f'Unable to auto generate bindings.\n{e}') @property def available_interfaces(self) -> List[str]: @@ -189,8 +189,8 @@ def return_name(this_interface) -> str: Return an interfaces name """ interface_name = this_interface.__name__ - if hasattr(this_interface, "name"): - interface_name = getattr(this_interface, "name") + if hasattr(this_interface, 'name'): + interface_name = getattr(this_interface, 'name') return interface_name @@ -225,4 +225,4 @@ def set_value(value): return set_value -iF = TypeVar("iF", bound=InterfaceFactoryTemplate) +iF = TypeVar('iF', bound=InterfaceFactoryTemplate) diff --git a/tests/integration_tests/test_undoRedo.py b/tests/integration_tests/test_undoRedo.py index 3c55bc42..a2e3cd82 100644 --- a/tests/integration_tests/test_undoRedo.py +++ b/tests/integration_tests/test_undoRedo.py @@ -265,7 +265,7 @@ def __call__(self, x: np.ndarray) -> np.ndarray: y = l1(x) + 0.125 * (dy - 0.5) - from easyscience.Fitting.Fitting import Fitter + from easyscience.Fitting import Fitter f = Fitter(l2, l2) try: diff --git a/tests/unit_tests/Fitting/test_fitting.py b/tests/unit_tests/Fitting/test_fitting.py index 8aa2c907..702fba23 100644 --- a/tests/unit_tests/Fitting/test_fitting.py +++ b/tests/unit_tests/Fitting/test_fitting.py @@ -9,8 +9,8 @@ import numpy as np from easyscience.Fitting.Constraints import ObjConstraint -from easyscience.Fitting.Fitting import Fitter -from easyscience.Fitting.Fitting import MultiFitter +from easyscience.Fitting.fitter import Fitter +from easyscience.Fitting.multi_fitter import MultiFitter from easyscience.Fitting.minimizers import FitError from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter From 22c31d58a88f2648b923b08532e6a133f285be37 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Wed, 5 Jun 2024 14:56:26 +0200 Subject: [PATCH 03/10] code clarifications --- src/easyscience/Fitting/fitter.py | 6 ++++-- src/easyscience/Fitting/multi_fitter.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/easyscience/Fitting/fitter.py b/src/easyscience/Fitting/fitter.py index 59904b2d..c22e2c29 100644 --- a/src/easyscience/Fitting/fitter.py +++ b/src/easyscience/Fitting/fitter.py @@ -21,8 +21,10 @@ import easyscience.Fitting.minimizers as minimizers from easyscience import default_fitting_engine +from .minimizers import FittingTemplate + _C = TypeVar('_C', bound=ABCMeta) -_M = TypeVar('_M', bound=minimizers.FittingTemplate) +_M = TypeVar('_M', bound=FittingTemplate) if TYPE_CHECKING: from easyscience.Utils.typing import B @@ -56,7 +58,7 @@ def __init__(self, fit_object: Optional[B] = None, fit_function: Optional[Callab fit_methods = [ x - for x, y in minimizers.FittingTemplate.__dict__.items() + for x, y in FittingTemplate.__dict__.items() if (isinstance(y, FunctionType) and not x.startswith('_')) and x != 'fit' ] for method_name in fit_methods: diff --git a/src/easyscience/Fitting/multi_fitter.py b/src/easyscience/Fitting/multi_fitter.py index 4683a19f..825b76e9 100644 --- a/src/easyscience/Fitting/multi_fitter.py +++ b/src/easyscience/Fitting/multi_fitter.py @@ -15,13 +15,13 @@ import numpy as np -import easyscience.Fitting.minimizers as minimizers from easyscience.Objects.Groups import BaseCollection from .fitter import Fitter +from .minimizers import FittingTemplate _C = TypeVar('_C', bound=ABCMeta) -_M = TypeVar('_M', bound=minimizers.FittingTemplate) +_M = TypeVar('_M', bound=FittingTemplate) if TYPE_CHECKING: from easyscience.Utils.typing import B From 697280bb570ed1e6093ce85b66eea84e1406ee78 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Thu, 6 Jun 2024 08:07:27 +0200 Subject: [PATCH 04/10] splitting up fitting tests --- .../integration_tests/Fitting/test_fitter.py | 278 ++++++++++++++++ .../Fitting/test_multi_fitter.py} | 310 +++++------------- 2 files changed, 352 insertions(+), 236 deletions(-) create mode 100644 tests/integration_tests/Fitting/test_fitter.py rename tests/{unit_tests/Fitting/test_fitting.py => integration_tests/Fitting/test_multi_fitter.py} (54%) diff --git a/tests/integration_tests/Fitting/test_fitter.py b/tests/integration_tests/Fitting/test_fitter.py new file mode 100644 index 00000000..7e3f10c1 --- /dev/null +++ b/tests/integration_tests/Fitting/test_fitter.py @@ -0,0 +1,278 @@ +# SPDX-FileCopyrightText: 2023 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +# © 2021-2023 Contributors to the EasyScience project 0 % This does not work as some methods don't calculate error + assert item1.error == pytest.approx(0, abs=1e-1) + assert item1.raw_value == pytest.approx(item2.raw_value, abs=5e-3) + y_calc_ref = ref_sin(x) + assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) + assert result.residual == pytest.approx(sp_sin(x) - y_calc_ref, abs=1e-2) + + +@pytest.mark.parametrize("with_errors", [False, True]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +def test_basic_fit(fit_engine, with_errors): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + if fit_engine is not None: + try: + f.switch_engine(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + args = [x, y] + kwargs = {} + if with_errors: + kwargs["weights"] = 1 / np.sqrt(y) + result = f.fit(*args, **kwargs) + + if fit_engine is not None: + assert result.fitting_engine.name == fit_engine + assert sp_sin.phase.raw_value == pytest.approx(ref_sin.phase.raw_value, rel=1e-3) + assert sp_sin.offset.raw_value == pytest.approx(ref_sin.offset.raw_value, rel=1e-3) + + +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +def test_fit_result(fit_engine): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + sp_ref1 = { + f"p{sp_sin._borg.map.convert_id_to_key(item1)}": item1.raw_value + for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) + } + sp_ref2 = { + f"p{sp_sin._borg.map.convert_id_to_key(item1)}": item2.raw_value + for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) + } + + f = Fitter(sp_sin, sp_sin) + + if fit_engine is not None: + try: + f.switch_engine(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + + result = f.fit(x, y) + check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2) + + +@pytest.mark.parametrize("fit_method", ["leastsq", "powell", "cobyla"]) +def test_lmfit_methods(fit_method): + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + assert fit_method in f.available_methods() + result = f.fit(x, y, method=fit_method) + check_fit_results(result, sp_sin, ref_sin, x) + + +@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) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + f.switch_engine("bumps") + assert fit_method in f.available_methods() + result = f.fit(x, y, method=fit_method) + check_fit_results(result, sp_sin, ref_sin, x) + + +@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "DFO_LS"]) +def test_fit_constraints(fit_engine): + ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) + sp_sin = AbsSin(1, 0.5) + + x = np.linspace(0, 5, 200) + y = ref_sin(x) + + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + + assert len(f.fit_constraints()) == 0 + c = ObjConstraint(sp_sin.offset, "2*", sp_sin.phase) + f.add_fit_constraint(c) + + if fit_engine is not None: + try: + f.switch_engine(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + + result = f.fit(x, y) + check_fit_results(result, sp_sin, ref_sin, x) + assert len(f.fit_constraints()) == 1 + f.remove_fit_constraint(0) + assert len(f.fit_constraints()) == 0 + + +@pytest.mark.parametrize("with_errors", [False, True]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +def test_2D_vectorized(fit_engine, with_errors): + 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 :-( + X, Y = np.meshgrid(x, x) + XY = np.stack((X, Y), axis=2) + ff = Fitter(m2, m2) + if fit_engine is not None: + try: + ff.switch_engine(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + try: + args = [XY, mm(XY)] + kwargs = {"vectorized": True} + if with_errors: + kwargs["weights"] = 1 / np.sqrt(args[1]) + result = ff.fit(*args, **kwargs) + except FitError as e: + if "Unable to allocate" in str(e): + pytest.skip(msg="MemoryError - Matrix too large") + else: + raise e + assert result.n_pars == len(m2.get_fit_parameters()) + assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) + assert result.success + assert np.all(result.x == XY) + y_calc_ref = m2(XY) + assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) + assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) + + +@pytest.mark.parametrize("with_errors", [False, True]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +def test_2D_non_vectorized(fit_engine, with_errors): + 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 :-( + X, Y = np.meshgrid(x, x) + XY = np.stack((X, Y), axis=2) + ff = Fitter(m2, m2) + if fit_engine is not None: + try: + ff.switch_engine(fit_engine) + except AttributeError: + pytest.skip(msg=f"{fit_engine} is not installed") + try: + args = [XY, mm(XY.reshape(-1, 2))] + kwargs = {"vectorized": False} + if with_errors: + kwargs["weights"] = 1 / np.sqrt(args[1]) + result = ff.fit(*args, **kwargs) + except FitError as e: + if "Unable to allocate" in str(e): + pytest.skip(msg="MemoryError - Matrix too large") + else: + raise e + assert result.n_pars == len(m2.get_fit_parameters()) + assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) + assert result.success + assert np.all(result.x == XY) + y_calc_ref = m2(XY.reshape(-1, 2)) + assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) + assert result.residual == pytest.approx( + mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 + ) \ No newline at end of file diff --git a/tests/unit_tests/Fitting/test_fitting.py b/tests/integration_tests/Fitting/test_multi_fitter.py similarity index 54% rename from tests/unit_tests/Fitting/test_fitting.py rename to tests/integration_tests/Fitting/test_multi_fitter.py index 702fba23..78eb58cc 100644 --- a/tests/unit_tests/Fitting/test_fitting.py +++ b/tests/integration_tests/Fitting/test_multi_fitter.py @@ -9,14 +9,12 @@ import numpy as np from easyscience.Fitting.Constraints import ObjConstraint -from easyscience.Fitting.fitter import Fitter from easyscience.Fitting.multi_fitter import MultiFitter from easyscience.Fitting.minimizers import FitError from easyscience.Objects.ObjectClasses import BaseObj from easyscience.Objects.ObjectClasses import Parameter - class Line(BaseObj): m: Parameter c: Parameter @@ -60,166 +58,6 @@ def __call__(self, x): ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) -class AbsSin2DL(AbsSin2D): - def __call__(self, x): - X = x[:, 0] # x is a 1D array - Y = x[:, 1] - return np.abs( - np.sin(self.phase.raw_value * X + self.offset.raw_value) - ) * np.abs(np.sin(self.phase.raw_value * Y + self.offset.raw_value)) - - -@pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_basic_fit(fit_engine, with_errors): - ref_sin = AbsSin(0.2, np.pi) - sp_sin = AbsSin(0.354, 3.05) - - x = np.linspace(0, 5, 200) - y = ref_sin(x) - - sp_sin.offset.fixed = False - sp_sin.phase.fixed = False - - f = Fitter(sp_sin, sp_sin) - if fit_engine is not None: - try: - f.switch_engine(fit_engine) - except AttributeError: - pytest.skip(msg=f"{fit_engine} is not installed") - args = [x, y] - kwargs = {} - if with_errors: - kwargs["weights"] = 1 / np.sqrt(y) - result = f.fit(*args, **kwargs) - - if fit_engine is not None: - assert result.fitting_engine.name == fit_engine - assert sp_sin.phase.raw_value == pytest.approx(ref_sin.phase.raw_value, rel=1e-3) - assert sp_sin.offset.raw_value == pytest.approx(ref_sin.offset.raw_value, rel=1e-3) - - -def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): - assert result.n_pars == len(sp_sin.get_fit_parameters()) - assert result.chi2 == pytest.approx(0, abs=1.5e-3 * (len(result.x) - result.n_pars)) - assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) - assert result.success - if "sp_ref1" in kwargs.keys(): - sp_ref1 = kwargs["sp_ref1"] - for key, value in sp_ref1.items(): - assert key in result.p.keys() - assert key in result.p0.keys() - assert result.p0[key] == pytest.approx( - value - ) # Bumps does something strange here - assert np.all(result.x == x) - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()): - # assert item.error > 0 % This does not work as some methods don't calculate error - assert item1.error == pytest.approx(0, abs=1e-1) - assert item1.raw_value == pytest.approx(item2.raw_value, abs=5e-3) - y_calc_ref = ref_sin(x) - assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) - assert result.residual == pytest.approx(sp_sin(x) - y_calc_ref, abs=1e-2) - - -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_fit_result(fit_engine): - ref_sin = AbsSin(0.2, np.pi) - sp_sin = AbsSin(0.354, 3.05) - - x = np.linspace(0, 5, 200) - y = ref_sin(x) - - sp_sin.offset.fixed = False - sp_sin.phase.fixed = False - - sp_ref1 = { - f"p{sp_sin._borg.map.convert_id_to_key(item1)}": item1.raw_value - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) - } - sp_ref2 = { - f"p{sp_sin._borg.map.convert_id_to_key(item1)}": item2.raw_value - for item1, item2 in zip(sp_sin._kwargs.values(), ref_sin._kwargs.values()) - } - - f = Fitter(sp_sin, sp_sin) - - if fit_engine is not None: - try: - f.switch_engine(fit_engine) - except AttributeError: - pytest.skip(msg=f"{fit_engine} is not installed") - - result = f.fit(x, y) - check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2) - - -@pytest.mark.parametrize("fit_method", ["leastsq", "powell", "cobyla"]) -def test_lmfit_methods(fit_method): - ref_sin = AbsSin(0.2, np.pi) - sp_sin = AbsSin(0.354, 3.05) - - x = np.linspace(0, 5, 200) - y = ref_sin(x) - - sp_sin.offset.fixed = False - sp_sin.phase.fixed = False - - f = Fitter(sp_sin, sp_sin) - assert fit_method in f.available_methods() - result = f.fit(x, y, method=fit_method) - check_fit_results(result, sp_sin, ref_sin, x) - - -@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) - sp_sin = AbsSin(0.354, 3.05) - - x = np.linspace(0, 5, 200) - y = ref_sin(x) - - sp_sin.offset.fixed = False - sp_sin.phase.fixed = False - - f = Fitter(sp_sin, sp_sin) - f.switch_engine("bumps") - assert fit_method in f.available_methods() - result = f.fit(x, y, method=fit_method) - check_fit_results(result, sp_sin, ref_sin, x) - - -@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "DFO_LS"]) -def test_fit_constraints(fit_engine): - ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) - sp_sin = AbsSin(1, 0.5) - - x = np.linspace(0, 5, 200) - y = ref_sin(x) - - sp_sin.phase.fixed = False - - f = Fitter(sp_sin, sp_sin) - - assert len(f.fit_constraints()) == 0 - c = ObjConstraint(sp_sin.offset, "2*", sp_sin.phase) - f.add_fit_constraint(c) - - if fit_engine is not None: - try: - f.switch_engine(fit_engine) - except AttributeError: - pytest.skip(msg=f"{fit_engine} is not installed") - - result = f.fit(x, y) - check_fit_results(result, sp_sin, ref_sin, x) - assert len(f.fit_constraints()) == 1 - f.remove_fit_constraint(0) - assert len(f.fit_constraints()) == 0 - - - @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) def test_multi_fit(fit_engine, with_errors): @@ -359,80 +197,6 @@ def test_multi_fit2(fit_engine, with_errors): ) -@pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_2D_vectorized(fit_engine, with_errors): - 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 :-( - X, Y = np.meshgrid(x, x) - XY = np.stack((X, Y), axis=2) - ff = Fitter(m2, m2) - if fit_engine is not None: - try: - ff.switch_engine(fit_engine) - except AttributeError: - pytest.skip(msg=f"{fit_engine} is not installed") - try: - args = [XY, mm(XY)] - kwargs = {"vectorized": True} - if with_errors: - kwargs["weights"] = 1 / np.sqrt(args[1]) - result = ff.fit(*args, **kwargs) - except FitError as e: - if "Unable to allocate" in str(e): - pytest.skip(msg="MemoryError - Matrix too large") - else: - raise e - assert result.n_pars == len(m2.get_fit_parameters()) - assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) - assert result.success - assert np.all(result.x == XY) - y_calc_ref = m2(XY) - assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) - assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) - - -@pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -def test_2D_non_vectorized(fit_engine, with_errors): - 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 :-( - X, Y = np.meshgrid(x, x) - XY = np.stack((X, Y), axis=2) - ff = Fitter(m2, m2) - if fit_engine is not None: - try: - ff.switch_engine(fit_engine) - except AttributeError: - pytest.skip(msg=f"{fit_engine} is not installed") - try: - args = [XY, mm(XY.reshape(-1, 2))] - kwargs = {"vectorized": False} - if with_errors: - kwargs["weights"] = 1 / np.sqrt(args[1]) - result = ff.fit(*args, **kwargs) - except FitError as e: - if "Unable to allocate" in str(e): - pytest.skip(msg="MemoryError - Matrix too large") - else: - raise e - assert result.n_pars == len(m2.get_fit_parameters()) - assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) - assert result.success - assert np.all(result.x == XY) - y_calc_ref = m2(XY.reshape(-1, 2)) - assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) - assert result.residual == pytest.approx( - mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 - ) - - @pytest.mark.parametrize("with_errors", [False, True]) @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) def test_multi_fit_1D_2D(fit_engine, with_errors): @@ -513,3 +277,77 @@ def test_multi_fit_1D_2D(fit_engine, with_errors): assert result.residual == pytest.approx( F_real[idx](X[idx]) - F_ref[idx](X[idx]), abs=1e-2 ) + + +# @pytest.mark.parametrize("with_errors", [False, True]) +# @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +# def test_2D_vectorized(fit_engine, with_errors): +# 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 :-( +# X, Y = np.meshgrid(x, x) +# XY = np.stack((X, Y), axis=2) +# ff = Fitter(m2, m2) +# if fit_engine is not None: +# try: +# ff.switch_engine(fit_engine) +# except AttributeError: +# pytest.skip(msg=f"{fit_engine} is not installed") +# try: +# args = [XY, mm(XY)] +# kwargs = {"vectorized": True} +# if with_errors: +# kwargs["weights"] = 1 / np.sqrt(args[1]) +# result = ff.fit(*args, **kwargs) +# except FitError as e: +# if "Unable to allocate" in str(e): +# pytest.skip(msg="MemoryError - Matrix too large") +# else: +# raise e +# assert result.n_pars == len(m2.get_fit_parameters()) +# assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) +# assert result.success +# assert np.all(result.x == XY) +# y_calc_ref = m2(XY) +# assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) +# assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) + + +# @pytest.mark.parametrize("with_errors", [False, True]) +# @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +# def test_2D_non_vectorized(fit_engine, with_errors): +# 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 :-( +# X, Y = np.meshgrid(x, x) +# XY = np.stack((X, Y), axis=2) +# ff = Fitter(m2, m2) +# if fit_engine is not None: +# try: +# ff.switch_engine(fit_engine) +# except AttributeError: +# pytest.skip(msg=f"{fit_engine} is not installed") +# try: +# args = [XY, mm(XY.reshape(-1, 2))] +# kwargs = {"vectorized": False} +# if with_errors: +# kwargs["weights"] = 1 / np.sqrt(args[1]) +# result = ff.fit(*args, **kwargs) +# except FitError as e: +# if "Unable to allocate" in str(e): +# pytest.skip(msg="MemoryError - Matrix too large") +# else: +# raise e +# assert result.n_pars == len(m2.get_fit_parameters()) +# assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) +# assert result.success +# assert np.all(result.x == XY) +# y_calc_ref = m2(XY.reshape(-1, 2)) +# assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) +# assert result.residual == pytest.approx( +# mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 +# ) \ No newline at end of file From 7ee33647bc5290aec451b21c86bada6741fc2df1 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Thu, 6 Jun 2024 09:11:15 +0200 Subject: [PATCH 05/10] cleaning --- src/easyscience/Fitting/__init__.py | 2 +- src/easyscience/Fitting/fitter.py | 23 +-- .../Fitting/minimizers/__init__.py | 11 +- src/easyscience/Fitting/minimizers/bumps.py | 14 +- .../Fitting/minimizers/{DFO_LS.py => dfo.py} | 14 +- .../{fitting_template.py => fitting_base.py} | 157 +++++++++--------- src/easyscience/Fitting/minimizers/lmfit.py | 14 +- src/easyscience/Fitting/minimizers/utils.py | 79 +++++++++ src/easyscience/Fitting/multi_fitter.py | 17 +- 9 files changed, 196 insertions(+), 135 deletions(-) rename src/easyscience/Fitting/minimizers/{DFO_LS.py => dfo.py} (97%) rename src/easyscience/Fitting/minimizers/{fitting_template.py => fitting_base.py} (76%) create mode 100644 src/easyscience/Fitting/minimizers/utils.py diff --git a/src/easyscience/Fitting/__init__.py b/src/easyscience/Fitting/__init__.py index 1eb2f1a9..d069d745 100644 --- a/src/easyscience/Fitting/__init__.py +++ b/src/easyscience/Fitting/__init__.py @@ -1,4 +1,4 @@ from .fitter import Fitter # noqa: F401, E402 -from .minimizers.fitting_template import FitResults # noqa: F401, E402 +from .minimizers.fitting_base import FitResults # noqa: F401, E402 # Causes a circular import # from .multi_fitter import MultiFitter # noqa: F401, E402 diff --git a/src/easyscience/Fitting/fitter.py b/src/easyscience/Fitting/fitter.py index c22e2c29..e5e0233b 100644 --- a/src/easyscience/Fitting/fitter.py +++ b/src/easyscience/Fitting/fitter.py @@ -1,5 +1,3 @@ -from __future__ import annotations - __author__ = 'github.com/wardsimon' __version__ = '0.0.1' @@ -10,7 +8,6 @@ # © 2021-2023 Contributors to the EasyScience project B: + def fit_object(self): """ The EasyScience object which will be used as a model :return: EasyScience Model @@ -205,7 +198,7 @@ def fit_object(self) -> B: return self._fit_object @fit_object.setter - def fit_object(self, fit_object: B): + def fit_object(self, fit_object): """ Set the EasyScience object which wil be used as a model :param fit_object: New EasyScience object diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py index e284e640..eee608be 100644 --- a/src/easyscience/Fitting/minimizers/__init__.py +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -7,8 +7,6 @@ import warnings -from .fitting_template import FitError # noqa: F401, E402 - imported = -1 try: from .lmfit import lmfit # noqa: F401, E402 @@ -25,13 +23,16 @@ # TODO make this a proper message (use logging?) warnings.warn('bumps has not been installed.', ImportWarning, stacklevel=2) try: - from .DFO_LS import DFO # noqa: F401, E402 + from .dfo import DFO # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('dfo-ls has not been installed.', ImportWarning, stacklevel=2) -from .fitting_template import FittingTemplate # noqa: E402 +from .fitting_base import FittingBase # noqa: E402 + +engines: list = FittingBase._engines -engines: list = FittingTemplate._engines +from .utils import FitError # noqa: F401, E402 +from .utils import FitResults # noqa: F401, E402 diff --git a/src/easyscience/Fitting/minimizers/bumps.py b/src/easyscience/Fitting/minimizers/bumps.py index 66467f06..8768cb29 100644 --- a/src/easyscience/Fitting/minimizers/bumps.py +++ b/src/easyscience/Fitting/minimizers/bumps.py @@ -6,24 +6,24 @@ __version__ = '0.1.0' import inspect +from typing import Callable from typing import List from typing import Optional +import numpy as np from bumps.fitters import FIT_AVAILABLE_IDS from bumps.fitters import fit as bumps_fit from bumps.names import Curve from bumps.names import FitProblem from bumps.parameter import Parameter as bumpsParameter -from .fitting_template import Callable -from .fitting_template import FitError -from .fitting_template import FitResults -from .fitting_template import FittingTemplate -from .fitting_template import NameConverter -from .fitting_template import np +from .fitting_base import FittingBase +from .utils import FitError +from .utils import FitResults +from .utils import NameConverter -class bumps(FittingTemplate): # noqa: S101 +class bumps(FittingBase): # noqa: S101 """ This is a wrapper to bumps: https://bumps.readthedocs.io/ It allows for the bumps fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. diff --git a/src/easyscience/Fitting/minimizers/DFO_LS.py b/src/easyscience/Fitting/minimizers/dfo.py similarity index 97% rename from src/easyscience/Fitting/minimizers/DFO_LS.py rename to src/easyscience/Fitting/minimizers/dfo.py index 3987f155..86292c91 100644 --- a/src/easyscience/Fitting/minimizers/DFO_LS.py +++ b/src/easyscience/Fitting/minimizers/dfo.py @@ -6,21 +6,21 @@ __version__ = '0.1.0' from numbers import Number +from typing import Callable from typing import List from typing import Optional # Import dfols specific objects import dfols +import numpy as np -from .fitting_template import Callable -from .fitting_template import FitError -from .fitting_template import FitResults -from .fitting_template import FittingTemplate -from .fitting_template import NameConverter -from .fitting_template import np +from .fitting_base import FittingBase +from .utils import FitError +from .utils import FitResults +from .utils import NameConverter -class DFO(FittingTemplate): # noqa: S101 +class DFO(FittingBase): # noqa: S101 """ This is a wrapper to Derivative free optimisation: https://numericalalgorithmsgroup.github.io/dfols/ """ diff --git a/src/easyscience/Fitting/minimizers/fitting_template.py b/src/easyscience/Fitting/minimizers/fitting_base.py similarity index 76% rename from src/easyscience/Fitting/minimizers/fitting_template.py rename to src/easyscience/Fitting/minimizers/fitting_base.py index 22d11c99..a7a43773 100644 --- a/src/easyscience/Fitting/minimizers/fitting_template.py +++ b/src/easyscience/Fitting/minimizers/fitting_base.py @@ -1,7 +1,6 @@ __author__ = 'github.com/wardsimon' __version__ = '0.1.0' - # SPDX-FileCopyrightText: 2023 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project str: - return getattr(self._borg.map.get_item_by_key(item_key), 'name', '') - - def get_item_from_key(self, item_key: int) -> object: - return self._borg.map.get_item_by_key(item_key) - - def get_key(self, item: object) -> int: - return self._borg.map.convert_id_to_key(item) - - -class FitError(Exception): - def __init__(self, e: Exception = None): - self.e = e - - def __str__(self) -> str: - s = '' - if self.e is not None: - s = f'{self.e}\n' - return s + 'Something has gone wrong with the fit' +# class FitResults: +# """ +# At the moment this is just a dummy way of unifying the returned fit parameters. +# """ + +# __slots__ = [ +# 'success', +# 'fitting_engine', +# 'fit_args', +# 'p', +# 'p0', +# 'x', +# 'x_matrices', +# 'y_obs', +# 'y_calc', +# 'y_err', +# 'engine_result', +# 'total_results', +# ] + +# def __init__(self): +# self.success = False +# self.fitting_engine = None +# self.fit_args = {} +# self.p = {} +# self.p0 = {} +# self.x = np.ndarray([]) +# self.x_matrices = np.ndarray([]) +# self.y_obs = np.ndarray([]) +# self.y_calc = np.ndarray([]) +# self.y_err = np.ndarray([]) +# self.engine_result = None +# self.total_results = None + +# @property +# def n_pars(self): +# return len(self.p) + +# @property +# def residual(self): +# return self.y_obs - self.y_calc + +# @property +# def chi2(self): +# return ((self.residual / self.y_err) ** 2).sum() + +# @property +# def reduced_chi(self): +# return self.chi2 / (len(self.x) - self.n_pars) + + +# class NameConverter: +# def __init__(self): +# from easyscience import borg + +# self._borg = borg + +# def get_name_from_key(self, item_key: int) -> str: +# return getattr(self._borg.map.get_item_by_key(item_key), 'name', '') + +# def get_item_from_key(self, item_key: int) -> object: +# return self._borg.map.get_item_by_key(item_key) + +# def get_key(self, item: object) -> int: +# return self._borg.map.convert_id_to_key(item) + + +# class FitError(Exception): +# def __init__(self, e: Exception = None): +# self.e = e + +# def __str__(self) -> str: +# s = '' +# if self.e is not None: +# s = f'{self.e}\n' +# return s + 'Something has gone wrong with the fit' diff --git a/src/easyscience/Fitting/minimizers/lmfit.py b/src/easyscience/Fitting/minimizers/lmfit.py index 7bac002e..68e78909 100644 --- a/src/easyscience/Fitting/minimizers/lmfit.py +++ b/src/easyscience/Fitting/minimizers/lmfit.py @@ -6,9 +6,11 @@ __version__ = '0.1.0' import inspect +from typing import Callable from typing import List from typing import Optional +import numpy as np from lmfit import Model as lmModel # Import lmfit specific objects @@ -16,15 +18,13 @@ from lmfit import Parameters as lmParameters from lmfit.model import ModelResult -from .fitting_template import Callable -from .fitting_template import FitError -from .fitting_template import FitResults -from .fitting_template import FittingTemplate -from .fitting_template import NameConverter -from .fitting_template import np +from .fitting_base import FittingBase +from .utils import FitError +from .utils import FitResults +from .utils import NameConverter -class lmfit(FittingTemplate): # noqa: S101 +class lmfit(FittingBase): # noqa: S101 """ This is a wrapper to lmfit: https://lmfit.github.io/ It allows for the lmfit fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. diff --git a/src/easyscience/Fitting/minimizers/utils.py b/src/easyscience/Fitting/minimizers/utils.py new file mode 100644 index 00000000..ff56e87a --- /dev/null +++ b/src/easyscience/Fitting/minimizers/utils.py @@ -0,0 +1,79 @@ +import numpy as np + + +class FitResults: + """ + At the moment this is just a dummy way of unifying the returned fit parameters. + """ + + __slots__ = [ + 'success', + 'fitting_engine', + 'fit_args', + 'p', + 'p0', + 'x', + 'x_matrices', + 'y_obs', + 'y_calc', + 'y_err', + 'engine_result', + 'total_results', + ] + + def __init__(self): + self.success = False + self.fitting_engine = None + self.fit_args = {} + self.p = {} + self.p0 = {} + self.x = np.ndarray([]) + self.x_matrices = np.ndarray([]) + self.y_obs = np.ndarray([]) + self.y_calc = np.ndarray([]) + self.y_err = np.ndarray([]) + self.engine_result = None + self.total_results = None + + @property + def n_pars(self): + return len(self.p) + + @property + def residual(self): + return self.y_obs - self.y_calc + + @property + def chi2(self): + return ((self.residual / self.y_err) ** 2).sum() + + @property + def reduced_chi(self): + return self.chi2 / (len(self.x) - self.n_pars) + + +class NameConverter: + def __init__(self): + from easyscience import borg + + self._borg = borg + + def get_name_from_key(self, item_key: int) -> str: + return getattr(self._borg.map.get_item_by_key(item_key), 'name', '') + + def get_item_from_key(self, item_key: int) -> object: + return self._borg.map.get_item_by_key(item_key) + + def get_key(self, item: object) -> int: + return self._borg.map.convert_id_to_key(item) + + +class FitError(Exception): + def __init__(self, e: Exception = None): + self.e = e + + def __str__(self) -> str: + s = '' + if self.e is not None: + s = f'{self.e}\n' + return s + 'Something has gone wrong with the fit' diff --git a/src/easyscience/Fitting/multi_fitter.py b/src/easyscience/Fitting/multi_fitter.py index 825b76e9..f7f89fd8 100644 --- a/src/easyscience/Fitting/multi_fitter.py +++ b/src/easyscience/Fitting/multi_fitter.py @@ -1,32 +1,19 @@ -from __future__ import annotations - __author__ = 'github.com/wardsimon' __version__ = '0.0.1' # SPDX-FileCopyrightText: 2023 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause # © 2021-2023 Contributors to the EasyScience project Date: Thu, 6 Jun 2024 09:14:56 +0200 Subject: [PATCH 06/10] cleaning --- src/easyscience/Fitting/minimizers/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py index eee608be..594122c2 100644 --- a/src/easyscience/Fitting/minimizers/__init__.py +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -7,6 +7,10 @@ import warnings +from .fitting_base import FittingBase # noqa: E402 +from .utils import FitError # noqa: F401, E402 +from .utils import FitResults # noqa: F401, E402 + imported = -1 try: from .lmfit import lmfit # noqa: F401, E402 @@ -30,9 +34,4 @@ # TODO make this a proper message (use logging?) warnings.warn('dfo-ls has not been installed.', ImportWarning, stacklevel=2) -from .fitting_base import FittingBase # noqa: E402 - engines: list = FittingBase._engines - -from .utils import FitError # noqa: F401, E402 -from .utils import FitResults # noqa: F401, E402 From 36224548860ac2f35fdf6f5038122ecad2f5786a Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Thu, 6 Jun 2024 09:38:47 +0200 Subject: [PATCH 07/10] cleaning --- src/easyscience/Fitting/__init__.py | 5 ++--- src/easyscience/Fitting/fitter.py | 6 +++--- src/easyscience/Fitting/minimizers/__init__.py | 10 +++++----- .../Fitting/minimizers/{bumps.py => engine_bumps.py} | 4 ++-- .../Fitting/minimizers/{dfo.py => engine_dfo.py} | 5 ++--- .../Fitting/minimizers/{lmfit.py => engine_lmfit.py} | 6 ++---- .../minimizers/{fitting_base.py => minimizer_base.py} | 2 +- 7 files changed, 17 insertions(+), 21 deletions(-) rename src/easyscience/Fitting/minimizers/{bumps.py => engine_bumps.py} (99%) rename src/easyscience/Fitting/minimizers/{dfo.py => engine_dfo.py} (98%) rename src/easyscience/Fitting/minimizers/{lmfit.py => engine_lmfit.py} (99%) rename src/easyscience/Fitting/minimizers/{fitting_base.py => minimizer_base.py} (99%) diff --git a/src/easyscience/Fitting/__init__.py b/src/easyscience/Fitting/__init__.py index d069d745..4c4f3ac1 100644 --- a/src/easyscience/Fitting/__init__.py +++ b/src/easyscience/Fitting/__init__.py @@ -1,4 +1,3 @@ from .fitter import Fitter # noqa: F401, E402 -from .minimizers.fitting_base import FitResults # noqa: F401, E402 -# Causes a circular import -# from .multi_fitter import MultiFitter # noqa: F401, E402 +from .minimizers.minimizer_base import FitResults # noqa: F401, E402 +from .multi_fitter import MultiFitter # noqa: F401, E402 diff --git a/src/easyscience/Fitting/fitter.py b/src/easyscience/Fitting/fitter.py index e5e0233b..dc950eec 100644 --- a/src/easyscience/Fitting/fitter.py +++ b/src/easyscience/Fitting/fitter.py @@ -19,10 +19,10 @@ from easyscience import default_fitting_engine from .minimizers import FitResults -from .minimizers import FittingBase +from .minimizers import MinimizerBase _C = TypeVar('_C', bound=ABCMeta) -_M = TypeVar('_M', bound=FittingBase) +_M = TypeVar('_M', bound=MinimizerBase) class Fitter: @@ -51,7 +51,7 @@ def __init__(self, fit_object=None, fit_function: Optional[Callable] = None): fit_methods = [ x - for x, y in FittingBase.__dict__.items() + for x, y in MinimizerBase.__dict__.items() if (isinstance(y, FunctionType) and not x.startswith('_')) and x != 'fit' ] for method_name in fit_methods: diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py index 594122c2..d443030b 100644 --- a/src/easyscience/Fitting/minimizers/__init__.py +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -7,31 +7,31 @@ import warnings -from .fitting_base import FittingBase # noqa: E402 +from .minimizer_base import MinimizerBase # noqa: E402 from .utils import FitError # noqa: F401, E402 from .utils import FitResults # noqa: F401, E402 imported = -1 try: - from .lmfit import lmfit # noqa: F401, E402 + from .engine_lmfit import LmFit # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('lmfit has not been installed.', ImportWarning, stacklevel=2) try: - from .bumps import bumps # noqa: F401, E402 + from .engine_bumps import Bumps # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('bumps has not been installed.', ImportWarning, stacklevel=2) try: - from .dfo import DFO # noqa: F401, E402 + from .engine_dfo import DFO # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('dfo-ls has not been installed.', ImportWarning, stacklevel=2) -engines: list = FittingBase._engines +engines: list = MinimizerBase._engines diff --git a/src/easyscience/Fitting/minimizers/bumps.py b/src/easyscience/Fitting/minimizers/engine_bumps.py similarity index 99% rename from src/easyscience/Fitting/minimizers/bumps.py rename to src/easyscience/Fitting/minimizers/engine_bumps.py index 8768cb29..20204b56 100644 --- a/src/easyscience/Fitting/minimizers/bumps.py +++ b/src/easyscience/Fitting/minimizers/engine_bumps.py @@ -17,13 +17,13 @@ from bumps.names import FitProblem from bumps.parameter import Parameter as bumpsParameter -from .fitting_base import FittingBase +from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults from .utils import NameConverter -class bumps(FittingBase): # noqa: S101 +class Bumps(MinimizerBase): # noqa: S101 """ This is a wrapper to bumps: https://bumps.readthedocs.io/ It allows for the bumps fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. diff --git a/src/easyscience/Fitting/minimizers/dfo.py b/src/easyscience/Fitting/minimizers/engine_dfo.py similarity index 98% rename from src/easyscience/Fitting/minimizers/dfo.py rename to src/easyscience/Fitting/minimizers/engine_dfo.py index 86292c91..09e37093 100644 --- a/src/easyscience/Fitting/minimizers/dfo.py +++ b/src/easyscience/Fitting/minimizers/engine_dfo.py @@ -10,17 +10,16 @@ from typing import List from typing import Optional -# Import dfols specific objects import dfols import numpy as np -from .fitting_base import FittingBase +from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults from .utils import NameConverter -class DFO(FittingBase): # noqa: S101 +class DFO(MinimizerBase): # noqa: S101 """ This is a wrapper to Derivative free optimisation: https://numericalalgorithmsgroup.github.io/dfols/ """ diff --git a/src/easyscience/Fitting/minimizers/lmfit.py b/src/easyscience/Fitting/minimizers/engine_lmfit.py similarity index 99% rename from src/easyscience/Fitting/minimizers/lmfit.py rename to src/easyscience/Fitting/minimizers/engine_lmfit.py index 68e78909..5676f3d4 100644 --- a/src/easyscience/Fitting/minimizers/lmfit.py +++ b/src/easyscience/Fitting/minimizers/engine_lmfit.py @@ -12,19 +12,17 @@ import numpy as np from lmfit import Model as lmModel - -# Import lmfit specific objects from lmfit import Parameter as lmParameter from lmfit import Parameters as lmParameters from lmfit.model import ModelResult -from .fitting_base import FittingBase +from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults from .utils import NameConverter -class lmfit(FittingBase): # noqa: S101 +class LmFit(MinimizerBase): # noqa: S101 """ This is a wrapper to lmfit: https://lmfit.github.io/ It allows for the lmfit fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. diff --git a/src/easyscience/Fitting/minimizers/fitting_base.py b/src/easyscience/Fitting/minimizers/minimizer_base.py similarity index 99% rename from src/easyscience/Fitting/minimizers/fitting_base.py rename to src/easyscience/Fitting/minimizers/minimizer_base.py index a7a43773..da57f4b8 100644 --- a/src/easyscience/Fitting/minimizers/fitting_base.py +++ b/src/easyscience/Fitting/minimizers/minimizer_base.py @@ -17,7 +17,7 @@ from .utils import FitResults -class FittingBase(metaclass=ABCMeta): +class MinimizerBase(metaclass=ABCMeta): """ This template class is the basis for all fitting engines in `EasyScience`. """ From 0bd0b9ccdc10cddd2b09e8b80cded18582e8264b Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Thu, 6 Jun 2024 10:03:14 +0200 Subject: [PATCH 08/10] cleaning --- .../Fitting/minimizers/minimizer_base.py | 78 ------------------- .../integration_tests/Fitting/test_fitter.py | 2 +- .../Fitting/test_multi_fitter.py | 74 ------------------ 3 files changed, 1 insertion(+), 153 deletions(-) diff --git a/src/easyscience/Fitting/minimizers/minimizer_base.py b/src/easyscience/Fitting/minimizers/minimizer_base.py index da57f4b8..95ff0525 100644 --- a/src/easyscience/Fitting/minimizers/minimizer_base.py +++ b/src/easyscience/Fitting/minimizers/minimizer_base.py @@ -193,81 +193,3 @@ def _error_from_jacobian(jacobian: np.ndarray, residuals: np.ndarray, confidence z = stats.norm.pdf(z) error_matrix = z * np.sqrt(error_matrix) return error_matrix - - -# class FitResults: -# """ -# At the moment this is just a dummy way of unifying the returned fit parameters. -# """ - -# __slots__ = [ -# 'success', -# 'fitting_engine', -# 'fit_args', -# 'p', -# 'p0', -# 'x', -# 'x_matrices', -# 'y_obs', -# 'y_calc', -# 'y_err', -# 'engine_result', -# 'total_results', -# ] - -# def __init__(self): -# self.success = False -# self.fitting_engine = None -# self.fit_args = {} -# self.p = {} -# self.p0 = {} -# self.x = np.ndarray([]) -# self.x_matrices = np.ndarray([]) -# self.y_obs = np.ndarray([]) -# self.y_calc = np.ndarray([]) -# self.y_err = np.ndarray([]) -# self.engine_result = None -# self.total_results = None - -# @property -# def n_pars(self): -# return len(self.p) - -# @property -# def residual(self): -# return self.y_obs - self.y_calc - -# @property -# def chi2(self): -# return ((self.residual / self.y_err) ** 2).sum() - -# @property -# def reduced_chi(self): -# return self.chi2 / (len(self.x) - self.n_pars) - - -# class NameConverter: -# def __init__(self): -# from easyscience import borg - -# self._borg = borg - -# def get_name_from_key(self, item_key: int) -> str: -# return getattr(self._borg.map.get_item_by_key(item_key), 'name', '') - -# def get_item_from_key(self, item_key: int) -> object: -# return self._borg.map.get_item_by_key(item_key) - -# def get_key(self, item: object) -> int: -# return self._borg.map.convert_id_to_key(item) - - -# class FitError(Exception): -# def __init__(self, e: Exception = None): -# self.e = e - -# def __str__(self) -> str: -# s = '' -# if self.e is not None: -# s = f'{self.e}\n' -# return s + 'Something has gone wrong with the fit' diff --git a/tests/integration_tests/Fitting/test_fitter.py b/tests/integration_tests/Fitting/test_fitter.py index 7e3f10c1..23b7047f 100644 --- a/tests/integration_tests/Fitting/test_fitter.py +++ b/tests/integration_tests/Fitting/test_fitter.py @@ -275,4 +275,4 @@ def test_2D_non_vectorized(fit_engine, with_errors): assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) assert result.residual == pytest.approx( mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 - ) \ No newline at end of file + ) diff --git a/tests/integration_tests/Fitting/test_multi_fitter.py b/tests/integration_tests/Fitting/test_multi_fitter.py index 78eb58cc..46ab4094 100644 --- a/tests/integration_tests/Fitting/test_multi_fitter.py +++ b/tests/integration_tests/Fitting/test_multi_fitter.py @@ -277,77 +277,3 @@ def test_multi_fit_1D_2D(fit_engine, with_errors): assert result.residual == pytest.approx( F_real[idx](X[idx]) - F_ref[idx](X[idx]), abs=1e-2 ) - - -# @pytest.mark.parametrize("with_errors", [False, True]) -# @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -# def test_2D_vectorized(fit_engine, with_errors): -# 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 :-( -# X, Y = np.meshgrid(x, x) -# XY = np.stack((X, Y), axis=2) -# ff = Fitter(m2, m2) -# if fit_engine is not None: -# try: -# ff.switch_engine(fit_engine) -# except AttributeError: -# pytest.skip(msg=f"{fit_engine} is not installed") -# try: -# args = [XY, mm(XY)] -# kwargs = {"vectorized": True} -# if with_errors: -# kwargs["weights"] = 1 / np.sqrt(args[1]) -# result = ff.fit(*args, **kwargs) -# except FitError as e: -# if "Unable to allocate" in str(e): -# pytest.skip(msg="MemoryError - Matrix too large") -# else: -# raise e -# assert result.n_pars == len(m2.get_fit_parameters()) -# assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) -# assert result.success -# assert np.all(result.x == XY) -# y_calc_ref = m2(XY) -# assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) -# assert result.residual == pytest.approx(mm(XY) - y_calc_ref, abs=1e-2) - - -# @pytest.mark.parametrize("with_errors", [False, True]) -# @pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) -# def test_2D_non_vectorized(fit_engine, with_errors): -# 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 :-( -# X, Y = np.meshgrid(x, x) -# XY = np.stack((X, Y), axis=2) -# ff = Fitter(m2, m2) -# if fit_engine is not None: -# try: -# ff.switch_engine(fit_engine) -# except AttributeError: -# pytest.skip(msg=f"{fit_engine} is not installed") -# try: -# args = [XY, mm(XY.reshape(-1, 2))] -# kwargs = {"vectorized": False} -# if with_errors: -# kwargs["weights"] = 1 / np.sqrt(args[1]) -# result = ff.fit(*args, **kwargs) -# except FitError as e: -# if "Unable to allocate" in str(e): -# pytest.skip(msg="MemoryError - Matrix too large") -# else: -# raise e -# assert result.n_pars == len(m2.get_fit_parameters()) -# assert result.reduced_chi == pytest.approx(0, abs=1.5e-3) -# assert result.success -# assert np.all(result.x == XY) -# y_calc_ref = m2(XY.reshape(-1, 2)) -# assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) -# assert result.residual == pytest.approx( -# mm(XY.reshape(-1, 2)) - y_calc_ref, abs=1e-2 -# ) \ No newline at end of file From afd081d92643d8e9289cdca4c54680fa406cf389 Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Fri, 7 Jun 2024 09:22:45 +0200 Subject: [PATCH 09/10] from engine_ to minimizer_ --- src/easyscience/Fitting/minimizers/__init__.py | 6 +++--- .../minimizers/{engine_bumps.py => minimizer_bumps.py} | 0 .../Fitting/minimizers/{engine_dfo.py => minimizer_dfo.py} | 0 .../minimizers/{engine_lmfit.py => minimizer_lmfit.py} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename src/easyscience/Fitting/minimizers/{engine_bumps.py => minimizer_bumps.py} (100%) rename src/easyscience/Fitting/minimizers/{engine_dfo.py => minimizer_dfo.py} (100%) rename src/easyscience/Fitting/minimizers/{engine_lmfit.py => minimizer_lmfit.py} (100%) diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py index d443030b..b65e69d1 100644 --- a/src/easyscience/Fitting/minimizers/__init__.py +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -13,21 +13,21 @@ imported = -1 try: - from .engine_lmfit import LmFit # noqa: F401, E402 + from .minimizer_lmfit import LmFit # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('lmfit has not been installed.', ImportWarning, stacklevel=2) try: - from .engine_bumps import Bumps # noqa: F401, E402 + from .minimizer_bumps import Bumps # noqa: F401, E402 imported += 1 except ImportError: # TODO make this a proper message (use logging?) warnings.warn('bumps has not been installed.', ImportWarning, stacklevel=2) try: - from .engine_dfo import DFO # noqa: F401, E402 + from .minimizer_dfo import DFO # noqa: F401, E402 imported += 1 except ImportError: diff --git a/src/easyscience/Fitting/minimizers/engine_bumps.py b/src/easyscience/Fitting/minimizers/minimizer_bumps.py similarity index 100% rename from src/easyscience/Fitting/minimizers/engine_bumps.py rename to src/easyscience/Fitting/minimizers/minimizer_bumps.py diff --git a/src/easyscience/Fitting/minimizers/engine_dfo.py b/src/easyscience/Fitting/minimizers/minimizer_dfo.py similarity index 100% rename from src/easyscience/Fitting/minimizers/engine_dfo.py rename to src/easyscience/Fitting/minimizers/minimizer_dfo.py diff --git a/src/easyscience/Fitting/minimizers/engine_lmfit.py b/src/easyscience/Fitting/minimizers/minimizer_lmfit.py similarity index 100% rename from src/easyscience/Fitting/minimizers/engine_lmfit.py rename to src/easyscience/Fitting/minimizers/minimizer_lmfit.py From f096b30699a322d97264b7759386867f38f83d8b Mon Sep 17 00:00:00 2001 From: Andreas Pedersen Date: Fri, 7 Jun 2024 09:51:42 +0200 Subject: [PATCH 10/10] adjustments to small and large caps --- README.md | 2 +- examples_old/example_dataset2pt2.py | 2 +- examples_old/example_dataset2pt2_broken.py | 2 +- examples_old/example_dataset3pt2.py | 2 +- .../Fitting/minimizers/__init__.py | 2 +- .../Fitting/minimizers/minimizer_bumps.py | 28 +++++----- .../Fitting/minimizers/minimizer_dfo.py | 10 ++-- .../Fitting/minimizers/minimizer_lmfit.py | 53 +++++++------------ .../integration_tests/Fitting/test_fitter.py | 10 ++-- .../Fitting/test_multi_fitter.py | 6 +-- tests/integration_tests/test_undoRedo.py | 2 +- 11 files changed, 53 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 6c57878e..c993b0b4 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,6 @@ Documentation can be found at: We absolutely welcome contributions. **EasyScience** is maintained by the ESS and on a volunteer basis and thus we need to foster a community that can support user questions and develop new features to make this software a useful tool for all users while encouraging every member of the community to share their ideas. ## License -While **EasyScience** is under the BSD-3 license, DFO_LS is subject to the GPL license. +While **EasyScience** is under the BSD-3 license, DFO-LS is subject to the GPL license. diff --git a/examples_old/example_dataset2pt2.py b/examples_old/example_dataset2pt2.py index 1c3d737a..c33c9a91 100644 --- a/examples_old/example_dataset2pt2.py +++ b/examples_old/example_dataset2pt2.py @@ -39,7 +39,7 @@ def fit_fun(x, *args, **kwargs): f.initialize(b, fit_fun) fig, ax = plt.subplots(2, 3, sharey='row') -for idx, minimizer in enumerate(['lmfit', 'bumps', 'DFO_LS']): +for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']): b.m = m_starting_point b.c = c_starting_point diff --git a/examples_old/example_dataset2pt2_broken.py b/examples_old/example_dataset2pt2_broken.py index 1dee00af..b56ea7ae 100644 --- a/examples_old/example_dataset2pt2_broken.py +++ b/examples_old/example_dataset2pt2_broken.py @@ -39,7 +39,7 @@ def fit_fun(x, *args, **kwargs): f.initialize(b, fit_fun) fig, ax = plt.subplots(2, 3, sharey='row') -for idx, minimizer in enumerate(['lmfit', 'bumps', 'DFO_LS']): +for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']): b.m = m_starting_point b.c = c_starting_point diff --git a/examples_old/example_dataset3pt2.py b/examples_old/example_dataset3pt2.py index 3ad168b0..a21ff8ed 100644 --- a/examples_old/example_dataset3pt2.py +++ b/examples_old/example_dataset3pt2.py @@ -41,7 +41,7 @@ def fit_fun(x, *args, **kwargs): cbar_ax1 = fig.add_axes([0.85, 0.15, 0.05, 0.3]) cbar_ax2 = fig.add_axes([0.85, 0.60, 0.05, 0.3]) -for idx, minimizer in enumerate(['lmfit', 'bumps', 'DFO_LS']): +for idx, minimizer in enumerate(['lmfit', 'bumps', 'dfo_ls']): b.s_off = s_off_start_point b.c_off = c_off_start_point diff --git a/src/easyscience/Fitting/minimizers/__init__.py b/src/easyscience/Fitting/minimizers/__init__.py index b65e69d1..6afab1c7 100644 --- a/src/easyscience/Fitting/minimizers/__init__.py +++ b/src/easyscience/Fitting/minimizers/__init__.py @@ -13,7 +13,7 @@ imported = -1 try: - from .minimizer_lmfit import LmFit # noqa: F401, E402 + from .minimizer_lmfit import LMFit # noqa: F401, E402 imported += 1 except ImportError: diff --git a/src/easyscience/Fitting/minimizers/minimizer_bumps.py b/src/easyscience/Fitting/minimizers/minimizer_bumps.py index 20204b56..05000fd7 100644 --- a/src/easyscience/Fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/Fitting/minimizers/minimizer_bumps.py @@ -15,7 +15,7 @@ from bumps.fitters import fit as bumps_fit from bumps.names import Curve from bumps.names import FitProblem -from bumps.parameter import Parameter as bumpsParameter +from bumps.parameter import Parameter as BumpsParameter from .minimizer_base import MinimizerBase from .utils import FitError @@ -25,11 +25,11 @@ class Bumps(MinimizerBase): # noqa: S101 """ - This is a wrapper to bumps: https://bumps.readthedocs.io/ - It allows for the bumps fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. + This is a wrapper to Bumps: https://bumps.readthedocs.io/ + It allows for the Bumps fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. """ - property_type = bumpsParameter + property_type = BumpsParameter name = 'bumps' def __init__(self, obj, fit_function: Callable): @@ -45,9 +45,9 @@ def __init__(self, obj, fit_function: Callable): """ super().__init__(obj, fit_function) self._cached_pars_order = () - self.p_0 = {} + self._p_0 = {} - def make_model(self, pars: Optional[List[bumpsParameter]] = None) -> Callable: + def make_model(self, pars: 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* @@ -163,7 +163,7 @@ def fit( :param model: Optional Model which is being fitted to :type model: lmModel :param parameters: Optional parameters for the fit - :type parameters: List[bumpsParameter] + :type parameters: List[BumpsParameter] :param kwargs: Additional arguments for the fitting function. :param method: Method for minimization :type method: str @@ -191,7 +191,7 @@ def fit( model = self.make_model(pars=parameters) model = model(x, y, weights) self._cached_model = model - self.p_0 = {f'p{key}': self._cached_pars[key].raw_value for key in self._cached_pars.keys()} + self._p_0 = {f'p{key}': self._cached_pars[key].raw_value for key in self._cached_pars.keys()} problem = FitProblem(model) # Why do we do this? Because a fitting template has to have borg instantiated outside pre-runtime from easyscience import borg @@ -209,14 +209,14 @@ 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. :param par_list: If only a single/selection of parameter is required. Specify as a list :type par_list: List[str] :return: bumps Parameters list - :rtype: List[bumpsParameter] + :rtype: List[BumpsParameter] """ if par_list is None: # Assume that we have a BaseObj for which we can obtain a list @@ -226,14 +226,14 @@ def convert_to_pars_obj(self, par_list: Optional[List] = None) -> List[bumpsPara # For some reason I have to double staticmethod :-/ @staticmethod - def convert_to_par_object(obj) -> bumpsParameter: + def convert_to_par_object(obj) -> BumpsParameter: """ Convert an `EasyScience.Objects.Base.Parameter` object to a bumps Parameter object :return: bumps Parameter compatible object. - :rtype: bumpsParameter + :rtype: BumpsParameter """ - return bumpsParameter( + return BumpsParameter( name='p' + str(NameConverter().get_key(obj)), value=obj.raw_value, bounds=[obj.min, obj.max], @@ -285,7 +285,7 @@ def _gen_fit_results(self, fit_results, **kwargs) -> FitResults: for index, name in enumerate(self._cached_model._pnames): dict_name = int(name[1:]) item[name] = pars[dict_name].raw_value - results.p0 = self.p_0 + results.p0 = self._p_0 results.p = item results.x = self._cached_model.x results.y_obs = self._cached_model.y diff --git a/src/easyscience/Fitting/minimizers/minimizer_dfo.py b/src/easyscience/Fitting/minimizers/minimizer_dfo.py index 09e37093..48afbffe 100644 --- a/src/easyscience/Fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/Fitting/minimizers/minimizer_dfo.py @@ -21,11 +21,11 @@ class DFO(MinimizerBase): # noqa: S101 """ - This is a wrapper to Derivative free optimisation: https://numericalalgorithmsgroup.github.io/dfols/ + This is a wrapper to Derivative Free Optimisation for Least Square: https://numericalalgorithmsgroup.github.io/dfols/ """ property_type = Number - name = 'DFO_LS' + name = 'dfo_ls' def __init__(self, obj, fit_function: Callable): """ @@ -39,7 +39,7 @@ def __init__(self, obj, fit_function: Callable): :type fit_function: Callable """ super().__init__(obj, fit_function) - self.p_0 = {} + self._p_0 = {} def make_model(self, pars: Optional[List] = None) -> Callable: """ @@ -165,7 +165,7 @@ def fit( model = self.make_model(pars=parameters) model = model(x, y, weights) self._cached_model = model - self.p_0 = {f'p{key}': self._cached_pars[key].raw_value for key in self._cached_pars.keys()} + self._p_0 = {f'p{key}': self._cached_pars[key].raw_value for key in self._cached_pars.keys()} # Why do we do this? Because a fitting template has to have borg instantiated outside pre-runtime from easyscience import borg @@ -243,7 +243,7 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults: item = {} for p_name, par in pars.items(): item[f'p{p_name}'] = par.raw_value - results.p0 = self.p_0 + results.p0 = self._p_0 results.p = item results.x = self._cached_model.x results.y_obs = self._cached_model.y diff --git a/src/easyscience/Fitting/minimizers/minimizer_lmfit.py b/src/easyscience/Fitting/minimizers/minimizer_lmfit.py index 5676f3d4..0adad4ad 100644 --- a/src/easyscience/Fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/Fitting/minimizers/minimizer_lmfit.py @@ -11,9 +11,9 @@ from typing import Optional import numpy as np -from lmfit import Model as lmModel -from lmfit import Parameter as lmParameter -from lmfit import Parameters as lmParameters +from lmfit import Model as LMModel +from lmfit import Parameter as LMParameter +from lmfit import Parameters as LMParameters from lmfit.model import ModelResult from .minimizer_base import MinimizerBase @@ -22,48 +22,35 @@ from .utils import NameConverter -class LmFit(MinimizerBase): # noqa: S101 +class LMFit(MinimizerBase): # noqa: S101 """ - This is a wrapper to lmfit: https://lmfit.github.io/ + This is a wrapper to the extended Levenberg-Marquardt Fit: https://lmfit.github.io/lmfit-py/ It allows for the lmfit fitting engine to use parameters declared in an `EasyScience.Objects.Base.BaseObj`. """ - property_type = lmParameter + property_type = LMParameter name = 'lmfit' - def __init__(self, obj, fit_function: Callable): - """ - Initialize the fitting engine with a `BaseObj` and an arbitrary fitting function. - - :param obj: Object containing elements of the `Parameter` class - :type obj: BaseObj - :param fit_function: function that when called returns y values. 'x' must be the first - and only positional argument. Additional values can be supplied by - keyword/value pairs - :type fit_function: Callable - """ - super().__init__(obj, fit_function) - - def make_model(self, pars: Optional[lmParameters] = None) -> lmModel: + def make_model(self, pars: Optional[LMParameters] = None) -> LMModel: """ Generate a lmfit model from the supplied `fit_function` and parameters in the base object. :return: Callable lmfit model - :rtype: lmModel + :rtype: LMModel """ # Generate the fitting function fit_func = self._generate_fit_function() if pars is None: pars = self._cached_pars # Create the model - model = lmModel( + model = LMModel( fit_func, independent_vars=['x'], param_names=['p' + str(key) for key in pars.keys()], ) # Assign values from the `Parameter` to the model for name, item in pars.items(): - if isinstance(item, lmParameter): + if isinstance(item, LMParameter): value = item.value else: value = item.raw_value @@ -144,8 +131,8 @@ def fit( x: np.ndarray, y: np.ndarray, weights: Optional[np.ndarray] = None, - model: Optional[lmModel] = None, - parameters: Optional[lmParameters] = None, + model: Optional[LMModel] = None, + parameters: Optional[LMParameters] = None, method: Optional[str] = None, minimizer_kwargs: Optional[dict] = None, engine_kwargs: Optional[dict] = None, @@ -163,9 +150,9 @@ def fit( :param weights: Weights for supplied measured points :type weights: np.ndarray :param model: Optional Model which is being fitted to - :type model: lmModel + :type model: LMModel :param parameters: Optional parameters for the fit - :type parameters: lmParameters + :type parameters: LMParameters :param minimizer_kwargs: Arguments to be passed directly to the minimizer :type minimizer_kwargs: dict :param kwargs: Additional arguments for the fitting function. @@ -207,30 +194,30 @@ def fit( raise FitError(e) return results - def convert_to_pars_obj(self, par_list: Optional[List] = None) -> lmParameters: + def convert_to_pars_obj(self, par_list: Optional[List] = None) -> LMParameters: """ Create an lmfit compatible container with the `Parameters` converted from the base object. :param par_list: If only a single/selection of parameter is required. Specify as a list :type par_list: List[str] :return: lmfit Parameters compatible object - :rtype: lmParameters + :rtype: LMParameters """ if par_list is None: # Assume that we have a BaseObj for which we can obtain a list par_list = self._object.get_fit_parameters() - pars_obj = lmParameters().add_many([self.__class__.convert_to_par_object(obj) for obj in par_list]) + pars_obj = LMParameters().add_many([self.__class__.convert_to_par_object(obj) for obj in par_list]) return pars_obj @staticmethod - def convert_to_par_object(obj) -> lmParameter: + def convert_to_par_object(obj) -> LMParameter: """ Convert an `EasyScience.Objects.Base.Parameter` object to a lmfit Parameter object. :return: lmfit Parameter compatible object. - :rtype: lmParameter + :rtype: LMParameter """ - return lmParameter( + return LMParameter( 'p' + str(NameConverter().get_key(obj)), value=obj.raw_value, vary=not obj.fixed, diff --git a/tests/integration_tests/Fitting/test_fitter.py b/tests/integration_tests/Fitting/test_fitter.py index 23b7047f..0180ef36 100644 --- a/tests/integration_tests/Fitting/test_fitter.py +++ b/tests/integration_tests/Fitting/test_fitter.py @@ -78,7 +78,7 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_basic_fit(fit_engine, with_errors): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -107,7 +107,7 @@ def test_basic_fit(fit_engine, with_errors): assert sp_sin.offset.raw_value == pytest.approx(ref_sin.offset.raw_value, rel=1e-3) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_fit_result(fit_engine): ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) @@ -175,7 +175,7 @@ def test_bumps_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) -@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "dfo_ls"]) def test_fit_constraints(fit_engine): ref_sin = AbsSin(np.pi * 0.45, 0.45 * np.pi * 0.5) sp_sin = AbsSin(1, 0.5) @@ -205,7 +205,7 @@ def test_fit_constraints(fit_engine): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_2D_vectorized(fit_engine, with_errors): x = np.linspace(0, 5, 200) mm = AbsSin2D(0.3, 1.6) @@ -241,7 +241,7 @@ def test_2D_vectorized(fit_engine, with_errors): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_2D_non_vectorized(fit_engine, with_errors): x = np.linspace(0, 5, 200) mm = AbsSin2DL(0.3, 1.6) diff --git a/tests/integration_tests/Fitting/test_multi_fitter.py b/tests/integration_tests/Fitting/test_multi_fitter.py index 46ab4094..5ec8de9e 100644 --- a/tests/integration_tests/Fitting/test_multi_fitter.py +++ b/tests/integration_tests/Fitting/test_multi_fitter.py @@ -59,7 +59,7 @@ def __call__(self, x): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_multi_fit(fit_engine, with_errors): ref_sin_1 = AbsSin(0.2, np.pi) sp_sin_1 = AbsSin(0.354, 3.05) @@ -120,7 +120,7 @@ def test_multi_fit(fit_engine, with_errors): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_multi_fit2(fit_engine, with_errors): ref_sin_1 = AbsSin(0.2, np.pi) sp_sin_1 = AbsSin(0.354, 3.05) @@ -198,7 +198,7 @@ def test_multi_fit2(fit_engine, with_errors): @pytest.mark.parametrize("with_errors", [False, True]) -@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", [None, "lmfit", "bumps", "dfo_ls"]) def test_multi_fit_1D_2D(fit_engine, with_errors): # Generate fit and reference objects ref_sin1D = AbsSin(0.2, np.pi) diff --git a/tests/integration_tests/test_undoRedo.py b/tests/integration_tests/test_undoRedo.py index a2e3cd82..5db3a36a 100644 --- a/tests/integration_tests/test_undoRedo.py +++ b/tests/integration_tests/test_undoRedo.py @@ -229,7 +229,7 @@ def test_UndoRedoMacros(): assert item.raw_value == old_value + offset -@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "DFO_LS"]) +@pytest.mark.parametrize("fit_engine", ["lmfit", "bumps", "dfo_ls"]) def test_fittingUndoRedo(fit_engine): m_value = 6 c_value = 2