Stress Test 05: The Gauntlet
The capstone. Guides 00–04 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):
- Latent-demand confounding — Search and Social budgets chase demand, which also drives sales (stress 03's killer);
- Mediation — TV and Display work almost entirely indirectly, by building awareness (~97–99% of their true effect routes through it; stress 04);
- Cannibalization — Cold Brew steals from Original each summer (invisible in a single-KPI view);
- Hill saturation — the true response curve is not the default logistic (stress 01's form trap).
💡 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.
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:
- When alarms fire, they are a gift — don't bribe them. Bumping
target_acceptto 0.99 until the divergences stop is treating the smoke detector, not the fire. Diagnose before re-tuning. - When alarms don't fire, you've learned almost nothing. Naive variants of this same fit were measured at zero divergences — still ~4–8× wrong on Search. Every silent failure in the 16-world matrix sampled with r-hat ≤ 1.02 and zero divergences. Convergence checks the sampler, never the attribution.
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:
- Outlier screen — clean. Every channel's spend max/median is well under the ~8× danger line, so stress 01's normalization-crushing spike failure is ruled out for free.
- Identifiability (CoV) — two red flags. TV and Display are nearly always-on (spend CoV ≈ 0.11): almost no variation to learn from. Expect prior-dominated, wide posteriors no matter what you do observationally. Flag them for experiments now, before any fit.
- The demand-chasing screen — the killer. Search's spend correlates ≈ 0.85 with sales (a dashboard would crown it) and ≈ 0.93 with the category-demand index, which itself correlates 0.86 with sales. The second number poisons the first: when spend chases demand, corr(spend, sales) measures the bidding rule, not the ad. TV pairs the lowest naive correlation (≈ 0.29) with — the grader knows — the highest true ROAS.
- Causal roles. The demand proxy is a confounder (must include, don't shrink); price is a precision control; the awareness tracker is a mediator — exclude it as a control or you destroy TV/Display's effect (stress 04). The mediator call comes from institutional knowledge — awareness is what TV is bought to do — not from a correlation screen; TV's spend is too flat for the screen to see it.
Play with the mechanism that generates the mirage — the true ROAS below never changes; only how aggressively the budget chases demand does:
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 measured | What it means | The 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:
- The interval is the result. Search's 90% interval is now roughly [0.1, 2.4] — it contains "kill the channel" and "double it". Not a number you take to a budget meeting.
- Controlled ≠ identified. Search's spend correlates ≈ 0.93 with the control just added: after conditioning, almost no independent Search variation remains. The model splits a shared signal between the beta and the control, and priors plus noise decide the split. This is stress 03's noisy-proxy lesson at full scale.
- The mediated channels barely move (TV ≈ 0.21 → ≈ 0.4 on a tiny base), still with intervals several times their estimates — the EDA's flat-spend flag stands.
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.
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:
- The calibrated channels land. Search ≈ 0.62, Social ≈ 0.48 in the seeded notebook run — truth (0.66 / 0.49) inside tight intervals. In the recorded full-fidelity arc, the same move is Search ROAS 5.59 → 0.65 against a truth of 0.66.
- The stage's median error falls to ~31% (from ~95% naive).
- The untested channels stay wrong. TV remains ~−75% under-credited; its interval never tightens. Calibrating Search and Social did nothing for TV and Display — anchoring one channel is not evidence about another.
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.
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.
| # | Symptom | Likely cause(s) | Cheap check | Pivot | Guide |
|---|---|---|---|---|---|
| 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:
- 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.
- 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.
- 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.
- 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.