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 46 (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(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]
(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.74]
P(> 0)1.00
Draws2000
model.effect("X -> M -> Y")
EffectResult — X -> M -> Y
Mean0.3791
SD0.0245
94% HDI[0.3349, 0.4264]
P(> 0)1.0000
Draws2000

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