Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions src/easyscience/fitting/engine_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# SPDX-FileCopyrightText: 2026 EasyScience contributors <https://github.com/easyscience>
# SPDX-License-Identifier: BSD-3-Clause

from abc import ABCMeta
from inspect import Parameter as InspectParameter
from inspect import Signature
from inspect import _empty
from typing import Callable
from typing import Dict
from typing import Tuple

import numpy as np

# causes circular import when Parameter is imported
# from easyscience.base_classes import ObjBase
from easyscience.variable import Parameter

PARAMETER_PREFIX = 'p'


class EngineBase(metaclass=ABCMeta):
"""
Base for all evaluation engines — minimizers and samplers alike.

An engine binds an EasyScience object and a fit function, and
repeatedly evaluates the function while writing values back into the
object's ``Parameter`` instances. ``EngineBase`` owns that shared
machinery: the parameter cache, the ``Parameter``-writing wrapped
fit function, and value restore on failure. It deliberately declares
no abstract methods: the run interfaces live on its subclasses
(``MinimizerBase.fit``, ``DreamSampler.run``).
"""

package: str = None

def __init__(
self,
obj, #: ObjBase,
fit_function: Callable,
): # todo after constraint changes, add type hint: obj: ObjBase # noqa: E501
self._object = obj
self._original_fit_function = fit_function
self._cached_pars: Dict[str, Parameter] = {}
self._cached_pars_vals: Dict[str, Tuple[float, float]] = {}
self._fit_function = None

def _restore_parameter_values(self) -> None:
for key in self._cached_pars.keys():
self._cached_pars[key].value = self._cached_pars_vals[key][0]
self._cached_pars[key].error = self._cached_pars_vals[key][1]

def evaluate(
self, x: np.ndarray, minimizer_parameters: dict[str, float] | None = None, **kwargs
) -> np.ndarray:
"""
Evaluate the fit function for values of x.

Parameters used are either the latest or user supplied. If the
parameters are user supplied, it must be in a dictionary of
{'parameter_name': parameter_value,...}.

Parameters
----------
x : np.ndarray
X values for which the fit function will be evaluated.
minimizer_parameters : dict[str, float] | None, default=None
Dictionary of parameters which will be used in the fit
function. They must be in a dictionary of {'parameter_name':
parameter_value,...}. By default, None.
**kwargs :
Additional arguments.

Returns
-------
np.ndarray
Y values calculated at points x for a set of parameters.

Raises
------
TypeError
If ``minimizer_parameters`` is not a dictionary.
"""
if minimizer_parameters is None:
minimizer_parameters = {}
if not isinstance(minimizer_parameters, dict):
raise TypeError('minimizer_parameters must be a dictionary')

if self._fit_function is None:
# This will also generate self._cached_pars
self._fit_function = self._generate_fit_function()

minimizer_parameters = self._prepare_parameters(minimizer_parameters)

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

def _prepare_parameters(self, parameters: dict[str, float]) -> dict[str, float]:
"""
Prepare the parameters for the engine.

Parameters
----------
parameters : dict[str, float]
Dict of parameters for the engine with names as keys.

Returns
-------
dict[str, float]
Completed parameter dictionary for the engine.
"""
pars = self._cached_pars

for name, item in pars.items():
parameter_name = PARAMETER_PREFIX + str(name)
if parameter_name not in parameters.keys():
parameters[parameter_name] = item.value
return parameters

def _generate_fit_function(self) -> Callable:
"""
Using the user supplied ``fit_function``, wrap it in such a way
we can update ``Parameter`` on iterations.

Returns
-------
Callable
A fit function which is compatible with bumps models.
"""
# Original fit function
func = self._original_fit_function
# Get a list of `Parameters`
self._cached_pars = {}
self._cached_pars_vals = {}
for parameter in self._object.get_fit_parameters():
key = parameter.unique_name
self._cached_pars[key] = parameter
self._cached_pars_vals[key] = (parameter.value, parameter.error)

