Skills · Python
One function that runs the five sampler checks on anything PyMC returns and says, in one line, whether the posterior can be interpreted yet.
Drop the file beside your notebook, import it, and call check_fit on the result of pm.sample before you read a single parameter estimate.
Needs: arviz, matplotlib; PyMC for the self-test. License: MIT. Written from the method the session teaches; the instructor's own materials come with the recording.
MCMC in Practice. The skill is the method from that session, packaged so you can use it before you have watched it; the session is where the reasoning lives. Every skill in the library names its session the same way.
mcmc_checks.py · 119 lines
"""mcmc_checks: the fit-checking pass from the AthlyticZ session "MCMC in Practice" (Chris Fonnesbeck),
as one reusable function you can call on anything pm.sample() returns.
import mcmc_checks as mc
report = mc.check_fit(idata) # prints the verdict, returns a dict
mc.ppc_overlay(idata, "y") # one posterior predictive check, as a plot
What it checks, in the order the session teaches it:
1. divergences any at all means the sampler hit geometry it could not follow (think non-centered)
2. R-hat > 1.01 on any parameter: the chains disagree, do not trust the posterior yet
3. effective sample size bulk or tail ESS under 400: the estimate is noisier than the draw count suggests
4. BFMI under 0.3 on any chain: the energy transitions are poor, often a funnel
5. tree depth hits at the cap mean NUTS was cut short; raise the cap or reparameterize
Requires: arviz >= 0.17, matplotlib. Works on InferenceData from PyMC 5 and 6 (nutpie included).
License: MIT. Written for athlyticz.com/skills from the method the session teaches; the session's own
notebook and datasets come with the recording.
"""
from __future__ import annotations
import numpy as np
try:
import arviz as az
except ImportError as e: # pragma: no cover
raise SystemExit("mcmc_checks needs arviz: pip install arviz") from e
def check_fit(idata, var_names=None, rhat_max=1.01, ess_min=400, bfmi_min=0.3, verbose=True):
"""Run the five sampler checks on an InferenceData and return {check: (ok, detail)}.
var_names narrows the R-hat and ESS tables to the parameters you care about (defaults to all).
The thresholds are the session's: R-hat 1.01, ESS 400, BFMI 0.3.
"""
out = {}
ss = idata.get("sample_stats")
# 1. divergences
if ss is not None and "diverging" in ss:
n_div = int(ss["diverging"].sum())
out["divergences"] = (n_div == 0, f"{n_div} divergent transition(s)")
else:
out["divergences"] = (True, "no sample_stats.diverging recorded")
# 2 and 3. R-hat and ESS, over the posterior
summ = az.summary(idata, var_names=var_names, kind="diagnostics")
worst_rhat = float(summ["r_hat"].max())
out["r_hat"] = (worst_rhat <= rhat_max, f"max R-hat {worst_rhat:.3f} ({summ['r_hat'].idxmax()})")
ess_bulk, ess_tail = float(summ["ess_bulk"].min()), float(summ["ess_tail"].min())
out["ess"] = (min(ess_bulk, ess_tail) >= ess_min,
f"min bulk ESS {ess_bulk:.0f} ({summ['ess_bulk'].idxmin()}), min tail ESS {ess_tail:.0f} ({summ['ess_tail'].idxmin()})")
# 4. BFMI, per chain
try:
bfmi = az.bfmi(idata)
out["bfmi"] = (bool(np.all(bfmi >= bfmi_min)), "per-chain BFMI " + ", ".join(f"{b:.2f}" for b in bfmi))
except Exception as e: # no energy recorded (e.g. a non-HMC sampler)
out["bfmi"] = (True, f"BFMI not available ({type(e).__name__})")
# 5. tree depth saturation
if ss is not None and "tree_depth" in ss:
depth = ss["tree_depth"].values
cap = int(depth.max())
frac = float((depth == cap).mean())
out["tree_depth"] = (frac < 0.05 or cap < 10, f"max depth {cap}, {frac:.1%} of transitions at it")
else:
out["tree_depth"] = (True, "no tree_depth recorded")
if verbose:
width = max(len(k) for k in out)
for k, (ok, detail) in out.items():
print(f"{'ok ' if ok else 'FAIL'} {k:<{width}} {detail}")
bad = [k for k, (ok, _) in out.items() if not ok]
print("verdict:", "the computation is sound; now check the predictions" if not bad
else "do not interpret this posterior yet: " + ", ".join(bad))
return out
def ppc_overlay(idata, var, model=None, draws=100, ax=None):
"""One posterior predictive check: simulated datasets over the observed one.
If idata has no posterior_predictive group, pass the PyMC model and it is sampled here.
Returns the matplotlib Axes. The question to ask of the picture is the session's: do the
simulated datasets look like the real one, or is the model missing something the
sampler diagnostics could never show?
"""
import matplotlib.pyplot as plt
if "posterior_predictive" not in idata:
if model is None:
raise ValueError("no posterior_predictive group: pass model= so it can be sampled")
import pymc as pm
with model:
pm.sample_posterior_predictive(idata, extend_inferencedata=True, random_seed=1)
ax = az.plot_ppc(idata, var_names=[var], num_pp_samples=draws, ax=ax)
ax.set_title(f"posterior predictive check: {var}")
return ax
if __name__ == "__main__":
# a two-minute self-test: a well-specified model, then a funnel that should fail
import pymc as pm
rng = np.random.default_rng(1)
y = rng.normal(2.0, 1.0, size=200)
with pm.Model() as good:
mu = pm.Normal("mu", 0, 5)
sd = pm.HalfNormal("sd", 2)
pm.Normal("y", mu, sd, observed=y)
idata = pm.sample(1000, tune=1000, chains=4, progressbar=False, random_seed=1)
print("well-specified model:")
check_fit(idata)
with pm.Model() as funnel:
tau = pm.Normal("tau", 0, 3)
x = pm.Normal("x", 0, pm.math.exp(tau), shape=9)
idata_f = pm.sample(1000, tune=1000, chains=4, progressbar=False, random_seed=1)
print("\nNeal's funnel (centered):")
check_fit(idata_f)
First name and email. One message when a session recording, a skill, a cheat sheet or a posting worth your time arrives here, and no other mail. Replies reach a person.