Skip to content

Add Bayesian posterior sampling to Analysis1d - #237

Open
henrikjacobsenfys wants to merge 3 commits into
developfrom
bayesian
Open

Add Bayesian posterior sampling to Analysis1d#237
henrikjacobsenfys wants to merge 3 commits into
developfrom
bayesian

Conversation

@henrikjacobsenfys

@henrikjacobsenfys henrikjacobsenfys commented Aug 13, 2026

Copy link
Copy Markdown
Member

Adds Bayesian MCMC posterior sampling to Analysis1d, built on the BUMPS DREAM sampler that arrived in easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports one point with a curvature-derived uncertainty, which is only trustworthy when parameters are uncorrelated and roughly Gaussian — often not the case in QENS. Sampling maps the whole posterior instead, so correlated and skewed parameters get honest credible intervals, and the correlations themselves become visible.

This is the first of two PRs. Analysis and ParameterAnalysis follow in a second one.

API

Everything to do with sampling hangs off one bayesian property:

analysis.fit()
analysis.bayesian.suggest_bounds().apply()
results = analysis.bayesian.sample(samples=10000, burn=2000, thin=10)

analysis.bayesian.summary()                # median + 68% interval, real names and units
analysis.bayesian.plot_trace()             # convergence check
analysis.bayesian.plot_corner()            # correlations
analysis.bayesian.plot_posterior_predictive()  # data vs credible band

analysis.bayesian.extend(additional_samples=5000)
analysis.bayesian.save(path) / .load(path)
analysis.bayesian.set_parameters_to_median()
analysis.bayesian.results                  # the chain, or None

analysis.fitter.switch_minimizer(...)      # escape hatch

Structure

analysis.bayesian is a PosteriorSampler the analysis holds, not a mixin it inherits. The sampler knows nothing about analyses: it is handed three callables — the chain parameters, the (x, y, weights) to fit against, and a labeller — plus an optional hook to bring cached state up to date before a run. That is the whole contract, and it is what lets PR 2 reuse the same class for ParameterAnalysis, which is not an AnalysisBase at all.

The pieces that are not sampling live on their own:

  • posterior_labels.pyParameterLabels turns parameters into the names and units everything reports under, built once per call rather than once per parameter.
  • posterior.py — bounds suggestions, the summary table, pile-up detection.
  • utils/posterior_plotting.py — the figures, as plain functions over arrays.

fit() now uses a cached Fitter instead of constructing one per call, invalidated through the existing dirty-flag pattern. Behaviour is unchanged — the full pre-existing suite passes untouched.

Design notes

Bounds are the prior. In DREAM the bounds are the uniform prior, so sampling refuses to run while any free parameter is unbounded. suggest_bounds() proposes bounds from the fitted values and uncertainties, and is advisory until .apply() is called. It only ever fills an infinite side, so physical limits (a non-negative area) survive; and because too narrow a bound truncates the posterior and understates uncertainty, the defaults are deliberately generous (10σ plus 20% of the value). The relative pad covers minimizers that report zero uncertainty; when there is genuinely no scale information the parameter is flagged rather than given an invented one.

Sampling restores your parameters. BUMPS leaves them wherever the last likelihood evaluation put them, which would silently move a fitted model off its fit.

Chains use real parameter names. The sampler labels columns with unique_name (Parameter_4). Those are per-session, so a saved chain reloaded elsewhere was unreadable; save() now writes a sidecar mapping them to stable Parameter.names, and loading without one warns rather than mislabelling columns.

Pile-up detection. After sampling, a warning fires when the posterior has piled up against a bound — catching both over-tight bounds and degenerate parameters that drift until a bound stops them. Threshold calibrated against a measured clipped chain (0.135 occupancy) versus healthy ones (0.000).

Degenerate models. BUMPS crashes with a bare IndexError inside its own outlier removal when chains scatter, which in practice means the model is not identifiable. That is re-raised naming the likely cause and the sampler_kwargs={'outliers': 'none'} workaround.

Testing

  • Unit tests mock the sampler; integration tests run real chains and check the posterior recovers known parameters, survives a save/load round trip, and extends.
  • New bayesian.ipynb tutorial executes end to end under nbmake.
  • pixi run fix and pixi run check both clean.

Notes for reviewers

Two pre-existing issues surfaced while building this, both left untouched as out of scope:

  • normalize_resolution() sets the resolution area to 1 but leaves it free, so the next fit() moves it and the normalization silently does not hold.
  • The models keep copies of their components, so component.area.fixed = True on an object you constructed has no effect on the analysis — you have to go through analysis.get_free_parameters(). This is easy to get wrong.

Worth a separate issue: fitting data with no uncertainties. Weights fall back to 1, which is harmless for least-squares but not for a posterior, whose width scales directly with the assumed noise. Treating σ as a free nuisance parameter would fix it, but needs a likelihood carrying the −N log σ term that BUMPS' Curve does not have. Documented as a caveat in the tutorial for now.

🤖 Generated with Claude Code

Expose the EasyScience Fitter on Analysis1d and add MCMC posterior
sampling on top of it, using the BUMPS DREAM sampler introduced in
easyscience 2.5.1 (easyscience.fitting.Sampler).

