Skip to content

Add Bayesian posterior sampling to Analysis1d - #237

Open
henrikjacobsenfys wants to merge 2 commits into
developfrom
bayesian
Open

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

Conversation

@henrikjacobsenfys

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

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

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

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

Also extend_sampling(), save_chain()/load_chain(), set_parameters_to_posterior_median(), and posterior_result.

Structure

The machinery sits in BayesianSamplingMixin behind three hooks — build the fitter, bind the data, list the chain parameters — so PR 2 is additive rather than a refactor. A shared base class would not have worked: ParameterAnalysis is not an AnalysisBase, and it builds a MultiFitter over binding models rather than over itself.

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_chain() 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

  • 90 new tests; 1589 pass in total, including the pre-existing suite unchanged.
  • Unit tests mock the sampler; one integration test runs real chains (~13s) and checks the posterior recovers known parameters, survives a save/load round trip, and extends.
  • New bayesian.ipynb tutorial executes end to end in ~21s 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.

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>
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