Bayesian Workflow
Bayesian modeling is iterative. You don’t write a model, press “run”, and accept whatever comes out. You build up confidence through a cycle of inspection, simulation, and refinement — before and after fitting to data.
pathmc supports this workflow directly. Every step — from inspecting priors to simulating interventions — is a method call on the same PathModel object.
Each step is one or two method calls. We walk through the full cycle below on a simple mediation model.
Simulate data
We use synthetic data with known coefficients so we can verify recovery at each stage.
import numpy as np
import pandas as pd
rng = np.random.default_rng(42)
n = 500
X = rng.normal(size=n)
M = 0.5 * X + rng.normal(scale=0.3, size=n)
Y = 0.8 * M + 0.3 * X + rng.normal(scale=0.3, size=n)
df = pd.DataFrame({"X": X, "M": M, "Y": Y})True values: a = 0.5, b = 0.8, c = 0.3.
1. Build the model
Write causal assumptions as structural equations. model() compiles the spec and data into a PathModel — ready for inspection — without running MCMC.
import pathmc
from pathmc import Prior
spec = """
M ~ a*X
Y ~ b*M + c*X
indirect := a*b
"""
model = pathmc.model(spec, data=df)2. Inspect structure
Before spending any compute on sampling, verify that the model encodes what you intend.
model.graph()model.equations()\begin{aligned} \beta_{M} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{M} &\sim \text{HalfNormal}(sigma=1) \\ \beta_{Y} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{Y} &\sim \text{HalfNormal}(sigma=1) \\[6pt] \mu_{M} &= \beta_{0,\,M} + a \cdot \mathrm{X} \\ \mathrm{M} &\sim \text{Normal}(\mu_{M},\, \sigma_{M}) \\ \mu_{Y} &= \beta_{0,\,Y} + b \cdot \mathrm{M} + c \cdot \mathrm{X} \\ \mathrm{Y} &\sim \text{Normal}(\mu_{Y},\, \sigma_{Y}) \\ indirect &\equiv a \cdot b \end{aligned}
equations() shows the structural equations and the prior distribution assigned to every parameter, including defaults you may not have thought about. The defaults are deliberately vague: Normal(0, 10) for regression coefficients and HalfNormal(1) for residual scales. These work out of the box, but domain knowledge almost always lets you do better.
3. Prior predictive check
A prior predictive check asks: if we generated data from our priors (without seeing real data), would it look remotely plausible?
ppc_default = model.sample_prior_predictive(draws=500, random_seed=42)Sampling: [M, Y, beta_M, beta_Y, sigma_M, sigma_Y]
Code
import matplotlib.pyplot as plt
import arviz as az
FIG_WIDTH = 7
FIG_HEIGHT = 3.5
COLOR_DEFAULT = "#1b9e77"
COLOR_CUSTOM = "#d95f02"
y_prior = ppc_default.prior["Y"].values.flatten()
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
x_kde, y_kde, _ = az.kde(y_prior)
ax.plot(x_kde, y_kde, color=COLOR_DEFAULT, lw=2, label="Prior predictive (defaults)")
ax.fill_between(x_kde, y_kde, alpha=0.6, color=COLOR_DEFAULT)
ax.axvline(
df["Y"].mean(),
color="black",
linestyle="--",
linewidth=1.5,
label=f"Observed mean = {df['Y'].mean():.2f}",
)
ax.set_xlabel("Y")
ax.set_ylabel("Density")
ax.legend()
plt.tight_layout()
plt.show()
The default priors generate Y values spanning a huge range. For this dataset, where Y lives roughly in [-2, 2], coefficients of \pm 10 are implausible. We can do better.
4. Refine priors
set_priors() merges overrides into the current configuration and recompiles. Only the parameters you specify change.
model.set_priors({
"beta_M": Prior("Normal", mu=0, sigma=2),
"beta_Y": Prior("Normal", mu=0, sigma=2),
"sigma_M": Prior("HalfNormal", sigma=0.5),
"sigma_Y": Prior("HalfNormal", sigma=0.5),
})
model.equations()\begin{aligned} \beta_{M} &\sim \text{Normal}(mu=0,\, sigma=2) \\ \sigma_{M} &\sim \text{HalfNormal}(sigma=0.5) \\ \beta_{Y} &\sim \text{Normal}(mu=0,\, sigma=2) \\ \sigma_{Y} &\sim \text{HalfNormal}(sigma=0.5) \\[6pt] \mu_{M} &= \beta_{0,\,M} + a \cdot \mathrm{X} \\ \mathrm{M} &\sim \text{Normal}(\mu_{M},\, \sigma_{M}) \\ \mu_{Y} &= \beta_{0,\,Y} + b \cdot \mathrm{M} + c \cdot \mathrm{X} \\ \mathrm{Y} &\sim \text{Normal}(\mu_{Y},\, \sigma_{Y}) \\ indirect &\equiv a \cdot b \end{aligned}
Now re-check: do the refined priors generate plausible data?
ppc_custom = model.sample_prior_predictive(draws=500, random_seed=42)Sampling: [M, Y, beta_M, beta_Y, sigma_M, sigma_Y]
Code
y_default = ppc_default.prior["Y"].values.flatten()
y_custom = ppc_custom.prior["Y"].values.flatten()
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
x_kde, y_kde, _ = az.kde(y_default)
ax.plot(x_kde, y_kde, color=COLOR_DEFAULT, lw=2, label="Default priors")
ax.fill_between(x_kde, y_kde, alpha=0.45, color=COLOR_DEFAULT)
x_kde, y_kde, _ = az.kde(y_custom)
ax.plot(x_kde, y_kde, color=COLOR_CUSTOM, lw=2, label="Refined priors")
ax.fill_between(x_kde, y_kde, alpha=0.55, color=COLOR_CUSTOM)
ax.axvline(
df["Y"].mean(),
color="black",
linestyle="--",
linewidth=1.5,
label=f"Observed mean = {df['Y'].mean():.2f}",
)
ax.set_xlabel("Y")
ax.set_ylabel("Density")
ax.legend()
plt.tight_layout()
plt.show()
The refined priors concentrate probability mass in the plausible range — much better. The model still allows coefficients up to \pm 4–6 (generous), but no longer considers coefficients of 10 or 20 as likely.
sample_prior_predictive() draws from priors without MCMC — it runs in seconds. Iterate as many times as needed before committing to a full posterior fit.
5. Fit
With priors that generate plausible data, run MCMC.
model.fit(random_seed=42)Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [beta_Y, sigma_M, beta_M, sigma_Y]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 3 seconds.
<xarray.DataTree>
Group: /
├── Group: /posterior
│ Dimensions: (chain: 4, draw: 1000, Y_predictors: 3, M_predictors: 2,
│ mu_Y_dim_0: 500, mu_M_dim_0: 500)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 6 ... 993 994 995 996 997 998 999
│ * Y_predictors (Y_predictors) <U9 108B 'Intercept' 'M' 'X'
│ * M_predictors (M_predictors) <U9 72B 'Intercept' 'X'
│ * mu_Y_dim_0 (mu_Y_dim_0) int64 4kB 0 1 2 3 4 5 ... 494 495 496 497 498 499
│ * mu_M_dim_0 (mu_M_dim_0) int64 4kB 0 1 2 3 4 5 ... 494 495 496 497 498 499
│ Data variables:
│ beta_Y (chain, draw, Y_predictors) float64 96kB 0.01113 ... 0.3383
│ beta_M (chain, draw, M_predictors) float64 64kB -0.04368 ... 0.4884
│ sigma_M (chain, draw) float64 32kB 0.3083 0.2978 ... 0.2982 0.3008
│ sigma_Y (chain, draw) float64 32kB 0.3004 0.311 0.3 ... 0.3008 0.303
│ mu_Y (chain, draw, mu_Y_dim_0) float64 16MB 0.5574 ... -0.8974
│ mu_M (chain, draw, mu_M_dim_0) float64 16MB 0.108 ... -0.7418
│ Attributes:
│ created_at: 2026-09-30T20:18:06.240276+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.2.0
│ sample_dims: ['chain', 'draw']
│ sampling_time: 2.7307841777801514
│ tuning_steps: 1000
├── Group: /sample_stats
│ Dimensions: (chain: 4, draw: 1000)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 8kB 0 1 2 3 4 5 ... 995 996 997 998 999
│ Data variables: (12/18)
│ divergences (chain, draw) int64 32kB 0 0 0 0 0 0 ... 0 0 0 0 0 0
│ smallest_eigval (chain, draw) float64 32kB nan nan nan ... nan nan
│ tree_depth (chain, draw) int64 32kB 2 3 3 3 3 3 ... 3 3 3 3 3 3
│ perf_counter_diff (chain, draw) float64 32kB 8.554e-05 ... 0.0001381
│ n_steps (chain, draw) float64 32kB 3.0 7.0 7.0 ... 7.0 7.0
│ step_size_bar (chain, draw) float64 32kB 0.5609 0.5609 ... 0.5338
│ ... ...
│ reached_max_treedepth (chain, draw) bool 4kB False False ... False False
│ energy (chain, draw) float64 32kB 248.6 249.4 ... 246.7
│ energy_error (chain, draw) float64 32kB -0.1059 ... -0.168
│ acceptance_rate (chain, draw) float64 32kB 1.0 0.7703 ... 1.0 0.9995
│ process_time_diff (chain, draw) float64 32kB 8.5e-05 ... 0.000138
│ max_energy_error (chain, draw) float64 32kB -0.41 0.5627 ... -0.1698
│ Attributes:
│ created_at: 2026-09-30T20:18:06.247531+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.2.0
│ sample_dims: ['chain', 'draw']
│ sampling_time: 2.7307841777801514
│ tuning_steps: 1000
├── Group: /observed_data
│ Dimensions: (M_dim_0: 500, Y_dim_0: 500)
│ Coordinates:
│ * M_dim_0 (M_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
│ * Y_dim_0 (Y_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
│ Data variables:
│ M (M_dim_0) float64 4kB 0.5615 -0.2514 0.1594 ... -0.9562 -0.5004
│ Y (Y_dim_0) float64 4kB 0.5228 -0.7319 0.2283 ... -1.149 -1.203
│ Attributes:
│ created_at: 2026-09-30T20:18:06.249455+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.2.0
│ sample_dims: []
├── Group: /constant_data
│ Dimensions: (X_dim_0: 500)
│ Coordinates:
│ * X_dim_0 (X_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
│ Data variables:
│ X (X_dim_0) float64 4kB 0.3047 -1.04 0.7505 ... -0.3356 -1.991 -1.495
│ Attributes:
│ created_at: 2026-09-30T20:18:06.250090+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.2.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.2.0
│ sample_dims: []
└── Group: /log_likelihood
Dimensions: (chain: 4, draw: 1000, M_dim_0: 500, Y_dim_0: 500)
Coordinates:
* chain (chain) int64 32B 0 1 2 3
* draw (draw) int64 8kB 0 1 2 3 4 5 6 7 ... 993 994 995 996 997 998 999
* M_dim_0 (M_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
* Y_dim_0 (Y_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
Data variables:
M (chain, draw, M_dim_0) float64 16MB -0.8244 -0.247 ... -0.03983
Y (chain, draw, Y_dim_0) float64 16MB 0.2772 0.06512 ... -0.2345
Attributes:
created_at: 2026-09-30T20:18:06.453043+00:00
creation_library: ArviZ
creation_library_version: 1.2.0
creation_library_language: Python
inference_library: pymc
inference_library_version: 6.2.0
sample_dims: ['chain', 'draw']6. Posterior checks
Convergence diagnostics
Check R-hat and effective sample size. All R-hat values should be close to 1.0.
model.summary()| mean | sd | eti89_lb | eti89_ub | ess_bulk | ess_tail | r_hat | mcse_mean | mcse_sd | |
|---|---|---|---|---|---|---|---|---|---|
| beta_Y[Intercept] | -0.001352 | 0.013873 | -0.023526 | 0.020958 | 6224.765192 | 2787.529093 | 1.000319 | 0.000176 | 0.000126 |
| beta_Y[M] | 0.762801 | 0.046157 | 0.686841 | 0.836339 | 3114.835250 | 2459.103479 | 1.001050 | 0.000831 | 0.000604 |
| beta_Y[X] | 0.334834 | 0.026907 | 0.292305 | 0.378459 | 3135.411078 | 2693.120289 | 1.000892 | 0.000480 | 0.000351 |
| beta_M[Intercept] | -0.013397 | 0.013521 | -0.034536 | 0.008619 | 6518.164017 | 2976.004851 | 1.002013 | 0.000167 | 0.000121 |
| beta_M[X] | 0.497194 | 0.014603 | 0.473090 | 0.520441 | 6944.446926 | 3002.662552 | 0.999714 | 0.000178 | 0.000124 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| mu_M[495] | 0.451751 | 0.019416 | 0.420467 | 0.482618 | 5665.096163 | 3174.574749 | 1.000300 | 0.000258 | 0.000185 |
| mu_M[496] | -0.923961 | 0.029721 | -0.971028 | -0.876992 | 7068.899412 | 3312.438873 | 0.999943 | 0.000354 | 0.000249 |
| mu_M[497] | -0.180259 | 0.014288 | -0.202759 | -0.157267 | 6726.050676 | 3363.713430 | 1.001611 | 0.000174 | 0.000126 |
| mu_M[498] | -1.003216 | 0.031812 | -1.053393 | -0.952474 | 7065.766083 | 3325.551880 | 0.999888 | 0.000379 | 0.000266 |
| mu_M[499] | -0.756732 | 0.025445 | -0.796842 | -0.716835 | 7084.929549 | 3411.344015 | 1.000299 | 0.000303 | 0.000214 |
1007 rows × 9 columns
Effects summary
Do the estimated coefficients match what we expect? With known true values (a = 0.5, b = 0.8, c = 0.3), we can verify recovery.
model.effects_summary()| mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|
| name | ||||
| a | 0.497194 | 0.014603 | 0.468207 | 0.523708 |
| b | 0.762801 | 0.046157 | 0.676966 | 0.851365 |
| c | 0.334834 | 0.026907 | 0.284274 | 0.385843 |
| indirect | 0.379254 | 0.025407 | 0.332128 | 0.428326 |
Posterior predictive check
Can the fitted model reproduce the observed data?
Code
pp = model.predict()
y_pred = pp.posterior_predictive["Y"].values.flatten()
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))
x_kde, y_kde, _ = az.kde(df["Y"].values)
ax.plot(x_kde, y_kde, color=COLOR_DEFAULT, lw=2, label="Observed Y")
ax.fill_between(x_kde, y_kde, alpha=0.5, color=COLOR_DEFAULT)
x_kde, y_kde, _ = az.kde(y_pred)
ax.plot(x_kde, y_kde, color=COLOR_CUSTOM, lw=2, label="Posterior predictive Y")
ax.fill_between(x_kde, y_kde, alpha=0.5, color=COLOR_CUSTOM)
ax.set_xlabel("Y")
ax.set_ylabel("Density")
ax.legend()
plt.tight_layout()
plt.show()Sampling: [M, Y]
7. Causal queries
With a well-checked model, causal queries carry full posterior uncertainty through the structural equations via g-computation.
model.ate("Y", "X", values=(0, 1))| Mean | 0.71 |
| 94% HDI | [0.68, 0.75] |
| P(> 0) | 1.00 |
| Draws | 4000 |
model.effect("X -> M -> Y")| Mean | 0.3793 |
| SD | 0.0254 |
| 94% HDI | [0.3321, 0.4283] |
| P(> 0) | 1.0000 |
| Draws | 4000 |
The ATE should be close to the true total effect (a \times b + c = 0.5 \times 0.8 + 0.3 = 0.7), and the indirect effect through M should be close to a \times b = 0.4.
Summary
| Step | Method | Cost |
|---|---|---|
| Build model | pathmc.model(spec, data) |
Instant |
| Inspect | graph(), equations() | Instant |
| Prior predictive check | sample_prior_predictive() | Seconds |
| Refine priors | set_priors() | Instant |
| Fit | fit() | Minutes |
| Posterior checks | summary(), effects_summary(), predict() | Seconds |
| Causal queries | ate(), do(), prob(), effect() | Seconds |
The first four steps form a fast inner loop that you can repeat many times before committing to MCMC. The outer loop — revising the model specification itself — is for when posterior checks reveal structural problems.
Further reading
- Custom Priors — deeper dive into prior specification with hierarchical priors and different distribution families
- Model Specification — DSL syntax reference
- Causal Inference — the do-operator and g-computation
- Standardized Effects — comparing effect sizes across scales