NotebooksStructural causal models: one fit, three kinds of question
Static notebook preview · outputs are cleared. Download the notebook to run the cells.
Structural causal models: one fit, three kinds of question
A subscription business gives some customers a discount at renewal time. The account team decides how big, and they give bigger discounts to the customers they think are most engaged. Three people now ask three different questions about the same data.
The analyst asks what the data shows: customers with bigger discounts spend more, by how much? The pricing lead asks what a policy would do: if we set everyone's discount one dollar higher, how much more would they spend? And the account manager asks about one person: this customer got 5.25 dollars off and spent 56.85, what would they have spent with two dollars more?
These are the three rungs of the causal ladder: seeing, doing, and imagining. Earlier guides answered the first two with an estimator per question. A structural causal model (SCM) answers all three from one fitted object, because it models every variable as an equation of its causes plus its own noise. This guide fits one, climbs the ladder, and then removes a column to show what the model does when the data can't answer.
The answer key goes into the simulation first.
import logging
import numpy as np
import pandas as pd
# PyMC logs every sampler run; this keeps the notebook's output readable.
for name in ("pymc", "pymc.sampling", "pymc.stats.convergence"):
logging.getLogger(name).setLevel(logging.ERROR)
rng = np.random.default_rng(11)
n = 1000
engagement = rng.normal(0.0, 1.0, size=n)
# The account team gives bigger discounts to engaged customers: the confounding.
discount = 5.0 + 2.0 * engagement + rng.normal(0.0, 1.5, size=n)
# The answer key: each extra dollar of discount adds 1.5 dollars of spend.
spend = 50.0 + 1.5 * discount + 8.0 * engagement + rng.normal(0.0, 4.0, size=n)
# Renewal is a yes or no, pushed up by discount and engagement.
logit = -0.5 + 0.35 * (discount - 5.0) + 1.2 * engagement
renewed = (rng.uniform(size=n) < 1.0 / (1.0 + np.exp(-logit))).astype(int)
df = pd.DataFrame(
{"engagement": engagement, "discount": discount, "spend": spend, "renewed": renewed}
)
df.head(3)
Engagement drives both the discount and the spend, so it's a confounder. The true effect of one more dollar of discount on spend is 1.5. Renewal is a binary outcome with its own logistic response.
Fitting the model#
An SCM is a graph plus one fitted equation per node. You give it the graph and the data,
and it picks an equation for each node from the column's type: a Bayesian linear
equation with Gaussian noise for a continuous column, and a Gumbel-max equation (a
logistic model with explicit noise) for a binary or categorical one. Telling tabular
that renewed is categorical says so explicitly instead of letting it guess.
Fitting runs PyMC once per node. It needs the scm extra (pip install "ergodic[scm]").
from ergodic import dag, tabular
from ergodic.scm import SCM
data = tabular(df, types={"renewed": "categorical"})
graph = dag(
[
"engagement -> discount",
"engagement -> spend",
"discount -> spend",
"engagement -> renewed",
"discount -> renewed",
]
)
scm = SCM(graph, data, draws=500, tune=500, chains=1, random_state=0).fit()
{node: type(mechanism).__name__ for node, mechanism in scm.mechanisms.items()}
Rung one and rung two: seeing and doing#
The data alone says something about discount and spend: regress one on the other and
read the slope. That's the analyst's number. The pricing lead's number is different:
interventional sets the discount for everyone with do, which cuts the arrow from
engagement into discount, and reads the spend that follows.
seen = np.polyfit(df["discount"], df["spend"], 1)[0]
at_6 = scm.interventional("spend", {"discount": 6.0})
at_5 = scm.interventional("spend", {"discount": 5.0})
print(f"seeing: slope of spend on discount {seen:.2f}")
print(f"doing: spend at do(discount=6) minus do(discount=5) {at_6.mean() - at_5.mean():.2f}")
print("truth: 1.50")
The observed slope is about 4. Engaged customers get bigger discounts and spend more, so the slope mixes the discount's effect with engagement's. Intervening severs that link and lands on 1.5.
For the average effect you'd normally call ate. It does something the raw contrast
doesn't: it asks the identification layer first, and only computes a number if the graph
says the data can determine one. Here the confounder is measured, so it can.
ate = scm.ate("discount", "spend", levels=(5.0, 6.0))
lo, hi = ate.interval(0.95)
print(ate)
print(f"identified: {ate.identified}, 95% credible interval [{lo:.2f}, {hi:.2f}]")
The result is an SCMResult: posterior draws of the effect, with a credible interval
that covers 1.5. Hold on to that type name, because it changes in the last section.
Rung three: imagining#
The account manager's question is about one customer, not a population. What would this customer have spent with two more dollars of discount? Answering needs the customer's own noise: whatever made them spend what they spent, beyond what discount and engagement explain. The SCM recovers it from the customer's observed row (the step is called abduction), then replays the same customer with the discount changed. That's the twin network: two worlds that share one person's noise.
customer = data.take([0])
row = df.iloc[0]
print(f"factual: discount {row['discount']:.2f}, spend {row['spend']:.2f}")
what_if = scm.counterfactual(customer, {"discount": row["discount"] + 2.0}, "spend")
lo, hi = what_if.interval(0.95)
print(f"had the discount been {row['discount'] + 2.0:.2f}: spend {what_if.mean():.2f}")
print(f"95% credible interval [{lo:.2f}, {hi:.2f}]")
print(f"change for this customer: {what_if.mean() - row['spend']:.2f}")
Two extra dollars move this customer's spend by about 3, twice the per-dollar effect.
With a linear equation that's exactly what you'd expect: the noise cancels, so every
customer's counterfactual change is the slope times the change. The interval comes from
uncertainty in the fitted slope. Pass use_draws=False for the single point.
Per-customer effects with ite#
ite runs that twin-network contrast for every customer at once. With levels=(5, 6)
it compares each customer's outcome at a discount of 5 and at 6, both worlds sharing
that customer's noise.
ite_spend = scm.ite(data, "discount", "spend", levels=(5.0, 6.0))
values = np.asarray(ite_spend.values)
print(f"spend: per-customer effects run from {values.min():.3f} to {values.max():.3f}")
Every customer gets the same number. That isn't a bug: a linear equation with additive noise has one slope, so the individual effect is the slope for everyone. Per-customer spread appears when the equation is nonlinear, and renewal is. Compare a 3-dollar discount with an 8-dollar one:
renew_at_8 = scm.interventional("renewed", {"discount": 8.0})
renew_at_3 = scm.interventional("renewed", {"discount": 3.0})
print("population, do(discount=3):", renew_at_3)
print("population, do(discount=8):", renew_at_8)
ite_renew = scm.ite(data, "discount", "renewed", levels=(3.0, 8.0))
flips = np.asarray(ite_renew.values)
outcomes, counts = np.unique(flips, return_counts=True)
print("per-customer change in renewal:", dict(zip(outcomes.tolist(), counts.tolist(), strict=True)))
print(f"share of customers the bigger discount wins over: {np.mean(flips == 1):.1%}")
The population view says the renewal rate rises with the bigger discount. The individual view says who: each customer's effect is a flip, 1 if they renew only under the bigger discount, 0 if their answer doesn't change. About a quarter of customers flip, which matches the rise in the population rate from 30% to 55%, but now you can list them. That list is what the account manager can act on.
A caution about what this per-customer answer rests on. It's exact given the model: the graph, the equation types, and the assumption that each customer's noise stays the same across the two worlds. None of that is testable from the data, so a counterfactual is only as good as the structural story behind it.
When the data can't answer#
Now take engagement away. It's the most common situation in practice: the account team
knew who was engaged, and nobody wrote it down. The graph still says engagement drives
both discount and spend; mark it latent and fit on the two columns that remain.
hidden = dag(
["engagement -> discount", "engagement -> spend", "discount -> spend"],
latent=["engagement"],
)
scm_hidden = SCM(
hidden, tabular(df[["discount", "spend"]]), draws=500, tune=500, chains=1, random_state=0
).fit()
answer = scm_hidden.ate("discount", "spend")
print(type(answer).__name__)
print(answer)
The same ate call returns a Sensitivity, not an SCMResult. With engagement
unmeasured, no adjustment set exists and the effect isn't a function of the data.
Plenty of tools would still hand you a number with a tight interval. This object has no
.value at all, so you can't read a point off it by accident.
What it gives you instead is the effect as a function of how much confounding you
assume. Here c is the amount of the discount-spend covariance you attribute to the
hidden confounder. With c = 0 you're assuming no confounding, and you get the
observed slope back. The loop below sets c to a share of the observed covariance.
cov_xy = float(np.cov(df["discount"], df["spend"])[0, 1])
for share in (0.0, 0.25, 0.5, 0.75, 1.0):
c = share * cov_xy
print(f"confounding explains {share:4.0%} of the covariance -> effect {answer.at(c):5.2f}")
low, high = answer.range()
print(f"swept range [{low:.2f}, {high:.2f}]")
print(f"effect if no confounding {answer.identified_at_zero:.2f}")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7, 3.8))
ax.plot(answer.confounding, answer.effect, color="#33415c", lw=2, label="effect implied by c")
ax.axhline(1.5, color="#d1495b", ls="--", lw=1.2, label="true effect (from the code)")
ax.axhline(0.0, color="#9aa5b1", lw=1)
ax.set_xlabel("assumed confounding c (covariance attributed to engagement)")
ax.set_ylabel("effect of one dollar of discount")
ax.legend(frameon=False)
fig.tight_layout()
Read the curve as a decision tool. The truth sits on it, at the confounding strength the data can't reveal. The effect stays positive until hidden engagement explains all of the covariance between discount and spend. So "discounts raise spend" survives any confounding short of total, while "each dollar is worth 4" depends entirely on assuming none. If your team believes engagement explains at least half of the association, the curve tells you the effect is at most about 2.
What to take away#
One fitted SCM answers population questions with interventional and ate, and
individual ones with counterfactual and ite. Before it answers an average effect,
it checks identification, and the return type tells you which world you're in: an
SCMResult when the data determines the answer, a Sensitivity when it doesn't.
The SCM guides go further: fitting an SCM covers mechanisms and latent confounders, and interventions and counterfactuals covers the query surface. The next guide turns to a different kind of hidden variable: a switch that changes how a series behaves.