From 12ee356aeda913c1a62b575ad3f52348f23fd00c Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 6 Aug 2026 15:18:38 +0200 Subject: [PATCH 01/25] initial commit --- src/easyscience/fitting/engine_base.py | 200 +++++ src/easyscience/fitting/fitter.py | 65 +- .../minimizers/bumps_utils/__init__.py | 17 +- .../fitting/minimizers/bumps_utils/problem.py | 142 ++++ .../minimizers/bumps_utils/validation.py | 83 +++ .../fitting/minimizers/minimizer_base.py | 179 +---- .../fitting/minimizers/minimizer_bumps.py | 341 +-------- src/easyscience/fitting/sampler.py | 92 +-- src/easyscience/fitting/samplers/__init__.py | 6 + .../fitting/samplers/sampler_dream.py | 381 ++++++++++ tests/integration/fitting/test_fitter.py | 29 + tests/integration/fitting/test_sampler.py | 112 ++- .../minimizers/bumps_utils/__init__.py | 2 + .../minimizers/bumps_utils/test_problem.py | 179 +++++ .../minimizers/bumps_utils/test_validation.py | 83 +++ .../minimizers/test_minimizer_bumps.py | 681 ++---------------- tests/unit/fitting/samplers/__init__.py | 2 + .../fitting/samplers/test_sampler_dream.py | 481 +++++++++++++ tests/unit/fitting/test_sampler.py | 87 ++- 19 files changed, 1939 insertions(+), 1223 deletions(-) create mode 100644 src/easyscience/fitting/engine_base.py create mode 100644 src/easyscience/fitting/minimizers/bumps_utils/problem.py create mode 100644 src/easyscience/fitting/minimizers/bumps_utils/validation.py create mode 100644 src/easyscience/fitting/samplers/__init__.py create mode 100644 src/easyscience/fitting/samplers/sampler_dream.py create mode 100644 tests/unit/fitting/minimizers/bumps_utils/__init__.py create mode 100644 tests/unit/fitting/minimizers/bumps_utils/test_problem.py create mode 100644 tests/unit/fitting/minimizers/bumps_utils/test_validation.py create mode 100644 tests/unit/fitting/samplers/__init__.py create mode 100644 tests/unit/fitting/samplers/test_sampler_dream.py diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py new file mode 100644 index 00000000..08190ea8 --- /dev/null +++ b/src/easyscience/fitting/engine_base.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from abc import ABCMeta +from inspect import Parameter as InspectParameter +from inspect import Signature +from inspect import _empty +from typing import Callable +from typing import Dict +from typing import Tuple + +import numpy as np + +# causes circular import when Parameter is imported +# from easyscience.base_classes import ObjBase +from easyscience.variable import Parameter + +PARAMETER_PREFIX = 'p' + + +class EngineBase(metaclass=ABCMeta): + """ + Base for all evaluation engines — minimizers and samplers alike. + + An engine binds an EasyScience object and a fit function, and + repeatedly evaluates the function while writing values back into the + object's ``Parameter`` instances. ``EngineBase`` owns that shared + machinery: the parameter cache, the ``Parameter``-writing wrapped + fit function, and value restore on failure. It deliberately declares + no abstract methods: the run interfaces live on its subclasses + (``MinimizerBase.fit``, ``DreamSampler.run``). + """ + + package: str = None + + def __init__( + self, + obj, #: ObjBase, + fit_function: Callable, + ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + self._object = obj + self._original_fit_function = fit_function + self._cached_pars: Dict[str, Parameter] = {} + self._cached_pars_vals: Dict[str, Tuple[float, float]] = {} + self._fit_function = None + + def _restore_parameter_values(self) -> None: + for key in self._cached_pars.keys(): + self._cached_pars[key].value = self._cached_pars_vals[key][0] + self._cached_pars[key].error = self._cached_pars_vals[key][1] + + def evaluate( + self, x: np.ndarray, minimizer_parameters: dict[str, float] | None = None, **kwargs + ) -> np.ndarray: + """ + Evaluate the fit function for values of x. + + Parameters used are either the latest or user supplied. If the + parameters are user supplied, it must be in a dictionary of + {'parameter_name': parameter_value,...}. + + Parameters + ---------- + x : np.ndarray + X values for which the fit function will be evaluated. + minimizer_parameters : dict[str, float] | None, default=None + Dictionary of parameters which will be used in the fit + function. They must be in a dictionary of {'parameter_name': + parameter_value,...}. By default, None. + **kwargs : + Additional arguments. + + Returns + ------- + np.ndarray + Y values calculated at points x for a set of parameters. + + Raises + ------ + TypeError + If ``minimizer_parameters`` is not a dictionary. + """ + if minimizer_parameters is None: + minimizer_parameters = {} + if not isinstance(minimizer_parameters, dict): + raise TypeError('minimizer_parameters must be a dictionary') + + if self._fit_function is None: + # This will also generate self._cached_pars + self._fit_function = self._generate_fit_function() + + minimizer_parameters = self._prepare_parameters(minimizer_parameters) + + return self._fit_function(x, **minimizer_parameters, **kwargs) + + def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]: + """ + Prepare the parameters for the engine. + + Parameters + ---------- + parameters : dict[str, float] + Dict of parameters for the engine with names as keys. + + Returns + ------- + dict[str, float] + Completed parameter dictionary for the engine. + """ + pars = self._cached_pars + + for name, item in pars.items(): + parameter_name = PARAMETER_PREFIX + str(name) + if parameter_name not in parameters.keys(): + parameters[parameter_name] = item.value + return parameters + + def _generate_fit_function(self) -> Callable: + """ + Using the user supplied ``fit_function``, wrap it in such a way + we can update ``Parameter`` on iterations. + + Returns + ------- + Callable + A fit function which is compatible with bumps models. + """ + # Original fit function + func = self._original_fit_function + # Get a list of `Parameters` + self._cached_pars = {} + self._cached_pars_vals = {} + for parameter in self._object.get_fit_parameters(): + key = parameter.unique_name + self._cached_pars[key] = parameter + self._cached_pars_vals[key] = (parameter.value, parameter.error) + + # Make a new fit function + def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray: + """ + Wrapped fit function which now has an EasyScience compatible + form. + + Parameters + ---------- + x : np.ndarray + Array of data points to be calculated. + **kwargs : + Key word arguments. + + Returns + ------- + np.ndarray + Points calculated at ``x``. + """ + # Update the `Parameter` values and the callback if needed + # TODO THIS IS NOT THREAD SAFE :-( + + for name, value in kwargs.items(): + par_name = name[1:] + if par_name in self._cached_pars.keys(): + # This will take into account constraints + if self._cached_pars[par_name].value != value: + self._cached_pars[par_name].value = value + + # Since we are calling the parameter fset will be called. + # TODO Pre processing here + return_data = func(x) + # TODO Loading or manipulating data here + return return_data + + _fit_function.__signature__ = self._create_signature(self._cached_pars) + return _fit_function + + @staticmethod + def _create_signature(parameters: Dict[int, Parameter]) -> Signature: + """ + Wrap the function signature. + + This is done as lmfit wants the function to be in the form: f = + (x, a=1, b=2)... Where we need to be generic. Note that this + won't hold for much outside of this scope. + """ + wrapped_parameters = [] + wrapped_parameters.append( + InspectParameter('x', InspectParameter.POSITIONAL_OR_KEYWORD, annotation=_empty) + ) + + for name, parameter in parameters.items(): + default_value = parameter.value + + wrapped_parameters.append( + InspectParameter( + PARAMETER_PREFIX + str(name), + InspectParameter.POSITIONAL_OR_KEYWORD, + annotation=_empty, + default=default_value, + ) + ) + return Signature(wrapped_parameters) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index 7cb948e0..e2a26bd7 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import functools +import warnings from typing import Callable from typing import List from typing import Optional @@ -441,7 +442,6 @@ def mcmc_sample( ``_precompute_reshaping`` and ``_fit_function_wrapper`` are resolved on the concrete subclass at call time, so multi-dataset flattening is handled automatically when called on a - ``MultiFitter`` instance. Parameters ---------- @@ -488,42 +488,27 @@ def mcmc_sample( ValueError If ``samples``, ``burn``, or ``thin`` are invalid. RuntimeError - If the active minimizer is not a BUMPS instance. - """ - if not isinstance(samples, int) or samples <= 0: - raise ValueError('samples must be a positive integer.') - if not isinstance(burn, int) or burn < 0: - raise ValueError('burn must be a non-negative integer.') - if not isinstance(thin, int) or thin < 1: - raise ValueError('thin must be a positive integer.') - - x_fit, x_new, y_new, w_new, dims = self._precompute_reshaping(x, y, weights, vectorized) - self._dependent_dims = dims - - original_fit_func = self._fit_function - self.fit_function = self._fit_function_wrapper(x_new, flatten=True) - - try: - minimizer = self.minimizer - if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'): - raise RuntimeError( - 'Bayesian sampling requires a BUMPS minimizer. ' - 'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.' - ) - - result = minimizer.mcmc_sample( - x=x_fit, - y=y_new, - weights=w_new, - samples=samples, - burn=burn, - thin=thin, - population=population, - sampler_kwargs=sampler_kwargs, - progress_callback=progress_callback, - abort_test=abort_test, - ) - finally: - self.fit_function = original_fit_func - - return result + If the ``bumps`` package is not installed. + """ + warnings.warn( + 'Fitter.mcmc_sample() is deprecated. Use ' + 'Sampler(fitter, x, y, weights).sample(...) instead.', + DeprecationWarning, + stacklevel=2, + ) + from .sampler import Sampler + + # ``sampler_kwargs`` is deliberately passed per-call rather than to + # the constructor: for the fresh single-use Sampler created here the + # two are equivalent (constructor kwargs are just per-call defaults), + # and per-call matches the legacy one-shot semantics exactly. + sampler = Sampler(self, x, y, weights=weights, vectorized=vectorized) + return sampler.sample( + samples=samples, + burn=burn, + thin=thin, + population=population, + sampler_kwargs=sampler_kwargs, + progress_callback=progress_callback, + abort_test=abort_test, + ).to_legacy_dict() diff --git a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py index 35255615..cdd3d6c6 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py @@ -2,6 +2,21 @@ # SPDX-License-Identifier: BSD-3-Clause from .eval_counter import EvalCounter +from .problem import build_curve_problem +from .problem import parameter_names +from .problem import parameter_snapshot +from .problem import to_bumps_parameter from .progress_monitor import BumpsProgressMonitor +from .validation import validate_arrays +from .validation import validate_run_settings -__all__ = ['BumpsProgressMonitor', 'EvalCounter'] +__all__ = [ + 'BumpsProgressMonitor', + 'EvalCounter', + 'build_curve_problem', + 'parameter_names', + 'parameter_snapshot', + 'to_bumps_parameter', + 'validate_arrays', + 'validate_run_settings', +] diff --git a/src/easyscience/fitting/minimizers/bumps_utils/problem.py b/src/easyscience/fitting/minimizers/bumps_utils/problem.py new file mode 100644 index 00000000..b5e25d42 --- /dev/null +++ b/src/easyscience/fitting/minimizers/bumps_utils/problem.py @@ -0,0 +1,142 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""BUMPS problem construction shared by the ``Bumps`` minimizer and +``DreamSampler``. + +These are free functions rather than ``Bumps`` methods so that any +:class:`~easyscience.fitting.engine_base.EngineBase` — a minimizer or a +sampler — can build a BUMPS ``Curve``/``FitProblem`` without inheriting +from the minimizer. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from bumps.names import Curve +from bumps.names import FitProblem +from bumps.parameter import Parameter as BumpsParameter + +from easyscience.variable import Parameter + +from ...engine_base import PARAMETER_PREFIX +from .eval_counter import EvalCounter + +if TYPE_CHECKING: + from ...engine_base import EngineBase + + +def to_bumps_parameter(par: Parameter) -> BumpsParameter: + """Convert an EasyScience ``Parameter`` to a prefixed ``BumpsParameter``. + + Parameters + ---------- + par : Parameter + EasyScience parameter to convert. + + Returns + ------- + BumpsParameter + Bumps Parameter compatible object, named + ``PARAMETER_PREFIX + par.unique_name``. + """ + return BumpsParameter( + name=PARAMETER_PREFIX + par.unique_name, + value=par.value, + bounds=[par.min, par.max], + fixed=par.fixed, + ) + + +def build_curve_problem( + engine: 'EngineBase', + x: np.ndarray, + y: np.ndarray, + weights: np.ndarray, + parameters: list[Parameter] | None = None, +) -> tuple[FitProblem, EvalCounter, Curve]: + """Build a BUMPS ``FitProblem`` around an engine's wrapped fit function. + + Wraps ``engine._generate_fit_function()`` in an :class:`EvalCounter`, + converts the engine's cached parameters (or the explicitly supplied + ``parameters``) via :func:`to_bumps_parameter`, and assembles + ``Curve(fit_func, x, y, dy=1/weights, **bumps_pars)`` into a + ``FitProblem``. + + Parameters + ---------- + engine : EngineBase + The engine (minimizer or sampler) supplying the fit function and + parameter cache. + x : np.ndarray + Independent variable array. + y : np.ndarray + Dependent variable array. + weights : np.ndarray + Weight array; converted to ``dy = 1 / weights``. + parameters : list[Parameter] | None, default=None + Optional explicit EasyScience parameters to bind into the model + instead of the engine's cached parameters. + + Returns + ------- + tuple[FitProblem, EvalCounter, Curve] + The assembled problem, the evaluation counter wrapping the fit + function (exposes ``count`` for evaluation bookkeeping), and the + ``Curve`` model itself. The ``Curve`` is surfaced directly + because ``FitProblem.fitness`` is deprecated in BUMPS (>= 1.0.4 + it emits a ``UserWarning``) — callers must not go through it. + """ + fit_func = EvalCounter(engine._generate_fit_function()) + + bumps_pars = {} + if not parameters: + for name, par in engine._cached_pars.items(): + bumps_pars[PARAMETER_PREFIX + str(name)] = to_bumps_parameter(par) + else: + for par in parameters: + bumps_pars[PARAMETER_PREFIX + par.unique_name] = to_bumps_parameter(par) + + curve = Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) + return FitProblem(curve), fit_func, curve + + +def parameter_names(problem: FitProblem) -> list[str]: + """Return the problem's parameter names with the prefix stripped. + + Parameters + ---------- + problem : FitProblem + A BUMPS problem built by :func:`build_curve_problem`. + + Returns + ------- + list[str] + Parameter names in problem order, without ``PARAMETER_PREFIX``. + """ + return [(p.name or '')[len(PARAMETER_PREFIX) :] for p in problem._parameters] + + +def parameter_snapshot(problem: FitProblem, point: np.ndarray | None) -> dict: + """Snapshot the problem's parameter values as ``{name: value}``. + + Parameters + ---------- + problem : FitProblem + A BUMPS problem built by :func:`build_curve_problem`. + point : np.ndarray | None + Parameter values to report; when ``None`` the problem's current + values (``problem.getp()``) are used. + + Returns + ------- + dict + Mapping of prefix-stripped parameter names to ``float`` values. + """ + labels = problem.labels() + values = problem.getp() if point is None else point + snapshot = {} + for label, value in zip(labels, values): + snapshot[label[len(PARAMETER_PREFIX) :]] = float(value) + return snapshot diff --git a/src/easyscience/fitting/minimizers/bumps_utils/validation.py b/src/easyscience/fitting/minimizers/bumps_utils/validation.py new file mode 100644 index 00000000..54d3e466 --- /dev/null +++ b/src/easyscience/fitting/minimizers/bumps_utils/validation.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Input validation shared by the BUMPS minimizer and the DREAM sampler.""" + +from __future__ import annotations + +import numpy as np + + +def validate_run_settings(samples: int, burn: int, thin: int) -> None: + """Validate the DREAM run settings. + + Parameters + ---------- + samples : int + Number of raw samples to draw; must be a positive integer. + burn : int + Burn-in generations to discard; must be a non-negative integer. + thin : int + Thinning interval; must be a positive integer. + + Raises + ------ + ValueError + If any value is out of range or not an integer. + """ + if not isinstance(samples, int) or samples <= 0: + raise ValueError('samples must be a positive integer.') + if not isinstance(burn, int) or burn < 0: + raise ValueError('burn must be a non-negative integer.') + if not isinstance(thin, int) or thin < 1: + raise ValueError('thin must be a positive integer.') + + +def validate_arrays( + x: np.ndarray, + y: np.ndarray, + weights: np.ndarray, + *, + check_finite_xy: bool = True, +) -> None: + """Validate the (x, y, weights) arrays for a BUMPS problem. + + Checks shape agreement between the three arrays, finiteness and + strict positivity of the weights, and — when ``check_finite_xy`` is + ``True`` — finiteness of x and y. Sampling passes ``True``; the + classical fit path passes ``False`` to keep its historically more + permissive behaviour. + + Parameters + ---------- + x : np.ndarray + Independent variable array. + y : np.ndarray + Dependent variable array. + weights : np.ndarray + Weight array (converted to ``dy = 1 / weights`` downstream). + check_finite_xy : bool, default=True + Also require x and y to be free of NaN/infinite values. + + Raises + ------ + ValueError + If the shapes disagree, the weights are non-finite or + non-positive, or (with ``check_finite_xy``) x/y are non-finite. + """ + if y.shape != x.shape: + raise ValueError('x and y must have the same shape.') + + if check_finite_xy: + if not np.isfinite(x).all(): + raise ValueError('x cannot contain NaN or infinite values.') + if not np.isfinite(y).all(): + raise ValueError('y cannot contain NaN or infinite values.') + + if weights.shape != x.shape: + raise ValueError('Weights must have the same shape as x and y.') + + if not np.isfinite(weights).all(): + raise ValueError('Weights cannot be NaN or infinite.') + + if (weights <= 0).any(): + raise ValueError('Weights must be strictly positive and non-zero.') diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 48eef544..923881ce 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -1,17 +1,10 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from abc import ABCMeta from abc import abstractmethod -from inspect import Parameter as InspectParameter -from inspect import Signature -from inspect import _empty from typing import Any from typing import Callable -from typing import Dict from typing import List -from typing import Tuple -from typing import Union import numpy as np @@ -20,20 +13,23 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers +from ..engine_base import PARAMETER_PREFIX +from ..engine_base import EngineBase from .utils import FitError from .utils import FitResults -MINIMIZER_PARAMETER_PREFIX = 'p' +# Back-compat alias: the canonical constant now lives in +# ``easyscience.fitting.engine_base`` and is shared by minimizers and +# samplers alike. +MINIMIZER_PARAMETER_PREFIX = PARAMETER_PREFIX -class MinimizerBase(metaclass=ABCMeta): +class MinimizerBase(EngineBase): """ This template class is the basis for all minimizer engines in ``EasyScience``. """ - package: str = None - def __init__( self, obj, #: ObjBase, @@ -42,14 +38,10 @@ def __init__( ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 if minimizer_enum.method not in self.supported_methods(): raise FitError(f'Method {minimizer_enum.method} not available in {self.__class__}') - self._object = obj - self._original_fit_function = fit_function + super().__init__(obj=obj, fit_function=fit_function) self._minimizer_enum = minimizer_enum self._method = minimizer_enum.method - self._cached_pars: Dict[str, Parameter] = {} - self._cached_pars_vals: Dict[str, Tuple[float]] = {} self._cached_model = None - self._fit_function = None @property def enum(self) -> AvailableMinimizers: @@ -59,11 +51,6 @@ def enum(self) -> AvailableMinimizers: def name(self) -> str: return self._minimizer_enum.name - def _restore_parameter_values(self) -> None: - for key in self._cached_pars.keys(): - self._cached_pars[key].value = self._cached_pars_vals[key][0] - self._cached_pars[key].error = self._cached_pars_vals[key][1] - @abstractmethod def fit( self, @@ -110,50 +97,6 @@ def fit( Fit results. """ - def evaluate( - self, x: np.ndarray, minimizer_parameters: dict[str, float] | None = None, **kwargs - ) -> np.ndarray: - """ - Evaluate the fit function for values of x. - - Parameters used are either the latest or user supplied. If the - parameters are user supplied, it must be in a dictionary of - {'parameter_name': parameter_value,...}. - - Parameters - ---------- - x : np.ndarray - X values for which the fit function will be evaluated. - minimizer_parameters : dict[str, float] | None, default=None - Dictionary of parameters which will be used in the fit - function. They must be in a dictionary of {'parameter_name': - parameter_value,...}. By default, None. - **kwargs : - Additional arguments. - - Returns - ------- - np.ndarray - Y values calculated at points x for a set of parameters. - - Raises - ------ - TypeError - If ``minimizer_parameters`` is not a dictionary. - """ - if minimizer_parameters is None: - minimizer_parameters = {} - if not isinstance(minimizer_parameters, dict): - raise TypeError('minimizer_parameters must be a dictionary') - - if self._fit_function is None: - # This will also generate self._cached_pars - self._fit_function = self._generate_fit_function() - - minimizer_parameters = self._prepare_parameters(minimizer_parameters) - - return self._fit_function(x, **minimizer_parameters, **kwargs) - def _get_method_kwargs(self, passed_method: str | None = None) -> dict[str, str]: if passed_method is not None: if passed_method not in self.supported_methods(): @@ -215,112 +158,6 @@ def convert_to_par_object(obj): # todo after constraint changes, add type hint: engine Parameter object. """ - def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]: - """ - Prepare the parameters for the minimizer. - - Parameters - ---------- - parameters : dict[str, float] - Dict of parameters for the minimizer with names as keys. - - Returns - ------- - dict[str, float] - Completed parameter dictionary for the minimizer. - """ - pars = self._cached_pars - - for name, item in pars.items(): - parameter_name = MINIMIZER_PARAMETER_PREFIX + str(name) - if parameter_name not in parameters.keys(): - parameters[parameter_name] = item.value - return parameters - - def _generate_fit_function(self) -> Callable: - """ - Using the user supplied ``fit_function``, wrap it in such a way - we can update ``Parameter`` on iterations. - - Returns - ------- - Callable - A fit function which is compatible with bumps models. - """ - # Original fit function - func = self._original_fit_function - # Get a list of `Parameters` - self._cached_pars = {} - self._cached_pars_vals = {} - for parameter in self._object.get_fit_parameters(): - key = parameter.unique_name - self._cached_pars[key] = parameter - self._cached_pars_vals[key] = (parameter.value, parameter.error) - - # Make a new fit function - def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray: - """ - Wrapped fit function which now has an EasyScience compatible - form. - - Parameters - ---------- - x : np.ndarray - Array of data points to be calculated. - **kwargs : - Key word arguments. - - Returns - ------- - np.ndarray - Points calculated at ``x``. - """ - # Update the `Parameter` values and the callback if needed - # TODO THIS IS NOT THREAD SAFE :-( - - for name, value in kwargs.items(): - par_name = name[1:] - if par_name in self._cached_pars.keys(): - # This will take into account constraints - if self._cached_pars[par_name].value != value: - self._cached_pars[par_name].value = value - - # Since we are calling the parameter fset will be called. - # TODO Pre processing here - return_data = func(x) - # TODO Loading or manipulating data here - return return_data - - _fit_function.__signature__ = self._create_signature(self._cached_pars) - return _fit_function - - @staticmethod - def _create_signature(parameters: Dict[int, Parameter]) -> Signature: - """ - Wrap the function signature. - - This is done as lmfit wants the function to be in the form: f = - (x, a=1, b=2)... Where we need to be generic. Note that this - won't hold for much outside of this scope. - """ - wrapped_parameters = [] - wrapped_parameters.append( - InspectParameter('x', InspectParameter.POSITIONAL_OR_KEYWORD, annotation=_empty) - ) - - for name, parameter in parameters.items(): - default_value = parameter.value - - wrapped_parameters.append( - InspectParameter( - MINIMIZER_PARAMETER_PREFIX + str(name), - InspectParameter.POSITIONAL_OR_KEYWORD, - annotation=_empty, - default=default_value, - ) - ) - return Signature(wrapped_parameters) - @staticmethod def _error_from_jacobian( jacobian: np.ndarray, residuals: np.ndarray, confidence: float = 0.95 diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 315d856c..ea908fb4 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -4,7 +4,7 @@ from __future__ import annotations import copy -import math +import warnings from typing import TYPE_CHECKING from typing import Any from typing import Callable @@ -13,7 +13,6 @@ from bumps.fitters import FIT_AVAILABLE_IDS from bumps.fitters import FITTERS from bumps.fitters import FitDriver -from bumps.names import Curve from bumps.names import FitProblem from bumps.parameter import Parameter as BumpsParameter @@ -24,6 +23,10 @@ from ..available_minimizers import AvailableMinimizers from .bumps_utils import BumpsProgressMonitor from .bumps_utils import EvalCounter +from .bumps_utils import build_curve_problem +from .bumps_utils import parameter_snapshot +from .bumps_utils import to_bumps_parameter +from .bumps_utils import validate_arrays from .minimizer_base import MINIMIZER_PARAMETER_PREFIX from .minimizer_base import MinimizerBase from .utils import FitError @@ -150,17 +153,7 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + validate_arrays(x, y, weights, check_finite_xy=False) if engine_kwargs is None: engine_kwargs = {} @@ -193,14 +186,17 @@ def fit( minimizer_kwargs['steps'] = max_evaluations if model is None: - model_function = self._make_model(parameters=parameters) - model = model_function(x, y, weights) + # The Curve comes back directly from the helper: do NOT read it + # from ``problem.fitness``, which is deprecated in BUMPS and warns. + problem, self._eval_counter, model = build_curve_problem( + self, x, y, weights, parameters=parameters + ) + else: + problem = FitProblem(model) self._cached_model = model self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} - problem = FitProblem(model) - monitors = [] if progress_callback is not None: if not callable(progress_callback): @@ -275,7 +271,7 @@ def _build_progress_payload( chi2 = float(problem.chisq(nllf=nllf, norm=False)) reduced_chi2 = float(problem.chisq(nllf=nllf, norm=True)) - parameter_values = self._current_parameter_snapshot(problem, point) + parameter_values = parameter_snapshot(problem, point) return { 'iteration': iteration, @@ -286,15 +282,6 @@ def _build_progress_payload( 'finished': False, } - def _current_parameter_snapshot(self, problem, point: np.ndarray) -> dict: - labels = problem.labels() - values = problem.getp() if point is None else point - snapshot = {} - for label, value in zip(labels, values): - dict_name = label[len(MINIMIZER_PARAMETER_PREFIX) :] - snapshot[dict_name] = float(value) - return snapshot - def convert_to_pars_obj(self, par_list: list[Parameter] | None = None) -> list[BumpsParameter]: """ Create a container with the ``Parameters`` converted from the @@ -334,56 +321,7 @@ def convert_to_par_object(obj: Parameter) -> BumpsParameter: BumpsParameter Bumps Parameter compatible object. """ - - value = obj.value - - return BumpsParameter( - name=MINIMIZER_PARAMETER_PREFIX + obj.unique_name, - value=value, - bounds=[obj.min, obj.max], - fixed=obj.fixed, - ) - - def _make_model(self, parameters: list[BumpsParameter] | None = None) -> Callable: - """ - Generate a bumps model from the supplied ``fit_function`` and - parameters in the base object. Note that this makes a callable - as it needs to be initialized with *x*, *y*, *weights* - - Weights are converted to dy (standard deviation of y). - - Parameters - ---------- - parameters : list[BumpsParameter] | None, default=None - Optional BUMPS parameters to bind into the model. - - Returns - ------- - Callable - Callable to make a bumps Curve model. - """ - fit_func = EvalCounter(self._generate_fit_function()) - self._eval_counter = fit_func - - def _outer(obj): - - def _make_func(x, y, weights): - bumps_pars = {} - if not parameters: - for name, par in obj._cached_pars.items(): - bumps_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = ( - obj.convert_to_par_object(par) - ) - else: - for par in parameters: - bumps_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = ( - obj.convert_to_par_object(par) - ) - return Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) - - return _make_func - - return _outer(self) + return to_bumps_parameter(obj) def mcmc_sample( self, @@ -402,12 +340,6 @@ def mcmc_sample( """ Run Bayesian MCMC sampling using the BUMPS DREAM sampler. - Builds a BUMPS ``FitProblem`` from the current model and runs - the DREAM sampler. This is the public minimizer-level entry - point for Bayesian sampling; the higher-level - ``MultiFitter.mcmc_sample`` delegates to this method after - flattening multi-dataset arrays. - Parameters ---------- x : np.ndarray @@ -485,229 +417,28 @@ def mcmc_sample( Re-raised from DREAM fitting if any unexpected error occurs (parameter values are restored beforehand). """ - from bumps.fitters import DreamFit - from bumps.names import FitProblem - - x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - - if not isinstance(samples, int) or samples <= 0: - raise ValueError('samples must be a positive integer.') - if not isinstance(burn, int) or burn < 0: - raise ValueError('burn must be a non-negative integer.') - if not isinstance(thin, int) or thin < 1: - raise ValueError('thin must be a positive integer.') - - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if not np.isfinite(x).all(): - raise ValueError('x cannot contain NaN or infinite values.') - if not np.isfinite(y).all(): - raise ValueError('y cannot contain NaN or infinite values.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') - - # Build the BUMPS Curve model using the minimizer's existing machinery - model_func = self._make_model() - curve = model_func(x, y, weights) - problem = FitProblem(curve) - - pop = population - if resume_state is not None: - pop, burn = self._validate_resume_state(problem, resume_state, population, burn) - - # Build DREAM kwargs. Use the resolved ``pop``, not the raw - # ``population`` argument — on resume ``pop`` is the negative - # absolute chain count that reproduces the saved state's - # population, which BUMPS requires to match. - dream_kwargs: dict = {'samples': samples, 'burn': burn, 'thin': thin} - if pop is not None: - dream_kwargs['pop'] = pop - if sampler_kwargs: - dream_kwargs.update(sampler_kwargs) - - # Build monitors (same pattern as classical Bumps.fit()) - monitors = [] - if progress_callback is not None: - if not callable(progress_callback): - raise ValueError('progress_callback must be callable') - # Compute total DREAM steps for progress display (burn + sampling generations). - # BUMPS DREAM default population count is 10 when not specified by the user. - # A negative ``pop`` (resume) is an absolute chain count. - _dream_default_pop = 10 - pop_val = abs(pop) if pop is not None else _dream_default_pop - _total_steps = burn + (samples + pop_val - 1) // pop_val - monitors.append( - BumpsProgressMonitor( - problem, - progress_callback, - lambda problem, iteration, point, nllf: { - **self._build_sample_progress_payload(problem, iteration, point, nllf), - 'total_steps': _total_steps, - }, - ) - ) - - driver = FitDriver( - fitclass=DreamFit, - problem=problem, - monitors=monitors, - abort_test=abort_test if abort_test is not None else (lambda: False), - **dream_kwargs, + warnings.warn( + 'Bumps.mcmc_sample() is deprecated. Use easyscience.fitting.Sampler ' + '(which no longer requires a BUMPS minimizer) instead.', + DeprecationWarning, + stacklevel=2, + ) + from ..samplers.sampler_dream import DreamSampler + + engine = DreamSampler(self._object, self._original_fit_function) + return engine.run( + x=x, + y=y, + weights=weights, + samples=samples, + burn=burn, + thin=thin, + population=population, + resume_state=resume_state, + sampler_kwargs=sampler_kwargs, + progress_callback=progress_callback, + abort_test=abort_test, ) - driver.clip() - - from easyscience import global_object - - stack_status = global_object.stack.enabled - global_object.stack.enabled = False - - try: - fit_kwargs = {} - if resume_state is not None: - # Defensive copy: BUMPS mutates the state object in-place - # (via MCMCDraw.resize() — see bumps/dream/core.py allocate_state) - # during resume. Without a copy, the caller's original state - # object is silently altered, making it impossible to compare - # pre- and post-resume state (shape mismatch). See - # https://github.com/easyscience/core/pull/257 - fit_kwargs['fit_state'] = copy.deepcopy(resume_state) - x_opt, fx = driver.fit(**fit_kwargs) - result_state = getattr(driver.fitter, 'state', None) - if result_state is None: - raise FitError('Sampling aborted by user') - except Exception: - self._restore_parameter_values() - raise - finally: - global_object.stack.enabled = stack_status - - _draw = result_state.draw() - draws = _draw.points - param_names = [p.name[len(MINIMIZER_PARAMETER_PREFIX) :] for p in problem._parameters] - logp = _draw.logp - - return { - 'draws': draws, - 'param_names': param_names, - 'internal_bumps_object': result_state, - 'logp': logp, - } - - def _validate_resume_state( - self, - problem: FitProblem, - resume_state: MCMCDraw, - population: int | None, - burn: int, - ) -> tuple[int, int]: - """Check that ``resume_state`` is compatible with ``problem`` and - resolve the population and burn values to use when resuming. - - Parameters - ---------- - problem : FitProblem - The freshly built BUMPS ``FitProblem`` for the current model. - resume_state : MCMCDraw - The saved chain state to resume from. - population : int | None - The caller-supplied population scale factor, or ``None``. - burn : int - The caller-supplied burn-in, ignored (with a warning) on resume. - - Returns - ------- - tuple[int, int] - ``(population, burn)`` to pass to DREAM. The population is - returned as a **negative** number, which BUMPS' - ``initpop.generate`` reads as an absolute chain count, exactly - reproducing the saved state's population. ``burn`` is always 0: - a previously converged chain is never re-burned. - - Raises - ------ - ValueError - If ``resume_state`` is incompatible with the current model - (parameter count, names/order, or population mismatch). - """ - from easyscience import global_object - - logger = global_object.log.getLogger('fitting.bumps') - - # Parameter count - n_params = len(problem._parameters) - if n_params != resume_state.Nvar: - raise ValueError( - f'resume_state has {resume_state.Nvar} parameters but the current ' - f'model has {n_params}. The model must have the same ' - f'number of fitted parameters as when the saved chain was created.' - ) - - prefix = MINIMIZER_PARAMETER_PREFIX - fresh_names = [(p.name or '')[len(prefix) :] for p in problem._parameters] - state_labels = list(resume_state.labels) - if state_labels and all(lbl.startswith(prefix) for lbl in state_labels): - state_names = [lbl[len(prefix) :] for lbl in state_labels] - if fresh_names != state_names: - raise ValueError( - f'Parameter names/order mismatch between the current model ' - f'and resume_state.\n' - f' Current model : {fresh_names}\n' - f' resume_state : {state_names}' - ) - else: - logger.warning( - 'resume_state does not carry parameter names (it was most ' - 'likely reloaded from disk, where BUMPS does not preserve ' - 'labels). Parameter-name validation is skipped; the saved ' - 'chain is matched to the current model by parameter order. ' - 'Ensure this is the same model, with parameters in the same ' - 'order, used to create the chain.' - ) - - # Population. BUMPS creates ``ceil(population * n_params)`` chains - # and requires the resumed state's chain count to match. - if population is not None: - expected_npop = math.ceil(population * n_params) - if expected_npop != resume_state.Npop: - raise ValueError( - f'Requested population ({population}) would produce ' - f'{expected_npop} chains but the saved state has ' - f'{resume_state.Npop} chains. The population cannot ' - f'be changed on resume.' - ) - if burn > 0: - logger.warning( - f'burn={burn} ignored on resume: a previously converged ' - f'chain is not re-burned. Forcing burn=0.' - ) - - # A negative ``pop`` is read by ``bumps.initpop.generate`` as an - # absolute chain count, exactly reproducing the saved population - # without having to recover the original scale factor. - return -int(resume_state.Npop), 0 - - def _build_sample_progress_payload( - self, problem, iteration: int, point: np.ndarray, nllf: float - ) -> dict: - """ - Build a progress payload for Bayesian DREAM sampling steps. - - Called by :class:`BumpsProgressMonitor` at each DREAM - generation. The payload includes ``sampling: True`` so - downstream consumers can distinguish sampling progress from - classical fitting progress. - """ - payload = self._build_progress_payload(problem, iteration, point, nllf) - payload['sampling'] = True - return payload def _set_parameter_fit_result( self, diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index d3915115..96414e27 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -15,7 +15,7 @@ from easyscience import global_object -from .minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX +from .engine_base import PARAMETER_PREFIX if TYPE_CHECKING: # avoid import cycles; only needed for type hints from bumps.dream.state import MCMCDraw @@ -218,9 +218,7 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s # save_state/load_state does not preserve labels, so a reloaded state # typically carries default labels like ['P0', 'P1', ...]. param_names = [ - lbl[len(MINIMIZER_PARAMETER_PREFIX) :] - if lbl.startswith(MINIMIZER_PARAMETER_PREFIX) - else lbl + lbl[len(PARAMETER_PREFIX) :] if lbl.startswith(PARAMETER_PREFIX) else lbl for lbl in state.labels ] @@ -263,7 +261,7 @@ def to_legacy_dict(self) -> dict: class Sampler: - """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer. + """Bayesian MCMC sampler for one dataset, backed by the BUMPS DREAM engine. One ``Sampler`` instance represents one chain over one ``(x, y, weights)`` dataset. The data is bound at construction; ``sample()`` and ``extend()`` @@ -274,11 +272,12 @@ class Sampler: effect on the sampler, and there are deliberately no setters: to sample different data, create a new ``Sampler``. - Construct directly with a configured ``Fitter`` (or ``MultiFitter``) whose - minimizer has been switched to ``AvailableMinimizers.Bumps``. **Running a - fit first is not required** — the ``Fitter`` supplies the model and the - minimizer, not a fit result, and sampling from the initial parameter values - works fine. + Construct directly with a configured ``Fitter`` (or ``MultiFitter``). + Sampling is independent of the fitter's minimizer — any minimizer (LMFit, + DFO, BUMPS) may stay active; the only requirement is an installed + ``bumps`` package. **Running a fit first is not required** — the + ``Fitter`` supplies the model and fit function, not a fit result, and + sampling from the initial parameter values works fine. It is often worth fitting first anyway. DREAM seeds its whole starting population inside a tiny ball around the parameters' *current* values @@ -286,14 +285,15 @@ class Sampler: chain in the right region and shortens the burn-in needed to reach the typical set. From a poor initial guess, expect to burn for longer. - The sampler is BUMPS/DREAM-specific for now: the BUMPS check in ``_run()`` - is the seam where another backend would plug in. + The sampler is BUMPS/DREAM-specific for now: the ``DreamSampler`` + construction in ``_run()`` is the single line that knows a concrete + backend exists — the seam where a sampler factory would plug in. Parameters ---------- fitter : Fitter - A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been - switched to ``AvailableMinimizers.Bumps``. + A configured ``Fitter`` (or ``MultiFitter``) supplying the model and + fit function. Its active minimizer is irrelevant to sampling. x : np.ndarray | list[np.ndarray] Independent variable array (or list of arrays for ``MultiFitter``). y : np.ndarray | list[np.ndarray] @@ -484,46 +484,49 @@ def _run( """Shared sampling engine for ``sample()`` and ``extend()``. Argument validation for ``samples``/``burn``/``thin`` lives in - ``Bumps.mcmc_sample`` (single source of truth). + ``DreamSampler.run``. """ - # Check the minimizer is BUMPS *before* mutating the fitter — a - # non-BUMPS fitter must not be needlessly rebuilt. - minimizer = self._fitter.minimizer - if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'): + from .available_minimizers import bumps_engine_available + + if not bumps_engine_available: raise RuntimeError( - 'Bayesian sampling requires a BUMPS minimizer. ' - 'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.' + 'Bayesian sampling requires the bumps package. ' + 'Install it with ``pip install bumps``.' ) + from .samplers.sampler_dream import DreamSampler x_fit, x_new, y_new, w_new, dims = self._fitter._precompute_reshaping( self._x, self._y, self._weights, self._vectorized ) + # Required internal bookkeeping write: MultiFitter's + # ``_fit_function_wrapper`` reads ``_dependent_dims`` to reshape + # multi-dataset output. It is the only fitter attribute sampling + # modifies: the user-visible surface (fit_function, minimizer) is + # never mutated. self._fitter._dependent_dims = dims wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True) merged_kwargs = {**self._default_sampler_kwargs, **(sampler_kwargs or {})} - original_fit_func = self._fitter.fit_function - # Assigning fit_function triggers _update_minimizer() and *rebuilds* - # the minimizer object — it must be re-fetched after this assignment. - self._fitter.fit_function = wrapped - try: - minimizer = self._fitter.minimizer - result = minimizer.mcmc_sample( - x=x_fit, - y=y_new, - weights=w_new, - samples=samples, - burn=burn, - thin=thin, - population=population, - resume_state=resume_state, - sampler_kwargs=merged_kwargs or None, - progress_callback=progress_callback, - abort_test=abort_test, - ) - finally: - self._fitter.fit_function = original_fit_func + # A fresh engine per run is deliberate: it is cheap (the parameter + # cache is built lazily), and per-call construction guarantees the + # chain always sees the fitter's *current* fit function and + # parameter set. Chain continuity lives in ``resume_state``, not in + # engine. + engine = DreamSampler(obj=self._fitter.fit_object, fit_function=wrapped) + result = engine.run( + x=x_fit, + y=y_new, + weights=w_new, + samples=samples, + burn=burn, + thin=thin, + population=population, + resume_state=resume_state, + sampler_kwargs=merged_kwargs or None, + progress_callback=progress_callback, + abort_test=abort_test, + ) results = SamplingResults( draws=result['draws'], @@ -591,7 +594,7 @@ def sample( Exceptions propagate from the sampling engine: ``ValueError`` if ``samples``, ``burn``, or ``thin`` are invalid, and ``RuntimeError`` - if the active minimizer is not a BUMPS instance. + if the ``bumps`` package is not installed. """ if self._state is not None: global_object.log.getLogger('fitting').warning( @@ -660,7 +663,8 @@ def extend( ------ RuntimeError If there is no chain to extend (call ``sample()`` or - ``load_state()`` first), or the minimizer is not BUMPS. + ``load_state()`` first), or the ``bumps`` package is not + installed. Notes ----- diff --git a/src/easyscience/fitting/samplers/__init__.py b/src/easyscience/fitting/samplers/__init__.py new file mode 100644 index 00000000..cfdae878 --- /dev/null +++ b/src/easyscience/fitting/samplers/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from .sampler_dream import DreamSampler + +__all__ = ['DreamSampler'] diff --git a/src/easyscience/fitting/samplers/sampler_dream.py b/src/easyscience/fitting/samplers/sampler_dream.py new file mode 100644 index 00000000..bbc62014 --- /dev/null +++ b/src/easyscience/fitting/samplers/sampler_dream.py @@ -0,0 +1,381 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""The BUMPS DREAM MCMC engine — ``DreamSampler``. + +One file per sampling backend, mirroring the one-file-per-minimizer +layout under ``fitting/minimizers/``. When a second MCMC backend +arrives, its ``run()`` signature is formalized as a ``SamplerBase`` ABC +and dispatched via a factory (see discussion easyscience/core#280). +""" + +from __future__ import annotations + +import copy +import math +from typing import TYPE_CHECKING +from typing import Callable + +import numpy as np +from bumps.fitters import FitDriver + +from ..engine_base import PARAMETER_PREFIX +from ..engine_base import EngineBase +from ..minimizers.bumps_utils import BumpsProgressMonitor +from ..minimizers.bumps_utils import build_curve_problem +from ..minimizers.bumps_utils import parameter_names +from ..minimizers.bumps_utils import parameter_snapshot +from ..minimizers.bumps_utils import validate_arrays +from ..minimizers.bumps_utils import validate_run_settings +from ..minimizers.utils import FitError + +if TYPE_CHECKING: + from bumps.dream.state import MCMCDraw + from bumps.names import FitProblem + + +class DreamSampler(EngineBase): + """ + BUMPS DREAM MCMC engine. Runs and resumes chains for one + ``(obj, fit_function)`` binding. + + This is the minimizer-independent home of Bayesian sampling: it + builds its own BUMPS ``FitProblem`` via the shared ``bumps_utils`` + helpers, so sampling no longer requires the ``Fitter``'s active + minimizer to be BUMPS — only an installed ``bumps`` package. + + ``DreamSampler`` is internal machinery; the public entry point is + :class:`easyscience.fitting.Sampler`. + """ + + package = 'bumps' + + def __init__( + self, + obj, #: ObjBase, + fit_function: Callable, + ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + """ + Initialize the sampling engine. + + Parameters + ---------- + obj : object + Object containing the ``Parameter`` instances to sample. + fit_function : Callable + Callable returning model y values for the supplied x values. + """ + super().__init__(obj=obj, fit_function=fit_function) + + def run( + self, + x: np.ndarray, + y: np.ndarray, + weights: np.ndarray | None, + samples: int = 10000, + burn: int = 2000, + thin: int = 10, + population: int | None = None, + resume_state: MCMCDraw | None = None, + sampler_kwargs: dict | None = None, + progress_callback: Callable[[dict], bool | None] | None = None, + abort_test: Callable[[], bool] | None = None, + ) -> dict: + """ + Run Bayesian MCMC sampling using the BUMPS DREAM sampler. + + Builds a BUMPS ``FitProblem`` from the bound object and fit + function and runs the DREAM sampler. This is the engine-level + entry point for Bayesian sampling; the public + :class:`~easyscience.fitting.Sampler` delegates to this method + after flattening its bound data. + + Parameters + ---------- + x : np.ndarray + Flattened independent variable array. + y : np.ndarray + Flattened dependent variable array. + weights : np.ndarray | None + Flattened weight array. Must not be ``None`` — sampling has + no default weighting; a clear ``ValueError`` is raised. + samples : int, default=10000 + Number of raw samples to draw across all chains, before thinning. + A guaranteed minimum, not an exact count: DREAM advances in + blocks of 10 generations (one generation = one draw per chain) + and stops at the first block boundary at or past ``samples``. + burn : int, default=2000 + Burn-in generations to discard. BUMPS counts ``burn`` in + generations while ``samples`` counts raw draws, so ``burn=500`` + discards ``500 * n_chains`` raw samples. + thin : int, default=10 + Thinning interval — only every ``thin``-th generation is stored. + population : int | None, default=None + BUMPS DREAM population count per parameter (number of parallel + chains): BUMPS creates ``ceil(population * n_parameters)`` chains. + resume_state : MCMCDraw | None, default=None + A BUMPS ``MCMCDraw`` state object from a previous ``run()`` + call. When provided, DREAM **continues** the saved chain + instead of starting cold. The population, parameter count, + and parameter names must match the current model — a + ``ValueError`` is raised otherwise. + + ``samples`` must be the **total** number of raw samples, not an + increment: to extend an existing chain of ``N`` raw samples by + ``M``, pass ``samples=N + M`` (DREAM keeps only the last + ``samples`` draws in its buffer). The `Sampler.extend` helper + computes this for you. + + ``burn`` is forced to 0 on resume: a previously-converged chain is + never re-burned. + + The ``population`` and ``initializer`` parameters + have **no effect** when ``resume_state`` is provided — they + are determined by the saved state. + + Resuming against *different* data is undefined behaviour (the + chain's likelihood changes underneath it). + sampler_kwargs : dict | None, default=None + Additional keyword arguments forwarded to + ``bumps.fitters.fit``. + progress_callback : Callable[[dict], bool | None] | None, default=None + Optional callback for progress updates during sampling. The + payload dict includes ``iteration`` (DREAM generation + number) and ``sampling: True``. + abort_test : Callable[[], bool] | None, default=None + Optional callback that returns ``True`` to signal that + sampling should be aborted. Called periodically during the + DREAM sampling loop. + + Returns + ------- + dict + Dictionary with keys ``'draws'``, ``'param_names'``, + ``'internal_bumps_object'``, and ``'logp'``. + + Raises + ------ + ValueError + If the input shapes or weights are invalid, if + ``progress_callback`` is not callable, or if ``resume_state`` + is incompatible with the current model (parameter count, + names/order, or population mismatch). + FitError + If DREAM sampling was aborted by the user (via + ``abort_test``). + Exception + Re-raised from DREAM fitting if any unexpected error occurs + (parameter values are restored beforehand). + """ + from bumps.fitters import DreamFit + + if weights is None: + raise ValueError( + 'weights must not be None for Bayesian sampling. Pass ' + 'measurement weights (e.g. ``1 / sigma``) matching x and y.' + ) + x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) + + validate_run_settings(samples, burn, thin) + validate_arrays(x, y, weights, check_finite_xy=True) + + # Build the BUMPS Curve model around the engine's wrapped fit function + problem, _, _ = build_curve_problem(self, x, y, weights) + + pop = population + if resume_state is not None: + pop, burn = self._validate_resume_state(problem, resume_state, population, burn) + + # Build DREAM kwargs. Use the resolved ``pop``, not the raw + # ``population`` argument — on resume ``pop`` is the negative + # absolute chain count that reproduces the saved state's + # population, which BUMPS requires to match. + dream_kwargs: dict = {'samples': samples, 'burn': burn, 'thin': thin} + if pop is not None: + dream_kwargs['pop'] = pop + if sampler_kwargs: + dream_kwargs.update(sampler_kwargs) + + # Build monitors (same pattern as classical Bumps.fit()) + monitors = [] + if progress_callback is not None: + if not callable(progress_callback): + raise ValueError('progress_callback must be callable') + # Compute total DREAM steps for progress display (burn + sampling generations). + # BUMPS DREAM default population count is 10 when not specified by the user. + # A negative ``pop`` (resume) is an absolute chain count. + _dream_default_pop = 10 + pop_val = abs(pop) if pop is not None else _dream_default_pop + _total_steps = burn + (samples + pop_val - 1) // pop_val + monitors.append( + BumpsProgressMonitor( + problem, + progress_callback, + lambda problem, iteration, point, nllf: { + **self._build_sample_progress_payload(problem, iteration, point, nllf), + 'total_steps': _total_steps, + }, + ) + ) + + driver = FitDriver( + fitclass=DreamFit, + problem=problem, + monitors=monitors, + abort_test=abort_test if abort_test is not None else (lambda: False), + **dream_kwargs, + ) + driver.clip() + + from easyscience import global_object + + stack_status = global_object.stack.enabled + global_object.stack.enabled = False + + try: + fit_kwargs = {} + if resume_state is not None: + # Defensive copy: BUMPS mutates the state object in-place + # (via MCMCDraw.resize() — see bumps/dream/core.py allocate_state) + # during resume. Without a copy, the caller's original state + # object is silently altered, making it impossible to compare + # pre- and post-resume state (shape mismatch). See + # https://github.com/easyscience/core/pull/257 + fit_kwargs['fit_state'] = copy.deepcopy(resume_state) + x_opt, fx = driver.fit(**fit_kwargs) + result_state = getattr(driver.fitter, 'state', None) + if result_state is None: + raise FitError('Sampling aborted by user') + except Exception: + self._restore_parameter_values() + raise + finally: + global_object.stack.enabled = stack_status + + _draw = result_state.draw() + + return { + 'draws': _draw.points, + 'param_names': parameter_names(problem), + 'internal_bumps_object': result_state, + 'logp': _draw.logp, + } + + def _validate_resume_state( + self, + problem: FitProblem, + resume_state: MCMCDraw, + population: int | None, + burn: int, + ) -> tuple[int, int]: + """Check that ``resume_state`` is compatible with ``problem`` and + resolve the population and burn values to use when resuming. + + Parameters + ---------- + problem : FitProblem + The freshly built BUMPS ``FitProblem`` for the current model. + resume_state : MCMCDraw + The saved chain state to resume from. + population : int | None + The caller-supplied population scale factor, or ``None``. + burn : int + The caller-supplied burn-in, ignored (with a warning) on resume. + + Returns + ------- + tuple[int, int] + ``(population, burn)`` to pass to DREAM. The population is + returned as a **negative** number, which BUMPS' + ``initpop.generate`` reads as an absolute chain count, exactly + reproducing the saved state's population. ``burn`` is always 0: + a previously converged chain is never re-burned. + + Raises + ------ + ValueError + If ``resume_state`` is incompatible with the current model + (parameter count, names/order, or population mismatch). + """ + from easyscience import global_object + + logger = global_object.log.getLogger('fitting.bumps') + + # Parameter count + n_params = len(problem._parameters) + if n_params != resume_state.Nvar: + raise ValueError( + f'resume_state has {resume_state.Nvar} parameters but the current ' + f'model has {n_params}. The model must have the same ' + f'number of fitted parameters as when the saved chain was created.' + ) + + prefix = PARAMETER_PREFIX + fresh_names = [(p.name or '')[len(prefix) :] for p in problem._parameters] + state_labels = list(resume_state.labels) + if state_labels and all(lbl.startswith(prefix) for lbl in state_labels): + state_names = [lbl[len(prefix) :] for lbl in state_labels] + if fresh_names != state_names: + raise ValueError( + f'Parameter names/order mismatch between the current model ' + f'and resume_state.\n' + f' Current model : {fresh_names}\n' + f' resume_state : {state_names}' + ) + else: + logger.warning( + 'resume_state does not carry parameter names (it was most ' + 'likely reloaded from disk, where BUMPS does not preserve ' + 'labels). Parameter-name validation is skipped; the saved ' + 'chain is matched to the current model by parameter order. ' + 'Ensure this is the same model, with parameters in the same ' + 'order, used to create the chain.' + ) + + # Population. BUMPS creates ``ceil(population * n_params)`` chains + # and requires the resumed state's chain count to match. + if population is not None: + expected_npop = math.ceil(population * n_params) + if expected_npop != resume_state.Npop: + raise ValueError( + f'Requested population ({population}) would produce ' + f'{expected_npop} chains but the saved state has ' + f'{resume_state.Npop} chains. The population cannot ' + f'be changed on resume.' + ) + if burn > 0: + logger.warning( + f'burn={burn} ignored on resume: a previously converged ' + f'chain is not re-burned. Forcing burn=0.' + ) + + # A negative ``pop`` is read by ``bumps.initpop.generate`` as an + # absolute chain count, exactly reproducing the saved population + # without having to recover the original scale factor. + return -int(resume_state.Npop), 0 + + def _build_sample_progress_payload( + self, problem, iteration: int, point: np.ndarray, nllf: float + ) -> dict: + """ + Build a progress payload for Bayesian DREAM sampling steps. + + Called by :class:`BumpsProgressMonitor` at each DREAM + generation. The payload includes ``sampling: True`` so + downstream consumers can distinguish sampling progress from + classical fitting progress; the remaining keys match the + classical-fit payload built by the minimizers. + """ + # Use the nllf already computed by the sampler to avoid a costly + # model re-evaluation, and let BUMPS apply its own chisq scaling. + chi2 = float(problem.chisq(nllf=nllf, norm=False)) + reduced_chi2 = float(problem.chisq(nllf=nllf, norm=True)) + + return { + 'iteration': iteration, + 'chi2': chi2, + 'reduced_chi2': reduced_chi2, + 'parameter_values': parameter_snapshot(problem, point), + 'refresh_plots': False, + 'finished': False, + 'sampling': True, + } diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index df17a65c..5224da73 100644 --- a/tests/integration/fitting/test_fitter.py +++ b/tests/integration/fitting/test_fitter.py @@ -354,6 +354,35 @@ def test_bumps_methods(fit_method): check_fit_results(result, sp_sin, ref_sin, x) +@pytest.mark.fast +def test_bumps_fit_emits_no_fitness_deprecation_warning(): + """Regression (CR-1): the classical BUMPS fit path must not read the + deprecated ``FitProblem.fitness`` property, which emits a ``UserWarning`` + on bumps >= 1.0.4 — the ``Curve`` comes back from ``build_curve_problem`` + directly.""" + import warnings + + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + + x = np.linspace(0, 5, 200) + weights = np.ones_like(x) + y = ref_sin(x) + + sp_sin.offset.fixed = False + sp_sin.phase.fixed = False + + f = Fitter(sp_sin, sp_sin) + f.switch_minimizer('Bumps') + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + f.fit(x, y, weights=weights) + + fitness_warnings = [w for w in caught if 'fitness' in str(w.message)] + assert fitness_warnings == [] + + @pytest.mark.fast @pytest.mark.parametrize( 'fit_engine', diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index 032cc39f..3e244dfc 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -62,8 +62,13 @@ def __call__(self, x): ) -def _bumps_fitter_and_data(): - """Build a 2-parameter BUMPS MultiFitter over a small sine model.""" +def _fitter_and_data(): + """Build a 2-parameter MultiFitter over a small sine model. + + The fitter keeps its default (LMFit) minimizer: sampling no longer + requires switching to BUMPS, only an installed ``bumps`` package. + """ + pytest.importorskip('bumps') ref_sin = AbsSin(0.2, np.pi) sp = AbsSin(0.354, 3.05) sp.offset.fixed = False @@ -72,10 +77,6 @@ def _bumps_fitter_and_data(): y = ref_sin(x) weights = np.ones_like(x) f = MultiFitter([sp], [sp]) - try: - f.switch_minimizer('Bumps') - except AttributeError: - pytest.skip('BUMPS is not installed') return f, sp, x, y, weights @@ -85,7 +86,7 @@ class TestSampler: @pytest.mark.filterwarnings('ignore::UserWarning') def test_sample_returns_results_object(self): """sample() returns a populated SamplingResults; to_legacy_dict() has the legacy shape.""" - f, sp, x, y, weights = _bumps_fitter_and_data() + f, sp, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) results = sampler.sample(samples=100, burn=20, thin=2) @@ -133,11 +134,8 @@ def test_sample_multi_dataset(self): sp_sin_1.phase.fixed = False sp_line.c.fixed = False + pytest.importorskip('bumps') f = MultiFitter([sp_sin_1, sp_line], [sp_sin_1, sp_line]) - try: - f.switch_minimizer('Bumps') - except AttributeError: - pytest.skip('BUMPS is not installed') sampler = Sampler(f, [x1, x2], [y1, y2], [weights, weights]) results = sampler.sample(samples=100, burn=20, thin=2) @@ -149,7 +147,7 @@ def test_sample_multi_dataset(self): def test_sample_population(self): """Passing population should succeed and produce valid draws.""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) results = sampler.sample(samples=100, burn=20, thin=2, population=5) @@ -169,11 +167,8 @@ def test_sample_vectorized_2d(self): sp.offset.fixed = False sp.phase.fixed = False + pytest.importorskip('bumps') f = MultiFitter([sp], [sp]) - try: - f.switch_minimizer('Bumps') - except AttributeError: - pytest.skip('BUMPS is not installed') sampler = Sampler(f, [x2D], [y2D], [weights], vectorized=True) results = sampler.sample(samples=100, burn=20, thin=2) @@ -185,7 +180,7 @@ def test_sample_vectorized_2d(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_fit_function_restored_on_success(self): """fit_function must be restored after a successful sample().""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) original_func = f.fit_function @@ -195,7 +190,7 @@ def test_fit_function_restored_on_success(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_sampler_kwargs_forwarded(self): """Per-call sampler_kwargs dict is forwarded to the BUMPS DREAM sampler.""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) results = sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'random'}) @@ -204,33 +199,47 @@ def test_sampler_kwargs_forwarded(self): assert results.draws.shape[0] > 0 @pytest.mark.filterwarnings('ignore::UserWarning') - def test_default_sampler_kwargs_merged(self): + def test_default_sampler_kwargs_merged(self, monkeypatch): """Constructor-level sampler_kwargs defaults are used; per-call kwargs win.""" - f, _, x, y, weights = _bumps_fitter_and_data() + from easyscience.fitting.samplers.sampler_dream import DreamSampler + + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'init': 'random'}) captured = {} - original_mcmc_sample = type(f.minimizer).mcmc_sample + original_run = DreamSampler.run def spy(self, **kwargs): captured.update(kwargs.get('sampler_kwargs') or {}) - return original_mcmc_sample(self, **kwargs) + return original_run(self, **kwargs) - try: - type(f.minimizer).mcmc_sample = spy - sampler.sample(samples=100, burn=20, thin=2) - assert captured == {'init': 'random'} + monkeypatch.setattr(DreamSampler, 'run', spy) - captured.clear() - sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'lhs'}) - assert captured == {'init': 'lhs'} # per-call overrides default - finally: - type(f.minimizer).mcmc_sample = original_mcmc_sample + sampler.sample(samples=100, burn=20, thin=2) + assert captured == {'init': 'random'} + + captured.clear() + sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'lhs'}) + assert captured == {'init': 'lhs'} # per-call overrides default + + @pytest.mark.filterwarnings('ignore::UserWarning') + def test_sample_with_lmfit_minimizer_active(self): + """Sampling works without switching the fitter's minimizer to BUMPS — + the new capability enabled by the ``DreamSampler`` engine (#280).""" + f, _, x, y, weights = _fitter_and_data() + assert f.minimizer.package == 'lmfit' # the default LMFit minimizer + + sampler = Sampler(f, [x], [y], [weights]) + results = sampler.sample(samples=100, burn=20, thin=2) + + assert results.draws.shape[0] > 0 + # The active minimizer is untouched by sampling. + assert f.minimizer.package == 'lmfit' @pytest.mark.filterwarnings('ignore::UserWarning') def test_extend_chain(self): """extend(additional_samples=) continues the chain; ring-buffer math is done for the user.""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) first = sampler.sample(samples=100, burn=20, thin=1) @@ -250,7 +259,7 @@ def test_extend_with_thinning_keeps_existing_draws(self): generations (``Ngen * Npop``), not from the retained-draw count, which BUMPS divides by the thinning interval. """ - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) first = sampler.sample(samples=1000, burn=20, thin=10) @@ -264,7 +273,7 @@ def test_extend_with_thinning_keeps_existing_draws(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_extend_total_samples_override(self): """extend(total_samples=) bypasses the additional_samples arithmetic.""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) sampler.sample(samples=100, burn=20, thin=1) @@ -282,7 +291,7 @@ def test_extend_after_save_load_roundtrip(self, tmp_path, caplog): """ import logging - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) first = sampler.sample(samples=100, burn=20, thin=1) @@ -308,7 +317,7 @@ def test_extend_preserves_nondefault_population(self): saved state on resume, otherwise BUMPS regenerates the default population and raises ``Cannot change Nvar, Npop or Ncr on resize``. """ - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) first = sampler.sample(samples=100, burn=20, thin=1, population=5) @@ -327,7 +336,7 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp logs a warning and records ``null`` in the sidecar.""" import logging - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) sampler.sample(samples=100, burn=20, thin=2) @@ -344,7 +353,7 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp @pytest.mark.filterwarnings('ignore::UserWarning') def test_load_state_populates_results(self, tmp_path): """A freshly loaded sampler reports draws/logp/param_names without resampling.""" - f, sp, x, y, weights = _bumps_fitter_and_data() + f, sp, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) first = sampler.sample(samples=100, burn=20, thin=2) @@ -377,7 +386,7 @@ def test_load_short_chain_regression(self, tmp_path): reader collapses it to a 1-D array and ``load_state`` raises ``IndexError`` without the 2-D coercion workaround. """ - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) sampler.sample(samples=20, burn=5, thin=1) @@ -391,7 +400,7 @@ def test_load_short_chain_regression(self, tmp_path): @pytest.mark.filterwarnings('ignore::UserWarning') def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog): """Loading a chain into a sampler bound to different data warns.""" - f, _, x, y, weights = _bumps_fitter_and_data() + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) sampler.sample(samples=100, burn=20, thin=2) @@ -405,3 +414,26 @@ def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog): with caplog.at_level(logging.WARNING, logger='easyscience.fitting'): sampler2.load_state(prefix) assert 'does not match the data fingerprint' in caplog.text + + +class TestDeprecatedMcmcSampleShim: + """The released ``Fitter.mcmc_sample`` entry point keeps working as a + deprecation shim delegating to ``Sampler`` (the ``Bumps.mcmc_sample`` + delegate is unit-tested in ``test_minimizer_bumps.py``).""" + + @pytest.mark.filterwarnings('ignore::UserWarning') + def test_fitter_mcmc_sample_warns_and_returns_legacy_dict(self): + f, sp, x, y, weights = _fitter_and_data() + + with pytest.warns(DeprecationWarning, match='Fitter.mcmc_sample'): + legacy = f.mcmc_sample([x], [y], [weights], samples=100, burn=20, thin=2) + + assert set(legacy.keys()) == {'draws', 'param_names', 'internal_bumps_object', 'logp'} + assert legacy['draws'].ndim == 2 + assert legacy['draws'].shape[0] > 0 + assert legacy['draws'].shape[1] == len(legacy['param_names']) + expected_pars = {p.unique_name for p in sp.get_fit_parameters()} + assert set(legacy['param_names']) == expected_pars + # The shim no longer requires the BUMPS minimizer — the default + # LMFit minimizer stays active throughout. + assert f.minimizer.package == 'lmfit' diff --git a/tests/unit/fitting/minimizers/bumps_utils/__init__.py b/tests/unit/fitting/minimizers/bumps_utils/__init__.py new file mode 100644 index 00000000..4e798e20 --- /dev/null +++ b/tests/unit/fitting/minimizers/bumps_utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_problem.py b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py new file mode 100644 index 00000000..affe454d --- /dev/null +++ b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py @@ -0,0 +1,179 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for the shared BUMPS problem-construction helpers.""" + +from unittest.mock import MagicMock + +import numpy as np +import pytest + +import easyscience.fitting.minimizers.bumps_utils.problem +from easyscience.fitting.minimizers.bumps_utils import build_curve_problem +from easyscience.fitting.minimizers.bumps_utils import parameter_names +from easyscience.fitting.minimizers.bumps_utils import parameter_snapshot +from easyscience.fitting.minimizers.bumps_utils import to_bumps_parameter + + +class TestToBumpsParameter: + def test_convert_parameter_object(self) -> None: + from easyscience.variable import Parameter + + param = Parameter('thickness', 42.0, min=0.0, max=100.0) + param.fixed = False + + result = to_bumps_parameter(param) + + # to_bumps_parameter uses obj.unique_name which is auto-assigned + assert result.name.startswith('p') + assert result.value == 42.0 + assert result.bounds == (0.0, 100.0) + assert result.fixed is False + + def test_convert_fixed_parameter(self) -> None: + from easyscience.variable import Parameter + + param = Parameter('roughness', 5.0, min=0.0, max=20.0) + param.fixed = True + + result = to_bumps_parameter(param) + + assert result.name.startswith('p') + assert result.fixed is True + + +class TestBuildCurveProblem: + """Curve/FitProblem assembly, with the BUMPS classes mocked out.""" + + @pytest.fixture(autouse=True) + def _mock_bumps_classes(self, monkeypatch): + self.mock_curve_cls = MagicMock(return_value='curve') + self.mock_problem_cls = MagicMock(return_value='problem') + monkeypatch.setattr( + easyscience.fitting.minimizers.bumps_utils.problem, 'Curve', self.mock_curve_cls + ) + monkeypatch.setattr( + easyscience.fitting.minimizers.bumps_utils.problem, + 'FitProblem', + self.mock_problem_cls, + ) + self.mock_convert = MagicMock(side_effect=lambda par: f'converted-{par.unique_name}') + monkeypatch.setattr( + easyscience.fitting.minimizers.bumps_utils.problem, + 'to_bumps_parameter', + self.mock_convert, + ) + + @staticmethod + def _engine_with_cached_pars(cached_pars): + engine = MagicMock() + engine._generate_fit_function = MagicMock( + return_value=MagicMock(return_value=np.array([2.0])) + ) + engine._cached_pars = cached_pars + return engine + + def test_uses_cached_parameters_by_default(self): + cached_par = MagicMock() + cached_par.unique_name = 'alpha' + engine = self._engine_with_cached_pars({'alpha': cached_par}) + + problem, counter, curve = build_curve_problem( + engine, np.array([1.0]), np.array([2.0]), np.array([4.0]) + ) + + assert problem == 'problem' + # The Curve is surfaced directly so callers never have to read the + # deprecated ``FitProblem.fitness`` property (CR-1). + assert curve == 'curve' + engine._generate_fit_function.assert_called_once_with() + self.mock_convert.assert_called_once_with(cached_par) + assert self.mock_curve_cls.call_args.kwargs['palpha'] == 'converted-alpha' + self.mock_problem_cls.assert_called_once_with('curve') + + def test_explicit_parameters_override_cache(self): + engine = self._engine_with_cached_pars({'alpha': MagicMock(unique_name='alpha')}) + explicit = MagicMock() + explicit.unique_name = 'beta' + + build_curve_problem( + engine, np.array([1.0]), np.array([2.0]), np.array([4.0]), parameters=[explicit] + ) + + self.mock_convert.assert_called_once_with(explicit) + assert 'pbeta' in self.mock_curve_cls.call_args.kwargs + assert 'palpha' not in self.mock_curve_cls.call_args.kwargs + + def test_curve_receives_data_and_dy(self): + """weights are converted to dy = 1 / weights.""" + engine = self._engine_with_cached_pars({}) + x = np.array([1.0, 2.0]) + y = np.array([10.0, 20.0]) + weights = np.array([2.0, 4.0]) + + build_curve_problem(engine, x, y, weights) + + call = self.mock_curve_cls.call_args + np.testing.assert_array_equal(call.args[1], x) + np.testing.assert_array_equal(call.args[2], y) + np.testing.assert_array_equal(call.kwargs['dy'], 1 / weights) + + def test_counter_wraps_fit_function(self): + """The returned EvalCounter wraps the wrapped fit function and counts calls.""" + inner = MagicMock(return_value=np.array([11.0, 22.0])) + engine = self._engine_with_cached_pars({}) + engine._generate_fit_function = MagicMock(return_value=inner) + + _, counter, _ = build_curve_problem( + engine, np.array([1.0]), np.array([2.0]), np.array([4.0]) + ) + + # The counter itself is what Curve receives as the fit function. + assert self.mock_curve_cls.call_args.args[0] is counter + assert counter.count == 0 + counter(np.array([1.0])) + assert counter.count == 1 + inner.assert_called_once() + + +class TestParameterNames: + def test_strips_prefix(self): + params = [] + for name in ('palpha', 'pbeta'): + p = MagicMock() + p.name = name + params.append(p) + problem = MagicMock() + problem._parameters = params + + assert parameter_names(problem) == ['alpha', 'beta'] + + def test_tolerates_none_name(self): + p = MagicMock() + p.name = None + problem = MagicMock() + problem._parameters = [p] + + assert parameter_names(problem) == [''] + + +class TestParameterSnapshot: + def test_snapshot_from_point(self) -> None: + mock_problem = MagicMock() + mock_problem.labels.return_value = ['palpha', 'pbeta'] + + point = np.array([1.5, 2.5]) + + snapshot = parameter_snapshot(mock_problem, point) + + assert snapshot == {'alpha': 1.5, 'beta': 2.5} + mock_problem.getp.assert_not_called() + + def test_snapshot_falls_back_to_getp(self) -> None: + mock_problem = MagicMock() + mock_problem.labels.return_value = ['palpha'] + mock_problem.getp.return_value = np.array([3.5]) + + snapshot = parameter_snapshot(mock_problem, None) + + assert snapshot == {'alpha': 3.5} + mock_problem.getp.assert_called_once() diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py new file mode 100644 index 00000000..805d4b5d --- /dev/null +++ b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for the shared BUMPS input validation helpers.""" + +import numpy as np +import pytest + +from easyscience.fitting.minimizers.bumps_utils import validate_arrays +from easyscience.fitting.minimizers.bumps_utils import validate_run_settings + + +class TestValidateRunSettings: + @pytest.mark.parametrize( + 'kwargs, match', + [ + ({'samples': 0}, 'samples must be a positive integer'), + ({'samples': -1}, 'samples must be a positive integer'), + ({'samples': 10.0}, 'samples must be a positive integer'), + ({'burn': -1}, 'burn must be a non-negative integer'), + ({'burn': 1.5}, 'burn must be a non-negative integer'), + ({'thin': 0}, 'thin must be a positive integer'), + ({'thin': 2.0}, 'thin must be a positive integer'), + ], + ) + def test_invalid_settings_raise(self, kwargs, match): + settings = {'samples': 10, 'burn': 0, 'thin': 1} + settings.update(kwargs) + with pytest.raises(ValueError, match=match): + validate_run_settings(**settings) + + def test_valid_settings_pass(self): + validate_run_settings(samples=1, burn=0, thin=1) + validate_run_settings(samples=10000, burn=2000, thin=10) + + +class TestValidateArrays: + @staticmethod + def _data(): + return { + 'x': np.array([1.0, 2.0]), + 'y': np.array([0.1, 0.2]), + 'weights': np.array([1.0, 1.0]), + } + + @pytest.mark.parametrize( + 'overrides, match', + [ + ({'y': np.array([0.1])}, 'x and y must have the same shape'), + ({'weights': np.array([1.0])}, 'Weights must have the same shape'), + ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'), + ({'weights': np.array([1.0, np.inf])}, 'Weights cannot be NaN'), + ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), + ({'weights': np.array([1.0, -1.0])}, 'Weights must be strictly positive'), + ], + ) + @pytest.mark.parametrize('check_finite_xy', [True, False]) + def test_shared_checks_raise(self, overrides, match, check_finite_xy): + """Shape and weight checks apply on both the fit and sampling paths.""" + data = self._data() + data.update(overrides) + with pytest.raises(ValueError, match=match): + validate_arrays(**data, check_finite_xy=check_finite_xy) + + @pytest.mark.parametrize( + 'overrides, match', + [ + ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'), + ({'x': np.array([1.0, np.inf])}, 'x cannot contain NaN'), + ({'y': np.array([0.1, np.nan])}, 'y cannot contain NaN'), + ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'), + ], + ) + def test_finite_xy_checked_only_when_requested(self, overrides, match): + """x/y finiteness is enforced for sampling but not for the classical + fit path, preserving the fit path's historically permissive behaviour.""" + data = self._data() + data.update(overrides) + with pytest.raises(ValueError, match=match): + validate_arrays(**data, check_finite_xy=True) + validate_arrays(**data, check_finite_xy=False) # must not raise + + def test_valid_arrays_pass(self): + validate_arrays(**self._data(), check_finite_xy=True) diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index 28d4a049..11462696 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -65,17 +65,17 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None: # Prepare a mock parameter with .name = 'pmock_parm_1' mock_bumps_param = MagicMock() mock_bumps_param.name = 'pmock_parm_1' - # Patch FitProblem to have _parameters attribute as expected - mock_FitProblem_instance = MagicMock() - mock_FitProblem_instance._parameters = [mock_bumps_param] - mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance) + # A mock problem with _parameters, plus the Curve model returned + # directly by the helper (never via the deprecated problem.fitness) + mock_model = MagicMock() + mock_problem = MagicMock() + mock_problem._parameters = [mock_bumps_param] + mock_counter = MagicMock() + mock_build = MagicMock(return_value=(mock_problem, mock_counter, mock_model)) monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem + easyscience.fitting.minimizers.minimizer_bumps, 'build_curve_problem', mock_build ) - mock_model = MagicMock() - mock_model_function = MagicMock(return_value=mock_model) - minimizer._make_model = MagicMock(return_value=mock_model_function) minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') cached_par = MagicMock() @@ -104,7 +104,17 @@ def fake_set_parameter_fit_result(fit_result, stack_status, par_list): mock_FitDriver.assert_called_once() mock_driver_instance.clip.assert_called_once() mock_driver_instance.fit.assert_called_once() - minimizer._make_model.assert_called_once_with(parameters=None) + # The problem is built via the shared helper and its Curve is cached + mock_build.assert_called_once() + build_args = mock_build.call_args + assert build_args.args[0] is minimizer + assert np.array_equal(build_args.args[1], np.asarray(1.0)) + assert np.array_equal(build_args.args[2], np.asarray(2.0)) + assert np.array_equal(build_args.args[3], np.asarray(1)) + assert build_args.kwargs == {'parameters': None} + assert minimizer._eval_counter is mock_counter + assert minimizer._cached_model is mock_model + assert mock_FitDriver.call_args.kwargs['problem'] is mock_problem # _gen_fit_results is called with the OptimizeResult built from driver.fit() minimizer._gen_fit_results.assert_called_once() passed_result = minimizer._gen_fit_results.call_args.args[0] @@ -114,8 +124,6 @@ def fake_set_parameter_fit_result(fit_result, stack_status, par_list): 'max_evaluations': None, 'tolerance': None, } - mock_model_function.assert_called_once_with(1.0, 2.0, 1) - mock_FitProblem.assert_called_once_with(mock_model) @pytest.mark.parametrize( 'weights', @@ -134,34 +142,6 @@ def test_fit_weight_exceptions(self, minimizer: Bumps, weights) -> None: with pytest.raises(ValueError): minimizer.fit(x=np.array([1, 2, 3]), y=np.array([1, 2, 3]), weights=weights) - def test_make_model(self, minimizer: Bumps, monkeypatch) -> None: - # When - mock_fit_function = MagicMock(return_value=np.array([11, 22])) - minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function) - - mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' - minimizer.convert_to_par_object = MagicMock(return_value='converted_parm_1') - - mock_Curve = MagicMock(return_value='curve') - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_bumps, 'Curve', mock_Curve) - - # Then - model = minimizer._make_model(parameters=[mock_parm_1]) - curve_for_model = model( - x=np.array([1, 2]), y=np.array([10, 20]), weights=np.array([100, 200]) - ) - wrapped_fit_function = mock_Curve.call_args[0][0] - wrapped_fit_function(np.array([1, 2]), pmock_parm_1=3) - - # Expect - minimizer._generate_fit_function.assert_called_once_with() - assert minimizer._eval_counter is wrapped_fit_function - assert minimizer._eval_counter.count == 1 - assert all(mock_Curve.call_args[0][1] == np.array([1, 2])) - assert all(mock_Curve.call_args[0][2] == np.array([10, 20])) - assert curve_for_model == 'curve' - def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): # When minimizer._cached_pars = { @@ -334,16 +314,14 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None: mock_bumps_param = MagicMock() mock_bumps_param.name = 'pmock_parm_1' - mock_FitProblem_instance = MagicMock() - mock_FitProblem_instance._parameters = [mock_bumps_param] - mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance) + mock_problem = MagicMock() + mock_problem._parameters = [mock_bumps_param] monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem + easyscience.fitting.minimizers.minimizer_bumps, + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())), ) - mock_model = MagicMock() - mock_model_function = MagicMock(return_value=mock_model) - minimizer._make_model = MagicMock(return_value=mock_model_function) minimizer._set_parameter_fit_result = MagicMock() minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') @@ -363,7 +341,7 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None: monitors = driver_call_kwargs.kwargs.get('monitors', driver_call_kwargs[1].get('monitors')) assert len(monitors) == 1 assert isinstance(monitors[0], BumpsProgressMonitor) - assert monitors[0]._problem is mock_FitProblem_instance + assert monitors[0]._problem is mock_problem assert monitors[0]._callback is progress_callback assert monitors[0]._payload_builder == minimizer._build_progress_payload @@ -394,7 +372,10 @@ def test_fit_uses_supplied_model_and_optional_kwargs( MagicMock(return_value=mock_problem), ) - minimizer._make_model = MagicMock() + mock_build = MagicMock() + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, 'build_curve_problem', mock_build + ) minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) minimizer._set_parameter_fit_result = MagicMock() @@ -417,7 +398,7 @@ def test_fit_uses_supplied_model_and_optional_kwargs( ) assert result == 'gen_fit_results' - minimizer._make_model.assert_not_called() + mock_build.assert_not_called() fit_driver_kwargs = mock_FitDriver.call_args.kwargs assert fit_driver_kwargs['problem'] is mock_problem assert fit_driver_kwargs['existing_option'] == 'minimizer' @@ -522,19 +503,6 @@ def test_build_progress_payload_reduced_chi2_positive_dof(self, minimizer: Bumps ((), {'nllf': 5.0, 'norm': True}), ] - def test_current_parameter_snapshot(self, minimizer: Bumps) -> None: - # When - mock_problem = MagicMock() - mock_problem.labels.return_value = ['palpha', 'pbeta'] - - point = np.array([1.5, 2.5]) - - # Then - snapshot = minimizer._current_parameter_snapshot(mock_problem, point) - - # Expect - assert snapshot == {'alpha': 1.5, 'beta': 2.5} - @pytest.mark.parametrize('par_list', [None, [MagicMock(unique_name='alpha')]]) def test_convert_to_pars_obj_optional_parameter_list( self, minimizer: Bumps, par_list, monkeypatch @@ -557,25 +525,6 @@ def test_convert_to_pars_obj_optional_parameter_list( else: minimizer._object.get_fit_parameters.assert_not_called() - def test_make_model_without_parameters_uses_cached_parameters( - self, minimizer: Bumps, monkeypatch - ) -> None: - minimizer._generate_fit_function = MagicMock( - return_value=MagicMock(return_value=np.array([2.0])) - ) - minimizer._cached_pars = {'alpha': MagicMock(value=1.0)} - minimizer.convert_to_par_object = MagicMock(return_value='converted-alpha') - - mock_curve = MagicMock(return_value='curve') - monkeypatch.setattr(easyscience.fitting.minimizers.minimizer_bumps, 'Curve', mock_curve) - - model = minimizer._make_model() - curve = model(np.array([1.0]), np.array([2.0]), np.array([3.0])) - - assert curve == 'curve' - minimizer.convert_to_par_object.assert_called_once_with(minimizer._cached_pars['alpha']) - assert mock_curve.call_args.kwargs['palpha'] == 'converted-alpha' - def test_bumps_progress_monitor_calls_callback(self, minimizer: Bumps) -> None: # When callback = MagicMock(return_value=True) @@ -622,16 +571,13 @@ def test_fit_exception_restores_values(self, minimizer: Bumps, monkeypatch) -> N easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver ) - mock_FitProblem_instance = MagicMock() - mock_FitProblem_instance._parameters = [] - mock_FitProblem = MagicMock(return_value=mock_FitProblem_instance) + mock_problem = MagicMock() + mock_problem._parameters = [] monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'FitProblem', mock_FitProblem + easyscience.fitting.minimizers.minimizer_bumps, + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())), ) - - mock_model = MagicMock() - mock_model_function = MagicMock(return_value=mock_model) - minimizer._make_model = MagicMock(return_value=mock_model_function) minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) # Then Expect @@ -683,462 +629,14 @@ def test_gen_fit_results_uses_nit_for_budget_check( # =================================================================== -# Bumps.mcmc_sample() — Bayesian DREAM sampling +# Bumps.mcmc_sample() — deprecated delegate to DreamSampler # =================================================================== -class TestBumpsSample: - """Tests for the ``Bumps.mcmc_sample()`` method and its helpers.""" - - # Sentinel value to signal "set fitter.state = None" in _setup_driver_mock - ABORT = object() - - @pytest.fixture - def minimizer(self) -> Bumps: - return Bumps( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='bumps', method='amoeba'), - ) - - @pytest.fixture(autouse=True) - def _mock_bumps_internals(self, monkeypatch): - """Prevent sample() from constructing real BUMPS objects. - - ``sample()`` imports ``DreamFit`` and ``FitProblem`` from the real - ``bumps`` package internally, which would try to build real model - objects. We redirect those to mocks and also mock ``FitDriver`` - (which *is* a module-level import) so the whole flow stays under - test control. - - Also mock ``_make_model`` on the class so that the ``minimizer`` - fixture (which uses ``obj='obj'``) doesn't fail inside ``sample()``. - """ - import bumps.fitters - import bumps.names - - monkeypatch.setattr(bumps.fitters, 'DreamFit', MagicMock()) - monkeypatch.setattr(bumps.names, 'FitProblem', MagicMock(return_value=MagicMock())) - monkeypatch.setattr( - Bumps, '_make_model', MagicMock(return_value=MagicMock(return_value=MagicMock())) - ) - - def _setup_driver_mock( - self, monkeypatch, fitter_state_value=None, fit_result=None, fit_side_effect=None - ): - """Helper to create a mocked FitDriver with configurable behavior. - - :param fitter_state_value: If ``None``, ``driver.fitter.state`` will be - a regular MagicMock (non-None). Pass ``ABORT`` to set it to ``None`` - and simulate user abort. - """ - from easyscience import global_object - - global_object.stack.enabled = False - - mock_driver = MagicMock() - mock_driver.clip = MagicMock() - - if fit_side_effect is not None: - mock_driver.fit.side_effect = fit_side_effect - else: - mock_driver.fit.return_value = fit_result or (np.array([1.0]), 0.0) - - mock_driver.stderr = MagicMock(return_value=np.array([0.1])) - - if fitter_state_value is TestBumpsSample.ABORT: - mock_driver.fitter.state = None - else: - mock_state = MagicMock() - mock_state.Nvar = 1 - mock_state.Npop = 5 - mock_state.labels = ['p_param_0'] - mock_draw = MagicMock() - mock_draw.points = np.array([[1.0]]) - mock_draw.logp = np.array([0.5]) - mock_state.draw.return_value = mock_draw - mock_driver.fitter.state = mock_state - - mock_FitDriver = MagicMock(return_value=mock_driver) - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver - ) - return mock_FitDriver, mock_driver - - @pytest.mark.parametrize( - 'kwargs, match', - [ - ({'samples': 0}, 'samples must be a positive integer'), - ({'samples': -1}, 'samples must be a positive integer'), - ({'burn': -1}, 'burn must be a non-negative integer'), - ({'thin': 0}, 'thin must be a positive integer'), - ], - ) - def test_sample_invalid_args(self, minimizer: Bumps, kwargs, match) -> None: - """Invalid samples/burn/thin values raise ValueError before any sampling. - - This is the single source of truth for these checks — the higher-level - ``Sampler`` relies on it. - """ - with pytest.raises(ValueError, match=match): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=kwargs.get('samples', 10), - burn=kwargs.get('burn', 0), - thin=kwargs.get('thin', 1), - ) - - @pytest.mark.parametrize( - 'overrides, match', - [ - ({'y': np.array([0.1])}, 'x and y must have the same shape'), - ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'), - ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'), - ({'weights': np.array([1.0])}, 'Weights must have the same shape'), - ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'), - ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), - ], - ) - def test_sample_invalid_data(self, minimizer: Bumps, overrides, match) -> None: - """Shape mismatches and non-finite/non-positive data raise ValueError - before any sampling.""" - data = { - 'x': np.array([1.0, 2.0]), - 'y': np.array([0.1, 0.2]), - 'weights': np.array([1.0, 1.0]), - } - data.update(overrides) - with pytest.raises(ValueError, match=match): - minimizer.mcmc_sample(**data, samples=10, burn=0, thin=1) - - def test_sample_basic(self, minimizer: Bumps, monkeypatch) -> None: - """Verify that mcmc_sample() returns a dict with expected keys.""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - - result = minimizer.mcmc_sample( - x=np.array([1.0, 2.0]), - y=np.array([0.1, 0.2]), - weights=np.array([1.0, 1.0]), - samples=100, - burn=20, - thin=2, - population=5, - ) - - assert isinstance(result, dict) - assert 'draws' in result - assert 'param_names' in result - assert 'internal_bumps_object' in result - assert 'logp' in result - mock_FitDriver.assert_called_once() - - def test_sample_with_progress_callback(self, minimizer: Bumps, monkeypatch) -> None: - """Verify progress callback is wired up as a monitor.""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - progress_callback = MagicMock() - - result = minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=5, - thin=1, - progress_callback=progress_callback, - ) - - assert result is not None - call_kwargs = mock_FitDriver.call_args.kwargs - assert 'monitors' in call_kwargs - assert len(call_kwargs['monitors']) == 1 - assert isinstance(call_kwargs['monitors'][0], BumpsProgressMonitor) - - def test_sample_aborted_by_user_raises_fit_error(self, minimizer: Bumps, monkeypatch) -> None: - """Verify that sampling abortion raises FitError.""" - self._setup_driver_mock(monkeypatch, fitter_state_value=TestBumpsSample.ABORT) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - - with pytest.raises(FitError, match='Sampling aborted by user'): - minimizer.mcmc_sample(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0])) - - def test_sample_driver_exception_restores_parameters( - self, minimizer: Bumps, monkeypatch - ) -> None: - """Verify that a driver exception during sampling restores parameter values.""" - self._setup_driver_mock(monkeypatch, fit_side_effect=RuntimeError('driver failed')) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - minimizer._restore_parameter_values = MagicMock() - - with pytest.raises(RuntimeError, match='driver failed'): - minimizer.mcmc_sample(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0])) - - minimizer._restore_parameter_values.assert_called_once() - - def test_sample_population_param(self, minimizer: Bumps, monkeypatch) -> None: - """population kwarg is forwarded to DREAM as pop.""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - population=7, - ) - - call_kwargs = mock_FitDriver.call_args.kwargs - assert call_kwargs['pop'] == 7 - - def test_sample_sampler_kwargs_forwarded(self, minimizer: Bumps, monkeypatch) -> None: - """sampler_kwargs entries are merged into the DREAM kwargs.""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - sampler_kwargs={'trim': False}, - ) - - assert mock_FitDriver.call_args.kwargs['trim'] is False +class TestBumpsMcmcSampleDeprecated: + """``Bumps.mcmc_sample`` is a thin deprecated delegate; the DREAM run + itself is unit-tested in ``tests/unit/fitting/samplers/test_sampler_dream.py``.""" - def test_sample_rejects_non_callable_callback(self, minimizer: Bumps, monkeypatch) -> None: - import bumps.names - - monkeypatch.setattr(bumps.names, 'FitProblem', MagicMock(return_value=MagicMock())) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - - with pytest.raises(ValueError, match='progress_callback must be callable'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=5, - thin=1, - progress_callback='not-callable', - ) - - # --- Resume-state tests ------------------------------------------------- - - def _make_resume_state_mock(self, *, nvar=2, npop=10, labels=None): - """Build a mock MCMCDraw for resume tests. - - BUMPS labels follow the pattern ``'p'`` (the - ``MINIMIZER_PARAMETER_PREFIX`` concatenated with the unique name), - e.g. ``'pFilm_thickness'``. - - :param nvar: Number of parameters. - :param npop: Population size. - :param labels: Parameter labels (defaults to ``['pa', 'pb']`` - which strip to ``['a', 'b']``). - """ - if labels is None: - labels = ['pa', 'pb'] - mock_state = MagicMock() - mock_state.Nvar = nvar - mock_state.Npop = npop - mock_state.labels = labels - mock_draw = MagicMock() - mock_draw.points = np.ones((20, nvar)) - mock_draw.logp = np.ones(20) - mock_state.draw.return_value = mock_draw - return mock_state - - def _make_problem_with_parameters(self, param_names): - """Build a mock FitProblem whose ``_parameters`` yields the given names.""" - params = [] - for name in param_names: - p = MagicMock() - p.name = 'p' + name - params.append(p) - mock_problem = MagicMock() - mock_problem._parameters = params - return mock_problem - - def test_sample_resume_state(self, minimizer: Bumps, monkeypatch) -> None: - """Verify resume_state is forwarded to driver.fit().""" - mock_FitDriver, mock_driver = self._setup_driver_mock(monkeypatch) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - resume_state = self._make_resume_state_mock() - - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), - ) - - result = minimizer.mcmc_sample( - x=np.array([1.0, 2.0]), - y=np.array([0.1, 0.2]), - weights=np.array([1.0, 1.0]), - samples=10, - burn=0, - thin=1, - resume_state=resume_state, - ) - - assert result is not None - # Verify a fit_state (defensive copy of resume_state) was passed to driver.fit() - call_kwargs = mock_driver.fit.call_args.kwargs - assert call_kwargs.get('fit_state') is not None - assert call_kwargs['fit_state'] is not resume_state - - def test_sample_resume_param_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None: - """Parameter count mismatch raises ValueError before driver.fit().""" - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a'])), - ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - # resume_state has 2 params, model has 1 - resume_state = self._make_resume_state_mock(nvar=2) - - with pytest.raises(ValueError, match='resume_state has 2 parameters'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - resume_state=resume_state, - ) - - def test_sample_resume_param_name_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None: - """Parameter name/order mismatch raises ValueError before driver.fit().""" - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), - ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - # resume_state has labels ['px', 'py'] → stripped to ['x', 'y'] - # Current model has params ['pa', 'pb'] → stripped to ['a', 'b'] - # → mismatch - resume_state = self._make_resume_state_mock(nvar=2, labels=['px', 'py']) - - with pytest.raises(ValueError, match='Parameter names/order mismatch'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - resume_state=resume_state, - ) - - def test_sample_resume_population_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None: - """Explicit population differing from state.Npop raises ValueError.""" - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), - ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - resume_state = self._make_resume_state_mock(nvar=2, npop=10) - - with pytest.raises(ValueError, match='would produce'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - population=3, # ceil(3*2)=6 ≠ 10 - resume_state=resume_state, - ) - - def test_sample_resume_forces_burn_to_zero( - self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture' - ) -> None: - """burn>0 with resume_state warns and is forced to 0.""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), - ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - resume_state = self._make_resume_state_mock() - - with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=5, - thin=1, - resume_state=resume_state, - ) - - assert 'ignored on resume' in caplog.text - # burn must be forced to 0 in the kwargs passed to BUMPS - assert mock_FitDriver.call_args.kwargs['burn'] == 0 - - def test_sample_resume_unlabeled_state_warns_and_uses_absolute_pop( - self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture' - ) -> None: - """A state reloaded from disk carries default labels ('P0', ...), so - name validation is skipped with a warning, and the saved population is - reproduced as a negative pop (BUMPS' absolute-chain-count convention).""" - mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) - import bumps.names - - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), - ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) - resume_state = self._make_resume_state_mock(nvar=2, npop=10, labels=['P0', 'P1']) - - with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'): - minimizer.mcmc_sample( - x=np.array([1.0]), - y=np.array([0.1]), - weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - resume_state=resume_state, - ) - - assert 'does not carry parameter names' in caplog.text - assert mock_FitDriver.call_args.kwargs['pop'] == -10 - - -# =================================================================== -# _build_sample_progress_payload -# =================================================================== - - -class TestBuildSampleProgressPayload: @pytest.fixture def minimizer(self) -> Bumps: return Bumps( @@ -1147,68 +645,50 @@ def minimizer(self) -> Bumps: minimizer_enum=MagicMock(package='bumps', method='amoeba'), ) - def test_payload_structure_and_sampling_flag(self, minimizer: Bumps) -> None: - b = minimizer - - mock_problem = MagicMock() - mock_problem.chisq.side_effect = [25.0, 12.5] - mock_problem.labels.return_value = ['palpha'] - mock_problem.getp.return_value = np.array([1.0]) - b._cached_pars = {'alpha': MagicMock(value=1.0)} - - payload = b._build_sample_progress_payload(mock_problem, 7, np.array([1.0]), 12.5) - - assert payload['iteration'] == 7 - assert payload['chi2'] == 25.0 - assert payload['reduced_chi2'] == 12.5 - assert payload['parameter_values'] == {'alpha': 1.0} - assert payload['sampling'] is True - assert payload['finished'] is False - assert payload['refresh_plots'] is False - - def test_payload_keys(self, minimizer: Bumps) -> None: - b = minimizer - mock_problem = MagicMock() - mock_problem.chisq.side_effect = [10.0, 5.0] - mock_problem.labels.return_value = ['pa'] - mock_problem.getp.return_value = np.array([5.0]) - b._cached_pars = {'a': MagicMock(value=5.0)} + def test_warns_and_delegates_to_dream_sampler(self, minimizer: Bumps, monkeypatch) -> None: + import easyscience.fitting.samplers.sampler_dream as sampler_dream_module - payload = b._build_sample_progress_payload(mock_problem, 1, np.array([5.0]), nllf=5.0) - - expected_keys = { - 'iteration', - 'chi2', - 'reduced_chi2', - 'parameter_values', - 'refresh_plots', - 'finished', - 'sampling', + canned = { + 'draws': np.ones((2, 1)), + 'param_names': ['a'], + 'internal_bumps_object': object(), + 'logp': np.zeros(2), } - assert set(payload.keys()) == expected_keys - - def test_delegates_to_build_progress_payload(self, minimizer: Bumps) -> None: - """_build_sample_progress_payload calls _build_progress_payload and adds sampling.""" - mock_problem = MagicMock() + mock_engine = MagicMock() + mock_engine.run.return_value = canned + mock_engine_cls = MagicMock(return_value=mock_engine) + monkeypatch.setattr(sampler_dream_module, 'DreamSampler', mock_engine_cls) + + x = np.array([1.0, 2.0]) + y = np.array([0.1, 0.2]) + weights = np.array([1.0, 1.0]) + abort_test = MagicMock(return_value=False) - # Patch _build_progress_payload to track calls - base_payload = { - 'iteration': 3, - 'chi2': 42.0, - 'reduced_chi2': 21.0, - 'parameter_values': {'x': 7.0}, - 'refresh_plots': False, - 'finished': False, - } - with patch.object( - minimizer, '_build_progress_payload', return_value=base_payload - ) as mock_bpp: - result = minimizer._build_sample_progress_payload( - mock_problem, 3, np.array([7.0]), 21.0 + with pytest.warns(DeprecationWarning, match='Bumps.mcmc_sample'): + result = minimizer.mcmc_sample( + x=x, + y=y, + weights=weights, + samples=100, + burn=20, + thin=2, + population=5, + sampler_kwargs={'trim': False}, + abort_test=abort_test, ) - mock_bpp.assert_called_once_with(mock_problem, 3, np.array([7.0]), 21.0) - assert result == {**base_payload, 'sampling': True} + # The engine is bound to the minimizer's object and original fit function + mock_engine_cls.assert_called_once_with('obj', 'fit_function') + run_kwargs = mock_engine.run.call_args.kwargs + assert run_kwargs['samples'] == 100 + assert run_kwargs['burn'] == 20 + assert run_kwargs['thin'] == 2 + assert run_kwargs['population'] == 5 + assert run_kwargs['resume_state'] is None + assert run_kwargs['sampler_kwargs'] == {'trim': False} + assert run_kwargs['abort_test'] is abort_test + # The legacy dict comes straight back from the engine + assert result is canned # =================================================================== @@ -1319,11 +799,10 @@ def test_abort_test_passed_to_fit_driver(self, minimizer: Bumps, monkeypatch) -> mock_problem._parameters = [] monkeypatch.setattr( easyscience.fitting.minimizers.minimizer_bumps, - 'FitProblem', - MagicMock(return_value=mock_problem), + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(), MagicMock())), ) - minimizer._make_model = MagicMock(return_value=MagicMock(return_value=MagicMock())) minimizer._gen_fit_results = MagicMock(return_value='result') minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) minimizer._set_parameter_fit_result = MagicMock() diff --git a/tests/unit/fitting/samplers/__init__.py b/tests/unit/fitting/samplers/__init__.py new file mode 100644 index 00000000..4e798e20 --- /dev/null +++ b/tests/unit/fitting/samplers/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/fitting/samplers/test_sampler_dream.py b/tests/unit/fitting/samplers/test_sampler_dream.py new file mode 100644 index 00000000..7683d10d --- /dev/null +++ b/tests/unit/fitting/samplers/test_sampler_dream.py @@ -0,0 +1,481 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for ``DreamSampler`` — mirrors +``src/easyscience/fitting/samplers/sampler_dream.py``. + +Ported from the former ``TestBumpsSample`` suite in +``tests/unit/fitting/minimizers/test_minimizer_bumps.py`` when +``Bumps.mcmc_sample`` moved here as ``DreamSampler.run`` (easyscience/core#280). +""" + +import logging +from unittest.mock import MagicMock + +import numpy as np +import pytest + +import easyscience.fitting.samplers.sampler_dream +from easyscience.fitting.engine_base import EngineBase +from easyscience.fitting.minimizers.bumps_utils import BumpsProgressMonitor +from easyscience.fitting.minimizers.utils import FitError +from easyscience.fitting.samplers import DreamSampler + + +class TestDreamSamplerRun: + """Tests for ``DreamSampler.run()`` and its helpers.""" + + # Sentinel value to signal "set fitter.state = None" in _setup_driver_mock + ABORT = object() + + @pytest.fixture + def engine(self) -> DreamSampler: + return DreamSampler(obj='obj', fit_function='fit_function') + + @pytest.fixture(autouse=True) + def _mock_bumps_internals(self, monkeypatch): + """Prevent run() from constructing real BUMPS objects. + + ``run()`` imports ``DreamFit`` from the real ``bumps`` package + internally and builds its problem via ``build_curve_problem``, + which would try to build real model objects. We redirect those + to mocks and also mock ``FitDriver`` (a module-level import) so + the whole flow stays under test control. + """ + import bumps.fitters + + monkeypatch.setattr(bumps.fitters, 'DreamFit', MagicMock()) + self._set_problem(monkeypatch, MagicMock()) + + @staticmethod + def _set_problem(monkeypatch, problem): + """Point ``build_curve_problem`` at a canned (problem, counter, curve) triple.""" + monkeypatch.setattr( + easyscience.fitting.samplers.sampler_dream, + 'build_curve_problem', + MagicMock(return_value=(problem, MagicMock(), MagicMock())), + ) + + def _setup_driver_mock( + self, monkeypatch, fitter_state_value=None, fit_result=None, fit_side_effect=None + ): + """Helper to create a mocked FitDriver with configurable behavior. + + :param fitter_state_value: If ``None``, ``driver.fitter.state`` will be + a regular MagicMock (non-None). Pass ``ABORT`` to set it to ``None`` + and simulate user abort. + """ + from easyscience import global_object + + global_object.stack.enabled = False + + mock_driver = MagicMock() + mock_driver.clip = MagicMock() + + if fit_side_effect is not None: + mock_driver.fit.side_effect = fit_side_effect + else: + mock_driver.fit.return_value = fit_result or (np.array([1.0]), 0.0) + + mock_driver.stderr = MagicMock(return_value=np.array([0.1])) + + if fitter_state_value is TestDreamSamplerRun.ABORT: + mock_driver.fitter.state = None + else: + mock_state = MagicMock() + mock_state.Nvar = 1 + mock_state.Npop = 5 + mock_state.labels = ['p_param_0'] + mock_draw = MagicMock() + mock_draw.points = np.array([[1.0]]) + mock_draw.logp = np.array([0.5]) + mock_state.draw.return_value = mock_draw + mock_driver.fitter.state = mock_state + + mock_FitDriver = MagicMock(return_value=mock_driver) + monkeypatch.setattr( + easyscience.fitting.samplers.sampler_dream, 'FitDriver', mock_FitDriver + ) + return mock_FitDriver, mock_driver + + def test_is_an_engine(self, engine: DreamSampler) -> None: + assert isinstance(engine, EngineBase) + assert engine.package == 'bumps' + + @pytest.mark.parametrize( + 'kwargs, match', + [ + ({'samples': 0}, 'samples must be a positive integer'), + ({'samples': -1}, 'samples must be a positive integer'), + ({'burn': -1}, 'burn must be a non-negative integer'), + ({'thin': 0}, 'thin must be a positive integer'), + ], + ) + def test_run_invalid_args(self, engine: DreamSampler, kwargs, match) -> None: + """Invalid samples/burn/thin values raise ValueError before any sampling. + + This is the single source of truth for these checks — the higher-level + ``Sampler`` relies on it. + """ + with pytest.raises(ValueError, match=match): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=kwargs.get('samples', 10), + burn=kwargs.get('burn', 0), + thin=kwargs.get('thin', 1), + ) + + @pytest.mark.parametrize( + 'overrides, match', + [ + ({'y': np.array([0.1])}, 'x and y must have the same shape'), + ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'), + ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'), + ({'weights': np.array([1.0])}, 'Weights must have the same shape'), + ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'), + ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), + ], + ) + def test_run_invalid_data(self, engine: DreamSampler, overrides, match) -> None: + """Shape mismatches and non-finite/non-positive data raise ValueError + before any sampling.""" + data = { + 'x': np.array([1.0, 2.0]), + 'y': np.array([0.1, 0.2]), + 'weights': np.array([1.0, 1.0]), + } + data.update(overrides) + with pytest.raises(ValueError, match=match): + engine.run(**data, samples=10, burn=0, thin=1) + + def test_run_rejects_none_weights(self, engine: DreamSampler) -> None: + """weights=None gets a clear ValueError instead of a shape error + from ``np.asarray(None)`` (CR-5).""" + with pytest.raises(ValueError, match='weights must not be None'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=None, + samples=10, + burn=0, + thin=1, + ) + + def test_run_basic(self, engine: DreamSampler, monkeypatch) -> None: + """Verify that run() returns a dict with expected keys.""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + + result = engine.run( + x=np.array([1.0, 2.0]), + y=np.array([0.1, 0.2]), + weights=np.array([1.0, 1.0]), + samples=100, + burn=20, + thin=2, + population=5, + ) + + assert isinstance(result, dict) + assert 'draws' in result + assert 'param_names' in result + assert 'internal_bumps_object' in result + assert 'logp' in result + mock_FitDriver.assert_called_once() + + def test_run_with_progress_callback(self, engine: DreamSampler, monkeypatch) -> None: + """Verify progress callback is wired up as a monitor.""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + progress_callback = MagicMock() + + result = engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=5, + thin=1, + progress_callback=progress_callback, + ) + + assert result is not None + call_kwargs = mock_FitDriver.call_args.kwargs + assert 'monitors' in call_kwargs + assert len(call_kwargs['monitors']) == 1 + assert isinstance(call_kwargs['monitors'][0], BumpsProgressMonitor) + + def test_run_aborted_by_user_raises_fit_error(self, engine: DreamSampler, monkeypatch) -> None: + """Verify that sampling abortion raises FitError.""" + self._setup_driver_mock(monkeypatch, fitter_state_value=TestDreamSamplerRun.ABORT) + + with pytest.raises(FitError, match='Sampling aborted by user'): + engine.run(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0])) + + def test_run_driver_exception_restores_parameters( + self, engine: DreamSampler, monkeypatch + ) -> None: + """Verify that a driver exception during sampling restores parameter values.""" + self._setup_driver_mock(monkeypatch, fit_side_effect=RuntimeError('driver failed')) + engine._restore_parameter_values = MagicMock() + + with pytest.raises(RuntimeError, match='driver failed'): + engine.run(x=np.array([1.0]), y=np.array([0.1]), weights=np.array([1.0])) + + engine._restore_parameter_values.assert_called_once() + + def test_run_population_param(self, engine: DreamSampler, monkeypatch) -> None: + """population kwarg is forwarded to DREAM as pop.""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + population=7, + ) + + call_kwargs = mock_FitDriver.call_args.kwargs + assert call_kwargs['pop'] == 7 + + def test_run_sampler_kwargs_forwarded(self, engine: DreamSampler, monkeypatch) -> None: + """sampler_kwargs entries are merged into the DREAM kwargs.""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + sampler_kwargs={'trim': False}, + ) + + assert mock_FitDriver.call_args.kwargs['trim'] is False + + def test_run_rejects_non_callable_callback(self, engine: DreamSampler) -> None: + with pytest.raises(ValueError, match='progress_callback must be callable'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=5, + thin=1, + progress_callback='not-callable', + ) + + # --- Resume-state tests ------------------------------------------------- + + def _make_resume_state_mock(self, *, nvar=2, npop=10, labels=None): + """Build a mock MCMCDraw for resume tests. + + BUMPS labels follow the pattern ``'p'`` (the + ``PARAMETER_PREFIX`` concatenated with the unique name), + e.g. ``'pFilm_thickness'``. + + :param nvar: Number of parameters. + :param npop: Population size. + :param labels: Parameter labels (defaults to ``['pa', 'pb']`` + which strip to ``['a', 'b']``). + """ + if labels is None: + labels = ['pa', 'pb'] + mock_state = MagicMock() + mock_state.Nvar = nvar + mock_state.Npop = npop + mock_state.labels = labels + mock_draw = MagicMock() + mock_draw.points = np.ones((20, nvar)) + mock_draw.logp = np.ones(20) + mock_state.draw.return_value = mock_draw + return mock_state + + def _make_problem_with_parameters(self, param_names): + """Build a mock FitProblem whose ``_parameters`` yields the given names.""" + params = [] + for name in param_names: + p = MagicMock() + p.name = 'p' + name + params.append(p) + mock_problem = MagicMock() + mock_problem._parameters = params + return mock_problem + + def test_run_resume_state(self, engine: DreamSampler, monkeypatch) -> None: + """Verify resume_state is forwarded to driver.fit().""" + mock_FitDriver, mock_driver = self._setup_driver_mock(monkeypatch) + resume_state = self._make_resume_state_mock() + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b'])) + + result = engine.run( + x=np.array([1.0, 2.0]), + y=np.array([0.1, 0.2]), + weights=np.array([1.0, 1.0]), + samples=10, + burn=0, + thin=1, + resume_state=resume_state, + ) + + assert result is not None + # Verify a fit_state (defensive copy of resume_state) was passed to driver.fit() + call_kwargs = mock_driver.fit.call_args.kwargs + assert call_kwargs.get('fit_state') is not None + assert call_kwargs['fit_state'] is not resume_state + + def test_run_resume_param_mismatch_raises(self, engine: DreamSampler, monkeypatch) -> None: + """Parameter count mismatch raises ValueError before driver.fit().""" + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a'])) + # resume_state has 2 params, model has 1 + resume_state = self._make_resume_state_mock(nvar=2) + + with pytest.raises(ValueError, match='resume_state has 2 parameters'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + resume_state=resume_state, + ) + + def test_run_resume_param_name_mismatch_raises( + self, engine: DreamSampler, monkeypatch + ) -> None: + """Parameter name/order mismatch raises ValueError before driver.fit().""" + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b'])) + # resume_state has labels ['px', 'py'] → stripped to ['x', 'y'] + # Current model has params ['pa', 'pb'] → stripped to ['a', 'b'] + # → mismatch + resume_state = self._make_resume_state_mock(nvar=2, labels=['px', 'py']) + + with pytest.raises(ValueError, match='Parameter names/order mismatch'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + resume_state=resume_state, + ) + + def test_run_resume_population_mismatch_raises( + self, engine: DreamSampler, monkeypatch + ) -> None: + """Explicit population differing from state.Npop raises ValueError.""" + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b'])) + resume_state = self._make_resume_state_mock(nvar=2, npop=10) + + with pytest.raises(ValueError, match='would produce'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + population=3, # ceil(3*2)=6 ≠ 10 + resume_state=resume_state, + ) + + def test_run_resume_forces_burn_to_zero( + self, engine: DreamSampler, monkeypatch, caplog: 'pytest.LogCaptureFixture' + ) -> None: + """burn>0 with resume_state warns and is forced to 0.""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b'])) + resume_state = self._make_resume_state_mock() + + with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=5, + thin=1, + resume_state=resume_state, + ) + + assert 'ignored on resume' in caplog.text + # burn must be forced to 0 in the kwargs passed to BUMPS + assert mock_FitDriver.call_args.kwargs['burn'] == 0 + + def test_run_resume_unlabeled_state_warns_and_uses_absolute_pop( + self, engine: DreamSampler, monkeypatch, caplog: 'pytest.LogCaptureFixture' + ) -> None: + """A state reloaded from disk carries default labels ('P0', ...), so + name validation is skipped with a warning, and the saved population is + reproduced as a negative pop (BUMPS' absolute-chain-count convention).""" + mock_FitDriver, _ = self._setup_driver_mock(monkeypatch) + self._set_problem(monkeypatch, self._make_problem_with_parameters(['a', 'b'])) + resume_state = self._make_resume_state_mock(nvar=2, npop=10, labels=['P0', 'P1']) + + with caplog.at_level(logging.WARNING, logger='easyscience.fitting.bumps'): + engine.run( + x=np.array([1.0]), + y=np.array([0.1]), + weights=np.array([1.0]), + samples=10, + burn=0, + thin=1, + resume_state=resume_state, + ) + + assert 'does not carry parameter names' in caplog.text + assert mock_FitDriver.call_args.kwargs['pop'] == -10 + + +class TestDreamSamplerProgressPayload: + """``DreamSampler._build_sample_progress_payload``.""" + + @pytest.fixture + def engine(self) -> DreamSampler: + return DreamSampler(obj='obj', fit_function='fit_function') + + def test_payload_structure_and_sampling_flag(self, engine: DreamSampler) -> None: + mock_problem = MagicMock() + mock_problem.chisq.side_effect = [25.0, 12.5] + mock_problem.labels.return_value = ['palpha'] + mock_problem.getp.return_value = np.array([1.0]) + + payload = engine._build_sample_progress_payload(mock_problem, 7, np.array([1.0]), 12.5) + + assert payload['iteration'] == 7 + assert payload['chi2'] == 25.0 + assert payload['reduced_chi2'] == 12.5 + assert payload['parameter_values'] == {'alpha': 1.0} + assert payload['sampling'] is True + assert payload['finished'] is False + assert payload['refresh_plots'] is False + # The nllf already computed by the sampler is reused — no model + # re-evaluation via setp. + mock_problem.chisq.assert_any_call(nllf=12.5, norm=False) + mock_problem.chisq.assert_any_call(nllf=12.5, norm=True) + mock_problem.setp.assert_not_called() + + def test_payload_keys(self, engine: DreamSampler) -> None: + """Same keys as the classical-fit payload, plus ``sampling``.""" + mock_problem = MagicMock() + mock_problem.chisq.side_effect = [10.0, 5.0] + mock_problem.labels.return_value = ['pa'] + mock_problem.getp.return_value = np.array([5.0]) + + payload = engine._build_sample_progress_payload(mock_problem, 1, np.array([5.0]), nllf=5.0) + + expected_keys = { + 'iteration', + 'chi2', + 'reduced_chi2', + 'parameter_values', + 'refresh_plots', + 'finished', + 'sampling', + } + assert set(payload.keys()) == expected_keys diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 665c1d66..7f685780 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -50,8 +50,13 @@ def __init__(self, labels): self.labels = list(labels) -def _bumps_fitter_and_data(): - """Build a 2-parameter BUMPS MultiFitter over a small sine model.""" +def _fitter_and_data(): + """Build a 2-parameter MultiFitter over a small sine model. + + The fitter keeps its default (LMFit) minimizer: sampling no longer + requires switching to BUMPS, only an installed ``bumps`` package. + """ + pytest.importorskip('bumps') ref_sin = AbsSin(0.2, np.pi) sp = AbsSin(0.354, 3.05) sp.offset.fixed = False @@ -60,10 +65,6 @@ def _bumps_fitter_and_data(): y = ref_sin(x) weights = np.ones_like(x) f = MultiFitter([sp], [sp]) - try: - f.switch_minimizer('Bumps') - except AttributeError: - pytest.skip('BUMPS is not installed') return f, sp, x, y, weights @@ -209,31 +210,38 @@ def test_load_chain_rejects_bad_skip(self, tmp_path, skip): class TestSamplerErrorPaths: - def test_sample_requires_bumps(self): - """sample() must raise RuntimeError if the minimizer is not BUMPS — - and must not mutate the fitter (no needless minimizer rebuild).""" + def test_sample_requires_bumps_package(self, monkeypatch): + """sample() must raise RuntimeError when the bumps package is not + installed — regardless of the active minimizer — and must not touch + the fitter.""" sp = AbsSin(0.354, 3.05) f = MultiFitter([sp], [sp]) x, y, w = _xyw() sampler = Sampler(f, [x], [y], [w]) minimizer_before = f.minimizer - with pytest.raises(RuntimeError, match='Bayesian sampling requires a BUMPS minimizer'): + monkeypatch.setattr( + 'easyscience.fitting.available_minimizers.bumps_engine_available', False + ) + with pytest.raises(RuntimeError, match='requires the bumps package'): sampler.sample(samples=10, burn=5, thin=1) assert f.minimizer is minimizer_before - def test_fit_function_restored_on_error(self): - """fit_function must be restored even when the minimizer raises.""" - f, _, x, y, weights = _bumps_fitter_and_data() + def test_fitter_untouched_on_error(self): + """The fitter is never mutated by sampling, even when the engine + raises.""" + f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) original_func = f.fit_function + minimizer_before = f.minimizer - # Invalid `samples` is rejected by the minimizer (single source of - # validation) *after* the fitter has been mutated for sampling. + # Invalid `samples` is rejected by the engine (single source of + # validation). with pytest.raises(ValueError, match='samples must be a positive integer'): sampler.sample(samples=-1, burn=5, thin=1) assert f.fit_function is original_func + assert f.minimizer is minimizer_before def test_extend_requires_existing_state(self): """extend() before sample()/load_state() raises RuntimeError.""" @@ -408,11 +416,11 @@ def test_to_legacy_dict_maps_fields(self): class TestSamplerRunEngine: """The ``_run`` tail: results construction, storage, and kwarg merging, - with the minimizer's sampling entry point stubbed out.""" + with the ``DreamSampler`` engine stubbed out.""" def test_run_stores_results_and_exposes_properties(self, monkeypatch): - f, _, x, y, weights = _bumps_fitter_and_data() - from easyscience.fitting.minimizers.minimizer_bumps import Bumps + f, _, x, y, weights = _fitter_and_data() + from easyscience.fitting.samplers.sampler_dream import DreamSampler canned = { 'draws': np.arange(8.0).reshape(4, 2), @@ -422,14 +430,15 @@ def test_run_stores_results_and_exposes_properties(self, monkeypatch): } captured = {} - def fake_mcmc_sample(self, **kwargs): + def fake_run(self, **kwargs): captured.update(kwargs) return dict(canned) - monkeypatch.setattr(Bumps, 'mcmc_sample', fake_mcmc_sample) + monkeypatch.setattr(DreamSampler, 'run', fake_run) sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'trim': False}) original_func = f.fit_function + minimizer_before = f.minimizer results = sampler.sample(samples=100, burn=10, thin=2, sampler_kwargs={'init': 'lhs'}) assert isinstance(results, SamplingResults) @@ -443,8 +452,44 @@ def fake_mcmc_sample(self, **kwargs): assert captured['samples'] == 100 assert captured['burn'] == 10 assert captured['resume_state'] is None - # The fitter's fit function is restored after the run. + # The fitter is never mutated: a fresh engine gets the wrapped + # function directly, and the active (LMFit) minimizer stays put. assert f.fit_function is original_func + assert f.minimizer is minimizer_before + + def test_run_works_with_non_bumps_minimizer(self, monkeypatch): + """Sampling works with the default LMFit minimizer active — the + engine is constructed independently of the fitter's minimizer.""" + f, _, x, y, weights = _fitter_and_data() + from easyscience.fitting.samplers.sampler_dream import DreamSampler + + assert f.minimizer.package != 'bumps' # default is LMFit + + constructed = {} + original_init = DreamSampler.__init__ + + def spy_init(self, obj, fit_function): + constructed['obj'] = obj + constructed['fit_function'] = fit_function + original_init(self, obj, fit_function) + + canned = { + 'draws': np.zeros((2, 2)), + 'param_names': ['offset', 'phase'], + 'logp': np.zeros(2), + 'internal_bumps_object': object(), + } + monkeypatch.setattr(DreamSampler, '__init__', spy_init) + monkeypatch.setattr(DreamSampler, 'run', lambda self, **kwargs: dict(canned)) + + sampler = Sampler(f, [x], [y], [weights]) + results = sampler.sample(samples=10, burn=0, thin=1) + + assert results.param_names == ['offset', 'phase'] + # The engine is bound to the fitter's model object and a wrapped + # fit function, not to the minimizer. + assert constructed['obj'] is f.fit_object + assert callable(constructed['fit_function']) class TestSamplerExtendArithmetic: From f783dc7c22f7268e86c1b7b440ece32a3f7c88d1 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Thu, 6 Aug 2026 17:24:08 +0200 Subject: [PATCH 02/25] weird linting --- src/easyscience/fitting/fitter.py | 2 +- src/easyscience/fitting/minimizers/minimizer_bumps.py | 2 +- src/easyscience/fitting/samplers/sampler_dream.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index e2a26bd7..579af4f6 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -489,7 +489,7 @@ def mcmc_sample( If ``samples``, ``burn``, or ``thin`` are invalid. RuntimeError If the ``bumps`` package is not installed. - """ + """ # noqa: DOC502 -- raised in the delegated Sampler.sample() warnings.warn( 'Fitter.mcmc_sample() is deprecated. Use ' 'Sampler(fitter, x, y, weights).sample(...) instead.', diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index ea908fb4..b332ab15 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -416,7 +416,7 @@ def mcmc_sample( Exception Re-raised from DREAM fitting if any unexpected error occurs (parameter values are restored beforehand). - """ + """ # noqa: DOC502 -- raised in the delegated DreamSampler.run() warnings.warn( 'Bumps.mcmc_sample() is deprecated. Use easyscience.fitting.Sampler ' '(which no longer requires a BUMPS minimizer) instead.', diff --git a/src/easyscience/fitting/samplers/sampler_dream.py b/src/easyscience/fitting/samplers/sampler_dream.py index bbc62014..67dcd4a2 100644 --- a/src/easyscience/fitting/samplers/sampler_dream.py +++ b/src/easyscience/fitting/samplers/sampler_dream.py @@ -51,7 +51,7 @@ class DreamSampler(EngineBase): def __init__( self, - obj, #: ObjBase, + obj: object, #: ObjBase, fit_function: Callable, ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 """ From 2ee4d8d64a6fa7279925d39c34ea4135cc144076 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 7 Aug 2026 15:57:31 +0200 Subject: [PATCH 03/25] updated comments, type hinting and such --- .../fitting/minimizers/minimizer_base.py | 8 +- .../fitting/minimizers/minimizer_bumps.py | 264 +++++++++---- .../fitting/minimizers/minimizer_dfo.py | 13 +- .../fitting/minimizers/minimizer_lmfit.py | 9 +- .../minimizers/test_minimizer_bumps.py | 362 ++++++++++++++++-- 5 files changed, 542 insertions(+), 114 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 923881ce..136e4396 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -62,7 +62,7 @@ def fit( method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, **kwargs, ) -> FitResults: """ @@ -86,8 +86,10 @@ def fit( Requested convergence tolerance. By default, None. max_evaluations : int | None, default=None Maximum number of objective evaluations. By default, None. - progress_callback : Callable[[dict], bool | None] | None, default=None - Optional progress callback. By default, None. + progress_callback : Callable[[dict], None] | None, default=None + Optional progress callback. Its return value is ignored by + every backend; use ``abort_test`` to stop a running fit. By + default, None. **kwargs : Additional arguments for the fitting function. diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index b332ab15..8d58e30e 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -3,11 +3,11 @@ from __future__ import annotations -import copy import warnings from typing import TYPE_CHECKING from typing import Any from typing import Callable +from typing import cast import numpy as np from bumps.fitters import FIT_AVAILABLE_IDS @@ -15,6 +15,7 @@ from bumps.fitters import FitDriver from bumps.names import FitProblem from bumps.parameter import Parameter as BumpsParameter +from scipy.optimize import OptimizeResult # causes circular import when Parameter is imported # from easyscience.base_classes import ObjBase @@ -24,6 +25,7 @@ from .bumps_utils import BumpsProgressMonitor from .bumps_utils import EvalCounter from .bumps_utils import build_curve_problem +from .bumps_utils import parameter_names from .bumps_utils import parameter_snapshot from .bumps_utils import to_bumps_parameter from .bumps_utils import validate_arrays @@ -34,10 +36,12 @@ if TYPE_CHECKING: from bumps.dream.state import MCMCDraw + from bumps.fitters import FitBase -FIT_AVAILABLE_IDS_FILTERED = copy.copy(FIT_AVAILABLE_IDS) -# Considered experimental -FIT_AVAILABLE_IDS_FILTERED.remove('pt') +# 'pt' (parallel tempering) is considered experimental and is not exposed. +# Filtered with a comprehension rather than ``list.remove()`` so that importing +# this module does not raise if a future BUMPS release drops the id. +FIT_AVAILABLE_IDS_FILTERED = [fit_id for fit_id in FIT_AVAILABLE_IDS if fit_id != 'pt'] class Bumps(MinimizerBase): @@ -51,10 +55,10 @@ class Bumps(MinimizerBase): def __init__( self, - obj: object, #: ObjBase, + obj: object, fit_function: Callable, minimizer_enum: AvailableMinimizers | None = None, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): """ Initialize the fitting engine. @@ -73,7 +77,8 @@ def __init__( @staticmethod def all_methods() -> list[str]: - return FIT_AVAILABLE_IDS_FILTERED + # Copy so callers cannot mutate the module-level list in place. + return list(FIT_AVAILABLE_IDS_FILTERED) @staticmethod def supported_methods() -> list[str]: @@ -91,7 +96,7 @@ def fit( method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, abort_test: Callable[[], bool] | None = None, minimizer_kwargs: dict | None = None, engine_kwargs: dict | None = None, @@ -109,9 +114,14 @@ def fit( weights : np.ndarray Weights for supplied measured points. model : Callable | None, default=None - Optional Model which is being fitted to. By default, None. + Optional BUMPS ``Curve`` which is being fitted to. When + omitted, one is built from ``fit_function`` and the object's + fit parameters. A supplied ``Curve`` must expose ``pars``, + ``x``, ``y`` and ``dy``, since the results are assembled from + them. By default, None. parameters : list[Parameter] | None, default=None - Optional parameters for the fit. By default, None. + Optional parameters for the fit. Ignored when ``model`` is + supplied. By default, None. method : str | None, default=None Method for minimization. By default, None. tolerance : float | None, default=None @@ -121,17 +131,22 @@ def fit( ``steps`` parameter. If ``None``, the default value defined by the selected BUMPS fitter (``fitclass.settings``) is used. By default, None. - progress_callback : Callable[[dict], bool | None] | None, default=None + progress_callback : Callable[[dict], None] | None, default=None Optional callback for progress updates. The payload field - ``iteration`` carries the BUMPS optimizer step index. By - default, None. + ``iteration`` carries the BUMPS optimizer step index. The + return value is ignored — use ``abort_test`` to stop a + running fit. By default, None. abort_test : Callable[[], bool] | None, default=None Optional callback that returns ``True`` to signal that the fit should be aborted. Called periodically during the BUMPS - optimizer iteration loop. + optimizer iteration loop, and once more after the optimizer + returns in order to distinguish an aborted fit from a + converged one, so it must be side-effect free. An aborted fit + returns ``FitResults(success=False)`` rather than raising. minimizer_kwargs : dict | None, default=None Additional keyword arguments passed to the BUMPS minimizer. - By default, None. + The mapping is copied before use, so it is never mutated. By + default, None. engine_kwargs : dict | None, default=None Additional engine keyword arguments. By default, None. **kwargs : Any @@ -140,14 +155,24 @@ def fit( Returns ------- FitResults - Fit results. + Fit results. ``FitResults.iterations`` is the number of BUMPS + *optimizer steps* consumed (the last reported step index plus + one), which is what ``max_evaluations`` budgets against; it is + not comparable to LMFit's ``nfev`` or DFO-LS' ``nf``. The + objective-call count is reported separately as + ``FitResults.n_evaluations``, which is the cross-backend + consistent figure. Note that BUMPS derives the step index from + whatever the selected fitter reports to its monitors, so the + granularity of a "step" varies between fitters. Raises ------ FitError - If the BUMPS fit fails. + If the BUMPS fit raises. A fit that merely fails to converge + is reported as ``FitResults(success=False)`` instead. ValueError - If the input shapes or weights are invalid. + If the input shapes or weights are invalid, or if + ``progress_callback`` is not callable. """ method_dict = self._get_method_kwargs(method) @@ -155,35 +180,55 @@ def fit( validate_arrays(x, y, weights, check_finite_xy=False) + if progress_callback is not None and not callable(progress_callback): + raise ValueError('progress_callback must be callable') + if engine_kwargs is None: engine_kwargs = {} - if minimizer_kwargs is None: - minimizer_kwargs = {} + # Copy rather than mutate: `ftol`/`xtol`/`steps` are injected below, and a + # caller reusing the same mapping for a second fit would otherwise silently + # inherit the settings resolved for the first one. + minimizer_kwargs = {} if minimizer_kwargs is None else dict(minimizer_kwargs) minimizer_kwargs.update(engine_kwargs) method_str = method_dict.get('method', self._method) fitclass = self._resolve_fitclass(method_str) + # Reset the per-fit evaluation counter. A caller-supplied `model` bypasses + # `build_curve_problem`, which is what installs the counter, so without this + # the results would carry the previous fit's objective-call count. + self._eval_counter = None + # Resolve BUMPS-native defaults so the budget reported back to the caller (and # used by the budget-exhaustion check in `_gen_fit_results`) reflects the values # actually consumed by the fitter, even when the caller passes None. + # + # Only values the caller supplied explicitly are pushed back into + # `minimizer_kwargs`. BUMPS pairs an independent `ftol`/`xtol` default per + # fitter (`newton` combines ftol=1e-6 with xtol=1e-12, `amoeba` ftol=1e-8 with + # xtol=1e-6), so collapsing them onto a single resolved value would silently + # tighten the fitter's own convergence criteria on the default path. fitter_settings = dict(fitclass.settings) - if max_evaluations is None: + + if max_evaluations is not None: + minimizer_kwargs['steps'] = max_evaluations + else: max_evaluations = fitter_settings.get('steps') - if tolerance is None: - ftol = fitter_settings.get('ftol') - xtol = fitter_settings.get('xtol') - tols = [t for t in (ftol, xtol) if t is not None] - tolerance = min(tols) if tols else None if tolerance is not None: minimizer_kwargs['ftol'] = tolerance # tolerance for change in function value minimizer_kwargs['xtol'] = ( tolerance # tolerance for change in parameter value, could be an independent value ) - if max_evaluations is not None: - minimizer_kwargs['steps'] = max_evaluations + else: + # Report the stricter of the two BUMPS defaults; nothing is written back. + tols = [ + t + for t in (fitter_settings.get('ftol'), fitter_settings.get('xtol')) + if t is not None + ] + tolerance = min(tols) if tols else None if model is None: # The Curve comes back directly from the helper: do NOT read it @@ -192,6 +237,13 @@ def fit( self, x, y, weights, parameters=parameters ) else: + # A caller-supplied model bypasses `build_curve_problem`, which is also + # what populates the parameter cache that `_p_0`, + # `_set_parameter_fit_result` and `_gen_fit_results` all read. Build the + # wrapped fit function here purely for that side effect, so the cache + # describes the current object rather than being empty or left over from + # an earlier fit. + self._fit_function = self._generate_fit_function() problem = FitProblem(model) self._cached_model = model @@ -199,8 +251,6 @@ def fit( monitors = [] if progress_callback is not None: - if not callable(progress_callback): - raise ValueError('progress_callback must be callable') monitors.append( BumpsProgressMonitor(problem, progress_callback, self._build_progress_payload) ) @@ -225,25 +275,55 @@ def fit( # Drive the fit through the local FitDriver instance so the supplied # `monitors` (including the optional progress callback monitor) are # invoked. `bumps.fitters.fit` constructs its own driver. - x, fx = driver.fit() - from scipy.optimize import OptimizeResult + # + # Named `best_x` rather than `x` so the caller's independent-variable + # array stays intact for the rest of the method. + best_x, fx = driver.fit() + + # BUMPS signals a failed optimization by returning `None` in place of a + # parameter vector (e.g. Levenberg-Marquardt landing on non-finite + # values); `FitDriver.fit` skips `problem.setp` in that case. Poll + # `abort_test` once more to tell a user-cancelled run apart from a + # converged one, since BUMPS stops quietly either way. + if best_x is None: + success = False + message = 'BUMPS returned no solution; the fit did not converge' + elif abort_test is not None and abort_test(): + success = False + message = 'Fit aborted before convergence' + else: + success = True + message = 'successful termination' # BUMPS' `MonitorRunner.history.step` is populated by the driver itself # (independently of any user-supplied monitors) and exposes the canonical - # last-step index reached by the fitter, so we use it as `nit`. - history_step = getattr(getattr(driver, 'monitor_runner', None), 'history', None) - nit_value = int(history_step.step[0]) if history_step is not None else None + # last-step index reached by the fitter, so we use it as `nit`. `Trace` + # indexes into an internal list, so an empty trace raises `IndexError` + # rather than returning a default — that happens when the fit is aborted + # before the fitter reports its first step. + history = getattr(getattr(driver, 'monitor_runner', None), 'history', None) + step_trace = getattr(history, 'step', None) + nit_value = int(step_trace[0]) if step_trace is not None and len(step_trace) else None + model_results = OptimizeResult( - x=x, - dx=driver.stderr(), + # `driver.stderr()` derives the errors from the covariance at the + # solution, so it cannot be evaluated without one. + x=best_x, + dx=driver.stderr() if best_x is not None else None, fun=fx, - success=True, - status=0, - message='successful termination', + success=success, + status=0 if success else 1, + message=message, nit=nit_value, ) model_results.state = driver.fitter.state - self._set_parameter_fit_result(model_results, stack_status, problem._parameters) + + if best_x is None: + self._restore_parameter_values() + else: + self._set_parameter_fit_result( + model_results, stack_status, parameter_names(problem) + ) results = self._gen_fit_results( model_results, max_evaluations=max_evaluations, @@ -251,20 +331,42 @@ def fit( ) except Exception as e: self._restore_parameter_values() - raise FitError(e) + raise FitError(e) from e finally: global_object.stack.enabled = stack_status return results @staticmethod - def _resolve_fitclass(method: str): - for fitclass in FITTERS: - if fitclass.id == method: - return fitclass - raise FitError(f'Unknown BUMPS fitting method: {method}') + def _resolve_fitclass(method: str) -> type[FitBase]: + """ + Look up the BUMPS fitter class registered under ``method``. + + Parameters + ---------- + method : str + A BUMPS fitter id, e.g. ``'amoeba'``. + + Returns + ------- + type[FitBase] + The matching BUMPS fitter class. + + Raises + ------ + FitError + If no registered fitter carries that id. + """ + # Built per call rather than cached at import time so that fitters + # registered into `FITTERS` after import are still resolvable. + fitclass = {fitclass.id: fitclass for fitclass in FITTERS}.get(method) + if fitclass is None: + raise FitError(f'Unknown BUMPS fitting method: {method}') + # BUMPS annotates `FITTERS` as `List[FitBase]`, but it holds the fitter + # *classes* — `FitDriver` instantiates them as `self.fitclass(problem)`. + return cast('type[FitBase]', fitclass) def _build_progress_payload( - self, problem, iteration: int, point: np.ndarray, nllf: float + self, problem: FitProblem, iteration: int, point: np.ndarray, nllf: float ) -> dict: # Use the nllf already computed by the fitter to avoid a costly # model re-evaluation, and let BUMPS apply its own chisq scaling. @@ -301,10 +403,9 @@ def convert_to_pars_obj(self, par_list: list[Parameter] | None = None) -> list[B if par_list is None: # Assume that we have a ObjBase for which we can obtain a list par_list = self._object.get_fit_parameters() - pars_obj = [self.__class__.convert_to_par_object(obj) for obj in par_list] + pars_obj = [self.convert_to_par_object(obj) for obj in par_list] return pars_obj - # For some reason I have to double staticmethod :-/ @staticmethod def convert_to_par_object(obj: Parameter) -> BumpsParameter: """ @@ -334,7 +435,7 @@ def mcmc_sample( population: int | None = None, resume_state: MCMCDraw | None = None, sampler_kwargs: dict | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> dict: """ @@ -388,10 +489,10 @@ def mcmc_sample( sampler_kwargs : dict | None, default=None Additional keyword arguments forwarded to ``bumps.fitters.fit``. - progress_callback : Callable[[dict], bool | None] | None, default=None + progress_callback : Callable[[dict], None] | None, default=None Optional callback for progress updates during sampling. The payload dict includes ``iteration`` (DREAM generation - number) and ``sampling: True``. + number) and ``sampling: True``. The return value is ignored. abort_test : Callable[[], bool] | None, default=None Optional callback that returns ``True`` to signal that sampling should be aborted. Called periodically during the @@ -444,7 +545,7 @@ def _set_parameter_fit_result( self, fit_result: Any, stack_status: bool, - par_list: list[BumpsParameter], + par_names: list[str], ) -> None: """ Update parameters to their final values and assign a std error @@ -456,24 +557,28 @@ def _set_parameter_fit_result( BUMPS OptimizeResult containing best-fit values and errors. stack_status : bool Whether the undo stack was enabled. - par_list : list[BumpsParameter] - List of BUMPS parameter objects. + par_names : list[str] + Cached-parameter names in BUMPS problem order, already + stripped of ``MINIMIZER_PARAMETER_PREFIX`` — see + :func:`~easyscience.fitting.minimizers.bumps_utils.parameter_names`. """ from easyscience import global_object pars = self._cached_pars x_result = np.asarray(fit_result.x) - stderr = np.asarray(fit_result.dx) + # Some BUMPS fitters cannot produce a covariance and hand back no errors; + # report those parameters as having no uncertainty rather than failing, + # matching what the LMFit minimizer does when `errorbars` is False. + stderr = None if fit_result.dx is None else np.asarray(fit_result.dx) if stack_status: self._restore_parameter_values() global_object.stack.enabled = True global_object.stack.beginMacro('Fitting routine') - for index, name in enumerate([par.name for par in par_list]): - dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :] - pars[dict_name].value = x_result[index] - pars[dict_name].error = stderr[index] + for index, name in enumerate(par_names): + pars[name].value = x_result[index] + pars[name].error = 0.0 if stderr is None else stderr[index] if stack_status: global_object.stack.endMacro() @@ -505,8 +610,10 @@ def _gen_fit_results( """ results = FitResults() + # `hasattr`, not a truthiness test: every `FitResults` field starts out + # falsy, so testing the current value would silently discard every kwarg. for name, value in kwargs.items(): - if getattr(results, name, False): + if hasattr(results, name): setattr(results, name, value) n_evaluations = None if self._eval_counter is None else self._eval_counter.count # BUMPS exposes `nit` as the last reported optimizer step index rather than the @@ -539,34 +646,35 @@ def _gen_fit_results( results.p = item results.x = self._cached_model.x results.y_obs = self._cached_model.y + # Costs one extra model evaluation beyond those the optimizer consumed, and + # deliberately so: it runs through the uncounted `self._fit_function`, keeping + # `n_evaluations` a faithful count of optimizer-driven objective calls. results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p) results.y_err = self._cached_model.dy results.n_evaluations = n_evaluations results.iterations = n_steps_used - results.message = '' + # A successful fit carries no message; anything else reports why it stopped. + results.message = ( + '' if fit_results.success else (getattr(fit_results, 'message', '') or '') + ) + if stopped_on_budget: + from easyscience import global_object + results.message = ( f'Fit stopped: reached maximum optimizer steps ({max_evaluations}); ' f'objective evaluated {n_evaluations} times' ) - if stopped_on_budget: - from easyscience import global_object - if tolerance is None: - global_object.log.getLogger('fitting.bumps').warning( - f'Fit did not converge within the maximum optimizer steps of {max_evaluations} ' - f'({n_evaluations} objective evaluations). ' - 'Consider increasing the maximum number of evaluations or adjusting the tolerance.' - ) + reason = 'Fit did not converge within' else: - global_object.log.getLogger('fitting.bumps').warning( - f'Fit did not reach the desired tolerance of {tolerance} within the maximum optimizer steps of {max_evaluations} ' - f'({n_evaluations} objective evaluations). ' - 'Consider increasing the maximum number of evaluations or adjusting the tolerance.' - ) + reason = f'Fit did not reach the desired tolerance of {tolerance} within' + global_object.log.getLogger('fitting.bumps').warning( + f'{reason} the maximum optimizer steps of {max_evaluations} ' + f'({n_evaluations} objective evaluations). ' + 'Consider increasing the maximum number of evaluations or adjusting the tolerance.' + ) - # results.residual = results.y_obs - results.y_calc - # results.goodness_of_fit = np.sum(results.residual**2) results.minimizer_engine = self.__class__ results.fit_args = None results.engine_result = fit_results diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 319db849..51c57d63 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -83,7 +83,7 @@ def fit( method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, callback: Callable[[DFOCallbackState], None] | None = None, **kwargs, ) -> FitResults: @@ -108,8 +108,9 @@ def fit( Requested optimizer tolerance. By default, None. max_evaluations : int | None, default=None Maximum number of evaluations. By default, None. - progress_callback : Callable[[dict], bool | None] | None, default=None - Optional callback receiving normalized progress payloads. + progress_callback : Callable[[dict], None] | None, default=None + Optional callback receiving normalized progress payloads. Its + return value is ignored. callback : Callable[[DFOCallbackState], None] | None, default=None Optional native DFO callback. **kwargs : @@ -302,7 +303,7 @@ def wrapped_model(pars_values: List[float]) -> np.ndarray: @staticmethod def _make_progress_adapter( - progress_callback: Callable[[dict], bool | None], + progress_callback: Callable[[dict], None], ) -> Callable[['DFOCallbackState'], None]: """ Create a DFO callback that translates DFOCallbackState into the @@ -310,8 +311,8 @@ def _make_progress_adapter( Parameters ---------- - progress_callback : Callable[[dict], bool | None] - Standard progress callback (dict -> bool|None). + progress_callback : Callable[[dict], None] + Standard progress callback (dict -> None). Returns ------- diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 31fb543f..30664708 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -90,7 +90,7 @@ def fit( method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, minimizer_kwargs: dict | None = None, engine_kwargs: dict | None = None, **kwargs, @@ -116,8 +116,9 @@ def fit( Requested optimizer tolerance. By default, None. max_evaluations : int | None, default=None Maximum number of function evaluations. By default, None. - progress_callback : Callable[[dict], bool | None] | None, default=None - Optional callback receiving normalized progress payloads. + progress_callback : Callable[[dict], None] | None, default=None + Optional callback receiving normalized progress payloads. Its + return value is ignored. minimizer_kwargs : dict | None, default=None Additional keyword arguments passed to LMFit's minimizer. By default, None. @@ -194,7 +195,7 @@ def fit( def _create_iter_callback( self, - progress_callback: Callable[[dict], bool | None] | None, + progress_callback: Callable[[dict], None] | None, ) -> Callable | None: def iter_cb(params, iteration: int, residuals: np.ndarray, *args, **kwargs) -> bool: diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index 11462696..f7ef0517 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -41,6 +41,13 @@ def test_all_methods(self, minimizer: Bumps) -> None: # When Then Expect assert minimizer.all_methods() == ['amoeba', 'de', 'dream', 'newton', 'lm'] + def test_all_methods_returns_a_copy(self, minimizer: Bumps) -> None: + """Callers must not be able to mutate the module-level list in place.""" + methods = minimizer.all_methods() + methods.append('tampered') + + assert 'tampered' not in minimizer.all_methods() + def test_supported_methods(self, minimizer: Bumps) -> None: # When Then Expect assert set(minimizer.supported_methods()) == set(['newton', 'lm', 'amoeba']) @@ -84,11 +91,10 @@ def test_fit(self, minimizer: Bumps, monkeypatch) -> None: minimizer._cached_pars = cached_pars minimizer._cached_pars_vals = {'mock_parm_1': (1, 0.0)} - # Patch _set_parameter_fit_result - def fake_set_parameter_fit_result(fit_result, stack_status, par_list): - for index, name in enumerate([par.name for par in par_list]): - dict_name = name[len('p') :] - minimizer._cached_pars[dict_name].value = fit_result.x[index] + # Patch _set_parameter_fit_result. It now receives prefix-stripped names. + def fake_set_parameter_fit_result(fit_result, stack_status, par_names): + for index, name in enumerate(par_names): + minimizer._cached_pars[name].value = fit_result.x[index] minimizer._set_parameter_fit_result = fake_set_parameter_fit_result @@ -159,15 +165,8 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): mock_fit_result.x = np.array([1.0, 2.0]) mock_fit_result.dx = np.array([0.1, 0.2]) - # The new argument: par_list (list of mock parameters) - mock_par_a = MagicMock() - mock_par_a.name = 'pa' - mock_par_b = MagicMock() - mock_par_b.name = 'pb' - par_list = [mock_par_a, mock_par_b] - - # Then - minimizer._set_parameter_fit_result(mock_fit_result, False, par_list) + # Then - names arrive already stripped of the minimizer prefix + minimizer._set_parameter_fit_result(mock_fit_result, False, ['a', 'b']) # Expect assert minimizer._cached_pars['a'].value == 1.0 @@ -175,6 +174,20 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): assert minimizer._cached_pars['b'].value == 2.0 assert minimizer._cached_pars['b'].error == 0.2 + def test_set_parameter_fit_result_without_stderr(self, minimizer: Bumps): + """Fitters that cannot produce a covariance hand back ``dx=None``; + those parameters get a zero error instead of raising.""" + minimizer._cached_pars = {'a': MagicMock()} + + mock_fit_result = MagicMock() + mock_fit_result.x = np.array([1.0]) + mock_fit_result.dx = None + + minimizer._set_parameter_fit_result(mock_fit_result, False, ['a']) + + assert minimizer._cached_pars['a'].value == 1.0 + assert minimizer._cached_pars['a'].error == 0.0 + def test_gen_fit_results( self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture' ): @@ -282,6 +295,51 @@ def test_gen_fit_results_max_evaluations_boundary( assert mock_domain_fit_results.success is expected_success + def test_gen_fit_results_applies_extra_kwargs(self, minimizer: Bumps) -> None: + """Extra kwargs land on a real FitResults. Guarding the copy on the + current value instead of `hasattr` would drop every one of them, since + all FitResults fields start out falsy.""" + mock_cached_model = MagicMock() + mock_cached_model.x = np.array([1.0]) + mock_cached_model.y = np.array([2.0]) + mock_cached_model.dy = np.array([1.0]) + mock_cached_model.pars = {'ppar_1': 0} + minimizer._cached_model = mock_cached_model + minimizer._cached_pars = {'par_1': MagicMock(value=1.0)} + minimizer._p_0 = {} + minimizer._eval_counter = None + minimizer.evaluate = MagicMock(return_value=np.array([2.0])) + + mock_fit_result = MagicMock() + mock_fit_result.success = True + mock_fit_result.nit = 1 + + results = minimizer._gen_fit_results(mock_fit_result, x_matrices='copied') + + assert results.x_matrices == 'copied' + + def test_gen_fit_results_propagates_failure_message(self, minimizer: Bumps) -> None: + mock_cached_model = MagicMock() + mock_cached_model.x = np.array([1.0]) + mock_cached_model.y = np.array([2.0]) + mock_cached_model.dy = np.array([1.0]) + mock_cached_model.pars = {'ppar_1': 0} + minimizer._cached_model = mock_cached_model + minimizer._cached_pars = {'par_1': MagicMock(value=1.0)} + minimizer._p_0 = {} + minimizer._eval_counter = None + minimizer.evaluate = MagicMock(return_value=np.array([2.0])) + + mock_fit_result = MagicMock() + mock_fit_result.success = False + mock_fit_result.nit = 1 + mock_fit_result.message = 'Fit aborted before convergence' + + results = minimizer._gen_fit_results(mock_fit_result) + + assert results.success is False + assert results.message == 'Fit aborted before convergence' + def test_resolve_fitclass_valid(self, minimizer: Bumps) -> None: # When Then fitclass = Bumps._resolve_fitclass('lm') @@ -379,8 +437,14 @@ def test_fit_uses_supplied_model_and_optional_kwargs( minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) minimizer._set_parameter_fit_result = MagicMock() - minimizer._cached_pars = {'mock_parm_1': MagicMock(value=1.0)} - minimizer._cached_pars_vals = {'mock_parm_1': (1.0, 0.0)} + + # A supplied model bypasses build_curve_problem, so fit() must populate the + # parameter cache itself from the bound object rather than leaving it empty. + object_parameter = MagicMock(unique_name='mock_parm_1') + object_parameter.value = 1.0 + object_parameter.error = 0.0 + minimizer._object = MagicMock() + minimizer._object.get_fit_parameters = MagicMock(return_value=[object_parameter]) supplied_model = MagicMock() minimizer_kwargs = {'existing_option': 'minimizer'} @@ -407,6 +471,53 @@ def test_fit_uses_supplied_model_and_optional_kwargs( assert fit_driver_kwargs['xtol'] == 0.25 assert fit_driver_kwargs['steps'] == 7 mock_driver_instance.fit.assert_called_once() + # The cache and the starting-point snapshot are built from the bound object + assert minimizer._cached_pars == {'mock_parm_1': object_parameter} + assert minimizer._p_0 == {'pmock_parm_1': 1.0} + + def test_fit_with_supplied_model_resets_eval_counter( + self, minimizer: Bumps, monkeypatch + ) -> None: + """A supplied model installs no EvalCounter, so a counter left over + from a previous fit must not be reported as this fit's count.""" + from easyscience import global_object + + global_object.stack.enabled = False + + mock_driver_instance = MagicMock() + mock_driver_instance.fit = MagicMock(return_value=(np.array([3.0]), 0.0)) + mock_driver_instance.stderr = MagicMock(return_value=np.array([0.1])) + mock_driver_instance.monitor_runner.history.step = [0] + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'FitDriver', + MagicMock(return_value=mock_driver_instance), + ) + mock_problem = MagicMock() + mock_problem._parameters = [] + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'FitProblem', + MagicMock(return_value=mock_problem), + ) + + minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') + minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) + minimizer._set_parameter_fit_result = MagicMock() + minimizer._object = MagicMock() + minimizer._object.get_fit_parameters = MagicMock(return_value=[]) + + # Stale counter from an earlier fit + minimizer._eval_counter = MagicMock(count=999) + + minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + model=MagicMock(), + ) + + assert minimizer._eval_counter is None def test_fit_rejects_non_callable_progress_callback( self, minimizer: Bumps, monkeypatch @@ -628,6 +739,217 @@ def test_gen_fit_results_uses_nit_for_budget_check( ) +# =================================================================== +# fit() — tolerance / budget defaults are reported, never forced +# =================================================================== + + +class TestFitToleranceAndBudgetDefaults: + """BUMPS pairs an independent ftol/xtol default per fitter. Resolving + them for reporting must not push a single collapsed value back into the + fitter, which would silently tighten its convergence criteria.""" + + @pytest.fixture + def minimizer(self) -> Bumps: + return Bumps( + obj='obj', + fit_function='fit_function', + minimizer_enum=MagicMock(package='bumps', method='newton'), + ) + + @staticmethod + def _patch_driver_and_problem(minimizer: Bumps, monkeypatch) -> MagicMock: + from easyscience import global_object + + global_object.stack.enabled = False + + mock_driver = MagicMock() + mock_driver.fit = MagicMock(return_value=(np.array([42.0]), 0.0)) + mock_driver.stderr = MagicMock(return_value=np.array([0.1])) + mock_driver.monitor_runner.history.step = [0] + mock_FitDriver = MagicMock(return_value=mock_driver) + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver + ) + + mock_problem = MagicMock() + mock_problem._parameters = [] + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(count=3), MagicMock())), + ) + + minimizer._gen_fit_results = MagicMock(return_value='result') + minimizer._set_parameter_fit_result = MagicMock() + minimizer._cached_pars = {} + minimizer._cached_pars_vals = {} + return mock_FitDriver + + def test_tolerance_none_does_not_override_fitter_defaults( + self, minimizer: Bumps, monkeypatch + ) -> None: + mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch) + + minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0])) + + # The real 'newton' settings are ftol=1e-6 / xtol=1e-12. Neither may be + # forwarded, or BUMPS would run against a tolerance the caller never asked for. + driver_kwargs = mock_FitDriver.call_args.kwargs + assert 'ftol' not in driver_kwargs + assert 'xtol' not in driver_kwargs + assert 'steps' not in driver_kwargs + + # ...but the resolved defaults are still reported for the budget check. + gen_kwargs = minimizer._gen_fit_results.call_args.kwargs + assert gen_kwargs['tolerance'] == 1e-12 # min(ftol, xtol) + assert gen_kwargs['max_evaluations'] == 3000 # 'newton' default steps + + def test_explicit_tolerance_is_forwarded(self, minimizer: Bumps, monkeypatch) -> None: + mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch) + + minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + tolerance=1e-3, + max_evaluations=11, + ) + + driver_kwargs = mock_FitDriver.call_args.kwargs + assert driver_kwargs['ftol'] == 1e-3 + assert driver_kwargs['xtol'] == 1e-3 + assert driver_kwargs['steps'] == 11 + + def test_minimizer_kwargs_is_not_mutated(self, minimizer: Bumps, monkeypatch) -> None: + self._patch_driver_and_problem(minimizer, monkeypatch) + + minimizer_kwargs = {'existing': 'value'} + minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + tolerance=1e-3, + max_evaluations=11, + minimizer_kwargs=minimizer_kwargs, + engine_kwargs={'engine': 'option'}, + ) + + # The caller's mapping is untouched, so reusing it cannot leak settings + # from one fit into the next. + assert minimizer_kwargs == {'existing': 'value'} + + +# =================================================================== +# fit() — unsuccessful and aborted outcomes +# =================================================================== + + +class TestFitUnsuccessfulOutcomes: + @pytest.fixture + def minimizer(self) -> Bumps: + return Bumps( + obj='obj', + fit_function='fit_function', + minimizer_enum=MagicMock(package='bumps', method='amoeba'), + ) + + @staticmethod + def _patch(minimizer: Bumps, monkeypatch, driver_result, history_step=None) -> MagicMock: + from easyscience import global_object + + global_object.stack.enabled = False + + mock_driver = MagicMock() + mock_driver.fit = MagicMock(return_value=driver_result) + mock_driver.stderr = MagicMock(return_value=np.array([0.1])) + mock_driver.monitor_runner.history.step = [] if history_step is None else history_step + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'FitDriver', + MagicMock(return_value=mock_driver), + ) + + mock_problem = MagicMock() + mock_problem._parameters = [] + monkeypatch.setattr( + easyscience.fitting.minimizers.minimizer_bumps, + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(count=3), MagicMock())), + ) + + minimizer._gen_fit_results = MagicMock(return_value='result') + minimizer._set_parameter_fit_result = MagicMock() + minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) + minimizer._cached_pars = {} + minimizer._cached_pars_vals = {} + return mock_driver + + def test_no_solution_is_reported_not_raised(self, minimizer: Bumps, monkeypatch) -> None: + """BUMPS returns x=None for a failed optimization (e.g. LM landing on + non-finite values). That is a non-converged fit, not an exception.""" + self._patch(minimizer, monkeypatch, driver_result=(None, None), history_step=[4]) + minimizer._restore_parameter_values = MagicMock() + + result = minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0])) + + assert result == 'result' + passed = minimizer._gen_fit_results.call_args.args[0] + assert passed.success is False + assert passed.x is None + assert passed.dx is None # stderr() needs a solution to expand around + assert 'did not converge' in passed.message + # Parameters are rolled back and never written from a missing solution + minimizer._restore_parameter_values.assert_called_once() + minimizer._set_parameter_fit_result.assert_not_called() + + def test_abort_is_reported_as_unsuccessful(self, minimizer: Bumps, monkeypatch) -> None: + self._patch( + minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[2] + ) + + result = minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + abort_test=lambda: True, + ) + + assert result == 'result' + passed = minimizer._gen_fit_results.call_args.args[0] + assert passed.success is False + assert passed.message == 'Fit aborted before convergence' + # The best point reached before the abort is still applied + minimizer._set_parameter_fit_result.assert_called_once() + + def test_empty_step_history_does_not_raise(self, minimizer: Bumps, monkeypatch) -> None: + """An abort before the fitter reports its first step leaves the BUMPS + history trace empty; indexing it would raise IndexError.""" + self._patch(minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[]) + + result = minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0])) + + assert result == 'result' + assert minimizer._gen_fit_results.call_args.args[0].nit is None + + def test_successful_fit_reports_success(self, minimizer: Bumps, monkeypatch) -> None: + self._patch( + minimizer, monkeypatch, driver_result=(np.array([42.0]), 0.0), history_step=[7] + ) + + minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + abort_test=lambda: False, + ) + + passed = minimizer._gen_fit_results.call_args.args[0] + assert passed.success is True + assert passed.message == 'successful termination' + assert passed.nit == 7 + + # =================================================================== # Bumps.mcmc_sample() — deprecated delegate to DreamSampler # =================================================================== @@ -719,13 +1041,7 @@ def test_stack_status_true_calls_begin_end_macro(self, minimizer: Bumps) -> None mock_fit_result.x = np.array([1.0, 2.0]) mock_fit_result.dx = np.array([0.1, 0.2]) - mock_par_a = MagicMock() - mock_par_a.name = 'pa' - mock_par_b = MagicMock() - mock_par_b.name = 'pb' - par_list = [mock_par_a, mock_par_b] - - minimizer._set_parameter_fit_result(mock_fit_result, True, par_list) + minimizer._set_parameter_fit_result(mock_fit_result, True, ['a', 'b']) assert minimizer._cached_pars['a'].value == 1.0 assert minimizer._cached_pars['a'].error == 0.1 From cf920f4d2ea7a653cd3f240918971921748fe374 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Sun, 9 Aug 2026 21:56:58 +0200 Subject: [PATCH 04/25] removed all legacy junk --- src/easyscience/fitting/fitter.py | 94 -------------- .../fitting/minimizers/minimizer_bumps.py | 119 ------------------ src/easyscience/fitting/sampler.py | 10 -- tests/integration/fitting/test_sampler.py | 30 +---- .../minimizers/test_minimizer_bumps.py | 63 ---------- .../fitting/samplers/test_sampler_dream.py | 4 +- tests/unit/fitting/test_sampler.py | 13 -- 7 files changed, 4 insertions(+), 329 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index 579af4f6..70dcfed0 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -2,7 +2,6 @@ # SPDX-License-Identifier: BSD-3-Clause import functools -import warnings from typing import Callable from typing import List from typing import Optional @@ -419,96 +418,3 @@ def _post_compute_reshaping( fit_result.y_calc = np.reshape(fit_result.y_calc, y.shape) fit_result.y_err = np.reshape(fit_result.y_err, y.shape) return fit_result - - def mcmc_sample( - self, - x: np.ndarray, - y: np.ndarray, - weights: np.ndarray, - samples: int = 10000, - burn: int = 2000, - thin: int = 10, - population: Optional[int] = None, - vectorized: bool = False, - sampler_kwargs: Optional[dict] = None, - progress_callback: Optional[Callable[[dict], Optional[bool]]] = None, - abort_test: Optional[Callable[[], bool]] = None, - ) -> dict: - """ - Run Bayesian MCMC sampling using the BUMPS DREAM sampler. - - Works with both a plain ``Fitter`` (single dataset) and a - ``MultiFitter`` (multiple datasets) via polymorphic dispatch: - ``_precompute_reshaping`` and ``_fit_function_wrapper`` are - resolved on the concrete subclass at call time, so multi-dataset - flattening is handled automatically when called on a - - Parameters - ---------- - x : np.ndarray - Independent variable array (or list of arrays for - ``MultiFitter``). - y : np.ndarray - Dependent variable array (or list of arrays for - ``MultiFitter``). - weights : np.ndarray - Weight array (or list of arrays for ``MultiFitter``). - samples : int, default=10000 - Number of retained DREAM samples requested from BUMPS. - burn : int, default=2000 - Burn-in steps to discard before collecting samples. - thin : int, default=10 - Thinning interval — only every ``thin``-th sample is kept, - which reduces autocorrelation between consecutive draws. - population : Optional[int], default=None - BUMPS DREAM population count (number of parallel chains). - vectorized : bool, default=False - When ``True``, each x array may be multi-dimensional (e.g. - an ``(N, M, 2)`` grid for a 2D model) and is left as-is. - When ``False`` (default), each x array is expected to be - 1-D. - sampler_kwargs : Optional[dict], default=None - Additional keyword arguments forwarded to the BUMPS DREAM - sampler. - progress_callback : Optional[Callable[[dict], Optional[bool]]], default=None - Optional callback invoked at each DREAM generation. The - payload dict includes ``iteration`` and ``sampling: True``. - abort_test : Optional[Callable[[], bool]], default=None - Optional callable that returns ``True`` to abort sampling - early. - - Returns - ------- - dict - Dictionary with keys ``'draws'``, ``'param_names'``, - ``'internal_bumps_object'``, and ``'logp'``. - - Raises - ------ - ValueError - If ``samples``, ``burn``, or ``thin`` are invalid. - RuntimeError - If the ``bumps`` package is not installed. - """ # noqa: DOC502 -- raised in the delegated Sampler.sample() - warnings.warn( - 'Fitter.mcmc_sample() is deprecated. Use ' - 'Sampler(fitter, x, y, weights).sample(...) instead.', - DeprecationWarning, - stacklevel=2, - ) - from .sampler import Sampler - - # ``sampler_kwargs`` is deliberately passed per-call rather than to - # the constructor: for the fresh single-use Sampler created here the - # two are equivalent (constructor kwargs are just per-call defaults), - # and per-call matches the legacy one-shot semantics exactly. - sampler = Sampler(self, x, y, weights=weights, vectorized=vectorized) - return sampler.sample( - samples=samples, - burn=burn, - thin=thin, - population=population, - sampler_kwargs=sampler_kwargs, - progress_callback=progress_callback, - abort_test=abort_test, - ).to_legacy_dict() diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 8d58e30e..8038c2d4 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -3,7 +3,6 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING from typing import Any from typing import Callable @@ -35,7 +34,6 @@ from .utils import FitResults if TYPE_CHECKING: - from bumps.dream.state import MCMCDraw from bumps.fitters import FitBase # 'pt' (parallel tempering) is considered experimental and is not exposed. @@ -424,123 +422,6 @@ def convert_to_par_object(obj: Parameter) -> BumpsParameter: """ return to_bumps_parameter(obj) - def mcmc_sample( - self, - x: np.ndarray, - y: np.ndarray, - weights: np.ndarray, - samples: int = 10000, - burn: int = 2000, - thin: int = 10, - population: int | None = None, - resume_state: MCMCDraw | None = None, - sampler_kwargs: dict | None = None, - progress_callback: Callable[[dict], None] | None = None, - abort_test: Callable[[], bool] | None = None, - ) -> dict: - """ - Run Bayesian MCMC sampling using the BUMPS DREAM sampler. - - Parameters - ---------- - x : np.ndarray - Flattened independent variable array. - y : np.ndarray - Flattened dependent variable array. - weights : np.ndarray - Flattened weight array. - samples : int, default=10000 - Number of raw samples to draw across all chains, before thinning. - A guaranteed minimum, not an exact count: DREAM advances in - blocks of 10 generations (one generation = one draw per chain) - and stops at the first block boundary at or past ``samples``. - burn : int, default=2000 - Burn-in generations to discard. BUMPS counts ``burn`` in - generations while ``samples`` counts raw draws, so ``burn=500`` - discards ``500 * n_chains`` raw samples. - thin : int, default=10 - Thinning interval — only every ``thin``-th generation is stored. - population : int | None, default=None - BUMPS DREAM population count per parameter (number of parallel - chains): BUMPS creates ``ceil(population * n_parameters)`` chains. - resume_state : MCMCDraw | None, default=None - A BUMPS ``MCMCDraw`` state object from a previous - ``mcmc_sample()`` call (e.g. ``PosteriorResults.sampler_state``). - When provided, DREAM **continues** the saved chain instead of - starting cold. The population, parameter count, and parameter - names must match the current model — a ``ValueError`` is raised - otherwise. - - ``samples`` must be the **total** number of raw samples, not an - increment: to extend an existing chain of ``N`` raw samples by - ``M``, pass ``samples=N + M`` (DREAM keeps only the last - ``samples`` draws in its buffer). The `Sampler.extend` helper - computes this for you. - - ``burn`` is forced to 0 on resume: a previously-converged chain is - never re-burned. - - The ``population`` and ``initializer`` parameters - have **no effect** when ``resume_state`` is provided — they - are determined by the saved state. - - Resuming against *different* data is undefined behaviour (the - chain's likelihood changes underneath it). - sampler_kwargs : dict | None, default=None - Additional keyword arguments forwarded to - ``bumps.fitters.fit``. - progress_callback : Callable[[dict], None] | None, default=None - Optional callback for progress updates during sampling. The - payload dict includes ``iteration`` (DREAM generation - number) and ``sampling: True``. The return value is ignored. - abort_test : Callable[[], bool] | None, default=None - Optional callback that returns ``True`` to signal that - sampling should be aborted. Called periodically during the - DREAM sampling loop. - - Returns - ------- - dict - Dictionary with keys ``'draws'``, ``'param_names'``, - ``'internal_bumps_object'``, and ``'logp'``. - - Raises - ------ - ValueError - If the input shapes or weights are invalid, if - ``progress_callback`` is not callable, or if ``resume_state`` - is incompatible with the current model (parameter count, - names/order, or population mismatch). - FitError - If DREAM sampling was aborted by the user (via - ``abort_test``). - Exception - Re-raised from DREAM fitting if any unexpected error occurs - (parameter values are restored beforehand). - """ # noqa: DOC502 -- raised in the delegated DreamSampler.run() - warnings.warn( - 'Bumps.mcmc_sample() is deprecated. Use easyscience.fitting.Sampler ' - '(which no longer requires a BUMPS minimizer) instead.', - DeprecationWarning, - stacklevel=2, - ) - from ..samplers.sampler_dream import DreamSampler - - engine = DreamSampler(self._object, self._original_fit_function) - return engine.run( - x=x, - y=y, - weights=weights, - samples=samples, - burn=burn, - thin=thin, - population=population, - resume_state=resume_state, - sampler_kwargs=sampler_kwargs, - progress_callback=progress_callback, - abort_test=abort_test, - ) - def _set_parameter_fit_result( self, fit_result: Any, diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 96414e27..6e3afda3 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -249,16 +249,6 @@ class SamplingResults: logp: np.ndarray state: MCMCDraw - def to_legacy_dict(self) -> dict: - """Return the legacy dict shape produced by the deprecated - ``mcmc_sample()`` APIs.""" - return { - 'draws': self.draws, - 'param_names': self.param_names, - 'internal_bumps_object': self.state, - 'logp': self.logp, - } - class Sampler: """Bayesian MCMC sampler for one dataset, backed by the BUMPS DREAM engine. diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index 3e244dfc..45b97af5 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -85,7 +85,7 @@ class TestSampler: @pytest.mark.filterwarnings('ignore::UserWarning') def test_sample_returns_results_object(self): - """sample() returns a populated SamplingResults; to_legacy_dict() has the legacy shape.""" + """sample() returns a populated SamplingResults, cached on the sampler.""" f, sp, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights]) @@ -107,11 +107,6 @@ def test_sample_returns_results_object(self): assert sampler.draws is results.draws assert sampler.param_names == results.param_names - # legacy dict shape - legacy = results.to_legacy_dict() - assert set(legacy.keys()) == {'draws', 'param_names', 'internal_bumps_object', 'logp'} - assert legacy['internal_bumps_object'] is results.state - @pytest.mark.filterwarnings('ignore::UserWarning') def test_sample_multi_dataset(self): """Multi-dataset sampling via Sampler(f, ...) has correct param_names.""" @@ -414,26 +409,3 @@ def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog): with caplog.at_level(logging.WARNING, logger='easyscience.fitting'): sampler2.load_state(prefix) assert 'does not match the data fingerprint' in caplog.text - - -class TestDeprecatedMcmcSampleShim: - """The released ``Fitter.mcmc_sample`` entry point keeps working as a - deprecation shim delegating to ``Sampler`` (the ``Bumps.mcmc_sample`` - delegate is unit-tested in ``test_minimizer_bumps.py``).""" - - @pytest.mark.filterwarnings('ignore::UserWarning') - def test_fitter_mcmc_sample_warns_and_returns_legacy_dict(self): - f, sp, x, y, weights = _fitter_and_data() - - with pytest.warns(DeprecationWarning, match='Fitter.mcmc_sample'): - legacy = f.mcmc_sample([x], [y], [weights], samples=100, burn=20, thin=2) - - assert set(legacy.keys()) == {'draws', 'param_names', 'internal_bumps_object', 'logp'} - assert legacy['draws'].ndim == 2 - assert legacy['draws'].shape[0] > 0 - assert legacy['draws'].shape[1] == len(legacy['param_names']) - expected_pars = {p.unique_name for p in sp.get_fit_parameters()} - assert set(legacy['param_names']) == expected_pars - # The shim no longer requires the BUMPS minimizer — the default - # LMFit minimizer stays active throughout. - assert f.minimizer.package == 'lmfit' diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index f7ef0517..beb6301c 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -950,69 +950,6 @@ def test_successful_fit_reports_success(self, minimizer: Bumps, monkeypatch) -> assert passed.nit == 7 -# =================================================================== -# Bumps.mcmc_sample() — deprecated delegate to DreamSampler -# =================================================================== - - -class TestBumpsMcmcSampleDeprecated: - """``Bumps.mcmc_sample`` is a thin deprecated delegate; the DREAM run - itself is unit-tested in ``tests/unit/fitting/samplers/test_sampler_dream.py``.""" - - @pytest.fixture - def minimizer(self) -> Bumps: - return Bumps( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='bumps', method='amoeba'), - ) - - def test_warns_and_delegates_to_dream_sampler(self, minimizer: Bumps, monkeypatch) -> None: - import easyscience.fitting.samplers.sampler_dream as sampler_dream_module - - canned = { - 'draws': np.ones((2, 1)), - 'param_names': ['a'], - 'internal_bumps_object': object(), - 'logp': np.zeros(2), - } - mock_engine = MagicMock() - mock_engine.run.return_value = canned - mock_engine_cls = MagicMock(return_value=mock_engine) - monkeypatch.setattr(sampler_dream_module, 'DreamSampler', mock_engine_cls) - - x = np.array([1.0, 2.0]) - y = np.array([0.1, 0.2]) - weights = np.array([1.0, 1.0]) - abort_test = MagicMock(return_value=False) - - with pytest.warns(DeprecationWarning, match='Bumps.mcmc_sample'): - result = minimizer.mcmc_sample( - x=x, - y=y, - weights=weights, - samples=100, - burn=20, - thin=2, - population=5, - sampler_kwargs={'trim': False}, - abort_test=abort_test, - ) - - # The engine is bound to the minimizer's object and original fit function - mock_engine_cls.assert_called_once_with('obj', 'fit_function') - run_kwargs = mock_engine.run.call_args.kwargs - assert run_kwargs['samples'] == 100 - assert run_kwargs['burn'] == 20 - assert run_kwargs['thin'] == 2 - assert run_kwargs['population'] == 5 - assert run_kwargs['resume_state'] is None - assert run_kwargs['sampler_kwargs'] == {'trim': False} - assert run_kwargs['abort_test'] is abort_test - # The legacy dict comes straight back from the engine - assert result is canned - - # =================================================================== # _set_parameter_fit_result with stack_status=True # =================================================================== diff --git a/tests/unit/fitting/samplers/test_sampler_dream.py b/tests/unit/fitting/samplers/test_sampler_dream.py index 7683d10d..732f5cab 100644 --- a/tests/unit/fitting/samplers/test_sampler_dream.py +++ b/tests/unit/fitting/samplers/test_sampler_dream.py @@ -4,8 +4,10 @@ ``src/easyscience/fitting/samplers/sampler_dream.py``. Ported from the former ``TestBumpsSample`` suite in -``tests/unit/fitting/minimizers/test_minimizer_bumps.py`` when +``tests/unit/fitting/minimizers/test_minimizer_bumps.py`` when the former ``Bumps.mcmc_sample`` moved here as ``DreamSampler.run`` (easyscience/core#280). +That entry point, and ``Fitter.mcmc_sample``, have since been removed — +``Sampler`` and ``DreamSampler.run`` are the supported APIs. """ import logging diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 7f685780..40f85ffc 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -401,19 +401,6 @@ def test_fingerprint_without_weights(self): assert isinstance(sampler._fingerprint(), str) -class TestSamplingResultsLegacyDict: - def test_to_legacy_dict_maps_fields(self): - state = object() - results = SamplingResults( - draws=np.ones((2, 1)), param_names=['p'], logp=np.zeros(2), state=state - ) - legacy = results.to_legacy_dict() - assert legacy['internal_bumps_object'] is state - assert legacy['param_names'] == ['p'] - np.testing.assert_array_equal(legacy['draws'], results.draws) - np.testing.assert_array_equal(legacy['logp'], results.logp) - - class TestSamplerRunEngine: """The ``_run`` tail: results construction, storage, and kwarg merging, with the ``DreamSampler`` engine stubbed out.""" From 3e598107beb2de6820e0e4c0a68da8e3c9af4ae4 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 10 Aug 2026 10:21:33 +0200 Subject: [PATCH 05/25] minor CR comments addressed --- src/easyscience/fitting/fitter.py | 2 +- .../minimizers/bumps_utils/validation.py | 9 ++++-- src/easyscience/fitting/multi_fitter.py | 17 ++++++++--- src/easyscience/fitting/sampler.py | 16 ++++++----- .../fitting/samplers/sampler_dream.py | 6 ++-- tests/integration/fitting/test_sampler.py | 28 +++++++++++++++++++ .../minimizers/bumps_utils/test_validation.py | 5 ++++ tests/unit/fitting/test_multi_fitter.py | 27 ++++++++++++++++++ 8 files changed, 92 insertions(+), 18 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index 70dcfed0..e8e0d4d8 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -283,7 +283,7 @@ def inner_fit_callable( y: np.ndarray, weights: Optional[np.ndarray] = None, vectorized: bool = False, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, **kwargs, ) -> FitResults: """ diff --git a/src/easyscience/fitting/minimizers/bumps_utils/validation.py b/src/easyscience/fitting/minimizers/bumps_utils/validation.py index 54d3e466..cb254a3b 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/validation.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/validation.py @@ -24,11 +24,14 @@ def validate_run_settings(samples: int, burn: int, thin: int) -> None: ValueError If any value is out of range or not an integer. """ - if not isinstance(samples, int) or samples <= 0: + # bool is a subclass of int, so ``samples=True`` would otherwise pass as + # ``samples=1``; these checks are strict (``10.0`` is rejected), so + # booleans must be rejected too. + if not isinstance(samples, int) or isinstance(samples, bool) or samples <= 0: raise ValueError('samples must be a positive integer.') - if not isinstance(burn, int) or burn < 0: + if not isinstance(burn, int) or isinstance(burn, bool) or burn < 0: raise ValueError('burn must be a non-negative integer.') - if not isinstance(thin, int) or thin < 1: + if not isinstance(thin, int) or isinstance(thin, bool) or thin < 1: raise ValueError('thin must be a positive integer.') diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index e2d88943..568283c6 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -56,11 +56,20 @@ def _fit_function_wrapper( Callable Wrapped optimizer function. """ - # Extract of a list of callable functions + # Extract of a list of callable functions. + # ``Fitter._fit_function_wrapper`` reads ``self._fit_function``, so it + # is repointed per dataset inside the loop; the original must be + # restored afterwards or every caller (``Fitter.fit`` aside, which + # snapshots it itself, e.g. sampling) is left with the *last* + # dataset's function on the user-visible ``fit_function`` surface. 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)) + original_fit_function = self._fit_function + try: + 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)) + finally: + self._fit_function = original_fit_function def wrapped_fun(x, **kwargs): # Generate an empty Y based on x diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 6e3afda3..b169c2ad 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -468,7 +468,7 @@ def _run( population: int | None, resume_state: MCMCDraw | None, sampler_kwargs: dict | None, - progress_callback: Callable[[dict], bool | None] | None, + progress_callback: Callable[[dict], None] | None, abort_test: Callable[[], bool] | None, ) -> SamplingResults: """Shared sampling engine for ``sample()`` and ``extend()``. @@ -535,7 +535,7 @@ def sample( thin: int = 10, population: int | None = None, sampler_kwargs: dict | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: """Run fresh Bayesian MCMC sampling on the bound data. @@ -564,9 +564,10 @@ def sample( sampler_kwargs : dict | None, default=None Additional keyword arguments forwarded to the BUMPS DREAM sampler (merged over the instance defaults). - progress_callback : Callable[[dict], bool | None] | None, default=None + progress_callback : Callable[[dict], None] | None, default=None Optional callback invoked at each DREAM generation. The payload - dict includes ``iteration`` and ``sampling: True``. + dict includes ``iteration`` and ``sampling: True``. Any return + value is ignored. abort_test : Callable[[], bool] | None, default=None Optional callable that returns ``True`` to abort sampling early. @@ -608,7 +609,7 @@ def extend( thin: int = 10, total_samples: int | None = None, sampler_kwargs: dict | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: """Continue the existing chain with additional samples. @@ -639,8 +640,9 @@ def extend( sampler_kwargs : dict | None, default=None Additional keyword arguments forwarded to the BUMPS DREAM sampler (merged over the instance defaults). - progress_callback : Callable[[dict], bool | None] | None, default=None - Optional callback invoked at each DREAM generation. + progress_callback : Callable[[dict], None] | None, default=None + Optional callback invoked at each DREAM generation. Any return + value is ignored. abort_test : Callable[[], bool] | None, default=None Optional callable that returns ``True`` to abort sampling early. diff --git a/src/easyscience/fitting/samplers/sampler_dream.py b/src/easyscience/fitting/samplers/sampler_dream.py index 67dcd4a2..b985ad9f 100644 --- a/src/easyscience/fitting/samplers/sampler_dream.py +++ b/src/easyscience/fitting/samplers/sampler_dream.py @@ -77,7 +77,7 @@ def run( population: int | None = None, resume_state: MCMCDraw | None = None, sampler_kwargs: dict | None = None, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> dict: """ @@ -137,10 +137,10 @@ def run( sampler_kwargs : dict | None, default=None Additional keyword arguments forwarded to ``bumps.fitters.fit``. - progress_callback : Callable[[dict], bool | None] | None, default=None + progress_callback : Callable[[dict], None] | None, default=None Optional callback for progress updates during sampling. The payload dict includes ``iteration`` (DREAM generation - number) and ``sampling: True``. + number) and ``sampling: True``. Any return value is ignored. abort_test : Callable[[], bool] | None, default=None Optional callback that returns ``True`` to signal that sampling should be aborted. Called periodically during the diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index 45b97af5..a0a0a217 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -182,6 +182,34 @@ def test_fit_function_restored_on_success(self): sampler.sample(samples=100, burn=20, thin=2) assert f.fit_function is original_func + @pytest.mark.filterwarnings('ignore::UserWarning') + def test_fit_function_untouched_multi_dataset(self): + """With 2+ datasets the per-dataset wrapping in MultiFitter must not + leave fit_function pointing at the LAST dataset's function after + sampling (regression: the single-dataset variant above is vacuous for + this bug because last == first == original).""" + ref_sin = AbsSin(0.2, np.pi) + sp_sin = AbsSin(0.354, 3.05) + sp_line = Line(0.43, 6.1) + sp_sin.offset.fixed = False + sp_line.c.fixed = False + + x1 = np.linspace(0, 5, 50) + y1 = ref_sin(x1) + x2 = np.copy(x1) + y2 = Line(1, 4.6)(x2) + weights = np.ones_like(x1) + + pytest.importorskip('bumps') + f = MultiFitter([sp_sin, sp_line], [sp_sin, sp_line]) + original = f.fit_function + assert original is sp_sin # two distinct per-dataset functions + + sampler = Sampler(f, [x1, x2], [y1, y2], [weights, weights]) + sampler.sample(samples=50, burn=5, thin=1) + + assert f.fit_function is original + @pytest.mark.filterwarnings('ignore::UserWarning') def test_sampler_kwargs_forwarded(self): """Per-call sampler_kwargs dict is forwarded to the BUMPS DREAM sampler.""" diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py index 805d4b5d..c69d86f8 100644 --- a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py +++ b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py @@ -16,10 +16,15 @@ class TestValidateRunSettings: ({'samples': 0}, 'samples must be a positive integer'), ({'samples': -1}, 'samples must be a positive integer'), ({'samples': 10.0}, 'samples must be a positive integer'), + # bool is an int subclass; True must not sneak through as 1. + ({'samples': True}, 'samples must be a positive integer'), ({'burn': -1}, 'burn must be a non-negative integer'), ({'burn': 1.5}, 'burn must be a non-negative integer'), + ({'burn': True}, 'burn must be a non-negative integer'), + ({'burn': False}, 'burn must be a non-negative integer'), ({'thin': 0}, 'thin must be a positive integer'), ({'thin': 2.0}, 'thin must be a positive integer'), + ({'thin': True}, 'thin must be a positive integer'), ], ) def test_invalid_settings_raise(self, kwargs, match): diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index ac897653..f47dfcb7 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -142,6 +142,33 @@ def test_handles_single_dataset(self): assert np.allclose(results[0].y_calc, [1.1, 2.1, 3.1]) +# =================================================================== +# MultiFitter._fit_function_wrapper +# =================================================================== + + +class TestFitFunctionWrapper: + def test_fit_function_restored_with_multiple_datasets(self): + """Wrapping must not leave ``fit_function`` pointing at the last + dataset's function (regression: sampling on a 2+ dataset MultiFitter + silently swapped the user-visible fit function).""" + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + mf = MultiFitter(fit_objects, fit_objects) + original = mf.fit_function + assert original is fit_objects[0] + + x = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0])] + mf._dependent_dims = [(3,), (2,)] + wrapped = mf._fit_function_wrapper(x, flatten=True) + + assert mf.fit_function is original + + # Each wrapped section still evaluates its own dataset's function. + y = wrapped(np.zeros(5)) + expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])]) + assert np.allclose(y, expected) + + # =================================================================== # MultiFitter._precompute_reshaping with weights=None # =================================================================== From 59d0eb89b18a6d7660259a0ff7adb8e3c1505101 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 1 Sep 2026 12:38:28 +0200 Subject: [PATCH 06/25] minor text fixes and simplifications --- src/easyscience/fitting/engine_base.py | 14 +++--- .../fitting/minimizers/bumps_utils/problem.py | 11 ++--- .../minimizers/bumps_utils/validation.py | 6 +-- .../fitting/minimizers/minimizer_bumps.py | 20 +++----- .../fitting/samplers/sampler_dream.py | 49 ++++++++----------- .../fitting/samplers/test_sampler_dream.py | 10 +++- 6 files changed, 49 insertions(+), 61 deletions(-) diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py index 08190ea8..987899da 100644 --- a/src/easyscience/fitting/engine_base.py +++ b/src/easyscience/fitting/engine_base.py @@ -11,8 +11,6 @@ import numpy as np -# causes circular import when Parameter is imported -# from easyscience.base_classes import ObjBase from easyscience.variable import Parameter PARAMETER_PREFIX = 'p' @@ -20,14 +18,14 @@ class EngineBase(metaclass=ABCMeta): """ - Base for all evaluation engines — minimizers and samplers alike. + Base for all evaluation engines: minimizers and samplers. An engine binds an EasyScience object and a fit function, and repeatedly evaluates the function while writing values back into the - object's ``Parameter`` instances. ``EngineBase`` owns that shared - machinery: the parameter cache, the ``Parameter``-writing wrapped + object's ``Parameter`` instances. ``EngineBase`` owns this shared + system: the parameter cache, the ``Parameter``-writing wrapped fit function, and value restore on failure. It deliberately declares - no abstract methods: the run interfaces live on its subclasses + no abstract methods: the interfaces are defined on its subclasses (``MinimizerBase.fit``, ``DreamSampler.run``). """ @@ -35,9 +33,9 @@ class EngineBase(metaclass=ABCMeta): def __init__( self, - obj, #: ObjBase, + obj, fit_function: Callable, - ): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501 + ): self._object = obj self._original_fit_function = fit_function self._cached_pars: Dict[str, Parameter] = {} diff --git a/src/easyscience/fitting/minimizers/bumps_utils/problem.py b/src/easyscience/fitting/minimizers/bumps_utils/problem.py index b5e25d42..17f41254 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/problem.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/problem.py @@ -4,8 +4,8 @@ ``DreamSampler``. These are free functions rather than ``Bumps`` methods so that any -:class:`~easyscience.fitting.engine_base.EngineBase` — a minimizer or a -sampler — can build a BUMPS ``Curve``/``FitProblem`` without inheriting +:class:`~easyscience.fitting.engine_base.EngineBase`: a minimizer or a +sampler, can build a BUMPS ``Curve``/``FitProblem`` without inheriting from the minimizer. """ @@ -83,10 +83,9 @@ def build_curve_problem( ------- tuple[FitProblem, EvalCounter, Curve] The assembled problem, the evaluation counter wrapping the fit - function (exposes ``count`` for evaluation bookkeeping), and the - ``Curve`` model itself. The ``Curve`` is surfaced directly - because ``FitProblem.fitness`` is deprecated in BUMPS (>= 1.0.4 - it emits a ``UserWarning``) — callers must not go through it. + function, and the ``Curve`` model itself. + The ``Curve`` is returned because ``FitProblem.fitness`` is + deprecated in current BUMPS. """ fit_func = EvalCounter(engine._generate_fit_function()) diff --git a/src/easyscience/fitting/minimizers/bumps_utils/validation.py b/src/easyscience/fitting/minimizers/bumps_utils/validation.py index cb254a3b..f3e5d349 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/validation.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/validation.py @@ -45,9 +45,9 @@ def validate_arrays( """Validate the (x, y, weights) arrays for a BUMPS problem. Checks shape agreement between the three arrays, finiteness and - strict positivity of the weights, and — when ``check_finite_xy`` is - ``True`` — finiteness of x and y. Sampling passes ``True``; the - classical fit path passes ``False`` to keep its historically more + strict positivity of the weights, and (when ``check_finite_xy`` is + ``True``) finiteness of x and y. Sampling passes ``True``; the + standard fit path passes ``False`` to keep its historically more permissive behaviour. Parameters diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 8038c2d4..65e552ac 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -37,8 +37,6 @@ from bumps.fitters import FitBase # 'pt' (parallel tempering) is considered experimental and is not exposed. -# Filtered with a comprehension rather than ``list.remove()`` so that importing -# this module does not raise if a future BUMPS release drops the id. FIT_AVAILABLE_IDS_FILTERED = [fit_id for fit_id in FIT_AVAILABLE_IDS if fit_id != 'pt'] @@ -130,9 +128,9 @@ def fit( by the selected BUMPS fitter (``fitclass.settings``) is used. By default, None. progress_callback : Callable[[dict], None] | None, default=None - Optional callback for progress updates. The payload field + Optional callback for progress updates. The field ``iteration`` carries the BUMPS optimizer step index. The - return value is ignored — use ``abort_test`` to stop a + return value is ignored: use ``abort_test`` to stop a running fit. By default, None. abort_test : Callable[[], bool] | None, default=None Optional callback that returns ``True`` to signal that the @@ -155,7 +153,7 @@ def fit( FitResults Fit results. ``FitResults.iterations`` is the number of BUMPS *optimizer steps* consumed (the last reported step index plus - one), which is what ``max_evaluations`` budgets against; it is + one), which is what ``max_evaluations`` tests against; it is not comparable to LMFit's ``nfev`` or DFO-LS' ``nf``. The objective-call count is reported separately as ``FitResults.n_evaluations``, which is the cross-backend @@ -198,15 +196,10 @@ def fit( # the results would carry the previous fit's objective-call count. self._eval_counter = None - # Resolve BUMPS-native defaults so the budget reported back to the caller (and - # used by the budget-exhaustion check in `_gen_fit_results`) reflects the values - # actually consumed by the fitter, even when the caller passes None. - # # Only values the caller supplied explicitly are pushed back into # `minimizer_kwargs`. BUMPS pairs an independent `ftol`/`xtol` default per # fitter (`newton` combines ftol=1e-6 with xtol=1e-12, `amoeba` ftol=1e-8 with - # xtol=1e-6), so collapsing them onto a single resolved value would silently - # tighten the fitter's own convergence criteria on the default path. + # xtol=1e-6). fitter_settings = dict(fitclass.settings) if max_evaluations is not None: @@ -229,8 +222,7 @@ def fit( tolerance = min(tols) if tols else None if model is None: - # The Curve comes back directly from the helper: do NOT read it - # from ``problem.fitness``, which is deprecated in BUMPS and warns. + # The Curve comes back directly from the helper. problem, self._eval_counter, model = build_curve_problem( self, x, y, weights, parameters=parameters ) @@ -440,7 +432,7 @@ def _set_parameter_fit_result( Whether the undo stack was enabled. par_names : list[str] Cached-parameter names in BUMPS problem order, already - stripped of ``MINIMIZER_PARAMETER_PREFIX`` — see + stripped of ``MINIMIZER_PARAMETER_PREFIX``. As seen in :func:`~easyscience.fitting.minimizers.bumps_utils.parameter_names`. """ from easyscience import global_object diff --git a/src/easyscience/fitting/samplers/sampler_dream.py b/src/easyscience/fitting/samplers/sampler_dream.py index b985ad9f..dedf5e39 100644 --- a/src/easyscience/fitting/samplers/sampler_dream.py +++ b/src/easyscience/fitting/samplers/sampler_dream.py @@ -1,17 +1,12 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""The BUMPS DREAM MCMC engine — ``DreamSampler``. - -One file per sampling backend, mirroring the one-file-per-minimizer -layout under ``fitting/minimizers/``. When a second MCMC backend -arrives, its ``run()`` signature is formalized as a ``SamplerBase`` ABC -and dispatched via a factory (see discussion easyscience/core#280). -""" +"""The BUMPS DREAM MCMC engine — ``DreamSampler``.""" from __future__ import annotations import copy import math +from functools import partial from typing import TYPE_CHECKING from typing import Callable @@ -38,12 +33,12 @@ class DreamSampler(EngineBase): BUMPS DREAM MCMC engine. Runs and resumes chains for one ``(obj, fit_function)`` binding. - This is the minimizer-independent home of Bayesian sampling: it + This is the minimizer-independent location of Bayesian sampling: it builds its own BUMPS ``FitProblem`` via the shared ``bumps_utils`` helpers, so sampling no longer requires the ``Fitter``'s active - minimizer to be BUMPS — only an installed ``bumps`` package. + minimizer to be BUMPS, only an installed ``bumps`` package. - ``DreamSampler`` is internal machinery; the public entry point is + ``DreamSampler`` is internal. The public entry point is :class:`easyscience.fitting.Sampler`. """ @@ -96,7 +91,7 @@ def run( y : np.ndarray Flattened dependent variable array. weights : np.ndarray | None - Flattened weight array. Must not be ``None`` — sampling has + Flattened weight array. Must not be ``None``: sampling has no default weighting; a clear ``ValueError`` is raised. samples : int, default=10000 Number of raw samples to draw across all chains, before thinning. @@ -108,7 +103,7 @@ def run( generations while ``samples`` counts raw draws, so ``burn=500`` discards ``500 * n_chains`` raw samples. thin : int, default=10 - Thinning interval — only every ``thin``-th generation is stored. + Thinning interval: only every ``thin``-th generation is stored. population : int | None, default=None BUMPS DREAM population count per parameter (number of parallel chains): BUMPS creates ``ceil(population * n_parameters)`` chains. @@ -116,8 +111,8 @@ def run( A BUMPS ``MCMCDraw`` state object from a previous ``run()`` call. When provided, DREAM **continues** the saved chain instead of starting cold. The population, parameter count, - and parameter names must match the current model — a - ``ValueError`` is raised otherwise. + and parameter names must match the current model. Otherwise, a + ``ValueError`` is raised. ``samples`` must be the **total** number of raw samples, not an increment: to extend an existing chain of ``N`` raw samples by @@ -129,7 +124,7 @@ def run( never re-burned. The ``population`` and ``initializer`` parameters - have **no effect** when ``resume_state`` is provided — they + have **no effect** when ``resume_state`` is provided. They are determined by the saved state. Resuming against *different* data is undefined behaviour (the @@ -186,7 +181,7 @@ def run( pop, burn = self._validate_resume_state(problem, resume_state, population, burn) # Build DREAM kwargs. Use the resolved ``pop``, not the raw - # ``population`` argument — on resume ``pop`` is the negative + # ``population`` argument. On resume ``pop`` is the negative # absolute chain count that reproduces the saved state's # population, which BUMPS requires to match. dream_kwargs: dict = {'samples': samples, 'burn': burn, 'thin': thin} @@ -210,10 +205,7 @@ def run( BumpsProgressMonitor( problem, progress_callback, - lambda problem, iteration, point, nllf: { - **self._build_sample_progress_payload(problem, iteration, point, nllf), - 'total_steps': _total_steps, - }, + partial(self._build_sample_progress_payload, total_steps=_total_steps), ) ) @@ -235,11 +227,10 @@ def run( fit_kwargs = {} if resume_state is not None: # Defensive copy: BUMPS mutates the state object in-place - # (via MCMCDraw.resize() — see bumps/dream/core.py allocate_state) + # (via MCMCDraw.resize(): see bumps/dream/core.py allocate_state) # during resume. Without a copy, the caller's original state # object is silently altered, making it impossible to compare - # pre- and post-resume state (shape mismatch). See - # https://github.com/easyscience/core/pull/257 + # pre- and post-resume state (shape mismatch). fit_kwargs['fit_state'] = copy.deepcopy(resume_state) x_opt, fx = driver.fit(**fit_kwargs) result_state = getattr(driver.fitter, 'state', None) @@ -354,16 +345,17 @@ def _validate_resume_state( return -int(resume_state.Npop), 0 def _build_sample_progress_payload( - self, problem, iteration: int, point: np.ndarray, nllf: float + self, problem, iteration: int, point: np.ndarray, nllf: float, total_steps: int ) -> dict: """ Build a progress payload for Bayesian DREAM sampling steps. Called by :class:`BumpsProgressMonitor` at each DREAM - generation. The payload includes ``sampling: True`` so - downstream consumers can distinguish sampling progress from - classical fitting progress; the remaining keys match the - classical-fit payload built by the minimizers. + generation, with ``total_steps`` bound up front by the caller. + The payload includes ``sampling: True`` so downstream consumers + can distinguish sampling progress from classical fitting + progress; the remaining keys match the classical-fit payload + built by the minimizers. """ # Use the nllf already computed by the sampler to avoid a costly # model re-evaluation, and let BUMPS apply its own chisq scaling. @@ -378,4 +370,5 @@ def _build_sample_progress_payload( 'refresh_plots': False, 'finished': False, 'sampling': True, + 'total_steps': total_steps, } diff --git a/tests/unit/fitting/samplers/test_sampler_dream.py b/tests/unit/fitting/samplers/test_sampler_dream.py index 732f5cab..5c2d200e 100644 --- a/tests/unit/fitting/samplers/test_sampler_dream.py +++ b/tests/unit/fitting/samplers/test_sampler_dream.py @@ -447,9 +447,12 @@ def test_payload_structure_and_sampling_flag(self, engine: DreamSampler) -> None mock_problem.labels.return_value = ['palpha'] mock_problem.getp.return_value = np.array([1.0]) - payload = engine._build_sample_progress_payload(mock_problem, 7, np.array([1.0]), 12.5) + payload = engine._build_sample_progress_payload( + mock_problem, 7, np.array([1.0]), 12.5, 100 + ) assert payload['iteration'] == 7 + assert payload['total_steps'] == 100 assert payload['chi2'] == 25.0 assert payload['reduced_chi2'] == 12.5 assert payload['parameter_values'] == {'alpha': 1.0} @@ -469,7 +472,9 @@ def test_payload_keys(self, engine: DreamSampler) -> None: mock_problem.labels.return_value = ['pa'] mock_problem.getp.return_value = np.array([5.0]) - payload = engine._build_sample_progress_payload(mock_problem, 1, np.array([5.0]), nllf=5.0) + payload = engine._build_sample_progress_payload( + mock_problem, 1, np.array([5.0]), nllf=5.0, total_steps=50 + ) expected_keys = { 'iteration', @@ -479,5 +484,6 @@ def test_payload_keys(self, engine: DreamSampler) -> None: 'refresh_plots', 'finished', 'sampling', + 'total_steps', } assert set(payload.keys()) == expected_keys From 7519f4f567a8f2a6939164ced8487eb888fc4f8f Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 2 Sep 2026 14:48:10 +0200 Subject: [PATCH 07/25] fixed two minor issues in the LMFit wrapper. #301 and #302 --- .../fitting/minimizers/minimizer_lmfit.py | 14 +++++++++++--- tests/integration/fitting/test_fitter.py | 5 +++-- .../fitting/minimizers/test_minimizer_lmfit.py | 4 ++-- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 30664708..1c98ed1b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -242,10 +242,14 @@ def _get_fit_kws( ) -> dict[str:str]: if minimizer_kwargs is None: minimizer_kwargs = {} + # `method` is usually None, because `Fitter.fit` + # does not pass one; the minimizer's own method is what actually runs, + # so it decides which tolerance keyword the backend accepts. + effective_method = method if method is not None else self._method if tolerance is not None: - if method in [None, 'least_squares', 'leastsq']: + if effective_method in [None, 'least_squares', 'leastsq']: minimizer_kwargs['ftol'] = tolerance - if method in ['differential_evolution', 'powell', 'cobyla']: + if effective_method in ['differential_evolution', 'powell', 'cobyla']: minimizer_kwargs['tol'] = tolerance return minimizer_kwargs @@ -371,7 +375,11 @@ def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool) if fit_result.errorbars: pars[name].error = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].stderr else: - pars[name].error = 0.0 + # No covariance available (gradient-free method, aborted fit, or a + # parameter at a bound). None keeps that distinguishable from a + # genuine zero uncertainty and clears any stale error from a + # previous fit. + pars[name].error = None if stack_status: global_object.stack.endMacro() diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index 5224da73..0119f9ef 100644 --- a/tests/integration/fitting/test_fitter.py +++ b/tests/integration/fitting/test_fitter.py @@ -91,8 +91,9 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): 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=2.1e-1) + # Methods without error bars (e.g. lmfit's gradient-free powell/cobyla) + # report error as None rather than a fake 0.0. + assert item1.error is None or item1.error == pytest.approx(0, abs=2.1e-1) assert item1.value == pytest.approx(item2.value, abs=5e-3) y_calc_ref = ref_sin(x) assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) diff --git a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py index 781893d9..06bb2cf7 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py +++ b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py @@ -517,9 +517,9 @@ def test_set_parameter_fit_result_no_stack_status_no_error(self, minimizer: LMFi # Expect assert minimizer._cached_pars['a'].value == 1.0 - assert minimizer._cached_pars['a'].error == 0.0 + assert minimizer._cached_pars['a'].error is None assert minimizer._cached_pars['b'].value == 2.0 - assert minimizer._cached_pars['b'].error == 0.0 + assert minimizer._cached_pars['b'].error is None def test_gen_fit_results(self, minimizer: LMFit, monkeypatch) -> None: # When From d364c7ee78b0cc3facfce379a84fb495c23c6672 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Wed, 2 Sep 2026 15:35:29 +0200 Subject: [PATCH 08/25] more unit tests so Codecov doesn't complain --- .../minimizers/test_minimizer_lmfit.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py index 06bb2cf7..0141fc56 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py +++ b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py @@ -212,6 +212,48 @@ def test_fit_kwargs(self, minimizer: LMFit) -> None: ) assert callable(mock_model.fit.call_args.kwargs['iter_cb']) + @pytest.mark.parametrize( + 'minimizer_method, passed_method, expected', + [ + ('leastsq', None, {'ftol': 0.1}), + ('least_squares', None, {'ftol': 0.1}), + ('powell', None, {'tol': 0.1}), + ('differential_evolution', None, {'tol': 0.1}), + ('cobyla', None, {'tol': 0.1}), + ('leastsq', 'powell', {'tol': 0.1}), + ('powell', 'leastsq', {'ftol': 0.1}), + ('nelder', None, {}), + ], + ids=[ + 'leastsq', + 'least_squares', + 'powell', + 'differential_evolution', + 'cobyla', + 'explicit_method_overrides', + 'explicit_leastsq_overrides', + 'unmapped_method', + ], + ) + def test_get_fit_kws_tolerance( + self, minimizer: LMFit, minimizer_method, passed_method, expected + ) -> None: + # When + minimizer._method = minimizer_method + + # Then + fit_kws = minimizer._get_fit_kws(passed_method, 0.1, None) + + # Expect + assert fit_kws == expected + + def test_get_fit_kws_no_tolerance(self, minimizer: LMFit) -> None: + # When Then + fit_kws = minimizer._get_fit_kws(None, None, {'existing': 'kwarg'}) + + # Expect + assert fit_kws == {'existing': 'kwarg'} + def test_fit_progress_callback(self, minimizer: LMFit) -> None: # When progress_callback = MagicMock(return_value=True) From fff79989a3ed8e5dbbbc4ee31250636e2688c917 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 2 Sep 2026 19:37:17 +0200 Subject: [PATCH 09/25] CR comments addressed --- .../fitting/minimizers/minimizer_lmfit.py | 2 +- tests/integration/fitting/test_fitter.py | 23 ++++++++++++++----- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 1c98ed1b..b3dd9cbb 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -247,7 +247,7 @@ def _get_fit_kws( # so it decides which tolerance keyword the backend accepts. effective_method = method if method is not None else self._method if tolerance is not None: - if effective_method in [None, 'least_squares', 'leastsq']: + if effective_method in ['least_squares', 'leastsq']: minimizer_kwargs['ftol'] = tolerance if effective_method in ['differential_evolution', 'powell', 'cobyla']: minimizer_kwargs['tol'] = tolerance diff --git a/tests/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index 0119f9ef..1b4be9c4 100644 --- a/tests/integration/fitting/test_fitter.py +++ b/tests/integration/fitting/test_fitter.py @@ -78,7 +78,7 @@ def __call__(self, x: np.ndarray) -> np.ndarray: return self.slope.value * x + self.intercept.value -def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): +def check_fit_results(result, sp_sin, ref_sin, x, expect_error=True, **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_chi2 == pytest.approx(0, abs=1.5e-3) @@ -91,9 +91,13 @@ def check_fit_results(result, sp_sin, ref_sin, x, **kwargs): 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()): - # Methods without error bars (e.g. lmfit's gradient-free powell/cobyla) - # report error as None rather than a fake 0.0. - assert item1.error is None or item1.error == pytest.approx(0, abs=2.1e-1) + # Gradient-free methods (e.g. lmfit's powell/cobyla) have no covariance + # matrix, so they report error as None rather than a fake 0.0. Every + # other method must still produce a real uncertainty. + if expect_error: + assert item1.error == pytest.approx(0, abs=2.1e-1) + else: + assert item1.error is None assert item1.value == pytest.approx(item2.value, abs=5e-3) y_calc_ref = ref_sin(x) assert result.y_calc == pytest.approx(y_calc_ref, abs=1e-2) @@ -331,7 +335,9 @@ def test_lmfit_methods(fit_method): f = Fitter(sp_sin, sp_sin) assert fit_method in f._minimizer.supported_methods() result = f.fit(x, y, weights=weights, method=fit_method) - check_fit_results(result, sp_sin, ref_sin, x) + check_fit_results( + result, sp_sin, ref_sin, x, expect_error=fit_method not in ('powell', 'cobyla') + ) # @pytest.mark.xfail(reason="known bumps issue") @@ -410,7 +416,12 @@ def test_dependent_parameter(fit_engine): pytest.skip(reason=f'{fit_engine} is not installed') result = f.fit(x, y, weights=weights) - check_fit_results(result, sp_sin, ref_sin, x) + # With `offset` dependent on `phase` only one parameter varies against + # noiseless data, so lmfit's covariance is exactly [[0.]] and it reports + # `errorbars=False` -> no uncertainties. + check_fit_results( + result, sp_sin, ref_sin, x, expect_error=fit_engine is not AvailableMinimizers.LMFit + ) @pytest.mark.fast From fd8879b2990f27d7248854751bed2522e468d626 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 4 Sep 2026 16:24:25 +0200 Subject: [PATCH 10/25] Replaced CollectionBase with EasyList #304 --- src/easyscience/fitting/fitter.py | 3 + src/easyscience/fitting/multi_fitter.py | 9 +- .../integration/fitting/test_multi_fitter.py | 66 ++++++--- tests/integration/fitting/test_sampler.py | 66 ++++++--- tests/unit/fitting/test_multi_fitter.py | 137 +++++++++++++++++- tests/unit/fitting/test_sampler.py | 29 +++- 6 files changed, 247 insertions(+), 63 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index e8e0d4d8..3fd72c79 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -211,6 +211,9 @@ def fit_object(self) -> object: """ Get the EasyScience object used as a model. + For a ``MultiFitter`` this is not one of the supplied fit + objects but a read-only, indexable and iterable aggregate. + Returns ------- object diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index 568283c6..c2db4c25 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -5,7 +5,7 @@ import numpy as np -from ..base_classes import CollectionBase +from ..base_classes import EasyList from .fitter import Fitter from .minimizers import FitResults @@ -20,6 +20,7 @@ class MultiFitter(Fitter): The inherited ``fit`` wrapper from ``Fitter`` is used unchanged, including support for forwarding progress callbacks to the active minimizer. + """ def __init__( @@ -27,8 +28,10 @@ def __init__( fit_objects: list | None = None, fit_functions: list[Callable] | None = None, ): - # Create a dummy core object to hold all the fit objects. - self._fit_objects = CollectionBase('multi', *fit_objects) + # Aggregate the fit objects so a single object can be sent to Fitter. + # *-unpacking keeps any sequence (list, tuple, etc) working, as the + # old CollectionBase container did. + self._fit_objects = EasyList(*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. diff --git a/tests/integration/fitting/test_multi_fitter.py b/tests/integration/fitting/test_multi_fitter.py index 25de53d5..99dc60b4 100644 --- a/tests/integration/fitting/test_multi_fitter.py +++ b/tests/integration/fitting/test_multi_fitter.py @@ -4,47 +4,65 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter +from easyscience.base_classes import ModelBase from easyscience.fitting.minimizers import FitError from easyscience.fitting.multi_fitter import MultiFitter -class Line(ObjBase): - m: Parameter - c: Parameter - +class Line(ModelBase): 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) + super().__init__() + self._m = Parameter('m', m_val) + self._c = Parameter('c', c_val) + + @property + def m(self) -> Parameter: + return self._m + + @m.setter + def m(self, value: float) -> None: + self._m.value = value + + @property + def c(self) -> Parameter: + return self._c + + @c.setter + def c(self, value: float) -> None: + self._c.value = value def __call__(self, x): return self.m.value * x + self.c.value -class AbsSin(ObjBase): - phase: Parameter - offset: Parameter - +class AbsSin(ModelBase): 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) + super().__init__() + self._offset = Parameter('offset', offset_val) + self._phase = Parameter('phase', phase_val) - def __call__(self, x): - return np.abs(np.sin(self.phase.value * x + self.offset.value)) + @property + def offset(self) -> Parameter: + return self._offset + @offset.setter + def offset(self, value: float) -> None: + self._offset.value = value -class AbsSin2D(ObjBase): - phase: Parameter - offset: Parameter + @property + def phase(self) -> Parameter: + return self._phase + + @phase.setter + def phase(self, value: float) -> None: + self._phase.value = value + + def __call__(self, x): + return np.abs(np.sin(self.phase.value * x + self.offset.value)) - 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) +class AbsSin2D(AbsSin): def __call__(self, x): X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index a0a0a217..c5ae6937 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -12,48 +12,66 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter +from easyscience.base_classes import ModelBase from easyscience.fitting import Sampler from easyscience.fitting import SamplingResults from easyscience.fitting.multi_fitter import MultiFitter -class Line(ObjBase): - m: Parameter - c: Parameter - +class Line(ModelBase): 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) + super().__init__() + self._m = Parameter('m', m_val) + self._c = Parameter('c', c_val) + + @property + def m(self) -> Parameter: + return self._m + + @m.setter + def m(self, value: float) -> None: + self._m.value = value + + @property + def c(self) -> Parameter: + return self._c + + @c.setter + def c(self, value: float) -> None: + self._c.value = value def __call__(self, x): return self.m.value * x + self.c.value -class AbsSin(ObjBase): - phase: Parameter - offset: Parameter - +class AbsSin(ModelBase): 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) + super().__init__() + self._offset = Parameter('offset', offset_val) + self._phase = Parameter('phase', phase_val) - def __call__(self, x): - return np.abs(np.sin(self.phase.value * x + self.offset.value)) + @property + def offset(self) -> Parameter: + return self._offset + @offset.setter + def offset(self, value: float) -> None: + self._offset.value = value -class AbsSin2D(ObjBase): - phase: Parameter - offset: Parameter + @property + def phase(self) -> Parameter: + return self._phase + + @phase.setter + def phase(self, value: float) -> None: + self._phase.value = value + + def __call__(self, x): + return np.abs(np.sin(self.phase.value * x + self.offset.value)) - 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) +class AbsSin2D(AbsSin): def __call__(self, x): X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index f47dfcb7..97e51c1d 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +import logging from unittest.mock import MagicMock import numpy as np @@ -8,18 +9,47 @@ from easyscience import ObjBase from easyscience import Parameter +from easyscience import global_object +from easyscience.base_classes import EasyList +from easyscience.base_classes import ModelBase from easyscience.fitting.fitter import Fitter from easyscience.fitting.multi_fitter import MultiFitter -class Line(ObjBase): +class Line(ModelBase): + def __init__(self, m_val: float, c_val: float): + super().__init__() + self._m = Parameter('m', m_val) + self._c = Parameter('c', c_val) + + @property + def m(self) -> Parameter: + return self._m + + @m.setter + def m(self, value: float) -> None: + self._m.value = value + + @property + def c(self) -> Parameter: + return self._c + + @c.setter + def c(self, value: float) -> None: + self._c.value = value + + def __call__(self, x): + return self.m.value * x + self.c.value + + +class LegacyLine(ObjBase): + """Deprecated-hierarchy model; no longer accepted by MultiFitter.""" + m: Parameter c: Parameter def __init__(self, m_val: float, c_val: float): - m = Parameter('m', m_val) - c = Parameter('c', c_val) - super().__init__('line', m=m, c=c) + super().__init__('line', m=Parameter('m', m_val), c=Parameter('c', c_val)) def __call__(self, x): return self.m.value * x + self.c.value @@ -192,3 +222,102 @@ def test_weights_all_none_returns_none(self): assert w_new is None assert len(dims) == 2 + + +# =================================================================== +# The EasyList container replacing the deprecated CollectionBase +# =================================================================== + + +class TestFitObjectContainer: + def test_no_collection_base_deprecation_warning(self, caplog): + """Building a MultiFitter must not warn about CollectionBase. + + The assertion is on message content rather than on the logger, + because other deprecated classes warn on the very same logger. + """ + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + + with caplog.at_level(logging.WARNING, logger='easyscience'): + MultiFitter(fit_objects, fit_objects) + + assert not [r for r in caplog.records if 'CollectionBase is deprecated' in r.message] + + def test_container_is_an_easy_list(self): + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + + assert isinstance(MultiFitter(fit_objects, fit_objects).fit_object, EasyList) + + def test_fit_objects_are_not_retyped(self): + """The container must not reclassify the caller's fit objects. + + The old CollectionBase dummy re-typed every fit object as + 'created_internal', hiding the caller's own objects from the + map's 'created' set. + """ + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + types_before = [global_object.map.find_type(obj) for obj in fit_objects] + + MultiFitter(fit_objects, fit_objects) + + assert [global_object.map.find_type(obj) for obj in fit_objects] == types_before + assert all('created_internal' not in types for types in types_before) + + def test_sequence_contract(self): + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + container = MultiFitter(fit_objects, fit_objects).fit_object + + assert container[0] is fit_objects[0] + assert container[1] is fit_objects[1] + assert container[-1] is fit_objects[1] + assert list(container[0:1]) == [fit_objects[0]] + assert len(container) == 2 + assert list(container) == fit_objects + assert fit_objects[0] in container + assert container[fit_objects[0].unique_name] is fit_objects[0] + + def test_get_fit_parameters_concatenates_in_order(self): + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + container = MultiFitter(fit_objects, fit_objects).fit_object + + assert container.get_fit_parameters() == [ + *fit_objects[0].get_fit_parameters(), + *fit_objects[1].get_fit_parameters(), + ] + + def test_rejects_foreign_object(self): + with pytest.raises(TypeError, match='Items must be one of'): + MultiFitter([Line(1.0, 0.5), 'not a model'], [None, None]) + + def test_rejects_bare_parameter(self): + """CollectionBase accepted bare parameters; EasyList does not.""" + model = Line(1.0, 0.5) + with pytest.raises(TypeError, match='Items must be one of'): + MultiFitter([model, Parameter('p', 1.0)], [model, None]) + + def test_flattens_nested_list(self): + """Nested lists are flattened, as CollectionBase did.""" + models = [Line(1.0, 0.5), Line(2.0, 1.5)] + + container = MultiFitter([models[0], [models[1]]], models).fit_object + + assert list(container) == models + + def test_accepts_fit_objects_as_a_tuple(self): + """Any sequence works, not just a list. + + easyreflectometry passes its models as a tuple; a container that + only flattened lists wrapped the whole tuple as a single item. + """ + models = (Line(1.0, 0.5), Line(2.0, 1.5)) + + container = MultiFitter(models, list(models)).fit_object + + assert list(container) == list(models) + + def test_rejects_legacy_obj_base_fit_objects(self): + """Support for the deprecated ObjBase hierarchy was dropped.""" + legacy = [LegacyLine(1.0, 0.5), LegacyLine(2.0, 1.5)] + + with pytest.raises(TypeError, match='Items must be one of'): + MultiFitter(legacy, legacy) diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 40f85ffc..734b45ce 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -13,8 +13,8 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter +from easyscience.base_classes import ModelBase from easyscience.fitting import Sampler from easyscience.fitting import SamplingResults from easyscience.fitting.minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX @@ -23,14 +23,27 @@ from easyscience.fitting.sampler import load_chain -class AbsSin(ObjBase): - phase: Parameter - offset: Parameter - +class AbsSin(ModelBase): 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) + super().__init__() + self._offset = Parameter('offset', offset_val) + self._phase = Parameter('phase', phase_val) + + @property + def offset(self) -> Parameter: + return self._offset + + @offset.setter + def offset(self, value: float) -> None: + self._offset.value = value + + @property + def phase(self) -> Parameter: + return self._phase + + @phase.setter + def phase(self, value: float) -> None: + self._phase.value = value def __call__(self, x): return np.abs(np.sin(self.phase.value * x + self.offset.value)) From 39f308c002e75c6eba2682e3508ba15960e1a1cc Mon Sep 17 00:00:00 2001 From: rozyczko Date: Fri, 4 Sep 2026 18:23:32 +0200 Subject: [PATCH 11/25] DescriptorBase now is a ModelBase class --- src/easyscience/fitting/multi_fitter.py | 6 +- .../variable/descriptor_any_type.py | 4 +- src/easyscience/variable/descriptor_array.py | 4 +- src/easyscience/variable/descriptor_base.py | 106 +++++++++--------- src/easyscience/variable/descriptor_number.py | 4 +- src/easyscience/variable/parameter.py | 6 +- tests/unit/fitting/test_multi_fitter.py | 4 +- tests/unit/io/test_serializer_dict.py | 8 +- tests/unit/variable/test_descriptor_base.py | 35 ++++++ ...test_parameter_dependency_serialization.py | 2 +- 10 files changed, 112 insertions(+), 67 deletions(-) diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index c2db4c25..a63537aa 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -6,6 +6,7 @@ import numpy as np from ..base_classes import EasyList +from ..base_classes import ModelBase from .fitter import Fitter from .minimizers import FitResults @@ -31,7 +32,10 @@ def __init__( # Aggregate the fit objects so a single object can be sent to Fitter. # *-unpacking keeps any sequence (list, tuple, etc) working, as the # old CollectionBase container did. - self._fit_objects = EasyList(*fit_objects) + # Only ModelBase members are accepted: EasyList harvests parameters + # from ModelBase items alone, so any other NewBase (a bare Parameter, + # say) would be accepted and then silently sit out the fit. + self._fit_objects = EasyList(*fit_objects, protected_types=ModelBase) self._fit_functions = fit_functions # Initialize with the first of the fit_functions, without this it is # not possible to change the fitting engine. diff --git a/src/easyscience/variable/descriptor_any_type.py b/src/easyscience/variable/descriptor_any_type.py index 157117d8..d8830f1d 100644 --- a/src/easyscience/variable/descriptor_any_type.py +++ b/src/easyscience/variable/descriptor_any_type.py @@ -99,7 +99,7 @@ def __repr__(self) -> str: return f"<{self.__class__.__name__} '{self._name}': {value_repr}>" - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - raw_dict = super().as_dict(skip=skip) + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + raw_dict = super().to_dict(skip=skip) raw_dict['value'] = self._value return raw_dict diff --git a/src/easyscience/variable/descriptor_array.py b/src/easyscience/variable/descriptor_array.py index 35f1c022..e3542122 100644 --- a/src/easyscience/variable/descriptor_array.py +++ b/src/easyscience/variable/descriptor_array.py @@ -472,14 +472,14 @@ def __repr__(self) -> str: string = string.replace('\n', ',') return string - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ Dict representation of the current DescriptorArray. The dict contains the value, unit and variances, in addition to the properties of DescriptorBase. """ - raw_dict = super().as_dict(skip=skip) + raw_dict = super().to_dict(skip=skip) raw_dict['value'] = self._array.values raw_dict['unit'] = str(self._array.unit) raw_dict['variance'] = self._array.variances diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index ba7198bc..273efeb3 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -5,14 +5,17 @@ import abc from typing import Any +from typing import Dict +from typing import List from typing import Optional from easyscience import global_object +from easyscience.base_classes.new_base import NewBase from easyscience.global_object.undo_redo import property_stack -from easyscience.io import SerializerComponent +from easyscience.io.serializer_base import SerializerBase -class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta): +class DescriptorBase(NewBase, metaclass=abc.ABCMeta): """ This is the base of all variable descriptions for models. @@ -74,17 +77,14 @@ def __init__( has an invalid type. """ - if unique_name is None: - unique_name = global_object.generate_unique_name(self.__class__.__name__) - self._unique_name = unique_name - if not isinstance(name, str): raise TypeError('Name must be a string') - self._name: str = name - if display_name is not None and not isinstance(display_name, str): - raise TypeError('Display name must be a string or None') - self._display_name: str = display_name + # Registers the descriptor with the global object map and takes + # care of `unique_name` and `display_name`. + super().__init__(unique_name=unique_name, display_name=display_name) + + self._name: str = name if description is not None and not isinstance(description, str): raise TypeError('Description must be a string or None') @@ -98,9 +98,7 @@ def __init__( url = '' self._url: str = url - # Let the collective know we've been assimilated self._parent = parent - global_object.map.add_vertex(self, obj_type='created') # Make the connection between self and parent if parent is not None: global_object.map.add_edge(parent, self) @@ -142,6 +140,9 @@ def display_name(self) -> str: """ Get a pretty display name. + Unlike ``NewBase`` the fallback is the ``name`` of the + descriptor rather than its ``unique_name``. + Returns ------- str @@ -234,40 +235,6 @@ def url(self, url: Optional[str]) -> None: raise TypeError('url must be a string') self._url = url - @property - def unique_name(self) -> str: - """ - Get the unique name of this object. - - Returns - ------- - str - Unique name of this object. - """ - return self._unique_name - - @unique_name.setter - def unique_name(self, new_unique_name: str): - """ - Set a new unique name for the object. - - The old name is still kept in the map. - - Parameters - ---------- - new_unique_name : str - New unique name for the object. - - Raises - ------ - TypeError - If ``new_unique_name`` is not a string. - """ - if not isinstance(new_unique_name, str): - raise TypeError('Unique name has to be a string.') - self._unique_name = new_unique_name - global_object.map.add_vertex(self) - @property @abc.abstractmethod def value(self) -> Any: @@ -282,8 +249,45 @@ def value(self, value: Any) -> None: def __repr__(self) -> str: """Return printable representation of the object.""" - def __copy__(self) -> DescriptorBase: - """Return a copy of the object.""" - temp = self.as_dict(skip=['unique_name']) - new_obj = self.__class__.from_dict(temp) - return new_obj + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Convert a descriptor into a full dictionary using + ``SerializerBase``s generic ``convert_to_dict`` method. + + Unlike ``NewBase.to_dict`` neither ``unique_name`` nor + ``display_name`` is dropped when it was not supplied + explicitly. + + Parameters + ---------- + skip : Optional[List[str]], default=None + List of field names as strings to skip when forming the + dictionary. By default, None. + + Returns + ------- + Dict[str, Any] + Encoded object containing all information to + get back the descriptor. + """ + if skip is None: + skip = [] + return SerializerBase()._convert_to_dict(self, skip=skip, full_encode=False) + + def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Alias of ``to_dict``, kept for backwards compatibility. + + Parameters + ---------- + skip : Optional[List[str]], default=None + List of field names as strings to skip when forming the + dictionary. By default, None. + + Returns + ------- + Dict[str, Any] + Encoded object containing all information to reform the + descriptor. + """ + return self.to_dict(skip=skip) diff --git a/src/easyscience/variable/descriptor_number.py b/src/easyscience/variable/descriptor_number.py index 5bcbcb0d..2aba019f 100644 --- a/src/easyscience/variable/descriptor_number.py +++ b/src/easyscience/variable/descriptor_number.py @@ -444,8 +444,8 @@ def __repr__(self) -> str: return string # return f"<{class_name} '{obj_name}': {obj_value:0.04f}{obj_unit}>" - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - raw_dict = super().as_dict(skip=skip) + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + raw_dict = super().to_dict(skip=skip) raw_dict['value'] = self._scalar.value raw_dict['unit'] = str(self._scalar.unit) raw_dict['variance'] = self._scalar.variance diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 3ec654f7..3e5095c8 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -903,11 +903,11 @@ def free(self) -> bool: def free(self, value: bool) -> None: self.fixed = not value - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ - Overwrite the as_dict method to handle dependency information. + Overwrite the to_dict method to handle dependency information. """ - raw_dict = super().as_dict(skip=skip) + raw_dict = super().to_dict(skip=skip) # Add dependency information for dependent parameters if not self._independent: diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index 97e51c1d..4e3eeaf4 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -290,7 +290,9 @@ def test_rejects_foreign_object(self): MultiFitter([Line(1.0, 0.5), 'not a model'], [None, None]) def test_rejects_bare_parameter(self): - """CollectionBase accepted bare parameters; EasyList does not.""" + """CollectionBase accepted bare parameters; the ModelBase-gated + EasyList does not (a Parameter is a NewBase, but EasyList only + harvests parameters from ModelBase members).""" model = Line(1.0, 0.5) with pytest.raises(TypeError, match='Items must be one of'): MultiFitter([model, Parameter('p', 1.0)], [model, None]) diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py index 1ddf21ef..5df61f7c 100644 --- a/tests/unit/io/test_serializer_dict.py +++ b/tests/unit/io/test_serializer_dict.py @@ -50,7 +50,7 @@ def test_variable_SerializerDict(dp_kwargs: dict, dp_cls: Type[DescriptorNumber] if not isinstance(skip, list): skip = [skip] - enc = obj.encode(skip=skip, encoder=SerializerDict) + enc = SerializerDict().encode(obj, skip=skip) expected_keys = set(dp_kwargs.keys()) obtained_keys = set(enc.keys()) @@ -71,9 +71,9 @@ def test_variable_SerializerDict_decode(dp_kwargs: dict, dp_cls: Type[Descriptor obj = dp_cls(**data_dict) - enc = obj.encode(encoder=SerializerDict) + enc = SerializerDict().encode(obj) global_object.map._clear() - dec = dp_cls.decode(enc, decoder=SerializerDict) + dec = SerializerDict.decode(enc) for k in data_dict.keys(): if hasattr(obj, k) and hasattr(dec, k): @@ -88,7 +88,7 @@ def test_variable_SerializerDict_from_dict(dp_kwargs: dict, dp_cls: Type[Descrip obj = dp_cls(**data_dict) - enc = obj.encode(encoder=SerializerDict) + enc = SerializerDict().encode(obj) global_object.map._clear() dec = dp_cls.from_dict(enc) diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py index 24c342dc..b747e5be 100644 --- a/tests/unit/variable/test_descriptor_base.py +++ b/tests/unit/variable/test_descriptor_base.py @@ -4,6 +4,10 @@ import pytest from easyscience import global_object +from easyscience.base_classes import EasyList +from easyscience.base_classes import NewBase +from easyscience.io import SerializerComponent +from easyscience.variable import DescriptorNumber from easyscience.variable.descriptor_base import DescriptorBase @@ -223,3 +227,34 @@ def test_unique_name_change_exception(self, input, descriptor: DescriptorBase): # When Then Expect with pytest.raises(TypeError): descriptor.unique_name = input + + def test_is_a_new_base(self, descriptor: DescriptorBase): + # When Then Expect + assert isinstance(descriptor, NewBase) + assert not isinstance(descriptor, SerializerComponent) + + def test_as_dict_is_an_alias_of_to_dict(self, descriptor: DescriptorBase): + # When Then Expect + assert descriptor.as_dict() == descriptor.to_dict() + assert descriptor.as_dict(skip=['url']) == descriptor.to_dict(skip=['url']) + + def test_to_dict_keeps_generated_unique_name(self, clear): + """``NewBase.to_dict`` drops a generated unique_name, a + descriptor must not: parameter dependencies and serialized + models refer to descriptors by unique_name.""" + # When + descriptor = DescriptorNumber(name='name', value=1.0) + + # Then Expect + assert descriptor._default_unique_name + assert descriptor.to_dict()['unique_name'] == descriptor.unique_name + + def test_can_be_held_by_an_easy_list(self, clear): + """Descriptors are NewBase objects, so EasyList accepts them.""" + # When + descriptor = DescriptorNumber(name='name', value=1.0) + easy_list = EasyList(descriptor) + + # Then Expect + assert list(easy_list) == [descriptor] + assert easy_list[descriptor.unique_name] is descriptor diff --git a/tests/unit/variable/test_parameter_dependency_serialization.py b/tests/unit/variable/test_parameter_dependency_serialization.py index 62feb5f3..0dddc5d5 100644 --- a/tests/unit/variable/test_parameter_dependency_serialization.py +++ b/tests/unit/variable/test_parameter_dependency_serialization.py @@ -397,7 +397,7 @@ def test_backward_compatibility_base_deserializer(self, clear_global_map): ) # Use base serializer path (SerializerDict.decode) - serialized = b.encode(encoder=SerializerDict) + serialized = SerializerDict().encode(b) global_object.map._clear() # This should not raise the "_independent" error anymore From fda0d2863dde72756ae9209cdddda1501f0bed7f Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 4 Sep 2026 21:25:23 +0200 Subject: [PATCH 12/25] updated handling of unique_names --- src/easyscience/variable/descriptor_base.py | 28 +-------------------- tests/unit/variable/test_descriptor_base.py | 19 ++++++++++---- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index 273efeb3..d59db944 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -12,7 +12,6 @@ from easyscience import global_object from easyscience.base_classes.new_base import NewBase from easyscience.global_object.undo_redo import property_stack -from easyscience.io.serializer_base import SerializerBase class DescriptorBase(NewBase, metaclass=abc.ABCMeta): @@ -249,34 +248,9 @@ def value(self, value: Any) -> None: def __repr__(self) -> str: """Return printable representation of the object.""" - def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Convert a descriptor into a full dictionary using - ``SerializerBase``s generic ``convert_to_dict`` method. - - Unlike ``NewBase.to_dict`` neither ``unique_name`` nor - ``display_name`` is dropped when it was not supplied - explicitly. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Encoded object containing all information to - get back the descriptor. - """ - if skip is None: - skip = [] - return SerializerBase()._convert_to_dict(self, skip=skip, full_encode=False) - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ - Alias of ``to_dict``, kept for backwards compatibility. + Alias of ``NewBase.to_dict``, kept for backwards compatibility. Parameters ---------- diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py index b747e5be..c4498cf4 100644 --- a/tests/unit/variable/test_descriptor_base.py +++ b/tests/unit/variable/test_descriptor_base.py @@ -238,16 +238,25 @@ def test_as_dict_is_an_alias_of_to_dict(self, descriptor: DescriptorBase): assert descriptor.as_dict() == descriptor.to_dict() assert descriptor.as_dict(skip=['url']) == descriptor.to_dict(skip=['url']) - def test_to_dict_keeps_generated_unique_name(self, clear): - """``NewBase.to_dict`` drops a generated unique_name, a - descriptor must not: parameter dependencies and serialized - models refer to descriptors by unique_name.""" + def test_to_dict_drops_generated_unique_name(self, clear): + """Descriptors follow the ``NewBase`` design: an auto-generated + unique_name is not serialized, so a decoded descriptor is given + a fresh one instead of colliding with the original.""" # When descriptor = DescriptorNumber(name='name', value=1.0) # Then Expect assert descriptor._default_unique_name - assert descriptor.to_dict()['unique_name'] == descriptor.unique_name + assert 'unique_name' not in descriptor.to_dict() + + def test_to_dict_keeps_explicit_unique_name(self, clear): + """An explicitly supplied unique_name is still serialized.""" + # When + descriptor = DescriptorNumber(name='name', value=1.0, unique_name='explicit_name') + + # Then Expect + assert not descriptor._default_unique_name + assert descriptor.to_dict()['unique_name'] == 'explicit_name' def test_can_be_held_by_an_easy_list(self, clear): """Descriptors are NewBase objects, so EasyList accepts them.""" From c02814f5f58db5c2479abc06fccaa198dc44ef78 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 4 Sep 2026 22:24:58 +0200 Subject: [PATCH 13/25] updated docstrings and some docs --- docs/docs/api-reference/index.md | 8 +++++--- src/easyscience/base_classes/easy_list.py | 5 ++++- src/easyscience/base_classes/new_base.py | 8 ++++++++ src/easyscience/fitting/multi_fitter.py | 15 +++++++++++++++ src/easyscience/variable/descriptor_base.py | 16 ++++++++++++++++ 5 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 4bd6f561..c6100bfe 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -8,8 +8,9 @@ This section contains the reference detailing the functions and modules available in EasyScience. - [base_classes](base_classes.md) – Core abstract and helper base - classes used to build EasyScience objects (e.g. `ObjBase`, - `ModelBase`). + classes used to build EasyScience objects (e.g. `NewBase`, + `ModelBase`, `EasyList`; the legacy `ObjBase` and `CollectionBase` are + deprecated). - [fitting](fitting.md) – Fitting utilities and interfaces, including `Fitter` and available minimizers. - [global_object](global_object.md) – Global singleton providing shared @@ -23,4 +24,5 @@ available in EasyScience. - [utils](utils.md) – Miscellaneous utility functions and helpers (class tools, decorators, type helpers). - [variable](variable.md) – Descriptor types and variable abstractions - (e.g. `DescriptorNumber`, `Parameter`, `DescriptorArray`). + (e.g. `DescriptorNumber`, `Parameter`, `DescriptorArray`). All of them + are `NewBase` objects, serialized with `to_dict`/`from_dict`. diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 23ac673d..f1f60acd 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -45,7 +45,10 @@ def __init__( Initial items to add to the list. protected_types : list[Type[NewBase]] | Type[NewBase] | None, default=None Types that are allowed in the list. Can be a single NewBase - subclass or a list of them. If None,. By default, None. + subclass or a list of them. If None, any ``NewBase`` object + is accepted, including descriptors and parameters. Note that + only ``ModelBase`` items contribute to ``get_all_variables`` + and hence to fitting. By default, None. unique_name : Optional[str], default=None Optional unique name for the list. By default, None. display_name : Optional[str], default=None diff --git a/src/easyscience/base_classes/new_base.py b/src/easyscience/base_classes/new_base.py index 115677f1..106e6f3c 100644 --- a/src/easyscience/base_classes/new_base.py +++ b/src/easyscience/base_classes/new_base.py @@ -137,6 +137,14 @@ def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: Dict[str, Any] Encoded object containing all information to reform an EasyScience object. + + Notes + ----- + A ``unique_name`` that was generated automatically is not + written; only an explicitly supplied one is, so that a + deserialized object gets a fresh name instead of clashing with + the original. Likewise ``display_name`` is omitted when it is + ``None``. Pass ``skip`` to drop further fields. """ serializer = SerializerBase() if skip is None: diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index a63537aa..6c70074e 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -29,6 +29,21 @@ def __init__( fit_objects: list | None = None, fit_functions: list[Callable] | None = None, ): + """ + Set up a fitter for several models and datasets at once. + + Parameters + ---------- + fit_objects : list | None, default=None + ``ModelBase`` objects to fit, one per dataset. Any sequence + is accepted; each element must be a ``ModelBase`` instance. + By default, None. + fit_functions : list[Callable] | None, default=None + Fit functions, one per fit object and in the same order. The + first one is used to initialise the underlying ``Fitter``. + By default, None. + + """ # Aggregate the fit objects so a single object can be sent to Fitter. # *-unpacking keeps any sequence (list, tuple, etc) working, as the # old CollectionBase container did. diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index d59db944..452752e1 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -27,6 +27,22 @@ class DescriptorBase(NewBase, metaclass=abc.ABCMeta): A ``Descriptor`` is typically something which describes part of a model and is non-fittable and generally changes the state of an object. + + ``DescriptorBase`` is a ``NewBase`` object. As such every descriptor + is registered in the global object map under its ``unique_name``, + has an optional ``display_name`` and is serialized with + ``to_dict``/``from_dict``. Descriptors and parameters can + therefore be held directly by an ``EasyList``. + + Following the ``NewBase`` design, a ``unique_name`` that was + generated automatically is *not* written by ``to_dict``; a + deserialized descriptor is simply assigned a fresh one. Only a + ``unique_name`` passed explicitly to the constructor is serialized. + + Descriptors no longer provide the ``SerializerComponent`` methods + ``encode``, ``decode`` and ``encode_data``. Use a serializer + directly instead, e.g. ``SerializerDict().encode(descriptor)`` and + ``SerializerDict.decode(data)``. """ _global_object = global_object From ac5966a8ab7a35896081e5be92d6e54c22da059e Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 8 Sep 2026 19:37:26 +0200 Subject: [PATCH 14/25] code review comments. part 1 --- src/easyscience/fitting/engine_base.py | 54 ++++++++++--- src/easyscience/fitting/fitter.py | 12 ++- .../minimizers/bumps_utils/__init__.py | 12 --- .../minimizers/bumps_utils/validation.py | 55 +------------ .../fitting/minimizers/minimizer_base.py | 24 ------ .../fitting/minimizers/minimizer_bumps.py | 78 ++++--------------- .../fitting/minimizers/minimizer_dfo.py | 31 +++----- .../fitting/minimizers/minimizer_lmfit.py | 55 +++---------- src/easyscience/fitting/multi_fitter.py | 12 ++- src/easyscience/fitting/sampler.py | 34 +++----- src/easyscience/fitting/samplers/__init__.py | 4 +- .../{sampler_dream.py => sampler_bumps.py} | 11 ++- tests/integration/fitting/test_sampler.py | 2 +- .../minimizers/bumps_utils/test_validation.py | 54 +------------ .../fitting/minimizers/test_minimizer_base.py | 8 +- .../minimizers/test_minimizer_bumps.py | 31 ++------ .../minimizers/test_minimizer_lmfit.py | 50 ------------ ...sampler_dream.py => test_sampler_bumps.py} | 8 +- tests/unit/fitting/test_engine_base.py | 42 ++++++++++ tests/unit/fitting/test_fitter.py | 13 ---- tests/unit/fitting/test_multi_fitter.py | 15 ++++ tests/unit/fitting/test_sampler.py | 14 ++-- 22 files changed, 202 insertions(+), 417 deletions(-) rename src/easyscience/fitting/samplers/{sampler_dream.py => sampler_bumps.py} (98%) rename tests/unit/fitting/samplers/{test_sampler_dream.py => test_sampler_bumps.py} (98%) create mode 100644 tests/unit/fitting/test_engine_base.py diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py index 987899da..5cb12513 100644 --- a/src/easyscience/fitting/engine_base.py +++ b/src/easyscience/fitting/engine_base.py @@ -16,6 +16,42 @@ PARAMETER_PREFIX = 'p' +def validate_arrays(x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> None: + """Validate the (x, y, weights) arrays handed to an engine. + + Parameters + ---------- + x : np.ndarray + Independent variable array. + y : np.ndarray + Dependent variable array. + weights : np.ndarray + Weight array. + + Raises + ------ + ValueError + If the shapes disagree, x or y contain NaN or infinite values, or + the weights are non-finite or non-positive. + """ + if y.shape != x.shape: + raise ValueError('x and y must have the same shape.') + + if not np.isfinite(x).all(): + raise ValueError('x cannot contain NaN or infinite values.') + if not np.isfinite(y).all(): + raise ValueError('y cannot contain NaN or infinite values.') + + if weights.shape != x.shape: + raise ValueError('Weights must have the same shape as x and y.') + + if not np.isfinite(weights).all(): + raise ValueError('Weights cannot be NaN or infinite.') + + if (weights <= 0).any(): + raise ValueError('Weights must be strictly positive and non-zero.') + + class EngineBase(metaclass=ABCMeta): """ Base for all evaluation engines: minimizers and samplers. @@ -48,7 +84,7 @@ def _restore_parameter_values(self) -> None: self._cached_pars[key].error = self._cached_pars_vals[key][1] def evaluate( - self, x: np.ndarray, minimizer_parameters: dict[str, float] | None = None, **kwargs + self, x: np.ndarray, parameters: dict[str, float] | None = None, **kwargs ) -> np.ndarray: """ Evaluate the fit function for values of x. @@ -61,7 +97,7 @@ def evaluate( ---------- x : np.ndarray X values for which the fit function will be evaluated. - minimizer_parameters : dict[str, float] | None, default=None + parameters : dict[str, float] | None, default=None Dictionary of parameters which will be used in the fit function. They must be in a dictionary of {'parameter_name': parameter_value,...}. By default, None. @@ -76,20 +112,20 @@ def evaluate( Raises ------ TypeError - If ``minimizer_parameters`` is not a dictionary. + If ``parameters`` is not a dictionary. """ - if minimizer_parameters is None: - minimizer_parameters = {} - if not isinstance(minimizer_parameters, dict): - raise TypeError('minimizer_parameters must be a dictionary') + if parameters is None: + parameters = {} + if not isinstance(parameters, dict): + raise TypeError('parameters must be a dictionary') if self._fit_function is None: # This will also generate self._cached_pars self._fit_function = self._generate_fit_function() - minimizer_parameters = self._prepare_parameters(minimizer_parameters) + parameters = self._prepare_parameters(parameters) - return self._fit_function(x, **minimizer_parameters, **kwargs) + return self._fit_function(x, **parameters, **kwargs) def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]: """ diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index e8e0d4d8..1160d221 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -43,9 +43,6 @@ def make_model(self, pars=None) -> Callable: def evaluate(self, pars=None) -> np.ndarray: return self._minimizer.evaluate(pars) - def convert_to_pars_obj(self, pars) -> object: - return self._minimizer.convert_to_pars_obj(pars) - # TODO: remove this method when we are ready to adjust the dependent products def initialize(self, fit_object: object, fit_function: Callable) -> None: """ @@ -232,7 +229,10 @@ def fit_object(self, fit_object: object) -> None: self._update_minimizer(self._enum_current_minimizer) def _fit_function_wrapper( - self, real_x: Optional[np.ndarray] = None, flatten: bool = True + self, + real_x: Optional[np.ndarray] = None, + flatten: bool = True, + dependent_dims: Optional[list[tuple[int, ...]]] = None, ) -> Callable: """ Simple fit function which injects the real X (independent) @@ -246,6 +246,10 @@ def _fit_function_wrapper( Independent x parameters to be injected. By default, None. flatten : bool, default=True Should the result be a flat 1D array? By default, True. + dependent_dims : Optional[list[tuple[int, ...]]], default=None + Unused for a single dataset; accepted so that callers can + pass it uniformly to ``Fitter`` and ``MultiFitter``. By + default, None. Returns ------- diff --git a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py index cdd3d6c6..88e8cb9a 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py @@ -7,16 +7,4 @@ from .problem import parameter_snapshot from .problem import to_bumps_parameter from .progress_monitor import BumpsProgressMonitor -from .validation import validate_arrays from .validation import validate_run_settings - -__all__ = [ - 'BumpsProgressMonitor', - 'EvalCounter', - 'build_curve_problem', - 'parameter_names', - 'parameter_snapshot', - 'to_bumps_parameter', - 'validate_arrays', - 'validate_run_settings', -] diff --git a/src/easyscience/fitting/minimizers/bumps_utils/validation.py b/src/easyscience/fitting/minimizers/bumps_utils/validation.py index f3e5d349..00fae214 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/validation.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/validation.py @@ -1,11 +1,9 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Input validation shared by the BUMPS minimizer and the DREAM sampler.""" +"""Run-settings validation for the DREAM sampler.""" from __future__ import annotations -import numpy as np - def validate_run_settings(samples: int, burn: int, thin: int) -> None: """Validate the DREAM run settings. @@ -33,54 +31,3 @@ def validate_run_settings(samples: int, burn: int, thin: int) -> None: raise ValueError('burn must be a non-negative integer.') if not isinstance(thin, int) or isinstance(thin, bool) or thin < 1: raise ValueError('thin must be a positive integer.') - - -def validate_arrays( - x: np.ndarray, - y: np.ndarray, - weights: np.ndarray, - *, - check_finite_xy: bool = True, -) -> None: - """Validate the (x, y, weights) arrays for a BUMPS problem. - - Checks shape agreement between the three arrays, finiteness and - strict positivity of the weights, and (when ``check_finite_xy`` is - ``True``) finiteness of x and y. Sampling passes ``True``; the - standard fit path passes ``False`` to keep its historically more - permissive behaviour. - - Parameters - ---------- - x : np.ndarray - Independent variable array. - y : np.ndarray - Dependent variable array. - weights : np.ndarray - Weight array (converted to ``dy = 1 / weights`` downstream). - check_finite_xy : bool, default=True - Also require x and y to be free of NaN/infinite values. - - Raises - ------ - ValueError - If the shapes disagree, the weights are non-finite or - non-positive, or (with ``check_finite_xy``) x/y are non-finite. - """ - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if check_finite_xy: - if not np.isfinite(x).all(): - raise ValueError('x cannot contain NaN or infinite values.') - if not np.isfinite(y).all(): - raise ValueError('y cannot contain NaN or infinite values.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 136e4396..ed12bfd1 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -13,16 +13,10 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers -from ..engine_base import PARAMETER_PREFIX from ..engine_base import EngineBase from .utils import FitError from .utils import FitResults -# Back-compat alias: the canonical constant now lives in -# ``easyscience.fitting.engine_base`` and is shared by minimizers and -# samplers alike. -MINIMIZER_PARAMETER_PREFIX = PARAMETER_PREFIX - class MinimizerBase(EngineBase): """ @@ -110,24 +104,6 @@ def _get_method_kwargs(self, passed_method: str | None = None) -> dict[str, str] return {} - @abstractmethod - def convert_to_pars_obj(self, par_list: List[Parameter] | None = None) -> Any: - """ - Create an engine compatible container with the ``Parameters`` - converted from the base object. - - Parameters - ---------- - par_list : List[Parameter] | None, default=None - If only a single/selection of parameter is required. Specify - as a list. By default, None. - - Returns - ------- - Any - Engine Parameters compatible object. - """ - @staticmethod @abstractmethod def supported_methods() -> List[str]: diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 65e552ac..6e96ba88 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from typing import Any from typing import Callable -from typing import cast import numpy as np from bumps.fitters import FIT_AVAILABLE_IDS @@ -21,14 +20,14 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers +from ..engine_base import PARAMETER_PREFIX +from ..engine_base import validate_arrays from .bumps_utils import BumpsProgressMonitor from .bumps_utils import EvalCounter from .bumps_utils import build_curve_problem from .bumps_utils import parameter_names from .bumps_utils import parameter_snapshot from .bumps_utils import to_bumps_parameter -from .bumps_utils import validate_arrays -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -174,7 +173,7 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - validate_arrays(x, y, weights, check_finite_xy=False) + validate_arrays(x, y, weights) if progress_callback is not None and not callable(progress_callback): raise ValueError('progress_callback must be callable') @@ -182,9 +181,6 @@ def fit( if engine_kwargs is None: engine_kwargs = {} - # Copy rather than mutate: `ftol`/`xtol`/`steps` are injected below, and a - # caller reusing the same mapping for a second fit would otherwise silently - # inherit the settings resolved for the first one. minimizer_kwargs = {} if minimizer_kwargs is None else dict(minimizer_kwargs) minimizer_kwargs.update(engine_kwargs) @@ -214,11 +210,9 @@ def fit( ) else: # Report the stricter of the two BUMPS defaults; nothing is written back. - tols = [ - t - for t in (fitter_settings.get('ftol'), fitter_settings.get('xtol')) - if t is not None - ] + ftol = fitter_settings.get('ftol') + xtol = fitter_settings.get('xtol') + tols = [t for t in (ftol, xtol) if t is not None] tolerance = min(tols) if tols else None if model is None: @@ -265,9 +259,6 @@ def fit( # Drive the fit through the local FitDriver instance so the supplied # `monitors` (including the optional progress callback monitor) are # invoked. `bumps.fitters.fit` constructs its own driver. - # - # Named `best_x` rather than `x` so the caller's independent-variable - # array stays intact for the rest of the method. best_x, fx = driver.fit() # BUMPS signals a failed optimization by returning `None` in place of a @@ -283,7 +274,7 @@ def fit( message = 'Fit aborted before convergence' else: success = True - message = 'successful termination' + message = 'Fit converged successfully' # BUMPS' `MonitorRunner.history.step` is populated by the driver itself # (independently of any user-supplied monitors) and exposes the canonical @@ -346,17 +337,13 @@ def _resolve_fitclass(method: str) -> type[FitBase]: FitError If no registered fitter carries that id. """ - # Built per call rather than cached at import time so that fitters - # registered into `FITTERS` after import are still resolvable. - fitclass = {fitclass.id: fitclass for fitclass in FITTERS}.get(method) - if fitclass is None: - raise FitError(f'Unknown BUMPS fitting method: {method}') - # BUMPS annotates `FITTERS` as `List[FitBase]`, but it holds the fitter - # *classes* — `FitDriver` instantiates them as `self.fitclass(problem)`. - return cast('type[FitBase]', fitclass) + for fitclass in FITTERS: + if fitclass.id == method: + return fitclass + raise FitError(f'Unknown BUMPS fitting method: {method}') def _build_progress_payload( - self, problem: FitProblem, iteration: int, point: np.ndarray, nllf: float + self, problem: FitProblem, iteration: int, point: np.ndarray | None, nllf: float ) -> dict: # Use the nllf already computed by the fitter to avoid a costly # model re-evaluation, and let BUMPS apply its own chisq scaling. @@ -374,28 +361,6 @@ def _build_progress_payload( 'finished': False, } - def convert_to_pars_obj(self, par_list: list[Parameter] | None = None) -> list[BumpsParameter]: - """ - Create a container with the ``Parameters`` converted from the - base object. - - Parameters - ---------- - par_list : list[Parameter] | None, default=None - If only a single/selection of parameter is required. Specify - as a list. By default, None. - - Returns - ------- - list[BumpsParameter] - Bumps Parameters list. - """ - if par_list is None: - # Assume that we have a ObjBase for which we can obtain a list - par_list = self._object.get_fit_parameters() - pars_obj = [self.convert_to_par_object(obj) for obj in par_list] - return pars_obj - @staticmethod def convert_to_par_object(obj: Parameter) -> BumpsParameter: """ @@ -432,16 +397,13 @@ def _set_parameter_fit_result( Whether the undo stack was enabled. par_names : list[str] Cached-parameter names in BUMPS problem order, already - stripped of ``MINIMIZER_PARAMETER_PREFIX``. As seen in + stripped of ``PARAMETER_PREFIX``. As seen in :func:`~easyscience.fitting.minimizers.bumps_utils.parameter_names`. """ from easyscience import global_object pars = self._cached_pars x_result = np.asarray(fit_result.x) - # Some BUMPS fitters cannot produce a covariance and hand back no errors; - # report those parameters as having no uncertainty rather than failing, - # matching what the LMFit minimizer does when `errorbars` is False. stderr = None if fit_result.dx is None else np.asarray(fit_result.dx) if stack_status: @@ -451,7 +413,7 @@ def _set_parameter_fit_result( for index, name in enumerate(par_names): pars[name].value = x_result[index] - pars[name].error = 0.0 if stderr is None else stderr[index] + pars[name].error = None if stderr is None else stderr[index] if stack_status: global_object.stack.endMacro() @@ -512,24 +474,18 @@ def _gen_fit_results( pars = self._cached_pars item = {} for index, name in enumerate(self._cached_model.pars.keys()): - dict_name = name[len(MINIMIZER_PARAMETER_PREFIX) :] + dict_name = name[len(PARAMETER_PREFIX) :] item[name] = pars[dict_name].value results.p0 = self._p_0 results.p = item results.x = self._cached_model.x results.y_obs = self._cached_model.y - # Costs one extra model evaluation beyond those the optimizer consumed, and - # deliberately so: it runs through the uncounted `self._fit_function`, keeping - # `n_evaluations` a faithful count of optimizer-driven objective calls. - results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p) + results.y_calc = self.evaluate(results.x, parameters=results.p) results.y_err = self._cached_model.dy results.n_evaluations = n_evaluations results.iterations = n_steps_used - # A successful fit carries no message; anything else reports why it stopped. - results.message = ( - '' if fit_results.success else (getattr(fit_results, 'message', '') or '') - ) + results.message = '' if fit_results.success else fit_results.message if stopped_on_budget: from easyscience import global_object diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 51c57d63..21e15495 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -15,7 +15,8 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX +from ..engine_base import PARAMETER_PREFIX +from ..engine_base import validate_arrays from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -132,17 +133,7 @@ def fit( """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + validate_arrays(x, y, weights) # Bridge progress_callback into the DFO callback mechanism if progress_callback is not None and callback is None: @@ -188,10 +179,6 @@ def fit( global_object.stack.enabled = stack_status return results - def convert_to_pars_obj(self, par_list: List[Parameter] | None = None): - """Required by interface but not needed for DFO-LS.""" - pass - @staticmethod def convert_to_par_object(obj) -> None: """Required by interface but not needed for DFO-LS.""" @@ -227,10 +214,10 @@ def _make_func(x, y, weights): dfo_pars = {} if not parameters: for name, par in obj._cached_pars.items(): - dfo_pars[MINIMIZER_PARAMETER_PREFIX + str(name)] = par.value + dfo_pars[PARAMETER_PREFIX + str(name)] = par.value else: for par in parameters: - dfo_pars[MINIMIZER_PARAMETER_PREFIX + par.unique_name] = par.value + dfo_pars[PARAMETER_PREFIX + par.unique_name] = par.value def _residuals(pars_values: List[float]) -> np.ndarray: for idx, par_name in enumerate(dfo_pars.keys()): @@ -251,8 +238,8 @@ def _get_callback_parameter_names( self, parameters: List[Parameter] | None = None ) -> list[str]: if parameters is not None: - return [MINIMIZER_PARAMETER_PREFIX + parameter.unique_name for parameter in parameters] - return [MINIMIZER_PARAMETER_PREFIX + name for name in self._cached_pars.keys()] + return [PARAMETER_PREFIX + parameter.unique_name for parameter in parameters] + return [PARAMETER_PREFIX + name for name in self._cached_pars.keys()] @staticmethod def _wrap_model_with_callback( @@ -325,7 +312,7 @@ def adapter(state: 'DFOCallbackState') -> None: dof = max(np.asarray(state.residuals).size - len(state.best_parameters), 1) reduced_chi2 = chi2 / dof if dof > 0 else chi2 param_snapshot = { - name[len(MINIMIZER_PARAMETER_PREFIX) :]: float(val) + name[len(PARAMETER_PREFIX) :]: float(val) for name, val in state.best_parameters.items() } payload = { @@ -412,7 +399,7 @@ def _gen_fit_results(self, fit_results: Any, weights: np.ndarray, **kwargs: Any) results.p0 = self._p_0 results.x = self._cached_model.x results.y_obs = self._cached_model.y - results.y_calc = self.evaluate(results.x, minimizer_parameters=results.p) + results.y_calc = self.evaluate(results.x, parameters=results.p) # `weights` here are 1/sigma (residuals are multiplied by them in `_make_model`). # `FitResults.chi2` divides residuals by `y_err`, so `y_err` must be sigma, not the weight. results.y_err = 1 / np.asarray(weights) diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index b3dd9cbb..89733ff1 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -16,7 +16,8 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX +from ..engine_base import PARAMETER_PREFIX +from ..engine_base import validate_arrays from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -143,17 +144,7 @@ def fit( """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') + validate_arrays(x, y, weights) if engine_kwargs is None: engine_kwargs = {} @@ -219,12 +210,12 @@ def _build_progress_payload(self, params, iteration: int, residuals: np.ndarray) reduced_chi2 = chi2 / degrees_of_freedom if degrees_of_freedom > 0 else chi2 parameter_values = { - parameter_name[len(MINIMIZER_PARAMETER_PREFIX) :]: float(parameter.value) + parameter_name[len(PARAMETER_PREFIX) :]: float(parameter.value) for parameter_name, parameter in params.items() - if parameter_name.startswith(MINIMIZER_PARAMETER_PREFIX) + if parameter_name.startswith(PARAMETER_PREFIX) } for parameter_name, parameter in self._cached_pars.items(): - lmfit_parameter_name = f'{MINIMIZER_PARAMETER_PREFIX}{parameter_name}' + lmfit_parameter_name = f'{PARAMETER_PREFIX}{parameter_name}' if lmfit_parameter_name not in params: parameter_values[parameter_name] = float(parameter.value) @@ -253,30 +244,6 @@ def _get_fit_kws( minimizer_kwargs['tol'] = tolerance return minimizer_kwargs - def convert_to_pars_obj(self, parameters: List[Parameter] | None = None) -> LMParameters: - """ - Create an lmfit compatible container with the ``Parameters`` - converted from the base object. - - Parameters - ---------- - parameters : List[Parameter] | None, default=None - If only a single/selection of parameter is required. Specify - as a list. By default, None. - - Returns - ------- - LMParameters - Lmfit Parameters compatible object. - """ - if parameters is None: - # Assume that we have a ObjBase for which we can obtain a list - parameters = self._object.get_fit_parameters() - lm_parameters = LMParameters().add_many([ - self.convert_to_par_object(parameter) for parameter in parameters - ]) - return lm_parameters - @staticmethod def convert_to_par_object(parameter: Parameter) -> LMParameter: """ @@ -296,7 +263,7 @@ def convert_to_par_object(parameter: Parameter) -> LMParameter: value = parameter.value return LMParameter( - MINIMIZER_PARAMETER_PREFIX + parameter.unique_name, + PARAMETER_PREFIX + parameter.unique_name, value=value, vary=not parameter.fixed, min=parameter.min, @@ -331,7 +298,7 @@ def _make_model(self, pars: LMParameters | None = None) -> LMModel: model = LMModel( fit_func, independent_vars=['x'], - param_names=[MINIMIZER_PARAMETER_PREFIX + str(key) for key in pars.keys()], + param_names=[PARAMETER_PREFIX + str(key) for key in pars.keys()], ) # Assign values from the `Parameter` to the model for name, item in pars.items(): @@ -341,7 +308,7 @@ def _make_model(self, pars: LMParameters | None = None) -> LMModel: value = item.value model.set_param_hint( - MINIMIZER_PARAMETER_PREFIX + str(name), + PARAMETER_PREFIX + str(name), value=value, min=item.min, max=item.max, @@ -371,9 +338,9 @@ def _set_parameter_fit_result(self, fit_result: ModelResult, stack_status: bool) global_object.stack.enabled = True global_object.stack.beginMacro('Fitting routine') for name in pars.keys(): - pars[name].value = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].value + pars[name].value = fit_result.params[PARAMETER_PREFIX + str(name)].value if fit_result.errorbars: - pars[name].error = fit_result.params[MINIMIZER_PARAMETER_PREFIX + str(name)].stderr + pars[name].error = fit_result.params[PARAMETER_PREFIX + str(name)].stderr else: # No covariance available (gradient-free method, aborted fit, or a # parameter at a bound). None keeps that distinguishable from a diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index 568283c6..21b2767c 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -35,7 +35,10 @@ def __init__( super().__init__(self._fit_objects, self._fit_functions[0]) def _fit_function_wrapper( - self, real_x: list[np.ndarray] | None = None, flatten: bool = True + self, + real_x: list[np.ndarray] | None = None, + flatten: bool = True, + dependent_dims: list[tuple[int, ...]] | None = None, ) -> Callable: """ Simple fit function which injects the N real X (independent) @@ -50,6 +53,10 @@ def _fit_function_wrapper( None. flatten : bool, default=True Should the result be a flat 1D array? By default, True. + dependent_dims : list[tuple[int, ...]] | None, default=None + Per-dataset dependent shapes used to slice the combined + output. When ``None``, ``self._dependent_dims`` (set by + ``fit``) is read at call time. By default, None. Returns ------- @@ -75,9 +82,10 @@ def wrapped_fun(x, **kwargs): # Generate an empty Y based on x y = np.zeros_like(x) i = 0 + dims = self._dependent_dims if dependent_dims is None else dependent_dims # 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): + for idx, dim in enumerate(dims): ep = i + np.prod(dim) y[i:ep] = wrapped_fns[idx](x, **kwargs) i = ep diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index b169c2ad..ce75f73f 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -263,11 +263,9 @@ class Sampler: different data, create a new ``Sampler``. Construct directly with a configured ``Fitter`` (or ``MultiFitter``). - Sampling is independent of the fitter's minimizer — any minimizer (LMFit, - DFO, BUMPS) may stay active; the only requirement is an installed - ``bumps`` package. **Running a fit first is not required** — the - ``Fitter`` supplies the model and fit function, not a fit result, and - sampling from the initial parameter values works fine. + The only requirement is an installed ``bumps`` package. **Running a fit + first is not required**; sampling from the initial parameter values + works fine. It is often worth fitting first anyway. DREAM seeds its whole starting population inside a tiny ball around the parameters' *current* values @@ -275,15 +273,11 @@ class Sampler: chain in the right region and shortens the burn-in needed to reach the typical set. From a poor initial guess, expect to burn for longer. - The sampler is BUMPS/DREAM-specific for now: the ``DreamSampler`` - construction in ``_run()`` is the single line that knows a concrete - backend exists — the seam where a sampler factory would plug in. - Parameters ---------- fitter : Fitter A configured ``Fitter`` (or ``MultiFitter``) supplying the model and - fit function. Its active minimizer is irrelevant to sampling. + fit function. x : np.ndarray | list[np.ndarray] Independent variable array (or list of arrays for ``MultiFitter``). y : np.ndarray | list[np.ndarray] @@ -483,26 +477,20 @@ def _run( 'Bayesian sampling requires the bumps package. ' 'Install it with ``pip install bumps``.' ) - from .samplers.sampler_dream import DreamSampler + from .samplers.sampler_bumps import DreamSampler x_fit, x_new, y_new, w_new, dims = self._fitter._precompute_reshaping( self._x, self._y, self._weights, self._vectorized ) - # Required internal bookkeeping write: MultiFitter's - # ``_fit_function_wrapper`` reads ``_dependent_dims`` to reshape - # multi-dataset output. It is the only fitter attribute sampling - # modifies: the user-visible surface (fit_function, minimizer) is - # never mutated. - self._fitter._dependent_dims = dims - wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True) + # The dims are passed explicitly so the fitter itself is never mutated. + wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True, dependent_dims=dims) merged_kwargs = {**self._default_sampler_kwargs, **(sampler_kwargs or {})} - # A fresh engine per run is deliberate: it is cheap (the parameter - # cache is built lazily), and per-call construction guarantees the - # chain always sees the fitter's *current* fit function and - # parameter set. Chain continuity lives in ``resume_state``, not in - # engine. + # A fresh engine per run keeps the chain on the fitter's current fit + # function and parameters; chain continuity lives in ``resume_state``. + # This is where a sampler factory would plug in once there is more + # than one backend. engine = DreamSampler(obj=self._fitter.fit_object, fit_function=wrapped) result = engine.run( x=x_fit, diff --git a/src/easyscience/fitting/samplers/__init__.py b/src/easyscience/fitting/samplers/__init__.py index cfdae878..a5870f75 100644 --- a/src/easyscience/fitting/samplers/__init__.py +++ b/src/easyscience/fitting/samplers/__init__.py @@ -1,6 +1,4 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -from .sampler_dream import DreamSampler - -__all__ = ['DreamSampler'] +from .sampler_bumps import DreamSampler diff --git a/src/easyscience/fitting/samplers/sampler_dream.py b/src/easyscience/fitting/samplers/sampler_bumps.py similarity index 98% rename from src/easyscience/fitting/samplers/sampler_dream.py rename to src/easyscience/fitting/samplers/sampler_bumps.py index dedf5e39..aa4ac444 100644 --- a/src/easyscience/fitting/samplers/sampler_dream.py +++ b/src/easyscience/fitting/samplers/sampler_bumps.py @@ -15,11 +15,11 @@ from ..engine_base import PARAMETER_PREFIX from ..engine_base import EngineBase +from ..engine_base import validate_arrays from ..minimizers.bumps_utils import BumpsProgressMonitor from ..minimizers.bumps_utils import build_curve_problem from ..minimizers.bumps_utils import parameter_names from ..minimizers.bumps_utils import parameter_snapshot -from ..minimizers.bumps_utils import validate_arrays from ..minimizers.bumps_utils import validate_run_settings from ..minimizers.utils import FitError @@ -171,7 +171,7 @@ def run( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) validate_run_settings(samples, burn, thin) - validate_arrays(x, y, weights, check_finite_xy=True) + validate_arrays(x, y, weights) # Build the BUMPS Curve model around the engine's wrapped fit function problem, _, _ = build_curve_problem(self, x, y, weights) @@ -345,7 +345,12 @@ def _validate_resume_state( return -int(resume_state.Npop), 0 def _build_sample_progress_payload( - self, problem, iteration: int, point: np.ndarray, nllf: float, total_steps: int + self, + problem: FitProblem, + iteration: int, + point: np.ndarray | None, + nllf: float, + total_steps: int, ) -> dict: """ Build a progress payload for Bayesian DREAM sampling steps. diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index a0a0a217..c588763b 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -224,7 +224,7 @@ def test_sampler_kwargs_forwarded(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_default_sampler_kwargs_merged(self, monkeypatch): """Constructor-level sampler_kwargs defaults are used; per-call kwargs win.""" - from easyscience.fitting.samplers.sampler_dream import DreamSampler + from easyscience.fitting.samplers.sampler_bumps import DreamSampler f, _, x, y, weights = _fitter_and_data() sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'init': 'random'}) diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py index c69d86f8..481e767e 100644 --- a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py +++ b/tests/unit/fitting/minimizers/bumps_utils/test_validation.py @@ -1,11 +1,9 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Unit tests for the shared BUMPS input validation helpers.""" +"""Unit tests for the DREAM run-settings validation helper.""" -import numpy as np import pytest -from easyscience.fitting.minimizers.bumps_utils import validate_arrays from easyscience.fitting.minimizers.bumps_utils import validate_run_settings @@ -36,53 +34,3 @@ def test_invalid_settings_raise(self, kwargs, match): def test_valid_settings_pass(self): validate_run_settings(samples=1, burn=0, thin=1) validate_run_settings(samples=10000, burn=2000, thin=10) - - -class TestValidateArrays: - @staticmethod - def _data(): - return { - 'x': np.array([1.0, 2.0]), - 'y': np.array([0.1, 0.2]), - 'weights': np.array([1.0, 1.0]), - } - - @pytest.mark.parametrize( - 'overrides, match', - [ - ({'y': np.array([0.1])}, 'x and y must have the same shape'), - ({'weights': np.array([1.0])}, 'Weights must have the same shape'), - ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'), - ({'weights': np.array([1.0, np.inf])}, 'Weights cannot be NaN'), - ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), - ({'weights': np.array([1.0, -1.0])}, 'Weights must be strictly positive'), - ], - ) - @pytest.mark.parametrize('check_finite_xy', [True, False]) - def test_shared_checks_raise(self, overrides, match, check_finite_xy): - """Shape and weight checks apply on both the fit and sampling paths.""" - data = self._data() - data.update(overrides) - with pytest.raises(ValueError, match=match): - validate_arrays(**data, check_finite_xy=check_finite_xy) - - @pytest.mark.parametrize( - 'overrides, match', - [ - ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'), - ({'x': np.array([1.0, np.inf])}, 'x cannot contain NaN'), - ({'y': np.array([0.1, np.nan])}, 'y cannot contain NaN'), - ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'), - ], - ) - def test_finite_xy_checked_only_when_requested(self, overrides, match): - """x/y finiteness is enforced for sampling but not for the classical - fit path, preserving the fit path's historically permissive behaviour.""" - data = self._data() - data.update(overrides) - with pytest.raises(ValueError, match=match): - validate_arrays(**data, check_finite_xy=True) - validate_arrays(**data, check_finite_xy=False) # must not raise - - def test_valid_arrays_pass(self): - validate_arrays(**self._data(), check_finite_xy=True) diff --git a/tests/unit/fitting/minimizers/test_minimizer_base.py b/tests/unit/fitting/minimizers/test_minimizer_base.py index 86ec4292..86026234 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_base.py +++ b/tests/unit/fitting/minimizers/test_minimizer_base.py @@ -61,7 +61,7 @@ def test_evaluate(self, minimizer: MinimizerBase): # Then result = minimizer.evaluate( 'x', - minimizer_parameters={'parms_key': 'parms_val'}, + parameters={'parms_key': 'parms_val'}, kwargs={'kwargs_key': 'kwargs_val'}, ) @@ -84,7 +84,7 @@ def test_evaluate_no_fit_function(self, minimizer: MinimizerBase): # Then minimizer.evaluate( 'x', - minimizer_parameters={'parms_key': 'parms_val'}, + parameters={'parms_key': 'parms_val'}, kwargs={'kwargs_key': 'kwargs_val'}, ) @@ -108,11 +108,11 @@ def test_evaluate_no_parameters(self, minimizer: MinimizerBase): def test_evaluate_exception(self, minimizer: MinimizerBase): # When - minimizer_parameters = 'not dict type' + parameters = 'not dict type' # Then Expect with pytest.raises(TypeError): - minimizer.evaluate('x', minimizer_parameters=minimizer_parameters) + minimizer.evaluate('x', parameters=parameters) def test_prepare_parameters(self, minimizer: MinimizerBase): # When diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index beb6301c..e7c4e9c8 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -176,7 +176,8 @@ def test_set_parameter_fit_result_no_stack_status(self, minimizer: Bumps): def test_set_parameter_fit_result_without_stderr(self, minimizer: Bumps): """Fitters that cannot produce a covariance hand back ``dx=None``; - those parameters get a zero error instead of raising.""" + those parameters get ``error=None`` (no uncertainty information) + rather than a misleading ``0.0``.""" minimizer._cached_pars = {'a': MagicMock()} mock_fit_result = MagicMock() @@ -186,7 +187,7 @@ def test_set_parameter_fit_result_without_stderr(self, minimizer: Bumps): minimizer._set_parameter_fit_result(mock_fit_result, False, ['a']) assert minimizer._cached_pars['a'].value == 1.0 - assert minimizer._cached_pars['a'].error == 0.0 + assert minimizer._cached_pars['a'].error is None def test_gen_fit_results( self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture' @@ -251,7 +252,7 @@ def test_gen_fit_results( assert domain_fit_results.fit_args is None assert domain_fit_results.engine_result == mock_fit_result minimizer.evaluate.assert_called_once_with( - 'x', minimizer_parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} + 'x', parameters={'ppar_1': 'par_value_1', 'ppar_2': 'par_value_2'} ) @pytest.mark.parametrize( @@ -614,28 +615,6 @@ def test_build_progress_payload_reduced_chi2_positive_dof(self, minimizer: Bumps ((), {'nllf': 5.0, 'norm': True}), ] - @pytest.mark.parametrize('par_list', [None, [MagicMock(unique_name='alpha')]]) - def test_convert_to_pars_obj_optional_parameter_list( - self, minimizer: Bumps, par_list, monkeypatch - ) -> None: - object_parameters = [MagicMock(unique_name='beta')] - minimizer._object = MagicMock() - minimizer._object.get_fit_parameters = MagicMock(return_value=object_parameters) - monkeypatch.setattr( - Bumps, - 'convert_to_par_object', - staticmethod(lambda parameter: parameter.unique_name), - ) - - converted = minimizer.convert_to_pars_obj(par_list) - - expected_parameters = object_parameters if par_list is None else par_list - assert converted == [parameter.unique_name for parameter in expected_parameters] - if par_list is None: - minimizer._object.get_fit_parameters.assert_called_once_with() - else: - minimizer._object.get_fit_parameters.assert_not_called() - def test_bumps_progress_monitor_calls_callback(self, minimizer: Bumps) -> None: # When callback = MagicMock(return_value=True) @@ -946,7 +925,7 @@ def test_successful_fit_reports_success(self, minimizer: Bumps, monkeypatch) -> passed = minimizer._gen_fit_results.call_args.args[0] assert passed.success is True - assert passed.message == 'successful termination' + assert passed.message == 'Fit converged successfully' assert passed.nit == 7 diff --git a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py index 0141fc56..ce24dc27 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py +++ b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py @@ -427,56 +427,6 @@ def test_gen_fit_results_success_does_not_warn( assert len(caplog.records) == 0 assert result.success is True - def test_convert_to_pars_obj(self, minimizer: LMFit, monkeypatch) -> None: - # When - minimizer._object = MagicMock() - minimizer._object.get_fit_parameters = MagicMock(return_value=['parm_1', 'parm_2']) - - minimizer.convert_to_par_object = MagicMock(return_value='convert_to_par_object') - - mock_lm_parameter = MagicMock() - mock_lm_parameter.add_many = MagicMock(return_value='add_many') - mock_LMParameters = MagicMock(return_value=mock_lm_parameter) - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameters', mock_LMParameters - ) - - # Then - pars = minimizer.convert_to_pars_obj() - - # Expect - assert pars == 'add_many' - assert minimizer.convert_to_par_object.call_count == 2 - minimizer._object.get_fit_parameters.assert_called_once_with() - minimizer.convert_to_par_object.assert_called_with('parm_2') - mock_lm_parameter.add_many.assert_called_once_with([ - 'convert_to_par_object', - 'convert_to_par_object', - ]) - - def test_convert_to_pars_obj_with_parameters(self, minimizer: LMFit, monkeypatch) -> None: - # When - minimizer.convert_to_par_object = MagicMock(return_value='convert_to_par_object') - - mock_lm_parameter = MagicMock() - mock_lm_parameter.add_many = MagicMock(return_value='add_many') - mock_LMParameters = MagicMock(return_value=mock_lm_parameter) - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameters', mock_LMParameters - ) - - # Then - pars = minimizer.convert_to_pars_obj(['parm_1', 'parm_2']) - - # Expect - assert pars == 'add_many' - assert minimizer.convert_to_par_object.call_count == 2 - minimizer.convert_to_par_object.assert_called_with('parm_2') - mock_lm_parameter.add_many.assert_called_once_with([ - 'convert_to_par_object', - 'convert_to_par_object', - ]) - def test_convert_to_par_object(self, minimizer: LMFit, monkeypatch) -> None: # When mock_lm_parameter = MagicMock() diff --git a/tests/unit/fitting/samplers/test_sampler_dream.py b/tests/unit/fitting/samplers/test_sampler_bumps.py similarity index 98% rename from tests/unit/fitting/samplers/test_sampler_dream.py rename to tests/unit/fitting/samplers/test_sampler_bumps.py index 5c2d200e..747ffc7e 100644 --- a/tests/unit/fitting/samplers/test_sampler_dream.py +++ b/tests/unit/fitting/samplers/test_sampler_bumps.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause """Unit tests for ``DreamSampler`` — mirrors -``src/easyscience/fitting/samplers/sampler_dream.py``. +``src/easyscience/fitting/samplers/sampler_bumps.py``. Ported from the former ``TestBumpsSample`` suite in ``tests/unit/fitting/minimizers/test_minimizer_bumps.py`` when the former @@ -16,7 +16,7 @@ import numpy as np import pytest -import easyscience.fitting.samplers.sampler_dream +import easyscience.fitting.samplers.sampler_bumps from easyscience.fitting.engine_base import EngineBase from easyscience.fitting.minimizers.bumps_utils import BumpsProgressMonitor from easyscience.fitting.minimizers.utils import FitError @@ -52,7 +52,7 @@ def _mock_bumps_internals(self, monkeypatch): def _set_problem(monkeypatch, problem): """Point ``build_curve_problem`` at a canned (problem, counter, curve) triple.""" monkeypatch.setattr( - easyscience.fitting.samplers.sampler_dream, + easyscience.fitting.samplers.sampler_bumps, 'build_curve_problem', MagicMock(return_value=(problem, MagicMock(), MagicMock())), ) @@ -95,7 +95,7 @@ def _setup_driver_mock( mock_FitDriver = MagicMock(return_value=mock_driver) monkeypatch.setattr( - easyscience.fitting.samplers.sampler_dream, 'FitDriver', mock_FitDriver + easyscience.fitting.samplers.sampler_bumps, 'FitDriver', mock_FitDriver ) return mock_FitDriver, mock_driver diff --git a/tests/unit/fitting/test_engine_base.py b/tests/unit/fitting/test_engine_base.py new file mode 100644 index 00000000..22676861 --- /dev/null +++ b/tests/unit/fitting/test_engine_base.py @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for the engine-agnostic helpers in ``engine_base.py``.""" + +import numpy as np +import pytest + +from easyscience.fitting.engine_base import validate_arrays + + +class TestValidateArrays: + @staticmethod + def _data(): + return { + 'x': np.array([1.0, 2.0]), + 'y': np.array([0.1, 0.2]), + 'weights': np.array([1.0, 1.0]), + } + + @pytest.mark.parametrize( + 'overrides, match', + [ + ({'y': np.array([0.1])}, 'x and y must have the same shape'), + ({'x': np.array([1.0, np.nan])}, 'x cannot contain NaN'), + ({'x': np.array([1.0, np.inf])}, 'x cannot contain NaN'), + ({'y': np.array([0.1, np.nan])}, 'y cannot contain NaN'), + ({'y': np.array([0.1, np.inf])}, 'y cannot contain NaN'), + ({'weights': np.array([1.0])}, 'Weights must have the same shape'), + ({'weights': np.array([1.0, np.nan])}, 'Weights cannot be NaN'), + ({'weights': np.array([1.0, np.inf])}, 'Weights cannot be NaN'), + ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), + ({'weights': np.array([1.0, -1.0])}, 'Weights must be strictly positive'), + ], + ) + def test_invalid_arrays_raise(self, overrides, match): + data = self._data() + data.update(overrides) + with pytest.raises(ValueError, match=match): + validate_arrays(**data) + + def test_valid_arrays_pass(self): + validate_arrays(**self._data()) diff --git a/tests/unit/fitting/test_fitter.py b/tests/unit/fitting/test_fitter.py index 702f5e59..64605c2a 100644 --- a/tests/unit/fitting/test_fitter.py +++ b/tests/unit/fitting/test_fitter.py @@ -54,19 +54,6 @@ def test_evaluate(self, fitter: Fitter): assert result == 'result' mock_minimizer.evaluate.assert_called_once_with('pars') - def test_convert_to_pars_obj(self, fitter: Fitter): - # When - mock_minimizer = MagicMock() - mock_minimizer.convert_to_pars_obj = MagicMock(return_value='obj') - fitter._minimizer = mock_minimizer - - # Then - obj = fitter.convert_to_pars_obj('pars') - - # Expect - assert obj == 'obj' - mock_minimizer.convert_to_pars_obj.assert_called_once_with('pars') - def test_initialize(self, fitter: Fitter): # When mock_fit_object = MagicMock() diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index f47dfcb7..6c7401df 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -168,6 +168,21 @@ def test_fit_function_restored_with_multiple_datasets(self): expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])]) assert np.allclose(y, expected) + def test_explicit_dependent_dims_do_not_touch_fitter(self): + """Passing ``dependent_dims`` (as the ``Sampler`` does) slices the + combined output without writing ``_dependent_dims`` onto the fitter.""" + fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] + mf = MultiFitter(fit_objects, fit_objects) + assert mf._dependent_dims is None + + x = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0])] + wrapped = mf._fit_function_wrapper(x, flatten=True, dependent_dims=[(3,), (2,)]) + + y = wrapped(np.zeros(5)) + expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])]) + assert np.allclose(y, expected) + assert mf._dependent_dims is None + # =================================================================== # MultiFitter._precompute_reshaping with weights=None diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 40f85ffc..16745ed0 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -17,7 +17,7 @@ from easyscience import Parameter from easyscience.fitting import Sampler from easyscience.fitting import SamplingResults -from easyscience.fitting.minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX +from easyscience.fitting.engine_base import PARAMETER_PREFIX from easyscience.fitting.multi_fitter import MultiFitter from easyscience.fitting.sampler import _data_fingerprint from easyscience.fitting.sampler import load_chain @@ -328,7 +328,7 @@ def test_missing_sidecar_falls_back_to_state_labels(self, tmp_path): assert names == ['P0', 'P1'] def test_fallback_strips_minimizer_prefix_from_labels(self, tmp_path): - self.state.labels = [f'{MINIMIZER_PARAMETER_PREFIX}a', f'{MINIMIZER_PARAMETER_PREFIX}b'] + self.state.labels = [f'{PARAMETER_PREFIX}a', f'{PARAMETER_PREFIX}b'] _, names, _ = load_chain(str(tmp_path / 'chain')) assert names == ['a', 'b'] @@ -407,7 +407,7 @@ class TestSamplerRunEngine: def test_run_stores_results_and_exposes_properties(self, monkeypatch): f, _, x, y, weights = _fitter_and_data() - from easyscience.fitting.samplers.sampler_dream import DreamSampler + from easyscience.fitting.samplers.sampler_bumps import DreamSampler canned = { 'draws': np.arange(8.0).reshape(4, 2), @@ -426,6 +426,7 @@ def fake_run(self, **kwargs): sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'trim': False}) original_func = f.fit_function minimizer_before = f.minimizer + dims_before = f._dependent_dims results = sampler.sample(samples=100, burn=10, thin=2, sampler_kwargs={'init': 'lhs'}) assert isinstance(results, SamplingResults) @@ -440,15 +441,18 @@ def fake_run(self, **kwargs): assert captured['burn'] == 10 assert captured['resume_state'] is None # The fitter is never mutated: a fresh engine gets the wrapped - # function directly, and the active (LMFit) minimizer stays put. + # function directly, the active (LMFit) minimizer stays put, and the + # reshaping bookkeeping is passed to the wrapper rather than written + # onto the fitter. assert f.fit_function is original_func assert f.minimizer is minimizer_before + assert f._dependent_dims is dims_before def test_run_works_with_non_bumps_minimizer(self, monkeypatch): """Sampling works with the default LMFit minimizer active — the engine is constructed independently of the fitter's minimizer.""" f, _, x, y, weights = _fitter_and_data() - from easyscience.fitting.samplers.sampler_dream import DreamSampler + from easyscience.fitting.samplers.sampler_bumps import DreamSampler assert f.minimizer.package != 'bumps' # default is LMFit From 85f0bb32e2a3d7c97f9c13716540d57a455f27b3 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 8 Sep 2026 21:09:22 +0200 Subject: [PATCH 15/25] removed optional Parameters list passed to the minimizers. Part of the PR code review --- .../fitting/minimizers/bumps_utils/problem.py | 19 +++------- .../fitting/minimizers/minimizer_base.py | 3 -- .../fitting/minimizers/minimizer_bumps.py | 8 +--- .../fitting/minimizers/minimizer_dfo.py | 30 ++++----------- .../fitting/minimizers/minimizer_lmfit.py | 3 -- .../minimizers/bumps_utils/test_problem.py | 13 ------- .../minimizers/test_minimizer_bumps.py | 2 +- .../fitting/minimizers/test_minimizer_dfo.py | 37 +++++-------------- 8 files changed, 25 insertions(+), 90 deletions(-) diff --git a/src/easyscience/fitting/minimizers/bumps_utils/problem.py b/src/easyscience/fitting/minimizers/bumps_utils/problem.py index 17f41254..df471cfc 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/problem.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/problem.py @@ -54,13 +54,12 @@ def build_curve_problem( x: np.ndarray, y: np.ndarray, weights: np.ndarray, - parameters: list[Parameter] | None = None, ) -> tuple[FitProblem, EvalCounter, Curve]: """Build a BUMPS ``FitProblem`` around an engine's wrapped fit function. Wraps ``engine._generate_fit_function()`` in an :class:`EvalCounter`, - converts the engine's cached parameters (or the explicitly supplied - ``parameters``) via :func:`to_bumps_parameter`, and assembles + converts the engine's cached parameters via + :func:`to_bumps_parameter`, and assembles ``Curve(fit_func, x, y, dy=1/weights, **bumps_pars)`` into a ``FitProblem``. @@ -75,9 +74,6 @@ def build_curve_problem( Dependent variable array. weights : np.ndarray Weight array; converted to ``dy = 1 / weights``. - parameters : list[Parameter] | None, default=None - Optional explicit EasyScience parameters to bind into the model - instead of the engine's cached parameters. Returns ------- @@ -89,13 +85,10 @@ def build_curve_problem( """ fit_func = EvalCounter(engine._generate_fit_function()) - bumps_pars = {} - if not parameters: - for name, par in engine._cached_pars.items(): - bumps_pars[PARAMETER_PREFIX + str(name)] = to_bumps_parameter(par) - else: - for par in parameters: - bumps_pars[PARAMETER_PREFIX + par.unique_name] = to_bumps_parameter(par) + bumps_pars = { + PARAMETER_PREFIX + str(name): to_bumps_parameter(par) + for name, par in engine._cached_pars.items() + } curve = Curve(fit_func, x, y, dy=1 / weights, **bumps_pars) return FitProblem(curve), fit_func, curve diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index ed12bfd1..4d8c32ca 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -52,7 +52,6 @@ def fit( y: np.ndarray, weights: np.ndarray, model: Callable | None = None, - parameters: List[Parameter] | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -72,8 +71,6 @@ def fit( Weights for supplied measured points. model : Callable | None, default=None Optional Model which is being fitted to. By default, None. - parameters : List[Parameter] | None, default=None - Optional parameters for the fit. By default, None. method : str | None, default=None Method for the minimizer to use. By default, None. tolerance : float | None, default=None diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 6e96ba88..6201e763 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -87,7 +87,6 @@ def fit( y: np.ndarray, weights: np.ndarray, model: Callable | None = None, - parameters: list[Parameter] | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -114,9 +113,6 @@ def fit( fit parameters. A supplied ``Curve`` must expose ``pars``, ``x``, ``y`` and ``dy``, since the results are assembled from them. By default, None. - parameters : list[Parameter] | None, default=None - Optional parameters for the fit. Ignored when ``model`` is - supplied. By default, None. method : str | None, default=None Method for minimization. By default, None. tolerance : float | None, default=None @@ -217,9 +213,7 @@ def fit( if model is None: # The Curve comes back directly from the helper. - problem, self._eval_counter, model = build_curve_problem( - self, x, y, weights, parameters=parameters - ) + problem, self._eval_counter, model = build_curve_problem(self, x, y, weights) else: # A caller-supplied model bypasses `build_curve_problem`, which is also # what populates the parameter cache that `_p_0`, diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 21e15495..a9d9e7e8 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -80,7 +80,6 @@ def fit( y: np.ndarray, weights: np.ndarray, model: Callable | None = None, - parameters: List[Parameter] | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -101,8 +100,6 @@ def fit( Weights for supplied measured points. model : Callable | None, default=None Optional Model which is being fitted to. By default, None. - parameters : List[Parameter] | None, default=None - Optional parameters for the fit. By default, None. method : str | None, default=None Method for minimization. By default, None. tolerance : float | None, default=None @@ -140,15 +137,12 @@ def fit( callback = self._make_progress_adapter(progress_callback) if model is None: - model_function = self._make_model( - parameters=parameters, - callback=callback, - ) + model_function = self._make_model(callback=callback) model = model_function(x, y, weights) elif callback is not None: model = self._wrap_model_with_callback( model, - self._get_callback_parameter_names(parameters), + self._get_callback_parameter_names(), callback, ) self._cached_model = model @@ -186,7 +180,6 @@ def convert_to_par_object(obj) -> None: def _make_model( self, - parameters: List[Parameter] | None = None, callback: Callable[[DFOCallbackState], None] | None = None, ) -> Callable: """ @@ -196,8 +189,6 @@ def _make_model( Parameters ---------- - parameters : List[Parameter] | None, default=None - Optional parameter subset to include in the model. callback : Callable[[DFOCallbackState], None] | None, default=None Optional callback invoked on each objective evaluation. @@ -211,13 +202,10 @@ def _make_model( def _outer(obj: DFO): def _make_func(x, y, weights): - dfo_pars = {} - if not parameters: - for name, par in obj._cached_pars.items(): - dfo_pars[PARAMETER_PREFIX + str(name)] = par.value - else: - for par in parameters: - dfo_pars[PARAMETER_PREFIX + par.unique_name] = par.value + dfo_pars = { + PARAMETER_PREFIX + str(name): par.value + for name, par in obj._cached_pars.items() + } def _residuals(pars_values: List[float]) -> np.ndarray: for idx, par_name in enumerate(dfo_pars.keys()): @@ -234,11 +222,7 @@ def _residuals(pars_values: List[float]) -> np.ndarray: return _outer(self) - def _get_callback_parameter_names( - self, parameters: List[Parameter] | None = None - ) -> list[str]: - if parameters is not None: - return [PARAMETER_PREFIX + parameter.unique_name for parameter in parameters] + def _get_callback_parameter_names(self) -> list[str]: return [PARAMETER_PREFIX + name for name in self._cached_pars.keys()] @staticmethod diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 89733ff1..bc77fb30 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -87,7 +87,6 @@ def fit( y: np.ndarray, weights: np.ndarray = None, model: LMModel | None = None, - parameters: LMParameters | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -109,8 +108,6 @@ def fit( Weights for supplied measured points. By default, None. model : LMModel | None, default=None Optional Model which is being fitted to. By default, None. - parameters : LMParameters | None, default=None - Optional parameters for the fit. By default, None. method : str | None, default=None Minimizer method. By default, None. tolerance : float | None, default=None diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_problem.py b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py index affe454d..d1208138 100644 --- a/tests/unit/fitting/minimizers/bumps_utils/test_problem.py +++ b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py @@ -90,19 +90,6 @@ def test_uses_cached_parameters_by_default(self): assert self.mock_curve_cls.call_args.kwargs['palpha'] == 'converted-alpha' self.mock_problem_cls.assert_called_once_with('curve') - def test_explicit_parameters_override_cache(self): - engine = self._engine_with_cached_pars({'alpha': MagicMock(unique_name='alpha')}) - explicit = MagicMock() - explicit.unique_name = 'beta' - - build_curve_problem( - engine, np.array([1.0]), np.array([2.0]), np.array([4.0]), parameters=[explicit] - ) - - self.mock_convert.assert_called_once_with(explicit) - assert 'pbeta' in self.mock_curve_cls.call_args.kwargs - assert 'palpha' not in self.mock_curve_cls.call_args.kwargs - def test_curve_receives_data_and_dy(self): """weights are converted to dy = 1 / weights.""" engine = self._engine_with_cached_pars({}) diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index e7c4e9c8..39806769 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -117,7 +117,7 @@ def fake_set_parameter_fit_result(fit_result, stack_status, par_names): assert np.array_equal(build_args.args[1], np.asarray(1.0)) assert np.array_equal(build_args.args[2], np.asarray(2.0)) assert np.array_equal(build_args.args[3], np.asarray(1)) - assert build_args.kwargs == {'parameters': None} + assert build_args.kwargs == {} assert minimizer._eval_counter is mock_counter assert minimizer._cached_model is mock_model assert mock_FitDriver.call_args.kwargs['problem'] is mock_problem diff --git a/tests/unit/fitting/minimizers/test_minimizer_dfo.py b/tests/unit/fitting/minimizers/test_minimizer_dfo.py index 823dcce5..f857bd03 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_dfo.py +++ b/tests/unit/fitting/minimizers/test_minimizer_dfo.py @@ -70,10 +70,7 @@ def test_fit(self, minimizer: DFO) -> None: minimizer._dfo_fit.assert_called_once_with( cached_pars, mock_model, user_params={'logging.save_diagnostic_info': True} ) - minimizer._make_model.assert_called_once_with( - parameters=None, - callback=None, - ) + minimizer._make_model.assert_called_once_with(callback=None) minimizer._set_parameter_fit_result.assert_called_once_with('fit', False) minimizer._gen_fit_results.assert_called_once_with('fit', 1) mock_model_function.assert_called_once_with(1.0, 2.0, 1) @@ -98,10 +95,7 @@ def test_fit_passes_callback_to_model_builder(self, minimizer: DFO) -> None: minimizer.fit(x=1.0, y=2.0, weights=1, callback=callback) - minimizer._make_model.assert_called_once_with( - parameters=None, - callback=callback, - ) + minimizer._make_model.assert_called_once_with(callback=callback) def test_fit_wraps_supplied_model_with_explicit_callback(self, minimizer: DFO) -> None: from easyscience import global_object @@ -219,14 +213,13 @@ def test_make_model(self, minimizer: DFO) -> None: minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function) mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' mock_parm_1.value = 1000.0 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' mock_parm_2.value = 2000.0 + minimizer._cached_pars = {'mock_parm_1': mock_parm_1, 'mock_parm_2': mock_parm_2} # Then - model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2]) + model = minimizer._make_model() residuals_for_model = model( x=np.array([1, 2]), y=np.array([10, 20]), @@ -247,15 +240,14 @@ def test_make_model_callback(self, minimizer: DFO) -> None: minimizer._generate_fit_function = MagicMock(return_value=mock_fit_function) mock_parm_1 = MagicMock() - mock_parm_1.unique_name = 'mock_parm_1' mock_parm_1.value = 1000.0 mock_parm_2 = MagicMock() - mock_parm_2.unique_name = 'mock_parm_2' mock_parm_2.value = 2000.0 + minimizer._cached_pars = {'mock_parm_1': mock_parm_1, 'mock_parm_2': mock_parm_2} callback = MagicMock() - model = minimizer._make_model(parameters=[mock_parm_1, mock_parm_2], callback=callback) + model = minimizer._make_model(callback=callback) residuals_for_model = model( x=np.array([1, 2]), y=np.array([10, 20]), @@ -759,21 +751,12 @@ def test_progress_callback_not_used_when_explicit_callback_given(self, minimizer call_kwargs = minimizer._make_model.call_args[1] assert call_kwargs['callback'] is explicit_cb - @pytest.mark.parametrize( - ('parameters', 'expected_names'), - [ - ([MagicMock(unique_name='alpha')], ['palpha']), - (None, ['pbeta']), - ], - ) - def test_get_callback_parameter_names_optional_parameters( - self, minimizer: DFO, parameters, expected_names - ) -> None: - minimizer._cached_pars = {'beta': MagicMock(value=1.0)} + def test_get_callback_parameter_names_from_cache(self, minimizer: DFO) -> None: + minimizer._cached_pars = {'beta': MagicMock(value=1.0), 'gamma': MagicMock(value=2.0)} - parameter_names = minimizer._get_callback_parameter_names(parameters) + parameter_names = minimizer._get_callback_parameter_names() - assert parameter_names == expected_names + assert parameter_names == ['pbeta', 'pgamma'] def test_wrap_model_with_callback_invokes_on_each_evaluation(self, minimizer: DFO) -> None: callback = MagicMock() From 6aadc0795d92392184cb020ed16dcbde596385a5 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 8 Sep 2026 21:48:27 +0200 Subject: [PATCH 16/25] docstring lint --- src/easyscience/fitting/minimizers/minimizer_dfo.py | 2 -- src/easyscience/fitting/minimizers/minimizer_lmfit.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index a9d9e7e8..643ac6a0 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -125,8 +125,6 @@ def fit( ------ FitError If the DFO fit fails. - ValueError - If the input shapes, weights, or tolerance are invalid. """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index bc77fb30..e1f8e8cc 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -136,8 +136,6 @@ def fit( ------ FitError If the LMFit optimization fails. - ValueError - If the input shapes or weights are invalid. """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) From d3a1d0d1c5db5cc6df0a9cd0c8512a424e835a3a Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 10 Sep 2026 13:40:42 +0200 Subject: [PATCH 17/25] More fixes for the PR review. Not ready yet --- src/easyscience/fitting/fitter.py | 19 +- .../fitting/minimizers/minimizer_base.py | 11 -- .../fitting/minimizers/minimizer_bumps.py | 46 +---- .../fitting/minimizers/minimizer_dfo.py | 22 +-- .../fitting/minimizers/minimizer_lmfit.py | 34 +--- .../minimizers/test_minimizer_bumps.py | 162 +----------------- .../fitting/minimizers/test_minimizer_dfo.py | 75 -------- .../minimizers/test_minimizer_lmfit.py | 54 +----- 8 files changed, 25 insertions(+), 398 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index 1160d221..d1f20080 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -4,7 +4,6 @@ import functools from typing import Callable from typing import List -from typing import Optional from typing import Union import numpy as np @@ -230,9 +229,9 @@ def fit_object(self, fit_object: object) -> None: def _fit_function_wrapper( self, - real_x: Optional[np.ndarray] = None, + real_x: np.ndarray | None = None, flatten: bool = True, - dependent_dims: Optional[list[tuple[int, ...]]] = None, + dependent_dims: list[tuple[int, ...]] | None = None, ) -> Callable: """ Simple fit function which injects the real X (independent) @@ -242,11 +241,11 @@ def _fit_function_wrapper( Parameters ---------- - real_x : Optional[np.ndarray], default=None + real_x : np.ndarray | None, default=None Independent x parameters to be injected. By default, None. flatten : bool, default=True Should the result be a flat 1D array? By default, True. - dependent_dims : Optional[list[tuple[int, ...]]], default=None + dependent_dims : list[tuple[int, ...]] | None, default=None Unused for a single dataset; accepted so that callers can pass it uniformly to ``Fitter`` and ``MultiFitter``. By default, None. @@ -285,7 +284,7 @@ def fit(self) -> Callable: def inner_fit_callable( x: np.ndarray, y: np.ndarray, - weights: Optional[np.ndarray] = None, + weights: np.ndarray | None = None, vectorized: bool = False, progress_callback: Callable[[dict], None] | None = None, **kwargs, @@ -333,9 +332,9 @@ def inner_fit_callable( def _precompute_reshaping( x: np.ndarray, y: np.ndarray, - weights: Optional[np.ndarray], + weights: np.ndarray | None, vectorized: bool, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], tuple[int, ...]]: + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]]: """ Check the dimensions of the inputs and reshape if necessary. @@ -345,14 +344,14 @@ def _precompute_reshaping( ND matrix of dependent points. y : np.ndarray N-1D matrix of independent points. - weights : Optional[np.ndarray] + weights : np.ndarray | None Optional weights for the fit. vectorized : bool Whether ``x`` already stores vectorized coordinates. Returns ------- - tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], tuple[int, ...]] + tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]] Reshaped x values, reshaped input data, flattened y values, flattened weights, and the original x shape. diff --git a/src/easyscience/fitting/minimizers/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 4d8c32ca..e598a678 100644 --- a/src/easyscience/fitting/minimizers/minimizer_base.py +++ b/src/easyscience/fitting/minimizers/minimizer_base.py @@ -51,7 +51,6 @@ def fit( x: np.ndarray, y: np.ndarray, weights: np.ndarray, - model: Callable | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -69,8 +68,6 @@ def fit( Measured points. weights : np.ndarray Weights for supplied measured points. - model : Callable | None, default=None - Optional Model which is being fitted to. By default, None. method : str | None, default=None Method for the minimizer to use. By default, None. tolerance : float | None, default=None @@ -125,14 +122,6 @@ def all_methods() -> List[str]: List of all available methods. """ - @staticmethod - @abstractmethod - def convert_to_par_object(obj): # todo after constraint changes, add type hint: obj: ObjBase - """ - Convert an ``EasyScience.variable.Parameter`` object to an - engine Parameter object. - """ - @staticmethod def _error_from_jacobian( jacobian: np.ndarray, residuals: np.ndarray, confidence: float = 0.95 diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 6201e763..8fd6ee9b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -86,7 +86,6 @@ def fit( x: np.ndarray, y: np.ndarray, weights: np.ndarray, - model: Callable | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -107,12 +106,6 @@ def fit( Measured points. weights : np.ndarray Weights for supplied measured points. - model : Callable | None, default=None - Optional BUMPS ``Curve`` which is being fitted to. When - omitted, one is built from ``fit_function`` and the object's - fit parameters. A supplied ``Curve`` must expose ``pars``, - ``x``, ``y`` and ``dy``, since the results are assembled from - them. By default, None. method : str | None, default=None Method for minimization. By default, None. tolerance : float | None, default=None @@ -183,11 +176,6 @@ def fit( method_str = method_dict.get('method', self._method) fitclass = self._resolve_fitclass(method_str) - # Reset the per-fit evaluation counter. A caller-supplied `model` bypasses - # `build_curve_problem`, which is what installs the counter, so without this - # the results would carry the previous fit's objective-call count. - self._eval_counter = None - # Only values the caller supplied explicitly are pushed back into # `minimizer_kwargs`. BUMPS pairs an independent `ftol`/`xtol` default per # fitter (`newton` combines ftol=1e-6 with xtol=1e-12, `amoeba` ftol=1e-8 with @@ -211,18 +199,8 @@ def fit( tols = [t for t in (ftol, xtol) if t is not None] tolerance = min(tols) if tols else None - if model is None: - # The Curve comes back directly from the helper. - problem, self._eval_counter, model = build_curve_problem(self, x, y, weights) - else: - # A caller-supplied model bypasses `build_curve_problem`, which is also - # what populates the parameter cache that `_p_0`, - # `_set_parameter_fit_result` and `_gen_fit_results` all read. Build the - # wrapped fit function here purely for that side effect, so the cache - # describes the current object rather than being empty or left over from - # an earlier fit. - self._fit_function = self._generate_fit_function() - problem = FitProblem(model) + # The Curve comes back directly from the helper. + problem, self._eval_counter, model = build_curve_problem(self, x, y, weights) self._cached_model = model self._p_0 = {f'p{key}': self._cached_pars[key].value for key in self._cached_pars.keys()} @@ -268,7 +246,7 @@ def fit( message = 'Fit aborted before convergence' else: success = True - message = 'Fit converged successfully' + message = 'Fit converged' # BUMPS' `MonitorRunner.history.step` is populated by the driver itself # (independently of any user-supplied monitors) and exposes the canonical @@ -355,24 +333,6 @@ def _build_progress_payload( 'finished': False, } - @staticmethod - def convert_to_par_object(obj: Parameter) -> BumpsParameter: - """ - Convert an ``EasyScience.variable.Parameter`` object to a bumps - Parameter object. - - Parameters - ---------- - obj : Parameter - EasyScience parameter to convert. - - Returns - ------- - BumpsParameter - Bumps Parameter compatible object. - """ - return to_bumps_parameter(obj) - def _set_parameter_fit_result( self, fit_result: Any, diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 643ac6a0..2457c3a6 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -79,7 +79,6 @@ def fit( x: np.ndarray, y: np.ndarray, weights: np.ndarray, - model: Callable | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -98,8 +97,6 @@ def fit( Measured points. weights : np.ndarray Weights for supplied measured points. - model : Callable | None, default=None - Optional Model which is being fitted to. By default, None. method : str | None, default=None Method for minimization. By default, None. tolerance : float | None, default=None @@ -134,15 +131,8 @@ def fit( if progress_callback is not None and callback is None: callback = self._make_progress_adapter(progress_callback) - if model is None: - model_function = self._make_model(callback=callback) - model = model_function(x, y, weights) - elif callback is not None: - model = self._wrap_model_with_callback( - model, - self._get_callback_parameter_names(), - callback, - ) + model_function = self._make_model(callback=callback) + model = model_function(x, y, weights) self._cached_model = model self._cached_model.x = x self._cached_model.y = y @@ -171,11 +161,6 @@ def fit( global_object.stack.enabled = stack_status return results - @staticmethod - def convert_to_par_object(obj) -> None: - """Required by interface but not needed for DFO-LS.""" - pass - def _make_model( self, callback: Callable[[DFOCallbackState], None] | None = None, @@ -220,9 +205,6 @@ def _residuals(pars_values: List[float]) -> np.ndarray: return _outer(self) - def _get_callback_parameter_names(self) -> list[str]: - return [PARAMETER_PREFIX + name for name in self._cached_pars.keys()] - @staticmethod def _wrap_model_with_callback( model: Callable, diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index e1f8e8cc..35f1a04d 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -86,7 +86,6 @@ def fit( x: np.ndarray, y: np.ndarray, weights: np.ndarray = None, - model: LMModel | None = None, method: str | None = None, tolerance: float | None = None, max_evaluations: int | None = None, @@ -106,8 +105,6 @@ def fit( Measured points. weights : np.ndarray, default=None Weights for supplied measured points. By default, None. - model : LMModel | None, default=None - Optional Model which is being fitted to. By default, None. method : str | None, default=None Minimizer method. By default, None. tolerance : float | None, default=None @@ -154,8 +151,7 @@ def fit( global_object.stack.enabled = False try: - if model is None: - model = self._make_model() + model = self._make_model() self._last_iteration = None iter_cb = self._create_iter_callback(progress_callback) @@ -239,34 +235,6 @@ def _get_fit_kws( minimizer_kwargs['tol'] = tolerance return minimizer_kwargs - @staticmethod - def convert_to_par_object(parameter: Parameter) -> LMParameter: - """ - Convert an EasyScience Parameter object to a lmfit Parameter - object. - - Parameters - ---------- - parameter : Parameter - EasyScience parameter to convert. - - Returns - ------- - LMParameter - Lmfit Parameter compatible object. - """ - value = parameter.value - - return LMParameter( - PARAMETER_PREFIX + parameter.unique_name, - value=value, - vary=not parameter.fixed, - min=parameter.min, - max=parameter.max, - expr=None, - brute_step=None, - ) - def _make_model(self, pars: LMParameters | None = None) -> LMModel: """ Generate a lmfit model from the supplied ``fit_function`` and diff --git a/tests/unit/fitting/minimizers/test_minimizer_bumps.py b/tests/unit/fitting/minimizers/test_minimizer_bumps.py index 39806769..4c29f420 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_bumps.py +++ b/tests/unit/fitting/minimizers/test_minimizer_bumps.py @@ -404,122 +404,6 @@ def test_fit_progress_callback(self, minimizer: Bumps, monkeypatch) -> None: assert monitors[0]._callback is progress_callback assert monitors[0]._payload_builder == minimizer._build_progress_payload - def test_fit_uses_supplied_model_and_optional_kwargs( - self, minimizer: Bumps, monkeypatch - ) -> None: - from easyscience import global_object - - global_object.stack.enabled = False - - mock_driver_instance = MagicMock() - mock_driver_instance.clip = MagicMock() - mock_driver_instance.fit = MagicMock(return_value=(np.array([3.0]), 0.0)) - mock_driver_instance.stderr = MagicMock(return_value=np.array([0.1])) - mock_driver_instance.monitor_runner.history.step = [0] - mock_FitDriver = MagicMock(return_value=mock_driver_instance) - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'FitDriver', mock_FitDriver - ) - - mock_bumps_param = MagicMock() - mock_bumps_param.name = 'pmock_parm_1' - mock_problem = MagicMock() - mock_problem._parameters = [mock_bumps_param] - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, - 'FitProblem', - MagicMock(return_value=mock_problem), - ) - - mock_build = MagicMock() - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, 'build_curve_problem', mock_build - ) - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') - minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) - minimizer._set_parameter_fit_result = MagicMock() - - # A supplied model bypasses build_curve_problem, so fit() must populate the - # parameter cache itself from the bound object rather than leaving it empty. - object_parameter = MagicMock(unique_name='mock_parm_1') - object_parameter.value = 1.0 - object_parameter.error = 0.0 - minimizer._object = MagicMock() - minimizer._object.get_fit_parameters = MagicMock(return_value=[object_parameter]) - - supplied_model = MagicMock() - minimizer_kwargs = {'existing_option': 'minimizer'} - engine_kwargs = {'engine_option': 'engine'} - - result = minimizer.fit( - x=np.array([1.0]), - y=np.array([2.0]), - weights=np.array([1.0]), - model=supplied_model, - tolerance=0.25, - max_evaluations=7, - minimizer_kwargs=minimizer_kwargs, - engine_kwargs=engine_kwargs, - ) - - assert result == 'gen_fit_results' - mock_build.assert_not_called() - fit_driver_kwargs = mock_FitDriver.call_args.kwargs - assert fit_driver_kwargs['problem'] is mock_problem - assert fit_driver_kwargs['existing_option'] == 'minimizer' - assert fit_driver_kwargs['engine_option'] == 'engine' - assert fit_driver_kwargs['ftol'] == 0.25 - assert fit_driver_kwargs['xtol'] == 0.25 - assert fit_driver_kwargs['steps'] == 7 - mock_driver_instance.fit.assert_called_once() - # The cache and the starting-point snapshot are built from the bound object - assert minimizer._cached_pars == {'mock_parm_1': object_parameter} - assert minimizer._p_0 == {'pmock_parm_1': 1.0} - - def test_fit_with_supplied_model_resets_eval_counter( - self, minimizer: Bumps, monkeypatch - ) -> None: - """A supplied model installs no EvalCounter, so a counter left over - from a previous fit must not be reported as this fit's count.""" - from easyscience import global_object - - global_object.stack.enabled = False - - mock_driver_instance = MagicMock() - mock_driver_instance.fit = MagicMock(return_value=(np.array([3.0]), 0.0)) - mock_driver_instance.stderr = MagicMock(return_value=np.array([0.1])) - mock_driver_instance.monitor_runner.history.step = [0] - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, - 'FitDriver', - MagicMock(return_value=mock_driver_instance), - ) - mock_problem = MagicMock() - mock_problem._parameters = [] - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_bumps, - 'FitProblem', - MagicMock(return_value=mock_problem), - ) - - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') - minimizer._resolve_fitclass = MagicMock(return_value=MagicMock(id='amoeba')) - minimizer._set_parameter_fit_result = MagicMock() - minimizer._object = MagicMock() - minimizer._object.get_fit_parameters = MagicMock(return_value=[]) - - # Stale counter from an earlier fit - minimizer._eval_counter = MagicMock(count=999) - - minimizer.fit( - x=np.array([1.0]), - y=np.array([2.0]), - weights=np.array([1.0]), - model=MagicMock(), - ) - - assert minimizer._eval_counter is None - def test_fit_rejects_non_callable_progress_callback( self, minimizer: Bumps, monkeypatch ) -> None: @@ -535,7 +419,6 @@ def test_fit_rejects_non_callable_progress_callback( x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0]), - model=MagicMock(), progress_callback='not-callable', ) @@ -801,7 +684,7 @@ def test_explicit_tolerance_is_forwarded(self, minimizer: Bumps, monkeypatch) -> assert driver_kwargs['steps'] == 11 def test_minimizer_kwargs_is_not_mutated(self, minimizer: Bumps, monkeypatch) -> None: - self._patch_driver_and_problem(minimizer, monkeypatch) + mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch) minimizer_kwargs = {'existing': 'value'} minimizer.fit( @@ -814,8 +697,13 @@ def test_minimizer_kwargs_is_not_mutated(self, minimizer: Bumps, monkeypatch) -> engine_kwargs={'engine': 'option'}, ) - # The caller's mapping is untouched, so reusing it cannot leak settings - # from one fit into the next. + # Both mappings reach the driver... + driver_kwargs = mock_FitDriver.call_args.kwargs + assert driver_kwargs['existing'] == 'value' + assert driver_kwargs['engine'] == 'option' + + # ...and the caller's mapping is untouched, so reusing it cannot leak + # settings from one fit into the next. assert minimizer_kwargs == {'existing': 'value'} @@ -925,7 +813,7 @@ def test_successful_fit_reports_success(self, minimizer: Bumps, monkeypatch) -> passed = minimizer._gen_fit_results.call_args.args[0] assert passed.success is True - assert passed.message == 'Fit converged successfully' + assert passed.message == 'Fit converged' assert passed.nit == 7 @@ -966,38 +854,6 @@ def test_stack_status_true_calls_begin_end_macro(self, minimizer: Bumps) -> None minimizer._restore_parameter_values.assert_called_once() -# =================================================================== -# convert_to_par_object -# =================================================================== - - -class TestConvertToParObject: - def test_convert_parameter_object(self) -> None: - from easyscience.variable import Parameter - - param = Parameter('thickness', 42.0, min=0.0, max=100.0) - param.fixed = False - - result = Bumps.convert_to_par_object(param) - - # convert_to_par_object uses obj.unique_name which is auto-assigned - assert result.name.startswith('p') - assert result.value == 42.0 - assert result.bounds == (0.0, 100.0) - assert result.fixed is False - - def test_convert_fixed_parameter(self) -> None: - from easyscience.variable import Parameter - - param = Parameter('roughness', 5.0, min=0.0, max=20.0) - param.fixed = True - - result = Bumps.convert_to_par_object(param) - - assert result.name.startswith('p') - assert result.fixed is True - - # =================================================================== # fit() with abort_test # =================================================================== diff --git a/tests/unit/fitting/minimizers/test_minimizer_dfo.py b/tests/unit/fitting/minimizers/test_minimizer_dfo.py index f857bd03..f083412a 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_dfo.py +++ b/tests/unit/fitting/minimizers/test_minimizer_dfo.py @@ -97,74 +97,6 @@ def test_fit_passes_callback_to_model_builder(self, minimizer: DFO) -> None: minimizer._make_model.assert_called_once_with(callback=callback) - def test_fit_wraps_supplied_model_with_explicit_callback(self, minimizer: DFO) -> None: - from easyscience import global_object - - global_object.stack.enabled = False - - supplied_model = MagicMock() - wrapped_model = MagicMock() - explicit_callback = MagicMock() - - minimizer._make_model = MagicMock() - minimizer._wrap_model_with_callback = MagicMock(return_value=wrapped_model) - minimizer._get_callback_parameter_names = MagicMock(return_value=['palpha']) - minimizer._dfo_fit = MagicMock(return_value='fit') - minimizer._set_parameter_fit_result = MagicMock() - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') - minimizer._cached_pars = {'alpha': MagicMock(value=1.0)} - - result = minimizer.fit( - x=np.array([1.0]), - y=np.array([2.0]), - weights=np.array([1.0]), - model=supplied_model, - callback=explicit_callback, - ) - - assert result == 'gen_fit_results' - minimizer._make_model.assert_not_called() - minimizer._wrap_model_with_callback.assert_called_once_with( - supplied_model, - ['palpha'], - explicit_callback, - ) - minimizer._dfo_fit.assert_called_once_with( - minimizer._cached_pars, - wrapped_model, - user_params={'logging.save_diagnostic_info': True}, - ) - - def test_fit_uses_supplied_model_without_callback(self, minimizer: DFO) -> None: - from easyscience import global_object - - global_object.stack.enabled = False - - supplied_model = MagicMock() - - minimizer._make_model = MagicMock() - minimizer._wrap_model_with_callback = MagicMock() - minimizer._dfo_fit = MagicMock(return_value='fit') - minimizer._set_parameter_fit_result = MagicMock() - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') - minimizer._cached_pars = {'alpha': MagicMock(value=1.0)} - - result = minimizer.fit( - x=np.array([1.0]), - y=np.array([2.0]), - weights=np.array([1.0]), - model=supplied_model, - ) - - assert result == 'gen_fit_results' - minimizer._make_model.assert_not_called() - minimizer._wrap_model_with_callback.assert_not_called() - minimizer._dfo_fit.assert_called_once_with( - minimizer._cached_pars, - supplied_model, - user_params={'logging.save_diagnostic_info': True}, - ) - def test_generate_fit_function(self, minimizer: DFO) -> None: # When minimizer._original_fit_function = MagicMock(return_value='fit_function_result') @@ -751,13 +683,6 @@ def test_progress_callback_not_used_when_explicit_callback_given(self, minimizer call_kwargs = minimizer._make_model.call_args[1] assert call_kwargs['callback'] is explicit_cb - def test_get_callback_parameter_names_from_cache(self, minimizer: DFO) -> None: - minimizer._cached_pars = {'beta': MagicMock(value=1.0), 'gamma': MagicMock(value=2.0)} - - parameter_names = minimizer._get_callback_parameter_names() - - assert parameter_names == ['pbeta', 'pgamma'] - def test_wrap_model_with_callback_invokes_on_each_evaluation(self, minimizer: DFO) -> None: callback = MagicMock() wrapped_model = minimizer._wrap_model_with_callback( diff --git a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py index ce24dc27..b187ce50 100644 --- a/tests/unit/fitting/minimizers/test_minimizer_lmfit.py +++ b/tests/unit/fitting/minimizers/test_minimizer_lmfit.py @@ -138,24 +138,6 @@ def test_fit(self, minimizer: LMFit) -> None: minimizer._set_parameter_fit_result.assert_called_once_with('fit', False) minimizer._gen_fit_results.assert_called_once_with('fit', iterations=None) - def test_fit_model(self, minimizer: LMFit) -> None: - # When - mock_model = MagicMock() - mock_model.fit = MagicMock(return_value='fit') - minimizer._make_model = MagicMock(return_value=mock_model) - minimizer._set_parameter_fit_result = MagicMock() - minimizer._gen_fit_results = MagicMock(return_value='gen_fit_results') - - # Then - minimizer.fit(x=1.0, y=2.0, weights=1, model=mock_model) - - # Expect - mock_model.fit.assert_called_once_with( - 2.0, x=1.0, weights=1, max_nfev=None, iter_cb=ANY, fit_kws={}, method='leastsq' - ) - assert callable(mock_model.fit.call_args.kwargs['iter_cb']) - minimizer._make_model.assert_not_called() - def test_fit_method(self, minimizer: LMFit) -> None: # When mock_model = MagicMock() @@ -271,9 +253,7 @@ def test_fit_progress_callback(self, minimizer: LMFit) -> None: iter_cb = mock_model.fit.call_args.kwargs['iter_cb'] assert callable(iter_cb) - def test_fit_progress_callback_with_supplied_model_uses_iter_params( - self, minimizer: LMFit - ) -> None: + def test_fit_progress_callback_uses_iter_params(self, minimizer: LMFit) -> None: progress_callback = MagicMock(return_value=True) mock_model = MagicMock() mock_param_alpha = MagicMock() @@ -294,12 +274,10 @@ def test_fit_progress_callback_with_supplied_model_uses_iter_params( x=np.array([1.0, 2.0]), y=np.array([1.0, 2.0]), weights=np.array([1.0, 1.0]), - model=mock_model, progress_callback=progress_callback, ) assert result == 'gen_fit_results' - minimizer._make_model.assert_not_called() payload = progress_callback.call_args[0][0] assert payload['parameter_values'] == {'alpha': 1.0} assert payload['chi2'] == 25.0 @@ -427,36 +405,6 @@ def test_gen_fit_results_success_does_not_warn( assert len(caplog.records) == 0 assert result.success is True - def test_convert_to_par_object(self, minimizer: LMFit, monkeypatch) -> None: - # When - mock_lm_parameter = MagicMock() - mock_LMParameter = MagicMock(return_value=mock_lm_parameter) - monkeypatch.setattr( - easyscience.fitting.minimizers.minimizer_lmfit, 'LMParameter', mock_LMParameter - ) - - mock_parm = MagicMock(Parameter) - mock_parm.value = 1.0 - mock_parm.fixed = True - mock_parm.min = -10.0 - mock_parm.max = 10.0 - mock_parm.unique_name = 'key_converted' - - # Then - par = minimizer.convert_to_par_object(mock_parm) - - # Expect - assert par == mock_lm_parameter - mock_LMParameter.assert_called_once_with( - 'pkey_converted', - value=1.0, - vary=False, - min=-10.0, - max=10.0, - expr=None, - brute_step=None, - ) - def test_set_parameter_fit_result_no_stack_status(self, minimizer: LMFit) -> None: # When minimizer._cached_pars = { From 82a1b010a3ae58a1d034f7a7f190d2377496a749 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 11 Sep 2026 09:23:44 +0200 Subject: [PATCH 18/25] PR review issues addressed #3 --- src/easyscience/fitting/engine_base.py | 80 ++++++++++--------- .../minimizers/bumps_utils/__init__.py | 1 - .../fitting/minimizers/minimizer_bumps.py | 3 +- .../fitting/minimizers/minimizer_dfo.py | 3 +- .../fitting/minimizers/minimizer_lmfit.py | 3 +- src/easyscience/fitting/sampler.py | 55 +++++-------- src/easyscience/fitting/samplers/__init__.py | 1 + .../fitting/samplers/sampler_bumps.py | 19 ++--- .../bumps_utils => samplers}/validation.py | 0 .../fitting/samplers/test_sampler_bumps.py | 7 +- .../test_validation.py | 2 +- tests/unit/fitting/test_engine_base.py | 7 +- tests/unit/fitting/test_sampler.py | 49 ++++++------ 13 files changed, 113 insertions(+), 117 deletions(-) rename src/easyscience/fitting/{minimizers/bumps_utils => samplers}/validation.py (100%) rename tests/unit/fitting/{minimizers/bumps_utils => samplers}/test_validation.py (95%) diff --git a/src/easyscience/fitting/engine_base.py b/src/easyscience/fitting/engine_base.py index 5cb12513..edfd58e6 100644 --- a/src/easyscience/fitting/engine_base.py +++ b/src/easyscience/fitting/engine_base.py @@ -16,42 +16,6 @@ PARAMETER_PREFIX = 'p' -def validate_arrays(x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> None: - """Validate the (x, y, weights) arrays handed to an engine. - - Parameters - ---------- - x : np.ndarray - Independent variable array. - y : np.ndarray - Dependent variable array. - weights : np.ndarray - Weight array. - - Raises - ------ - ValueError - If the shapes disagree, x or y contain NaN or infinite values, or - the weights are non-finite or non-positive. - """ - if y.shape != x.shape: - raise ValueError('x and y must have the same shape.') - - if not np.isfinite(x).all(): - raise ValueError('x cannot contain NaN or infinite values.') - if not np.isfinite(y).all(): - raise ValueError('y cannot contain NaN or infinite values.') - - if weights.shape != x.shape: - raise ValueError('Weights must have the same shape as x and y.') - - if not np.isfinite(weights).all(): - raise ValueError('Weights cannot be NaN or infinite.') - - if (weights <= 0).any(): - raise ValueError('Weights must be strictly positive and non-zero.') - - class EngineBase(metaclass=ABCMeta): """ Base for all evaluation engines: minimizers and samplers. @@ -83,6 +47,50 @@ def _restore_parameter_values(self) -> None: self._cached_pars[key].value = self._cached_pars_vals[key][0] self._cached_pars[key].error = self._cached_pars_vals[key][1] + @staticmethod + def validate_arrays(x: np.ndarray, y: np.ndarray, weights: np.ndarray) -> None: + """Validate the (x, y, weights) arrays handed to an engine. + + Parameters + ---------- + x : np.ndarray + Independent variable array. + y : np.ndarray + Dependent variable array. + weights : np.ndarray + Weight array. + + Raises + ------ + ValueError + If any array is non-numeric, the shapes disagree, x or y contain + NaN or infinite values, or the weights are non-finite or + non-positive. + """ + # A non-numeric dtype (an object array holding None, or a string + # array) would otherwise reach np.isfinite and raise an opaque numpy + # TypeError instead of the ValueError documented here. + for name, arr in (('x', x), ('y', y), ('weights', weights)): + if not np.issubdtype(arr.dtype, np.number): + raise ValueError(f'{name} must hold numeric values, got dtype {arr.dtype}.') + + if y.shape != x.shape: + raise ValueError('x and y must have the same shape.') + + if not np.isfinite(x).all(): + raise ValueError('x cannot contain NaN or infinite values.') + if not np.isfinite(y).all(): + raise ValueError('y cannot contain NaN or infinite values.') + + if weights.shape != x.shape: + raise ValueError('Weights must have the same shape as x and y.') + + if not np.isfinite(weights).all(): + raise ValueError('Weights cannot be NaN or infinite.') + + if (weights <= 0).any(): + raise ValueError('Weights must be strictly positive and non-zero.') + def evaluate( self, x: np.ndarray, parameters: dict[str, float] | None = None, **kwargs ) -> np.ndarray: diff --git a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py index 88e8cb9a..82b5dfc6 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py @@ -7,4 +7,3 @@ from .problem import parameter_snapshot from .problem import to_bumps_parameter from .progress_monitor import BumpsProgressMonitor -from .validation import validate_run_settings diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 8fd6ee9b..34e4587b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -21,7 +21,6 @@ from ..available_minimizers import AvailableMinimizers from ..engine_base import PARAMETER_PREFIX -from ..engine_base import validate_arrays from .bumps_utils import BumpsProgressMonitor from .bumps_utils import EvalCounter from .bumps_utils import build_curve_problem @@ -162,7 +161,7 @@ def fit( x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - validate_arrays(x, y, weights) + self.validate_arrays(x, y, weights) if progress_callback is not None and not callable(progress_callback): raise ValueError('progress_callback must be callable') diff --git a/src/easyscience/fitting/minimizers/minimizer_dfo.py b/src/easyscience/fitting/minimizers/minimizer_dfo.py index 2457c3a6..21cc2a1b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -16,7 +16,6 @@ from ..available_minimizers import AvailableMinimizers from ..engine_base import PARAMETER_PREFIX -from ..engine_base import validate_arrays from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -125,7 +124,7 @@ def fit( """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - validate_arrays(x, y, weights) + self.validate_arrays(x, y, weights) # Bridge progress_callback into the DFO callback mechanism if progress_callback is not None and callback is None: diff --git a/src/easyscience/fitting/minimizers/minimizer_lmfit.py b/src/easyscience/fitting/minimizers/minimizer_lmfit.py index 35f1a04d..ea226b5e 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -17,7 +17,6 @@ from ..available_minimizers import AvailableMinimizers from ..engine_base import PARAMETER_PREFIX -from ..engine_base import validate_arrays from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -136,7 +135,7 @@ def fit( """ x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) - validate_arrays(x, y, weights) + self.validate_arrays(x, y, weights) if engine_kwargs is None: engine_kwargs = {} diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index ce75f73f..345fe97b 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -51,7 +51,6 @@ def _data_fingerprint( def _validate_dataset_arrays( name: str, data: np.ndarray | list | tuple, - allow_none_entries: bool = False, ) -> None: """Check that ``data`` (an array or list of arrays) holds numeric, at-least-1-D, non-empty arrays. @@ -67,9 +66,6 @@ def _validate_dataset_arrays( ``'weights'``). data : np.ndarray | list | tuple A single dataset array, or a list/tuple of dataset arrays. - allow_none_entries : bool, default=False - Accept ``None`` entries inside a list (used for per-dataset - optional weights). Raises ------ @@ -81,8 +77,6 @@ def _validate_dataset_arrays( is_multi = isinstance(data, (list, tuple)) for i, entry in enumerate(data if is_multi else [data]): label = f'{name}[{i}]' if is_multi else name - if entry is None and allow_none_entries: - continue try: arr = np.asarray(entry) except Exception as exc: @@ -108,8 +102,6 @@ def _copy_data(data): in-place mutation of the copies, so the chain and the ``save()`` fingerprint always describe the data actually sampled. """ - if data is None: - return None if isinstance(data, (list, tuple)): return [_copy_data(d) for d in data] arr = np.array(data, copy=True) @@ -282,8 +274,10 @@ class Sampler: Independent variable array (or list of arrays for ``MultiFitter``). y : np.ndarray | list[np.ndarray] Dependent variable array (or list of arrays for ``MultiFitter``). - weights : np.ndarray | list[np.ndarray | None] | None, default=None - Weight array (or list of arrays for ``MultiFitter``). + weights : np.ndarray | list[np.ndarray] + Weight array (or list of arrays for ``MultiFitter``). Required: + sampling has no default weighting, so a missing weight array is + rejected here rather than deep inside the sampling engine. vectorized : bool, default=False When ``True``, each x array may be multi-dimensional (e.g. an ``(N, M, 2)`` grid for a 2D model) and is left as-is. @@ -295,7 +289,7 @@ class Sampler: Raises ------ TypeError - If ``fitter`` is not Fitter-shaped (no ``minimizer``/``fit_function``), + If ``fitter`` is not Fitter-shaped (no ``fit_function``), if any dataset in ``x``/``y``/``weights`` is not a numeric array (e.g. a string), or ``vectorized``/``sampler_kwargs`` have the wrong type. @@ -349,11 +343,11 @@ def __init__( fitter: 'Fitter', x: np.ndarray | list[np.ndarray], y: np.ndarray | list[np.ndarray], - weights: np.ndarray | list[np.ndarray | None] | None = None, + weights: np.ndarray | list[np.ndarray], vectorized: bool = False, sampler_kwargs: dict | None = None, ): - if not (hasattr(fitter, 'minimizer') and hasattr(fitter, 'fit_function')): + if not hasattr(fitter, 'fit_function'): raise TypeError( f'fitter must be a configured Fitter or MultiFitter, got {type(fitter).__name__}.' ) @@ -364,20 +358,18 @@ def __init__( raise ValueError( f'x and y must hold the same number of datasets, got {len(x)} and {len(y)}.' ) - if weights is not None: - if isinstance(weights, (list, tuple)) != x_is_multi: - raise ValueError( - 'weights must match the structure of x and y (array or list of arrays).' - ) - if x_is_multi and len(weights) != len(x): - raise ValueError( - f'weights must hold the same number of datasets as x and y, ' - f'got {len(weights)} and {len(x)}.' - ) + if isinstance(weights, (list, tuple)) != x_is_multi: + raise ValueError( + 'weights must match the structure of x and y (array or list of arrays).' + ) + if x_is_multi and len(weights) != len(x): + raise ValueError( + f'weights must hold the same number of datasets as x and y, ' + f'got {len(weights)} and {len(x)}.' + ) _validate_dataset_arrays('x', x) _validate_dataset_arrays('y', y) - if weights is not None: - _validate_dataset_arrays('weights', weights, allow_none_entries=True) + _validate_dataset_arrays('weights', weights) if not isinstance(vectorized, bool): raise TypeError(f'vectorized must be a bool, got {type(vectorized).__name__}.') if sampler_kwargs is not None and not isinstance(sampler_kwargs, dict): @@ -413,8 +405,8 @@ def y(self) -> np.ndarray | list[np.ndarray]: return list(self._y) if isinstance(self._y, list) else self._y @property - def weights(self) -> np.ndarray | list[np.ndarray | None] | None: - """The bound weight data (read-only copy, or None).""" + def weights(self) -> np.ndarray | list[np.ndarray]: + """The bound weight data (read-only copy).""" return list(self._weights) if isinstance(self._weights, list) else self._weights @property @@ -446,12 +438,9 @@ def _fingerprint(self) -> str | None: """SHA-256 fingerprint of the bound (x, y, weights) data, or None.""" x_list = list(self._x) if isinstance(self._x, (list, tuple)) else [self._x] y_list = list(self._y) if isinstance(self._y, (list, tuple)) else [self._y] - if self._weights is None: - w_list = [] - elif isinstance(self._weights, (list, tuple)): - w_list = [w for w in self._weights if w is not None] - else: - w_list = [self._weights] + w_list = ( + list(self._weights) if isinstance(self._weights, (list, tuple)) else [self._weights] + ) return _data_fingerprint(x_list, y_list, w_list) def _run( diff --git a/src/easyscience/fitting/samplers/__init__.py b/src/easyscience/fitting/samplers/__init__.py index a5870f75..a617b861 100644 --- a/src/easyscience/fitting/samplers/__init__.py +++ b/src/easyscience/fitting/samplers/__init__.py @@ -2,3 +2,4 @@ # SPDX-License-Identifier: BSD-3-Clause from .sampler_bumps import DreamSampler +from .validation import validate_run_settings diff --git a/src/easyscience/fitting/samplers/sampler_bumps.py b/src/easyscience/fitting/samplers/sampler_bumps.py index aa4ac444..b9722066 100644 --- a/src/easyscience/fitting/samplers/sampler_bumps.py +++ b/src/easyscience/fitting/samplers/sampler_bumps.py @@ -15,13 +15,12 @@ from ..engine_base import PARAMETER_PREFIX from ..engine_base import EngineBase -from ..engine_base import validate_arrays from ..minimizers.bumps_utils import BumpsProgressMonitor from ..minimizers.bumps_utils import build_curve_problem from ..minimizers.bumps_utils import parameter_names from ..minimizers.bumps_utils import parameter_snapshot -from ..minimizers.bumps_utils import validate_run_settings from ..minimizers.utils import FitError +from .validation import validate_run_settings if TYPE_CHECKING: from bumps.dream.state import MCMCDraw @@ -65,7 +64,7 @@ def run( self, x: np.ndarray, y: np.ndarray, - weights: np.ndarray | None, + weights: np.ndarray, samples: int = 10000, burn: int = 2000, thin: int = 10, @@ -90,9 +89,10 @@ def run( Flattened independent variable array. y : np.ndarray Flattened dependent variable array. - weights : np.ndarray | None - Flattened weight array. Must not be ``None``: sampling has - no default weighting; a clear ``ValueError`` is raised. + weights : np.ndarray + Flattened weight array. Sampling has no default weighting, + so weights are required; ``Sampler`` enforces this at + construction. samples : int, default=10000 Number of raw samples to draw across all chains, before thinning. A guaranteed minimum, not an exact count: DREAM advances in @@ -163,15 +163,10 @@ def run( """ from bumps.fitters import DreamFit - if weights is None: - raise ValueError( - 'weights must not be None for Bayesian sampling. Pass ' - 'measurement weights (e.g. ``1 / sigma``) matching x and y.' - ) x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) validate_run_settings(samples, burn, thin) - validate_arrays(x, y, weights) + self.validate_arrays(x, y, weights) # Build the BUMPS Curve model around the engine's wrapped fit function problem, _, _ = build_curve_problem(self, x, y, weights) diff --git a/src/easyscience/fitting/minimizers/bumps_utils/validation.py b/src/easyscience/fitting/samplers/validation.py similarity index 100% rename from src/easyscience/fitting/minimizers/bumps_utils/validation.py rename to src/easyscience/fitting/samplers/validation.py diff --git a/tests/unit/fitting/samplers/test_sampler_bumps.py b/tests/unit/fitting/samplers/test_sampler_bumps.py index 747ffc7e..76219444 100644 --- a/tests/unit/fitting/samplers/test_sampler_bumps.py +++ b/tests/unit/fitting/samplers/test_sampler_bumps.py @@ -152,9 +152,10 @@ def test_run_invalid_data(self, engine: DreamSampler, overrides, match) -> None: engine.run(**data, samples=10, burn=0, thin=1) def test_run_rejects_none_weights(self, engine: DreamSampler) -> None: - """weights=None gets a clear ValueError instead of a shape error - from ``np.asarray(None)`` (CR-5).""" - with pytest.raises(ValueError, match='weights must not be None'): + """weights are required; a None slipping past ``Sampler`` still gets a + clear ValueError from the dtype guard rather than an opaque numpy + TypeError out of ``np.isfinite`` (CR-5).""" + with pytest.raises(ValueError, match='weights must hold numeric values'): engine.run( x=np.array([1.0]), y=np.array([0.1]), diff --git a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py b/tests/unit/fitting/samplers/test_validation.py similarity index 95% rename from tests/unit/fitting/minimizers/bumps_utils/test_validation.py rename to tests/unit/fitting/samplers/test_validation.py index 481e767e..6a4b5dc0 100644 --- a/tests/unit/fitting/minimizers/bumps_utils/test_validation.py +++ b/tests/unit/fitting/samplers/test_validation.py @@ -4,7 +4,7 @@ import pytest -from easyscience.fitting.minimizers.bumps_utils import validate_run_settings +from easyscience.fitting.samplers.validation import validate_run_settings class TestValidateRunSettings: diff --git a/tests/unit/fitting/test_engine_base.py b/tests/unit/fitting/test_engine_base.py index 22676861..92886ccf 100644 --- a/tests/unit/fitting/test_engine_base.py +++ b/tests/unit/fitting/test_engine_base.py @@ -5,7 +5,9 @@ import numpy as np import pytest -from easyscience.fitting.engine_base import validate_arrays +from easyscience.fitting.engine_base import EngineBase + +validate_arrays = EngineBase.validate_arrays class TestValidateArrays: @@ -30,6 +32,9 @@ def _data(): ({'weights': np.array([1.0, np.inf])}, 'Weights cannot be NaN'), ({'weights': np.array([1.0, 0.0])}, 'Weights must be strictly positive'), ({'weights': np.array([1.0, -1.0])}, 'Weights must be strictly positive'), + ({'x': np.array([1.0, None], dtype=object)}, 'x must hold numeric values'), + ({'y': np.array(['a', 'b'])}, 'y must hold numeric values'), + ({'weights': np.asarray(None)}, 'weights must hold numeric values'), ], ) def test_invalid_arrays_raise(self, overrides, match): diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 16745ed0..47136b11 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -39,7 +39,6 @@ def __call__(self, x): class _StubFitter: """Duck-types the Fitter attributes checked by the Sampler constructor.""" - minimizer = None fit_function = None @@ -94,20 +93,27 @@ def _make_state(ngen=6, npop=5, nvar=2, seed=7): class TestSamplerConstructorValidation: - def test_rejects_fitter_without_minimizer(self): + def test_rejects_fitter_without_fit_function(self): x, y, w = _xyw() with pytest.raises(TypeError, match='fitter must be a configured Fitter'): Sampler(object(), [x], [y], [w]) + def test_requires_weights(self): + """Sampling has no default weighting, so weights are a required + argument rather than a None that only blows up at sample().""" + x, y, _ = _xyw() + with pytest.raises(TypeError, match='weights'): + Sampler(_StubFitter(), [x], [y]) + def test_rejects_mixed_array_and_list(self): x, y, w = _xyw() with pytest.raises(ValueError, match='both be arrays or both be lists'): - Sampler(_StubFitter(), [x], y) + Sampler(_StubFitter(), [x], y, [w]) def test_rejects_dataset_count_mismatch(self): x, y, w = _xyw() with pytest.raises(ValueError, match='same number of datasets'): - Sampler(_StubFitter(), [x, x], [y]) + Sampler(_StubFitter(), [x, x], [y], [w, w]) def test_rejects_weights_structure_mismatch(self): x, y, w = _xyw() @@ -145,11 +151,6 @@ def test_properties_expose_bound_data(self): np.testing.assert_array_equal(sampler.y[0], y) np.testing.assert_array_equal(sampler.weights[0], w) - def test_weights_property_none_when_unset(self): - x, y, _ = _xyw() - sampler = Sampler(_StubFitter(), [x], [y]) - assert sampler.weights is None - def test_inputs_are_copied(self): """Mutating the caller's arrays after construction must not change the bound data (nor the save() fingerprint derived from it).""" @@ -353,51 +354,51 @@ class TestSamplerConstructorDataValidation: """Scalars, strings and empty/ragged data must be rejected at construction.""" def test_rejects_scalar_x(self): - _, y, _ = _xyw() + _, y, w = _xyw() with pytest.raises(ValueError, match='x must be an array of values, got a scalar'): - Sampler(_StubFitter(), 5.0, y) + Sampler(_StubFitter(), 5.0, y, w) def test_rejects_scalar_dataset_in_list(self): - x, y, _ = _xyw() + x, y, w = _xyw() with pytest.raises(ValueError, match=r'y\[1\] must be an array of values'): - Sampler(_StubFitter(), [x, x], [y, 3.0]) + Sampler(_StubFitter(), [x, x], [y, 3.0], [w, w]) def test_rejects_string_data(self): - x, _, _ = _xyw() + x, _, w = _xyw() with pytest.raises(TypeError, match='y must hold numeric values'): - Sampler(_StubFitter(), x, 'abc') + Sampler(_StubFitter(), x, 'abc', w) def test_rejects_non_numeric_object_array(self): _, y, _ = _xyw() with pytest.raises(TypeError, match='x must hold numeric values'): - Sampler(_StubFitter(), np.array([{}, {}], dtype=object), y) + Sampler(_StubFitter(), np.array([{}, {}], dtype=object), y, np.ones(2)) def test_rejects_empty_array(self): with pytest.raises(ValueError, match='x must not be empty'): - Sampler(_StubFitter(), np.array([]), np.array([])) + Sampler(_StubFitter(), np.array([]), np.array([]), np.array([])) def test_rejects_ragged_dataset(self): with pytest.raises(TypeError, match=r'x\[0\] could not be converted'): - Sampler(_StubFitter(), [[1.0, [2.0, 3.0]]], [np.zeros(3)]) + Sampler(_StubFitter(), [[1.0, [2.0, 3.0]]], [np.zeros(3)], [np.ones(3)]) def test_rejects_scalar_weights(self): x, y, _ = _xyw() with pytest.raises(ValueError, match='weights must be an array of values'): Sampler(_StubFitter(), x, y, 2.0) - def test_weights_list_may_hold_none_entries(self): + def test_rejects_none_weight_entry(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x, x], [y, y], [w, None]) - assert sampler.weights[1] is None + with pytest.raises(TypeError, match=r'weights\[1\] must hold numeric values'): + Sampler(_StubFitter(), [x, x], [y, y], [w, None]) class TestDataFingerprint: def test_returns_none_on_unhashable_data(self): assert _data_fingerprint([object()], [], []) is None - def test_fingerprint_without_weights(self): - x, y, _ = _xyw() - sampler = Sampler(_StubFitter(), x, y) + def test_fingerprint_of_single_arrays(self): + x, y, w = _xyw() + sampler = Sampler(_StubFitter(), x, y, w) assert isinstance(sampler._fingerprint(), str) From 4c3a8b4e243fb464d781800826e56830e4b3cad6 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 11 Sep 2026 12:56:54 +0200 Subject: [PATCH 19/25] PR issues addressed --- docs/docs/api-reference/index.md | 3 +- src/easyscience/base_classes/based_base.py | 6 +-- src/easyscience/io/serializer_base.py | 4 +- src/easyscience/io/serializer_component.py | 4 +- src/easyscience/job/theoreticalmodel.py | 4 +- .../variable/descriptor_any_type.py | 8 ++-- src/easyscience/variable/descriptor_array.py | 12 ++--- src/easyscience/variable/descriptor_base.py | 47 +------------------ src/easyscience/variable/descriptor_bool.py | 3 -- src/easyscience/variable/descriptor_number.py | 8 ++-- src/easyscience/variable/descriptor_str.py | 3 -- src/easyscience/variable/parameter.py | 5 -- .../unit/base_classes/test_collection_base.py | 8 ++-- tests/unit/base_classes/test_obj_base.py | 10 ++-- .../test_integration_comprehensive.py | 4 +- tests/unit/io/test_serializer_base.py | 2 +- tests/unit/io/test_serializer_component.py | 4 +- tests/unit/io/test_serializer_dict.py | 4 +- .../unit/variable/test_descriptor_any_type.py | 1 - tests/unit/variable/test_descriptor_array.py | 8 ---- tests/unit/variable/test_descriptor_base.py | 45 ------------------ tests/unit/variable/test_descriptor_bool.py | 2 - tests/unit/variable/test_descriptor_number.py | 5 -- tests/unit/variable/test_descriptor_str.py | 2 - tests/unit/variable/test_parameter.py | 3 -- ...test_parameter_dependency_serialization.py | 30 ++++++------ 26 files changed, 53 insertions(+), 182 deletions(-) diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index c6100bfe..1a035c29 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -9,8 +9,7 @@ available in EasyScience. - [base_classes](base_classes.md) – Core abstract and helper base classes used to build EasyScience objects (e.g. `NewBase`, - `ModelBase`, `EasyList`; the legacy `ObjBase` and `CollectionBase` are - deprecated). + `ModelBase`, `EasyList`). - [fitting](fitting.md) – Fitting utilities and interfaces, including `Fitter` and available minimizers. - [global_object](global_object.md) – Global singleton providing shared diff --git a/src/easyscience/base_classes/based_base.py b/src/easyscience/base_classes/based_base.py index 4044fb7b..7070f93a 100644 --- a/src/easyscience/base_classes/based_base.py +++ b/src/easyscience/base_classes/based_base.py @@ -239,11 +239,11 @@ def __dir__(self) -> Iterable[str]: def __copy__(self) -> BasedBase: """Return a copy of the object.""" - temp = self.as_dict(skip=['unique_name']) + temp = self.to_dict(skip=['unique_name']) new_obj = self.__class__.from_dict(temp) return new_obj - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ Convert an object into a full dictionary using ``SerializerDict``. This is a shortcut for @@ -266,4 +266,4 @@ def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: skip = [] if 'unique_name' not in skip: skip.append('unique_name') - return super().as_dict(skip=skip) + return super().to_dict(skip=skip) diff --git a/src/easyscience/io/serializer_base.py b/src/easyscience/io/serializer_base.py index 5d9b0e51..26b1ad72 100644 --- a/src/easyscience/io/serializer_base.py +++ b/src/easyscience/io/serializer_base.py @@ -215,13 +215,13 @@ def runner(o): err = False if err: raise NotImplementedError( - 'Unable to automatically determine as_dict ' + 'Unable to automatically determine to_dict ' 'format from class. MSONAble requires all ' 'args to be present as either self.argname or ' 'self._argname, and kwargs to be present under' 'a self.kwargs variable to automatically ' 'determine the dict format. Alternatively, ' - 'you can implement both as_dict and from_dict.' + 'you can implement both to_dict and from_dict.' ) d[c] = self._recursive_encoder( a, skip=skip, encoder=self, full_encode=full_encode, **kwargs diff --git a/src/easyscience/io/serializer_component.py b/src/easyscience/io/serializer_component.py index 10c5f7aa..9ef079ab 100644 --- a/src/easyscience/io/serializer_component.py +++ b/src/easyscience/io/serializer_component.py @@ -26,7 +26,7 @@ class SerializerComponent: """ def __deepcopy__(self, memo): - return self.from_dict(self.as_dict()) + return self.from_dict(self.to_dict()) def encode( self, skip: Optional[List[str]] = None, encoder: Optional[SerializerBase] = None, **kwargs @@ -82,7 +82,7 @@ def decode(cls, obj: Any, decoder: Optional[SerializerBase] = None) -> Any: decoder = SerializerDict return decoder.decode(obj) - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + def to_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ Convert an EasyScience object into a full dictionary using ``SerializerDict``. This is a shortcut for diff --git a/src/easyscience/job/theoreticalmodel.py b/src/easyscience/job/theoreticalmodel.py index 3fa87e50..3cddec7e 100644 --- a/src/easyscience/job/theoreticalmodel.py +++ b/src/easyscience/job/theoreticalmodel.py @@ -18,6 +18,6 @@ def __init__(self, name: str, *args, **kwargs): def __str__(self): raise NotImplementedError('Copy not implemented') - def as_dict(self, skip: list = []) -> dict: - this_dict = super().as_dict(skip=skip) + def to_dict(self, skip: list = []) -> dict: + this_dict = super().to_dict(skip=skip) return this_dict diff --git a/src/easyscience/variable/descriptor_any_type.py b/src/easyscience/variable/descriptor_any_type.py index d8830f1d..06f52dbf 100644 --- a/src/easyscience/variable/descriptor_any_type.py +++ b/src/easyscience/variable/descriptor_any_type.py @@ -34,7 +34,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, ): """ Constructor for the DescriptorAnyType class. @@ -42,9 +41,9 @@ def __init__( param name: Name of the descriptor param value: Value of the descriptor param description: Description of the descriptor param url: URL of the descriptor param display_name: Display - name of the descriptor param parent: Parent of the descriptor .. - note:: Undo/Redo functionality is implemented for the attributes - ``variance``, ``error``, ``unit`` and ``value``. + name of the descriptor .. note:: Undo/Redo functionality is + implemented for the attributes ``variance``, ``error``, + ``unit`` and ``value``. """ self._value = value @@ -55,7 +54,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, ) @property diff --git a/src/easyscience/variable/descriptor_array.py b/src/easyscience/variable/descriptor_array.py index e3542122..de6c0dac 100644 --- a/src/easyscience/variable/descriptor_array.py +++ b/src/easyscience/variable/descriptor_array.py @@ -42,7 +42,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, dimensions: Optional[list] = None, ): """ @@ -52,11 +51,11 @@ def __init__( the values of the descriptor param unit: Unit of the descriptor param variance: Variances of the descriptor param description: Description of the descriptor param url: URL of the descriptor - param display_name: Display name of the descriptor param parent: - Parent of the descriptor param dimensions: List of dimensions to - pass to scipp. Will be autogenerated if not supplied. .. note:: - Undo/Redo functionality is implemented for the attributes - ``variance``, ``error``, ``unit`` and ``value``. + param display_name: Display name of the descriptor param + dimensions: List of dimensions to pass to scipp. Will be + autogenerated if not supplied. .. note:: Undo/Redo functionality + is implemented for the attributes ``variance``, ``error``, + ``unit`` and ``value``. """ if not isinstance(value, (list, np.ndarray)): @@ -104,7 +103,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, ) # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency. diff --git a/src/easyscience/variable/descriptor_base.py b/src/easyscience/variable/descriptor_base.py index 452752e1..e2214d3a 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -27,27 +27,11 @@ class DescriptorBase(NewBase, metaclass=abc.ABCMeta): A ``Descriptor`` is typically something which describes part of a model and is non-fittable and generally changes the state of an object. - - ``DescriptorBase`` is a ``NewBase`` object. As such every descriptor - is registered in the global object map under its ``unique_name``, - has an optional ``display_name`` and is serialized with - ``to_dict``/``from_dict``. Descriptors and parameters can - therefore be held directly by an ``EasyList``. - - Following the ``NewBase`` design, a ``unique_name`` that was - generated automatically is *not* written by ``to_dict``; a - deserialized descriptor is simply assigned a fresh one. Only a - ``unique_name`` passed explicitly to the constructor is serialized. - - Descriptors no longer provide the ``SerializerComponent`` methods - ``encode``, ``decode`` and ``encode_data``. Use a serializer - directly instead, e.g. ``SerializerDict().encode(descriptor)`` and - ``SerializerDict.decode(data)``. """ _global_object = global_object # Used by serializer - _REDIRECT = {'parent': None} + _REDIRECT = {} def __init__( self, @@ -56,7 +40,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, ): """ This is the base of variables for models. @@ -81,9 +64,6 @@ def __init__( Lookup url for documentation/information. By default, None. display_name : Optional[str], default=None A pretty name for the object. By default, None. - parent : Optional[Any], default=None - The object which this descriptor is attached to. By default, - None. Raises ------ @@ -95,8 +75,6 @@ def __init__( if not isinstance(name, str): raise TypeError('Name must be a string') - # Registers the descriptor with the global object map and takes - # care of `unique_name` and `display_name`. super().__init__(unique_name=unique_name, display_name=display_name) self._name: str = name @@ -113,11 +91,6 @@ def __init__( url = '' self._url: str = url - self._parent = parent - # Make the connection between self and parent - if parent is not None: - global_object.map.add_edge(parent, self) - @property def name(self) -> str: """ @@ -263,21 +236,3 @@ def value(self, value: Any) -> None: @abc.abstractmethod def __repr__(self) -> str: """Return printable representation of the object.""" - - def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: - """ - Alias of ``NewBase.to_dict``, kept for backwards compatibility. - - Parameters - ---------- - skip : Optional[List[str]], default=None - List of field names as strings to skip when forming the - dictionary. By default, None. - - Returns - ------- - Dict[str, Any] - Encoded object containing all information to reform the - descriptor. - """ - return self.to_dict(skip=skip) diff --git a/src/easyscience/variable/descriptor_bool.py b/src/easyscience/variable/descriptor_bool.py index 05a09d9d..874509fc 100644 --- a/src/easyscience/variable/descriptor_bool.py +++ b/src/easyscience/variable/descriptor_bool.py @@ -3,7 +3,6 @@ from __future__ import annotations -from typing import Any from typing import Optional from easyscience.global_object.undo_redo import property_stack @@ -22,7 +21,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, ): if not isinstance(value, bool): raise ValueError(f'{value=} must be type bool') @@ -32,7 +30,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, ) if not isinstance(value, bool): raise TypeError(f'{value=} must be type bool') diff --git a/src/easyscience/variable/descriptor_number.py b/src/easyscience/variable/descriptor_number.py index 2aba019f..c2a97262 100644 --- a/src/easyscience/variable/descriptor_number.py +++ b/src/easyscience/variable/descriptor_number.py @@ -65,7 +65,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, **kwargs: Any, # Additional keyword arguments (used for (de)serialization) ): """ @@ -75,9 +74,9 @@ def __init__( descriptor param unit: Unit of the descriptor param variance: Variance of the descriptor param description: Description of the descriptor param url: URL of the descriptor param display_name: - Display name of the descriptor param parent: Parent of the - descriptor .. note:: Undo/Redo functionality is implemented for - the attributes ``variance``, ``error``, ``unit`` and ``value``. + Display name of the descriptor .. note:: Undo/Redo functionality + is implemented for the attributes ``variance``, ``error``, + ``unit`` and ``value``. """ self._observers: List[DescriptorNumber] = [] @@ -107,7 +106,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, ) # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency. diff --git a/src/easyscience/variable/descriptor_str.py b/src/easyscience/variable/descriptor_str.py index 5e74e786..e4b28823 100644 --- a/src/easyscience/variable/descriptor_str.py +++ b/src/easyscience/variable/descriptor_str.py @@ -3,7 +3,6 @@ from __future__ import annotations -from typing import Any from typing import Optional from easyscience.global_object.undo_redo import property_stack @@ -22,7 +21,6 @@ def __init__( description: Optional[str] = None, url: Optional[str] = None, display_name: Optional[str] = None, - parent: Optional[Any] = None, ): super().__init__( name=name, @@ -30,7 +28,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, ) if not isinstance(value, str): raise ValueError(f'{value=} must be type str') diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 3e5095c8..35654ed5 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -52,7 +52,6 @@ def __init__( url: Optional[str] = None, display_name: Optional[str] = None, callback: property = property(), - parent: Optional[Any] = None, **kwargs: Any, # Additional keyword arguments (used for (de)serialization) ): """ @@ -92,9 +91,6 @@ def __init__( callback : property, default=property() Callback used to synchronize the parameter with an external model. - parent : Optional[Any], default=None - The object which is the parent to this one. By default, - None. **kwargs : Any Additional keyword arguments used during serialization. @@ -147,7 +143,6 @@ def __init__( description=description, url=url, display_name=display_name, - parent=parent, **kwargs, # Additional keyword arguments (used for (de)serialization) ) diff --git a/tests/unit/base_classes/test_collection_base.py b/tests/unit/base_classes/test_collection_base.py index 732c3acf..8886a474 100644 --- a/tests/unit/base_classes/test_collection_base.py +++ b/tests/unit/base_classes/test_collection_base.py @@ -305,7 +305,7 @@ def test_CollectionBase_dir(cls): 'append', 'unique_name', 'index', - 'as_dict', + 'to_dict', 'clear', 'extend', 'encode', @@ -327,11 +327,11 @@ def test_CollectionBase_dir(cls): @pytest.mark.parametrize('cls', class_constructors) -def test_CollectionBase_as_dict(cls): +def test_CollectionBase_to_dict(cls): name = 'testing' kwargs = {'p1': DescriptorNumber('par1', 1)} obj = cls(name, **kwargs) - d = obj.as_dict() + d = obj.to_dict() def check_dict(dict_1: dict, dict_2: dict): keys_1 = list(dict_1.keys()) @@ -423,7 +423,7 @@ def test_CollectionBase_iterator_dict(cls): l_object = [p1, p2, p3, p4] obj = cls(name, *l_object) - d = obj.as_dict() + d = obj.to_dict() global_object.map._clear() obj2 = cls.from_dict(d) diff --git a/tests/unit/base_classes/test_obj_base.py b/tests/unit/base_classes/test_obj_base.py index 5eb1b2ea..723d617f 100644 --- a/tests/unit/base_classes/test_obj_base.py +++ b/tests/unit/base_classes/test_obj_base.py @@ -141,11 +141,11 @@ def test_ObjBase_fit_objects(setup_pars: dict): pass -def test_ObjBase_as_dict(clear, setup_pars: dict): +def test_ObjBase_to_dict(clear, setup_pars: dict): name = setup_pars['name'] del setup_pars['name'] obj = ObjBase(name, **setup_pars) - obtained = obj.as_dict() + obtained = obj.to_dict() assert isinstance(obtained, dict) expected = { '@module': 'easyscience.legacy.obj_base', @@ -238,7 +238,7 @@ def test_ObjBase_dict_roundtrip(clear, setup_pars: dict): name = setup_pars['name'] del setup_pars['name'] obj = ObjBase(name, **setup_pars, unique_name='special_name') - obj_dict = obj.as_dict() + obj_dict = obj.to_dict() global_object.map._clear() @@ -246,7 +246,7 @@ def test_ObjBase_dict_roundtrip(clear, setup_pars: dict): new_obj = ObjBase.from_dict(obj_dict) # Expect - new_obj_dict = new_obj.as_dict() + new_obj_dict = new_obj.to_dict() assert obj_dict == new_obj_dict @@ -257,7 +257,7 @@ def test_ObjBase_dir(setup_pars): expected = [ 'encode', 'decode', - 'as_dict', + 'to_dict', 'des1', 'des2', 'from_dict', diff --git a/tests/unit/global_object/test_integration_comprehensive.py b/tests/unit/global_object/test_integration_comprehensive.py index 61268954..541c9e46 100644 --- a/tests/unit/global_object/test_integration_comprehensive.py +++ b/tests/unit/global_object/test_integration_comprehensive.py @@ -361,8 +361,8 @@ def test_serialization_integration_with_global_state(self, clear_all): original_vertex_count = len(global_obj.map.vertices()) # When - Serialize objects - param_dict = param.as_dict() - obj_dict = obj.as_dict() + param_dict = param.to_dict() + obj_dict = obj.to_dict() # Clear global state global_obj.map._clear() diff --git a/tests/unit/io/test_serializer_base.py b/tests/unit/io/test_serializer_base.py index c3c8dd21..550c4d75 100644 --- a/tests/unit/io/test_serializer_base.py +++ b/tests/unit/io/test_serializer_base.py @@ -471,7 +471,7 @@ def __init__(self, name: str, missing_param: str = 'default'): obj = MockObjMissingAttrs('test') - with pytest.raises(NotImplementedError, match='Unable to automatically determine as_dict'): + with pytest.raises(NotImplementedError, match='Unable to automatically determine to_dict'): serializer._convert_to_dict(obj) def test_convert_to_dict_with_kwargs_attribute(self, serializer, clear): diff --git a/tests/unit/io/test_serializer_component.py b/tests/unit/io/test_serializer_component.py index c4e21f49..a90e364c 100644 --- a/tests/unit/io/test_serializer_component.py +++ b/tests/unit/io/test_serializer_component.py @@ -71,7 +71,7 @@ def check_dict(check, item): @pytest.mark.parametrize(**skip_dict) @pytest.mark.parametrize(**dp_param_dict) -def test_variable_as_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip): +def test_variable_to_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber], skip): data_dict = {k: v for k, v in dp_kwargs.items() if k[0] != '@'} obj = dp_cls(**data_dict) @@ -84,7 +84,7 @@ def test_variable_as_dict_methods(dp_kwargs: dict, dp_cls: Type[DescriptorNumber if not isinstance(skip, list): skip = [skip] - enc = obj.as_dict(skip=skip) + enc = obj.to_dict(skip=skip) expected_keys = set(dp_kwargs.keys()) obtained_keys = set(enc.keys()) diff --git a/tests/unit/io/test_serializer_dict.py b/tests/unit/io/test_serializer_dict.py index 5df61f7c..1b37fae9 100644 --- a/tests/unit/io/test_serializer_dict.py +++ b/tests/unit/io/test_serializer_dict.py @@ -106,7 +106,7 @@ def test_group_encode(): from easyscience.base_classes import CollectionBase b = CollectionBase('test', d0, d1) - d = b.as_dict() + d = b.to_dict() assert isinstance(d['data'], list) @@ -117,5 +117,5 @@ def test_group_encode2(): from easyscience.base_classes import CollectionBase b = ObjBase('outer', b=CollectionBase('test', d0, d1)) - d = b.as_dict() + d = b.to_dict() assert isinstance(d['b'], dict) diff --git a/tests/unit/variable/test_descriptor_any_type.py b/tests/unit/variable/test_descriptor_any_type.py index 5dc28b58..12db376a 100644 --- a/tests/unit/variable/test_descriptor_any_type.py +++ b/tests/unit/variable/test_descriptor_any_type.py @@ -17,7 +17,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor diff --git a/tests/unit/variable/test_descriptor_array.py b/tests/unit/variable/test_descriptor_array.py index 73bb0857..36ede2e3 100644 --- a/tests/unit/variable/test_descriptor_array.py +++ b/tests/unit/variable/test_descriptor_array.py @@ -23,7 +23,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -37,7 +36,6 @@ def descriptor_dimensionless(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -67,7 +65,6 @@ def test_init_sc_unit(self): description='description', url='url', display_name='display_name', - parent=None, ) # Expect @@ -86,7 +83,6 @@ def test_init_sc_unit_unknown(self): description='description', url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize('value', [True, 'string']) @@ -103,7 +99,6 @@ def test_init_value_type_exception(self, value): description='description', url='url', display_name='display_name', - parent=None, ) def test_init_variance_exception(self): @@ -119,7 +114,6 @@ def test_init_variance_exception(self): description='description', url='url', display_name='display_name', - parent=None, ) # test from_scipp @@ -1481,7 +1475,6 @@ def test_negation(self, descriptor): description='description', url='url', display_name='display_name', - parent=None, ) assert type(result) == DescriptorArray assert result.name == result.unique_name @@ -1500,7 +1493,6 @@ def test_abs(self, descriptor): description='description', url='url', display_name='display_name', - parent=None, ) # Then diff --git a/tests/unit/variable/test_descriptor_base.py b/tests/unit/variable/test_descriptor_base.py index c4498cf4..9661afa3 100644 --- a/tests/unit/variable/test_descriptor_base.py +++ b/tests/unit/variable/test_descriptor_base.py @@ -23,7 +23,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -44,7 +43,6 @@ def test_init_name_type_error(self, name): description='description', url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize( @@ -60,7 +58,6 @@ def test_init_display_name_type_error(self, display_name): description='description', url='url', display_name=display_name, - parent=None, ) @pytest.mark.parametrize( @@ -76,7 +73,6 @@ def test_init_description_type_error(self, description): description=description, url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize( @@ -92,7 +88,6 @@ def test_init_url_type_error(self, url): description='description', url=url, display_name='display_name', - parent=None, ) def test_init(self, descriptor: DescriptorBase): @@ -227,43 +222,3 @@ def test_unique_name_change_exception(self, input, descriptor: DescriptorBase): # When Then Expect with pytest.raises(TypeError): descriptor.unique_name = input - - def test_is_a_new_base(self, descriptor: DescriptorBase): - # When Then Expect - assert isinstance(descriptor, NewBase) - assert not isinstance(descriptor, SerializerComponent) - - def test_as_dict_is_an_alias_of_to_dict(self, descriptor: DescriptorBase): - # When Then Expect - assert descriptor.as_dict() == descriptor.to_dict() - assert descriptor.as_dict(skip=['url']) == descriptor.to_dict(skip=['url']) - - def test_to_dict_drops_generated_unique_name(self, clear): - """Descriptors follow the ``NewBase`` design: an auto-generated - unique_name is not serialized, so a decoded descriptor is given - a fresh one instead of colliding with the original.""" - # When - descriptor = DescriptorNumber(name='name', value=1.0) - - # Then Expect - assert descriptor._default_unique_name - assert 'unique_name' not in descriptor.to_dict() - - def test_to_dict_keeps_explicit_unique_name(self, clear): - """An explicitly supplied unique_name is still serialized.""" - # When - descriptor = DescriptorNumber(name='name', value=1.0, unique_name='explicit_name') - - # Then Expect - assert not descriptor._default_unique_name - assert descriptor.to_dict()['unique_name'] == 'explicit_name' - - def test_can_be_held_by_an_easy_list(self, clear): - """Descriptors are NewBase objects, so EasyList accepts them.""" - # When - descriptor = DescriptorNumber(name='name', value=1.0) - easy_list = EasyList(descriptor) - - # Then Expect - assert list(easy_list) == [descriptor] - assert easy_list[descriptor.unique_name] is descriptor diff --git a/tests/unit/variable/test_descriptor_bool.py b/tests/unit/variable/test_descriptor_bool.py index 3c181f0e..48372485 100644 --- a/tests/unit/variable/test_descriptor_bool.py +++ b/tests/unit/variable/test_descriptor_bool.py @@ -16,7 +16,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -45,7 +44,6 @@ def test_init_bool_value_type_exception(self, bool_value): description='description', url='url', display_name='display_name', - parent=None, ) def test_value(self, descriptor: DescriptorBool): diff --git a/tests/unit/variable/test_descriptor_number.py b/tests/unit/variable/test_descriptor_number.py index 6abd3029..3d3f7331 100644 --- a/tests/unit/variable/test_descriptor_number.py +++ b/tests/unit/variable/test_descriptor_number.py @@ -20,7 +20,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -51,7 +50,6 @@ def test_init_sc_unit(self): description='description', url='url', display_name='display_name', - parent=None, ) # Expect @@ -70,7 +68,6 @@ def test_init_sc_unit_unknown(self): description='description', url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize('value', [True, 'string']) @@ -87,7 +84,6 @@ def test_init_value_type_exception(self, value): description='description', url='url', display_name='display_name', - parent=None, ) def test_init_variance_exception(self): @@ -104,7 +100,6 @@ def test_init_variance_exception(self): description='description', url='url', display_name='display_name', - parent=None, ) # test from_scipp diff --git a/tests/unit/variable/test_descriptor_str.py b/tests/unit/variable/test_descriptor_str.py index 46acc847..36b721c1 100644 --- a/tests/unit/variable/test_descriptor_str.py +++ b/tests/unit/variable/test_descriptor_str.py @@ -16,7 +16,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -44,7 +43,6 @@ def test_init_string_type_exception(self, string): description='description', url='url', display_name='display_name', - parent=None, ) def test_value(self, descriptor: DescriptorStr): diff --git a/tests/unit/variable/test_parameter.py b/tests/unit/variable/test_parameter.py index 1ac05b45..5376edf3 100644 --- a/tests/unit/variable/test_parameter.py +++ b/tests/unit/variable/test_parameter.py @@ -29,7 +29,6 @@ def parameter(self) -> Parameter: url='url', display_name='display_name', callback=self.mock_callback, - parent=None, ) return parameter @@ -96,7 +95,6 @@ def test_init_value_min_exception(self): url='url', display_name='display_name', callback=mock_callback, - parent=None, ) def test_init_value_max_exception(self): @@ -117,7 +115,6 @@ def test_init_value_max_exception(self): url='url', display_name='display_name', callback=mock_callback, - parent=None, ) def test_make_dependent_on(self, normal_parameter: Parameter): diff --git a/tests/unit/variable/test_parameter_dependency_serialization.py b/tests/unit/variable/test_parameter_dependency_serialization.py index 0dddc5d5..98daed45 100644 --- a/tests/unit/variable/test_parameter_dependency_serialization.py +++ b/tests/unit/variable/test_parameter_dependency_serialization.py @@ -40,7 +40,7 @@ def test_independent_parameter_serialization(self, clear_global_map): param = Parameter(name='test', value=5.0, unit='m', min=0, max=10) # Serialize - serialized = param.as_dict() + serialized = param.to_dict() # Should not contain dependency fields assert '_dependency_string' not in serialized @@ -68,7 +68,7 @@ def test_dependent_parameter_serialization(self, clear_global_map): ) # Serialize dependent parameter - serialized = b.as_dict() + serialized = b.to_dict() # Should contain dependency information assert serialized['_dependency_string'] == '2 * a' @@ -106,7 +106,7 @@ def test_dependency_resolution_after_deserialization(self, clear_global_map): assert c.value == 5.0 # 2 + 3 # Serialize all parameters - params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()} + params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} # Clear and deserialize (manual approach) global_object.map._clear() @@ -151,7 +151,7 @@ def test_dependency_resolution_after_deserialization_desired_unit(self, clear_gl assert c.unit == 'cm' # Serialize all parameters - params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()} + params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} # Clear and deserialize (manual approach) global_object.map._clear() @@ -192,8 +192,8 @@ def test_unique_name_dependency_serialization(self, clear_global_map): ) # Serialize both parameters - a_serialized = a.as_dict() - b_serialized = b.as_dict() + a_serialized = a.to_dict() + b_serialized = b.to_dict() # Should contain unique name mapping assert b_serialized['_dependency_string'] == '2 * __Parameter_0__' @@ -237,9 +237,9 @@ def test_json_serialization_roundtrip(self, clear_global_map): # Serialize to JSON params_data = { - 'length': length.as_dict(), - 'width': width.as_dict(), - 'area': area.as_dict(), + 'length': length.to_dict(), + 'width': width.to_dict(), + 'area': area.to_dict(), } json_str = json.dumps(params_data, default=str) @@ -281,7 +281,7 @@ def test_multiple_dependent_parameters(self, clear_global_map): assert z.value == 6.0 # 4 + 2 # Serialize all - params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()} + params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()} # Deserialize and resolve global_object.map._clear() @@ -319,7 +319,7 @@ def test_dependency_with_descriptor_number(self, clear_global_map): # Then # Serialize all - params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()} + params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()} # Deserialize and resolve global_object.map._clear() new_params = {} @@ -350,7 +350,7 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map): ) # Serialize and deserialize - params_data = {'a': a.as_dict(), 'b': b.as_dict()} + params_data = {'a': a.to_dict(), 'b': b.to_dict()} global_object.map._clear() new_params = {} for name, data in params_data.items(): @@ -376,7 +376,7 @@ def test_error_handling_missing_dependency(self, clear_global_map): ) # Serialize b but not a - b_data = b.as_dict() + b_data = b.to_dict() # Deserialize without a in the global map global_object.map._clear() @@ -440,7 +440,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): y_dep_id = y._DescriptorNumber__serializer_id # Serialize all parameters - params_data = {'x': x.as_dict(), 'y': y.as_dict(), 'z': z.as_dict()} + params_data = {'x': x.to_dict(), 'y': y.to_dict(), 'z': z.to_dict()} # Verify dependency IDs are in serialized data assert params_data['x']['__serializer_id'] == x_dep_id @@ -492,7 +492,7 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): assert c.value == 5.0 # 2 + 3 # Serialize all parameters - params_data = {'a': a.as_dict(), 'b': b.as_dict(), 'c': c.as_dict()} + params_data = {'a': a.to_dict(), 'b': b.to_dict(), 'c': c.to_dict()} # Clear global map global_object.map._clear() From 2e9d9c233e6c95885bec0a8fc5fcfae21210c126 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Fri, 11 Sep 2026 13:09:50 +0200 Subject: [PATCH 20/25] PR review fixes --- src/easyscience/fitting/fitter.py | 2 +- src/easyscience/fitting/multi_fitter.py | 16 ++++-- tests/unit/fitting/test_multi_fitter.py | 69 +++++++++++-------------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index 3fd72c79..20d5160e 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -211,7 +211,7 @@ def fit_object(self) -> object: """ Get the EasyScience object used as a model. - For a ``MultiFitter`` this is not one of the supplied fit + For a ``Fitter`` this is not one of the supplied fit objects but a read-only, indexable and iterable aggregate. Returns diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index c2db4c25..dbafa3b6 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -28,14 +28,24 @@ def __init__( fit_objects: list | None = None, fit_functions: list[Callable] | None = None, ): + # Both arguments default to None so the constructor can be called + # empty; normalise to empty sequences so nothing below has to + # special-case None. + if fit_objects is None: + fit_objects = [] + if fit_functions is None: + fit_functions = [] # Aggregate the fit objects so a single object can be sent to Fitter. # *-unpacking keeps any sequence (list, tuple, etc) working, as the # old CollectionBase container did. self._fit_objects = EasyList(*fit_objects) - self._fit_functions = fit_functions + self._fit_functions = list(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]) + # not possible to change the fitting engine. With no functions given + # the Fitter is created with ``None``; the minimizer only stores the + # callable, so this is harmless until a fit is attempted. + first_fit_function = self._fit_functions[0] if self._fit_functions else None + super().__init__(self._fit_objects, first_fit_function) def _fit_function_wrapper( self, real_x: list[np.ndarray] | None = None, flatten: bool = True diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index 97e51c1d..1960affe 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -223,46 +223,15 @@ def test_weights_all_none_returns_none(self): assert w_new is None assert len(dims) == 2 - -# =================================================================== -# The EasyList container replacing the deprecated CollectionBase -# =================================================================== - - -class TestFitObjectContainer: - def test_no_collection_base_deprecation_warning(self, caplog): - """Building a MultiFitter must not warn about CollectionBase. - - The assertion is on message content rather than on the logger, - because other deprecated classes warn on the very same logger. - """ - fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] - - with caplog.at_level(logging.WARNING, logger='easyscience'): - MultiFitter(fit_objects, fit_objects) - - assert not [r for r in caplog.records if 'CollectionBase is deprecated' in r.message] + # =================================================================== + # The EasyList container replacing the deprecated CollectionBase + # =================================================================== def test_container_is_an_easy_list(self): fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] assert isinstance(MultiFitter(fit_objects, fit_objects).fit_object, EasyList) - def test_fit_objects_are_not_retyped(self): - """The container must not reclassify the caller's fit objects. - - The old CollectionBase dummy re-typed every fit object as - 'created_internal', hiding the caller's own objects from the - map's 'created' set. - """ - fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] - types_before = [global_object.map.find_type(obj) for obj in fit_objects] - - MultiFitter(fit_objects, fit_objects) - - assert [global_object.map.find_type(obj) for obj in fit_objects] == types_before - assert all('created_internal' not in types for types in types_before) - def test_sequence_contract(self): fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] container = MultiFitter(fit_objects, fit_objects).fit_object @@ -315,9 +284,31 @@ def test_accepts_fit_objects_as_a_tuple(self): assert list(container) == list(models) - def test_rejects_legacy_obj_base_fit_objects(self): - """Support for the deprecated ObjBase hierarchy was dropped.""" - legacy = [LegacyLine(1.0, 0.5), LegacyLine(2.0, 1.5)] + # =================================================================== + # Constructor defaults + # =================================================================== - with pytest.raises(TypeError, match='Items must be one of'): - MultiFitter(legacy, legacy) + def test_default_arguments_do_not_crash(self): + """Both arguments default to None; construction must not index into them.""" + mf = MultiFitter() + + assert isinstance(mf.fit_object, EasyList) + assert len(mf.fit_object) == 0 + assert mf._fit_functions == [] + assert mf.fit_function is None + + def test_none_arguments_are_treated_as_empty(self): + mf = MultiFitter(None, None) + + assert len(mf.fit_object) == 0 + assert mf._fit_functions == [] + assert mf.fit_function is None + + def test_accepts_tuple_arguments(self): + """Any sequence works thanks to *-unpacking, as with CollectionBase.""" + models = (Line(1.0, 0.5), Line(2.0, 1.5)) + mf = MultiFitter(models, models) + + assert list(mf.fit_object) == list(models) + assert mf._fit_functions == list(models) + assert mf.fit_function is models[0] From 03f86be3eeb2b926458883a484ce1b210e377a31 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Mon, 14 Sep 2026 09:32:26 +0200 Subject: [PATCH 21/25] fix the behaviour when parameters are passed --- src/easyscience/fitting/multi_fitter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index b8b89c3a..55933d4e 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -39,7 +39,7 @@ def __init__( # Aggregate the fit objects so a single object can be sent to Fitter. # *-unpacking keeps any sequence (list, tuple, etc) working, as the # old CollectionBase container did. - self._fit_objects = EasyList(*fit_objects) + self._fit_objects = EasyList(*fit_objects, protected_types=ModelBase) self._fit_functions = list(fit_functions) # Initialize with the first of the fit_functions, without this it is # not possible to change the fitting engine. With no functions given From f6460497436f5edb91a9cd3fd78936c207fb831a Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Tue, 15 Sep 2026 17:02:02 +0200 Subject: [PATCH 22/25] list -> sequence --- src/easyscience/fitting/multi_fitter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index dbafa3b6..afec15fd 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -1,6 +1,7 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from collections.abc import Sequence from typing import Callable import numpy as np @@ -25,8 +26,8 @@ class MultiFitter(Fitter): def __init__( self, - fit_objects: list | None = None, - fit_functions: list[Callable] | None = None, + fit_objects: Sequence | None = None, + fit_functions: Sequence[Callable] | None = None, ): # Both arguments default to None so the constructor can be called # empty; normalise to empty sequences so nothing below has to From 5f8bd2f911ca87ed66800a04b44ad3729b88747c Mon Sep 17 00:00:00 2001 From: rozyczko Date: Thu, 17 Sep 2026 11:46:47 +0200 Subject: [PATCH 23/25] Make Sampler completely Fitter-agnostic --- docs/docs/tutorials/fitting-bayesian.ipynb | 76 +++---- src/easyscience/fitting/fitter.py | 93 +------- src/easyscience/fitting/multi_fitter.py | 112 ++-------- src/easyscience/fitting/reshaping.py | 216 ++++++++++++++++++ src/easyscience/fitting/sampler.py | 196 +++++++++++------ tests/integration/fitting/test_sampler.py | 135 ++++++------ tests/unit/fitting/test_multi_fitter.py | 15 -- tests/unit/fitting/test_reshaping.py | 100 +++++++++ tests/unit/fitting/test_sampler.py | 241 +++++++++++++-------- 9 files changed, 715 insertions(+), 469 deletions(-) create mode 100644 src/easyscience/fitting/reshaping.py create mode 100644 tests/unit/fitting/test_reshaping.py diff --git a/docs/docs/tutorials/fitting-bayesian.ipynb b/docs/docs/tutorials/fitting-bayesian.ipynb index 302800c2..ed90222e 100644 --- a/docs/docs/tutorials/fitting-bayesian.ipynb +++ b/docs/docs/tutorials/fitting-bayesian.ipynb @@ -28,7 +28,7 @@ "\n", "where $\\theta$ are the model parameters, $d$ is the observed data, $p(d \\mid \\theta)$ is the likelihood, and $p(\\theta)$ is the prior. In `easyscience`, the `min`/`max` bounds of a `Parameter` are interpreted as a **uniform prior**, and a Gaussian likelihood is constructed from the data and supplied weights.\n", "\n", - "`easyscience` exposes a Bayesian Markov-chain Monte Carlo (MCMC) sampler through the `Sampler` class. Under the hood this uses BUMPS' DREAM sampler, so the underlying minimizer must be switched to BUMPS.\n", + "`easyscience` exposes a Bayesian Markov-chain Monte Carlo (MCMC) sampler through the `Sampler` class. It is a parallel entry point to `Fitter`: both take a model object and a model function, so you can sample without ever creating a `Fitter`. Under the hood `Sampler` uses BUMPS' DREAM sampler, so the `bumps` package must be installed.\n", "\n", "```{note}\n", "This tutorial focuses on Bayesian analysis with a simple QENS model for illustration. For dedicated QENS fitting with more sophisticated models, consider using [`EasyDynamics`](https://github.com/easyscience/easydynamics).\n", @@ -138,12 +138,12 @@ }, { "cell_type": "markdown", - "id": "9", + "id": "7", "metadata": {}, "source": [ "## Defining parameters with priors\n", "\n", - "Create four `Parameter` objects, for the area $A$, $\\gamma$, $\\omega_0$ and $\\sigma$. The `min` and `max` arguments define a **uniform prior** on each parameter — the sampler will only consider values inside this range and will treat every value inside the range as equally plausible *a priori*.\n", + "Create four `Parameter` objects, for the area $A$, $\\gamma$, $\\omega_0$ and $\\sigma$. The `min` and `max` arguments define a **uniform prior** on each parameter — the sampler will only consider values inside this range and will treat every value inside the range as equally plausible *a priori*. The parameters are then collected in an `ObjBase` container: this is the model object that both `Fitter` and `Sampler` take, so it is defined here, before the optional fit.\n", "\n", "| Parameter | Initial Value | Min | Max |\n", "| --- | --- | --- | --- |\n", @@ -156,21 +156,24 @@ { "cell_type": "code", "execution_count": null, - "id": "10", + "id": "8", "metadata": {}, "outputs": [], "source": [ + "from easyscience import ObjBase\n", "from easyscience import Parameter\n", "\n", "area = Parameter(name='area', value=10, fixed=False, min=1, max=100)\n", "gamma = Parameter(name='gamma', value=8e-3, fixed=False, min=1e-4, max=1e-2)\n", "omega_0 = Parameter(name='omega_0', value=1e-3, fixed=False, min=0, max=2e-3)\n", - "sigma = Parameter(name='sigma', value=1e-3, fixed=False, min=1e-5, max=1e-1)" + "sigma = Parameter(name='sigma', value=1e-3, fixed=False, min=1e-5, max=1e-1)\n", + "\n", + "parameter_container = ObjBase(name='params', A=area, gamma=gamma, omega_0=omega_0, sigma=sigma)" ] }, { "cell_type": "markdown", - "id": "77ce3f63", + "id": "9", "metadata": {}, "source": [ "## The model\n", @@ -193,7 +196,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36a0c4f4", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -218,8 +221,8 @@ "## Maximum-likelihood fit (optional, but recommended)\n", "\n", "Perform a quick maximum-likelihood fit. This is **not** a prerequisite for sampling: `Sampler`\n", - "needs a *configured* `Fitter`, not a *fitted* one, and you can sample straight from the initial\n", - "parameter values.\n", + "does not need a `Fitter` at all, and you can sample straight from the initial parameter\n", + "values.\n", "\n", "It is worth doing anyway, for two reasons:\n", "\n", @@ -239,9 +242,6 @@ "outputs": [], "source": [ "from easyscience import Fitter\n", - "from easyscience import ObjBase\n", - "\n", - "parameter_container = ObjBase(name='params', A=area, gamma=gamma, omega_0=omega_0, sigma=sigma)\n", "\n", "mle_fitter = Fitter(parameter_container, intensity_model)\n", "mle_result = mle_fitter.fit(x=omega, y=intensity_obs, weights=1 / intensity_error)\n", @@ -261,7 +261,7 @@ "\n", "We now draw samples from the posterior distribution $p(\\theta \\mid d)$ using the BUMPS DREAM (DiffeRential Evolution Adaptive Metropolis) algorithm. DREAM is an ensemble MCMC method that runs multiple chains in parallel and automatically tunes the proposal distribution.\n", "\n", - "DREAM only works with the BUMPS minimizer. We reuse the ``mle_fitter`` created above — any configured `Fitter` would do, and it does not have to have been fitted — switch it to BUMPS, and create a `Sampler` instance bound to the fitter and data. Calling `sampler.sample()` returns a `SamplingResults` object with the following attributes:\n", + "Create a `Sampler` from the same `parameter_container` and `intensity_model` we gave the `Fitter`, bound to the data. No `Fitter` is involved: the sampler only needs the model object, the model function, the data and the `bumps` package. Calling `sampler.sample()` returns a `SamplingResults` object with the following attributes:\n", "\n", "- `draws`: a `(n_samples, n_parameters)` array of posterior samples: each **row** is one complete draw from the joint posterior (one value for every parameter simultaneously), and each **column** holds all sampled values for a single parameter. Note this is a *trimmed* view of the chain rather than the raw buffer, so `n_samples` is smaller than `samples / thin` — see the note under [Extend the chain](#extend-the-chain-and-check-convergence);\n", "- `param_names`: the unique names of the parameters, in the same column order as `draws`;\n", @@ -274,7 +274,9 @@ "- `burn` (500): the number of initial *burn-in* generations to discard — the sampler needs time to find the typical set of the posterior, and early samples are not representative. Note this counts generations, not raw samples, so `burn=500` discards `500 × n_chains` raw samples;\n", "- `thin` (2): the *thinning* interval — only every second generation is kept, which reduces autocorrelation between consecutive draws;\n", "\n", - "First, we switch to the BUMPS minimizer:" + "```{note}\n", + "If you already have a `Fitter`, `Sampler.from_fitter(mle_fitter, omega, intensity_obs, weights=1 / intensity_error)` builds the same sampler from the model bound to it. The fitter is neither needed afterwards nor modified.\n", + "```" ] }, { @@ -283,22 +285,12 @@ "id": "14", "metadata": {}, "outputs": [], - "source": [ - "from easyscience import AvailableMinimizers\n", - "\n", - "mle_fitter.switch_minimizer(AvailableMinimizers.Bumps)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5a219fcd", - "metadata": {}, - "outputs": [], "source": [ "from easyscience.fitting import Sampler\n", "\n", - "sampler = Sampler(mle_fitter, omega, intensity_obs, weights=1 / intensity_error)\n", + "sampler = Sampler(\n", + " parameter_container, intensity_model, omega, intensity_obs, weights=1 / intensity_error\n", + ")\n", "results = sampler.sample(samples=10000, burn=500, thin=2)\n", "\n", "print(f'Drew {results.draws.shape[0]} samples for {results.draws.shape[1]} parameters.')\n", @@ -307,7 +299,7 @@ }, { "cell_type": "markdown", - "id": "8766b170", + "id": "15", "metadata": {}, "source": [ "## Convergence diagnostics\n", @@ -323,7 +315,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3c49ab6f", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -359,7 +351,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "## Posterior summaries\n", @@ -377,7 +369,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ce3e38a8", + "id": "18", "metadata": {}, "outputs": [], "source": [] @@ -385,7 +377,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -407,7 +399,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "20", "metadata": {}, "source": [ "## Visualise the joint posterior\n", @@ -418,7 +410,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -452,7 +444,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "22", "metadata": {}, "source": [ "## Posterior-predictive band\n", @@ -463,7 +455,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -499,7 +491,7 @@ }, { "cell_type": "markdown", - "id": "03339658", + "id": "24", "metadata": {}, "source": [ "## Extend the chain and check convergence\n", @@ -543,7 +535,7 @@ { "cell_type": "code", "execution_count": null, - "id": "293b140b", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -573,7 +565,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9ec4302c", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -614,7 +606,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b0f30be6", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -641,7 +633,7 @@ }, { "cell_type": "markdown", - "id": "50b7213a", + "id": "28", "metadata": {}, "source": [ "### What is Gelman-Rubin R-hat?\n", @@ -675,7 +667,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3449e0a7", + "id": "29", "metadata": {}, "outputs": [], "source": [ diff --git a/src/easyscience/fitting/fitter.py b/src/easyscience/fitting/fitter.py index d1f20080..41857098 100644 --- a/src/easyscience/fitting/fitter.py +++ b/src/easyscience/fitting/fitter.py @@ -15,6 +15,8 @@ from .minimizers import FitResults from .minimizers import MinimizerBase from .minimizers.factory import factory +from .reshaping import inject_x +from .reshaping import reshape_dataset DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq @@ -231,13 +233,10 @@ def _fit_function_wrapper( self, real_x: np.ndarray | None = None, flatten: bool = True, - dependent_dims: list[tuple[int, ...]] | None = None, ) -> Callable: """ - Simple fit function which injects the real X (independent) - values into the optimizer function. - - This will also flatten the results if needed. + Wrap the fit function so it evaluates on the real X (independent) + values instead of the optimizer's dummy x, flattening if needed. Parameters ---------- @@ -245,28 +244,13 @@ def _fit_function_wrapper( Independent x parameters to be injected. By default, None. flatten : bool, default=True Should the result be a flat 1D array? By default, True. - dependent_dims : list[tuple[int, ...]] | None, default=None - Unused for a single dataset; accepted so that callers can - pass it uniformly to ``Fitter`` and ``MultiFitter``. By - default, None. Returns ------- Callable Wrapped optimizer function. """ - fun = self._fit_function - - @functools.wraps(fun) - def wrapped_fit_function(x, **kwargs): - if real_x is not None: - x = real_x - dependent = fun(x, **kwargs) - if flatten: - dependent = dependent.flatten() - return dependent - - return wrapped_fit_function + return inject_x(self._fit_function, real_x, flatten=flatten) @property def fit(self) -> Callable: @@ -328,72 +312,7 @@ def inner_fit_callable( return inner_fit_callable - @staticmethod - def _precompute_reshaping( - x: np.ndarray, - y: np.ndarray, - weights: np.ndarray | None, - vectorized: bool, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]]: - """ - Check the dimensions of the inputs and reshape if necessary. - - Parameters - ---------- - x : np.ndarray - ND matrix of dependent points. - y : np.ndarray - N-1D matrix of independent points. - weights : np.ndarray | None - Optional weights for the fit. - vectorized : bool - Whether ``x`` already stores vectorized coordinates. - - Returns - ------- - tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]] - Reshaped x values, reshaped input data, flattened y values, - flattened weights, and the original x shape. - - Raises - ------ - ValueError - If the shapes of ``x`` and ``y`` are incompatible. - """ - # Make sure that they are np arrays - x_new = np.array(x) - y_new = np.array(y) - # Get the shape - x_shape = x_new.shape - # Check if the x data is 1D - if len(x_shape) > 1: - # It is ND data - # Check if the data is vectorized. i.e. should x be [NxMx...x Ndims] - if vectorized: - # Assert that the shapes are the same - if np.all(x_shape[:-1] != y_new.shape): - raise ValueError('The shape of the x and y data must be the same') - # If so do nothing but note that the data is vectorized - # x_shape = (-1,) # Should this be done? - else: - # Assert that the shapes are the same - if np.prod(x_new.shape[:-1]) != y_new.size: - raise ValueError('The number of elements in x and y data must be the same') - # Reshape the data to be [len(NxMx..), Ndims] i.e. flatten to columns - x_new = x_new.reshape(-1, x_shape[-1], order='F') - else: - # Assert that the shapes are the same - if np.all(x_shape != y_new.shape): - raise ValueError('The shape of the x and y data must be the same') - # It is 1D data - x_new = x.flatten() - # The optimizer needs a 1D array, flatten the y data - y_new = y_new.flatten() - if weights is not None: - weights = np.array(weights).flatten() - # Make a 'dummy' x array for the fit function - x_for_fit = np.array(range(y_new.size)) - return x_for_fit, x_new, y_new, weights, x_shape + _precompute_reshaping = staticmethod(reshape_dataset) @staticmethod def _post_compute_reshaping( diff --git a/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index 21b2767c..d328047b 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -8,6 +8,8 @@ from ..base_classes import CollectionBase from .fitter import Fitter from .minimizers import FitResults +from .reshaping import inject_x_multi +from .reshaping import reshape_datasets class MultiFitter(Fitter): @@ -34,17 +36,27 @@ def __init__( # not possible to change the fitting engine. super().__init__(self._fit_objects, self._fit_functions[0]) + @property + def fit_functions(self) -> list[Callable]: + """ + Get the per-dataset fit functions, in dataset order. + + Returns + ------- + list[Callable] + One fit function per dataset. + """ + return list(self._fit_functions) + def _fit_function_wrapper( self, real_x: list[np.ndarray] | None = None, flatten: bool = True, - dependent_dims: list[tuple[int, ...]] | None = None, ) -> Callable: """ - Simple fit function which injects the N real X (independent) - values into the optimizer function. - - This will also flatten the results if needed. + Wrap the per-dataset fit functions into one function that + evaluates each on its real X (independent) values and + concatenates the results, flattening if needed. Parameters ---------- @@ -53,101 +65,15 @@ def _fit_function_wrapper( None. flatten : bool, default=True Should the result be a flat 1D array? By default, True. - dependent_dims : list[tuple[int, ...]] | None, default=None - Per-dataset dependent shapes used to slice the combined - output. When ``None``, ``self._dependent_dims`` (set by - ``fit``) is read at call time. By default, None. Returns ------- Callable Wrapped optimizer function. """ - # Extract of a list of callable functions. - # ``Fitter._fit_function_wrapper`` reads ``self._fit_function``, so it - # is repointed per dataset inside the loop; the original must be - # restored afterwards or every caller (``Fitter.fit`` aside, which - # snapshots it itself, e.g. sampling) is left with the *last* - # dataset's function on the user-visible ``fit_function`` surface. - wrapped_fns = [] - original_fit_function = self._fit_function - try: - 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)) - finally: - self._fit_function = original_fit_function - - def wrapped_fun(x, **kwargs): - # Generate an empty Y based on x - y = np.zeros_like(x) - i = 0 - dims = self._dependent_dims if dependent_dims is None else dependent_dims - # Iterate through wrapped functions, passing the WRONG x, the correct - # x was injected in the step above. - for idx, dim in enumerate(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: list[np.ndarray] | None, - vectorized: bool, - ) -> tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]: - """ - Convert an array of X's and Y's to an acceptable shape for - fitting. + return inject_x_multi(self._fit_functions, real_x, self._dependent_dims, flatten=flatten) - Parameters - ---------- - x : list[np.ndarray] - List of independent variables. - y : list[np.ndarray] - List of dependent variables. - weights : list[np.ndarray] | None - Optional weights for each dataset. - vectorized : bool - When ``True``, each x array may be multi-dimensional (e.g. - an ``(N, M, 2)`` grid for a 2D model) and is left as-is. - When ``False`` (default), each x array is expected to be - 1-D. - - Returns - ------- - tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]] - Reshaped x values, reshaped input data, flattened y values, - flattened weights, and stored dependent dimensions. - """ - if weights is None: - weights = [None] * len(x) - _, _x_new, _y_new, _weights, _dims = Fitter._precompute_reshaping( - x[0], y[0], weights[0], vectorized - ) - 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 - ) - 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 + _precompute_reshaping = staticmethod(reshape_datasets) def _post_compute_reshaping( self, diff --git a/src/easyscience/fitting/reshaping.py b/src/easyscience/fitting/reshaping.py new file mode 100644 index 00000000..c68a7739 --- /dev/null +++ b/src/easyscience/fitting/reshaping.py @@ -0,0 +1,216 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Data reshaping and fit-function wrapping shared by ``Fitter`` and ``Sampler``. + +Both fitting and sampling provide the engine witha flat 1-D ``y`` array +and a dummy 1-D ``x`` index array, while the user's fit function is called +with the real ``x``. The helpers here do the translation in one place: +``reshape_dataset``/``inject_x`` do one dataset, +``reshape_datasets``/``inject_x_multi`` do a list of datasets. +""" + +from __future__ import annotations + +import functools +from typing import Callable + +import numpy as np + + +def reshape_dataset( + x: np.ndarray, + y: np.ndarray, + weights: np.ndarray | None, + vectorized: bool, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]]: + """ + Check the dimensions of the inputs and reshape if necessary. + + Parameters + ---------- + x : np.ndarray + Independent points; 1-D, or ND with the coordinate components on + the last axis. + y : np.ndarray + Dependent points, one per observation. + weights : np.ndarray | None + Optional weights for the fit. + vectorized : bool + Whether ``x`` already stores vectorized coordinates. + + Returns + ------- + tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray | None, tuple[int, ...]] + Dummy x index array for the engine, reshaped x values, flattened + y values, flattened weights, and the shape of ``y`` (the dependent + dimensions; its product is the number of observations, which the + multi-dataset helpers use to slice the combined output). + + Raises + ------ + ValueError + If the shapes of ``x`` and ``y`` are incompatible. + """ + # Make sure that they are np arrays + x_new = np.array(x) + y_new = np.array(y) + # Get the shapes + x_shape = x_new.shape + y_shape = y_new.shape + # Check if the x data is 1D + if len(x_shape) > 1: + # It is ND data + # Check if the data is vectorized. i.e. should x be [NxMx...x Ndims] + if vectorized: + # Assert that the shapes are the same + if np.all(x_shape[:-1] != y_new.shape): + raise ValueError('The shape of the x and y data must be the same') + # If so do nothing but note that the data is vectorized + # x_shape = (-1,) # Should this be done? + else: + # Assert that the shapes are the same + if np.prod(x_new.shape[:-1]) != y_new.size: + raise ValueError('The number of elements in x and y data must be the same') + # Reshape the data to be [len(NxMx..), Ndims] i.e. flatten to columns + x_new = x_new.reshape(-1, x_shape[-1], order='F') + else: + # Assert that the shapes are the same + if np.all(x_shape != y_new.shape): + raise ValueError('The shape of the x and y data must be the same') + # It is 1D data + x_new = x.flatten() + # The optimizer needs a 1D array, flatten the y data + y_new = y_new.flatten() + if weights is not None: + weights = np.array(weights).flatten() + # Make a 'dummy' x array for the fit function + x_for_fit = np.array(range(y_new.size)) + return x_for_fit, x_new, y_new, weights, y_shape + + +def reshape_datasets( + x: list[np.ndarray], + y: list[np.ndarray], + weights: list[np.ndarray] | None, + vectorized: bool, +) -> tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]]: + """ + Convert a list of X's and Y's to an acceptable shape for fitting. + + Parameters + ---------- + x : list[np.ndarray] + List of independent variables. + y : list[np.ndarray] + List of dependent variables. + weights : list[np.ndarray] | None + Optional weights for each dataset. + vectorized : bool + When ``True``, each x array may be multi-dimensional (e.g. an + ``(N, M, 2)`` grid for a 2D model) and is left as-is. When + ``False`` (default), each x array is expected to be 1-D. + + Returns + ------- + tuple[np.ndarray, list[np.ndarray], np.ndarray, np.ndarray | None, list[tuple[int, ...]]] + Dummy x index array for the engine, per-dataset reshaped x values, + concatenated y values, concatenated weights, and the per-dataset + dependent dimensions. + """ + if weights is None: + weights = [None] * len(x) + x_new, y_new, w_new, dims = [], [], [], [] + for _x, _y, _w in zip(x, y, weights): + _, _x_new, _y_new, _w_new, _dims = reshape_dataset(_x, _y, _w, vectorized) + x_new.append(_x_new) + y_new.append(_y_new) + w_new.append(_w_new) + dims.append(_dims) + y_new = np.hstack(y_new) + w_new = None if w_new[0] is None else 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 + + +def inject_x( + fit_function: Callable, + real_x: np.ndarray | None = None, + flatten: bool = True, +) -> Callable: + """ + Wrap a fit function so it ignores the engine's dummy x and evaluates + on ``real_x`` instead, flattening the result if needed. + + Parameters + ---------- + fit_function : Callable + The user's fit function. + real_x : np.ndarray | None, default=None + Independent x values to be injected. By default, None. + flatten : bool, default=True + Should the result be a flat 1D array? By default, True. + + Returns + ------- + Callable + Wrapped optimizer function. + """ + + @functools.wraps(fit_function) + def wrapped_fit_function(x, **kwargs): + if real_x is not None: + x = real_x + dependent = fit_function(x, **kwargs) + if flatten: + dependent = dependent.flatten() + return dependent + + return wrapped_fit_function + + +def inject_x_multi( + fit_functions: list[Callable], + real_x: list[np.ndarray], + dims: list[tuple[int, ...]], + flatten: bool = True, +) -> Callable: + """ + Wrap one fit function per dataset into a single function whose output + is the concatenation of the per-dataset outputs. + + Parameters + ---------- + fit_functions : list[Callable] + One fit function per dataset. + real_x : list[np.ndarray] + One independent x array per dataset, injected into the matching + fit function. + dims : list[tuple[int, ...]] + Per-dataset dependent (``y``) shapes used to slice the combined + output, as returned by ``reshape_datasets``. + flatten : bool, default=True + Should each per-dataset result be flattened? By default, True. + + Returns + ------- + Callable + Wrapped optimizer function. + """ + wrapped_fns = [ + inject_x(this_fun, this_x, flatten=flatten) + for this_x, this_fun in zip(real_x, fit_functions) + ] + + 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 wrapped, dim in zip(wrapped_fns, dims): + ep = i + np.prod(dim) + y[i:ep] = wrapped(x, **kwargs) + i = ep + return y + + return wrapped_fun diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 345fe97b..3d9ef589 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -16,6 +16,8 @@ from easyscience import global_object from .engine_base import PARAMETER_PREFIX +from .reshaping import inject_x_multi +from .reshaping import reshape_datasets if TYPE_CHECKING: # avoid import cycles; only needed for type hints from bumps.dream.state import MCMCDraw @@ -165,7 +167,7 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s """Reload a DREAM chain state saved by ``Sampler.save``. This is the standalone reader: unlike ``Sampler.load_state`` it needs no - fitter, model or data, so a saved chain can be inspected or post-processed + model or data, so a saved chain can be inspected or post-processed on a machine that does not have the model. Parameter names are restored from the sidecar when available (schema versions 1 and 2), falling back to the state's labels with the minimizer prefix stripped. @@ -254,28 +256,25 @@ class Sampler: effect on the sampler, and there are deliberately no setters: to sample different data, create a new ``Sampler``. - Construct directly with a configured ``Fitter`` (or ``MultiFitter``). - The only requirement is an installed ``bumps`` package. **Running a fit - first is not required**; sampling from the initial parameter values - works fine. - - It is often worth fitting first anyway. DREAM seeds its whole starting - population inside a tiny ball around the parameters' *current* values - (BUMPS' default ``init='eps'``), so sampling from fitted values starts the - chain in the right region and shortens the burn-in needed to reach the - typical set. From a poor initial guess, expect to burn for longer. + ``Sampler`` is a parallel entry point to ``Fitter``: it takes the same + ``(fit_object, fit_function)`` pair plus the data. Parameters ---------- - fitter : Fitter - A configured ``Fitter`` (or ``MultiFitter``) supplying the model and - fit function. + fit_object : object + The EasyScience model object holding the ``Parameter`` instances to + sample. For multiple datasets this is one object exposing all the + parameters (for example an ``EasyList`` of models, or the + ``fit_object`` of a ``MultiFitter``). + fit_function : Callable | list[Callable] + The model function, or one per dataset when ``x``, ``y`` and + ``weights`` are lists of arrays. x : np.ndarray | list[np.ndarray] - Independent variable array (or list of arrays for ``MultiFitter``). + Independent variable array (or list of arrays, one per dataset). y : np.ndarray | list[np.ndarray] - Dependent variable array (or list of arrays for ``MultiFitter``). + Dependent variable array (or list of arrays, one per dataset). weights : np.ndarray | list[np.ndarray] - Weight array (or list of arrays for ``MultiFitter``). Required: + Weight array (or list of arrays, one per dataset). Required: sampling has no default weighting, so a missing weight array is rejected here rather than deep inside the sampling engine. vectorized : bool, default=False @@ -289,14 +288,14 @@ class Sampler: Raises ------ TypeError - If ``fitter`` is not Fitter-shaped (no ``fit_function``), - if any dataset in ``x``/``y``/``weights`` is not a numeric array - (e.g. a string), or ``vectorized``/``sampler_kwargs`` have the wrong - type. + If ``fit_object`` has no ``get_fit_parameters``, ``fit_function`` + is not callable, any dataset in ``x``/``y``/``weights`` is not a + numeric array (e.g. a string), or ``vectorized``/``sampler_kwargs`` + have the wrong type. ValueError - If ``x``, ``y`` and ``weights`` do not hold matching structures - (all arrays, or lists of the same length), or any dataset is a - scalar or empty array. + If ``fit_function``, ``x``, ``y`` and ``weights`` do not hold + matching structures (all single, or lists of the same length), or + any dataset is a scalar or empty array. Notes ----- @@ -329,7 +328,7 @@ class Sampler: the whole chain:: sampler = Sampler( - fitter, x, y, weights=w, sampler_kwargs={'trim': False} + model, model, x, y, weights=w, sampler_kwargs={'trim': False} ) Note also that trimming does not survive a ``save()``/``load_state()`` @@ -340,29 +339,26 @@ class Sampler: def __init__( self, - fitter: 'Fitter', + fit_object: object, + fit_function: Callable | list[Callable], x: np.ndarray | list[np.ndarray], y: np.ndarray | list[np.ndarray], weights: np.ndarray | list[np.ndarray], vectorized: bool = False, sampler_kwargs: dict | None = None, ): - if not hasattr(fitter, 'fit_function'): - raise TypeError( - f'fitter must be a configured Fitter or MultiFitter, got {type(fitter).__name__}.' - ) - x_is_multi = isinstance(x, (list, tuple)) - if x_is_multi != isinstance(y, (list, tuple)): + is_multi = isinstance(x, (list, tuple)) + if is_multi != isinstance(y, (list, tuple)): raise ValueError('x and y must either both be arrays or both be lists of arrays.') - if x_is_multi and len(x) != len(y): + if is_multi and len(x) != len(y): raise ValueError( f'x and y must hold the same number of datasets, got {len(x)} and {len(y)}.' ) - if isinstance(weights, (list, tuple)) != x_is_multi: + if isinstance(weights, (list, tuple)) != is_multi: raise ValueError( 'weights must match the structure of x and y (array or list of arrays).' ) - if x_is_multi and len(weights) != len(x): + if is_multi and len(weights) != len(x): raise ValueError( f'weights must hold the same number of datasets as x and y, ' f'got {len(weights)} and {len(x)}.' @@ -370,44 +366,122 @@ def __init__( _validate_dataset_arrays('x', x) _validate_dataset_arrays('y', y) _validate_dataset_arrays('weights', weights) + if isinstance(fit_function, (list, tuple)) != is_multi: + raise ValueError( + 'fit_function must be a list of callables when x, y and weights are ' + 'lists of arrays, and a single callable otherwise.' + ) + if is_multi and len(fit_function) != len(x): + raise ValueError( + f'fit_function must hold one callable per dataset, ' + f'got {len(fit_function)} for {len(x)} datasets.' + ) + fit_functions = list(fit_function) if is_multi else [fit_function] + if not all(callable(f) for f in fit_functions): + raise TypeError('fit_function must be callable (or a list of callables).') + if not hasattr(fit_object, 'get_fit_parameters'): + raise TypeError( + f'fit_object must be an EasyScience model object exposing the parameters ' + f'to sample, got {type(fit_object).__name__}.' + ) if not isinstance(vectorized, bool): raise TypeError(f'vectorized must be a bool, got {type(vectorized).__name__}.') if sampler_kwargs is not None and not isinstance(sampler_kwargs, dict): raise TypeError( f'sampler_kwargs must be a dict or None, got {type(sampler_kwargs).__name__}.' ) - self._fitter = fitter - # Defensive copies, exposed read-only: mutating the caller's arrays - # (or the properties) cannot desynchronise the chain and the save() - # fingerprint from the data actually sampled. To sample different - # data, create a new Sampler. - self._x = _copy_data(x) - self._y = _copy_data(y) - self._weights = _copy_data(weights) + self._is_multi = is_multi + self._fit_object = fit_object + self._fit_functions = fit_functions + + self._x = _copy_data(x if is_multi else [x]) + self._y = _copy_data(y if is_multi else [y]) + self._weights = _copy_data(weights if is_multi else [weights]) self._vectorized = vectorized self._default_sampler_kwargs = dict(sampler_kwargs or {}) self._state: MCMCDraw | None = None # current chain state self._results: SamplingResults | None = None + @classmethod + def from_fitter( + cls, + fitter: Fitter, + x: np.ndarray | list[np.ndarray], + y: np.ndarray | list[np.ndarray], + weights: np.ndarray | list[np.ndarray], + vectorized: bool = False, + sampler_kwargs: dict | None = None, + ) -> Sampler: + """Build a ``Sampler`` from the model bound to an existing ``Fitter``. + + A convenience for the common fit-then-sample workflow: the sampler + takes the fitter's ``fit_object`` and fit function(s) and is + otherwise identical to one constructed directly. The fitter is not + retained or modified. A ``MultiFitter`` yields a multi-dataset + sampler, so ``x``, ``y`` and ``weights`` must then be lists of arrays. + + Parameters + ---------- + fitter : Fitter + A configured ``Fitter`` or ``MultiFitter``. It does not need to + have been fitted. + x : np.ndarray | list[np.ndarray] + Independent variable array (or list of arrays, one per dataset). + y : np.ndarray | list[np.ndarray] + Dependent variable array (or list of arrays, one per dataset). + weights : np.ndarray | list[np.ndarray] + Weight array (or list of arrays, one per dataset). + vectorized : bool, default=False + See ``Sampler``. + sampler_kwargs : dict | None, default=None + See ``Sampler``. + + Returns + ------- + Sampler + A sampler bound to the fitter's model and the given data. + """ + # A MultiFitter exposes one function per dataset as ``fit_functions``; + # a plain Fitter has a single ``fit_function``. + fit_function = getattr(fitter, 'fit_functions', None) or fitter.fit_function + return cls( + fitter.fit_object, + fit_function, + x, + y, + weights, + vectorized=vectorized, + sampler_kwargs=sampler_kwargs, + ) + + def _single_or_list(self, data: list): + """Return bound data the way it was passed in: one item or a list copy.""" + return list(data) if self._is_multi else data[0] + @property - def fitter(self) -> Fitter: - """The Fitter supplying the model and minimizer (read-only).""" - return self._fitter + def fit_object(self) -> object: + """The EasyScience model object holding the sampled parameters (read-only).""" + return self._fit_object + + @property + def fit_function(self) -> Callable | list[Callable]: + """The model function, or list of them for multiple datasets (read-only).""" + return self._single_or_list(self._fit_functions) @property def x(self) -> np.ndarray | list[np.ndarray]: """The bound independent variable data (read-only copy).""" - return list(self._x) if isinstance(self._x, list) else self._x + return self._single_or_list(self._x) @property def y(self) -> np.ndarray | list[np.ndarray]: """The bound dependent variable data (read-only copy).""" - return list(self._y) if isinstance(self._y, list) else self._y + return self._single_or_list(self._y) @property def weights(self) -> np.ndarray | list[np.ndarray]: """The bound weight data (read-only copy).""" - return list(self._weights) if isinstance(self._weights, list) else self._weights + return self._single_or_list(self._weights) @property def state(self) -> MCMCDraw | None: @@ -436,12 +510,7 @@ def logp(self) -> np.ndarray | None: def _fingerprint(self) -> str | None: """SHA-256 fingerprint of the bound (x, y, weights) data, or None.""" - x_list = list(self._x) if isinstance(self._x, (list, tuple)) else [self._x] - y_list = list(self._y) if isinstance(self._y, (list, tuple)) else [self._y] - w_list = ( - list(self._weights) if isinstance(self._weights, (list, tuple)) else [self._weights] - ) - return _data_fingerprint(x_list, y_list, w_list) + return _data_fingerprint(self._x, self._y, self._weights) def _run( self, @@ -468,19 +537,16 @@ def _run( ) from .samplers.sampler_bumps import DreamSampler - x_fit, x_new, y_new, w_new, dims = self._fitter._precompute_reshaping( + x_fit, x_new, y_new, w_new, dims = reshape_datasets( self._x, self._y, self._weights, self._vectorized ) - # The dims are passed explicitly so the fitter itself is never mutated. - wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True, dependent_dims=dims) + wrapped = inject_x_multi(self._fit_functions, x_new, dims) merged_kwargs = {**self._default_sampler_kwargs, **(sampler_kwargs or {})} - # A fresh engine per run keeps the chain on the fitter's current fit - # function and parameters; chain continuity lives in ``resume_state``. - # This is where a sampler factory would plug in once there is more - # than one backend. - engine = DreamSampler(obj=self._fitter.fit_object, fit_function=wrapped) + # This is where a sampler factory would plug in once there + # is more than one backend. + engine = DreamSampler(obj=self._fit_object, fit_function=wrapped) result = engine.run( x=x_fit, y=y_new, @@ -678,7 +744,7 @@ def save(self, path: str | os.PathLike) -> None: ``.params.json`` sidecar with the parameter names, the easyscience version, and a fingerprint of the bound data (verified with a warning on ``load_state()``). Use ``load_chain`` to read the - files back without a fitter. + files back without the model. Parameters ---------- @@ -721,7 +787,7 @@ def save(self, path: str | os.PathLike) -> None: def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: """Load a previously saved chain into this sampler. - The sampler must be constructed with the same fitter and data used to + The sampler must be constructed with the same model and data used to create the chain — ``extend()`` then continues the saved chain. If the sidecar carries a data fingerprint and it does not match this sampler's bound data, a warning is logged (extending a chain against diff --git a/tests/integration/fitting/test_sampler.py b/tests/integration/fitting/test_sampler.py index c588763b..2bb51941 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -14,6 +14,7 @@ from easyscience import ObjBase from easyscience import Parameter +from easyscience.fitting import Fitter from easyscience.fitting import Sampler from easyscience.fitting import SamplingResults from easyscience.fitting.multi_fitter import MultiFitter @@ -62,11 +63,11 @@ def __call__(self, x): ) -def _fitter_and_data(): - """Build a 2-parameter MultiFitter over a small sine model. +def _model_and_data(): + """Build a 2-parameter sine model and a small dataset to sample. - The fitter keeps its default (LMFit) minimizer: sampling no longer - requires switching to BUMPS, only an installed ``bumps`` package. + No ``Fitter`` is involved: sampling needs only the model, its fit + function, the data, and an installed ``bumps`` package. """ pytest.importorskip('bumps') ref_sin = AbsSin(0.2, np.pi) @@ -76,18 +77,17 @@ def _fitter_and_data(): x = np.linspace(0, 5, 50) y = ref_sin(x) weights = np.ones_like(x) - f = MultiFitter([sp], [sp]) - return f, sp, x, y, weights + return sp, x, y, weights class TestSampler: - """Integration tests for ``Sampler(f, ...)`` / ``Sampler``.""" + """Integration tests for ``Sampler``.""" @pytest.mark.filterwarnings('ignore::UserWarning') def test_sample_returns_results_object(self): """sample() returns a populated SamplingResults, cached on the sampler.""" - f, sp, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) results = sampler.sample(samples=100, burn=20, thin=2) @@ -109,7 +109,7 @@ def test_sample_returns_results_object(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_sample_multi_dataset(self): - """Multi-dataset sampling via Sampler(f, ...) has correct param_names.""" + """Multi-dataset sampling has correct param_names.""" ref_sin_1 = AbsSin(0.2, np.pi) sp_sin_1 = AbsSin(0.354, 3.05) sp_line = Line(0.43, 6.1) @@ -130,9 +130,13 @@ def test_sample_multi_dataset(self): sp_line.c.fixed = False pytest.importorskip('bumps') + # Direct multi-dataset construction: one container object exposing all + # parameters (here the one MultiFitter builds) and one function per + # dataset. f = MultiFitter([sp_sin_1, sp_line], [sp_sin_1, sp_line]) - - sampler = Sampler(f, [x1, x2], [y1, y2], [weights, weights]) + sampler = Sampler( + f.fit_object, [sp_sin_1, sp_line], [x1, x2], [y1, y2], [weights, weights] + ) results = sampler.sample(samples=100, burn=20, thin=2) # All parameters across both models should appear @@ -142,8 +146,8 @@ def test_sample_multi_dataset(self): def test_sample_population(self): """Passing population should succeed and produce valid draws.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) results = sampler.sample(samples=100, burn=20, thin=2, population=5) assert results.draws.shape[0] > 0 @@ -163,9 +167,7 @@ def test_sample_vectorized_2d(self): sp.phase.fixed = False pytest.importorskip('bumps') - f = MultiFitter([sp], [sp]) - - sampler = Sampler(f, [x2D], [y2D], [weights], vectorized=True) + sampler = Sampler(sp, sp, x2D, y2D, weights, vectorized=True) results = sampler.sample(samples=100, burn=20, thin=2) assert results.draws.ndim == 2 @@ -173,21 +175,24 @@ def test_sample_vectorized_2d(self): assert results.draws.shape[1] == len(results.param_names) @pytest.mark.filterwarnings('ignore::UserWarning') - def test_fit_function_restored_on_success(self): - """fit_function must be restored after a successful sample().""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) - original_func = f.fit_function + def test_from_fitter_samples_and_leaves_fitter_untouched(self): + """from_fitter() on a plain, unfitted Fitter with its default LMFit + minimizer samples fine and never touches the fitter.""" + sp, x, y, weights = _model_and_data() + f = Fitter(sp, sp) + assert f.minimizer.package == 'lmfit' - sampler.sample(samples=100, burn=20, thin=2) - assert f.fit_function is original_func + sampler = Sampler.from_fitter(f, x, y, weights) + results = sampler.sample(samples=100, burn=20, thin=2) + + assert results.draws.shape[0] > 0 + assert f.fit_function is sp + assert f.minimizer.package == 'lmfit' @pytest.mark.filterwarnings('ignore::UserWarning') - def test_fit_function_untouched_multi_dataset(self): - """With 2+ datasets the per-dataset wrapping in MultiFitter must not - leave fit_function pointing at the LAST dataset's function after - sampling (regression: the single-dataset variant above is vacuous for - this bug because last == first == original).""" + def test_from_multi_fitter_untouched_multi_dataset(self): + """from_fitter() on a 2-dataset MultiFitter samples each dataset with + its own function and leaves the fitter's fit_function alone.""" ref_sin = AbsSin(0.2, np.pi) sp_sin = AbsSin(0.354, 3.05) sp_line = Line(0.43, 6.1) @@ -205,16 +210,18 @@ def test_fit_function_untouched_multi_dataset(self): original = f.fit_function assert original is sp_sin # two distinct per-dataset functions - sampler = Sampler(f, [x1, x2], [y1, y2], [weights, weights]) - sampler.sample(samples=50, burn=5, thin=1) + sampler = Sampler.from_fitter(f, [x1, x2], [y1, y2], [weights, weights]) + results = sampler.sample(samples=50, burn=5, thin=1) assert f.fit_function is original + assert sampler.fit_function == [sp_sin, sp_line] + assert results.draws.shape[0] > 0 @pytest.mark.filterwarnings('ignore::UserWarning') def test_sampler_kwargs_forwarded(self): """Per-call sampler_kwargs dict is forwarded to the BUMPS DREAM sampler.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) results = sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'random'}) @@ -226,8 +233,8 @@ def test_default_sampler_kwargs_merged(self, monkeypatch): """Constructor-level sampler_kwargs defaults are used; per-call kwargs win.""" from easyscience.fitting.samplers.sampler_bumps import DreamSampler - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'init': 'random'}) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights, sampler_kwargs={'init': 'random'}) captured = {} original_run = DreamSampler.run @@ -245,25 +252,11 @@ def spy(self, **kwargs): sampler.sample(samples=100, burn=20, thin=2, sampler_kwargs={'init': 'lhs'}) assert captured == {'init': 'lhs'} # per-call overrides default - @pytest.mark.filterwarnings('ignore::UserWarning') - def test_sample_with_lmfit_minimizer_active(self): - """Sampling works without switching the fitter's minimizer to BUMPS — - the new capability enabled by the ``DreamSampler`` engine (#280).""" - f, _, x, y, weights = _fitter_and_data() - assert f.minimizer.package == 'lmfit' # the default LMFit minimizer - - sampler = Sampler(f, [x], [y], [weights]) - results = sampler.sample(samples=100, burn=20, thin=2) - - assert results.draws.shape[0] > 0 - # The active minimizer is untouched by sampling. - assert f.minimizer.package == 'lmfit' - @pytest.mark.filterwarnings('ignore::UserWarning') def test_extend_chain(self): """extend(additional_samples=) continues the chain; ring-buffer math is done for the user.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) first = sampler.sample(samples=100, burn=20, thin=1) n_first = first.draws.shape[0] @@ -282,8 +275,8 @@ def test_extend_with_thinning_keeps_existing_draws(self): generations (``Ngen * Npop``), not from the retained-draw count, which BUMPS divides by the thinning interval. """ - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) first = sampler.sample(samples=1000, burn=20, thin=10) n_first = first.draws.shape[0] @@ -296,8 +289,8 @@ def test_extend_with_thinning_keeps_existing_draws(self): @pytest.mark.filterwarnings('ignore::UserWarning') def test_extend_total_samples_override(self): """extend(total_samples=) bypasses the additional_samples arithmetic.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) sampler.sample(samples=100, burn=20, thin=1) extended = sampler.extend(total_samples=150, thin=1) @@ -314,14 +307,14 @@ def test_extend_after_save_load_roundtrip(self, tmp_path, caplog): """ import logging - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) first = sampler.sample(samples=100, burn=20, thin=1) prefix = str(tmp_path / 'chain') sampler.save(prefix) - sampler2 = Sampler(f, [x], [y], [weights]) + sampler2 = Sampler(sp, sp, x, y, weights) loaded = sampler2.load_state(prefix) assert loaded.draws.shape[1] == first.draws.shape[1] @@ -340,8 +333,8 @@ def test_extend_preserves_nondefault_population(self): saved state on resume, otherwise BUMPS regenerates the default population and raises ``Cannot change Nvar, Npop or Ncr on resize``. """ - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) first = sampler.sample(samples=100, burn=20, thin=1, population=5) first_npop = first.state.Npop @@ -359,8 +352,8 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp logs a warning and records ``null`` in the sidecar.""" import logging - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) sampler.sample(samples=100, burn=20, thin=2) monkeypatch.setattr('easyscience.fitting.sampler._data_fingerprint', lambda *args: None) @@ -376,14 +369,14 @@ def test_save_warns_when_fingerprint_unavailable(self, tmp_path, caplog, monkeyp @pytest.mark.filterwarnings('ignore::UserWarning') def test_load_state_populates_results(self, tmp_path): """A freshly loaded sampler reports draws/logp/param_names without resampling.""" - f, sp, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) first = sampler.sample(samples=100, burn=20, thin=2) prefix = str(tmp_path / 'chain') sampler.save(prefix) - sampler2 = Sampler(f, [x], [y], [weights]) + sampler2 = Sampler(sp, sp, x, y, weights) assert sampler2.draws is None loaded = sampler2.load_state(prefix) @@ -409,22 +402,22 @@ def test_load_short_chain_regression(self, tmp_path): reader collapses it to a 1-D array and ``load_state`` raises ``IndexError`` without the 2-D coercion workaround. """ - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) sampler.sample(samples=20, burn=5, thin=1) prefix = str(tmp_path / 'short_chain') sampler.save(prefix) - sampler2 = Sampler(f, [x], [y], [weights]) + sampler2 = Sampler(sp, sp, x, y, weights) loaded = sampler2.load_state(prefix) assert loaded.draws.shape[0] > 0 @pytest.mark.filterwarnings('ignore::UserWarning') def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog): """Loading a chain into a sampler bound to different data warns.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) sampler.sample(samples=100, burn=20, thin=2) prefix = str(tmp_path / 'chain') @@ -433,7 +426,7 @@ def test_load_fingerprint_mismatch_warns(self, tmp_path, caplog): import logging other_y = y + 0.5 - sampler2 = Sampler(f, [x], [other_y], [weights]) + sampler2 = Sampler(sp, sp, x, other_y, weights) with caplog.at_level(logging.WARNING, logger='easyscience.fitting'): sampler2.load_state(prefix) assert 'does not match the data fingerprint' in caplog.text diff --git a/tests/unit/fitting/test_multi_fitter.py b/tests/unit/fitting/test_multi_fitter.py index 6c7401df..f47dfcb7 100644 --- a/tests/unit/fitting/test_multi_fitter.py +++ b/tests/unit/fitting/test_multi_fitter.py @@ -168,21 +168,6 @@ def test_fit_function_restored_with_multiple_datasets(self): expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])]) assert np.allclose(y, expected) - def test_explicit_dependent_dims_do_not_touch_fitter(self): - """Passing ``dependent_dims`` (as the ``Sampler`` does) slices the - combined output without writing ``_dependent_dims`` onto the fitter.""" - fit_objects = [Line(1.0, 0.5), Line(2.0, 1.5)] - mf = MultiFitter(fit_objects, fit_objects) - assert mf._dependent_dims is None - - x = [np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0])] - wrapped = mf._fit_function_wrapper(x, flatten=True, dependent_dims=[(3,), (2,)]) - - y = wrapped(np.zeros(5)) - expected = np.hstack([fit_objects[0](x[0]), fit_objects[1](x[1])]) - assert np.allclose(y, expected) - assert mf._dependent_dims is None - # =================================================================== # MultiFitter._precompute_reshaping with weights=None diff --git a/tests/unit/fitting/test_reshaping.py b/tests/unit/fitting/test_reshaping.py new file mode 100644 index 00000000..aada7c91 --- /dev/null +++ b/tests/unit/fitting/test_reshaping.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for ``reshaping.py`` — the data reshaping and fit-function +wrapping shared by ``Fitter``, ``MultiFitter`` and ``Sampler``.""" + +import numpy as np +import pytest + +from easyscience.fitting.reshaping import inject_x +from easyscience.fitting.reshaping import inject_x_multi +from easyscience.fitting.reshaping import reshape_dataset +from easyscience.fitting.reshaping import reshape_datasets + + +def _grid(n: int, m: int): + """A vectorized ``(n, m, 2)`` coordinate grid and its ``(n, m)`` dependent values.""" + X, Y = np.meshgrid(np.linspace(0, 1, m), np.linspace(0, 1, n)) + x = np.stack((X, Y), axis=2) + return x, X + 2 * Y + + +def _sum_xy(x): + return x[..., 0] + 2 * x[..., 1] + + +class TestReshapeDataset: + def test_1d_dims_are_y_shape(self): + x = np.linspace(0, 1, 5) + x_fit, x_new, y_new, w_new, dims = reshape_dataset(x, 2 * x, np.ones(5), vectorized=False) + assert dims == (5,) + assert x_fit.shape == y_new.shape == w_new.shape == (5,) + + def test_vectorized_dims_are_y_shape_not_x_shape(self): + """For multi-dimensional coordinates the dependent dims must exclude + the coordinate-component axis: ``(2, 3, 2)`` x holds 6 observations.""" + x, y = _grid(2, 3) + _, x_new, y_new, _, dims = reshape_dataset(x, y, None, vectorized=True) + assert dims == (2, 3) + assert y_new.shape == (6,) + assert x_new.shape == (2, 3, 2) + + def test_non_vectorized_nd_dims_are_y_shape(self): + x = np.random.default_rng(0).random((6, 2)) + y = np.arange(6.0) + _, x_new, y_new, _, dims = reshape_dataset(x, y, None, vectorized=False) + assert dims == (6,) + assert x_new.shape == (6, 2) + + +class TestInjectX: + def test_injects_real_x_and_flattens(self): + x, y = _grid(2, 3) + wrapped = inject_x(_sum_xy, x, flatten=True) + np.testing.assert_allclose(wrapped(np.zeros(6)), y.flatten()) + + +class TestInjectXMulti: + def test_two_multidimensional_datasets_are_sliced_by_observation_count(self): + """Regression: slicing by the product of the x shape allocated twice as + many output positions as observations for vectorized grids, which + raised on the first dataset and silently clipped on the last.""" + x1, y1 = _grid(2, 3) + x2, y2 = _grid(3, 4) + x_fit, x_new, y_new, _, dims = reshape_datasets([x1, x2], [y1, y2], None, vectorized=True) + assert dims == [(2, 3), (3, 4)] + assert y_new.shape == (18,) + + wrapped = inject_x_multi([_sum_xy, _sum_xy], x_new, dims) + np.testing.assert_allclose(wrapped(x_fit), np.hstack([y1.flatten(), y2.flatten()])) + + def test_multidimensional_dataset_first_then_1d(self): + """Dataset boundaries hold regardless of order; a 2D dataset that is + not last used to raise a broadcast error.""" + x1, y1 = _grid(2, 3) + x2 = np.linspace(0, 1, 4) + y2 = 3 * x2 + x_fit, x_new, y_new, _, dims = reshape_datasets([x1, x2], [y1, y2], None, vectorized=True) + assert dims == [(2, 3), (4,)] + + wrapped = inject_x_multi([_sum_xy, lambda x: 3 * x], x_new, dims) + np.testing.assert_allclose(wrapped(x_fit), np.hstack([y1.flatten(), y2])) + + def test_weights_none_for_all_datasets(self): + x = np.linspace(0, 1, 3) + _, _, _, w_new, _ = reshape_datasets([x, x], [x, x], [None, None], vectorized=False) + assert w_new is None + + def test_weights_are_concatenated(self): + x = np.linspace(0, 1, 3) + _, _, _, w_new, _ = reshape_datasets( + [x, x], [x, x], [np.ones(3), 2 * np.ones(3)], vectorized=False + ) + np.testing.assert_array_equal(w_new, [1, 1, 1, 2, 2, 2]) + + +@pytest.mark.parametrize('vectorized', [False, True]) +def test_shape_mismatch_raises(vectorized): + x = np.linspace(0, 1, 4) + with pytest.raises(ValueError, match='shape of the x and y data must be the same'): + reshape_dataset(x, np.zeros(3), None, vectorized=vectorized) diff --git a/tests/unit/fitting/test_sampler.py b/tests/unit/fitting/test_sampler.py index 47136b11..1e488945 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -15,6 +15,7 @@ from easyscience import ObjBase from easyscience import Parameter +from easyscience.fitting import Fitter from easyscience.fitting import Sampler from easyscience.fitting import SamplingResults from easyscience.fitting.engine_base import PARAMETER_PREFIX @@ -36,10 +37,15 @@ def __call__(self, x): return np.abs(np.sin(self.phase.value * x + self.offset.value)) -class _StubFitter: - """Duck-types the Fitter attributes checked by the Sampler constructor.""" +class _StubModel: + """Duck-types the model attribute checked by the Sampler constructor.""" - fit_function = None + def get_fit_parameters(self): + return [] + + +def _identity(x): + return x class _StubState: @@ -49,12 +55,8 @@ def __init__(self, labels): self.labels = list(labels) -def _fitter_and_data(): - """Build a 2-parameter MultiFitter over a small sine model. - - The fitter keeps its default (LMFit) minimizer: sampling no longer - requires switching to BUMPS, only an installed ``bumps`` package. - """ +def _model_and_data(): + """Build a 2-parameter sine model and a small dataset to sample.""" pytest.importorskip('bumps') ref_sin = AbsSin(0.2, np.pi) sp = AbsSin(0.354, 3.05) @@ -63,8 +65,7 @@ def _fitter_and_data(): x = np.linspace(0, 5, 50) y = ref_sin(x) weights = np.ones_like(x) - f = MultiFitter([sp], [sp]) - return f, sp, x, y, weights + return sp, x, y, weights def _xyw(): @@ -93,70 +94,109 @@ def _make_state(ngen=6, npop=5, nvar=2, seed=7): class TestSamplerConstructorValidation: - def test_rejects_fitter_without_fit_function(self): + def test_rejects_fit_object_without_fit_parameters(self): + x, y, w = _xyw() + with pytest.raises(TypeError, match='fit_object must be an EasyScience model'): + Sampler(object(), [_identity], [x], [y], [w]) + + def test_rejects_non_callable_fit_function(self): + x, y, w = _xyw() + with pytest.raises(TypeError, match='fit_function must be callable'): + Sampler(_StubModel(), 'not-callable', x, y, w) + with pytest.raises(TypeError, match='fit_function must be callable'): + Sampler(_StubModel(), [_identity, None], [x, x], [y, y], [w, w]) + + def test_rejects_fit_function_structure_mismatch(self): + """One callable for a list of datasets (or vice versa) is an error: + multi-dataset sampling takes one fit function per dataset.""" x, y, w = _xyw() - with pytest.raises(TypeError, match='fitter must be a configured Fitter'): - Sampler(object(), [x], [y], [w]) + with pytest.raises(ValueError, match='fit_function must be a list of callables'): + Sampler(_StubModel(), _identity, [x], [y], [w]) + with pytest.raises(ValueError, match='fit_function must be a list of callables'): + Sampler(_StubModel(), [_identity], x, y, w) + + def test_rejects_fit_function_count_mismatch(self): + x, y, w = _xyw() + with pytest.raises(ValueError, match='one callable per dataset'): + Sampler(_StubModel(), [_identity], [x, x], [y, y], [w, w]) + + def test_rejects_list_of_fit_objects(self): + """Multiple datasets take one container object exposing all the + parameters, not a bare list of models.""" + sp_1 = AbsSin(0.1, 1.0) + sp_2 = AbsSin(0.2, 2.0) + x, y, w = _xyw() + with pytest.raises(TypeError, match='fit_object must be an EasyScience model'): + Sampler([sp_1, sp_2], [sp_1, sp_2], [x, x], [y, y], [w, w]) def test_requires_weights(self): """Sampling has no default weighting, so weights are a required argument rather than a None that only blows up at sample().""" x, y, _ = _xyw() with pytest.raises(TypeError, match='weights'): - Sampler(_StubFitter(), [x], [y]) + Sampler(_StubModel(), [_identity], [x], [y]) def test_rejects_mixed_array_and_list(self): x, y, w = _xyw() with pytest.raises(ValueError, match='both be arrays or both be lists'): - Sampler(_StubFitter(), [x], y, [w]) + Sampler(_StubModel(), [_identity], [x], y, [w]) def test_rejects_dataset_count_mismatch(self): x, y, w = _xyw() with pytest.raises(ValueError, match='same number of datasets'): - Sampler(_StubFitter(), [x, x], [y], [w, w]) + Sampler(_StubModel(), [_identity, _identity], [x, x], [y], [w, w]) def test_rejects_weights_structure_mismatch(self): x, y, w = _xyw() with pytest.raises(ValueError, match='weights must match the structure'): - Sampler(_StubFitter(), [x], [y], w) + Sampler(_StubModel(), [_identity], [x], [y], w) def test_rejects_weights_count_mismatch(self): x, y, w = _xyw() with pytest.raises(ValueError, match='weights must hold the same number'): - Sampler(_StubFitter(), [x], [y], [w, w]) + Sampler(_StubModel(), [_identity], [x], [y], [w, w]) def test_rejects_non_bool_vectorized(self): x, y, w = _xyw() with pytest.raises(TypeError, match='vectorized must be a bool'): - Sampler(_StubFitter(), [x], [y], [w], vectorized=1) + Sampler(_StubModel(), [_identity], [x], [y], [w], vectorized=1) def test_rejects_non_dict_sampler_kwargs(self): x, y, w = _xyw() with pytest.raises(TypeError, match='sampler_kwargs must be a dict'): - Sampler(_StubFitter(), [x], [y], [w], sampler_kwargs=[('init', 'random')]) + Sampler(_StubModel(), [_identity], [x], [y], [w], sampler_kwargs=[('init', 'random')]) def test_accepts_single_arrays(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), x, y, w) + sampler = Sampler(_StubModel(), _identity, x, y, w) assert sampler.results is None class TestSamplerDataBinding: def test_properties_expose_bound_data(self): x, y, w = _xyw() - f = _StubFitter() - sampler = Sampler(f, [x], [y], [w]) - assert sampler.fitter is f - np.testing.assert_array_equal(sampler.x[0], x) - np.testing.assert_array_equal(sampler.y[0], y) - np.testing.assert_array_equal(sampler.weights[0], w) + model = _StubModel() + sampler = Sampler(model, _identity, x, y, w) + assert sampler.fit_object is model + assert sampler.fit_function is _identity + np.testing.assert_array_equal(sampler.x, x) + np.testing.assert_array_equal(sampler.y, y) + np.testing.assert_array_equal(sampler.weights, w) + + def test_properties_keep_list_structure(self): + """Multi-dataset inputs come back as lists, in the order given.""" + x, y, w = _xyw() + sampler = Sampler(_StubModel(), [_identity, _identity], [x, 2 * x], [y, y], [w, w]) + assert sampler.fit_function == [_identity, _identity] + assert len(sampler.x) == 2 + np.testing.assert_array_equal(sampler.x[1], 2 * x) def test_inputs_are_copied(self): """Mutating the caller's arrays after construction must not change the bound data (nor the save() fingerprint derived from it).""" x, y, w = _xyw() y_original = y.copy() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) fingerprint_before = sampler._fingerprint() y[:] = 0.0 @@ -166,7 +206,7 @@ def test_inputs_are_copied(self): def test_bound_arrays_are_read_only(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), x, y, w) + sampler = Sampler(_StubModel(), _identity, x, y, w) with pytest.raises(ValueError, match='read-only'): sampler.x[0] = 99.0 @@ -174,8 +214,8 @@ def test_data_properties_have_no_setters(self): """Bound data is deliberately immutable — sample new data with a new Sampler, so a chain can never be extended against different data.""" x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) - for name in ('fitter', 'x', 'y', 'weights'): + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) + for name in ('fit_object', 'fit_function', 'x', 'y', 'weights'): with pytest.raises(AttributeError): setattr(sampler, name, None) @@ -183,20 +223,20 @@ def test_data_properties_have_no_setters(self): class TestSamplerPathValidation: def test_save_rejects_non_pathlike(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) with pytest.raises(TypeError, match='path must be a str or os.PathLike'): sampler.save(123) def test_save_accepts_pathlike(self, tmp_path): """A Path object passes validation; the empty sampler then raises RuntimeError.""" x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) with pytest.raises(RuntimeError, match='No chain state to save'): sampler.save(tmp_path / 'chain') def test_load_state_rejects_non_pathlike(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) with pytest.raises(TypeError, match='path must be a str or os.PathLike'): sampler.load_state(123) @@ -213,41 +253,28 @@ def test_load_chain_rejects_bad_skip(self, tmp_path, skip): class TestSamplerErrorPaths: def test_sample_requires_bumps_package(self, monkeypatch): """sample() must raise RuntimeError when the bumps package is not - installed — regardless of the active minimizer — and must not touch - the fitter.""" - sp = AbsSin(0.354, 3.05) - f = MultiFitter([sp], [sp]) - + installed.""" x, y, w = _xyw() - sampler = Sampler(f, [x], [y], [w]) - minimizer_before = f.minimizer + sampler = Sampler(_StubModel(), _identity, x, y, w) monkeypatch.setattr( 'easyscience.fitting.available_minimizers.bumps_engine_available', False ) with pytest.raises(RuntimeError, match='requires the bumps package'): sampler.sample(samples=10, burn=5, thin=1) - assert f.minimizer is minimizer_before - - def test_fitter_untouched_on_error(self): - """The fitter is never mutated by sampling, even when the engine - raises.""" - f, _, x, y, weights = _fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) - original_func = f.fit_function - minimizer_before = f.minimizer - - # Invalid `samples` is rejected by the engine (single source of - # validation). + + def test_engine_argument_errors_propagate(self): + """Invalid ``samples`` is rejected by the engine (single source of + validation) and surfaces unchanged.""" + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) + with pytest.raises(ValueError, match='samples must be a positive integer'): sampler.sample(samples=-1, burn=5, thin=1) - assert f.fit_function is original_func - assert f.minimizer is minimizer_before - def test_extend_requires_existing_state(self): """extend() before sample()/load_state() raises RuntimeError.""" x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) with pytest.raises(RuntimeError, match='No chain to extend'): sampler.extend(additional_samples=10) @@ -255,7 +282,7 @@ def test_extend_requires_existing_state(self): def test_save_raises_without_state(self, tmp_path): """save() before sample() raises RuntimeError.""" x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) with pytest.raises(RuntimeError, match='No chain state to save'): sampler.save(str(tmp_path / 'chain')) @@ -264,7 +291,7 @@ def test_sample_warns_when_replacing_existing_chain(self, monkeypatch, caplog): """sample() over an existing chain logs a replace warning; a fresh sampler does not.""" x, y, w = _xyw() - sampler = Sampler(_StubFitter(), [x], [y], [w]) + sampler = Sampler(_StubModel(), [_identity], [x], [y], [w]) dummy = SamplingResults( draws=np.zeros((1, 1)), param_names=['p'], logp=np.zeros(1), state=object() @@ -356,40 +383,40 @@ class TestSamplerConstructorDataValidation: def test_rejects_scalar_x(self): _, y, w = _xyw() with pytest.raises(ValueError, match='x must be an array of values, got a scalar'): - Sampler(_StubFitter(), 5.0, y, w) + Sampler(_StubModel(), _identity, 5.0, y, w) def test_rejects_scalar_dataset_in_list(self): x, y, w = _xyw() with pytest.raises(ValueError, match=r'y\[1\] must be an array of values'): - Sampler(_StubFitter(), [x, x], [y, 3.0], [w, w]) + Sampler(_StubModel(), [_identity, _identity], [x, x], [y, 3.0], [w, w]) def test_rejects_string_data(self): x, _, w = _xyw() with pytest.raises(TypeError, match='y must hold numeric values'): - Sampler(_StubFitter(), x, 'abc', w) + Sampler(_StubModel(), _identity, x, 'abc', w) def test_rejects_non_numeric_object_array(self): _, y, _ = _xyw() with pytest.raises(TypeError, match='x must hold numeric values'): - Sampler(_StubFitter(), np.array([{}, {}], dtype=object), y, np.ones(2)) + Sampler(_StubModel(), _identity, np.array([{}, {}], dtype=object), y, np.ones(2)) def test_rejects_empty_array(self): with pytest.raises(ValueError, match='x must not be empty'): - Sampler(_StubFitter(), np.array([]), np.array([]), np.array([])) + Sampler(_StubModel(), _identity, np.array([]), np.array([]), np.array([])) def test_rejects_ragged_dataset(self): with pytest.raises(TypeError, match=r'x\[0\] could not be converted'): - Sampler(_StubFitter(), [[1.0, [2.0, 3.0]]], [np.zeros(3)], [np.ones(3)]) + Sampler(_StubModel(), [_identity], [[1.0, [2.0, 3.0]]], [np.zeros(3)], [np.ones(3)]) def test_rejects_scalar_weights(self): x, y, _ = _xyw() with pytest.raises(ValueError, match='weights must be an array of values'): - Sampler(_StubFitter(), x, y, 2.0) + Sampler(_StubModel(), _identity, x, y, 2.0) def test_rejects_none_weight_entry(self): x, y, w = _xyw() with pytest.raises(TypeError, match=r'weights\[1\] must hold numeric values'): - Sampler(_StubFitter(), [x, x], [y, y], [w, None]) + Sampler(_StubModel(), [_identity, _identity], [x, x], [y, y], [w, None]) class TestDataFingerprint: @@ -398,7 +425,7 @@ def test_returns_none_on_unhashable_data(self): def test_fingerprint_of_single_arrays(self): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), x, y, w) + sampler = Sampler(_StubModel(), _identity, x, y, w) assert isinstance(sampler._fingerprint(), str) @@ -407,7 +434,7 @@ class TestSamplerRunEngine: with the ``DreamSampler`` engine stubbed out.""" def test_run_stores_results_and_exposes_properties(self, monkeypatch): - f, _, x, y, weights = _fitter_and_data() + sp, x, y, weights = _model_and_data() from easyscience.fitting.samplers.sampler_bumps import DreamSampler canned = { @@ -424,10 +451,7 @@ def fake_run(self, **kwargs): monkeypatch.setattr(DreamSampler, 'run', fake_run) - sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'trim': False}) - original_func = f.fit_function - minimizer_before = f.minimizer - dims_before = f._dependent_dims + sampler = Sampler(sp, sp, x, y, weights, sampler_kwargs={'trim': False}) results = sampler.sample(samples=100, burn=10, thin=2, sampler_kwargs={'init': 'lhs'}) assert isinstance(results, SamplingResults) @@ -441,21 +465,12 @@ def fake_run(self, **kwargs): assert captured['samples'] == 100 assert captured['burn'] == 10 assert captured['resume_state'] is None - # The fitter is never mutated: a fresh engine gets the wrapped - # function directly, the active (LMFit) minimizer stays put, and the - # reshaping bookkeeping is passed to the wrapper rather than written - # onto the fitter. - assert f.fit_function is original_func - assert f.minimizer is minimizer_before - assert f._dependent_dims is dims_before - - def test_run_works_with_non_bumps_minimizer(self, monkeypatch): - """Sampling works with the default LMFit minimizer active — the - engine is constructed independently of the fitter's minimizer.""" - f, _, x, y, weights = _fitter_and_data() - from easyscience.fitting.samplers.sampler_bumps import DreamSampler - assert f.minimizer.package != 'bumps' # default is LMFit + def test_run_binds_engine_to_model_and_wrapped_function(self, monkeypatch): + """The engine is built from the sampler's own model and a wrapped + fit function; no Fitter or minimizer is involved.""" + sp, x, y, weights = _model_and_data() + from easyscience.fitting.samplers.sampler_bumps import DreamSampler constructed = {} original_init = DreamSampler.__init__ @@ -474,14 +489,48 @@ def spy_init(self, obj, fit_function): monkeypatch.setattr(DreamSampler, '__init__', spy_init) monkeypatch.setattr(DreamSampler, 'run', lambda self, **kwargs: dict(canned)) - sampler = Sampler(f, [x], [y], [weights]) + sampler = Sampler(sp, sp, x, y, weights) results = sampler.sample(samples=10, burn=0, thin=1) assert results.param_names == ['offset', 'phase'] - # The engine is bound to the fitter's model object and a wrapped - # fit function, not to the minimizer. - assert constructed['obj'] is f.fit_object + assert constructed['obj'] is sp assert callable(constructed['fit_function']) + assert constructed['fit_function'] is not sp + + +class TestSamplerFromFitter: + """``from_fitter`` mirrors direct construction and leaves the fitter alone.""" + + def test_from_plain_fitter(self): + sp, x, y, weights = _model_and_data() + f = Fitter(sp, sp) + + sampler = Sampler.from_fitter(f, x, y, weights, sampler_kwargs={'init': 'lhs'}) + + assert sampler.fit_object is sp + assert sampler.fit_function is sp + np.testing.assert_array_equal(sampler.x, x) + assert sampler._default_sampler_kwargs == {'init': 'lhs'} + + def test_from_multi_fitter(self): + sp_1 = AbsSin(0.1, 1.0) + sp_2 = AbsSin(0.2, 2.0) + x, y, w = _xyw() + f = MultiFitter([sp_1, sp_2], [sp_1, sp_2]) + + sampler = Sampler.from_fitter(f, [x, x], [y, y], [w, w]) + + # The fitter's container object exposes every model's parameters. + assert sampler.fit_object is f.fit_object + assert sampler.fit_function == [sp_1, sp_2] + assert f.fit_function is sp_1 # the fitter is untouched + + def test_multi_fitter_requires_list_data(self): + sp_1 = AbsSin(0.1, 1.0) + x, y, w = _xyw() + f = MultiFitter([sp_1], [sp_1]) + with pytest.raises(ValueError, match='fit_function must be a list of callables'): + Sampler.from_fitter(f, x, y, w) class TestSamplerExtendArithmetic: @@ -490,7 +539,7 @@ class TestSamplerExtendArithmetic: @staticmethod def _sampler_with_stub_state(monkeypatch, ngen=7, npop=5): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), x, y, w) + sampler = Sampler(_StubModel(), _identity, x, y, w) sampler._state = SimpleNamespace(Ngen=ngen, Npop=npop) captured = {} dummy = SamplingResults( @@ -530,7 +579,7 @@ class TestSamplerPersistenceRoundTrip: @staticmethod def _sampler_with_state(): x, y, w = _xyw() - sampler = Sampler(_StubFitter(), x, y, w) + sampler = Sampler(_StubModel(), _identity, x, y, w) state = _make_state() _draw = state.draw() sampler._state = state @@ -595,7 +644,7 @@ def test_load_state_populates_results(self, tmp_path, caplog): sampler.save(prefix) x, y, w = _xyw() - fresh = Sampler(_StubFitter(), x, y, w) + fresh = Sampler(_StubModel(), _identity, x, y, w) with caplog.at_level(logging.WARNING, logger='easyscience.fitting'): results = fresh.load_state(prefix) @@ -613,7 +662,7 @@ def test_load_state_warns_on_different_data(self, tmp_path, caplog): sampler.save(prefix) x, y, w = _xyw() - other = Sampler(_StubFitter(), x, 2.0 * y, w) + other = Sampler(_StubModel(), _identity, x, 2.0 * y, w) with caplog.at_level(logging.WARNING, logger='easyscience.fitting'): other.load_state(prefix) From 71e3327fe0761ddf8b6359bd899456dcf8c7b9c4 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 22 Sep 2026 09:48:41 +0200 Subject: [PATCH 24/25] CR review fixes --- docs/docs/tutorials/fitting-bayesian.ipynb | 8 +++----- src/easyscience/fitting/sampler.py | 7 ++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/docs/tutorials/fitting-bayesian.ipynb b/docs/docs/tutorials/fitting-bayesian.ipynb index ed90222e..695ca508 100644 --- a/docs/docs/tutorials/fitting-bayesian.ipynb +++ b/docs/docs/tutorials/fitting-bayesian.ipynb @@ -28,7 +28,7 @@ "\n", "where $\\theta$ are the model parameters, $d$ is the observed data, $p(d \\mid \\theta)$ is the likelihood, and $p(\\theta)$ is the prior. In `easyscience`, the `min`/`max` bounds of a `Parameter` are interpreted as a **uniform prior**, and a Gaussian likelihood is constructed from the data and supplied weights.\n", "\n", - "`easyscience` exposes a Bayesian Markov-chain Monte Carlo (MCMC) sampler through the `Sampler` class. It is a parallel entry point to `Fitter`: both take a model object and a model function, so you can sample without ever creating a `Fitter`. Under the hood `Sampler` uses BUMPS' DREAM sampler, so the `bumps` package must be installed.\n", + "`easyscience` exposes a Bayesian Markov-chain Monte Carlo (MCMC) sampler through the `Sampler` class.\n", "\n", "```{note}\n", "This tutorial focuses on Bayesian analysis with a simple QENS model for illustration. For dedicated QENS fitting with more sophisticated models, consider using [`EasyDynamics`](https://github.com/easyscience/easydynamics).\n", @@ -220,9 +220,7 @@ "source": [ "## Maximum-likelihood fit (optional, but recommended)\n", "\n", - "Perform a quick maximum-likelihood fit. This is **not** a prerequisite for sampling: `Sampler`\n", - "does not need a `Fitter` at all, and you can sample straight from the initial parameter\n", - "values.\n", + "Perform a quick maximum-likelihood fit.\n", "\n", "It is worth doing anyway, for two reasons:\n", "\n", @@ -261,7 +259,7 @@ "\n", "We now draw samples from the posterior distribution $p(\\theta \\mid d)$ using the BUMPS DREAM (DiffeRential Evolution Adaptive Metropolis) algorithm. DREAM is an ensemble MCMC method that runs multiple chains in parallel and automatically tunes the proposal distribution.\n", "\n", - "Create a `Sampler` from the same `parameter_container` and `intensity_model` we gave the `Fitter`, bound to the data. No `Fitter` is involved: the sampler only needs the model object, the model function, the data and the `bumps` package. Calling `sampler.sample()` returns a `SamplingResults` object with the following attributes:\n", + "Create a `Sampler` from the same `parameter_container` and `intensity_model` we gave the `Fitter`, bound to the data. Calling `sampler.sample()` returns a `SamplingResults` object with the following attributes:\n", "\n", "- `draws`: a `(n_samples, n_parameters)` array of posterior samples: each **row** is one complete draw from the joint posterior (one value for every parameter simultaneously), and each **column** holds all sampled values for a single parameter. Note this is a *trimmed* view of the chain rather than the raw buffer, so `n_samples` is smaller than `samples / thin` — see the note under [Extend the chain](#extend-the-chain-and-check-convergence);\n", "- `param_names`: the unique names of the parameters, in the same column order as `draws`;\n", diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 3d9ef589..e6ca3dfa 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -328,7 +328,12 @@ class Sampler: the whole chain:: sampler = Sampler( - model, model, x, y, weights=w, sampler_kwargs={'trim': False} + parameter_container, + model_function, + x, + y, + weights=w, + sampler_kwargs={'trim': False}, ) Note also that trimming does not survive a ``save()``/``load_state()`` From 922b2a3210b3225736ca6026bd35538a24e35e66 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 22 Sep 2026 11:23:02 +0200 Subject: [PATCH 25/25] Addressed #307: Fix EasyList to accept a list of parameters --- src/easyscience/base_classes/easy_list.py | 29 ++++++----- tests/unit/base_classes/test_easy_list.py | 60 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index f1f60acd..624983a0 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -30,7 +30,7 @@ class EasyList(ModelBase, MutableSequence[ProtectedType_]): # we would have to overwrite "extend", "remove", "__iadd__", "count", "append", "__iter__" and "clear" def __init__( self, - *args: ProtectedType_ | list[ProtectedType_], + *args: ProtectedType_ | Iterable[ProtectedType_], protected_types: list[Type[NewBase]] | Type[NewBase] | None = None, unique_name: Optional[str] = None, display_name: Optional[str] = None, @@ -41,14 +41,15 @@ def __init__( Parameters ---------- - *args : ProtectedType_ | list[ProtectedType_] + *args : ProtectedType_ | Iterable[ProtectedType_] Initial items to add to the list. protected_types : list[Type[NewBase]] | Type[NewBase] | None, default=None Types that are allowed in the list. Can be a single NewBase subclass or a list of them. If None, any ``NewBase`` object - is accepted, including descriptors and parameters. Note that - only ``ModelBase`` items contribute to ``get_all_variables`` - and hence to fitting. By default, None. + is accepted, including descriptors and parameters. Both bare + descriptors and ``ModelBase`` items contribute to + ``get_all_variables`` and hence to fitting. By default, + None. unique_name : Optional[str], default=None Optional unique name for the list. By default, None. display_name : Optional[str], default=None @@ -79,7 +80,7 @@ def __init__( # Add initial items for item in args: - if isinstance(item, list): + if isinstance(item, (list, tuple)): for sub_item in item: self.append(sub_item) else: @@ -269,12 +270,14 @@ def _get_key(self, obj: ProtectedType_) -> str: def get_all_variables(self) -> List[DescriptorBase]: """ - Get all ``Descriptor`` and ``Parameter`` objects from all - elements that are derived from ``ModelBase``. + Get all ``Descriptor`` and ``Parameter`` objects held by this + list. - For each element that is a ``ModelBase`` instance, the element's - own ``get_all_variables()`` method is called and the results are - collected into a single flat list. + Elements that are ``DescriptorBase`` instances (e.g. a bare + ``Parameter``) are collected directly, while elements derived + from ``ModelBase`` contribute the result of their own + ``get_all_variables()`` call. Everything is collected into a + single flat list. Returns ------- @@ -284,7 +287,9 @@ def get_all_variables(self) -> List[DescriptorBase]: """ all_vars: List[DescriptorBase] = [] for item in self._data: - if isinstance(item, ModelBase): + if isinstance(item, DescriptorBase): + all_vars.append(item) + elif isinstance(item, ModelBase): all_vars.extend(item.get_all_variables()) return all_vars diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 69b9bf6e..b1a2e3d2 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -638,6 +638,66 @@ def test_get_all_variables_returns_descriptorbase_instances(self): assert isinstance(v, DescriptorBase) + def test_get_all_variables_bare_parameters(self): + """Bare Parameters passed to the constructor should be collected directly.""" + p1 = Parameter('a', value=1.0) + p2 = Parameter('b', value=2.0) + el = EasyList(p1, p2) + vars = el.get_all_variables() + assert vars == [p1, p2] + assert el.get_fit_parameters() == [p1, p2] + + def test_get_all_variables_bare_descriptors(self): + """Bare DescriptorNumbers are collected as variables but are not fit parameters.""" + p1 = Parameter('a', value=1.0) + d1 = DescriptorNumber('b', value=2.0) + el = EasyList(p1, d1) + assert el.get_all_variables() == [p1, d1] + assert el.get_all_parameters() == [p1] + assert el.get_fit_parameters() == [p1] + + def test_get_all_variables_fixed_parameter_not_fitted(self): + """A fixed bare Parameter is returned as a variable but not as a fit parameter.""" + free = Parameter('a', value=1.0) + fixed = Parameter('b', value=2.0, fixed=True) + el = EasyList(free, fixed) + assert el.get_all_variables() == [free, fixed] + assert el.get_fit_parameters() == [free] + + def test_get_all_variables_parameters_from_list(self): + """A plain list of Parameters should be flattened and collected.""" + p1 = Parameter('a', value=1.0) + p2 = Parameter('b', value=2.0) + el = EasyList([p1, p2]) + assert len(el) == 2 + assert el.get_all_variables() == [p1, p2] + + def test_get_all_variables_parameters_from_tuple(self): + """A plain tuple of Parameters should be flattened and collected.""" + p1 = Parameter('a', value=1.0) + p2 = Parameter('b', value=2.0) + el = EasyList((p1, p2)) + assert len(el) == 2 + assert el.get_all_variables() == [p1, p2] + + def test_get_all_variables_bare_parameters_and_model(self): + """Bare Parameters and ModelBase items should both contribute variables.""" + p1 = Parameter('a', value=1.0) + m1 = MockModel(unique_name='m1', temperature=10, volume=5.0) + el = EasyList(p1, m1) + vars = el.get_all_variables() + assert len(vars) == 3 + assert vars[0] is p1 + assert {v.name for v in vars[1:]} == {'temperature', 'volume'} + + def test_get_all_variables_bare_parameters_in_nested_easylist(self): + """Bare Parameters inside a nested EasyList should be collected by the outer list.""" + p1 = Parameter('a', value=1.0) + p2 = Parameter('b', value=2.0) + inner_list = EasyList(p1) + outer_list = EasyList(inner_list, p2) + assert outer_list.get_all_variables() == [p1, p2] + def test_get_all_variables_nested_easylist(self): """An EasyList containing another EasyList with mixed NewBase/ModelBase elements should collect variables from the inner EasyList's ModelBase items,