Stress Test 03: Confounding & Selection

Where stress 01 and stress 02 attacked the model's functional form, this notebook attacks its causal structure — the assumptions no amount of curve flexibility can rescue. Budgets that chase latent demand inflate the chasing channel by roughly +110% while every diagnostic stays green. "We controlled for it" with a noisy proxy still leaves ~+35% residual bias. Near-perfectly collinear spend turns the channel split into a posterior seesaw. And the only thing that fully repairs a confounded channel is a number bought from outside the dataset: one lift test pulls Search from +110% to +14%. This page mirrors nbs/stress/stress_03_confounding_and_selection.ipynb.

+110%
Search overstatement under demand-chasing — every diagnostic green
+35%
residual bias after "controlling for it" with a noisy proxy
−0.5
worst pairwise posterior contribution correlation on the collinear world
+14%
Search after one ROAS lift test — calibration is surgical

Every world below comes from src/mmm_framework/synth/dgp.py: a clean core world drawn from the model's exact structural family plus exactly one causal violation, with the true counterfactual contribution of every channel recorded. The arc per act is the series template — world → naive fit → truth gap → diagnostics → pivot → measured improvement. The doctrine was set in stress 00: green diagnostics validate the computation, never the causal claim.

🧭 Before you start

Prerequisites: Workshop 03–05 or equivalent MMM experience; the Aurora series helpful.  ·  Time: ~30–40 min.

You will learn:

  • Why confounding is silent — a demand-chasing channel reports roughly double its true effect while convergence, PPC, and the RV screen all stay quiet — and why the only EDA tell is spend-vs-demand-proxy correlation.
  • Why "we controlled for it" is a spectrum, not a checkbox: a noisy proxy still leaves ~+35% residual bias, and causal roles (confounder, mediator, precision control) are spec decisions no correlation screen can make.
  • Why collinear channels and confounded channels need experiments, not tighter priors — one lift test pulls Search from +110% to +14%.

Run it: nbs/stress/stress_03_confounding_and_selection.ipynb on GitHub.

Act 1 — Demand-chasing: the #1 silent failure

The world. unobserved_confounding is the clean control world with one change: a latent demand signal (seasonal + growth + AR(1)) pushes both the baseline and the budgets of the demand-chasing channels (Search and Social). Marketers do this on purpose — bid harder when the category is hot. The model never sees the demand series; it only sees spend and sales moving together, and it pays demand's credit to the channels that chased it.

The spend-vs-demand correlations look modest in EDA — nothing a screen would scream about. The bias is large anyway, because confounding strength is the product of both paths: \( \text{bias} \propto \mathrm{corr}(\text{spend}, D) \times \gamma_{D \to \text{sales}} \) — the same demand also drives the baseline hard, so the spend-side correlation alone understates the damage.

Measured — per-channel recording from tests/synth/results/stress_matrix.md (re-recorded 2026-06-10, seeded NumPyro at matrix fidelity): Search reads +69% with the truth outside its 90% interval; the non-chasers are fine. The magnitude is fidelity- and defaults-dependent — the notebook's original seeded run logged ~+110% on Search (the series punchline), earlier matrix recordings reached +153%, and the 2026-06-10 trend-prior loosening roughly halved the bias at notebook fidelity (~+40%). What never changes is the shape: the bias lands surgically on the demand-chasers, same model, same data quality, same priors.

⚠️ What the diagnostics said

This is what makes it the #1 silent failure. The full dashboard an analyst would run — including the unobserved-confounding sensitivity screen that exists precisely to catch this — comes back clean:

r-hat (max)1.01PASS
divergences0PASS
posterior predictivepassPASS — confounding is not a fit problem
robustness values (RV)0.15–0.22 across channelsfragile channels flagged: NONE

The bias is carried by a plausible posterior, not a strained one. Exogeneity is an assumption about the world, and no within-model diagnostic can audit the world.

Doctrine: causal roles are model specification

Declaring what a variable is — confounder, mediator, or nuisance precision control — is not bookkeeping; it is part of the model spec, with consequences wired into the graph. In this framework a control marked CONFOUNDER gets the wide, un-shrunk prior (sigma=2.0) instead of the regularizing precision prior (sigma=0.5), mediators are refused outright, and selection priors never touch a confounder. Shrinking a confounder re-opens the very back-door you are closing. No correlation screen can make these calls for you — act 4's decoys correlate with everything and cause nothing.

💡 A framework fix that moved this page's numbers

The 2026-06-10 trend-prior loosening (surfaced by stress 02; linear growth sigma 0.1 → 0.5) lets the trend absorb part of the demand growth and roughly halved this world's bias at notebook fidelity (~+110% → ~+40%). It also taught the series a maintenance lesson: when a framework default changes, re-probe every notebook's seeded asserts — stress 03's margins went stale and failed on the next rebake. The asserts now carry the magnitude history in comments, and this page labels each number's recording honestly.

Act 2 — The noisy proxy: "we controlled for it" is not a fix

In real life you never observe demand itself; you observe a proxy — a category index, a search-trends panel, distribution data. The confounding_controlled twin of act 1's world adds exactly that: the same latent demand, observed as CategoryDemand = 100·demand + noise, included as a control and marked CONFOUNDER so it keeps its wide prior.

It helps a lot — and not enough. In the notebook's seeded run the worst chaser goes from ~+110% naive to ~+35% controlled, with the truth still outside its interval; the 2026-06-10 matrix recording has the same pair at +69% → +50%. The reason is mechanical: a proxy measures the confounder with error, and measurement error only partially closes the back-door. With proxy reliability \( \lambda = \sigma_D^2 / (\sigma_D^2 + \sigma_\epsilon^2) \), the residual confounding bias scales roughly with \( (1 - \lambda) \) of the naive bias. Drag the slider to see how proxy quality sets the floor:

Illustrative simulation in your browser — a seeded 156-week omitted-variable simulation (40 replicates per noise level) of regressing the KPI on spend plus a noisy proxy of the confounder; the dashed curve is the closed-form attenuation rule, residual bias ≈ (1 − reliability) × naive bias. The anchors are measured: the naive ceiling is the notebook's +110% Search overstatement, and the marked operating point is the notebook's own proxy world, which leaves ~+35% residual bias with truth outside the HDI (matrix recording: +69% → +50%, still flagged a silent failure). Nothing in the data tells you where your proxy sits on this curve.

⚠️ What the diagnostics said (proxy-controlled fit)

r-hat 1.02, 0 divergences, posterior predictive passes. The dashboard cannot distinguish "controlled" from "partially controlled" — proxy quality is part of the spec, and the data cannot measure it for you. Two spec decisions did the work that was done: including the proxy at all, and assigning it the CONFOUNDER role so it isn't shrunk. Hold the residual for act 5.

Act 3 — Collinearity: the model can't split what the buy didn't split

The world. multicollinearity keeps the clean world's deep flighting pulses — total media is just as identifiable — but drives every channel from one shared burst calendar (channels bought together, one trafficking plan; mean pairwise spend correlation > 0.85). Each channel keeps its own true effect; only the separability is gone.

The recorded result (matrix fidelity, 2026-06-10): the point estimates shuffle real credit between channels — median error 39%, worst channel +60% — while the combined-media total lands within −5% of truth and every channel's truth stays inside its (very wide) 90% interval: 100% coverage. The intervals widen honestly — in the notebook every channel's interval is wider than its own estimate.

channeltrueestrel errtruth in 90% CI
TV8,8285,498−38%
Search5,5733,660−34%
Social5,1477,252+41%
Display3,4855,580+60%

Measuredmulticollinearity per-channel table, tests/synth/results/stress_matrix.md (2026-06-10). r-hat 1.01, 0 divergences. silent failure on the split — but listen to the intervals.

The posterior seesaw: the joint posterior's own confession

Wide per-channel intervals are only half of what the posterior is telling you. The other half lives in the joint distribution: sum each channel's contribution over time per posterior draw and correlate the totals across channels. On the clean control (stress 00 §3.1, distinct flighting calendars) those correlations sit near zero. Here, every channel was bought on one calendar, and the posterior says so in its own language: every pairwise correlation is negative (worst pair ≈ −0.5) — draws where one channel takes more credit are draws where its shelf-mates take less. The split is a seesaw: conserved in total, arbitrary in allocation.

Illustrative recreation of the notebook's posterior-correlation heatmap (channel contribution totals, per draw). The measured punchlines it is built to match (seeded asserts in the notebook): on the collinear world every off-diagonal is negative — no pair above +0.05, the worst pair below −0.35 (≈ −0.5), at least three pairs below −0.20; on the clean control all pairs sit near 0. This check needs no ground truth — it is available in every real engagement.

Illustrative recreation of the seesaw drawn as draws: the worst pair's per-draw contribution totals, a cloud with correlation ≈ −0.5. The cloud's centers are the measured point estimates (Social 7,252; Display 5,580) and the diamond is the measured truth (5,147; 3,485) — inside the joint cloud, as the 100% coverage says. Conditioning on one coordinate pins the other: that is why one lift test on either member collapses the seesaw.

💡 Reading the seesaw in practice

Two uses, both available without ground truth. (1) Grouping: channels whose contribution totals correlate below ≈ −0.3 should be reported as a group until an experiment splits them. (2) Experiment design: the scatter shows that one lift test on either member pins the other by subtraction — exactly what act 5 does. The wrong move is tightening priors until the intervals look presentable: that manufactures a split the data does not contain, and whoever sets the prior sets the ROI.

Act 4 — 25 candidate controls: selection without re-opening the back-door

Acts 1–3 had one or two controls. Real briefs arrive with a spreadsheet: macro indices, weather, pricing, category trackers, competitor pulls — some causal, most not, several suspiciously co-moving with your own media. Including everything costs precision (25 coefficients on 156 weeks); dropping the wrong one re-opens act 1. Which controls you keep is a causal decision; how you regularize the keepers is a statistical one. The dense_controls world tests the machinery for splitting that difference. Its 25 candidates, each group a designed trap:

The contest. Four fits, identical except for how the controls enter:

variantcontrolscoefficient prior on controls
(a) kitchen sinkall 25role widths only (confounder wide, rest sigma=0.5) — no selection
(b) horseshoeall 25regularized horseshoe on the 24 non-confounder controls, expected_nonzero=4; confounder exempt
(c) oraclethe 3 truly-causal onlyrole widths — the benchmark you can't run in real life
(d) spike-and-slaball 25as (b) with a spike-slab selector (sensitivity row)

The roles are asserted spec, not learned (ControlVariableConfig.causal_role). The selection machinery's contract (model/base.py:_build_control_betas): confounder-role controls keep the wide un-shrunk Normal no matter what — act 2 showed what even a noisy proxy costs; a shrunk one is a half-open back-door.

Who survives selection? The shrinkage profile

Illustrative recreation of the notebook's beta_controls shrinkage profile (|posterior mean| on a log scale, grouped by true role). The measured mechanics it encodes (seeded asserts): the horseshoe shrinks the noise pile and the decoys at least harder than the plain Normal (in the run, several-fold), the exempt confounder keeps its coefficient essentially untouched (|β| > 0.3, not shrunk vs the no-selection fit), and demand_proxy is the lone survivor of the 90%-HDI-excludes-zero test. The known cost, also measured: the horseshoe over-shrinks the genuine precision controls (price/weather land well below their no-selection estimates).

Honest findings — including the one the world wasn't designed to show:

Doctrine: roles are spec; selection is statistics; keep them in that order

Classify first — the confounder gets the wide prior, mediators are excluded outright — then let a selection prior tidy the nuisance pile you couldn't classify. The same shrinkage that crushes 18 noise walks and 4 decoys would, applied to demand_proxy, reintroduce act 1's confounding bias: what it does to weather it would do to your back-door. A selector that can shrink a confounder, or a workflow that picks controls by correlation with the KPI, is act 1 with extra steps.

Act 5 — The real fix: experiment calibration

Act 2 ended with an uncomfortable residue: even with the proxy controlled, the worst channel keeps a double-digit bias, and nothing in the data can locate the floor proxy noise sets. Act 3 ended with an honest shrug. Both point the same way: the information that identifies a confounded or collinear channel is not in the observational data — you have to buy it.

A geo lift test measures a channel's incremental effect by randomization, immune to demand-chasing by design. The framework folds it into the model as an in-graph likelihood term (add_experiment_calibration, called before fit): the measured ROAS becomes a data point whose model expectation is the channel's own estimand, expressed through the same beta, saturation, and adstock parameters the time series informs. Experiment and time series negotiate in one posterior. The notebook simulates a well-powered test the way a real one works: it measures the truth with a 10% standard error.

Measured in the notebook (seeded run) — the series punchline: one ROAS lift test on Search pulls its overstatement from +110% to +14%, with the truth back inside a sharply tighter interval (the seeded assert pins the contraction at < 0.7× the naive width). The dashed line is zero error — the truth.

⚠️ Calibration is surgical, not systemic

The other demand-chasing channel (Social) — same confounding mechanism, no experiment — barely moves: the seeded assert keeps it > 20% off after calibration. One experiment repairs one identification problem. Run lift tests where the money and the failure modes live: the biggest spenders, the demand-chasers, and one member of every collinear group. The capstone (stress 05) runs the same move on the kitchen-sink world: Search ROAS 5.59 → 0.65 against a truth of 0.66 — and untested channels stay wrong there too.

What to take away

What to take away

  • Confounding is silent. Convergence, PPC, even the targeted RV screen — all quiet while a demand-chasing channel reports roughly double its true effect. Exogeneity lives in the world, not the trace; the only EDA tell is spend-vs-demand-proxy correlation, and even a modest one is enough.
  • "Controlled for" is a spectrum, not a checkbox. A noisy proxy closes most of the back-door; the residual (~+35% here) is a bias whose size the data cannot tell you. Proxy quality is part of the spec.
  • Causal roles are SPEC. Confounders get the wide un-shrunk prior, mediators are refused, precision controls may be regularized — and no correlation screen makes these calls: the decoys correlate with everything and cause nothing.
  • Selection is for the nuisance pile only. Role-exempt horseshoe matched the oracle control set; the same machinery pointed at a confounder would reintroduce act 1. Its known cost: real-but-weak controls get attenuated.
  • Collinear channels need experiments, not priors. Wide intervals around a well-identified total are the model being honest; all-negative contribution correlations are the plot that says "buy a lift test, not a tighter prior."

💡 Run it yourself

This page mirrors nbs/stress/stress_03_confounding_and_selection.ipynb (authored by nbs/builders/build_stress_03_confounding_selection.py), backed by the scenario harness in tests/synth/ — the worlds are unobserved_confounding, confounding_controlled, multicollinearity, and dense_controls, with the recorded scorecard at tests/synth/results/stress_matrix.md. Every computational cell ends in a seeded assert encoding the claim it demonstrates — if the notebook executes clean, the story on this page is still true. Source: github.com/redam94/mmm-framework. Next in the series: 04 · Extension Traps.