Minimal, plug-and-play MCMC samplers for Python.
Built for Bayesian model updating and inverse problems in engineering, where the expensive part is your forward model and you want the sampler to stay out of the way. Depends only on NumPy and SciPy.
pip install mcmckit # core (numpy + scipy)
pip install mcmckit[plot] # + matplotlib for plotsFor development:
git clone https://github.com/LuigiCaglio/mcmckit
cd mcmckit
pip install -e ".[dev,plot]"Every sampler is a plain function that advances the chain by one step. State goes in as arguments and comes back as return values. Nothing is hidden on an object, so the recursion is yours to write, stop, inspect and modify.
The simplest sampler, with a fixed proposal covariance and no adaptation. If you read one example, read this one:
import numpy as np
from mcmckit import mh_step
def log_post(theta): # your model goes here
return -0.5 * np.sum(theta**2) # a standard normal, for illustration
x = np.zeros(2) # current position
logp = log_post(x) # its log posterior
cov = np.eye(2) * 0.5 # proposal covariance
chain = [] # a list, so the run can be any length
n_accepted = 0
for i in range(10_000):
x, logp, accepted = mh_step(log_post, x, logp, cov)
chain.append(x)
n_accepted += accepted
chain = np.array(chain) # (n_iterations, n_parameters)
posterior = chain[1000:] # burn-in is just a slice
print(posterior.mean(0), posterior.std(0))
print(f"acceptance rate: {n_accepted / len(chain):.2f}")mh_step proposes a move, accepts or rejects it, and hands back the new
position, its log posterior, and whether the move was taken. That is the
entire interface. Everything else in this package is a variation on it.
Because the chain is a plain list you append to, nothing needs to know the
run length in advance. Break out of the loop whenever your own criterion says
so, and np.array whatever you collected.
The acceptance rate is your tuning signal. This one prints about 0.67,
which is too high: the proposal is too small, so the chain accepts nearly
everything and inches along. Widening cov would fix it. Roughly 0.2 to 0.4
is healthy for a random walk.
Plain MH needs a good cov to work well, and you rarely know one in advance.
RAM learns it while sampling. The only change is that its adaptation state
S is threaded through alongside everything else, and it needs the step
number:
import numpy as np
from mcmckit import ram_step
x = np.zeros(2)
logp = log_post(x)
S = np.linalg.cholesky(np.eye(2) * 0.1**2) # a rough guess is fine
chain = []
n_accepted = 0
for i in range(1, 10_001): # 1-indexed: drives adaptation
x, logp, S, accepted = ram_step(log_post, x, logp, S, i)
chain.append(x)
n_accepted += accepted
if i % 1000 == 0: # your convergence check, your rules
print(i, x, logp)
chain = np.array(chain)
print(f"acceptance rate: {n_accepted / len(chain):.2f}")Same shape, one extra threaded value. This prints about 0.25, against RAM's 0.234 target: starting from a proposal 5x too small, it found a sensible one on its own, which is the whole point of using it.
One caveat worth knowing early: a high acceptance rate is not a good sign.
It usually means the steps are too small and the chain is crawling. Judge a
run by effective sample size (result.ess()), not by acceptance alone. See
Choosing a sampler for
what that looks like in practice.
Everything is a plain module-level function, so import what you use and call it bare, or keep the package namespace if you prefer. Both are the same call:
from mcmckit import mh_step, ram_step # bare
import mcmckit as mc # namespaced: mc.ram_step(...)| Function | Threaded state | Returns |
|---|---|---|
mh_step |
— (fixed cov) |
x, logp, accepted |
ram_step |
S, step index i |
x, logp, S, accepted |
dram_step |
DRAMState |
x, logp, state, accepted |
mala_step |
grad |
x, logp, grad, accepted |
adaptive_mala_step |
grad, log_step, i |
x, logp, grad, log_step, accepted |
gibbs_step |
— (blocks, proposal_std) |
x, logp, accepted_per_block |
Every function takes a log-posterior: bigger is a better fit. If your code carries a negative log-posterior, wrap it once:
log_post = lambda theta: -my_nll(theta)If your callable returns (log_post, aux), the extra payload rides along and
comes back attached to the accepted sample. Natural frequencies, mode shapes,
residuals — whatever your model already computed, without re-running it:
def log_post(theta):
freqs = surrogate(theta)
return -0.5 * np.sum(((freqs - measured) / sigma)**2), freqs
x, logp, S, accepted, freqs = ram_step(log_post, x, logp, S, i, aux=freqs)Return a plain float instead and no aux comes back, so the signature stays
its usual width.
When you do not need control of the recursion, the full-run helpers are thin loops over exactly the same step functions:
from mcmckit import ram
result = ram(log_post, x0=[0.0, 0.0], n_samples=10_000)
print(result.mean(), result.std())
result.discard(1000).plot_corner(title="Posterior")metropolis, ram, dram, mala, adaptive_mala and gibbs all follow
this shape and return a Result with statistics and plots attached.
| Step function | Full run | Method | Notes |
|---|---|---|---|
mh_step |
metropolis |
Random-walk MH | Fixed proposal covariance |
mala_step |
mala |
Langevin | Needs the gradient |
ram_step |
ram |
Robust Adaptive MH | Self-tunes covariance (Vihola 2012) |
dram_step |
dram |
Delayed Rejection + AM | Best general-purpose adaptive sampler |
adaptive_mala_step |
adaptive_mala |
Adaptive Langevin | Log-space step-size tuning |
gibbs_step |
gibbs |
Metropolis-within-Gibbs | Block updates, per-block rates |
| — | TMCMC |
Transitional MCMC | Prior→posterior bridge, log-evidence |
TMCMC works on a population of particles per stage rather than one chain position, so it does not have a single-step form. It stays a class.
Not sure which to use? Choosing a sampler compares them all on the same problem, with traces, effective sample sizes and a hard target that separates them. Plots shows everything the package draws.
The original class interface is still there for stop-and-inspect workflows, and is unchanged:
sampler = mc.RAM(n_samples=10_000, initial_cov=np.eye(2))
sampler.initialize(problem, x0=[0.0, 0.0])
sampler.step()
result = sampler.get_result()These take a Problem, which bundles a prior and likelihood:
problem = mc.Problem(prior=log_prior, likelihood=log_likelihood,
param_names=["E", "zeta"])The full-run helpers accept either a Problem or a bare log_post callable.
TMCMC bridges the prior to the posterior through tempered distributions and provides a log-evidence (log marginal likelihood) estimate:
prior_samples = np.random.uniform(-10, 10, size=(1000, 2))
tmcmc = mc.TMCMC(n_particles=1000, n_mcmc_steps=3)
result = tmcmc.run(problem, prior_samples=prior_samples)
print(f"log-evidence: {result.log_evidence:.3f}")
tmcmc.plot_stages(max_stages=6, title="Prior → Posterior")Opt-in, off by default. The cost in model updating is the forward model inside your likelihood, so mcmckit can spread those calls over cores:
if __name__ == "__main__": # required: workers re-import the module
result = mc.TMCMC(n_particles=1000, n_workers=4).run(problem, prior_samples=ps)
multi = mc.run_chains(sampler, problem, x0, n_chains=4, n_workers=4)n_workers=-1 uses one worker per core. TMCMC particles and independent chains
are parallelised; a single chain is sequential by construction and is not.
Process workers pickle your likelihood, so it must be a module-level function rather than a lambda or closure; mcmckit says so clearly instead of failing deep inside the executor. TMCMC gives bit-identical results with and without workers.
On a 14-core machine with a ~4 ms likelihood: 2.60x on 4 workers, 3.36x on 8.
Black-box solvers work — examples/openseespy_parallel.py updates a 6-storey
shear building through OpenSeesPy and gets bit-identical results at 1, 4 and 8
workers. Use processes, not threads, for any solver with global state:
OpenSeesPy keeps one global model domain, and threading it raises OpenSeesError
or segfaults. See the docs.
Result objects have built-in plotting:
result.plot_trace()
result.plot_marginals()
result.plot_corner(style="corner", true_values=[2.0, -1.0])
# styles: "corner" | "scatter" | "full" | "kde"Driving the loop yourself gives you a plain array, which you can wrap when you want those plots:
from mcmckit import Result
result = Result(samples=chain, param_names=["E", "zeta"])
result.plot_corner()| Script | Demonstrates |
|---|---|
examples/own_loop.py |
Start here. Step functions, your own loop, early stopping, aux output |
examples/simple_gaussian.py |
MH, all corner styles, burn-in, raw samples |
examples/mala_vs_mh.py |
MH vs MALA side-by-side |
examples/ram_example.py |
RAM self-tuning from bad initial covariance |
examples/adaptive_samplers.py |
MH / RAM / DRAM / AdaptiveMALA comparison |
examples/tmcmc_and_gibbs.py |
TMCMC stages + Gibbs scalar/block |
examples/diagnostics_multichain.py |
Multi-chain runs, R-hat and ESS diagnostics |
examples/noise_estimation.py |
Inferring measurement noise alongside parameters |
examples/structural_identification.py |
Stiffness identification from modal data |
examples/hierarchical_updating.py |
Hierarchical models across multiple structures |
examples/model_comparison.py |
Bayes factors from TMCMC log-evidence |
examples/model_averaging.py |
Posterior model averaging |
examples/sequential.py |
Sequential / online updating with PosteriorPrior |
examples/sequential_10dof.py |
10-parameter sequential structural identification |
examples/openseespy_parallel.py |
Black-box OpenSeesPy forward model, run in parallel |
If you use mcmckit in academic work, please cite it:
Caglio, L. mcmckit: minimal, plug-and-play MCMC samplers for Python. https://doi.org/10.5281/zenodo.22300143
That DOI always resolves to the newest release. To cite the exact version you
ran, use its own DOI from the
Zenodo record; v0.3.0 is
10.5281/zenodo.22300144.
CITATION.cff carries the same metadata, and GitHub's "Cite this repository"
button renders it as BibTeX or APA.
- Python ≥ 3.9
- numpy, scipy
- matplotlib (optional, for plots)