# Make a new fit function
def _fit_function(x: np.ndarray, **kwargs) -> np.ndarray:
"""
Wrapped fit function which now has an EasyScience compatible
form.

Parameters
----------
x : np.ndarray
Array of data points to be calculated.
**kwargs :
Key word arguments.

Returns
-------
np.ndarray
Points calculated at ``x``.
"""
# Update the `Parameter` values and the callback if needed
# TODO THIS IS NOT THREAD SAFE :-(

for name, value in kwargs.items():
par_name = name[1:]
if par_name in self._cached_pars.keys():
# This will take into account constraints
if self._cached_pars[par_name].value != value:
self._cached_pars[par_name].value = value

# Since we are calling the parameter fset will be called.
# TODO Pre processing here
return_data = func(x)
# TODO Loading or manipulating data here
return return_data

_fit_function.__signature__ = self._create_signature(self._cached_pars)
return _fit_function

@staticmethod
def _create_signature(parameters: Dict[int, Parameter]) -> Signature:
"""
Wrap the function signature.

This is done as lmfit wants the function to be in the form: f =
(x, a=1, b=2)... Where we need to be generic. Note that this
won't hold for much outside of this scope.
"""
wrapped_parameters = []
wrapped_parameters.append(
InspectParameter('x', InspectParameter.POSITIONAL_OR_KEYWORD, annotation=_empty)
)

for name, parameter in parameters.items():
default_value = parameter.value

wrapped_parameters.append(
InspectParameter(
PARAMETER_PREFIX + str(name),
InspectParameter.POSITIONAL_OR_KEYWORD,
annotation=_empty,
default=default_value,
)
)
return Signature(wrapped_parameters)
111 changes: 1 addition & 110 deletions src/easyscience/fitting/fitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def inner_fit_callable(
y: np.ndarray,
weights: Optional[np.ndarray] = None,
vectorized: bool = False,
progress_callback: Callable[[dict], bool | None] | None = None,
progress_callback: Callable[[dict], None] | None = None,
**kwargs,
) -> FitResults:
"""
Expand Down Expand Up @@ -418,112 +418,3 @@ def _post_compute_reshaping(
fit_result.y_calc = np.reshape(fit_result.y_calc, y.shape)
fit_result.y_err = np.reshape(fit_result.y_err, y.shape)
return fit_result

def mcmc_sample(
self,
x: np.ndarray,
y: np.ndarray,
weights: np.ndarray,
samples: int = 10000,
burn: int = 2000,
thin: int = 10,
population: Optional[int] = None,
vectorized: bool = False,
sampler_kwargs: Optional[dict] = None,
progress_callback: Optional[Callable[[dict], Optional[bool]]] = None,
abort_test: Optional[Callable[[], bool]] = None,
) -> dict:
"""
Run Bayesian MCMC sampling using the BUMPS DREAM sampler.

Works with both a plain ``Fitter`` (single dataset) and a
``MultiFitter`` (multiple datasets) via polymorphic dispatch:
``_precompute_reshaping`` and ``_fit_function_wrapper`` are
resolved on the concrete subclass at call time, so multi-dataset
flattening is handled automatically when called on a
``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
17 changes: 16 additions & 1 deletion src/easyscience/fitting/minimizers/bumps_utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@
# SPDX-License-Identifier: BSD-3-Clause

from .eval_counter import EvalCounter
from .problem import build_curve_problem
from .problem import parameter_names
from .problem import parameter_snapshot
from .problem import to_bumps_parameter
from .progress_monitor import BumpsProgressMonitor
from .validation import validate_arrays
from .validation import validate_run_settings

__all__ = ['BumpsProgressMonitor', 'EvalCounter']
__all__ = [
'BumpsProgressMonitor',
'EvalCounter',
'build_curve_problem',
'parameter_names',
'parameter_snapshot',
'to_bumps_parameter',
'validate_arrays',
'validate_run_settings',
]
Loading