Model Specification

pathmc is a Python package for Bayesian path analysis — a form of observed-variable structural equation modeling (SEM) — built on PyMC. It lets you specify a system of structural equations using a concise, lavaan-inspired DSL, compiles them into a PyMC model, and provides tools for causal reasoning via the do-operator.

flowchart LR
  A["DSL spec<br/>(~ , ~~ , :=)"] --> B["Parsed Spec + Causal DAG<br/>(networkx GraphInfo)"]
  B --> C["Compile generative PyMC model"]
  C --> D["pm.observe() → estimate"]
  D --> E["pm.do() → intervene<br/>do()/ate()/cate()"]
  B --> E

The pathmc pipeline: a spec string is parsed, compiled into a generative PyMC model, then used for estimation (pm.observe) and intervention (pm.do).

pathmc uses a generative model architecture. For a mediation graph M = aX + ε_M, Y = cX + bM + ε_Y, the compiler emits X as pm.Data, and M and Y as free random variables wired through their structural equations. pm.observe() conditions on observed data for estimation. pm.do() replaces variables with constants for interventional reasoning — Pearl’s do-operator as PyMC graph surgery.

See the Overview for the birds-eye architecture overview.

The DSL

A pathmc specification is a multi-line string describing the structural relationships among observed variables. Three operators are supported:

Syntax Meaning Example
~ Regression (directed edge) Y ~ X1 + X2
~~ Residual covariance M1 ~~ M2
:= Defined parameter indirect := a*b

Statements are separated by newlines or semicolons.

Regression equations

Each lhs ~ rhs statement declares that the left-hand variable is caused by the right-hand variables:

spec = """
M ~ a*X
Y ~ b*M + c*X
"""

This compiles into the structural equations:

M = \beta_0^{(M)} + a \cdot X + \varepsilon_M Y = \beta_0^{(Y)} + b \cdot M + c \cdot X + \varepsilon_Y

Every regression includes an intercept, slope coefficients (one per predictor), and a residual term. All parameters — intercepts, slopes, and residual scales — are estimated jointly via MCMC.

Intercepts

Intercepts are included by default because pathmc does not assume the data is centered or standardized. Without an intercept, the regression would be forced through the origin — appropriate only when the outcome is expected to be zero when all predictors are zero, which is rarely the case with real-world data.

To suppress the intercept, prefix the right-hand side with 0 +:

spec = "Y ~ 0 + X1 + X2"

Labeled and unlabeled coefficients

Coefficient labels are optional. The label*variable syntax (e.g. a*X) gives a name to a regression weight, making it available for defined parameters (:=) and path-specific effects. Without a label, the coefficient still exists — it just doesn’t get a user-facing name.

# Labeled: coefficients named 'a', 'b', 'c'
spec = """
M ~ a*X
Y ~ b*M + c*X
indirect := a*b
"""

# Unlabeled: equivalent model, but coefficients are only
# accessible via the beta vectors (beta_M, beta_Y)
spec = """
M ~ X
Y ~ M + X
"""

Use labels when you need to refer to specific coefficients — for defined parameters, effect() path queries, or readability. Leave them off when you just need the structural relationship in the DAG.

Interaction terms

Interaction terms capture effect modification — when the effect of one variable depends on another. Use the : operator to create interactions:

spec = """
Y ~ a*X + b*Z + c*X:Z
"""

Here X:Z is the product X \cdot Z. The coefficient c captures how the effect of X on Y changes per unit of Z.

Syntax Meaning
X:Z Two-way interaction (product of X and Z)
X:Z:W Three-way interaction (product of X, Z, and W)
c*X:Z Labeled interaction coefficient

Interaction terms appear in equations as X × Z (plain text) or \mathrm{X} \times \mathrm{Z} (LaTeX).

Including both main effects alongside the interaction is typical:

# Main effects + interaction (recommended)
spec = "Y ~ a*X + b*Z + c*X:Z"

An interaction-only model (no main effects) is syntactically valid but rarely appropriate — it constrains the effect of X to be exactly zero when Z = 0:

# Interaction only (use with caution)
spec = "Y ~ c*X:Z"
WarningUnsupported syntax
  • X*Z shorthand is not supported. In pathmc’s DSL, * is the label operator (a*X labels the coefficient on X as a), so X*Z would be parsed as “label the coefficient X on predictor Z”. Use X:Z for interactions.
  • Transforms inside interactions (e.g., adstock(X):Z) are not supported. Apply transforms to the constituent variables before fitting, or use separate transformed columns.

For a full worked example of moderation analysis with interaction terms, see Moderation (Effect Modification).

Defined parameters

The := operator creates derived quantities evaluated over posterior draws:

spec = """
M ~ a*X
Y ~ b*M + c*X
indirect := a*b
"""

