Skip to content

Commit

Permalink
Merge branch '81-to-increase-usability-of-minimizer-it-should-be-poss…
Browse files Browse the repository at this point in the history
…ible-to-set-the-tolerance-and-the-max-number-of-iteration' into test
  • Loading branch information
AndrewSazonov committed Nov 11, 2024
2 parents 7b116f7 + 3bda6df commit d51dd48
Show file tree
Hide file tree
Showing 10 changed files with 245 additions and 39 deletions.
57 changes: 51 additions & 6 deletions src/easyscience/fitting/fitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ class Fitter:
def __init__(self, fit_object, fit_function: Callable):
self._fit_object = fit_object
self._fit_function = fit_function
self._dependent_dims = None
self._dependent_dims: int = None
self._tolerance: float = None
self._max_evaluations: int = None

self._enum_current_minimizer = DEFAULT_MINIMIZER
self._minimizer: MinimizerBase # _minimizer is set in the create method
self._update_minimizer(self._enum_current_minimizer)
self._minimizer: MinimizerBase = None # set in _update_minimizer
self._enum_current_minimizer: AvailableMinimizers = None # set in _update_minimizer
self._update_minimizer(DEFAULT_MINIMIZER)

def fit_constraints(self) -> list:
return self._minimizer.fit_constraints()
Expand Down Expand Up @@ -110,6 +112,42 @@ def minimizer(self) -> MinimizerBase:
"""
return self._minimizer

@property
def tolerance(self) -> float:
"""
Get the tolerance for the minimizer.
:return: Tolerance for the minimizer
"""
return self._tolerance

@tolerance.setter
def tolerance(self, tolerance: float) -> None:
"""
Set the tolerance for the minimizer.
:param tolerance: Tolerance for the minimizer
"""
self._tolerance = tolerance

@property
def max_evaluations(self) -> int:
"""
Get the maximal number of evaluations for the minimizer.
:return: Maximal number of steps for the minimizer
"""
return self._max_evaluations

@max_evaluations.setter
def max_evaluations(self, max_evaluations: int) -> None:
"""
Set the maximal number of evaluations for the minimizer.
:param max_evaluations: Maximal number of steps for the minimizer
"""
self._max_evaluations = max_evaluations

@property
def fit_function(self) -> Callable:
"""
Expand Down Expand Up @@ -175,7 +213,7 @@ def fit(self) -> Callable:
re-constitute the independent variables and once the fit is completed, reshape the inputs to those expected.
"""

@functools.wraps(self.minimizer.fit)
@functools.wraps(self._minimizer.fit)
def inner_fit_callable(
x: np.ndarray,
y: np.ndarray,
Expand All @@ -202,7 +240,14 @@ def inner_fit_callable(
constraints = self._minimizer.fit_constraints()
self.fit_function = fit_fun_wrap
self._minimizer.set_fit_constraint(constraints)
f_res = self.minimizer.fit(x_fit, y_new, weights=weights, **kwargs)
f_res = self._minimizer.fit(
x_fit,
y_new,
weights=weights,
tolerance=self._tolerance,
max_evaluations=self._max_evaluations,
**kwargs,
)

# Postcompute
fit_result = self._post_compute_reshaping(f_res, x, y)
Expand Down
10 changes: 8 additions & 2 deletions src/easyscience/fitting/minimizers/minimizer_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(
self,
obj, #: BaseObj,
fit_function: Callable,
minimizer_enum: Optional[AvailableMinimizers] = None,
minimizer_enum: AvailableMinimizers,
): # todo after constraint changes, add type hint: obj: BaseObj # noqa: E501
if minimizer_enum.method not in self.supported_methods():
raise FitError(f'Method {minimizer_enum.method} not available in {self.__class__}')
Expand All @@ -58,6 +58,10 @@ def __init__(
def all_constraints(self) -> List[ObjConstraint]:
return [*self._constraints, *self._object._constraints]

@property
def enum(self) -> AvailableMinimizers:
return self._minimizer_enum

@property
def name(self) -> str:
return self._minimizer_enum.name
Expand All @@ -83,6 +87,8 @@ def fit(
model: Optional[Callable] = None,
parameters: Optional[Parameter] = None,
method: Optional[str] = None,
tolerance: Optional[float] = None,
max_evaluations: Optional[int] = None,
**kwargs,
) -> FitResults:
"""
Expand Down Expand Up @@ -129,7 +135,7 @@ def evaluate(self, x: np.ndarray, minimizer_parameters: Optional[dict[str, float

return self._fit_function(x, **minimizer_parameters, **kwargs)

def _get_method_dict(self, passed_method: Optional[str] = None) -> dict[str, str]:
def _get_method_kwargs(self, passed_method: Optional[str] = None) -> dict[str, str]:
if passed_method is not None:
if passed_method not in self.supported_methods():
raise FitError(f'Method {passed_method} not available in {self.__class__}')
Expand Down
14 changes: 11 additions & 3 deletions src/easyscience/fitting/minimizers/minimizer_bumps.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def fit(
model: Optional[Callable] = None,
parameters: Optional[Parameter] = None,
method: Optional[str] = None,
tolerance: Optional[float] = None,
max_evaluations: Optional[int] = None,
minimizer_kwargs: Optional[dict] = None,
engine_kwargs: Optional[dict] = None,
**kwargs,
Expand All @@ -97,7 +99,7 @@ def fit(
:return: Fit results
:rtype: ModelResult
"""
method_dict = self._get_method_dict(method)
method_dict = self._get_method_kwargs(method)

if weights is None:
weights = np.sqrt(np.abs(y))
Expand All @@ -107,10 +109,16 @@ def fit(

if minimizer_kwargs is None:
minimizer_kwargs = {}
# else:
# minimizer_kwargs = {"fit_kws": minimizer_kwargs}
minimizer_kwargs.update(engine_kwargs)

if tolerance is not None:
if 0.1 < tolerance:
raise ValueError('Tolerance must be equal or smaller than 0.1')
minimizer_kwargs['ftol'] = tolerance # tolerance for change in function value
minimizer_kwargs['xtol'] = tolerance # tolerance for change in parameter value, could be an independent value
if max_evaluations is not None:
minimizer_kwargs['steps'] = max_evaluations

if model is None:
model_function = self._make_model(parameters=parameters)
model = model_function(x, y, weights)
Expand Down
34 changes: 24 additions & 10 deletions src/easyscience/fitting/minimizers/minimizer_dfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ def supported_methods() -> List[str]:

@staticmethod
def all_methods() -> List[str]:
return [
'leastsq',
]
return ['leastsq']

def fit(
self,
Expand All @@ -65,8 +63,8 @@ def fit(
model: Optional[Callable] = None,
parameters: Optional[List[Parameter]] = None,
method: str = None,
xtol: float = 1e-6,
ftol: float = 1e-8,
tolerance: Optional[float] = None,
max_evaluations: Optional[int] = None,
**kwargs,
) -> FitResults:
"""
Expand Down Expand Up @@ -110,6 +108,8 @@ def fit(
stack_status = global_object.stack.enabled
global_object.stack.enabled = False

kwargs = self._prepare_kwargs(tolerance, max_evaluations, **kwargs)

try:
model_results = self._dfo_fit(self._cached_pars, model, **kwargs)
self._set_parameter_fit_result(model_results, stack_status)
Expand Down Expand Up @@ -239,7 +239,11 @@ def _gen_fit_results(self, fit_results, weights, **kwargs) -> FitResults:
return results

@staticmethod
def _dfo_fit(pars: Dict[str, Parameter], model: Callable, **kwargs):
def _dfo_fit(
pars: Dict[str, Parameter],
model: Callable,
**kwargs,
):
"""
Method to convert EasyScience styling to DFO-LS styling (yes, again)
Expand All @@ -261,13 +265,23 @@ def _dfo_fit(pars: Dict[str, Parameter], model: Callable, **kwargs):
np.array([par.max for par in pars.values()]),
)
# https://numericalalgorithmsgroup.github.io/dfols/build/html/userguide.html
if np.isinf(bounds).any():
results = dfols.solve(model, pars_values, bounds=bounds, **kwargs)
else:
if not np.isinf(bounds).any():
# It is only possible to scale (normalize) variables if they are bound (different from inf)
results = dfols.solve(model, pars_values, bounds=bounds, scaling_within_bounds=True, **kwargs)
kwargs['scaling_within_bounds'] = True

results = dfols.solve(model, pars_values, bounds=bounds, **kwargs)

if 'Success' not in results.msg:
raise FitError(f'Fit failed with message: {results.msg}')

return results

@staticmethod
def _prepare_kwargs(tolerance: Optional[float] = None, max_evaluations: Optional[int] = None, **kwargs) -> dict[str:str]:
if max_evaluations is not None:
kwargs['maxfun'] = max_evaluations # max number of function evaluations
if tolerance is not None:
if 0.1 < tolerance:
raise ValueError('Tolerance must be equal or smaller than 0.1')
kwargs['rhoend'] = tolerance # size of the trust region
return kwargs
32 changes: 24 additions & 8 deletions src/easyscience/fitting/minimizers/minimizer_lmfit.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ def fit(
model: Optional[LMModel] = None,
parameters: Optional[LMParameters] = None,
method: Optional[str] = None,
tolerance: Optional[float] = None,
max_evaluations: Optional[int] = None,
minimizer_kwargs: Optional[dict] = None,
engine_kwargs: Optional[dict] = None,
**kwargs,
Expand All @@ -110,19 +112,14 @@ def fit(
:return: Fit results
:rtype: ModelResult
"""
method_dict = self._get_method_dict(method)

if weights is None:
weights = 1 / np.sqrt(np.abs(y))

if engine_kwargs is None:
engine_kwargs = {}

if minimizer_kwargs is None:
minimizer_kwargs = {}
else:
minimizer_kwargs = {'fit_kws': minimizer_kwargs}
minimizer_kwargs.update(engine_kwargs)
method_kwargs = self._get_method_kwargs(method)
fit_kws_dict = self._get_fit_kws(method, tolerance, minimizer_kwargs)

# Why do we do this? Because a fitting template has to have global_object instantiated outside pre-runtime
from easyscience import global_object
Expand All @@ -134,7 +131,16 @@ def fit(
if model is None:
model = self._make_model()

model_results = model.fit(y, x=x, weights=weights, **method_dict, **minimizer_kwargs, **kwargs)
model_results = model.fit(
y,
x=x,
weights=weights,
max_nfev=max_evaluations,
fit_kws=fit_kws_dict,
**method_kwargs,
**engine_kwargs,
**kwargs,
)
self._set_parameter_fit_result(model_results, stack_status)
results = self._gen_fit_results(model_results)
except Exception as e:
Expand All @@ -143,6 +149,16 @@ def fit(
raise FitError(e)
return results

def _get_fit_kws(self, method: str, tolerance: float, minimizer_kwargs: dict[str:str]) -> dict[str:str]:
if minimizer_kwargs is None:
minimizer_kwargs = {}
if tolerance is not None:
if method in [None, 'least_squares', 'leastsq']:
minimizer_kwargs['ftol'] = tolerance
if method in ['differential_evolution', 'powell', 'cobyla']:
minimizer_kwargs['tol'] = tolerance
return minimizer_kwargs

def convert_to_pars_obj(self, parameters: Optional[List[Parameter]] = None) -> LMParameters:
"""
Create an lmfit compatible container with the `Parameters` converted from the base object.
Expand Down
56 changes: 56 additions & 0 deletions tests/integration_tests/Fitting/test_fitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,62 @@ def test_fit_result(fit_engine):
check_fit_results(result, sp_sin, ref_sin, x, sp_ref1=sp_ref1, sp_ref2=sp_ref2)


@pytest.mark.parametrize("fit_engine", [None, AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO])
def test_basic_max_evaluations(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

f = Fitter(sp_sin, sp_sin)
if fit_engine is not None:
try:
f.switch_minimizer(fit_engine)
except AttributeError:
pytest.skip(msg=f"{fit_engine} is not installed")
args = [x, y]
kwargs = {}
f.max_evaluations = 3
try:
result = f.fit(*args, **kwargs)
# Result should not be the same as the reference
assert sp_sin.phase.value != pytest.approx(ref_sin.phase.value, rel=1e-3)
assert sp_sin.offset.value != pytest.approx(ref_sin.offset.value, rel=1e-3)
except FitError as e:
# DFO throws a different error
assert "Objective has been called MAXFUN times" in str(e)


@pytest.mark.parametrize("fit_engine,tolerance", [(None, 10), (AvailableMinimizers.LMFit, 10), (AvailableMinimizers.Bumps, 0.1), (AvailableMinimizers.DFO, 0.1)])
def test_basic_tolerance(fit_engine, tolerance):
ref_sin = AbsSin(0.2, np.pi)
sp_sin = AbsSin(0.354, 3.05)

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_minimizer(fit_engine)
except AttributeError:
pytest.skip(msg=f"{fit_engine} is not installed")
args = [x, y]
kwargs = {}
f.tolerance = tolerance
result = f.fit(*args, **kwargs)
# Result should not be the same as the reference
assert sp_sin.phase.value != pytest.approx(ref_sin.phase.value, rel=1e-3)
assert sp_sin.offset.value != pytest.approx(ref_sin.offset.value, rel=1e-3)


@pytest.mark.parametrize("fit_method", ["leastsq", "powell", "cobyla"])
def test_lmfit_methods(fit_method):
ref_sin = AbsSin(0.2, np.pi)
Expand Down
Loading

0 comments on commit d51dd48

Please sign in to comment.