From 581ac0ec4d45ef49175651da90bcd50e83cd408e Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 14:39:00 +0200 Subject: [PATCH 1/9] Fix EasyList serialization issue and add test --- src/easyscience/base_classes/easy_list.py | 3 +- .../fitting/minimizers/minimizer_bumps.py | 85 ++-- src/easyscience/fitting/sampler.py | 382 ++++++++++-------- tests/unit/base_classes/test_easy_list.py | 36 ++ 4 files changed, 298 insertions(+), 208 deletions(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 23ac673d..535c0243 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -423,8 +423,9 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ) # noqa: E501 else: protected_types = None + data_dicts = temp_dict.pop('data', None) + data = [SerializerBase._deserialize_value(value_dict) for value_dict in data_dicts] kwargs = SerializerBase.deserialize_dict(temp_dict) - data = kwargs.pop('data', []) return cls(data, protected_types=protected_types, **kwargs) else: raise ValueError( diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 315d856c..500edce6 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -417,42 +417,45 @@ def mcmc_sample( 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``. + 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. + generations while ``samples`` counts raw draws, so + ``burn=500`` discards ``500 * n_chains`` raw samples. thin : int, default=10 - Thinning interval — only every ``thin``-th generation is stored. + Thinning interval — only every ``thin``-th generation is + stored. population : int | None, default=None - BUMPS DREAM population count per parameter (number of parallel - chains): BUMPS creates ``ceil(population * n_parameters)`` chains. + 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). + ``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``. @@ -475,9 +478,9 @@ def mcmc_sample( ------ 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). + ``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``). @@ -608,28 +611,32 @@ def _validate_resume_state( population: int | None, burn: int, ) -> tuple[int, int]: - """Check that ``resume_state`` is compatible with ``problem`` and + """ + 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. + 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. + 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. + ``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 ------ diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index d3915115..3917a5bc 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers.""" +""" +Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers. +""" from __future__ import annotations @@ -38,7 +40,9 @@ def _data_fingerprint( y_list: list, w_list: list, ) -> str | None: - """Return a SHA-256 hex digest of concatenated (x|y|weights), or None.""" + """ + Return a SHA-256 hex digest of concatenated (x|y|weights), or None. + """ try: h = hashlib.sha256() for arr in list(x_list) + list(y_list) + list(w_list): @@ -53,7 +57,8 @@ def _validate_dataset_arrays( data: np.ndarray | list | tuple, allow_none_entries: bool = False, ) -> None: - """Check that ``data`` (an array or list of arrays) holds numeric, + """ + Check that ``data`` (an array or list of arrays) holds numeric, at-least-1-D, non-empty arrays. Structural checks (array vs list, matching dataset counts) are in @@ -101,12 +106,13 @@ def _validate_dataset_arrays( def _copy_data(data): - """Copy an array (or list of arrays) and mark the copies read-only. + """ + Copy an array (or list of arrays) and mark the copies read-only. - The ``Sampler`` binds its data at construction; copying decouples the - bound data from the caller's arrays, and the read-only flag stops - in-place mutation of the copies, so the chain and the ``save()`` - fingerprint always describe the data actually sampled. + The ``Sampler`` binds its data at construction; copying decouples + the bound data from the caller's arrays, and the read-only flag + stops 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 @@ -118,10 +124,13 @@ def _copy_data(data): def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: - """Validate the persistence arguments shared by the save/load functions. + """ + Validate the persistence arguments shared by the save/load + functions. - Returns ``path`` coerced to ``str``. Raises ``TypeError`` if ``path`` is - not path-like and ``ValueError`` if ``skip`` is not a non-negative int. + Returns ``path`` coerced to ``str``. Raises ``TypeError`` if + ``path`` is not path-like and ``ValueError`` if ``skip`` is not a + non-negative int. """ if not isinstance(path, (str, os.PathLike)): raise TypeError(f'path must be a str or os.PathLike, got {type(path).__name__}.') @@ -131,14 +140,15 @@ def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: def _load_bumps_state(path: str, skip: int = 0) -> MCMCDraw: - """Read a BUMPS chain from disk, working around a BUMPS bug. + """ + Read a BUMPS chain from disk, working around a BUMPS bug. ``bumps.dream.state.load_state`` reads the saved buffers with - ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. A - short chain stores a single CR-weight update row, so ``load_state``'s - subsequent ``stats[:, 0]`` indexing raises ``IndexError: too many indices - for array``. We coerce each buffer read back to 2-D before ``load_state`` - consumes it. + ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. + A short chain stores a single CR-weight update row, so + ``load_state``'s subsequent ``stats[:, 0]`` indexing raises + ``IndexError: too many indices for array``. We coerce each buffer + read back to 2-D before ``load_state`` consumes it. Parameters ---------- @@ -170,28 +180,30 @@ def _loadtxt_2d(file, report: int = 0) -> np.ndarray: def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[str] | None, dict]: - """Reload a DREAM chain state saved by ``Sampler.save``. + """ + 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 - 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. + 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 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. Parameters ---------- path : str | os.PathLike File path prefix used when saving. skip : int, default=0 - Discard the first ``skip`` saved generations on load, forwarded to - ``bumps.dream.state.load_state``. + Discard the first ``skip`` saved generations on load, forwarded + to ``bumps.dream.state.load_state``. Returns ------- tuple[MCMCDraw, list[str] | None, dict] - The reloaded BUMPS chain state, the parameter names (or ``None`` if - neither sidecar nor labels yielded them), and the raw sidecar dict - (empty if absent/unreadable). + The reloaded BUMPS chain state, the parameter names (or ``None`` + if neither sidecar nor labels yielded them), and the raw sidecar + dict (empty if absent/unreadable). Raises ------ @@ -229,15 +241,18 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s @dataclass class SamplingResults: - """Structured result of an MCMC sampling run (analogous to ``FitResults``). + """ + Structured result of an MCMC sampling run (analogous to + ``FitResults``). Attributes ---------- draws : np.ndarray - Posterior samples, shape ``(n_samples, n_params)``. For results from - ``Sampler.sample()``/``Sampler.extend()`` this is a *trimmed, - outlier-filtered* view of the chain, not the raw buffer, so it holds - fewer rows than ``samples / thin`` — see the notes on ``Sampler``. + Posterior samples, shape ``(n_samples, n_params)``. For results + from ``Sampler.sample()``/``Sampler.extend()`` this is a + *trimmed, outlier-filtered* view of the chain, not the raw + buffer, so it holds fewer rows than ``samples / thin`` — see the + notes on ``Sampler``. param_names : list[str] Parameter names (one per column of ``draws``). logp : np.ndarray @@ -252,8 +267,10 @@ class SamplingResults: state: MCMCDraw def to_legacy_dict(self) -> dict: - """Return the legacy dict shape produced by the deprecated - ``mcmc_sample()`` APIs.""" + """ + Return the legacy dict shape produced by the deprecated + ``mcmc_sample()`` APIs. + """ return { 'draws': self.draws, 'param_names': self.param_names, @@ -263,58 +280,64 @@ def to_legacy_dict(self) -> dict: class Sampler: - """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer. - - One ``Sampler`` instance represents one chain over one ``(x, y, weights)`` - dataset. The data is bound at construction; ``sample()`` and ``extend()`` - take no data arguments, so a chain can never be extended against different - data (undefined behaviour in BUMPS). The bound data is a defensive, - read-only copy of the caller's arrays, exposed via the ``x``, ``y`` and - ``weights`` properties — mutating the originals after construction has no - 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. + """ + Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS + minimizer. + + One ``Sampler`` instance represents one chain over one ``(x, y, + weights)`` dataset. The data is bound at construction; ``sample()`` + and ``extend()`` take no data arguments, so a chain can never be + extended against different data (undefined behaviour in BUMPS). The + bound data is a defensive, read-only copy of the caller's arrays, + exposed via the ``x``, ``y`` and ``weights`` properties — mutating + the originals after construction has no 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. Parameters ---------- - fitter : Fitter - A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been - switched to ``AvailableMinimizers.Bumps``. + fitter : 'Fitter' + A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has + been switched to ``AvailableMinimizers.Bumps``. x : np.ndarray | list[np.ndarray] - Independent variable array (or list of arrays for ``MultiFitter``). + Independent variable array (or list of arrays for + ``MultiFitter``). y : np.ndarray | list[np.ndarray] - Dependent variable array (or list of arrays for ``MultiFitter``). + 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``). 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. sampler_kwargs : dict | None, default=None - Per-instance default keyword arguments forwarded to the BUMPS DREAM - sampler on every run, merged with (and overridden by) per-call - ``sampler_kwargs``. + Per-instance default keyword arguments forwarded to the BUMPS + DREAM sampler on every run, merged with (and overridden by) + per-call ``sampler_kwargs``. 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 ``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. ValueError If ``x``, ``y`` and ``weights`` do not hold matching structures (all arrays, or lists of the same length), or any dataset is a @@ -322,42 +345,44 @@ class Sampler: Notes ----- - **The retained draws are a trimmed view, not the whole chain.** BUMPS' - DREAM sampler defaults to ``trim=True``: once sampling finishes it runs a - convergence-based burn-point detector over the chain and returns only the - portion after that point. ``state.draw()`` additionally drops chains - flagged as outliers. So ``results.draws`` is usually *smaller* than the - chain, and its length is not deterministic — the detector re-runs from - scratch on every ``sample()`` and ``extend()`` call and may place the burn - point differently each time. Read the count off the array; do not predict - it. - - Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` is a - guaranteed minimum, not an exact count. DREAM advances in blocks of 10 - generations (one generation = one draw per chain) and only checks its - stopping condition between blocks, so the raw chain length is ``samples`` - rounded up to a multiple of ``10 * n_chains``. - - The trimming is only a *view* — nothing is dropped from the buffer. To - read the full untrimmed chain:: + **The retained draws are a trimmed view, not the whole chain.** + BUMPS' DREAM sampler defaults to ``trim=True``: once sampling + finishes it runs a convergence-based burn-point detector over the + chain and returns only the portion after that point. + ``state.draw()`` additionally drops chains flagged as outliers. So + ``results.draws`` is usually *smaller* than the chain, and its + length is not deterministic — the detector re-runs from scratch on + every ``sample()`` and ``extend()`` call and may place the burn + point differently each time. Read the count off the array; do not + predict it. + + Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` + is a guaranteed minimum, not an exact count. DREAM advances in + blocks of 10 generations (one generation = one draw per chain) and + only checks its stopping condition between blocks, so the raw chain + length is ``samples`` rounded up to a multiple of ``10 * n_chains``. + + The trimming is only a *view* — nothing is dropped from the buffer. + To read the full untrimmed chain:: results = sampler.sample(samples=10000, burn=500, thin=2) full = results.state.draw(portion=1.0, outliers=True) full.points # (n_chain_rows, n_params) full.logp - To switch the automatic trimming off entirely, pass BUMPS' own ``trim`` - option straight through ``sampler_kwargs``; ``results.draws`` then holds - the whole chain:: + To switch the automatic trimming off entirely, pass BUMPS' own + ``trim`` option straight through ``sampler_kwargs``; + ``results.draws`` then holds the whole chain:: sampler = Sampler( fitter, x, y, weights=w, sampler_kwargs={'trim': False} ) - Note also that trimming does not survive a ``save()``/``load_state()`` - round-trip: BUMPS does not persist the trim point, so a reloaded chain - comes back untrimmed and ``load_state()`` reports *more* draws than the - ``sample()`` call that created it. The chain itself is identical. + Note also that trimming does not survive a + ``save()``/``load_state()`` round-trip: BUMPS does not persist the + trim point, so a reloaded chain comes back untrimmed and + ``load_state()`` reports *more* draws than the ``sample()`` call + that created it. The chain itself is identical. """ def __init__( @@ -435,7 +460,9 @@ def weights(self) -> np.ndarray | list[np.ndarray | None] | None: @property def state(self) -> MCMCDraw | None: - """Raw BUMPS MCMCDraw state (None before first sample/load_state).""" + """ + Raw BUMPS MCMCDraw state (None before first sample/load_state). + """ return self._state @property @@ -459,7 +486,9 @@ def logp(self) -> np.ndarray | None: return self._results.logp if self._results is not None else None def _fingerprint(self) -> str | None: - """SHA-256 fingerprint of the bound (x, y, weights) data, or 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: @@ -481,7 +510,8 @@ def _run( progress_callback: Callable[[dict], bool | None] | None, abort_test: Callable[[], bool] | None, ) -> SamplingResults: - """Shared sampling engine for ``sample()`` and ``extend()``. + """ + Shared sampling engine for ``sample()`` and ``extend()``. Argument validation for ``samples``/``burn``/``thin`` lives in ``Bumps.mcmc_sample`` (single source of truth). @@ -545,37 +575,43 @@ def sample( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """Run fresh Bayesian MCMC sampling on the bound data. + """ + Run fresh Bayesian MCMC sampling on the bound data. - Calling ``sample()`` on a sampler that already holds a chain starts a - **fresh** chain — the previous state and results are replaced. Use - ``extend()`` to continue an existing chain. + Calling ``sample()`` on a sampler that already holds a chain + starts a **fresh** chain — the previous state and results are + replaced. Use ``extend()`` to continue an existing chain. Parameters ---------- samples : int, default=10000 - Number of raw samples to draw across all chains, before thinning. - This is 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``. + Number of raw samples to draw across all chains, before + thinning. This is 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 before collecting samples. Note - BUMPS counts ``burn`` in generations while ``samples`` counts raw - draws, so ``burn=500`` discards ``500 * n_chains`` raw samples. + Burn-in generations to discard before collecting samples. + Note 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 kept, - which reduces autocorrelation between consecutive draws. + Thinning interval — only every ``thin``-th generation is + kept, which reduces autocorrelation between consecutive + draws. population : int | None, default=None - DREAM population **scale factor** (not an absolute chain count): - BUMPS creates ``ceil(population * n_parameters)`` parallel chains. + DREAM population **scale factor** (not an absolute chain + count): BUMPS creates ``ceil(population * n_parameters)`` + parallel chains. 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. The payload - dict includes ``iteration`` and ``sampling: True``. + Optional callback invoked at each DREAM generation. The + payload dict includes ``iteration`` and ``sampling: True``. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling early. + Optional callable that returns ``True`` to abort sampling + early. Returns ------- @@ -584,14 +620,16 @@ def sample( Notes ----- - ``results.draws`` is a trimmed, outlier-filtered view of the chain, - so it is usually smaller than the chain and its length is not - predictable from ``samples``/``thin``. See the ``Sampler`` class notes - for how to read the full chain or switch trimming off. + ``results.draws`` is a trimmed, outlier-filtered view of the + chain, so it is usually smaller than the chain and its length is + not predictable from ``samples``/``thin``. See the ``Sampler`` + class notes for how to read the full chain or switch trimming + off. 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. + ``samples``, ``burn``, or ``thin`` are invalid, and + ``RuntimeError`` if the active minimizer is not a BUMPS + instance. """ if self._state is not None: global_object.log.getLogger('fitting').warning( @@ -618,38 +656,43 @@ def extend( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """Continue the existing chain with additional samples. + """ + Continue the existing chain with additional samples. DREAM stores draws in a fixed-size ring buffer sized to its - ``samples`` parameter; this method does the ring-buffer arithmetic - for you (``samples = stored_generations * population + - additional_samples``) so no existing draws are dropped from the - buffer, regardless of the thinning interval. Runs with ``burn=0`` — - re-burning a converged chain is usually a mistake, and BUMPS forces - it to 0 on resume in any case. The DREAM population is recovered from - the saved state and cannot be changed on extend. + ``samples`` parameter; this method does the ring-buffer + arithmetic for you (``samples = stored_generations * population + + additional_samples``) so no existing draws are dropped from + the buffer, regardless of the thinning interval. Runs with + ``burn=0`` — re-burning a converged chain is usually a mistake, + and BUMPS forces it to 0 on resume in any case. The DREAM + population is recovered from the saved state and cannot be + changed on extend. Parameters ---------- additional_samples : int, default=5000 - Number of additional DREAM samples to draw, in the same units - as ``samples`` in ``sample()``. This grows the ring buffer by - exactly ``additional_samples``; how many of the new draws become - *visible* in ``results.draws`` depends on the thinning interval - and on BUMPS' automatic trimming (see Notes). + Number of additional DREAM samples to draw, in the same + units as ``samples`` in ``sample()``. This grows the ring + buffer by exactly ``additional_samples``; how many of the + new draws become *visible* in ``results.draws`` depends on + the thinning interval and on BUMPS' automatic trimming (see + Notes). thin : int, default=10 Thinning interval for the retained draws. total_samples : int | None, default=None - Advanced: total retained samples requested from the ring buffer, - **overriding** the ``additional_samples`` arithmetic. With - ``total_samples=N``, only the last N draws are retained. + Advanced: total retained samples requested from the ring + buffer, **overriding** the ``additional_samples`` + arithmetic. With ``total_samples=N``, only the last N draws + are retained. 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. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling early. + Optional callable that returns ``True`` to abort sampling + early. Returns ------- @@ -665,16 +708,17 @@ def extend( Notes ----- ``results.draws`` will **not** grow by exactly - ``additional_samples / thin``. Two things get in the way, neither of - them a re-applied burn-in: BUMPS re-runs its burn-point detector over - the whole extended chain and re-trims the returned view (so the - visible count can even shrink), and DREAM advances in blocks of 10 - generations, so the raw growth is ``additional_samples`` rounded up - to a multiple of ``10 * n_chains`` (i.e. at least - ``additional_samples / thin`` retained rows). To see the chain - itself, compare ``results.state.draw(portion=1.0, outliers=True)`` - before and after, or run with ``sampler_kwargs={'trim': False}``. See - the ``Sampler`` class notes. + ``additional_samples / thin``. Two things get in the way, + neither of them a re-applied burn-in: BUMPS re-runs its + burn-point detector over the whole extended chain and re-trims + the returned view (so the visible count can even shrink), and + DREAM advances in blocks of 10 generations, so the raw growth is + ``additional_samples`` rounded up to a multiple of ``10 * + n_chains`` (i.e. at least ``additional_samples / thin`` retained + rows). To see the chain itself, compare + ``results.state.draw(portion=1.0, outliers=True)`` before and + after, or run with ``sampler_kwargs={'trim': False}``. See the + ``Sampler`` class notes. """ if self._state is None: raise RuntimeError('No chain to extend. Call sample() or load_state() first.') @@ -698,14 +742,15 @@ def extend( ) def save(self, path: str | os.PathLike) -> None: - """Persist the chain state and metadata to disk. + """ + Persist the chain state and metadata to disk. Writes the BUMPS native files (``-chain.mc.gz``, ``-point.mc.gz`` and ``-stats.mc.gz``) plus a ``.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. + 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. Parameters ---------- @@ -746,13 +791,14 @@ def save(self, path: str | os.PathLike) -> None: json.dump(sidecar, f, indent=2) def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: - """Load a previously saved chain into this sampler. + """ + Load a previously saved chain into this sampler. - The sampler must be constructed with the same fitter 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 - different data is undefined behaviour). + The sampler must be constructed with the same fitter 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 different data is undefined behaviour). Populates ``state`` and ``results`` (draws, log-posterior and parameter names) from the saved chain, so summaries and @@ -763,8 +809,8 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: path : str | os.PathLike File path prefix used in ``save()``. skip : int, default=0 - Discard the first ``skip`` saved generations on load. Useful for - trimming additional burn-in without re-sampling. + Discard the first ``skip`` saved generations on load. Useful + for trimming additional burn-in without re-sampling. Returns ------- @@ -780,11 +826,11 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: Notes ----- - BUMPS does not persist its automatic trim point, so a reloaded chain - comes back **untrimmed**: this method reports more draws than the - ``sample()`` call that wrote the file, even though the chain is - identical. Use ``skip`` to discard leading generations explicitly. - See the ``Sampler`` class notes. + BUMPS does not persist its automatic trim point, so a reloaded + chain comes back **untrimmed**: this method reports more draws + than the ``sample()`` call that wrote the file, even though the + chain is identical. Use ``skip`` to discard leading generations + explicitly. See the ``Sampler`` class notes. """ # noqa: DOC502 -- raised in _validate_chain_path via load_chain state, param_names, sidecar = load_chain(path, skip=skip) diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 69b9bf6e..73bd1a09 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import logging +from unittest.mock import MagicMock import pytest @@ -9,6 +10,7 @@ from easyscience.base_classes.easy_list import EasyList from easyscience.base_classes.model_base import ModelBase from easyscience.base_classes.new_base import NewBase +from easyscience.io.serializer_base import SerializerBase from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter @@ -568,6 +570,40 @@ def test_from_dict_round_trip(self): assert el2[1].unique_name == 'a2' assert d == el2.to_dict() # The dicts should be the same after round trip + def test_from_dict_modelbase_round_trip(self, monkeypatch): + # When + model1 = MockModel(unique_name='m1', temperature=10, volume=5.0) + model1.__class__.__module__ = 'easyscience' # Ensure mock class is seen as easyscience + model2 = MockModel(unique_name='m2', temperature=99, volume=1.5) + model2.__class__.__module__ = 'easyscience' # Ensure mock class is seen as easyscience + el = EasyList(model1, model2, protected_types=ModelBase) + # Monkeypatch the imports to actually work with the test class + monkeypatch.setattr( + SerializerBase, + '_import_class', + MagicMock( + side_effect=lambda module_name, class_name: ( + MockModel + if class_name == 'MockModel' + else EasyList + if class_name == 'EasyList' + else Parameter + if class_name == 'Parameter' + else DescriptorNumber + ) + ), + ) + # Then + list_dict = el.to_dict() + # Clear the global map so deserialized objects can reuse the same unique names + global_object.map._clear() + el2 = EasyList.from_dict(list_dict) + # Expect + assert len(el2) == 2 + assert el2[0].unique_name == 'm1' + assert el2[1].unique_name == 'm2' + assert list_dict == el2.to_dict() # The dicts should be the same after round trip + # --- get_all_variables --- def test_get_all_variables_empty_list(self): From a7cf77c5041233c3f018ba7be494e8a9a9c5a9fd Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 15:54:35 +0200 Subject: [PATCH 2/9] Fix EasyList serialization issue and add test --- src/easyscience/base_classes/easy_list.py | 3 +- tests/unit/base_classes/test_easy_list.py | 36 +++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 23ac673d..535c0243 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -423,8 +423,9 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ) # noqa: E501 else: protected_types = None + data_dicts = temp_dict.pop('data', None) + data = [SerializerBase._deserialize_value(value_dict) for value_dict in data_dicts] kwargs = SerializerBase.deserialize_dict(temp_dict) - data = kwargs.pop('data', []) return cls(data, protected_types=protected_types, **kwargs) else: raise ValueError( diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 69b9bf6e..73bd1a09 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: BSD-3-Clause import logging +from unittest.mock import MagicMock import pytest @@ -9,6 +10,7 @@ from easyscience.base_classes.easy_list import EasyList from easyscience.base_classes.model_base import ModelBase from easyscience.base_classes.new_base import NewBase +from easyscience.io.serializer_base import SerializerBase from easyscience.variable import DescriptorNumber from easyscience.variable import Parameter @@ -568,6 +570,40 @@ def test_from_dict_round_trip(self): assert el2[1].unique_name == 'a2' assert d == el2.to_dict() # The dicts should be the same after round trip + def test_from_dict_modelbase_round_trip(self, monkeypatch): + # When + model1 = MockModel(unique_name='m1', temperature=10, volume=5.0) + model1.__class__.__module__ = 'easyscience' # Ensure mock class is seen as easyscience + model2 = MockModel(unique_name='m2', temperature=99, volume=1.5) + model2.__class__.__module__ = 'easyscience' # Ensure mock class is seen as easyscience + el = EasyList(model1, model2, protected_types=ModelBase) + # Monkeypatch the imports to actually work with the test class + monkeypatch.setattr( + SerializerBase, + '_import_class', + MagicMock( + side_effect=lambda module_name, class_name: ( + MockModel + if class_name == 'MockModel' + else EasyList + if class_name == 'EasyList' + else Parameter + if class_name == 'Parameter' + else DescriptorNumber + ) + ), + ) + # Then + list_dict = el.to_dict() + # Clear the global map so deserialized objects can reuse the same unique names + global_object.map._clear() + el2 = EasyList.from_dict(list_dict) + # Expect + assert len(el2) == 2 + assert el2[0].unique_name == 'm1' + assert el2[1].unique_name == 'm2' + assert list_dict == el2.to_dict() # The dicts should be the same after round trip + # --- get_all_variables --- def test_get_all_variables_empty_list(self): From 6272ab15f7effd6460708132ae041c954c700ccb Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:10:52 +0200 Subject: [PATCH 3/9] Merge branch 'Issue321_EasyList_serialization' of https://github.com/EasyScience/EasyScience into Issue321_EasyList_serialization --- .../fitting/minimizers/minimizer_bumps.py | 85 ++-- src/easyscience/fitting/sampler.py | 382 ++++++++++-------- 2 files changed, 260 insertions(+), 207 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 315d856c..500edce6 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -417,42 +417,45 @@ def mcmc_sample( 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``. + 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. + generations while ``samples`` counts raw draws, so + ``burn=500`` discards ``500 * n_chains`` raw samples. thin : int, default=10 - Thinning interval — only every ``thin``-th generation is stored. + Thinning interval — only every ``thin``-th generation is + stored. population : int | None, default=None - BUMPS DREAM population count per parameter (number of parallel - chains): BUMPS creates ``ceil(population * n_parameters)`` chains. + 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). + ``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``. @@ -475,9 +478,9 @@ def mcmc_sample( ------ 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). + ``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``). @@ -608,28 +611,32 @@ def _validate_resume_state( population: int | None, burn: int, ) -> tuple[int, int]: - """Check that ``resume_state`` is compatible with ``problem`` and + """ + 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. + 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. + 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. + ``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 ------ diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index d3915115..3917a5bc 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -1,6 +1,8 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers.""" +""" +Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers. +""" from __future__ import annotations @@ -38,7 +40,9 @@ def _data_fingerprint( y_list: list, w_list: list, ) -> str | None: - """Return a SHA-256 hex digest of concatenated (x|y|weights), or None.""" + """ + Return a SHA-256 hex digest of concatenated (x|y|weights), or None. + """ try: h = hashlib.sha256() for arr in list(x_list) + list(y_list) + list(w_list): @@ -53,7 +57,8 @@ def _validate_dataset_arrays( data: np.ndarray | list | tuple, allow_none_entries: bool = False, ) -> None: - """Check that ``data`` (an array or list of arrays) holds numeric, + """ + Check that ``data`` (an array or list of arrays) holds numeric, at-least-1-D, non-empty arrays. Structural checks (array vs list, matching dataset counts) are in @@ -101,12 +106,13 @@ def _validate_dataset_arrays( def _copy_data(data): - """Copy an array (or list of arrays) and mark the copies read-only. + """ + Copy an array (or list of arrays) and mark the copies read-only. - The ``Sampler`` binds its data at construction; copying decouples the - bound data from the caller's arrays, and the read-only flag stops - in-place mutation of the copies, so the chain and the ``save()`` - fingerprint always describe the data actually sampled. + The ``Sampler`` binds its data at construction; copying decouples + the bound data from the caller's arrays, and the read-only flag + stops 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 @@ -118,10 +124,13 @@ def _copy_data(data): def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: - """Validate the persistence arguments shared by the save/load functions. + """ + Validate the persistence arguments shared by the save/load + functions. - Returns ``path`` coerced to ``str``. Raises ``TypeError`` if ``path`` is - not path-like and ``ValueError`` if ``skip`` is not a non-negative int. + Returns ``path`` coerced to ``str``. Raises ``TypeError`` if + ``path`` is not path-like and ``ValueError`` if ``skip`` is not a + non-negative int. """ if not isinstance(path, (str, os.PathLike)): raise TypeError(f'path must be a str or os.PathLike, got {type(path).__name__}.') @@ -131,14 +140,15 @@ def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: def _load_bumps_state(path: str, skip: int = 0) -> MCMCDraw: - """Read a BUMPS chain from disk, working around a BUMPS bug. + """ + Read a BUMPS chain from disk, working around a BUMPS bug. ``bumps.dream.state.load_state`` reads the saved buffers with - ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. A - short chain stores a single CR-weight update row, so ``load_state``'s - subsequent ``stats[:, 0]`` indexing raises ``IndexError: too many indices - for array``. We coerce each buffer read back to 2-D before ``load_state`` - consumes it. + ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. + A short chain stores a single CR-weight update row, so + ``load_state``'s subsequent ``stats[:, 0]`` indexing raises + ``IndexError: too many indices for array``. We coerce each buffer + read back to 2-D before ``load_state`` consumes it. Parameters ---------- @@ -170,28 +180,30 @@ def _loadtxt_2d(file, report: int = 0) -> np.ndarray: def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[str] | None, dict]: - """Reload a DREAM chain state saved by ``Sampler.save``. + """ + 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 - 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. + 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 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. Parameters ---------- path : str | os.PathLike File path prefix used when saving. skip : int, default=0 - Discard the first ``skip`` saved generations on load, forwarded to - ``bumps.dream.state.load_state``. + Discard the first ``skip`` saved generations on load, forwarded + to ``bumps.dream.state.load_state``. Returns ------- tuple[MCMCDraw, list[str] | None, dict] - The reloaded BUMPS chain state, the parameter names (or ``None`` if - neither sidecar nor labels yielded them), and the raw sidecar dict - (empty if absent/unreadable). + The reloaded BUMPS chain state, the parameter names (or ``None`` + if neither sidecar nor labels yielded them), and the raw sidecar + dict (empty if absent/unreadable). Raises ------ @@ -229,15 +241,18 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s @dataclass class SamplingResults: - """Structured result of an MCMC sampling run (analogous to ``FitResults``). + """ + Structured result of an MCMC sampling run (analogous to + ``FitResults``). Attributes ---------- draws : np.ndarray - Posterior samples, shape ``(n_samples, n_params)``. For results from - ``Sampler.sample()``/``Sampler.extend()`` this is a *trimmed, - outlier-filtered* view of the chain, not the raw buffer, so it holds - fewer rows than ``samples / thin`` — see the notes on ``Sampler``. + Posterior samples, shape ``(n_samples, n_params)``. For results + from ``Sampler.sample()``/``Sampler.extend()`` this is a + *trimmed, outlier-filtered* view of the chain, not the raw + buffer, so it holds fewer rows than ``samples / thin`` — see the + notes on ``Sampler``. param_names : list[str] Parameter names (one per column of ``draws``). logp : np.ndarray @@ -252,8 +267,10 @@ class SamplingResults: state: MCMCDraw def to_legacy_dict(self) -> dict: - """Return the legacy dict shape produced by the deprecated - ``mcmc_sample()`` APIs.""" + """ + Return the legacy dict shape produced by the deprecated + ``mcmc_sample()`` APIs. + """ return { 'draws': self.draws, 'param_names': self.param_names, @@ -263,58 +280,64 @@ def to_legacy_dict(self) -> dict: class Sampler: - """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer. - - One ``Sampler`` instance represents one chain over one ``(x, y, weights)`` - dataset. The data is bound at construction; ``sample()`` and ``extend()`` - take no data arguments, so a chain can never be extended against different - data (undefined behaviour in BUMPS). The bound data is a defensive, - read-only copy of the caller's arrays, exposed via the ``x``, ``y`` and - ``weights`` properties — mutating the originals after construction has no - 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. + """ + Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS + minimizer. + + One ``Sampler`` instance represents one chain over one ``(x, y, + weights)`` dataset. The data is bound at construction; ``sample()`` + and ``extend()`` take no data arguments, so a chain can never be + extended against different data (undefined behaviour in BUMPS). The + bound data is a defensive, read-only copy of the caller's arrays, + exposed via the ``x``, ``y`` and ``weights`` properties — mutating + the originals after construction has no 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. Parameters ---------- - fitter : Fitter - A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been - switched to ``AvailableMinimizers.Bumps``. + fitter : 'Fitter' + A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has + been switched to ``AvailableMinimizers.Bumps``. x : np.ndarray | list[np.ndarray] - Independent variable array (or list of arrays for ``MultiFitter``). + Independent variable array (or list of arrays for + ``MultiFitter``). y : np.ndarray | list[np.ndarray] - Dependent variable array (or list of arrays for ``MultiFitter``). + 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``). 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. sampler_kwargs : dict | None, default=None - Per-instance default keyword arguments forwarded to the BUMPS DREAM - sampler on every run, merged with (and overridden by) per-call - ``sampler_kwargs``. + Per-instance default keyword arguments forwarded to the BUMPS + DREAM sampler on every run, merged with (and overridden by) + per-call ``sampler_kwargs``. 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 ``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. ValueError If ``x``, ``y`` and ``weights`` do not hold matching structures (all arrays, or lists of the same length), or any dataset is a @@ -322,42 +345,44 @@ class Sampler: Notes ----- - **The retained draws are a trimmed view, not the whole chain.** BUMPS' - DREAM sampler defaults to ``trim=True``: once sampling finishes it runs a - convergence-based burn-point detector over the chain and returns only the - portion after that point. ``state.draw()`` additionally drops chains - flagged as outliers. So ``results.draws`` is usually *smaller* than the - chain, and its length is not deterministic — the detector re-runs from - scratch on every ``sample()`` and ``extend()`` call and may place the burn - point differently each time. Read the count off the array; do not predict - it. - - Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` is a - guaranteed minimum, not an exact count. DREAM advances in blocks of 10 - generations (one generation = one draw per chain) and only checks its - stopping condition between blocks, so the raw chain length is ``samples`` - rounded up to a multiple of ``10 * n_chains``. - - The trimming is only a *view* — nothing is dropped from the buffer. To - read the full untrimmed chain:: + **The retained draws are a trimmed view, not the whole chain.** + BUMPS' DREAM sampler defaults to ``trim=True``: once sampling + finishes it runs a convergence-based burn-point detector over the + chain and returns only the portion after that point. + ``state.draw()`` additionally drops chains flagged as outliers. So + ``results.draws`` is usually *smaller* than the chain, and its + length is not deterministic — the detector re-runs from scratch on + every ``sample()`` and ``extend()`` call and may place the burn + point differently each time. Read the count off the array; do not + predict it. + + Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` + is a guaranteed minimum, not an exact count. DREAM advances in + blocks of 10 generations (one generation = one draw per chain) and + only checks its stopping condition between blocks, so the raw chain + length is ``samples`` rounded up to a multiple of ``10 * n_chains``. + + The trimming is only a *view* — nothing is dropped from the buffer. + To read the full untrimmed chain:: results = sampler.sample(samples=10000, burn=500, thin=2) full = results.state.draw(portion=1.0, outliers=True) full.points # (n_chain_rows, n_params) full.logp - To switch the automatic trimming off entirely, pass BUMPS' own ``trim`` - option straight through ``sampler_kwargs``; ``results.draws`` then holds - the whole chain:: + To switch the automatic trimming off entirely, pass BUMPS' own + ``trim`` option straight through ``sampler_kwargs``; + ``results.draws`` then holds the whole chain:: sampler = Sampler( fitter, x, y, weights=w, sampler_kwargs={'trim': False} ) - Note also that trimming does not survive a ``save()``/``load_state()`` - round-trip: BUMPS does not persist the trim point, so a reloaded chain - comes back untrimmed and ``load_state()`` reports *more* draws than the - ``sample()`` call that created it. The chain itself is identical. + Note also that trimming does not survive a + ``save()``/``load_state()`` round-trip: BUMPS does not persist the + trim point, so a reloaded chain comes back untrimmed and + ``load_state()`` reports *more* draws than the ``sample()`` call + that created it. The chain itself is identical. """ def __init__( @@ -435,7 +460,9 @@ def weights(self) -> np.ndarray | list[np.ndarray | None] | None: @property def state(self) -> MCMCDraw | None: - """Raw BUMPS MCMCDraw state (None before first sample/load_state).""" + """ + Raw BUMPS MCMCDraw state (None before first sample/load_state). + """ return self._state @property @@ -459,7 +486,9 @@ def logp(self) -> np.ndarray | None: return self._results.logp if self._results is not None else None def _fingerprint(self) -> str | None: - """SHA-256 fingerprint of the bound (x, y, weights) data, or 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: @@ -481,7 +510,8 @@ def _run( progress_callback: Callable[[dict], bool | None] | None, abort_test: Callable[[], bool] | None, ) -> SamplingResults: - """Shared sampling engine for ``sample()`` and ``extend()``. + """ + Shared sampling engine for ``sample()`` and ``extend()``. Argument validation for ``samples``/``burn``/``thin`` lives in ``Bumps.mcmc_sample`` (single source of truth). @@ -545,37 +575,43 @@ def sample( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """Run fresh Bayesian MCMC sampling on the bound data. + """ + Run fresh Bayesian MCMC sampling on the bound data. - Calling ``sample()`` on a sampler that already holds a chain starts a - **fresh** chain — the previous state and results are replaced. Use - ``extend()`` to continue an existing chain. + Calling ``sample()`` on a sampler that already holds a chain + starts a **fresh** chain — the previous state and results are + replaced. Use ``extend()`` to continue an existing chain. Parameters ---------- samples : int, default=10000 - Number of raw samples to draw across all chains, before thinning. - This is 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``. + Number of raw samples to draw across all chains, before + thinning. This is 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 before collecting samples. Note - BUMPS counts ``burn`` in generations while ``samples`` counts raw - draws, so ``burn=500`` discards ``500 * n_chains`` raw samples. + Burn-in generations to discard before collecting samples. + Note 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 kept, - which reduces autocorrelation between consecutive draws. + Thinning interval — only every ``thin``-th generation is + kept, which reduces autocorrelation between consecutive + draws. population : int | None, default=None - DREAM population **scale factor** (not an absolute chain count): - BUMPS creates ``ceil(population * n_parameters)`` parallel chains. + DREAM population **scale factor** (not an absolute chain + count): BUMPS creates ``ceil(population * n_parameters)`` + parallel chains. 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. The payload - dict includes ``iteration`` and ``sampling: True``. + Optional callback invoked at each DREAM generation. The + payload dict includes ``iteration`` and ``sampling: True``. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling early. + Optional callable that returns ``True`` to abort sampling + early. Returns ------- @@ -584,14 +620,16 @@ def sample( Notes ----- - ``results.draws`` is a trimmed, outlier-filtered view of the chain, - so it is usually smaller than the chain and its length is not - predictable from ``samples``/``thin``. See the ``Sampler`` class notes - for how to read the full chain or switch trimming off. + ``results.draws`` is a trimmed, outlier-filtered view of the + chain, so it is usually smaller than the chain and its length is + not predictable from ``samples``/``thin``. See the ``Sampler`` + class notes for how to read the full chain or switch trimming + off. 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. + ``samples``, ``burn``, or ``thin`` are invalid, and + ``RuntimeError`` if the active minimizer is not a BUMPS + instance. """ if self._state is not None: global_object.log.getLogger('fitting').warning( @@ -618,38 +656,43 @@ def extend( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """Continue the existing chain with additional samples. + """ + Continue the existing chain with additional samples. DREAM stores draws in a fixed-size ring buffer sized to its - ``samples`` parameter; this method does the ring-buffer arithmetic - for you (``samples = stored_generations * population + - additional_samples``) so no existing draws are dropped from the - buffer, regardless of the thinning interval. Runs with ``burn=0`` — - re-burning a converged chain is usually a mistake, and BUMPS forces - it to 0 on resume in any case. The DREAM population is recovered from - the saved state and cannot be changed on extend. + ``samples`` parameter; this method does the ring-buffer + arithmetic for you (``samples = stored_generations * population + + additional_samples``) so no existing draws are dropped from + the buffer, regardless of the thinning interval. Runs with + ``burn=0`` — re-burning a converged chain is usually a mistake, + and BUMPS forces it to 0 on resume in any case. The DREAM + population is recovered from the saved state and cannot be + changed on extend. Parameters ---------- additional_samples : int, default=5000 - Number of additional DREAM samples to draw, in the same units - as ``samples`` in ``sample()``. This grows the ring buffer by - exactly ``additional_samples``; how many of the new draws become - *visible* in ``results.draws`` depends on the thinning interval - and on BUMPS' automatic trimming (see Notes). + Number of additional DREAM samples to draw, in the same + units as ``samples`` in ``sample()``. This grows the ring + buffer by exactly ``additional_samples``; how many of the + new draws become *visible* in ``results.draws`` depends on + the thinning interval and on BUMPS' automatic trimming (see + Notes). thin : int, default=10 Thinning interval for the retained draws. total_samples : int | None, default=None - Advanced: total retained samples requested from the ring buffer, - **overriding** the ``additional_samples`` arithmetic. With - ``total_samples=N``, only the last N draws are retained. + Advanced: total retained samples requested from the ring + buffer, **overriding** the ``additional_samples`` + arithmetic. With ``total_samples=N``, only the last N draws + are retained. 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. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling early. + Optional callable that returns ``True`` to abort sampling + early. Returns ------- @@ -665,16 +708,17 @@ def extend( Notes ----- ``results.draws`` will **not** grow by exactly - ``additional_samples / thin``. Two things get in the way, neither of - them a re-applied burn-in: BUMPS re-runs its burn-point detector over - the whole extended chain and re-trims the returned view (so the - visible count can even shrink), and DREAM advances in blocks of 10 - generations, so the raw growth is ``additional_samples`` rounded up - to a multiple of ``10 * n_chains`` (i.e. at least - ``additional_samples / thin`` retained rows). To see the chain - itself, compare ``results.state.draw(portion=1.0, outliers=True)`` - before and after, or run with ``sampler_kwargs={'trim': False}``. See - the ``Sampler`` class notes. + ``additional_samples / thin``. Two things get in the way, + neither of them a re-applied burn-in: BUMPS re-runs its + burn-point detector over the whole extended chain and re-trims + the returned view (so the visible count can even shrink), and + DREAM advances in blocks of 10 generations, so the raw growth is + ``additional_samples`` rounded up to a multiple of ``10 * + n_chains`` (i.e. at least ``additional_samples / thin`` retained + rows). To see the chain itself, compare + ``results.state.draw(portion=1.0, outliers=True)`` before and + after, or run with ``sampler_kwargs={'trim': False}``. See the + ``Sampler`` class notes. """ if self._state is None: raise RuntimeError('No chain to extend. Call sample() or load_state() first.') @@ -698,14 +742,15 @@ def extend( ) def save(self, path: str | os.PathLike) -> None: - """Persist the chain state and metadata to disk. + """ + Persist the chain state and metadata to disk. Writes the BUMPS native files (``-chain.mc.gz``, ``-point.mc.gz`` and ``-stats.mc.gz``) plus a ``.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. + 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. Parameters ---------- @@ -746,13 +791,14 @@ def save(self, path: str | os.PathLike) -> None: json.dump(sidecar, f, indent=2) def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: - """Load a previously saved chain into this sampler. + """ + Load a previously saved chain into this sampler. - The sampler must be constructed with the same fitter 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 - different data is undefined behaviour). + The sampler must be constructed with the same fitter 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 different data is undefined behaviour). Populates ``state`` and ``results`` (draws, log-posterior and parameter names) from the saved chain, so summaries and @@ -763,8 +809,8 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: path : str | os.PathLike File path prefix used in ``save()``. skip : int, default=0 - Discard the first ``skip`` saved generations on load. Useful for - trimming additional burn-in without re-sampling. + Discard the first ``skip`` saved generations on load. Useful + for trimming additional burn-in without re-sampling. Returns ------- @@ -780,11 +826,11 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: Notes ----- - BUMPS does not persist its automatic trim point, so a reloaded chain - comes back **untrimmed**: this method reports more draws than the - ``sample()`` call that wrote the file, even though the chain is - identical. Use ``skip`` to discard leading generations explicitly. - See the ``Sampler`` class notes. + BUMPS does not persist its automatic trim point, so a reloaded + chain comes back **untrimmed**: this method reports more draws + than the ``sample()`` call that wrote the file, even though the + chain is identical. Use ``skip`` to discard leading generations + explicitly. See the ``Sampler`` class notes. """ # noqa: DOC502 -- raised in _validate_chain_path via load_chain state, param_names, sidecar = load_chain(path, skip=skip) From c08a0450718b17bd5c5df683f9dabc5e521e7db2 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:11:36 +0200 Subject: [PATCH 4/9] Added empty EasyList unit test --- src/easyscience/base_classes/easy_list.py | 2 +- tests/unit/base_classes/test_easy_list.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 535c0243..368528cb 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -423,7 +423,7 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ) # noqa: E501 else: protected_types = None - data_dicts = temp_dict.pop('data', None) + data_dicts = temp_dict.pop('data') data = [SerializerBase._deserialize_value(value_dict) for value_dict in data_dicts] kwargs = SerializerBase.deserialize_dict(temp_dict) return cls(data, protected_types=protected_types, **kwargs) diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 73bd1a09..966fc16e 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -570,6 +570,15 @@ def test_from_dict_round_trip(self): assert el2[1].unique_name == 'a2' assert d == el2.to_dict() # The dicts should be the same after round trip + def test_from_dict_round_trip_empty(self): + el = EasyList(unique_name='my_list', protected_types=Alpha) + d = el.to_dict() + # Clear the global map so deserialized objects can reuse the same unique names + global_object.map._clear() + el2 = EasyList.from_dict(d) + assert len(el2) == 0 + assert d == el2.to_dict() # The dicts should be the same after round trip + def test_from_dict_modelbase_round_trip(self, monkeypatch): # When model1 = MockModel(unique_name='m1', temperature=10, volume=5.0) From 8e1cf427372170ce4f5a446af5d1daecf9e6968f Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:12:43 +0200 Subject: [PATCH 5/9] revert sampler.py changes --- src/easyscience/fitting/sampler.py | 382 +++++++++++++---------------- 1 file changed, 168 insertions(+), 214 deletions(-) diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 3917a5bc..d3915115 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -1,8 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -""" -Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers. -""" +"""Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers.""" from __future__ import annotations @@ -40,9 +38,7 @@ def _data_fingerprint( y_list: list, w_list: list, ) -> str | None: - """ - Return a SHA-256 hex digest of concatenated (x|y|weights), or None. - """ + """Return a SHA-256 hex digest of concatenated (x|y|weights), or None.""" try: h = hashlib.sha256() for arr in list(x_list) + list(y_list) + list(w_list): @@ -57,8 +53,7 @@ def _validate_dataset_arrays( data: np.ndarray | list | tuple, allow_none_entries: bool = False, ) -> None: - """ - Check that ``data`` (an array or list of arrays) holds numeric, + """Check that ``data`` (an array or list of arrays) holds numeric, at-least-1-D, non-empty arrays. Structural checks (array vs list, matching dataset counts) are in @@ -106,13 +101,12 @@ def _validate_dataset_arrays( def _copy_data(data): - """ - Copy an array (or list of arrays) and mark the copies read-only. + """Copy an array (or list of arrays) and mark the copies read-only. - The ``Sampler`` binds its data at construction; copying decouples - the bound data from the caller's arrays, and the read-only flag - stops in-place mutation of the copies, so the chain and the - ``save()`` fingerprint always describe the data actually sampled. + The ``Sampler`` binds its data at construction; copying decouples the + bound data from the caller's arrays, and the read-only flag stops + 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 @@ -124,13 +118,10 @@ def _copy_data(data): def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: - """ - Validate the persistence arguments shared by the save/load - functions. + """Validate the persistence arguments shared by the save/load functions. - Returns ``path`` coerced to ``str``. Raises ``TypeError`` if - ``path`` is not path-like and ``ValueError`` if ``skip`` is not a - non-negative int. + Returns ``path`` coerced to ``str``. Raises ``TypeError`` if ``path`` is + not path-like and ``ValueError`` if ``skip`` is not a non-negative int. """ if not isinstance(path, (str, os.PathLike)): raise TypeError(f'path must be a str or os.PathLike, got {type(path).__name__}.') @@ -140,15 +131,14 @@ def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: def _load_bumps_state(path: str, skip: int = 0) -> MCMCDraw: - """ - Read a BUMPS chain from disk, working around a BUMPS bug. + """Read a BUMPS chain from disk, working around a BUMPS bug. ``bumps.dream.state.load_state`` reads the saved buffers with - ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. - A short chain stores a single CR-weight update row, so - ``load_state``'s subsequent ``stats[:, 0]`` indexing raises - ``IndexError: too many indices for array``. We coerce each buffer - read back to 2-D before ``load_state`` consumes it. + ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. A + short chain stores a single CR-weight update row, so ``load_state``'s + subsequent ``stats[:, 0]`` indexing raises ``IndexError: too many indices + for array``. We coerce each buffer read back to 2-D before ``load_state`` + consumes it. Parameters ---------- @@ -180,30 +170,28 @@ def _loadtxt_2d(file, report: int = 0) -> np.ndarray: def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[str] | None, dict]: - """ - Reload a DREAM chain state saved by ``Sampler.save``. + """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 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. + 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 + 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. Parameters ---------- path : str | os.PathLike File path prefix used when saving. skip : int, default=0 - Discard the first ``skip`` saved generations on load, forwarded - to ``bumps.dream.state.load_state``. + Discard the first ``skip`` saved generations on load, forwarded to + ``bumps.dream.state.load_state``. Returns ------- tuple[MCMCDraw, list[str] | None, dict] - The reloaded BUMPS chain state, the parameter names (or ``None`` - if neither sidecar nor labels yielded them), and the raw sidecar - dict (empty if absent/unreadable). + The reloaded BUMPS chain state, the parameter names (or ``None`` if + neither sidecar nor labels yielded them), and the raw sidecar dict + (empty if absent/unreadable). Raises ------ @@ -241,18 +229,15 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s @dataclass class SamplingResults: - """ - Structured result of an MCMC sampling run (analogous to - ``FitResults``). + """Structured result of an MCMC sampling run (analogous to ``FitResults``). Attributes ---------- draws : np.ndarray - Posterior samples, shape ``(n_samples, n_params)``. For results - from ``Sampler.sample()``/``Sampler.extend()`` this is a - *trimmed, outlier-filtered* view of the chain, not the raw - buffer, so it holds fewer rows than ``samples / thin`` — see the - notes on ``Sampler``. + Posterior samples, shape ``(n_samples, n_params)``. For results from + ``Sampler.sample()``/``Sampler.extend()`` this is a *trimmed, + outlier-filtered* view of the chain, not the raw buffer, so it holds + fewer rows than ``samples / thin`` — see the notes on ``Sampler``. param_names : list[str] Parameter names (one per column of ``draws``). logp : np.ndarray @@ -267,10 +252,8 @@ class SamplingResults: state: MCMCDraw def to_legacy_dict(self) -> dict: - """ - Return the legacy dict shape produced by the deprecated - ``mcmc_sample()`` APIs. - """ + """Return the legacy dict shape produced by the deprecated + ``mcmc_sample()`` APIs.""" return { 'draws': self.draws, 'param_names': self.param_names, @@ -280,64 +263,58 @@ def to_legacy_dict(self) -> dict: class Sampler: - """ - Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS - minimizer. - - One ``Sampler`` instance represents one chain over one ``(x, y, - weights)`` dataset. The data is bound at construction; ``sample()`` - and ``extend()`` take no data arguments, so a chain can never be - extended against different data (undefined behaviour in BUMPS). The - bound data is a defensive, read-only copy of the caller's arrays, - exposed via the ``x``, ``y`` and ``weights`` properties — mutating - the originals after construction has no 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. + """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer. + + One ``Sampler`` instance represents one chain over one ``(x, y, weights)`` + dataset. The data is bound at construction; ``sample()`` and ``extend()`` + take no data arguments, so a chain can never be extended against different + data (undefined behaviour in BUMPS). The bound data is a defensive, + read-only copy of the caller's arrays, exposed via the ``x``, ``y`` and + ``weights`` properties — mutating the originals after construction has no + 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. Parameters ---------- - fitter : 'Fitter' - A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has - been switched to ``AvailableMinimizers.Bumps``. + fitter : Fitter + A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been + switched to ``AvailableMinimizers.Bumps``. x : np.ndarray | list[np.ndarray] - Independent variable array (or list of arrays for - ``MultiFitter``). + Independent variable array (or list of arrays for ``MultiFitter``). y : np.ndarray | list[np.ndarray] - Dependent variable array (or list of arrays for - ``MultiFitter``). + 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``). 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. sampler_kwargs : dict | None, default=None - Per-instance default keyword arguments forwarded to the BUMPS - DREAM sampler on every run, merged with (and overridden by) - per-call ``sampler_kwargs``. + Per-instance default keyword arguments forwarded to the BUMPS DREAM + sampler on every run, merged with (and overridden by) per-call + ``sampler_kwargs``. 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 ``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. ValueError If ``x``, ``y`` and ``weights`` do not hold matching structures (all arrays, or lists of the same length), or any dataset is a @@ -345,44 +322,42 @@ class Sampler: Notes ----- - **The retained draws are a trimmed view, not the whole chain.** - BUMPS' DREAM sampler defaults to ``trim=True``: once sampling - finishes it runs a convergence-based burn-point detector over the - chain and returns only the portion after that point. - ``state.draw()`` additionally drops chains flagged as outliers. So - ``results.draws`` is usually *smaller* than the chain, and its - length is not deterministic — the detector re-runs from scratch on - every ``sample()`` and ``extend()`` call and may place the burn - point differently each time. Read the count off the array; do not - predict it. - - Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` - is a guaranteed minimum, not an exact count. DREAM advances in - blocks of 10 generations (one generation = one draw per chain) and - only checks its stopping condition between blocks, so the raw chain - length is ``samples`` rounded up to a multiple of ``10 * n_chains``. - - The trimming is only a *view* — nothing is dropped from the buffer. - To read the full untrimmed chain:: + **The retained draws are a trimmed view, not the whole chain.** BUMPS' + DREAM sampler defaults to ``trim=True``: once sampling finishes it runs a + convergence-based burn-point detector over the chain and returns only the + portion after that point. ``state.draw()`` additionally drops chains + flagged as outliers. So ``results.draws`` is usually *smaller* than the + chain, and its length is not deterministic — the detector re-runs from + scratch on every ``sample()`` and ``extend()`` call and may place the burn + point differently each time. Read the count off the array; do not predict + it. + + Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` is a + guaranteed minimum, not an exact count. DREAM advances in blocks of 10 + generations (one generation = one draw per chain) and only checks its + stopping condition between blocks, so the raw chain length is ``samples`` + rounded up to a multiple of ``10 * n_chains``. + + The trimming is only a *view* — nothing is dropped from the buffer. To + read the full untrimmed chain:: results = sampler.sample(samples=10000, burn=500, thin=2) full = results.state.draw(portion=1.0, outliers=True) full.points # (n_chain_rows, n_params) full.logp - To switch the automatic trimming off entirely, pass BUMPS' own - ``trim`` option straight through ``sampler_kwargs``; - ``results.draws`` then holds the whole chain:: + To switch the automatic trimming off entirely, pass BUMPS' own ``trim`` + option straight through ``sampler_kwargs``; ``results.draws`` then holds + the whole chain:: sampler = Sampler( fitter, x, y, weights=w, sampler_kwargs={'trim': False} ) - Note also that trimming does not survive a - ``save()``/``load_state()`` round-trip: BUMPS does not persist the - trim point, so a reloaded chain comes back untrimmed and - ``load_state()`` reports *more* draws than the ``sample()`` call - that created it. The chain itself is identical. + Note also that trimming does not survive a ``save()``/``load_state()`` + round-trip: BUMPS does not persist the trim point, so a reloaded chain + comes back untrimmed and ``load_state()`` reports *more* draws than the + ``sample()`` call that created it. The chain itself is identical. """ def __init__( @@ -460,9 +435,7 @@ def weights(self) -> np.ndarray | list[np.ndarray | None] | None: @property def state(self) -> MCMCDraw | None: - """ - Raw BUMPS MCMCDraw state (None before first sample/load_state). - """ + """Raw BUMPS MCMCDraw state (None before first sample/load_state).""" return self._state @property @@ -486,9 +459,7 @@ def logp(self) -> np.ndarray | None: return self._results.logp if self._results is not None else None def _fingerprint(self) -> str | None: - """ - SHA-256 fingerprint of the bound (x, y, weights) data, or 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: @@ -510,8 +481,7 @@ def _run( progress_callback: Callable[[dict], bool | None] | None, abort_test: Callable[[], bool] | None, ) -> SamplingResults: - """ - Shared sampling engine for ``sample()`` and ``extend()``. + """Shared sampling engine for ``sample()`` and ``extend()``. Argument validation for ``samples``/``burn``/``thin`` lives in ``Bumps.mcmc_sample`` (single source of truth). @@ -575,43 +545,37 @@ def sample( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """ - Run fresh Bayesian MCMC sampling on the bound data. + """Run fresh Bayesian MCMC sampling on the bound data. - Calling ``sample()`` on a sampler that already holds a chain - starts a **fresh** chain — the previous state and results are - replaced. Use ``extend()`` to continue an existing chain. + Calling ``sample()`` on a sampler that already holds a chain starts a + **fresh** chain — the previous state and results are replaced. Use + ``extend()`` to continue an existing chain. Parameters ---------- samples : int, default=10000 - Number of raw samples to draw across all chains, before - thinning. This is 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``. + Number of raw samples to draw across all chains, before thinning. + This is 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 before collecting samples. - Note BUMPS counts ``burn`` in generations while ``samples`` - counts raw draws, so ``burn=500`` discards ``500 * - n_chains`` raw samples. + Burn-in generations to discard before collecting samples. Note + 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 - kept, which reduces autocorrelation between consecutive - draws. + Thinning interval — only every ``thin``-th generation is kept, + which reduces autocorrelation between consecutive draws. population : int | None, default=None - DREAM population **scale factor** (not an absolute chain - count): BUMPS creates ``ceil(population * n_parameters)`` - parallel chains. + DREAM population **scale factor** (not an absolute chain count): + BUMPS creates ``ceil(population * n_parameters)`` parallel chains. 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. The - payload dict includes ``iteration`` and ``sampling: True``. + Optional callback invoked at each DREAM generation. The payload + dict includes ``iteration`` and ``sampling: True``. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling - early. + Optional callable that returns ``True`` to abort sampling early. Returns ------- @@ -620,16 +584,14 @@ def sample( Notes ----- - ``results.draws`` is a trimmed, outlier-filtered view of the - chain, so it is usually smaller than the chain and its length is - not predictable from ``samples``/``thin``. See the ``Sampler`` - class notes for how to read the full chain or switch trimming - off. + ``results.draws`` is a trimmed, outlier-filtered view of the chain, + so it is usually smaller than the chain and its length is not + predictable from ``samples``/``thin``. See the ``Sampler`` class notes + for how to read the full chain or switch trimming off. 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. + ``samples``, ``burn``, or ``thin`` are invalid, and ``RuntimeError`` + if the active minimizer is not a BUMPS instance. """ if self._state is not None: global_object.log.getLogger('fitting').warning( @@ -656,43 +618,38 @@ def extend( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """ - Continue the existing chain with additional samples. + """Continue the existing chain with additional samples. DREAM stores draws in a fixed-size ring buffer sized to its - ``samples`` parameter; this method does the ring-buffer - arithmetic for you (``samples = stored_generations * population - + additional_samples``) so no existing draws are dropped from - the buffer, regardless of the thinning interval. Runs with - ``burn=0`` — re-burning a converged chain is usually a mistake, - and BUMPS forces it to 0 on resume in any case. The DREAM - population is recovered from the saved state and cannot be - changed on extend. + ``samples`` parameter; this method does the ring-buffer arithmetic + for you (``samples = stored_generations * population + + additional_samples``) so no existing draws are dropped from the + buffer, regardless of the thinning interval. Runs with ``burn=0`` — + re-burning a converged chain is usually a mistake, and BUMPS forces + it to 0 on resume in any case. The DREAM population is recovered from + the saved state and cannot be changed on extend. Parameters ---------- additional_samples : int, default=5000 - Number of additional DREAM samples to draw, in the same - units as ``samples`` in ``sample()``. This grows the ring - buffer by exactly ``additional_samples``; how many of the - new draws become *visible* in ``results.draws`` depends on - the thinning interval and on BUMPS' automatic trimming (see - Notes). + Number of additional DREAM samples to draw, in the same units + as ``samples`` in ``sample()``. This grows the ring buffer by + exactly ``additional_samples``; how many of the new draws become + *visible* in ``results.draws`` depends on the thinning interval + and on BUMPS' automatic trimming (see Notes). thin : int, default=10 Thinning interval for the retained draws. total_samples : int | None, default=None - Advanced: total retained samples requested from the ring - buffer, **overriding** the ``additional_samples`` - arithmetic. With ``total_samples=N``, only the last N draws - are retained. + Advanced: total retained samples requested from the ring buffer, + **overriding** the ``additional_samples`` arithmetic. With + ``total_samples=N``, only the last N draws are retained. 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. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling - early. + Optional callable that returns ``True`` to abort sampling early. Returns ------- @@ -708,17 +665,16 @@ def extend( Notes ----- ``results.draws`` will **not** grow by exactly - ``additional_samples / thin``. Two things get in the way, - neither of them a re-applied burn-in: BUMPS re-runs its - burn-point detector over the whole extended chain and re-trims - the returned view (so the visible count can even shrink), and - DREAM advances in blocks of 10 generations, so the raw growth is - ``additional_samples`` rounded up to a multiple of ``10 * - n_chains`` (i.e. at least ``additional_samples / thin`` retained - rows). To see the chain itself, compare - ``results.state.draw(portion=1.0, outliers=True)`` before and - after, or run with ``sampler_kwargs={'trim': False}``. See the - ``Sampler`` class notes. + ``additional_samples / thin``. Two things get in the way, neither of + them a re-applied burn-in: BUMPS re-runs its burn-point detector over + the whole extended chain and re-trims the returned view (so the + visible count can even shrink), and DREAM advances in blocks of 10 + generations, so the raw growth is ``additional_samples`` rounded up + to a multiple of ``10 * n_chains`` (i.e. at least + ``additional_samples / thin`` retained rows). To see the chain + itself, compare ``results.state.draw(portion=1.0, outliers=True)`` + before and after, or run with ``sampler_kwargs={'trim': False}``. See + the ``Sampler`` class notes. """ if self._state is None: raise RuntimeError('No chain to extend. Call sample() or load_state() first.') @@ -742,15 +698,14 @@ def extend( ) def save(self, path: str | os.PathLike) -> None: - """ - Persist the chain state and metadata to disk. + """Persist the chain state and metadata to disk. Writes the BUMPS native files (``-chain.mc.gz``, ``-point.mc.gz`` and ``-stats.mc.gz``) plus a ``.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. + 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. Parameters ---------- @@ -791,14 +746,13 @@ def save(self, path: str | os.PathLike) -> None: json.dump(sidecar, f, indent=2) def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: - """ - Load a previously saved chain into this sampler. + """Load a previously saved chain into this sampler. - The sampler must be constructed with the same fitter 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 different data is undefined behaviour). + The sampler must be constructed with the same fitter 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 + different data is undefined behaviour). Populates ``state`` and ``results`` (draws, log-posterior and parameter names) from the saved chain, so summaries and @@ -809,8 +763,8 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: path : str | os.PathLike File path prefix used in ``save()``. skip : int, default=0 - Discard the first ``skip`` saved generations on load. Useful - for trimming additional burn-in without re-sampling. + Discard the first ``skip`` saved generations on load. Useful for + trimming additional burn-in without re-sampling. Returns ------- @@ -826,11 +780,11 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: Notes ----- - BUMPS does not persist its automatic trim point, so a reloaded - chain comes back **untrimmed**: this method reports more draws - than the ``sample()`` call that wrote the file, even though the - chain is identical. Use ``skip`` to discard leading generations - explicitly. See the ``Sampler`` class notes. + BUMPS does not persist its automatic trim point, so a reloaded chain + comes back **untrimmed**: this method reports more draws than the + ``sample()`` call that wrote the file, even though the chain is + identical. Use ``skip`` to discard leading generations explicitly. + See the ``Sampler`` class notes. """ # noqa: DOC502 -- raised in _validate_chain_path via load_chain state, param_names, sidecar = load_chain(path, skip=skip) From bf61d1f264fa8649f887a618bcb3a04ee9a1e9ff Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:13:28 +0200 Subject: [PATCH 6/9] Reverted minimizer_bumps changes --- .../fitting/minimizers/minimizer_bumps.py | 85 +++++++++---------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 500edce6..315d856c 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -417,45 +417,42 @@ def mcmc_sample( 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``. + 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. + generations while ``samples`` counts raw draws, so ``burn=500`` + discards ``500 * n_chains`` raw samples. thin : int, default=10 - Thinning interval — only every ``thin``-th generation is - stored. + Thinning interval — only every ``thin``-th generation is stored. population : int | None, default=None - BUMPS DREAM population count per parameter (number of - parallel chains): BUMPS creates ``ceil(population * - n_parameters)`` chains. + 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). + ``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``. @@ -478,9 +475,9 @@ def mcmc_sample( ------ 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). + ``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``). @@ -611,32 +608,28 @@ def _validate_resume_state( population: int | None, burn: int, ) -> tuple[int, int]: - """ - Check that ``resume_state`` is compatible with ``problem`` and + """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. + 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. + 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. + ``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 ------ From e0ca10c6c042f2fb902d27cdd6c146f767d685cf Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:16:02 +0200 Subject: [PATCH 7/9] revert minimizer_bumps changes again --- .../fitting/minimizers/minimizer_bumps.py | 85 +++++++++---------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/src/easyscience/fitting/minimizers/minimizer_bumps.py b/src/easyscience/fitting/minimizers/minimizer_bumps.py index 500edce6..315d856c 100644 --- a/src/easyscience/fitting/minimizers/minimizer_bumps.py +++ b/src/easyscience/fitting/minimizers/minimizer_bumps.py @@ -417,45 +417,42 @@ def mcmc_sample( 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``. + 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. + generations while ``samples`` counts raw draws, so ``burn=500`` + discards ``500 * n_chains`` raw samples. thin : int, default=10 - Thinning interval — only every ``thin``-th generation is - stored. + Thinning interval — only every ``thin``-th generation is stored. population : int | None, default=None - BUMPS DREAM population count per parameter (number of - parallel chains): BUMPS creates ``ceil(population * - n_parameters)`` chains. + 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). + ``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``. @@ -478,9 +475,9 @@ def mcmc_sample( ------ 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). + ``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``). @@ -611,32 +608,28 @@ def _validate_resume_state( population: int | None, burn: int, ) -> tuple[int, int]: - """ - Check that ``resume_state`` is compatible with ``problem`` and + """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. + 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. + 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. + ``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 ------ From e40d6de9a81eab4a1d4cec7ae8c6f9726c9218a8 Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Tue, 6 Oct 2026 16:16:38 +0200 Subject: [PATCH 8/9] Revert sampler.py again --- src/easyscience/fitting/sampler.py | 382 +++++++++++++---------------- 1 file changed, 168 insertions(+), 214 deletions(-) diff --git a/src/easyscience/fitting/sampler.py b/src/easyscience/fitting/sampler.py index 3917a5bc..d3915115 100644 --- a/src/easyscience/fitting/sampler.py +++ b/src/easyscience/fitting/sampler.py @@ -1,8 +1,6 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -""" -Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers. -""" +"""Bayesian MCMC sampling — the ``Sampler`` class and persistence helpers.""" from __future__ import annotations @@ -40,9 +38,7 @@ def _data_fingerprint( y_list: list, w_list: list, ) -> str | None: - """ - Return a SHA-256 hex digest of concatenated (x|y|weights), or None. - """ + """Return a SHA-256 hex digest of concatenated (x|y|weights), or None.""" try: h = hashlib.sha256() for arr in list(x_list) + list(y_list) + list(w_list): @@ -57,8 +53,7 @@ def _validate_dataset_arrays( data: np.ndarray | list | tuple, allow_none_entries: bool = False, ) -> None: - """ - Check that ``data`` (an array or list of arrays) holds numeric, + """Check that ``data`` (an array or list of arrays) holds numeric, at-least-1-D, non-empty arrays. Structural checks (array vs list, matching dataset counts) are in @@ -106,13 +101,12 @@ def _validate_dataset_arrays( def _copy_data(data): - """ - Copy an array (or list of arrays) and mark the copies read-only. + """Copy an array (or list of arrays) and mark the copies read-only. - The ``Sampler`` binds its data at construction; copying decouples - the bound data from the caller's arrays, and the read-only flag - stops in-place mutation of the copies, so the chain and the - ``save()`` fingerprint always describe the data actually sampled. + The ``Sampler`` binds its data at construction; copying decouples the + bound data from the caller's arrays, and the read-only flag stops + 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 @@ -124,13 +118,10 @@ def _copy_data(data): def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: - """ - Validate the persistence arguments shared by the save/load - functions. + """Validate the persistence arguments shared by the save/load functions. - Returns ``path`` coerced to ``str``. Raises ``TypeError`` if - ``path`` is not path-like and ``ValueError`` if ``skip`` is not a - non-negative int. + Returns ``path`` coerced to ``str``. Raises ``TypeError`` if ``path`` is + not path-like and ``ValueError`` if ``skip`` is not a non-negative int. """ if not isinstance(path, (str, os.PathLike)): raise TypeError(f'path must be a str or os.PathLike, got {type(path).__name__}.') @@ -140,15 +131,14 @@ def _validate_chain_path(path: str | os.PathLike, skip: int = 0) -> str: def _load_bumps_state(path: str, skip: int = 0) -> MCMCDraw: - """ - Read a BUMPS chain from disk, working around a BUMPS bug. + """Read a BUMPS chain from disk, working around a BUMPS bug. ``bumps.dream.state.load_state`` reads the saved buffers with - ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. - A short chain stores a single CR-weight update row, so - ``load_state``'s subsequent ``stats[:, 0]`` indexing raises - ``IndexError: too many indices for array``. We coerce each buffer - read back to 2-D before ``load_state`` consumes it. + ``numpy.loadtxt``, which collapses a single-row file to a 1-D array. A + short chain stores a single CR-weight update row, so ``load_state``'s + subsequent ``stats[:, 0]`` indexing raises ``IndexError: too many indices + for array``. We coerce each buffer read back to 2-D before ``load_state`` + consumes it. Parameters ---------- @@ -180,30 +170,28 @@ def _loadtxt_2d(file, report: int = 0) -> np.ndarray: def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[str] | None, dict]: - """ - Reload a DREAM chain state saved by ``Sampler.save``. + """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 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. + 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 + 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. Parameters ---------- path : str | os.PathLike File path prefix used when saving. skip : int, default=0 - Discard the first ``skip`` saved generations on load, forwarded - to ``bumps.dream.state.load_state``. + Discard the first ``skip`` saved generations on load, forwarded to + ``bumps.dream.state.load_state``. Returns ------- tuple[MCMCDraw, list[str] | None, dict] - The reloaded BUMPS chain state, the parameter names (or ``None`` - if neither sidecar nor labels yielded them), and the raw sidecar - dict (empty if absent/unreadable). + The reloaded BUMPS chain state, the parameter names (or ``None`` if + neither sidecar nor labels yielded them), and the raw sidecar dict + (empty if absent/unreadable). Raises ------ @@ -241,18 +229,15 @@ def load_chain(path: str | os.PathLike, skip: int = 0) -> tuple[MCMCDraw, list[s @dataclass class SamplingResults: - """ - Structured result of an MCMC sampling run (analogous to - ``FitResults``). + """Structured result of an MCMC sampling run (analogous to ``FitResults``). Attributes ---------- draws : np.ndarray - Posterior samples, shape ``(n_samples, n_params)``. For results - from ``Sampler.sample()``/``Sampler.extend()`` this is a - *trimmed, outlier-filtered* view of the chain, not the raw - buffer, so it holds fewer rows than ``samples / thin`` — see the - notes on ``Sampler``. + Posterior samples, shape ``(n_samples, n_params)``. For results from + ``Sampler.sample()``/``Sampler.extend()`` this is a *trimmed, + outlier-filtered* view of the chain, not the raw buffer, so it holds + fewer rows than ``samples / thin`` — see the notes on ``Sampler``. param_names : list[str] Parameter names (one per column of ``draws``). logp : np.ndarray @@ -267,10 +252,8 @@ class SamplingResults: state: MCMCDraw def to_legacy_dict(self) -> dict: - """ - Return the legacy dict shape produced by the deprecated - ``mcmc_sample()`` APIs. - """ + """Return the legacy dict shape produced by the deprecated + ``mcmc_sample()`` APIs.""" return { 'draws': self.draws, 'param_names': self.param_names, @@ -280,64 +263,58 @@ def to_legacy_dict(self) -> dict: class Sampler: - """ - Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS - minimizer. - - One ``Sampler`` instance represents one chain over one ``(x, y, - weights)`` dataset. The data is bound at construction; ``sample()`` - and ``extend()`` take no data arguments, so a chain can never be - extended against different data (undefined behaviour in BUMPS). The - bound data is a defensive, read-only copy of the caller's arrays, - exposed via the ``x``, ``y`` and ``weights`` properties — mutating - the originals after construction has no 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. + """Bayesian MCMC sampler for one dataset, backed by a Fitter's BUMPS minimizer. + + One ``Sampler`` instance represents one chain over one ``(x, y, weights)`` + dataset. The data is bound at construction; ``sample()`` and ``extend()`` + take no data arguments, so a chain can never be extended against different + data (undefined behaviour in BUMPS). The bound data is a defensive, + read-only copy of the caller's arrays, exposed via the ``x``, ``y`` and + ``weights`` properties — mutating the originals after construction has no + 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. Parameters ---------- - fitter : 'Fitter' - A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has - been switched to ``AvailableMinimizers.Bumps``. + fitter : Fitter + A configured ``Fitter`` (or ``MultiFitter``) whose minimizer has been + switched to ``AvailableMinimizers.Bumps``. x : np.ndarray | list[np.ndarray] - Independent variable array (or list of arrays for - ``MultiFitter``). + Independent variable array (or list of arrays for ``MultiFitter``). y : np.ndarray | list[np.ndarray] - Dependent variable array (or list of arrays for - ``MultiFitter``). + 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``). 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. sampler_kwargs : dict | None, default=None - Per-instance default keyword arguments forwarded to the BUMPS - DREAM sampler on every run, merged with (and overridden by) - per-call ``sampler_kwargs``. + Per-instance default keyword arguments forwarded to the BUMPS DREAM + sampler on every run, merged with (and overridden by) per-call + ``sampler_kwargs``. 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 ``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. ValueError If ``x``, ``y`` and ``weights`` do not hold matching structures (all arrays, or lists of the same length), or any dataset is a @@ -345,44 +322,42 @@ class Sampler: Notes ----- - **The retained draws are a trimmed view, not the whole chain.** - BUMPS' DREAM sampler defaults to ``trim=True``: once sampling - finishes it runs a convergence-based burn-point detector over the - chain and returns only the portion after that point. - ``state.draw()`` additionally drops chains flagged as outliers. So - ``results.draws`` is usually *smaller* than the chain, and its - length is not deterministic — the detector re-runs from scratch on - every ``sample()`` and ``extend()`` call and may place the burn - point differently each time. Read the count off the array; do not - predict it. - - Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` - is a guaranteed minimum, not an exact count. DREAM advances in - blocks of 10 generations (one generation = one draw per chain) and - only checks its stopping condition between blocks, so the raw chain - length is ``samples`` rounded up to a multiple of ``10 * n_chains``. - - The trimming is only a *view* — nothing is dropped from the buffer. - To read the full untrimmed chain:: + **The retained draws are a trimmed view, not the whole chain.** BUMPS' + DREAM sampler defaults to ``trim=True``: once sampling finishes it runs a + convergence-based burn-point detector over the chain and returns only the + portion after that point. ``state.draw()`` additionally drops chains + flagged as outliers. So ``results.draws`` is usually *smaller* than the + chain, and its length is not deterministic — the detector re-runs from + scratch on every ``sample()`` and ``extend()`` call and may place the burn + point differently each time. Read the count off the array; do not predict + it. + + Nor is the chain itself exactly ``samples / thin`` rows: ``samples`` is a + guaranteed minimum, not an exact count. DREAM advances in blocks of 10 + generations (one generation = one draw per chain) and only checks its + stopping condition between blocks, so the raw chain length is ``samples`` + rounded up to a multiple of ``10 * n_chains``. + + The trimming is only a *view* — nothing is dropped from the buffer. To + read the full untrimmed chain:: results = sampler.sample(samples=10000, burn=500, thin=2) full = results.state.draw(portion=1.0, outliers=True) full.points # (n_chain_rows, n_params) full.logp - To switch the automatic trimming off entirely, pass BUMPS' own - ``trim`` option straight through ``sampler_kwargs``; - ``results.draws`` then holds the whole chain:: + To switch the automatic trimming off entirely, pass BUMPS' own ``trim`` + option straight through ``sampler_kwargs``; ``results.draws`` then holds + the whole chain:: sampler = Sampler( fitter, x, y, weights=w, sampler_kwargs={'trim': False} ) - Note also that trimming does not survive a - ``save()``/``load_state()`` round-trip: BUMPS does not persist the - trim point, so a reloaded chain comes back untrimmed and - ``load_state()`` reports *more* draws than the ``sample()`` call - that created it. The chain itself is identical. + Note also that trimming does not survive a ``save()``/``load_state()`` + round-trip: BUMPS does not persist the trim point, so a reloaded chain + comes back untrimmed and ``load_state()`` reports *more* draws than the + ``sample()`` call that created it. The chain itself is identical. """ def __init__( @@ -460,9 +435,7 @@ def weights(self) -> np.ndarray | list[np.ndarray | None] | None: @property def state(self) -> MCMCDraw | None: - """ - Raw BUMPS MCMCDraw state (None before first sample/load_state). - """ + """Raw BUMPS MCMCDraw state (None before first sample/load_state).""" return self._state @property @@ -486,9 +459,7 @@ def logp(self) -> np.ndarray | None: return self._results.logp if self._results is not None else None def _fingerprint(self) -> str | None: - """ - SHA-256 fingerprint of the bound (x, y, weights) data, or 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: @@ -510,8 +481,7 @@ def _run( progress_callback: Callable[[dict], bool | None] | None, abort_test: Callable[[], bool] | None, ) -> SamplingResults: - """ - Shared sampling engine for ``sample()`` and ``extend()``. + """Shared sampling engine for ``sample()`` and ``extend()``. Argument validation for ``samples``/``burn``/``thin`` lives in ``Bumps.mcmc_sample`` (single source of truth). @@ -575,43 +545,37 @@ def sample( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """ - Run fresh Bayesian MCMC sampling on the bound data. + """Run fresh Bayesian MCMC sampling on the bound data. - Calling ``sample()`` on a sampler that already holds a chain - starts a **fresh** chain — the previous state and results are - replaced. Use ``extend()`` to continue an existing chain. + Calling ``sample()`` on a sampler that already holds a chain starts a + **fresh** chain — the previous state and results are replaced. Use + ``extend()`` to continue an existing chain. Parameters ---------- samples : int, default=10000 - Number of raw samples to draw across all chains, before - thinning. This is 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``. + Number of raw samples to draw across all chains, before thinning. + This is 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 before collecting samples. - Note BUMPS counts ``burn`` in generations while ``samples`` - counts raw draws, so ``burn=500`` discards ``500 * - n_chains`` raw samples. + Burn-in generations to discard before collecting samples. Note + 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 - kept, which reduces autocorrelation between consecutive - draws. + Thinning interval — only every ``thin``-th generation is kept, + which reduces autocorrelation between consecutive draws. population : int | None, default=None - DREAM population **scale factor** (not an absolute chain - count): BUMPS creates ``ceil(population * n_parameters)`` - parallel chains. + DREAM population **scale factor** (not an absolute chain count): + BUMPS creates ``ceil(population * n_parameters)`` parallel chains. 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. The - payload dict includes ``iteration`` and ``sampling: True``. + Optional callback invoked at each DREAM generation. The payload + dict includes ``iteration`` and ``sampling: True``. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling - early. + Optional callable that returns ``True`` to abort sampling early. Returns ------- @@ -620,16 +584,14 @@ def sample( Notes ----- - ``results.draws`` is a trimmed, outlier-filtered view of the - chain, so it is usually smaller than the chain and its length is - not predictable from ``samples``/``thin``. See the ``Sampler`` - class notes for how to read the full chain or switch trimming - off. + ``results.draws`` is a trimmed, outlier-filtered view of the chain, + so it is usually smaller than the chain and its length is not + predictable from ``samples``/``thin``. See the ``Sampler`` class notes + for how to read the full chain or switch trimming off. 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. + ``samples``, ``burn``, or ``thin`` are invalid, and ``RuntimeError`` + if the active minimizer is not a BUMPS instance. """ if self._state is not None: global_object.log.getLogger('fitting').warning( @@ -656,43 +618,38 @@ def extend( progress_callback: Callable[[dict], bool | None] | None = None, abort_test: Callable[[], bool] | None = None, ) -> SamplingResults: - """ - Continue the existing chain with additional samples. + """Continue the existing chain with additional samples. DREAM stores draws in a fixed-size ring buffer sized to its - ``samples`` parameter; this method does the ring-buffer - arithmetic for you (``samples = stored_generations * population - + additional_samples``) so no existing draws are dropped from - the buffer, regardless of the thinning interval. Runs with - ``burn=0`` — re-burning a converged chain is usually a mistake, - and BUMPS forces it to 0 on resume in any case. The DREAM - population is recovered from the saved state and cannot be - changed on extend. + ``samples`` parameter; this method does the ring-buffer arithmetic + for you (``samples = stored_generations * population + + additional_samples``) so no existing draws are dropped from the + buffer, regardless of the thinning interval. Runs with ``burn=0`` — + re-burning a converged chain is usually a mistake, and BUMPS forces + it to 0 on resume in any case. The DREAM population is recovered from + the saved state and cannot be changed on extend. Parameters ---------- additional_samples : int, default=5000 - Number of additional DREAM samples to draw, in the same - units as ``samples`` in ``sample()``. This grows the ring - buffer by exactly ``additional_samples``; how many of the - new draws become *visible* in ``results.draws`` depends on - the thinning interval and on BUMPS' automatic trimming (see - Notes). + Number of additional DREAM samples to draw, in the same units + as ``samples`` in ``sample()``. This grows the ring buffer by + exactly ``additional_samples``; how many of the new draws become + *visible* in ``results.draws`` depends on the thinning interval + and on BUMPS' automatic trimming (see Notes). thin : int, default=10 Thinning interval for the retained draws. total_samples : int | None, default=None - Advanced: total retained samples requested from the ring - buffer, **overriding** the ``additional_samples`` - arithmetic. With ``total_samples=N``, only the last N draws - are retained. + Advanced: total retained samples requested from the ring buffer, + **overriding** the ``additional_samples`` arithmetic. With + ``total_samples=N``, only the last N draws are retained. 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. abort_test : Callable[[], bool] | None, default=None - Optional callable that returns ``True`` to abort sampling - early. + Optional callable that returns ``True`` to abort sampling early. Returns ------- @@ -708,17 +665,16 @@ def extend( Notes ----- ``results.draws`` will **not** grow by exactly - ``additional_samples / thin``. Two things get in the way, - neither of them a re-applied burn-in: BUMPS re-runs its - burn-point detector over the whole extended chain and re-trims - the returned view (so the visible count can even shrink), and - DREAM advances in blocks of 10 generations, so the raw growth is - ``additional_samples`` rounded up to a multiple of ``10 * - n_chains`` (i.e. at least ``additional_samples / thin`` retained - rows). To see the chain itself, compare - ``results.state.draw(portion=1.0, outliers=True)`` before and - after, or run with ``sampler_kwargs={'trim': False}``. See the - ``Sampler`` class notes. + ``additional_samples / thin``. Two things get in the way, neither of + them a re-applied burn-in: BUMPS re-runs its burn-point detector over + the whole extended chain and re-trims the returned view (so the + visible count can even shrink), and DREAM advances in blocks of 10 + generations, so the raw growth is ``additional_samples`` rounded up + to a multiple of ``10 * n_chains`` (i.e. at least + ``additional_samples / thin`` retained rows). To see the chain + itself, compare ``results.state.draw(portion=1.0, outliers=True)`` + before and after, or run with ``sampler_kwargs={'trim': False}``. See + the ``Sampler`` class notes. """ if self._state is None: raise RuntimeError('No chain to extend. Call sample() or load_state() first.') @@ -742,15 +698,14 @@ def extend( ) def save(self, path: str | os.PathLike) -> None: - """ - Persist the chain state and metadata to disk. + """Persist the chain state and metadata to disk. Writes the BUMPS native files (``-chain.mc.gz``, ``-point.mc.gz`` and ``-stats.mc.gz``) plus a ``.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. + 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. Parameters ---------- @@ -791,14 +746,13 @@ def save(self, path: str | os.PathLike) -> None: json.dump(sidecar, f, indent=2) def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: - """ - Load a previously saved chain into this sampler. + """Load a previously saved chain into this sampler. - The sampler must be constructed with the same fitter 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 different data is undefined behaviour). + The sampler must be constructed with the same fitter 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 + different data is undefined behaviour). Populates ``state`` and ``results`` (draws, log-posterior and parameter names) from the saved chain, so summaries and @@ -809,8 +763,8 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: path : str | os.PathLike File path prefix used in ``save()``. skip : int, default=0 - Discard the first ``skip`` saved generations on load. Useful - for trimming additional burn-in without re-sampling. + Discard the first ``skip`` saved generations on load. Useful for + trimming additional burn-in without re-sampling. Returns ------- @@ -826,11 +780,11 @@ def load_state(self, path: str | os.PathLike, skip: int = 0) -> SamplingResults: Notes ----- - BUMPS does not persist its automatic trim point, so a reloaded - chain comes back **untrimmed**: this method reports more draws - than the ``sample()`` call that wrote the file, even though the - chain is identical. Use ``skip`` to discard leading generations - explicitly. See the ``Sampler`` class notes. + BUMPS does not persist its automatic trim point, so a reloaded chain + comes back **untrimmed**: this method reports more draws than the + ``sample()`` call that wrote the file, even though the chain is + identical. Use ``skip`` to discard leading generations explicitly. + See the ``Sampler`` class notes. """ # noqa: DOC502 -- raised in _validate_chain_path via load_chain state, param_names, sidecar = load_chain(path, skip=skip) From ac77a9c3cad2c42d6359a599e4a7358e05fdfe7a Mon Sep 17 00:00:00 2001 From: Christian Vedel Date: Wed, 7 Oct 2026 11:01:48 +0200 Subject: [PATCH 9/9] Raise ValueError on missing `data` field. --- src/easyscience/base_classes/easy_list.py | 4 ++++ tests/unit/base_classes/test_easy_list.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/easyscience/base_classes/easy_list.py b/src/easyscience/base_classes/easy_list.py index 368528cb..1b99ab85 100644 --- a/src/easyscience/base_classes/easy_list.py +++ b/src/easyscience/base_classes/easy_list.py @@ -423,6 +423,10 @@ def from_dict(cls, obj_dict: Dict[str, Any]) -> NewBase: ) # noqa: E501 else: protected_types = None + if 'data' not in temp_dict: + raise ValueError( + 'The provided dictionary does not represent an EasyList. Missing the "data" key.' + ) data_dicts = temp_dict.pop('data') data = [SerializerBase._deserialize_value(value_dict) for value_dict in data_dicts] kwargs = SerializerBase.deserialize_dict(temp_dict) diff --git a/tests/unit/base_classes/test_easy_list.py b/tests/unit/base_classes/test_easy_list.py index 966fc16e..e3f7b35f 100644 --- a/tests/unit/base_classes/test_easy_list.py +++ b/tests/unit/base_classes/test_easy_list.py @@ -613,6 +613,15 @@ def test_from_dict_modelbase_round_trip(self, monkeypatch): assert el2[1].unique_name == 'm2' assert list_dict == el2.to_dict() # The dicts should be the same after round trip + def test_from_dict_missing_data_key(self): + el_dict = { + '@module': 'easyscience', + '@class': 'EasyList', + 'protected_types': [{'@module': 'easyscience.base_classes', '@class': 'ModelBase'}] + } + with pytest.raises(ValueError, match='The provided dictionary does not represent an EasyList. Missing the "data" key.'): + EasyList.from_dict(el_dict) + # --- get_all_variables --- def test_get_all_variables_empty_list(self):