Causal Inference 04: Latent Confounders
Patio furniture is a discretionary purchase. When the economy hums — hiring up,
confidence up, houses turning over — Veranda Home sells more and spends more,
because performance budgets get topped up in good quarters. Economic
health is a textbook confounder: it drives both sides of every spend–sales
correlation. And nobody has a column called economic_health in their
warehouse — what they have is four noisy indicators that each reflect
it partially. This guide mirrors
nbs/causal/causal_04_latent_confounders.ipynb: three rungs of adjustment
on one synthetic world, same estimand, every rung graded against the sealed key.
Notebook 03 modeled a mediator;
this one models a measurement.
🧭 Before you start
Prerequisites: Causal 02 (adjustment and back-door thinking); Causal 03 helpful for the structural mindset. · Time: ~25 min.
You will learn:
- Why demand-chasing channels absorb a boom's correlation — and why the naive read isn't subtly wrong but spectacularly wrong (+778% on Search).
- Why indicators-as-controls genuinely helps yet cannot finish the job: noisy proxies attenuate, leaving a residual back-door no fit output announces.
- What a joint latent-factor model honestly buys when its media point estimates tie with plain controls — and the residual floor that only an experiment can break.
Run it:
nbs/causal/causal_04_latent_confounders.ipynb on GitHub.
1 — The world on the desk
The economic_health world (make_economic_health in
src/mmm_framework/synth/dgp.py): a latent macro factor — AR(1) cycle plus
slow growth plus a seasonal wobble — drives spend and sales. Search and
Social chase it hardest (their budgets are topped up in booms); TV and Display barely
move with it. The factor is measured only through four noisy indicators, each of the
form \( \mathrm{ind}_k = \lambda_k \cdot \mathrm{econ}_t + \epsilon_{kt} \), with the
planted loadings sealed in the answer key:
from mmm_framework.synth.dgp import make_economic_health
world = make_economic_health() # sealed answer key rides in world.notes
world.notes["true_loadings"]
# {'gdp_growth': 0.9, 'consumer_confidence': 0.8,
# 'unemployment': -0.7, 'retail_sales': 0.6}
world.notes["chasers"] # ['Search', 'Social'] — the demand-chasers
The indicators visibly co-move (they share one driver), but none of them is
the driver: each carries its own noise, and unemployment runs upside-down. The DAG has
a classic back-door: spend ← economic health → sales, with the confounder
itself unobservable and its four measurements dangling off it. One methodological note
for everything below: all three rungs are fit without a linear trend,
matching the framework's own recovery test, so the comparison isolates de-confounding
cleanly — the world's only slow structure is the economic factor. (What a
trend term accidentally does is measured in the aside at the end.)
2 — Rung A: ignore it
The naive fit — Price is the only control. The chasers absorb the boom-time correlation, and the regression rebalances the rest of the portfolio around the distortion. Graded against the sealed key:
- Search: +778% — credited roughly nine times its true contribution, truth far outside the 90% interval.
- Display: −81% — a genuinely working channel crushed to near-zero to compensate. Truth outside the interval.
- TV +81%, Social +33% — inflated, but their (wide) intervals still cover.
A budget that acted on this read would pour money into the channel that happened to surf the boom and cut one that was quietly earning.
3 — Rung B: indicators as controls
The practitioner's move: throw all four indicators into the control set (marked
CONFOUNDER, per notebook 01's lesson). This works, mostly — the
four series jointly span much of the economy's variance, and the chaser bias collapses
by roughly a factor of four: Search +778% → +172%.
But look closely at what's left. The indicators each carry idiosyncratic noise, so they can never absorb all of the factor's variance — the classic errors-in-variables attenuation: regression adjusts for the proxy, not the confounder, and the unmeasured remainder keeps a back-door open. The Search estimate stays materially inflated (+172%, truth still outside its 90% interval), and nothing in the fit output announces it. The model converged; the controls "worked"; the residual bias is silent. (Marking the indicators as confounders vs leaving them unmarked barely matters here — +172% vs +175% — their signal survives default shrinkage either way.)
4 — Rung C: model the measurement
The structural move: instead of handing the noisy proxies to the regression, declare
what they are — four measurements of one latent factor — and estimate the
factor inside the same PyMC graph as the MMM. The framework's worked
garden-model example examples/garden_models/latent_factor_mmm.py does
exactly this: a latent AR(1) factor with a measurement block, entering the sales
equation as the de-confounder, its uncertainty propagating into every media
coefficient — no two-stage plug-in.
# illustrative — rung C rides the framework's worked garden-model example
# (examples/garden_models/latent_factor_mmm.py): a measurement block and the
# MMM estimated jointly in ONE PyMC graph.
latent_mmm = LatentFactorMMM(...) # indicators tagged INDICATOR, not CONTROL
latent_mmm.fit(draws=400, tune=800, chains=4) # NUTS only — MAP is unstable here
latent_mmm.factor_loadings_summary() # graded below vs the planted loadings
Identification is delicate and instructive: the realized factor is
standardized in-graph (otherwise the AR variance trades off against
the loadings and the sampler collapses the loadings to zero), and the first
loading is constrained positive (gdp_growth, the anchor) to pin
the factor's orientation — every other loading is free to go negative, which is
exactly what unemployment should do. The fit is 4-chain NUTS (max r-hat 1.017).
Measured — posterior loadings (mean, HDI) vs the planted values.
All four signs recovered, including unemployment's negative loading —
a model that forced positive loadings would have broken this world — and the planted
ordering (gdp > confidence > retail in magnitude) comes through. Posterior means
land near the planted values (0.90 vs 0.9, 0.87 vs 0.8, −0.83 vs −0.7, 0.75 vs 0.6);
magnitudes run slightly hot for the smaller loadings, a known scale trade-off with the
AR(1) factor. Numbers: nbs/artifacts/causal_04_latent_recovery.json.
Measured — the confounder, made visible: the recovered economic-health factor (posterior mean, 90% band) against the true series from the sealed key, aligned for display. Correlation: 0.981. This is a nameable weekly series with uncertainty — a thing a CFO can monitor, extend with new indicators, or compare against external indices.
5 — The ladder, graded honestly
Same estimand for all three rungs — counterfactual contribution vs the sealed key, chaser channels (Search & Social) averaged:
Measured — mean |relative error| on the chaser channels:
A 406% → B 101% → C 112%. Search alone: +778% → +172% → +191%.
Both structural rungs collapse the naive bias ~4×. And the honest part:
on this world, B and C essentially tie on media point error — four
indicators used as plain controls span about as much of the confounder's variance as
the one-factor model extracts from them. Numbers:
nbs/artifacts/causal_04_latent_recovery.json.
⚠️ Aside — the accidental de-confounder
The same naive model with a linear trend (the series' usual default) reads Search at +399% instead of +778%: the trend soaks up the economy's slow growth component and halves the damage by accident. A trend term is a partial, unprincipled de-confounder — it absorbs whatever slow structure is around, confounder and genuine carryover alike, and it never tells you which. Don't count on it; name the confounder instead.
6 — What rung C actually buys
If B and C tie on point error, rung C is not an accuracy purchase — it's a knowledge purchase:
- The measurement model is itself a graded causal claim. C recovered the loadings — all four signs, the planted ordering, the negative unemployment loading — and the factor's weekly path (corr 0.981). B's output is four regression coefficients on noisy proxies: nothing you could hand to an economist, reuse next year, or falsify.
- A nameable series to monitor. C produces "economic health, weekly, with uncertainty." B produces nothing reusable.
- Honest propagation. C's media intervals carry the factor's estimation uncertainty inside the same posterior. B silently treats noisy proxies as if they were the confounder itself.
- A platform. The measurement block composes with everything else in this series — the funnel mediators of notebook 03, the experiment calibration the series turns to next.
📌 The residual floor — and its price tag
One thing neither rung buys: both leave the same residual Search bias (~+172% vs +191%), because it lives in the part of the confounder the indicators simply don't measure — the shared indicator noise floor. No observational structure can reach it: you cannot adjust for variance no measurement carries. That floor — bias you can name but not remove — is exactly the price of an experiment, which is where the series goes next: Causal 05 — Measuring One Experiment.
Check your understanding
Commit to an answer before opening each one.
Four strong indicators went into the control set and the model converged cleanly. Why did +172% of Search bias survive?
Errors-in-variables attenuation. Each indicator is the factor plus its own noise, so regression on the indicators adjusts for the proxies, not the confounder — the fraction of the factor's variance that no indicator carries stays unadjusted, and its back-door stays open. Crucially, nothing in the fit output announces this: convergence, intervals and fit statistics all look normal. The residual is only visible here because the world has a sealed answer key.
Rung C's media point estimates tie with rung B's. Name two things C delivers that B cannot, and one thing neither can.
C delivers: (1) a graded measurement model — loadings with signs and uncertainty, including unemployment's negative one, something falsifiable and reusable; (2) a nameable weekly economic-health series (corr 0.981 with truth) whose estimation uncertainty propagates into the media posteriors instead of being silently ignored. Neither rung can remove the residual chaser bias that lives in the confounder variance the indicators don't measure — that part has no observational fix and is what an experiment buys.
Why did adding a plain linear trend cut the naive Search bias roughly in half (+778% → +399%)?
The economic factor in this world has a slow growth component, and a linear trend absorbs it — so part of the confounding is soaked up by accident. But a trend is an unprincipled de-confounder: it takes whatever slow structure exists, whether that's the confounder or genuine long-carryover media effects, and it leaves the factor's cyclical component untouched. It halved the bias here; it could just as easily eat real media value elsewhere. Name the confounder and model its measurements instead.
💡 Run it yourself
This guide mirrors
nbs/causal/causal_04_latent_confounders.ipynb
(Notebook 4 of the Causal Inference in Practice series), backed by the
economic_health world in src/mmm_framework/synth/dgp.py
and the LatentFactorMMM garden model. Every computational cell ends
in a seeded assert encoding the claim it demonstrates — if the notebook executes
clean, this page is still true. All numbers from
nbs/artifacts/causal_04_latent_recovery.json, produced by
nbs/causal/causal_04_latent_confounders.ipynb.
Previous: Causal 03 — Structural
Mediation. Next: Causal 05 —
Measuring One Experiment, where the residual floor gets its price.