Skip to content

Bayesian inference: sampling the posterior of a mechanism's rate constants (Epstein et al. 2016) - #197

Merged
remislp merged 4 commits into
masterfrom
mcmc
Sep 26, 2026
Merged

remislp merged 4 commits into
masterfrom
mcmc

Conversation

@remislp

@remislp remislp commented Sep 26, 2026

Copy link
Copy Markdown
Member

Bayesian inference over a mechanism's rate constants with the exact HJC missed-events likelihood, following Epstein, Calderhead, Girolami & Sivilotti (2016) Biophys J 111:333–348. It is a new pure-Python module, HJCFIT.likelihood.mcmc, driven by the same fit specification as hjcfit fit. There are no C++ changes.

[mcmc]
n = 20000
burnin = 5000
chains = 4
hjcfit sample my-fit.toml -o post

What's in it

HJCFIT.likelihood.mcmc is duck-typed on a mechanism like HJCFitter, and does not import scalcs.

  • LogPosterior is ln prior + ln likelihood over one or more Records. It wraps HJCFitter but never clips a rate to its limits. A point outside the prior gives −∞ without computing the likelihood. A likelihood that can't be computed also gives −∞ and is counted.
  • Priors: UniformPrior (the paper's) and LogUniformPrior. UniformPrior.from_mechanism() reads each free rate's limits, and scalcs' default limits are exactly the paper's prior.
  • mwg_sample is the paper's pilot: Metropolis-within-Gibbs on log rates, with the Jacobian applied and step sizes tuned during burn-in only.
  • adaptive_sample is adaptive Metropolis (Haario et al. 2001). The mixture can be the paper's weighted sum or the Roberts & Rosenthal (2009) random choice, and an optional initial covariance can shape the first proposals.
  • Chain holds samples, proposals, acceptance and scales, saves to .npz, and has mode() and kept().
  • Diagnostics: effective_sample_size (Geyer's initial monotone sequence, as the paper), significant_lags, autocorrelation and split-R̂ (potential_scale_reduction).
  • gaussian_approximation gives N(mode, (−H)⁻¹) from a numerical Hessian. It uses relative steps and one Richardson extrapolation, and reports an error estimate with every Hessian (relative_error). These are the paper's red curves and the ML standard errors.

Fitting layer

  • HJCFitter(solver=...) passes the root-finding options (SOLVER_OPTIONS) to every record's Log10Likelihood.

Fit specification and command

  • [likelihood] table: those same options, for fit and sample alike.
  • [mcmc] table:
    • start = "fit" (the default): search first, then start the chains at the ML estimate, with the Hessian as the first proposal covariance. If the Hessian can't be used, the run says so and falls back to the paper's small step.
    • start = "guess": the paper's pilot from the initial guess.
    • Also sampler, n, burnin, prior, chains, seed, and a few others.
  • runner.sample(): chains after the first start from draws of the Gaussian approximation.
    • Parallel chains run as spawned processes, each rebuilding its posterior from the spec (the C++ likelihood can't be pickled) and each limited to its share of the OpenMP threads.
    • It writes <prefix>.json (summary, spec, provenance) and one .npz per chain.
  • hjcfit sample SPEC [-o PREFIX] [--chains N] [--processes P] exits with status 2 when split-R̂ > 1.1.

Why the solver options matter

On the paper's three AChR records, its settings (nmax=2, tolerances 1e-12) give an ln L 0.38 higher than HJCFIT's defaults at the same rates. With those settings, a scalcs mechanism through HJCFIT reproduces the value from the paper's own MATLAB code (BICME) exactly: 237158.0646.

Validation

  • Unit tests: 288 pass locally (124 new).
    • The samplers are tested on targets whose answers are known: a correlated Gaussian, a log-normal and a box.
    • Removing the Jacobian from the log-space walk makes its tests fail, with the means shifted by exactly −σ² as theory predicts.
    • The statistical tests also pass for ten other seeds.
    • The Hessian is tested on functions with known second derivatives, including rates eight decades apart.
  • Reproduction of the paper, kept outside this repo since it uses unpublished data. Figs 3–10 were reproduced. On identical data, our chains agree with the paper's stored chains rate by rate (|z| ≤ 2.3 for the HJC comparison). Two headline results:
    • Real AChR data: the MCMC and Hessian standard deviations agree to 0.96–1.04 for eight rates. α1b and β1b come out non-Gaussian (SD ratios 1.32 and 2.68), as in the paper's Fig. 7.
    • Synthetic data: all ten true rates lie inside their 95% intervals. The adaptive sampler gives about 4× the effective samples per iteration of the pilot (the paper: 3×).
  • Performance, measured:
    • 99% of each evaluation is the C++ likelihood: 57, 36 and 29 ms per AChR evaluation on 1, 2 and 4 OpenMP threads.
    • On a 4-core laptop, four parallel chains ran 1.3–1.6× faster than four in turn.

Two things a reviewer should know

  • Nothing here was built locally (there is no compiler on the machine). The Python was tested against the 1.1.0 wheel, whose compiled part this branch does not change. This PR's CI is the first real build. mcmc.py is added to PYTHON_MODULES in likelihood/python/CMakeLists.txt.
  • A trap in scalcs: mec.theta_unsqueeze() does not rebuild mec.Q; only set_eff() does. HJCFitter and LogPosterior are safe because they call set_eff for every record. Any new code that reads mec.Q directly after setting rates must call it too.

🤖 Generated with Claude Code

remislp and others added 4 commits September 26, 2026 12:29
HJCFIT.likelihood.mcmc is the first part of Bayesian inference over the
free rate constants, following Epstein, Calderhead, Girolami & Sivilotti
(2016) Biophys J 111:333-348: the posterior density the samplers will
draw from.

- UniformPrior (the paper's) and LogUniformPrior. UniformPrior can be
  built from the free rates' own limits, and scalcs' default limits are
  exactly that paper's prior.
- LogPosterior: ln prior + ln likelihood over one or more Records. It
  wraps HJCFitter but never clips a rate to its limits. A point outside
  the prior is -inf without computing the likelihood, and a likelihood
  that cannot be computed is -inf and counted.
- HJCFitter(solver=...) passes root-finding options (SOLVER_OPTIONS) to
  every record's Log10Likelihood. On the paper's AChR records, its
  settings move ln L by 0.38 compared with the defaults.

Like the fitter, the module does not import scalcs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mwg_sample is the pilot: Metropolis-within-Gibbs on the logarithm of one
rate at a time. The Jacobian of the log walk is applied, and each step
size is tuned from its acceptance rate during burn-in.

adaptive_sample is adaptive Metropolis (Haario et al. 2001). It uses a
small isotropic step until adapt_start, then the running covariance of
the chain scaled by 2.38^2/k, mixed with the small step. The mixture can
be the paper's weighted sum or the Roberts & Rosenthal (2009) random
choice, and an optional initial covariance (e.g. an inverse Hessian)
shapes the small step. A global scale is tuned during burn-in only.

Both take any callable log density, return a Chain (samples, proposals,
acceptance, scales, provenance; .npz save/load), are reproducible from a
seed, and report progress through a callback.

effective_sample_size is Geyer's initial monotone sequence estimator,
truncated at significant_lags as in that paper's Table 2.

The tests use targets with known answers. The Jacobian test fails with
the Jacobian removed, the means shifting by exactly -sigma^2. The
statistical tests also pass for ten other seeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hessian() takes central second differences with steps relative to each
coordinate, because rates span eight decades. One Richardson
extrapolation gives O(h^4) and an error estimate for every element. A
step that leaves the prior, or where the likelihood fails, raises
rather than returning a meaningless number.

gaussian_approximation() returns N(mode, (-H)^-1): the basis of
maximum-likelihood standard errors, and the red curves of Epstein et al.
(2016, Figs 4 and 7). A point that is not a maximum is refused.
GaussianApproximation.relative_error shows how well the Hessian was
determined.

Measured on real data. On the paper's three AChR records, steps from
3e-2 to 1e-3 give identical standard deviations, with a normalised error
near 1e-5 at the default 3e-3. On the CH82 sample record, one
concentration with eight free rates, one direction is so flat that
likelihood noise (about 5e-8 in ln L) decides its curvature. The docs
say so, as a matter of identifiability.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Bayesian layer driven by the same file as a fit.

- [likelihood] passes root-finding settings (SOLVER_OPTIONS) to every
  record's likelihood, for `hjcfit fit` and `hjcfit sample` alike. A
  published value is reproduced only with the settings it was computed
  with: on the AChR records of Epstein et al. (2016) they move ln L by
  0.38.
- [mcmc] says how to sample. By default the search runs first and the
  chains start at the maximum-likelihood estimate, with the inverse
  Hessian there shaping the first proposals at the optimal scale.
  start = "guess" is the paper's pilot from the initial guess instead.
  Also: sampler, n, burnin, prior, mixture, log_space, chains, seed.
- runner.sample() runs the chains. It falls back, with a note, when the
  Hessian cannot be used, and starts extra chains from draws of the
  Gaussian approximation. Chains run in spawned processes, each
  rebuilding its posterior from the specification because the C++
  likelihood cannot be pickled, and each limited to its share of the
  OpenMP threads. Reports mean, sd, 95% interval, ESS and split-R-hat
  per rate, and writes <prefix>.json plus one .npz per chain.
- `hjcfit sample SPEC -o PREFIX [--chains N] [--processes P]`. Exits 2
  when split-R-hat exceeds 1.1.
- mcmc.potential_scale_reduction: split-R-hat (Gelman et al. 2013).

Measured, and stated in the docstring rather than assumed: the C++
likelihood takes 57/36/29 ms per AChR evaluation on 1/2/4 OpenMP
threads, and 99% of the time is inside it. On a 4-core laptop, four
parallel CH82 chains ran 1.3-1.6x faster than four in turn; two threads
per worker beat one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@remislp
remislp merged commit 953ae8f into master Sep 26, 2026
15 checks passed
@remislp
remislp deleted the mcmc branch September 26, 2026 14:59
@remislp remislp mentioned this pull request Sep 26, 2026
remislp added a commit that referenced this pull request Sep 26, 2026
1.1.0 published the fitting layer. This publishes a fit written as a file,
and Bayesian inference over the same fit.

  #196  A fit specification (TOML) and the hjcfit command: template, check,
        fit, show.
  #197  HJCFIT.likelihood.mcmc: the posterior of a mechanism's rate
        constants by MCMC, following Epstein et al. (2016). LogPosterior,
        priors, the pilot and adaptive samplers, Chain, ESS and split-R-hat,
        and the Gaussian approximation from a numerical Hessian. Also
        HJCFitter(solver=...), the [likelihood] and [mcmc] tables, and
        hjcfit sample.

288 tests passing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant