diff --git a/docs/docs/api-reference/index.md b/docs/docs/api-reference/index.md index 4bd6f561..1a035c29 100644 --- a/docs/docs/api-reference/index.md +++ b/docs/docs/api-reference/index.md @@ -8,8 +8,8 @@ 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`). - [fitting](fitting.md) – Fitting utilities and interfaces, including `Fitter` and available minimizers. - [global_object](global_object.md) – Global singleton providing shared @@ -23,4 +23,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/docs/docs/tutorials/fitting-bayesian.ipynb b/docs/docs/tutorials/fitting-bayesian.ipynb index 302800c2..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. 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.\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": [ @@ -217,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", - "needs a *configured* `Fitter`, not a *fitted* one, and you can sample straight from the initial\n", - "parameter values.\n", + "Perform a quick maximum-likelihood fit.\n", "\n", "It is worth doing anyway, for two reasons:\n", "\n", @@ -239,9 +240,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 +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", - "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. 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 +272,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 +283,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 +297,7 @@ }, { "cell_type": "markdown", - "id": "8766b170", + "id": "15", "metadata": {}, "source": [ "## Convergence diagnostics\n", @@ -323,7 +313,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3c49ab6f", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -359,7 +349,7 @@ }, { "cell_type": "markdown", - "id": "15", + "id": "17", "metadata": {}, "source": [ "## Posterior summaries\n", @@ -377,7 +367,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ce3e38a8", + "id": "18", "metadata": {}, "outputs": [], "source": [] @@ -385,7 +375,7 @@ { "cell_type": "code", "execution_count": null, - "id": "16", + "id": "19", "metadata": {}, "outputs": [], "source": [ @@ -407,7 +397,7 @@ }, { "cell_type": "markdown", - "id": "17", + "id": "20", "metadata": {}, "source": [ "## Visualise the joint posterior\n", @@ -418,7 +408,7 @@ { "cell_type": "code", "execution_count": null, - "id": "18", + "id": "21", "metadata": {}, "outputs": [], "source": [ @@ -452,7 +442,7 @@ }, { "cell_type": "markdown", - "id": "19", + "id": "22", "metadata": {}, "source": [ "## Posterior-predictive band\n", @@ -463,7 +453,7 @@ { "cell_type": "code", "execution_count": null, - "id": "20", + "id": "23", "metadata": {}, "outputs": [], "source": [ @@ -499,7 +489,7 @@ }, { "cell_type": "markdown", - "id": "03339658", + "id": "24", "metadata": {}, "source": [ "## Extend the chain and check convergence\n", @@ -543,7 +533,7 @@ { "cell_type": "code", "execution_count": null, - "id": "293b140b", + "id": "25", "metadata": {}, "outputs": [], "source": [ @@ -573,7 +563,7 @@ { "cell_type": "code", "execution_count": null, - "id": "9ec4302c", + "id": "26", "metadata": {}, "outputs": [], "source": [ @@ -614,7 +604,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b0f30be6", + "id": "27", "metadata": {}, "outputs": [], "source": [ @@ -641,7 +631,7 @@ }, { "cell_type": "markdown", - "id": "50b7213a", + "id": "28", "metadata": {}, "source": [ "### What is Gelman-Rubin R-hat?\n", @@ -675,7 +665,7 @@ { "cell_type": "code", "execution_count": null, - "id": "3449e0a7", + "id": "29", "metadata": {}, "outputs": [], "source": [ 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/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 23ac673d..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,11 +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,. By default, None. + subclass or a list of them. If None, any ``NewBase`` object + 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 @@ -76,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: @@ -266,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 ------- @@ -281,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/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/engine_base.py b/src/easyscience/fitting/engine_base.py new file mode 100644 index 00000000..edfd58e6 --- /dev/null +++ b/src/easyscience/fitting/engine_base.py @@ -0,0 +1,242 @@ +# 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 + +from easyscience.variable import Parameter + +PARAMETER_PREFIX = 'p' + + +class EngineBase(metaclass=ABCMeta): + """ + 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 this shared + system: the parameter cache, the ``Parameter``-writing wrapped + fit function, and value restore on failure. It deliberately declares + no abstract methods: the interfaces are defined on its subclasses + (``MinimizerBase.fit``, ``DreamSampler.run``). + """ + + package: str = None + + def __init__( + self, + obj, + fit_function: Callable, + ): + 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] + + @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: + """ + 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. + 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 ``parameters`` is not 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() + + parameters = self._prepare_parameters(parameters) + + return self._fit_function(x, **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..a1dd64e0 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 @@ -16,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 @@ -43,9 +44,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: """ @@ -211,6 +209,9 @@ def fit_object(self) -> object: """ Get the EasyScience object used as a model. + For a ``Fitter`` this is not one of the supplied fit + objects but a read-only, indexable and iterable aggregate. + Returns ------- object @@ -232,17 +233,17 @@ 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: np.ndarray | None = None, + flatten: bool = True, ) -> 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 ---------- - 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. @@ -252,18 +253,7 @@ def _fit_function_wrapper( 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: @@ -281,9 +271,9 @@ 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], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, **kwargs, ) -> FitResults: """ @@ -325,72 +315,7 @@ def inner_fit_callable( return inner_fit_callable - @staticmethod - def _precompute_reshaping( - x: np.ndarray, - y: np.ndarray, - weights: Optional[np.ndarray], - vectorized: bool, - ) -> tuple[np.ndarray, np.ndarray, np.ndarray, Optional[np.ndarray], 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 : Optional[np.ndarray] - 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, ...]] - 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( @@ -418,112 +343,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 - ``MultiFitter`` instance. - - 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 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 diff --git a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py index 35255615..82b5dfc6 100644 --- a/src/easyscience/fitting/minimizers/bumps_utils/__init__.py +++ b/src/easyscience/fitting/minimizers/bumps_utils/__init__.py @@ -2,6 +2,8 @@ # 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 - -__all__ = ['BumpsProgressMonitor', 'EvalCounter'] 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..df471cfc --- /dev/null +++ b/src/easyscience/fitting/minimizers/bumps_utils/problem.py @@ -0,0 +1,134 @@ +# 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, +) -> 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 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``. + + Returns + ------- + tuple[FitProblem, EvalCounter, Curve] + The assembled problem, the evaluation counter wrapping the fit + 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()) + + 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 + + +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/minimizer_base.py b/src/easyscience/fitting/minimizers/minimizer_base.py index 48eef544..e598a678 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,17 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers +from ..engine_base import EngineBase from .utils import FitError from .utils import FitResults -MINIMIZER_PARAMETER_PREFIX = 'p' - -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 +32,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,23 +45,16 @@ 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, x: np.ndarray, 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, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, **kwargs, ) -> FitResults: """ @@ -89,18 +68,16 @@ 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. - 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 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. @@ -110,50 +87,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(): @@ -165,24 +98,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]: @@ -207,120 +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. - """ - - 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..34e4587b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -3,8 +3,6 @@ from __future__ import annotations -import copy -import math from typing import TYPE_CHECKING from typing import Any from typing import Callable @@ -13,28 +11,31 @@ 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 +from scipy.optimize import OptimizeResult # causes circular import when Parameter is imported # from easyscience.base_classes import ObjBase from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers +from ..engine_base import PARAMETER_PREFIX from .bumps_utils import BumpsProgressMonitor from .bumps_utils import EvalCounter -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX +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 .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults 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. +FIT_AVAILABLE_IDS_FILTERED = [fit_id for fit_id in FIT_AVAILABLE_IDS if fit_id != 'pt'] class Bumps(MinimizerBase): @@ -48,10 +49,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. @@ -70,7 +71,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]: @@ -83,12 +85,10 @@ def fit( x: np.ndarray, 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, - 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, @@ -105,10 +105,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. - 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 @@ -118,17 +114,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 - Optional callback for progress updates. The payload field - ``iteration`` carries the BUMPS optimizer step index. By - default, None. + progress_callback : Callable[[dict], None] | None, default=None + 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 + 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 @@ -137,74 +138,74 @@ 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`` 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 + 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) 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.') + self.validate_arrays(x, y, weights) - 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.') + 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 = {} + 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) - # 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). 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. + 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: - model_function = self._make_model(parameters=parameters) - model = model_function(x, y, weights) + # 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()} - problem = FitProblem(model) - 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) ) @@ -229,25 +230,52 @@ 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 + 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 = 'Fit converged' # 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, @@ -255,27 +283,45 @@ 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): + 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. + """ for fitclass in FITTERS: if fitclass.id == method: return fitclass raise FitError(f'Unknown BUMPS fitting method: {method}') def _build_progress_payload( - self, problem, 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. 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,434 +332,11 @@ 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 - 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.__class__.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: - """ - Convert an ``EasyScience.variable.Parameter`` object to a bumps - Parameter object. - - Parameters - ---------- - obj : Parameter - EasyScience parameter to convert. - - Returns - ------- - 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) - - 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], 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 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 - 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], 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 - 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, - ) - 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, 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 @@ -725,24 +348,25 @@ 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 ``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) - stderr = np.asarray(fit_result.dx) + 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 = None if stderr is None else stderr[index] if stack_status: global_object.stack.endMacro() @@ -774,8 +398,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 @@ -801,41 +427,36 @@ 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 - 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 - results.message = '' + results.message = '' if fit_results.success else fit_results.message + 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..21cc2a1b 100644 --- a/src/easyscience/fitting/minimizers/minimizer_dfo.py +++ b/src/easyscience/fitting/minimizers/minimizer_dfo.py @@ -15,7 +15,7 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX +from ..engine_base import PARAMETER_PREFIX from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -78,12 +78,10 @@ def fit( x: np.ndarray, 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, - progress_callback: Callable[[dict], bool | None] | None = None, + progress_callback: Callable[[dict], None] | None = None, callback: Callable[[DFOCallbackState], None] | None = None, **kwargs, ) -> FitResults: @@ -98,18 +96,15 @@ 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. - 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 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 : @@ -126,39 +121,17 @@ 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) - 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.') + self.validate_arrays(x, y, weights) # Bridge progress_callback into the DFO callback mechanism 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( - parameters=parameters, - 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), - 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 @@ -187,18 +160,8 @@ 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.""" - pass - def _make_model( self, - parameters: List[Parameter] | None = None, callback: Callable[[DFOCallbackState], None] | None = None, ) -> Callable: """ @@ -208,8 +171,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. @@ -223,13 +184,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[MINIMIZER_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 + 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()): @@ -246,13 +204,6 @@ 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 [MINIMIZER_PARAMETER_PREFIX + parameter.unique_name for parameter in parameters] - return [MINIMIZER_PARAMETER_PREFIX + name for name in self._cached_pars.keys()] - @staticmethod def _wrap_model_with_callback( model: Callable, @@ -302,7 +253,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 +261,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 ------- @@ -324,7 +275,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 = { @@ -411,7 +362,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 31fb543f..ea226b5e 100644 --- a/src/easyscience/fitting/minimizers/minimizer_lmfit.py +++ b/src/easyscience/fitting/minimizers/minimizer_lmfit.py @@ -16,7 +16,7 @@ from easyscience.variable import Parameter from ..available_minimizers import AvailableMinimizers -from .minimizer_base import MINIMIZER_PARAMETER_PREFIX +from ..engine_base import PARAMETER_PREFIX from .minimizer_base import MinimizerBase from .utils import FitError from .utils import FitResults @@ -85,12 +85,10 @@ def fit( x: np.ndarray, 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, - 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, @@ -106,18 +104,15 @@ 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. - 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 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. @@ -137,22 +132,10 @@ 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) - 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.') + self.validate_arrays(x, y, weights) if engine_kwargs is None: engine_kwargs = {} @@ -167,8 +150,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) @@ -194,7 +176,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: @@ -218,12 +200,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) @@ -241,65 +223,17 @@ 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 ['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 - 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: - """ - 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( - MINIMIZER_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 @@ -326,7 +260,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(): @@ -336,7 +270,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, @@ -366,11 +300,15 @@ 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: - 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/src/easyscience/fitting/multi_fitter.py b/src/easyscience/fitting/multi_fitter.py index e2d88943..1e9ccbfd 100644 --- a/src/easyscience/fitting/multi_fitter.py +++ b/src/easyscience/fitting/multi_fitter.py @@ -1,13 +1,17 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause +from collections.abc import Sequence from typing import Callable import numpy as np -from ..base_classes import CollectionBase +from ..base_classes import EasyList +from ..base_classes import ModelBase from .fitter import Fitter from .minimizers import FitResults +from .reshaping import inject_x_multi +from .reshaping import reshape_datasets class MultiFitter(Fitter): @@ -20,28 +24,54 @@ class MultiFitter(Fitter): The inherited ``fit`` wrapper from ``Fitter`` is used unchanged, including support for forwarding progress callbacks to the active minimizer. + """ 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, ): - # Create a dummy core object to hold all the fit objects. - self._fit_objects = CollectionBase('multi', *fit_objects) - self._fit_functions = fit_functions + # 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, 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. - 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) + + @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 + self, + real_x: list[np.ndarray] | None = None, + flatten: bool = True, ) -> Callable: """ - Simple fit function which injects the N real X (independent) - values into the optimizer function. - - This will also flatten the results if needed. + 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 ---------- @@ -56,81 +86,9 @@ def _fit_function_wrapper( Callable Wrapped optimizer function. """ - # Extract of a list of callable functions - wrapped_fns = [] - for this_x, this_fun in zip(real_x, self._fit_functions): - self._fit_function = this_fun - wrapped_fns.append(Fitter._fit_function_wrapper(self, this_x, flatten=flatten)) - - def wrapped_fun(x, **kwargs): - # Generate an empty Y based on x - y = np.zeros_like(x) - i = 0 - # Iterate through wrapped functions, passing the WRONG x, the correct - # x was injected in the step above. - for idx, dim in enumerate(self._dependent_dims): - ep = i + np.prod(dim) - y[i:ep] = wrapped_fns[idx](x, **kwargs) - i = ep - return y - - return wrapped_fun - - @staticmethod - def _precompute_reshaping( - x: list[np.ndarray], - y: list[np.ndarray], - weights: 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. - - 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. + return inject_x_multi(self._fit_functions, real_x, self._dependent_dims, flatten=flatten) - 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 d3915115..e6ca3dfa 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -15,7 +15,9 @@ from easyscience import global_object -from .minimizers.minimizer_base import MINIMIZER_PARAMETER_PREFIX +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 @@ -51,7 +53,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 +68,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 +79,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 +104,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) @@ -173,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. @@ -218,9 +212,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 ] @@ -251,19 +243,9 @@ 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 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,32 +256,27 @@ 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. - - 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. - - The sampler is BUMPS/DREAM-specific for now: the BUMPS check in ``_run()`` - is the seam where another backend would plug in. + ``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``) whose minimizer has been - switched to ``AvailableMinimizers.Bumps``. + 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``). - weights : np.ndarray | list[np.ndarray | None] | None, default=None - Weight 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, 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 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. @@ -311,14 +288,14 @@ class Sampler: Raises ------ TypeError - If ``fitter`` is not Fitter-shaped (no ``minimizer``/``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 ----- @@ -351,7 +328,12 @@ class Sampler: the whole chain:: sampler = Sampler( - fitter, 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()`` @@ -362,76 +344,149 @@ 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 | 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')): - 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 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)) != is_multi: + raise ValueError( + 'weights must match the structure of x and y (array or list of arrays).' + ) + 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)}.' + ) _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 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 | None] | None: - """The bound weight data (read-only copy, or None).""" - return list(self._weights) if isinstance(self._weights, list) else self._weights + def weights(self) -> np.ndarray | list[np.ndarray]: + """The bound weight data (read-only copy).""" + return self._single_or_list(self._weights) @property def state(self) -> MCMCDraw | None: @@ -460,15 +515,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] - 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] - return _data_fingerprint(x_list, y_list, w_list) + return _data_fingerprint(self._x, self._y, self._weights) def _run( self, @@ -478,52 +525,46 @@ 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()``. 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_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 ) - self._fitter._dependent_dims = dims - wrapped = self._fitter._fit_function_wrapper(x_new, flatten=True) + wrapped = inject_x_multi(self._fit_functions, x_new, dims) 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 + # 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, + 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'], @@ -542,7 +583,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. @@ -571,9 +612,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. @@ -591,7 +633,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( @@ -615,7 +657,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. @@ -646,8 +688,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. @@ -660,7 +703,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 ----- @@ -705,7 +749,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 ---------- @@ -748,7 +792,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/src/easyscience/fitting/samplers/__init__.py b/src/easyscience/fitting/samplers/__init__.py new file mode 100644 index 00000000..a617b861 --- /dev/null +++ b/src/easyscience/fitting/samplers/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# 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 new file mode 100644 index 00000000..b9722066 --- /dev/null +++ b/src/easyscience/fitting/samplers/sampler_bumps.py @@ -0,0 +1,374 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""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 + +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.utils import FitError +from .validation import validate_run_settings + +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 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. + + ``DreamSampler`` is internal. The public entry point is + :class:`easyscience.fitting.Sampler`. + """ + + package = 'bumps' + + def __init__( + self, + obj: object, #: 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, + 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. + + 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 + 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 + 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. 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 + ``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``. 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 + 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 + + x, y, weights = np.asarray(x), np.asarray(y), np.asarray(weights) + + validate_run_settings(samples, burn, thin) + 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) + + 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, + partial(self._build_sample_progress_payload, 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). + 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: FitProblem, + iteration: int, + point: np.ndarray | None, + nllf: float, + total_steps: int, + ) -> dict: + """ + Build a progress payload for Bayesian DREAM sampling steps. + + Called by :class:`BumpsProgressMonitor` at each DREAM + 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. + 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, + 'total_steps': total_steps, + } diff --git a/src/easyscience/fitting/samplers/validation.py b/src/easyscience/fitting/samplers/validation.py new file mode 100644 index 00000000..00fae214 --- /dev/null +++ b/src/easyscience/fitting/samplers/validation.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Run-settings validation for the DREAM sampler.""" + +from __future__ import annotations + + +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. + """ + # 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 isinstance(burn, bool) or burn < 0: + 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.') 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 157117d8..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 @@ -99,7 +97,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..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. @@ -472,14 +470,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..e2214d3a 100644 --- a/src/easyscience/variable/descriptor_base.py +++ b/src/easyscience/variable/descriptor_base.py @@ -5,14 +5,16 @@ 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 -class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta): +class DescriptorBase(NewBase, metaclass=abc.ABCMeta): """ This is the base of all variable descriptions for models. @@ -29,7 +31,7 @@ class DescriptorBase(SerializerComponent, metaclass=abc.ABCMeta): _global_object = global_object # Used by serializer - _REDIRECT = {'parent': None} + _REDIRECT = {} def __init__( self, @@ -38,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. @@ -63,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 ------ @@ -74,17 +72,12 @@ 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 + 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,13 +91,6 @@ 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) - @property def name(self) -> str: """ @@ -142,6 +128,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 +223,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: @@ -281,9 +236,3 @@ def value(self, value: Any) -> None: @abc.abstractmethod 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 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 5bcbcb0d..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. @@ -444,8 +442,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/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 3ec654f7..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) ) @@ -903,11 +898,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/integration/fitting/test_fitter.py b/tests/integration/fitting/test_fitter.py index df17a65c..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,8 +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()): - # 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) + # 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) @@ -330,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") @@ -354,6 +361,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', @@ -380,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 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 032cc39f..2e428907 100644 --- a/tests/integration/fitting/test_sampler.py +++ b/tests/integration/fitting/test_sampler.py @@ -12,48 +12,67 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter +from easyscience.base_classes import ModelBase +from easyscience.fitting import Fitter 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 - 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) + @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)) + + +class AbsSin2D(AbsSin): def __call__(self, x): X = x[:, :, 0] # x is a 2D array Y = x[:, :, 1] @@ -62,8 +81,13 @@ def __call__(self, x): ) -def _bumps_fitter_and_data(): - """Build a 2-parameter BUMPS MultiFitter over a small sine model.""" +def _model_and_data(): + """Build a 2-parameter sine model and a small dataset to sample. + + 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) sp = AbsSin(0.354, 3.05) sp.offset.fixed = False @@ -71,22 +95,17 @@ def _bumps_fitter_and_data(): x = np.linspace(0, 5, 50) 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 + 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; to_legacy_dict() has the legacy shape.""" - f, sp, x, y, weights = _bumps_fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) + """sample() returns a populated SamplingResults, cached on the sampler.""" + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, x, y, weights) results = sampler.sample(samples=100, burn=20, thin=2) @@ -106,14 +125,9 @@ 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.""" + """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) @@ -133,13 +147,14 @@ def test_sample_multi_dataset(self): sp_sin_1.phase.fixed = False 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]) - try: - f.switch_minimizer('Bumps') - except AttributeError: - pytest.skip('BUMPS is not installed') - - 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 @@ -149,8 +164,8 @@ 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() - 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 @@ -169,13 +184,8 @@ def test_sample_vectorized_2d(self): sp.offset.fixed = False sp.phase.fixed = False - 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) + pytest.importorskip('bumps') + sampler = Sampler(sp, sp, x2D, y2D, weights, vectorized=True) results = sampler.sample(samples=100, burn=20, thin=2) assert results.draws.ndim == 2 @@ -183,20 +193,53 @@ 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 = _bumps_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 = Sampler.from_fitter(f, x, y, weights) + results = sampler.sample(samples=100, burn=20, thin=2) - sampler.sample(samples=100, burn=20, thin=2) - assert f.fit_function is original_func + assert results.draws.shape[0] > 0 + assert f.fit_function is sp + assert f.minimizer.package == 'lmfit' + + @pytest.mark.filterwarnings('ignore::UserWarning') + 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) + 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.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 = _bumps_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'}) @@ -204,34 +247,34 @@ 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() - sampler = Sampler(f, [x], [y], [weights], sampler_kwargs={'init': 'random'}) + from easyscience.fitting.samplers.sampler_bumps import DreamSampler + + sp, x, y, weights = _model_and_data() + sampler = Sampler(sp, sp, 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) + + 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 - finally: - type(f.minimizer).mcmc_sample = original_mcmc_sample + 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_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() - 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] @@ -250,8 +293,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 = _bumps_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] @@ -264,8 +307,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 = _bumps_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) @@ -282,14 +325,14 @@ def test_extend_after_save_load_roundtrip(self, tmp_path, caplog): """ import logging - f, _, x, y, weights = _bumps_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] @@ -308,8 +351,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 = _bumps_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 @@ -327,8 +370,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 = _bumps_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) @@ -344,14 +387,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 = _bumps_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) @@ -377,22 +420,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 = _bumps_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 = _bumps_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') @@ -401,7 +444,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/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_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, 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/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..d1208138 --- /dev/null +++ b/tests/unit/fitting/minimizers/bumps_utils/test_problem.py @@ -0,0 +1,166 @@ +# 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_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/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 28d4a049..4c29f420 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']) @@ -65,17 +72,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() @@ -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 @@ -104,7 +110,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 == {} + 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 +130,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 +148,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 = { @@ -179,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 @@ -195,6 +174,21 @@ 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 ``error=None`` (no uncertainty information) + rather than a misleading ``0.0``.""" + 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 is None + def test_gen_fit_results( self, minimizer: Bumps, monkeypatch, caplog: 'pytest.LogCaptureFixture' ): @@ -258,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( @@ -302,6 +296,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') @@ -334,16 +373,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,70 +400,10 @@ 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 - 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), - ) - - minimizer._make_model = MagicMock() - 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)} - - 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' - minimizer._make_model.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() - def test_fit_rejects_non_callable_progress_callback( self, minimizer: Bumps, monkeypatch ) -> None: @@ -442,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', ) @@ -522,60 +498,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 - ) -> 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_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 +544,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,532 +602,219 @@ def test_gen_fit_results_uses_nit_for_budget_check( # =================================================================== -# Bumps.mcmc_sample() — Bayesian DREAM sampling +# fit() — tolerance / budget defaults are reported, never forced # =================================================================== -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() +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='amoeba'), + minimizer_enum=MagicMock(package='bumps', method='newton'), ) - @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. - """ + @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.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.fit = MagicMock(return_value=(np.array([42.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_driver.monitor_runner.history.step = [0] 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, + 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())), ) - 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])) + 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_sample_driver_exception_restores_parameters( + def test_tolerance_none_does_not_override_fitter_defaults( 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() + mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch) - 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.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0])) - minimizer._restore_parameter_values.assert_called_once() + # 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 - 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())) + # ...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 - minimizer.mcmc_sample( + 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([0.1]), + y=np.array([2.0]), weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - population=7, + tolerance=1e-3, + max_evaluations=11, ) - call_kwargs = mock_FitDriver.call_args.kwargs - assert call_kwargs['pop'] == 7 + 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_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())) + def test_minimizer_kwargs_is_not_mutated(self, minimizer: Bumps, monkeypatch) -> None: + mock_FitDriver = self._patch_driver_and_problem(minimizer, monkeypatch) - minimizer.mcmc_sample( + minimizer_kwargs = {'existing': 'value'} + minimizer.fit( x=np.array([1.0]), - y=np.array([0.1]), + y=np.array([2.0]), weights=np.array([1.0]), - samples=10, - burn=0, - thin=1, - sampler_kwargs={'trim': False}, + tolerance=1e-3, + max_evaluations=11, + minimizer_kwargs=minimizer_kwargs, + engine_kwargs={'engine': 'option'}, ) - assert mock_FitDriver.call_args.kwargs['trim'] is False - - 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', - ) + # Both mappings reach the driver... + driver_kwargs = mock_FitDriver.call_args.kwargs + assert driver_kwargs['existing'] == 'value' + assert driver_kwargs['engine'] == 'option' - # --- 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 + # ...and the caller's mapping is untouched, so reusing it cannot leak + # settings from one fit into the next. + assert minimizer_kwargs == {'existing': 'value'} - 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 +# =================================================================== +# fit() — unsuccessful and aborted outcomes +# =================================================================== - 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, +class TestFitUnsuccessfulOutcomes: + @pytest.fixture + def minimizer(self) -> Bumps: + return Bumps( + obj='obj', + fit_function='fit_function', + minimizer_enum=MagicMock(package='bumps', method='amoeba'), ) - 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 + @staticmethod + def _patch(minimizer: Bumps, monkeypatch, driver_result, history_step=None) -> MagicMock: + from easyscience import global_object - def test_sample_resume_param_mismatch_raises(self, minimizer: Bumps, monkeypatch) -> None: - """Parameter count mismatch raises ValueError before driver.fit().""" - import bumps.names + 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( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a'])), + easyscience.fitting.minimizers.minimizer_bumps, + 'FitDriver', + MagicMock(return_value=mock_driver), ) - 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 + mock_problem = MagicMock() + mock_problem._parameters = [] monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), + easyscience.fitting.minimizers.minimizer_bumps, + 'build_curve_problem', + MagicMock(return_value=(mock_problem, MagicMock(count=3), MagicMock())), ) - 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) + 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() - 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, - ) + result = minimizer.fit(x=np.array([1.0]), y=np.array([2.0]), weights=np.array([1.0])) - 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 + 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() - monkeypatch.setattr( - bumps.names, - 'FitProblem', - MagicMock(return_value=self._make_problem_with_parameters(['a', 'b'])), + 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] ) - 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'])), + result = minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + abort_test=lambda: True, ) - 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 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() - assert 'does not carry parameter names' in caplog.text - assert mock_FitDriver.call_args.kwargs['pop'] == -10 + 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])) -# =================================================================== -# _build_sample_progress_payload -# =================================================================== + assert result == 'result' + assert minimizer._gen_fit_results.call_args.args[0].nit is None - -class TestBuildSampleProgressPayload: - @pytest.fixture - def minimizer(self) -> Bumps: - return Bumps( - obj='obj', - fit_function='fit_function', - minimizer_enum=MagicMock(package='bumps', method='amoeba'), + 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] ) - 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)} - - 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', - } - 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() - - # 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 - ) + minimizer.fit( + x=np.array([1.0]), + y=np.array([2.0]), + weights=np.array([1.0]), + abort_test=lambda: False, + ) - mock_bpp.assert_called_once_with(mock_problem, 3, np.array([7.0]), 21.0) - assert result == {**base_payload, 'sampling': True} + passed = minimizer._gen_fit_results.call_args.args[0] + assert passed.success is True + assert passed.message == 'Fit converged' + assert passed.nit == 7 # =================================================================== @@ -1239,13 +845,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 @@ -1254,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 # =================================================================== @@ -1319,11 +887,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/minimizers/test_minimizer_dfo.py b/tests/unit/fitting/minimizers/test_minimizer_dfo.py index 823dcce5..f083412a 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,78 +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, - ) - - 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}, - ) + minimizer._make_model.assert_called_once_with(callback=callback) def test_generate_fit_function(self, minimizer: DFO) -> None: # When @@ -219,14 +145,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 +172,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,22 +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 - @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)} - - parameter_names = minimizer._get_callback_parameter_names(parameters) - - assert parameter_names == expected_names - 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 781893d9..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() @@ -212,6 +194,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) @@ -229,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() @@ -252,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 @@ -385,86 +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_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() - 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 = { @@ -517,9 +457,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 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_bumps.py b/tests/unit/fitting/samplers/test_sampler_bumps.py new file mode 100644 index 00000000..76219444 --- /dev/null +++ b/tests/unit/fitting/samplers/test_sampler_bumps.py @@ -0,0 +1,490 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for ``DreamSampler`` — mirrors +``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 +``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 +from unittest.mock import MagicMock + +import numpy as np +import pytest + +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 +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_bumps, + '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_bumps, '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 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]), + 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, 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} + 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, total_steps=50 + ) + + expected_keys = { + 'iteration', + 'chi2', + 'reduced_chi2', + 'parameter_values', + 'refresh_plots', + 'finished', + 'sampling', + 'total_steps', + } + assert set(payload.keys()) == expected_keys diff --git a/tests/unit/fitting/samplers/test_validation.py b/tests/unit/fitting/samplers/test_validation.py new file mode 100644 index 00000000..6a4b5dc0 --- /dev/null +++ b/tests/unit/fitting/samplers/test_validation.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Unit tests for the DREAM run-settings validation helper.""" + +import pytest + +from easyscience.fitting.samplers.validation 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'), + # 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): + 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) diff --git a/tests/unit/fitting/test_engine_base.py b/tests/unit/fitting/test_engine_base.py new file mode 100644 index 00000000..92886ccf --- /dev/null +++ b/tests/unit/fitting/test_engine_base.py @@ -0,0 +1,47 @@ +# 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 EngineBase + +validate_arrays = EngineBase.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'), + ({'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): + 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 ac897653..3af44358 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 @@ -142,6 +172,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 # =================================================================== @@ -165,3 +222,95 @@ def test_weights_all_none_returns_none(self): assert w_new is None assert len(dims) == 2 + + # =================================================================== + # 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_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; 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]) + + 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) + + # =================================================================== + # Constructor defaults + # =================================================================== + + 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] 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 665c1d66..e2151741 100644 --- a/tests/unit/fitting/test_sampler.py +++ b/tests/unit/fitting/test_sampler.py @@ -13,34 +13,52 @@ import numpy as np import pytest -from easyscience import ObjBase from easyscience import Parameter +from easyscience.base_classes import ModelBase +from easyscience.fitting import Fitter 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 -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)) -class _StubFitter: - """Duck-types the Fitter attributes checked by the Sampler constructor.""" +class _StubModel: + """Duck-types the model attribute checked by the Sampler constructor.""" + + def get_fit_parameters(self): + return [] + - minimizer = None - fit_function = None +def _identity(x): + return x class _StubState: @@ -50,8 +68,9 @@ 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 _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) sp.offset.fixed = False @@ -59,12 +78,7 @@ def _bumps_fitter_and_data(): x = np.linspace(0, 5, 50) 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 + return sp, x, y, weights def _xyw(): @@ -93,68 +107,109 @@ def _make_state(ngen=6, npop=5, nvar=2, seed=7): class TestSamplerConstructorValidation: - def test_rejects_fitter_without_minimizer(self): + def test_rejects_fit_object_without_fit_parameters(self): x, y, w = _xyw() - with pytest.raises(TypeError, match='fitter must be a configured Fitter'): - Sampler(object(), [x], [y], [w]) + 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(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(_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) + 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]) + 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) - - def test_weights_property_none_when_unset(self): - x, y, _ = _xyw() - sampler = Sampler(_StubFitter(), [x], [y]) - assert sampler.weights is None + 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 @@ -164,7 +219,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 @@ -172,8 +227,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) @@ -181,20 +236,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) @@ -209,36 +264,30 @@ 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).""" - sp = AbsSin(0.354, 3.05) - f = MultiFitter([sp], [sp]) - + def test_sample_requires_bumps_package(self, monkeypatch): + """sample() must raise RuntimeError when the bumps package is not + installed.""" 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'): + 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_fit_function_restored_on_error(self): - """fit_function must be restored even when the minimizer raises.""" - f, _, x, y, weights = _bumps_fitter_and_data() - sampler = Sampler(f, [x], [y], [weights]) - original_func = f.fit_function + 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) - # Invalid `samples` is rejected by the minimizer (single source of - # validation) *after* the fitter has been mutated for sampling. 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 - 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) @@ -246,7 +295,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')) @@ -255,7 +304,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() @@ -320,7 +369,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'] @@ -345,74 +394,61 @@ 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(_StubModel(), _identity, 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(_StubModel(), [_identity, _identity], [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(_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) + 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([])) + 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)]) + 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_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(_StubModel(), [_identity, _identity], [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(_StubModel(), _identity, x, y, w) 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 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 + sp, x, y, weights = _model_and_data() + from easyscience.fitting.samplers.sampler_bumps import DreamSampler canned = { 'draws': np.arange(8.0).reshape(4, 2), @@ -422,14 +458,13 @@ 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 + 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) @@ -443,8 +478,72 @@ 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. - assert f.fit_function is original_func + + 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__ + + 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(sp, sp, x, y, weights) + results = sampler.sample(samples=10, burn=0, thin=1) + + assert results.param_names == ['offset', 'phase'] + 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: @@ -453,7 +552,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( @@ -493,7 +592,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 @@ -558,7 +657,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) @@ -576,7 +675,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) 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 1ddf21ef..1b37fae9 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) @@ -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 24c342dc..9661afa3 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 @@ -19,7 +23,6 @@ def descriptor(self): description='description', url='url', display_name='display_name', - parent=None, ) return descriptor @@ -40,7 +43,6 @@ def test_init_name_type_error(self, name): description='description', url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize( @@ -56,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( @@ -72,7 +73,6 @@ def test_init_description_type_error(self, description): description=description, url='url', display_name='display_name', - parent=None, ) @pytest.mark.parametrize( @@ -88,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): 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 62feb5f3..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() @@ -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 @@ -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()