Stress Test 05: The Gauntlet

The capstone. Guides 0004 broke the model's assumptions one at a time; this one breaks them all at once and walks the entire analyst workflow — first data pull → EDA → naive fit → triage → structural pivots → an experiment → a budget answer you could defend. The naive fit's channel ranking comes out fully inverted, with Search roughly 8× overstated. The recorded recovery arc ends at Search ROAS 5.59 → 0.65, against a truth of 0.66 — and the untested channels stay wrong, because calibration is surgical, not contagious.

🧭 Before you start

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

You will learn:

  • How to run the full analyst workflow — EDA pre-flight, naive fit, symptom-driven triage, structural pivots, experiment — on a world with four simultaneous violations and a fully inverted naive ranking.
  • Why fit quality is not attribution: four specs with R² within 0.011 of each other span truth-gaps from 95% to 31%, and interval width is the one analyst-visible signal that tracks the gap.
  • Why experiments end arguments exactly where they're aimed — Search 5.59 → 0.65 against a truth of 0.66 — while the untested channels stay wrong, so the honest deliverable is a costed test plan.

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

The world: everything broken at once

aurora_kitchen_sink (from src/mmm_framework/synth/dgp.py) is Aurora Coffee Co.: two years of weekly data, four channels — and four simultaneous violations, fit demand-blind (the demand proxy is the column nobody exported):

  1. Latent-demand confounding — Search and Social budgets chase demand, which also drives sales (stress 03's killer);
  2. Mediation — TV and Display work almost entirely indirectly, by building awareness (~97–99% of their true effect routes through it; stress 04);
  3. Cannibalization — Cold Brew steals from Original each summer (invisible in a single-KPI view);
  4. Hill saturation — the true response curve is not the default logistic (stress 01's form trap).
92%
median |contribution error|, naive fit (recorded matrix row)
+223%
worst channel (Search); TV −92%
25%
90%-interval coverage of the truth
11
divergences — the only world in the matrix with any

💡 Two voices: the analyst and the grader

Because the world is synthetic, we know the truth; a real analyst never does. The notebook (and this page) speak in two voices: ordinary prose is the analyst, restricted to observables — data, diagnostics, intervals, spec sensitivity, experiment readouts. Boxes marked 🔭 grader are synthetic-world omniscience, scoring each stage against the known truth. Every pivot below is justified by analyst-visible evidence before the grader speaks — because in the field, the grader's box does not exist.

🔭 GRADER — ground truth (the analyst never sees this)

True ROAS: TV 2.14, Display ≈ 2.1 (the "cheap-looking" brand channels are the strongest, ~97–99% mediated through awareness) — Search 0.66, Social 0.49 (the "direct-response stars" are the weakest). Everything below is the story of how close an analyst armed only with observables can get to this table.

The naive fit: a ranking turned upside down

First, what the deadline ships: the default model on the data as pulled — KPI, four spend columns, a price control. No demand proxy, default adstock, default logistic saturation. It converges. It fits beautifully (R² ≈ 0.92 — hold that thought for the fit-quality trap). And its attribution is the truth, almost exactly reversed:

Measured — recorded matrix row for aurora_kitchen_sink (tests/synth/results/stress_matrix.md, PyMC 500×500×2, seed 0, re-recorded 2026-06-10). The naive fit hands TV's contribution to Search: TV 13,053 → 1,057 (−92%), Search 2,193 → 7,091 (+223%), Display 6,037 → 1,718 (−72%), Social 1,508 → 2,899 (+92%). Coverage: 1 channel in 4. The estimated ROAS ranking equals the naive corr(spend, sales) ranking — [Search, Social, Display, TV] — the dashboard mirage with credible intervals on it.

Reading the alarms — what "loud" actually looks like

This is the one world in the matrix with any divergences — and even here the alarm volume is unreliable. An earlier recording of this same row (code state 2026-06-08) was genuinely loud: r-hat 1.11, 108 divergences. Re-recorded on later code, same config and seed: r-hat 1.02, 11 divergences. The notebook's fast NumPyro fit is quieter still. Same model, same data, same ~90% wrongness every time; the siren did not survive a code revision. Two lessons the capstone insists on:

Decision point: whichever volume you got — a converged-looking fit that mirrors a correlation table you already know is confounded, or a noisy fit with warnings — you do not ship. You work the recovery ladder. Each step below is measured.

Step 1 · The EDA pre-flight — half the failures are visible before sampling

The pre-flight takes minutes and no MCMC. On this world it flags, before any model exists:

Play with the mechanism that generates the mirage — the true ROAS below never changes; only how aggressively the budget chases demand does:

chase = 0.80

Illustrative simulation in your browser (seeded PRNG) — not notebook output. A channel with a fixed true ROAS of 0.66 whose budget chases demand: as chasing strength rises, corr(spend, sales) climbs toward 0.9 while the ad's true effect stays unchanged. A naive correlation far above any plausible ROAS is a confounding tell, not a performance read. The measured world's numbers: corr(Search spend, sales) ≈ 0.85, corr(Search spend, proxy) ≈ 0.93.

Step 2 · Triage — which symptoms point where

Don't re-tune; diagnose, with the toolkit from stress 00. On the naive fit, each instrument reads (all analyst-visible, measured in the seeded notebook run):

Symptom measuredWhat it meansThe suspect here
beta_Search: negative contraction (posterior wider than the prior) + large upward shift Prior-data conflict: the data demands an effect the prior thinks absurd Demand confounding inflating Search — the EDA's corr(spend, proxy) ≈ 0.93
Adstock / shape parameters prior-dominated Response shape unidentified — equifinality Flat TV/Display spend, plus form misspec stacked on confounding
Confounding robustness value (RV) flags nothing; Search's RV is the highest RV measures how strong a confounder would have to be — it cannot know one exists A confounder of exactly the required strength does exist (proxy–sales corr 0.86). RV is no green light
PPC passes PPC certifies fit to the outcome, and demand is doing the predicting The model predicts sales beautifully — through the confounder
Residuals correlate +0.246 with the unused demand proxy A column the model never saw predicts the model's errors Omitted-variable structure routed into the media betas — the proxy belongs in the model

Triage verdict: two independent observable fingers — the prior-data conflict on the demand-chasing channel, and demand structure in the residuals — point at the same omitted variable. The first pivot is structural, and the EDA already named it: close the back-door.

Step 3 · Structural pivots, each one graded

Pivot (a): add the demand control — it helps, and it isn't enough

Refit with the category-demand index as a control. Measured (seeded NumPyro run, 400×400×2): Search's ROAS collapses 2.6 → 1.0 (−62% from one defensible spec change), and the stage's median contribution error falls ~95% → ~65%. But read what came with the new midpoint:

🔭 GRADER

The pivot went the right way and stopped well short: Search keeps a ~+50% contribution overstatement (truth inside the huge interval, far from the midpoint); TV and Display stay ~65–85% under-credited — the mediated channels did not move toward the truth at all.

Pivot (b): the functional-form pivot — it reshuffles, it doesn't rescue

The stress-01 discipline: refit under a deliberately different, defensible response family — in-graph parametric adstock plus per-channel Hill saturation, \( \beta\,\dfrac{x^{s}}{x^{s}+K^{s}} \) in place of the default \( \beta\,(1-e^{-\lambda x}) \) — and read it as a spec-sensitivity experiment. Measured: the form change moves every channel's estimate by ≤ 10%, while the demand control had moved Search by 62%. Nothing observable (fit, intervals, diagnostics) prefers either form. Conclusion the analyst can defend without truth access: the response form is not the binding constraint — identification is choosing these numbers.

🔭 GRADER

The punchline the analyst never gets to enjoy: pivot (b) handed the model the generative truth's exact response family on both axes — and the truth-gap got slightly worse (median error ~65% → ~71%). Under unresolved confounding, form pivots reshuffle the bias among equifinal parameterizations (carryover ↔ saturation ↔ β trade off) rather than remove it. Forms matter when form is the problem — on stress 01's Hill-truth world the same pivot cut total-media bias from ≈+40% to ≈+15%. Here it buys knobs the data cannot turn.

🔧 Framework fix surfaced by this series (2026-06-10)

The Hill pivot used here only exists because stress 01 discovered that per-channel SaturationConfig was silently ignored by the core model. Before: every channel got the logistic curve regardless of configuration. After: BayesianMMM honors hill / logistic / michaelis-menten / tanh / none in-graph (default unchanged, so existing models are bit-identical). The gauntlet then demonstrates the sharper lesson: even the true form can't fix a confounded world.

Pivot (c): acknowledge the mediation — and its limits

TV and Display still print sub-1 ROAS midpoints inside intervals several times that size. The structural case needs no truth: the DAG routes their effect through awareness — a pathway this single-equation model can only smear into the baseline — and their spend is nearly flat (CoV ≈ 0.11). The right structural model is a nested/mediation MMM with the awareness tracker as mediator outcome — that is stress 04's subject, including the trap that an extension adds unidentifiable knobs when mediator data is weak. And even a perfect mediation model cannot conjure identification out of a CoV-0.11 spend series.

Decision point: the observational program has hit its identification ceiling — the demand-chasers are controlled-but-collinear, the brand channels are mediated-and-flat. More observational modeling from here is knob-turning. The next dollar of effort buys more as a randomized experiment than as a fifth structural variant.

Step 4 · The experiment — surgical, not contagious

Aurora runs geo-holdout lift tests on the two cheapest-to-test channels — Search and Social (always-on auction media; a clean two-month holdout reads out fast). Each readout (Search ROAS 0.66 ± 0.07, Social 0.49 ± 0.07 over the test window) is folded in as an in-graph likelihood term on the model-implied ROAS estimand (experiments= / add_experiment_calibration) and the model refit. Measured:

Measured in the notebook (seeded run) — relative interval width (90% HDI width ÷ estimate) after calibration: Search 0.40, Social 0.68 — versus TV 2.10 and Display 2.32, which never drop below ~2.1 at any stage. Search's width was 1.64 one stage earlier: the experiment, not the spec work, is what tightened it. Above the dashed line, the interval spans "kill the channel" to "double it". Interval width is the one analyst-visible feature that tracks the truth-gap — believe the width, not the midpoint.

The ROAS ladder — the whole drama in one chart

Per-channel ROAS estimates at each workflow stage, with the grader's truth as a dashed line. Step through the stages, or see the whole trajectory at once:

Measured anchors from the seeded notebook run (NumPyro 400×400×2, seed 0; build-script probe 2026-06-10): Search 2.6 → 1.0 → ~1.0 → 0.62 (true 0.66); TV 0.21 → ~0.4 → ~0.4 → 0.55 (true 2.14); Social ends at 0.48 (true 0.49); stage median errors 95% → 65% → 71% → 31%. Cells without a recorded point value (Social and Display at intermediate stages, the form-pivot column) are placed inside the probe's recorded constraints — the form change moves every channel ≤ 10%, the mediated channels barely move at any stage — and should be read qualitatively. The story to read: Search is the skyscraper two structural pivots only half-demolish and one experiment levels; TV and Display never reach their dashed lines, because no observational pivot — and no calibration of other channels — identifies a flat-spend, ~98%-mediated channel.

🔭 GRADER — the stage scorecard

Median |contribution error| by stage: ~95% (naive) → ~65% (+demand control) → ~71% (+true form — worse) → ~31% (+experiments). The control helped; the form pivot reshuffled; the experiment was surgical. TV after everything: still ~−75% — and the analyst's "not identified, do not quote a point" verdict is the correct call. TV's true ROAS is ~2.1: the Stage-1 sentence "TV doesn't work — cut it" would have severed the company's best channel on the word of an unidentified posterior.

The fit-quality trap — four excellent fits, four different stories

If interval width is the analyst-visible signal that works, here is the popular one that doesn't: how well the model traces the sales curve. All four stages — from the demand-blind disaster to the calibrated finale — produce fits a slide deck would celebrate:

Measured in the notebook (seeded run) — R² by stage: 0.918, 0.930, 0.929, 0.927 (total spread 0.011); 90% predictive-band coverage 0.91–0.92 at every stage — while the truth-gap runs 95% → 65% → 71% → 31%. The line you watch in a readout meeting is flat; the number that matters falls by two-thirds. Fit quality and attribution identifiability are different axes: a predictive check cannot choose between attribution stories that all predict the same curve.

Where did the credit actually move? The decomposition (additive by construction) tells it: the naive model's media wedge is 16% of sales with Search alone at 9.8%; the final model's is 11% with Search at 2.4% — the weekly demand swings the naive model credited to Search's beta now flow through the demand control (std of the controls component 14.6 → 23.2; std of Search's media component 18.7 → 10.5). And the residual-vs-proxy correlation falls +0.246 → +0.007. The grader adds the sting: the true media share is 26% — even the calibrated final model under-credits total media, because TV/Display's mediated effect is parked in the baseline, invisible to this equation.

The decision table — the series in one artifact

Symptom → likely cause → cheapest check → pivot → where it's demonstrated. This is the table to copy out of the whole series. Filter by symptom category:

📋 This table ships as a printable one-pager — the MMM Diagnostic Checklist, with the EDA pre-flight and the fix ladder on the same sheet.

#SymptomLikely cause(s)Cheap checkPivotGuide
1 sampler Divergences / r-hat > 1.01 Model fighting the data (misspec, conflict-laden priors) — not merely a tuning problem Where do divergent draws sit? Does the conflict survive target_accept 0.95? Triage parameters (learning, pair plots) before re-tuning; fix the structure the conflict points at 00 · 05
2 ident One channel's HDI ≫ its estimate Weak/flat spend (low CoV) — intercept-confounded CoV < ~0.3? Report the interval, or buy an experiment; no model rescues a flat series 00
3 ident Two channels' HDIs huge and their spends correlated Collinearity — the combined effect is identified, the split isn't Spend corr > ~0.9 / VIF > 10 Group them, or lift-test one channel to anchor the split 03
4 fit PPC fails (shape / autocorrelation) Missing time structure: trend break, seasonality, carryover misspec Plot residuals over time and by season Richer trend / seasonality / adstock — then re-check 02 · 01
5 fit PPC passes — it certifies fit, not attribution (none — that's the point) Never read a green PPC as causal validation 00
6 conf Confounding robustness value (RV) low on a channel Estimate would be nullified by a mild confounder Does a candidate confounder that strong exist? Find/keep the proxy control; calibrate 03
7 conf RV high but a plausible confounder is known RV measures required strength, not existence corr(spend, proxy), corr(proxy, KPI) Control the proxy regardless; RV is no green light 05 (Search)
8 ident Parameter prior-dominated (contraction ≈ 0, overlap ≈ 1) The data carries no information (flat, collinear, short window) CoV, spend correlations, window length Experiment, or own the prior in the writeup 00
9 conf Negative contraction + big shift_z Prior-data conflict — often confounding demanding an absurd effect corr(spend, demand proxy) Close the back-door; if it persists, calibrate 03 · 05
10 med Estimate piled on the sign constraint (β ≈ 0 under a positive prior) True effect negative (cannibalization) or mediated-away Cross-product / holdout correlations; mediator inventory Allow the sign / model the cross-effect or mediation explicitly 04
11 data Spend max/median ≫ ~8 Data-entry spike or one-off burst → normalization trap The 30-second max/median screen Verify, winsorize/cap, percentile-normalize 01
12 time KPI level break Regime change a constant-coefficient MMM can't absorb Plot the KPI; ask the business what happened Dummy / piecewise trend; never let media absorb a break 02
13 time Holiday-shaped residuals Seasonality misspecification Residuals by week-of-year Holiday terms / higher Fourier order 02
14 conf A ROAS that would make the channel the best investment the company owns Demand confounding — a mirage by construction corr(spend, demand proxy); benchmark sanity Treat as confounded until an experiment says otherwise 03 · 05
15 conf Naive corr(spend, KPI) ≫ any plausible ROAS Spend chases demand — the dashboard mirage The Step-1 mirage table The proxy control is mandatory; calibrate the channel 05 (Step 1)

Compiled from the notebook's closing artifact. Bold 05 entries are demonstrated live on this page's world; linked guides carry the full treatment of each row.

The fix ladder — doctrine recap

Identification problems are solved by design, not by sampling longer. Every recovery in this series, ordered by where the leverage actually was:

  1. Estimand first. Pin down what "contribution" means (counterfactual zero-out) before reading any posterior. If you can't say what number would be wrong, no diagnostic can save you.
  2. EDA pre-flight. The mirage table, the CoV screen, the max/median outlier screen, the causal-role DAG — minutes of work that flagged every failure this world contains before the first sample was drawn.
  3. Structural pivots — chosen from symptoms, never from disliked answers. The demand control moved the headline channel 62% (it helps; it doesn't identify). The functional-form pivot moved everything ≤ 10% and made the truth-gap slightly worse — forms matter only when form is the problem.
  4. Experiments calibrate what structure can't. Search 5.59 → 0.65 against a truth of 0.66 — surgical. And not contagious: the untested channels stayed wrong, which is precisely why the deliverable ends with a costed test plan, not a full table of midpoints.

The pre-specification discipline that survives this notebook: lock the spec (causal roles, forms, priors, which channels get calibrated) before looking at ROAS; pivot only on measured diagnostics; and always escalate to an experiment for flat-spend channels (CoV ≲ 0.3), demand-tracking channels, collinear pairs that matter, and any channel about to receive a large budget move.

What to take away

  • EDA caught it first. The mirage table (corr 0.85 with sales, 0.93 with demand), the CoV screen, and the DAG flagged every failure this world contains — before sampling. The cheapest diagnostics run before the model exists.
  • Alarm volume is not failure size. The same world recorded 108 divergences on one code state and 11 on the next, ~90% wrong both times. Loud alarms mean stop; quiet ones certify nothing.
  • Fit quality is not attribution. Four specs, R² within 0.011 of each other, truth-gaps spanning 95% → 31%. Believe the interval width — the one analyst-visible signal that tracked the truth-gap — never the midpoint or the R².
  • Controlled ≠ identified, and the true form can't fix a confounded world. The demand control left a +50% overstatement inside a [0.1, 2.4] interval; handing the model the exact generative response family made the truth-gap slightly worse.
  • Experiments end arguments exactly where they're aimed. Search 5.59 → 0.65 (truth 0.66); TV and Display didn't tighten at all. The honest deliverable is experiment-backed numbers, wide intervals owned as wide — "not identified" said out loud — and a costed test plan for the rest.

💡 Run it yourself

This guide mirrors nbs/stress/stress_05_the_gauntlet.ipynb (authored by nbs/builders/build_stress_05_gauntlet.py), built on the scenario harness in tests/synth/ — the aurora_kitchen_sink world with known per-channel truth. 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. Companions: stress 00 (the diagnostic toolkit), stress 03 (confounding in isolation), stress 04 (the mediation ladder), and the series scorecard. Source: github.com/redam94/mmm-framework.