Here indirect is computed element-wise as the product of the posterior draws for a and b, giving a full posterior distribution for the indirect effect.

Residual covariance

Every structural equation has a residual — the variation not explained by its predictors. By default, pathmc assumes these residuals are independent across equations. The ~~ operator relaxes that assumption, declaring that two variables’ residuals move together.

Why model residual covariance?

The core reason is unmeasured common causes. If an unobserved variable U influences both M1 and M2, their residuals will be correlated even after conditioning on all observed predictors. In DAG terms, the bidirected ~~ arrow is the unmeasured confounder — it says “something out there affects both of these, and I can’t or didn’t measure it.”

cluster_true True data-generating process cluster_model Analyst's pathmc model T1 T M1a M1 T1->M1a M2a M2 T1->M2a Y1 Y T1->Y1 M1a->Y1 M2a->Y1 U U U->M1a U->M2a T2 T M1b M1 T2->M1b M2b M2 T2->M2b Y2 Y T2->Y2 M1b->M2b ~~ M1b->Y2 M2b->Y2
Figure 1: What residual covariance represents. Left: the true causal structure — an unmeasured variable U (dashed) causes both M1 and M2. Right: the analyst’s model — since U is unobserved, its effect is captured by declaring M1 M2, which models the correlation between their residuals.

This is powerful because in most regression contexts, an unmeasured confounder simply means bias — and there’s little you can do about it. In path analysis, the ~~ operator lets you acknowledge the confounder and account for its effects on uncertainty without needing to observe it directly.

Beyond unmeasured confounders, residual covariance is useful whenever residuals genuinely co-move:

  • Shared context. Variables measured on the same units (same person, same store, same time period) often share sources of variation that no predictor in the model captures.
  • Statistical efficiency. Jointly modeling correlated residuals uses more information from the data, producing tighter posterior intervals for all parameters in the system.
  • Honest uncertainty. Forcing truly correlated residuals to be independent imposes a false constraint that distorts the posterior — typically producing overconfident intervals that miss the shared variation between equations.
T T M1 M1 T->M1 a₁ M2 M2 T->M2 a₂ Y Y T->Y c M1->M2 ~~ M1->Y b₁ M2->Y b₂
Figure 2: Residual covariance structure. The directed edges (solid) encode the structural equations. The undirected edge M1 M2 (dashed, double-headed) declares correlated residuals. pathmc compiles M1 and M2 into a single multivariate normal block with an LKJ prior on the residual correlation.
spec = """
M1 ~ a1*T
M2 ~ a2*T
Y  ~ b1*M1 + b2*M2 + c*T
M1 ~~ M2
"""

Under the hood, pathmc groups ~~-connected variables into multivariate normal blocks with an LKJ prior on the residual correlation matrix.

Directed acyclic graphs

Every pathmc model defines a DAG (directed acyclic graph). Each regression Y ~ X1 + X2 creates directed edges X1 → Y and X2 → Y. pathmc validates that the graph is acyclic — cyclic specifications raise an error.

The DAG determines:

  • Topological order: the sequence in which variables can be computed, respecting parent → child dependencies.
  • Exogenous vs endogenous: variables with no parents in the DAG are exogenous (not modeled); variables with parents are endogenous (each gets a structural equation).

Inspect the graph after fitting:

model = pathmc.model(spec, data=df)
model.graph()  # Returns a graphviz Digraph

Latent deterministic mediators

Some causal pathways pass through variables that are not directly observed. For example, upper-funnel advertising spend may drive sales through an unobserved brand awareness mediator.

pathmc supports latent deterministic mediators — unobserved endogenous variables that are fully determined by their structural equation (no residual noise). The latent node has no data column, no residual variance, and no likelihood — it is a pure deterministic function of its parents.

upper_spend upper_spend brand brand_awareness (latent) upper_spend->brand φ sales sales upper_spend->sales b₁ brand->sales b₂
Figure 3: Latent mediator DAG. Brand awareness (dashed border) is unobserved — it mediates the effect of upper_spend on sales without contributing a likelihood term. The direct path upper_spend → sales and the indirect path upper_spend → brand_awareness → sales are both present.

Declare latent variables with the latent= parameter:

model = pathmc.model(
    """
    brand_awareness ~ phi*upper_spend
    sales ~ b1*upper_spend + b2*brand_awareness
    """,
    data=df,
    latent=["brand_awareness"],
)

Latent mediators:

  • Need no data column — they are unobserved by design.
  • Get a pm.Deterministic (no sigma, no likelihood) in the generative model.
  • Propagate correctly through do() — interventions on ancestors flow through the latent node.
  • Appear with dashed borders in model.graph() and are annotated [deterministic] in model.equations().