| name | pymc-modeling |
| description | Bayesian statistical modeling with PyMC v5+. Use when building probabilistic models, specifying priors, running MCMC inference, diagnosing convergence, or comparing models. Covers PyMC, ArviZ, pymc-bart, pymc-extras, nutpie, and JAX/NumPyro backends. Triggers on tasks involving: Bayesian inference, posterior sampling, hierarchical/multilevel models, GLMs, time series, Gaussian processes, BART, mixture models, prior/posterior predictive checks, MCMC diagnostics, LOO-CV, WAIC, model comparison, or causal inference with do/observe.
|
PyMC Modeling
Bayesian modeling workflow for PyMC v5+ with modern API patterns.
Claude understands the fundamentals of Bayesian inference—priors, likelihoods, posterior distributions, and Bayes' theorem. It knows MCMC is the standard approach for posterior sampling and can explain what a hierarchical model is. But getting from these concepts to a correctly-specified, well-diagnosed, and efficiently-sampled PyMC model requires domain-specific knowledge that changes over time.
This skill bridges that gap. It encodes modern best practices like using nutpie as the default sampler because it runs two to five times faster than the default NUTS implementation, choosing non-centered parameterization for hierarchical models to avoid pathological geometry, and reaching for HSGP instead of exact Gaussian processes for any dataset larger than a few hundred points. It covers the common pitfalls you will actually hit—why you are getting divergences and how to fix them, the specific error messages that indicate a shape mismatch or initialization failure, and when the centered parameterization actually performs better despite the folklore. It also details the correct API usage: how to structure coords and dims for readable InferenceData, why nutpie silently ignores log_likelihood requests and what to do about it, and the proper workflow for saving results to NetCDF.
Without this skill, Claude might suggest outdated defaults like the slow default NUTS sampler, miss critical diagnostics such as ESS and r_hat checks, or recommend inefficient parameterizations that lead to divergences. With it, you get concise, battle-tested patterns that actually work in practice.
Notebook preference: Use marimo for interactive modeling unless the project already uses Jupyter.
Model Specification
Basic Structure
import pymc as pm
import arviz as az
with pm.Model(coords=coords) as model:
x = pm.Data("x", x_obs, dims="obs")
beta = pm.Normal("beta", mu=0, sigma=1, dims="features")
sigma = pm.HalfNormal("sigma", sigma=1)
mu = pm.math.dot(x, beta)
y = pm.Normal("y", mu=mu, sigma=sigma, observed=y_obs, dims="obs")
idata = pm.sample(nuts_sampler="nutpie", random_seed=42)
Coords and Dims
Use coords/dims for interpretable InferenceData when model has meaningful structure:
coords = {
"obs": np.arange(n_obs),
"features": ["intercept", "age", "income"],
"group": group_labels,
}
Skip for simple models where overhead exceeds benefit.
Parameterization
Prefer non-centered parameterization for hierarchical models with weak data:
offset = pm.Normal("offset", 0, 1, dims="group")
alpha = mu_alpha + sigma_alpha * offset
alpha = pm.Normal("alpha", mu_alpha, sigma_alpha, dims="group")
Inference
Default Sampling (nutpie — always use)
Always use nutpie or numpyro for sampling. Never use PyMC's default NUTS — it is 2-5x slower. nutpie is Rust-based and supports all standard PyMC models including time series, GPs, mixtures, and custom likelihoods:
with model:
idata = pm.sample(
draws=1000, tune=1000, chains=4,
nuts_sampler="nutpie",
random_seed=42,
)
idata.to_netcdf("results.nc")
Important: nutpie does not store log_likelihood automatically (it silently ignores idata_kwargs={"log_likelihood": True}). If you need LOO-CV or model comparison, compute it after sampling:
pm.compute_log_likelihood(idata, model=model)
If nutpie Is Not Installed
Always use nuts_sampler="nutpie" or nuts_sampler="numpyro". If neither is installed, install before sampling:
import subprocess, sys
try:
import nutpie
except ImportError:
subprocess.check_call([sys.executable, "-m", "pip", "install", "nutpie"])
Do not fall back to PyMC's default NUTS sampler — it is 2-5x slower and should only be used temporarily for debugging model specification issues.
Alternative MCMC Backends
See references/inference.md for:
- NumPyro/JAX: GPU acceleration, vectorized chains
Approximate Inference
For fast (but inexact) posterior approximations:
- ADVI/DADVI: Variational inference with Gaussian approximation
- Pathfinder: Quasi-Newton optimization for initialization or screening
Diagnostics and ArviZ Workflow
Follow this systematic workflow after every sampling run:
Phase 1: Immediate Checks (Required)
n_div = idata.sample_stats["diverging"].sum().item()
print(f"Divergences: {n_div}")
summary = az.summary(idata, var_names=["~offset"])
print(summary[["mean", "sd", "hdi_3%", "hdi_97%", "ess_bulk", "ess_tail", "r_hat"]])
az.plot_trace(idata, compact=True)
az.plot_rank(idata, var_names=["beta", "sigma"])
Pass criteria (all must pass before proceeding):
- Zero divergences (or < 0.1% and randomly scattered)
r_hat < 1.01 for all parameters
ess_bulk > 400 and ess_tail > 400
- Trace plots show good mixing (overlapping densities, fuzzy caterpillar)
Phase 2: Deep Convergence (If Phase 1 marginal)
az.plot_ess(idata, kind="evolution")
az.plot_energy(idata)
az.plot_autocorr(idata, var_names=["beta"])
Phase 3: Model Criticism (Required)
with model:
pm.sample_posterior_predictive(idata, extend_inferencedata=True)
az.plot_ppc(idata, kind="cumulative")
az.plot_loo_pit(idata, y="y")
Critical rule: Never interpret parameters until Phases 1-3 pass.
Phase 4: Parameter Interpretation
az.plot_posterior(idata, var_names=["beta"], ref_val=0)
az.plot_forest(idata, var_names=["alpha"], combined=True)
az.plot_pair(idata, var_names=["alpha", "beta", "sigma"])
See references/arviz.md for comprehensive ArviZ usage.
See references/diagnostics.md for troubleshooting.
Prior and Posterior Predictive Checks
Prior Predictive (Before Fitting)
Always check prior implications before fitting:
with model:
prior_pred = pm.sample_prior_predictive(draws=500)
az.plot_ppc(prior_pred, group="prior", kind="cumulative")
prior_y = prior_pred.prior_predictive["y"].values.flatten()
print(f"Prior predictive range: [{prior_y.min():.1f}, {prior_y.max():.1f}]")
Warning signs: Prior predictive covers implausible values (negative counts, probabilities > 1) or is extremely wide/narrow.
Posterior Predictive (After Fitting)
with model:
pm.sample_posterior_predictive(idata, extend_inferencedata=True)
az.plot_ppc(idata, kind="kde")
az.plot_ppc(idata, kind="cumulative")
az.plot_loo_pit(idata, y="y")
Interpretation: Observed data (dark line) should fall within posterior predictive distribution (light lines). See references/arviz.md for detailed interpretation.
Model Debugging
Inspecting Model Structure
print(model)
pm.model_to_graphviz(model)
Checking for Specification Errors
Before sampling, validate the model:
model.debug()
model.point_logps()
Common Issues
| Symptom | Likely Cause | Fix |
|---|
ValueError: Shape mismatch | Parameter vs observation dimensions | Use index vectors: alpha[group_idx] |
Initial evaluation failed | Data outside distribution support | Check bounds; use init="adapt_diag" |
Mass matrix contains zeros | Unscaled predictors or flat priors | Standardize features; use weakly informative priors |
| High divergence count | Funnel geometry | Non-centered parameterization |
NaN in log-probability | Invalid parameter combinations | Check parameter constraints, add bounds |
-inf log-probability | Observations outside likelihood support | Verify data matches distribution domain |
| Slow discrete sampling | NUTS incompatible with discrete | Marginalize discrete variables |
See references/troubleshooting.md for comprehensive problem-solution guide.
Debugging Divergences
az.plot_pair(idata, var_names=["alpha", "beta", "sigma"], divergences=True)
Profiling Slow Models
profile = model.profile(model.logp())
profile.summary()
import pytensor
grad_profile = model.profile(pytensor.grad(model.logp(), model.continuous_value_vars))
grad_profile.summary()
See references/gotchas.md for additional troubleshooting.
Model Comparison
LOO-CV (Preferred)
loo = az.loo(idata, pointwise=True)
print(f"ELPD: {loo.elpd_loo:.1f} ± {loo.se:.1f}")
print(f"Bad k (>0.7): {(loo.pareto_k > 0.7).sum().item()}")
az.plot_khat(idata)
Comparing Models
comparison = az.compare({
"model_a": idata_a,
"model_b": idata_b,
}, ic="loo")
print(comparison[["rank", "elpd_loo", "d_loo", "weight", "dse"]])
az.plot_compare(comparison)
Decision rule: If d_loo < 2*dse, models are effectively equivalent.
See references/arviz.md for detailed model comparison workflow.
Saving and Loading Results
InferenceData Persistence
Save sampling results for later analysis or sharing:
idata.to_netcdf("results/model_v1.nc")
idata = az.from_netcdf("results/model_v1.nc")
Compressed Storage
For large InferenceData objects (many draws, large posterior predictive):
idata.to_netcdf(
"results/model_v1.nc",
engine="h5netcdf",
encoding={var: {"zlib": True, "complevel": 4}
for group in ["posterior", "posterior_predictive"]
if hasattr(idata, group)
for var in getattr(idata, group).data_vars}
)
What Gets Saved
InferenceData preserves the full Bayesian workflow:
posterior: Parameter samples from MCMC
prior, prior_predictive: Prior samples (if generated)
posterior_predictive: Predictions (if generated)
observed_data, constant_data: Data used in fitting
sample_stats: Diagnostics (divergences, tree depth, energy)
log_likelihood: Pointwise log-likelihood (for LOO-CV)
- All coordinates and dimensions
Workflow Pattern
with model:
idata = pm.sample(nuts_sampler="nutpie")
idata.to_netcdf("results.nc")
with model:
pm.sample_posterior_predictive(idata, extend_inferencedata=True)
idata.to_netcdf("results.nc")
idata = az.from_netcdf("results.nc")
az.plot_ppc(idata)
Prior Selection
See references/priors.md for:
- Weakly informative defaults by distribution type
- Prior predictive checking workflow
- Domain-specific recommendations
Common Patterns
Hierarchical/Multilevel
with pm.Model(coords={"group": groups, "obs": obs_idx}) as hierarchical:
mu_alpha = pm.Normal("mu_alpha", 0, 1)
sigma_alpha = pm.HalfNormal("sigma_alpha", 1)
alpha_offset = pm.Normal("alpha_offset", 0, 1, dims="group")
alpha = pm.Deterministic("alpha", mu_alpha + sigma_alpha * alpha_offset, dims="group")
y = pm.Normal("y", alpha[group_idx], sigma, observed=y_obs, dims="obs")
GLMs
with pm.Model() as logistic:
alpha = pm.Normal("alpha", 0, 2.5)
beta = pm.Normal("beta", 0, 2.5, dims="features")
logit_p = alpha + pm.math.dot(X, beta)
p = pm.math.sigmoid(logit_p)
y = pm.Bernoulli("y", p=p, observed=y_obs)
with pm.Model() as poisson:
beta = pm.Normal("beta", 0, 1, dims="features")
mu = pm.math.exp(pm.math.dot(X, beta))
y = pm.Poisson("y", mu=mu, observed=y_obs)
Gaussian Processes
Always prefer HSGP for GP problems with 1-3D inputs. It's O(nm) instead of O(n³), and even at n=200 exact GP (pm.gp.Marginal) is prohibitively slow for MCMC:
with pm.Model() as gp_model:
ell = pm.InverseGamma("ell", alpha=5, beta=5)
eta = pm.HalfNormal("eta", sigma=2)
sigma = pm.HalfNormal("sigma", sigma=0.5)
cov = eta**2 * pm.gp.cov.Matern52(1, ls=ell)
gp = pm.gp.HSGP(m=[20], c=1.5, cov_func=cov)
f = gp.prior("f", X=X[:, None])
y = pm.Normal("y", mu=f, sigma=sigma, observed=y_obs)
For periodic patterns, use pm.gp.HSGPPeriodic. Only use pm.gp.Marginal or pm.gp.Latent for very small datasets (n < ~50) where exact inference is specifically needed.
See references/gp.md for:
- HSGP parameter selection (choosing m and c, automatic heuristics)
- HSGPPeriodic for seasonal/cyclic patterns
- Approximation quality diagnostics
- Covariance functions and priors
- Common patterns (trend + seasonality, classification, heteroscedastic)
Time Series
with pm.Model(coords={"time": range(T)}) as ar_model:
rho = pm.Uniform("rho", -1, 1)
sigma = pm.HalfNormal("sigma", sigma=1)
y = pm.AR("y", rho=[rho], sigma=sigma, constant=True,
observed=y_obs, dims="time")
See references/timeseries.md for:
- Autoregressive models (AR, ARMA)
- Random walk and local level models
- Structural time series (trend + seasonality)
- State space models
- GPs for time series
- Handling multiple seasonalities
- Forecasting patterns
BART (Bayesian Additive Regression Trees)
import pymc_bart as pmb
with pm.Model() as bart_model:
mu = pmb.BART("mu", X=X, Y=y, m=50)
sigma = pm.HalfNormal("sigma", 1)
y_obs = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y)
See references/bart.md for:
- Regression and classification
- Variable importance and partial dependence
- Combining BART with parametric components
- Configuration (number of trees, depth priors)
Mixture Models
import numpy as np
coords = {"component": range(K)}
with pm.Model(coords=coords) as gmm:
w = pm.Dirichlet("w", a=np.ones(K), dims="component")
mu = pm.Normal("mu", mu=0, sigma=10, dims="component",
transform=pm.distributions.transforms.ordered)
sigma = pm.HalfNormal("sigma", sigma=2, dims="component")
y = pm.NormalMixture("y", w=w, mu=mu, sigma=sigma, observed=y_obs)
See references/mixtures.md for:
- Finite mixture models and mixture of regressions
- Label switching problem and solutions (ordering constraints, relabeling)
- Marginalized mixtures (pymc-extras)
- Diagnostics for mixture models
Sparse Regression / Horseshoe
Use the regularized (Finnish) horseshoe prior for high-dimensional regression with expected sparsity:
import pytensor.tensor as pt
with pm.Model(coords={"features": feature_names}) as sparse_model:
tau = pm.HalfStudentT("tau", nu=2, sigma=1)
lam = pm.HalfStudentT("lam", nu=5, dims="features")
c2 = pm.InverseGamma("c2", alpha=1, beta=1)
z = pm.Normal("z", 0, 1, dims="features")
lam_tilde = pt.sqrt(c2 / (c2 + tau**2 * lam**2))
beta = pm.Deterministic("beta", z * tau * lam * lam_tilde, dims="features")
mu = pm.math.dot(X, beta)
y = pm.Normal("y", mu=mu, sigma=sigma, observed=y_obs)
idata = pm.sample(nuts_sampler="nutpie", target_accept=0.95)
Important: Horseshoe priors create double-funnel geometry. Use target_accept=0.95 or higher to avoid divergences.
See references/priors.md for Laplace, R2D2, and spike-and-slab alternatives.
Specialized Likelihoods
with pm.Model() as zip_model:
psi = pm.Beta("psi", alpha=2, beta=2)
mu = pm.Exponential("mu", lam=1)
y = pm.ZeroInflatedPoisson("y", psi=psi, mu=mu, observed=y_obs)
with pm.Model() as censored_model:
mu = pm.Normal("mu", mu=0, sigma=10)
sigma = pm.HalfNormal("sigma", sigma=5)
y = pm.Censored("y", dist=pm.Normal.dist(mu=mu, sigma=sigma),
lower=None, upper=censoring_time, observed=y_obs)
with pm.Model() as ordinal:
beta = pm.Normal("beta", mu=0, sigma=2, dims="features")
cutpoints = pm.Normal("cutpoints", mu=0, sigma=2,
transform=pm.distributions.transforms.ordered,
shape=n_categories - 1)
y = pm.OrderedLogistic("y", eta=pm.math.dot(X, beta),
cutpoints=cutpoints, observed=y_obs)
Note: Don't use the same name for a variable and a dimension. For example, if you have a dimension called "cutpoints", don't also name a variable "cutpoints" — this causes shape errors.
See references/specialized_likelihoods.md for:
- Zero-inflated models (Poisson, Negative Binomial, Binomial)
- Hurdle models for count data
- Censored and truncated data
- Ordinal regression
- Robust regression with Student-t likelihood
Common Pitfalls
See references/gotchas.md for:
- Centered vs non-centered parameterization
- Priors on scale parameters
- Label switching in mixtures
- Performance issues (GPs, large Deterministics)
- Python conditionals and hard clipping
- Redundant intercepts in hierarchical models
See references/troubleshooting.md for comprehensive problem-solution guide covering:
- Shape and dimension errors
- Initialization failures
- Mass matrix and numerical issues
- Discrete variable challenges
- Data container and prediction issues
Causal Inference Operations
pm.do (Interventions)
Apply do-calculus interventions to set variables to fixed values:
with pm.Model() as causal_model:
x = pm.Normal("x", 0, 1)
y = pm.Normal("y", x, 1)
z = pm.Normal("z", y, 1)
with pm.do(causal_model, {"x": 2}) as intervention_model:
idata = pm.sample_prior_predictive()
pm.observe (Conditioning)
Condition on observed values without intervention:
with pm.observe(causal_model, {"y": 1}) as conditioned_model:
idata = pm.sample(nuts_sampler="nutpie")
Combining do and observe
with pm.do(causal_model, {"x": 2}) as m1:
with pm.observe(m1, {"z": 0}) as m2:
idata = pm.sample(nuts_sampler="nutpie")
pymc-extras
For specialized models and inference:
import pymc_extras as pmx
model = pmx.marginalize(model, ["discrete_var"])
residual_sigma, beta = pmx.R2D2M2CP(
"r2d2",
output_sigma=y.std(),
input_sigma=X.std(axis=0),
dims="features",
r2=0.5,
)
idata = pmx.fit_laplace(model)
Custom Distributions and Model Components
For extending PyMC beyond built-in distributions:
import pymc as pm
import pytensor.tensor as pt
def custom_logp(value, mu, sigma):
return pm.logp(pm.Normal.dist(mu=mu, sigma=sigma), value)
with pm.Model() as model:
mu = pm.Normal("mu", 0, 1)
y = pm.DensityDist("y", mu, 1.0, logp=custom_logp, observed=y_obs)
with pm.Model() as model:
alpha = pm.Normal("alpha", 0, 1, dims="group")
pm.Potential("sum_to_zero", -100 * pt.sqr(alpha.sum()))
See references/custom_models.md for:
pm.DensityDist for custom likelihoods
pm.Potential for soft constraints and Jacobian adjustments
pm.Simulator for simulation-based inference (ABC)
pm.CustomDist for custom prior distributions