Skip to main content

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.

specify 1. Build model model(spec, data) inspect 2. Inspect structure graph(), equations() specify->inspect ppc 3. Prior predictive check sample_prior_predictive() inspect->ppc plausible Plausible? ppc->plausible refine 4. Refine priors set_priors() plausible->refine No sample 5. Fit fit() plausible->sample Yes refine->ppc check 6. Posterior checks summary(), predict() sample->check goodfit Good fit? check->goodfit revise Revise model goodfit->revise No causal 7. Causal queries ate(), do(), prob() goodfit->causal Yes revise->specify
Figure 1: The Bayesian workflow in pathmc. The inner loop (prior predictive checks) is fast and iterative. The outer loop (model revision) involves rethinking causal structure.

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()
Figure 2: Prior predictive distribution of Y under default priors. The tails extend well beyond the observed data range, indicating the model considers extreme outcomes plausible.

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()
Figure 3: Prior predictive distributions under default (green) and refined (orange) priors. The refined priors concentrate mass in the plausible range.

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.

TipThis loop is cheap

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]
(a) Posterior predictive check: distribution of predicted Y values (orange) vs observed Y values (green).

(b)
(c)
Figure 4

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))
ATE of X on Y
Mean0.71
94% HDI[0.68, 0.75]
P(> 0)1.00
Draws4000
model.effect("X -> M -> Y")
EffectResult — X -> M -> Y
Mean0.3793
SD0.0254
94% HDI[0.3321, 0.4283]
P(> 0)1.0000
Draws4000

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