Least-squares fitting reports a single point with a curvature-derived
uncertainty, which is only trustworthy when parameters are uncorrelated
and roughly Gaussian. Sampling maps the whole posterior instead, so
correlated and skewed parameters get honest credible intervals.

The sampling machinery lives in a mixin with three hooks (build the
fitter, bind the data, list the chain parameters) so that Analysis and
ParameterAnalysis can reuse it. ParameterAnalysis is not an AnalysisBase
and builds a MultiFitter over binding models rather than over itself,
so a shared base class would not have worked.

Notable details:

- fit() now uses a cached Fitter instead of building one per call, and
  the cache is invalidated through the existing dirty-flag pattern.
- Bounds are the prior in DREAM, so sampling refuses to run with any
  infinite bound. suggest_bounds() proposes finite ones from the fitted
  values and uncertainties; it is advisory until .apply() is called and
  never loosens a bound that is already finite, so physical limits
  survive. A zero-width suggestion is flagged rather than invented.
- Sampling restores parameter values afterwards, since BUMPS leaves them
  wherever the last likelihood evaluation put them.
- Chains are reported under Parameter.name, not the internal unique_name.
  Those names are per-session, so save_chain() writes a sidecar mapping
  them to stable names and load_chain() uses it; loading without one
  warns rather than mislabelling the columns.
- After sampling, a warning fires when the posterior has piled up against
  a bound, which catches both bounds that are too tight and degenerate
  parameters that drift until a bound stops them.
- BUMPS crashes with a bare IndexError inside its own outlier removal
  when chains scatter, which in practice means a degenerate model. That
  is re-raised with the likely cause and a workaround.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.36111% with 44 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (develop@208d5d3). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/easydynamics/analysis/posterior_sampling.py 87.50% 21 Missing and 7 partials ⚠️
src/easydynamics/utils/posterior_plotting.py 90.26% 5 Missing and 6 partials ⚠️
src/easydynamics/analysis/posterior.py 96.71% 2 Missing and 3 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             develop     #237   +/-   ##
==========================================
  Coverage           ?   98.18%           
==========================================
  Files              ?       57           
  Lines              ?     4628           
  Branches           ?      795           
==========================================
  Hits               ?     4544           
  Misses             ?       47           
  Partials           ?       37           
Flag Coverage Δ
unittests 98.18% <92.36%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/easydynamics/analysis/__init__.py 100.00% <100.00%> (ø)
src/easydynamics/analysis/analysis1d.py 99.21% <100.00%> (ø)
src/easydynamics/analysis/posterior_labels.py 100.00% <100.00%> (ø)
src/easydynamics/utils/__init__.py 100.00% <100.00%> (ø)
src/easydynamics/analysis/posterior.py 96.71% <96.71%> (ø)
src/easydynamics/utils/posterior_plotting.py 90.26% <90.26%> (ø)
src/easydynamics/analysis/posterior_sampling.py 87.50% <87.50%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

henrikjacobsenfys and others added 2 commits August 14, 2026 13:07
Review feedback: bayesian_sampling.py had a lot in it that belonged
elsewhere, and it was unclear why it was a mixin at all.

It was a mixin because ParameterAnalysis is not an AnalysisBase and fits
its binding models rather than itself, so a shared base class does not
work. That was a reason, not a good one: it injected some forty methods
into every Analysis class.

The sampler is now composed. An Analysis exposes one `bayesian` property,
and hands the sampler the few things that differ between the Analysis
classes -- the data, the free parameters, their labels, and a hook to
refresh cached computation -- so PosteriorSampler needs no knowledge of
how any Analysis is built, and no Analysis inherits sampling machinery it
does not use.

Labelling moves to posterior_labels.py. Building it once for a fixed set
of parameters also removes the quadratic cost the old code needed a
scoped cache to avoid: the counts and lookups are computed in the
constructor rather than per column.

Plotting stays in posterior_plotting.py, where it already lived. The
sampler keeps three short delegates so a chain can still be plotted from
the object holding it, but none of the drawing happens there.

The public API becomes analysis.bayesian.sample() and friends, and the
explicit suggest_bounds().apply() step stays: in DREAM the bounds are the
prior, and an unbounded parameter gives a confident-looking interval set
by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The notebook tests run with '-n auto', and five of the notebooks fetch
vanadium_data_example.h5 through pooch. On a cold cache the workers race:
one is still writing the file into the cache while another opens it,
which fails on Windows with "PermissionError: Permission denied". This
failed twice in a row on windows-latest, always on that file, always
with the other sixteen notebooks passing.

The race is pre-existing, but adding a fifth notebook that wants the same
file, and lengthening tutorial 1, made it reliable rather than rare.

Fetching every tutorial data file once, before the parallel run starts,
leaves the workers with nothing to do but read, which is safe. The
prefetch reads the URLs and hashes out of the notebooks themselves, so it
cannot drift from what they actually download, and it never fails the
run: a file it cannot fetch is left to the notebook that needs it, which
reports the problem with far more context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46d745a)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[priority] medium Normal/default priority [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant