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(draws=500, tune=500, chains=4, random_seed=42)NUTS[nutpie]: [beta_Y, sigma_M, beta_M, sigma_Y]
<xarray.DataTree>
Group: /
├── Group: /posterior
│ Dimensions: (chain: 4, draw: 500, 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 4kB 0 1 2 3 4 5 6 ... 493 494 495 496 497 498 499
│ * Y_predictors (Y_predictors) object 24B 'Intercept' 'M' 'X'
│ * M_predictors (M_predictors) object 16B '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 48kB 0.01076 ... 0.3407
│ beta_M (chain, draw, M_predictors) float64 32kB -0.01287 ... 0.5213
│ sigma_M (chain, draw) float64 16kB 0.2867 0.3084 ... 0.2913 0.3228
│ sigma_Y (chain, draw) float64 16kB 0.297 0.3052 ... 0.3113 0.3007
│ mu_Y (chain, draw, mu_Y_dim_0) float64 8MB 0.5402 ... -0.8795
│ mu_M (chain, draw, mu_M_dim_0) float64 8MB 0.1378 ... -0.7864
│ Attributes:
│ created_at: 2026-08-07T10:16:08.501042+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.1.0
│ creation_library_language: Python
│ sample_dims: ['chain', 'draw']
│ inference_library: nutpie
│ inference_library_version: 0.16.10
│ sampling_time: 0.0751340389251709
│ tuning_steps: 500
├── Group: /sample_stats
│ Dimensions: (chain: 4, draw: 500)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 4kB 0 1 2 3 4 ... 495 496 497 498 499
│ Data variables: (12/20)
│ depth (chain, draw) uint64 16kB 2 3 3 2 2 ... 2 2 3 3 3
│ maxdepth_reached (chain, draw) bool 2kB False False ... False False
│ step_size (chain, draw) float64 16kB 0.6706 0.652 ... 0.7469
│ transformation_update_id (chain, draw) int64 16kB 0 0 0 0 0 0 ... 0 0 0 0 0
│ step_size_bar (chain, draw) float64 16kB 0.7114 ... 0.7294
│ mean_tree_accept (chain, draw) float64 16kB 0.5497 ... 0.9232
│ ... ...
│ fisher_distance (chain, draw) float64 16kB 0.1828 0.2064 ... 0.304
│ transformation_index (chain, draw) int64 16kB 420 420 420 ... 423 423
│ diverging (chain, draw) bool 2kB False False ... False False
│ divergence_draw (chain, draw) uint64 16kB 0 0 0 0 0 ... 0 0 0 0 0
│ divergence_message (chain, draw) object 16kB None None ... None None
│ divergence_energy_error (chain, draw) float64 16kB nan nan nan ... nan nan
│ Attributes:
│ created_at: 2026-08-07T10:16:08.495329+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.1.0
│ creation_library_language: Python
│ sample_dims: ['chain', 'draw']
│ inference_library: nutpie
│ inference_library_version: 0.16.10
│ inference_library_settings: {"sampler": "nuts", "adaptation": "diag", "s...
├── 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-08-07T10:16:08.498874+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.1.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: []
├── 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-08-07T10:16:08.500308+00:00
│ creation_library: ArviZ
│ creation_library_version: 1.1.0
│ creation_library_language: Python
│ inference_library: pymc
│ inference_library_version: 6.0.1
│ sample_dims: []
└── Group: /log_likelihood
Dimensions: (chain: 4, draw: 500, M_dim_0: 500, Y_dim_0: 500)
Coordinates:
* chain (chain) int64 32B 0 1 2 3
* draw (draw) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
* 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 8MB -0.7618 -0.1313 ... -0.1807
Y (chain, draw, Y_dim_0) float64 8MB 0.2933 0.05542 ... -0.2971
Attributes:
created_at: 2026-08-07T10:16:08.574793+00:00
creation_library: ArviZ
creation_library_version: 1.1.0
creation_library_language: Python
inference_library: pymc
inference_library_version: 6.0.1
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.001220 | 0.014322 | -0.023704 | 0.021457 | 2424.248628 | 1569.317162 | 1.001426 | 0.000292 | 0.000220 |
| beta_Y[M] | 0.763104 | 0.044793 | 0.692162 | 0.833056 | 1205.660767 | 1202.057615 | 1.001806 | 0.001291 | 0.000929 |
| beta_Y[X] | 0.334709 | 0.026554 | 0.292763 | 0.377682 | 1172.489881 | 1213.521526 | 1.002604 | 0.000773 | 0.000519 |
| beta_M[Intercept] | -0.013488 | 0.013654 | -0.036213 | 0.007656 | 2270.855562 | 1480.386088 | 1.002274 | 0.000291 | 0.000220 |
| beta_M[X] | 0.496857 | 0.014193 | 0.473523 | 0.519350 | 2621.807883 | 1431.405979 | 1.004140 | 0.000278 | 0.000200 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| mu_M[495] | 0.451345 | 0.018969 | 0.421096 | 0.481683 | 2955.349716 | 1510.138880 | 0.999079 | 0.000349 | 0.000244 |
| mu_M[496] | -0.923435 | 0.029456 | -0.971447 | -0.875764 | 2204.319822 | 1223.022922 | 1.007884 | 0.000629 | 0.000460 |
| mu_M[497] | -0.180237 | 0.014496 | -0.204192 | -0.157617 | 2116.326774 | 1386.885505 | 1.005673 | 0.000317 | 0.000238 |
| mu_M[498] | -1.002637 | 0.031479 | -1.053461 | -0.951949 | 2221.155324 | 1226.776063 | 1.008210 | 0.000669 | 0.000489 |
| mu_M[499] | -0.756320 | 0.025323 | -0.796728 | -0.715442 | 2160.284303 | 1188.509117 | 1.008050 | 0.000546 | 0.000400 |
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.496857 | 0.014193 | 0.469224 | 0.523093 |
| b | 0.763104 | 0.044793 | 0.680660 | 0.847168 |
| c | 0.334709 | 0.026554 | 0.288000 | 0.386576 |
| indirect | 0.379135 | 0.024465 | 0.334867 | 0.426382 |
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.74] |
| P(> 0) | 1.00 |
| Draws | 2000 |
model.effect("X -> M -> Y")| Mean | 0.3791 |
| SD | 0.0245 |
| 94% HDI | [0.3349, 0.4264] |
| P(> 0) | 1.0000 |
| Draws | 2000 |
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