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.

+778%
Search's over-credit when the confounder is ignored — ~9× its true contribution (Display, a real channel, crushed −81%)
+172%
Search's residual bias with all four indicators as controls — a big win, but attenuated bias survives, unannounced
0.981
correlation between the joint model's recovered factor and true economic health
4 / 4
loading signs recovered — including unemployment's planted negative loading

🧭 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:

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:

  1. 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.
  2. A nameable series to monitor. C produces "economic health, weekly, with uncertainty." B produces nothing reusable.
  3. 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.
  4. 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.