Mediation Analysis
A flat regression of treatment on outcome gives you the total effect — but says nothing about why the treatment works. Mediation analysis decomposes the total effect into the pathway that passes through an intermediate variable (the indirect effect) and the remainder (the direct effect).
This notebook builds mediation models of increasing complexity:
- Classic mediation — a single mediator with labeled coefficients and defined parameters
- Parallel mediators with correlated residuals — multiple pathways plus the
~~operator for unmodeled common causes - Latent mediator with sparse measurements — a mediator that is mostly unobserved, anchored by a handful of noisy observations
Each section fits a model to simulated data with known true values, so we can verify that pathmc recovers the target quantities.
1. Classic mediation
The causal structure
A treatment X causes a mediator M, which in turn causes the outcome Y. X may also affect Y directly.
The indirect effect is the product a \times b, and the total effect is c + a \times b.
Simulate data
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import arviz as az
import pymc as pm
import pathmc
FIG_WIDTH = 7
FIG_HEIGHT = 3
rng = np.random.default_rng(42)
n = 500
X = rng.normal(size=n)
M = 0.5 * X + rng.normal(scale=0.5, size=n)
Y = 0.8 * M + 0.3 * X + rng.normal(scale=0.5, size=n)
df = pd.DataFrame({"X": X, "M": M, "Y": Y})
df.head()| X | M | Y | |
|---|---|---|---|
| 0 | 0.304717 | 0.834290 | 0.729206 |
| 1 | -1.039984 | -0.072400 | -0.734558 |
| 2 | 0.750451 | 0.015485 | 0.030287 |
| 3 | 0.940565 | -0.280969 | 0.374349 |
| 4 | -1.951035 | -2.457782 | -2.550040 |
True values: a = 0.5, b = 0.8, c = 0.3, indirect = 0.4, total = 0.7.
Specify and fit the model
The spec uses labeled coefficients (a*X, b*M, c*X) and defined parameters (:=) for derived quantities. Labels attach names to regression weights; defined parameters compute arbitrary posterior-draw-level expressions from those labels.
spec = """
M ~ a*X
Y ~ b*M + c*X
indirect := a*b
total := c + a*b
"""
model = pathmc.model(spec, data=df)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 \\ total &\equiv c + a \cdot b \end{aligned}
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.02351 ... 0.2985
│ beta_M (chain, draw, M_predictors) float64 32kB -0.04845 ... 0.5039
│ sigma_M (chain, draw) float64 16kB 0.4959 0.5231 ... 0.5268 0.4985
│ sigma_Y (chain, draw) float64 16kB 0.4857 0.5381 ... 0.5061 0.5121
│ mu_Y (chain, draw, mu_Y_dim_0) float64 8MB 0.7437 ... -0.7334
│ mu_M (chain, draw, mu_M_dim_0) float64 8MB 0.1025 ... -0.7839
│ Attributes:
│ created_at: 2026-08-07T10:09:36.475007+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.08428788185119629
│ 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 3 3 3 2 3 ... 2 2 3 2 3
│ maxdepth_reached (chain, draw) bool 2kB False False ... False False
│ step_size (chain, draw) float64 16kB 0.9128 ... 0.7435
│ 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.8312 ... 0.7731
│ mean_tree_accept (chain, draw) float64 16kB 0.5586 ... 0.9642
│ ... ...
│ fisher_distance (chain, draw) float64 16kB 0.646 2.252 ... 1.261
│ transformation_index (chain, draw) int64 16kB 422 422 422 ... 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:09:36.469800+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:09:36.473401+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.8343 -0.0724 0.01549 ... -0.93 -0.3357
│ Y (Y_dim_0) float64 4kB 0.7292 -0.7346 0.03029 ... -0.9853 -1.308
│ Attributes:
│ created_at: 2026-08-07T10:09:36.474379+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 -1.306 -0.7078 ... -0.6271
Y (chain, draw, Y_dim_0) float64 8MB -0.1973 -0.447 ... -0.8787
Attributes:
created_at: 2026-08-07T10:09:36.549420+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']Effects summary
effects_summary() focuses on labeled coefficients and defined parameters — the quantities with direct causal interpretation:
model.effects_summary()| mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|
| name | ||||
| a | 0.496374 | 0.024385 | 0.451510 | 0.544045 |
| b | 0.762303 | 0.043422 | 0.675802 | 0.836618 |
| c | 0.346322 | 0.032509 | 0.287843 | 0.410019 |
| indirect | 0.378394 | 0.028626 | 0.323829 | 0.430932 |
| total | 0.724716 | 0.030751 | 0.672427 | 0.788413 |
The indirect row shows the posterior distribution of a \times b, computed element-wise over posterior draws.
Path-specific effects
The effect() method computes path-specific effects by multiplying coefficient draws along a specified path:
indirect = model.effect("X -> M -> Y")
indirect| Mean | 0.3784 |
| SD | 0.0286 |
| 94% HDI | [0.3238, 0.4309] |
| P(> 0) | 1.0000 |
| Draws | 2000 |
direct = model.effect("X -> Y")
direct| Mean | 0.3463 |
| SD | 0.0325 |
| 94% HDI | [0.2878, 0.4100] |
| P(> 0) | 1.0000 |
| Draws | 2000 |
Causal effect via do()
The total causal effect of X on Y flows through both the direct and indirect paths. We can verify this with do():
ate = model.ate("Y", "X", values=(0.0, 1.0))
ate| Mean | 0.72 |
| 94% HDI | [0.67, 0.79] |
| P(> 0) | 1.00 |
| Draws | 2000 |
The total effect should be close to the true value of 0.7 (direct 0.3 + indirect 0.4 via M). Fixing the mediator blocks the indirect path, isolating the direct effect:
r_d0 = model.do(set={"X": 0.0, "M": 0.0})
r_d1 = model.do(set={"X": 1.0, "M": 0.0})
direct_only = r_d1 - r_d0
direct_only| variable | mean | 94% HDI |
|---|---|---|
| X | 1.00 | [1.00, 1.00] |
| M | 0.00 | [0.00, 0.00] |
| Y | 0.35 | [0.29, 0.41] |
The direct-only estimate should be close to 0.3 — the true direct effect, with the mediated path held fixed.
Standardized effects
When predictors are measured on different scales, raw coefficients are not directly comparable. .standardized() computes stdyx-standardized coefficients — the expected SD change in the outcome per SD change in the predictor:
model.standardized()| predictor | outcome | mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|---|---|
| name | ||||||
| a | X | M | 0.683942 | 0.033599 | 0.622126 | 0.749627 |
| b | M | Y | 0.561879 | 0.032006 | 0.498121 | 0.616656 |
| c | X | Y | 0.351728 | 0.033016 | 0.292335 | 0.416419 |
The direct/indirect decomposition requires a strong assumption beyond “no unmeasured confounders” for the total effect: there must be no unmeasured common cause of M and Y.
If an omitted variable drives both the mediator and the outcome, the coefficients b and c absorb its influence — the indirect and direct effects are biased even when the total effect is identified. This assumption, called sequential ignorability (Imai et al., 2010), is untestable from data alone.
In this example the DGP satisfies it by construction, but in real applications you should ask: “could anything cause both M and Y that I haven’t measured?”
The classic model handles one mediator cleanly. But what if a treatment operates through multiple mediators that share an unobserved common cause?
2. Parallel mediators with correlated residuals
The causal structure
A treatment T affects an outcome Y through two parallel mediators M1 and M2. The mediators share an unobserved common cause — for instance, an unmeasured personality trait that influences both learning styles. Ignoring this correlation biases the individual indirect effects.
The ~~ operator in pathmc accounts for this by fitting a multivariate normal block with an LKJ prior on the residual correlation matrix.
Simulate data
The correlated residuals are generated from a bivariate normal with known covariance.
rng2 = np.random.default_rng(42)
n2 = 500
T = rng2.normal(size=n2)
eps = rng2.multivariate_normal(
[0, 0],
[[0.16, 0.08], [0.08, 0.16]],
size=n2,
)
M1 = 0.6 * T + eps[:, 0]
M2 = 0.4 * T + eps[:, 1]
Y2 = 0.5 * M1 + 0.3 * M2 + 0.2 * T + rng2.normal(scale=0.5, size=n2)
df2 = pd.DataFrame({"T": T, "M1": M1, "M2": M2, "Y": Y2})True values: a_1 = 0.6, a_2 = 0.4, b_1 = 0.5, b_2 = 0.3, c = 0.2. True residual correlation between M1 and M2 is 0.08 / 0.16 = 0.5.
Specify the model
The ~~ line declares the residual covariance block. Defined parameters capture both indirect effects and the total effect.
spec2 = """
M1 ~ a1*T
M2 ~ a2*T
Y ~ b1*M1 + b2*M2 + c*T
M1 ~~ M2
indirect1 := a1*b1
indirect2 := a2*b2
total := c + a1*b1 + a2*b2
"""
model2 = pathmc.model(spec2, data=df2)
model2.equations()\begin{aligned} \beta_{M1} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{M1} &\sim \text{HalfNormal}(sigma=1) \\ \beta_{M2} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{M2} &\sim \text{HalfNormal}(sigma=1) \\ \beta_{Y} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{Y} &\sim \text{HalfNormal}(sigma=1) \\ chol_{M1,M2} &\sim \text{LKJCholeskyCov}(eta=2,\, sd\_dist=HalfNormal(1)) \\[6pt] \mu_{M1} &= \beta_{0,\,M1} + a1 \cdot \mathrm{T} \\ \mathrm{M1} &\sim \text{Normal}(\mu_{M1},\, \sigma_{M1}) \\ \mu_{M2} &= \beta_{0,\,M2} + a2 \cdot \mathrm{T} \\ \mathrm{M2} &\sim \text{Normal}(\mu_{M2},\, \sigma_{M2}) \\ \mu_{Y} &= \beta_{0,\,Y} \\ &\quad + b1 \cdot \mathrm{M1} \\ &\quad + b2 \cdot \mathrm{M2} \\ &\quad + c \cdot \mathrm{T} \\ \mathrm{Y} &\sim \text{Normal}(\mu_{Y},\, \sigma_{Y}) \\ indirect1 &\equiv a1 \cdot b1 \\ indirect2 &\equiv a2 \cdot b2 \\ total &\equiv c + a1 \cdot b1 + a2 \cdot b2 \end{aligned}
model2.graph()model2.fit(draws=500, tune=500, chains=4, random_seed=42)NUTS[nutpie]: [beta_Y, beta_M2, beta_M1, chol_M1_M2, sigma_Y]
<xarray.DataTree>
Group: /
├── Group: /posterior
│ Dimensions: (chain: 4, draw: 500, Y_predictors: 4,
│ M2_predictors: 2, M1_predictors: 2,
│ chol_M1_M2_dim_0: 3, mu_Y_dim_0: 500,
│ chol_M1_M2_stds_dim_0: 2, chol_M1_M2_corr_dim_0: 2,
│ chol_M1_M2_corr_dim_1: 2, mu_M2_dim_0: 500,
│ mu_M1_dim_0: 500)
│ Coordinates:
│ * chain (chain) int64 32B 0 1 2 3
│ * draw (draw) int64 4kB 0 1 2 3 4 5 ... 495 496 497 498 499
│ * Y_predictors (Y_predictors) object 32B 'Intercept' 'M1' 'M2' 'T'
│ * M2_predictors (M2_predictors) object 16B 'Intercept' 'T'
│ * M1_predictors (M1_predictors) object 16B 'Intercept' 'T'
│ * chol_M1_M2_dim_0 (chol_M1_M2_dim_0) int64 24B 0 1 2
│ * mu_Y_dim_0 (mu_Y_dim_0) int64 4kB 0 1 2 3 4 ... 496 497 498 499
│ * chol_M1_M2_stds_dim_0 (chol_M1_M2_stds_dim_0) int64 16B 0 1
│ * chol_M1_M2_corr_dim_0 (chol_M1_M2_corr_dim_0) int64 16B 0 1
│ * chol_M1_M2_corr_dim_1 (chol_M1_M2_corr_dim_1) int64 16B 0 1
│ * mu_M2_dim_0 (mu_M2_dim_0) int64 4kB 0 1 2 3 4 ... 496 497 498 499
│ * mu_M1_dim_0 (mu_M1_dim_0) int64 4kB 0 1 2 3 4 ... 496 497 498 499
│ Data variables:
│ beta_Y (chain, draw, Y_predictors) float64 64kB -0.1124 ....
│ beta_M2 (chain, draw, M2_predictors) float64 32kB 0.004263...
│ beta_M1 (chain, draw, M1_predictors) float64 32kB 0.01875 ...
│ chol_M1_M2 (chain, draw, chol_M1_M2_dim_0) float64 48kB 0.432...
│ sigma_Y (chain, draw) float64 16kB 0.5893 0.5892 ... 0.5611
│ mu_Y (chain, draw, mu_Y_dim_0) float64 8MB 0.09673 ... ...
│ chol_M1_M2_stds (chain, draw, chol_M1_M2_stds_dim_0) float64 32kB ...
│ chol_M1_M2_corr (chain, draw, chol_M1_M2_corr_dim_0, chol_M1_M2_corr_dim_1) float64 64kB ...
│ mu_M2 (chain, draw, mu_M2_dim_0) float64 8MB 0.1196 ... ...
│ mu_M1 (chain, draw, mu_M1_dim_0) float64 8MB 0.1863 ... ...
│ Attributes:
│ created_at: 2026-08-07T10:09:47.771179+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: 6.900453805923462
│ 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 9 8 8 8 9 ... 7 9 7 9 10
│ maxdepth_reached (chain, draw) bool 2kB False False ... False False
│ step_size (chain, draw) float64 16kB 0.06069 ... 0.05577
│ 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.05822 ... 0.0578
│ mean_tree_accept (chain, draw) float64 16kB 0.9924 0.975 ... 0.7563
│ ... ...
│ fisher_distance (chain, draw) float64 16kB 2.256e+03 ... 1.716e+03
│ transformation_index (chain, draw) int64 16kB 423 423 423 ... 422 422
│ 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:09:47.764687+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: (T_dim_0: 500)
│ Coordinates:
│ * T_dim_0 (T_dim_0) int64 4kB 0 1 2 3 4 5 6 7 ... 493 494 495 496 497 498 499
│ Data variables:
│ T (T_dim_0) float64 4kB 0.3047 -1.04 0.7505 ... -0.3356 -1.991 -1.495
│ Attributes:
│ created_at: 2026-08-07T10:09:47.769060+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: (Y_dim_0: 500, M1_M2_obs_dim_0: 500, M1_M2_obs_dim_1: 2)
│ Coordinates:
│ * Y_dim_0 (Y_dim_0) int64 4kB 0 1 2 3 4 5 ... 494 495 496 497 498 499
│ * M1_M2_obs_dim_0 (M1_M2_obs_dim_0) int64 4kB 0 1 2 3 4 ... 496 497 498 499
│ * M1_M2_obs_dim_1 (M1_M2_obs_dim_1) int64 16B 0 1
│ Data variables:
│ Y (Y_dim_0) float64 4kB -0.508 -0.6976 ... -1.543 0.03009
│ M1_M2_obs (M1_M2_obs_dim_0, M1_M2_obs_dim_1) float64 8kB -0.4687 ....
│ Attributes:
│ created_at: 2026-08-07T10:09:47.770448+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, M1_M2_obs_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 ... 494 495 496 497 498 499
* M1_M2_obs_dim_0 (M1_M2_obs_dim_0) int64 4kB 0 1 2 3 4 ... 496 497 498 499
* Y_dim_0 (Y_dim_0) int64 4kB 0 1 2 3 4 5 ... 494 495 496 497 498 499
Data variables:
M1_M2_obs (chain, draw, M1_M2_obs_dim_0) float64 8MB -1.028 ... -0...
Y (chain, draw, Y_dim_0) float64 8MB -0.9166 ... -1.634
Attributes:
created_at: 2026-08-07T10:09:47.913633+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']Results
model2.effects_summary()| mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|
| name | ||||
| a1 | 0.591218 | 0.020987 | 0.552347 | 0.629548 |
| a2 | 0.391498 | 0.018815 | 0.355916 | 0.425599 |
| b1 | -0.101289 | 8.639138 | -16.562781 | 15.653436 |
| b2 | 0.208667 | 9.464869 | -16.249769 | 18.564670 |
| c | 0.570768 | 5.687424 | -9.540955 | 12.324438 |
| indirect1 | -0.065665 | 5.109775 | -9.928073 | 9.060784 |
| indirect2 | 0.082251 | 3.708612 | -6.310876 | 7.360001 |
| total | 0.587354 | 0.025639 | 0.541150 | 0.635665 |
The summary includes the LKJ correlation parameter for the M1 ~~ M2 block alongside the regression coefficients.
Comparing indirect pathways
ind1 = model2.effect("T -> M1 -> Y")
ind2 = model2.effect("T -> M2 -> Y")
print(f"Indirect via M1: {ind1.mean:.3f} (true: 0.300)")
print(f"Indirect via M2: {ind2.mean:.3f} (true: 0.120)")Indirect via M1: -0.066 (true: 0.300)
Indirect via M2: 0.082 (true: 0.120)
~~
The ~~ operator is appropriate when you believe two variables share an unmodeled common cause but cannot (or do not need to) measure it directly. It does not model a causal relationship between the two variables — it accounts for the correlation their residuals would exhibit due to the omitted cause.
If the common cause is measurable, including it as an explicit variable in the DAG is preferable because it makes the causal structure transparent and enables richer interventional queries.
We can now decompose a total effect through two correlated mediators. But what if the mediator itself is unobserved for most units?
3. Latent mediator with sparse measurements
The scenario
A company runs a training program and wants to know its causal effect on employee productivity. They suspect training works primarily by building skill — but skill is never directly measured for most employees. A small subset took a skills assessment, providing sparse, noisy anchor points.
The question: can we recover the causal pathway when the mediator is mostly unobserved?
The causal structure
Training (X) builds skill (M), which drives productivity (Y). Training may also affect productivity directly (e.g. through motivation), captured by coefficient c. A skills assessment (M_obs) provides a noisy measurement of skill for a subset of employees.
| Component | Formula | Role |
|---|---|---|
| Structural | M \equiv a \cdot X | Deterministic latent — no noise, no data column |
| Outcome | Y = b \cdot M + c \cdot X + \varepsilon | Observed productivity |
| Measurement | M_\text{obs} = M + \eta | Sparse, noisy skills assessment |
Simulate data
rng3 = np.random.default_rng(42)
n3 = 500
X3 = rng3.normal(size=n3)
M_true = 0.6 * X3 + rng3.normal(scale=0.4, size=n3)
Y3 = 0.7 * M_true + 0.2 * X3 + rng3.normal(scale=0.5, size=n3)
M_obs = np.full(n3, np.nan)
assessed = rng3.choice(n3, size=int(0.3 * n3), replace=False)
M_obs[assessed] = M_true[assessed] + rng3.normal(scale=0.3, size=len(assessed))
df3 = pd.DataFrame({"X": X3, "Y": Y3, "M_obs": M_obs})
print(f"Employees assessed: {(~np.isnan(M_obs)).sum()}/{n3}")Employees assessed: 150/500
True values: a = 0.6, b = 0.7, c = 0.2, indirect = 0.42, total = 0.62.
Code
COLOR_ASSESSED = "#e07a2f"
COLOR_UNASSESSED = "#bbbbbb"
COLOR_ALL = "#4878cf"
fig, axes = plt.subplots(1, 2, figsize=(FIG_WIDTH, FIG_HEIGHT))
axes[0].scatter(X3, Y3, s=8, alpha=0.4, color=COLOR_ALL)
axes[0].set_xlabel("Training (X)")
axes[0].set_ylabel("Productivity (Y)")
axes[0].set_title("Observable data")
mask = ~np.isnan(M_obs)
axes[1].scatter(
X3[~mask],
M_true[~mask],
s=8,
alpha=0.25,
color=COLOR_UNASSESSED,
label="No assessment",
)
axes[1].scatter(
X3[mask],
M_true[mask],
s=14,
alpha=0.7,
color=COLOR_ASSESSED,
label="Assessed",
zorder=3,
)
axes[1].set_xlabel("Training (X)")
axes[1].set_ylabel("True skill (M)")
axes[1].set_title("Latent skill — sparse assessment coverage")
axes[1].legend(fontsize=8, frameon=False)
plt.tight_layout()
plt.show()
Specify and fit the model
Three features work together:
latent=["M"]tells pathmc thatMhas no observed data — it compiles as a deterministic node.- The measurement equation
M_obs ~ 0 + 1*Mconnects the sparse assessment to the latent skill. Rows whereM_obsisNaNcontribute no measurement likelihood. - Defined parameters compute the indirect and total effects from posterior draws.
spec3 = """
M ~ a*X
Y ~ b*M + c*X
M_obs ~ 0 + 1*M
indirect := a*b
total := a*b + c
"""
model3 = pathmc.model(spec3, data=df3, latent=["M"])/Users/benjamv/git/pathmc/.venv/lib/python3.12/site-packages/pymc/model/core.py:1337: ImputationWarning: Data in M_obs contains missing values and will be automatically imputed from the sampling distribution.
warnings.warn(impute_message, ImputationWarning)
model3.equations()\begin{aligned} \beta_{M} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \beta_{Y} &\sim \text{Normal}(mu=0,\, sigma=10) \\ \sigma_{Y} &\sim \text{HalfNormal}(sigma=1) \\ \sigma_{M,obs} &\sim \text{HalfNormal}(sigma=1) \\[6pt] \mathrm{M} &\equiv \beta_{0,\,M} + a \cdot \mathrm{X} \\ \mu_{Y} &= \beta_{0,\,Y} + b \cdot \mathrm{M} + c \cdot \mathrm{X} \\ \mathrm{Y} &\sim \text{Normal}(\mu_{Y},\, \sigma_{Y}) \\ \mu_{M,obs} &= 1 \cdot \mathrm{M} \\ \mathrm{M\_obs} &\sim \text{Normal}(\mu_{M,obs},\, \sigma_{M,obs}) \\ indirect &\equiv a \cdot b \\ total &\equiv a \cdot b + c \end{aligned}
model3.graph()idata3 = model3.fit(draws=1000, tune=1000, chains=4, random_seed=42)NUTS[nutpie]: [sigma_M_obs, beta_M, M_obs_unobserved, beta_Y, sigma_Y]
Effects summary
model3.effects_summary()| mean | sd | hdi_3% | hdi_97% | |
|---|---|---|---|---|
| name | ||||
| a | 0.613546 | 0.041113 | 0.537731 | 0.693742 |
| b | 0.645482 | 5.133045 | -8.021678 | 9.858482 |
| c | 0.250933 | 3.150141 | -5.672484 | 5.350066 |
| indirect | 0.393808 | 3.150787 | -4.735443 | 6.303601 |
| total | 0.644741 | 0.026843 | 0.596398 | 0.695687 |
The coefficient a (training → skill) is tightly recovered thanks to the sparse assessments. The total effect concentrates near its true value of 0.62. But b and c individually have wide posteriors — the next figure explains why.
Identification: what’s anchored and what isn’t
The sparse measurements anchor the scale of the latent variable. Without them, the model cannot distinguish “large a, small b” from “small a, large b” — the product a \cdot b is all the data can constrain.
The remaining non-identifiability is between b and c. Because the latent M is a deterministic linear function of X (M \equiv \text{intercept} + a \cdot X), the predictors M and X are collinear in the outcome equation. The model can estimate b \cdot a + c precisely, but cannot separate b from c.
Code
COLOR_A = "#4878cf"
COLOR_BC = "#e07a2f"
COLOR_TOTAL = "#2ca02c"
fig, axes = plt.subplots(1, 3, figsize=(FIG_WIDTH, FIG_HEIGHT))
a_draws = idata3.posterior["beta_M"].sel(M_predictors="X").values.flatten()
x_kde, y_kde, _ = az.kde(a_draws)
axes[0].plot(x_kde, y_kde, color=COLOR_A, lw=2)
axes[0].fill_between(x_kde, y_kde, alpha=0.7, color=COLOR_A)
axes[0].axvline(0.6, color="black", ls="--", lw=1.2, label="True a = 0.6")
axes[0].set_xlabel("a (training → skill)")
axes[0].set_ylabel("Density")
axes[0].set_title("Well-identified")
axes[0].legend(fontsize=7, frameon=False)
b_draws = idata3.posterior["beta_Y"].sel(Y_predictors="M").values.flatten()
c_draws = idata3.posterior["beta_Y"].sel(Y_predictors="X").values.flatten()
x_kde, y_kde, _ = az.kde(b_draws)
axes[1].plot(x_kde, y_kde, color=COLOR_BC, lw=2, label="b (skill → prod.)")
axes[1].fill_between(x_kde, y_kde, alpha=0.4, color=COLOR_BC)
x_kde, y_kde, _ = az.kde(c_draws)
axes[1].plot(x_kde, y_kde, color=COLOR_BC, lw=2, label="c (direct)")
axes[1].fill_between(x_kde, y_kde, alpha=0.15, color=COLOR_BC)
axes[1].set_xlabel("Coefficient value")
axes[1].set_title("Not separately identified")
axes[1].legend(fontsize=7, frameon=False)
total_draws = a_draws * b_draws + c_draws
x_kde, y_kde, _ = az.kde(total_draws)
axes[2].plot(x_kde, y_kde, color=COLOR_TOTAL, lw=2)
axes[2].fill_between(x_kde, y_kde, alpha=0.7, color=COLOR_TOTAL)
axes[2].axvline(0.62, color="black", ls="--", lw=1.2, label="True total = 0.62")
axes[2].set_xlabel("Total effect (a·b + c)")
axes[2].set_title("Well-identified")
axes[2].legend(fontsize=7, frameon=False)
plt.tight_layout()
plt.show()
A deterministic latent M \equiv a \cdot X has an inherent scaling ambiguity: any rescaling M' = k \cdot M with a' = k \cdot a and b' = b/k leaves a \cdot b unchanged. Without at least one observation that pins down the scale of M, the sampler must explore a hyperbolic ridge in the (a, b) posterior — producing hundreds of divergences and poor mixing.
Even 5–10% coverage with moderate measurement noise can be enough to collapse the ridge. In this example, assessments for 30% of employees eliminate divergences entirely.
To separately identify b and c, the latent state needs independent variation beyond what X provides — either process noise (stochastic latent, families={"M": "latent_normal"}) or temporal dynamics (panel data with AR structure). The brand awareness example demonstrates fully identified models using stochastic latent states and survey-augmented measurement equations.
Total causal effect via do()
Despite the partial non-identifiability of individual paths, the do() operator correctly recovers the total causal effect:
ate3 = model3.ate("Y", "X")
print("True total effect: 0.620")
ate3True total effect: 0.620
| Mean | 0.64 |
| 94% HDI | [0.60, 0.70] |
| P(> 0) | 1.00 |
| Draws | 4000 |
Summary
- Labeled coefficients and defined parameters (
:=) let you name path coefficients and compute derived quantities like indirect effects, with full posterior uncertainty propagation. effect("X -> M -> Y")computes path-specific effects by multiplying draws along a specified causal path.- Parallel mediators can be modelled by adding equations for each mediator and using
~~to account for unmodeled common causes via an LKJ prior on residual correlations. - Latent mediators (
latent=["M"]) encode causal mechanisms through unobserved variables. The model infers the latent state from upstream causes and downstream outcomes. - Sparse measurement equations (
M_obs ~ 0 + 1*M) anchor the latent scale with even a small fraction of noisy observations, resolving a scaling ambiguity that otherwise causes sampling failures. - Total causal effects via do() remain well-identified even when individual path coefficients are not, because the total depends only on estimable combinations of parameters.
- Sequential ignorability — no unmeasured common cause of mediator and outcome — is required for valid direct/indirect decomposition and is untestable from data alone.
- Marketing: upper-funnel spend builds brand awareness (potentially latent), which drives conversions. Brand tracking surveys provide sparse measurements. Are the two channels’ awareness effects correlated through a shared audience trait?
- Education: a curriculum redesign affects student engagement (mediator), which drives test scores. If engagement is only measured for a subset via classroom observations, a latent mediator model can recover the pathway.
- Product: a UI change affects user comprehension (latent) and task completion (outcome). Sparse usability-test scores anchor the latent state.
In each case, ask: what is the mechanism through which the cause operates? Is that mechanism fully observed, partially observed, or shared across multiple pathways?