The MMM Walkthrough
This is what the full analyst workflow looks like when the world is kind — realistic, messy, low signal-to-noise, but not engineered to break the model. We run the whole arc on a synthetic world with known causal ground truth: EDA before any fitting, a pre-specified model, a naive first fit that fails (with green diagnostics), a DAG-driven revision that recovers, lift-test calibration for what the data cannot identify, and a final recommendation with honest confidence labels. Every number is measured. For the same arc on a world built to be hostile, see stress 05 · The Gauntlet; for the doctrine and scorecard behind both, see Pressure Testing.
The world is mmm_framework.synth.dgp.make_realistic(seed=42): 156 weekly observations,
7 media channels (TV, Search, Social, Display, Video, Radio, Print) and 13 candidate
controls. Because the data is synthetic, every channel's true counterfactual
contribution is recorded — so each modeling iteration gets graded, not guessed at.
New to MMM entirely? Start with
Workshop 03 · Your First MMM and
Getting Started; this page assumes you know what a fit
looks like and shows you the workflow around it.
🧭 Before you start
Prerequisites: Workshop 03 or equivalent — this page assumes you know what a fit looks like. · Time: ~60–90 min.
You will learn:
- Why pre-fit EDA (CoV, VIF, the max/median screen, spend-vs-demand correlation, control classification) decides identifiability before any sampling — and why green sampler diagnostics say nothing about attribution.
- How DAG-driven control classification — not more data or tuning — turns a naive fit's +101% Search error into +7%.
- What lift-test calibration can and cannot fix (anchored coefficient SDs contract several-fold; TV's residual bias survives), and how to ship recommendations with honest confidence labels instead of bare point estimates.
Run it:
nbs/demos/mmm_walkthrough.ipynb on GitHub.
Part 1 — EDA before you model
For MMM, EDA is not mainly about the KPI. It is about two questions that decide whether the model can possibly succeed: is each channel identifiable (does its spend vary enough, independently enough, to estimate an effect?) and are the controls causally valid (which are confounders you must keep, which are post-treatment "bad controls" you must drop?). Six checks answer those.
1.1 — Look at the KPI
Orient first: scale, trend, seasonality, anomalies. These decide your trend/seasonality spec and warn of structural breaks a constant-coefficient MMM won't handle (a sharp break needs a dummy or piecewise trend, or it leaks into media — measured in stress 02). Here: mild upward trend, yearly seasonality, no breaks. Measured stats: mean 814, sd 137, range 544–1145.
Illustrative recreation (seeded) — a weekly KPI series with the same structure the notebook plots (mild trend + annual cycle + noise), anchored to the notebook's measured stats: mean 814, sd 137, min 544, max 1145 over 156 weeks. Look for: trend (yes, mild), seasonality (yes, annual), level shifts (none).
1.2 — Spend variation = identifiability
A coefficient can only be estimated if its driver varies. An always-on channel with near-constant spend is confounded with the intercept — no amount of data separates its effect from the baseline. The screen is the coefficient of variation (CoV = sd/mean), with ~0.3 as the rule-of-thumb floor. Measured here: every channel passes comfortably (Radio and Print lowest at 0.93; Search highest at 1.34) — flighting is bursty, which is good news for identification.
1.3 — Collinearity: which channels can't be separated
If two channels always move together, the data identifies their combined effect but not the split. The measured screen finds exactly one problem — and it's severe:
| Channel | VIF | Verdict (>10 is problematic) |
|---|---|---|
| TV / Search / Social | 1.1 | fine |
| Display / Video | 1.0 | fine |
| Radio | 124.7 | collinear with Print (corr = 1.00) |
| 123.7 | collinear with Radio (corr = 1.00) |
Radio and Print are bought together — a perfectly collinear pair. The data can estimate their sum, never their split. Options: model them as one grouped channel, or anchor one with an experiment (we do the latter in v3). Reporting separate point ROIs for a collinear pair is a trap — stress 03 shows the posterior "seesaw" this produces.
1.4 — Outliers and the normalization trap
The model normalizes each channel by its training max; one mega-spike compresses every normal week into a sliver of the response curve. The 30-second screen is max ÷ median. Measured here: TV 12.9×, Search 16.7×, Social 10.3×, Display 9.6×, Video 10.7×. These exceed the ~8–10× alarm line — but inspection shows they are genuine bursty flighting (the same heavy-tailed pattern that gave us healthy CoV in §1.2), not one-week data-entry spikes. The screen's job is to make you check. Contrast stress 01, where genuine 15× one-week spikes crush interval coverage to 0% until winsorized.
1.5 — Exogeneity: is spend driven by demand?
The deadliest MMM bias: a hidden demand signal drives both spend (teams bid harder when demand is high) and sales — then corr(spend, sales) is mostly demand, not media effect. Screen every channel against any demand proxy you have.
Measured — each channel's correlation with the latent demand index (solid) next to its naive correlation with sales (hatched). TV (0.53) and Search (0.48) chase demand: their sales correlations (0.57, 0.46) are mostly demand, not effect. Never read corr(spend, sales) as ROI — the second bar is the trap.
1.6 — Classify the controls: build the DAG
The single highest-leverage EDA step. For each candidate control, ask how it relates to spend and to the KPI:
| Pattern | Role | Treatment |
|---|---|---|
| correlates with both spend & KPI | confounder | must include, do NOT shrink |
| correlates with KPI, not spend | precision control | include (regularize) |
| is downstream of media | mediator / bad control | must EXCLUDE |
| correlates with neither | irrelevant | harmless; the prior shrinks it |
Measured — all 13 candidate controls placed by their measured
correlations: max |corr with any channel's spend| (x) vs |corr with KPI| (y), colored by
the world's answer-key role. category_demand and
distribution sit high on both axes — confounders, keep with a wide prior.
brand_awareness is strongly predicted by TV/Video (corr 0.56) — the tell of
a mediator (media → awareness → sales): including it blocks the path and
under-counts those channels. The noise_* cluster hugs the origin.
This classification is the model spec — the DAG in five lines:
category_demand / distribution ──► spend AND sales (confounders: keep, wide prior)
TV, Search ──────────────────────► sales (but chase demand)
TV, Video ──► brand_awareness ────► sales (MEDIATOR: exclude — bad control)
price, competitor, weather, holiday ► sales (precision: keep, regularized)
noise_1..6 ──╴(no edge)╶────────── sales (irrelevant: prior shrinks them)
Radio ≈ Print (collinear) (needs an experiment to split)
1.7 — How hard is this world? (it is not "simple synthetic data")
It's easy to make synthetic data the model nails — draw it from the model's own assumptions and recovery is near-perfect (the positive control on the scorecard recovers at ~7% median error, 100% coverage). This world is deliberately the opposite. Measured difficulty: 20 candidate factors to sort out (6 of them pure noise); media is only ~26% of total sales; the worst spend–demand confounding correlation is 0.53 (exogeneity violated); the worst channel collinearity is 1.00 (a pair the data cannot split). The structure the model must fight is in the data, not the sampler — which is why a simple recovery test tells you only that the sampler works.
Pre-modeling checklist
Before fitting anything: KPI trend/seasonality/breaks understood · every channel's CoV high enough · no |corr| > 0.9 / VIF > 10 pairs unhandled · no spend spike distorting normalization · spend not driven by demand (if it is, the demand proxy is mandatory) · every control classified (confounder / precision / mediator / irrelevant) · adstock, saturation, priors, trend/seasonality pre-specified now, before seeing results.
📋 This checklist exists as a printable one-pager — the MMM Diagnostic Checklist (EDA pre-flight + the symptom→diagnosis→action table + the fix ladder).
Part 2 — The pre-specified model and the prior gate
Question: each channel's incremental contribution, with honest uncertainty, to defend a budget reallocation. Story: the DAG from §1.6. Model (pre-specified, before any fit): additive, geometric adstock (estimated per channel), saturating response, Fourier seasonality, linear trend, positive coefficients, and per-control prior widths assigned by causal role — confounders wide (never shrunk), the rest regularized:
\[ y_t \;=\; \alpha \;+\; \mathrm{trend}_t \;+\; \mathrm{season}_t \;+\; \sum_{c} \beta_c \left(1 - e^{-\lambda_c \tilde{x}_{c,t}}\right) \;+\; \gamma^{\top} z_t \;+\; \varepsilon_t \]
where \( \tilde{x}_{c,t} \) is adstocked, max-normalized spend and \( z_t \) the retained controls. Fits use NumPyro NUTS, 1000 draws × 4 chains (as executed in the baked notebook).
The prior predictive gate: do the priors generate plausible sales before seeing the likelihood? Measured: the prior-predictive 90% KPI range is [685, 1750] against an observed range of [544, 1145] — the observed mean (814) sits comfortably inside the prior band, which is neither absurdly wide nor too tight to contain the data. Priors pass; proceed.
Iteration 1 — v1, the naive fit (failure)
A first pass without the DAG: include the obvious controls and the
tempting brand_awareness, but omit the demand confounders — their back-door
is invisible until you draw the story. The diagnostics gate
passes: r-hat = 1.021, zero post-tuning divergences, 11.4 s
wall-clock. And the answer is badly wrong.
Measured — estimated total contribution per channel (bars) against the recorded ground truth (dark ticks), for each of the three iterations. Hover for the full grade row: relative error, whether truth landed in the 90% interval, the relative interval width, and the prior→posterior learning verdict. v1: Search +101% and TV −45% with truth outside their intervals. v2: every confounded channel snaps back (Search +7%, TV −18% — though TV's truth stays just outside its interval, and its learning verdict reads relocated: the posterior moved more than a prior-sd without narrowing). v3: lift tests pin Radio/Print/TV/Search coefficients — and TV's residual bias survives (−19%, truth still outside the now-narrowed interval).
⚠️ Failure mode #1 — confident & wrong
Computational diagnostics check the sampler, not the causal structure.
v1 converged cleanly (r-hat 1.021) and would pass a posterior-predictive check — yet
Search is +101% and TV −45%, both with truth outside the 90% interval. Two causal
errors at work: omitting the demand proxies leaves the back-door open (TV, Search
inflate or distort), and including the brand_awareness mediator absorbs
part of TV's and Video's effect. This is the silent-failure mode that
the scorecard records eight times across sixteen
worlds.
Iteration 2 — v2, apply the causal structure (recovery)
Revise using the DAG: add the confounders
(category_demand, distribution) with a wide, un-shrunk prior via
CausalControlRole.CONFOUNDER; drop the mediator; keep the
precision controls. Measured result (r-hat 1.020, zero divergences, 10.2 s): the large
confounded/mediated errors collapse — TV −45% → −18%, Search
+101% → +7%, Video −33% → +9% — and every channel's truth but
TV's is inside its 90% interval (TV's sits just outside; the residual-bias thread
below picks that up). Toggle the chart above to v2 and compare.
Two confirmations that the structure is doing its job, both measured from the posterior:
the demand confounder is now the largest-credited control
(coefficient 0.308, distribution 0.191), and the six noise_* controls are
shrunk to ≈ 0 (between −0.10 and +0.11) by the regularizing prior — automatic soft
variable selection, no extra step.
Variable selection with a horseshoe prior
With many candidate controls you can replace the soft Normal regularizer with an explicit
horseshoe selection prior
(ControlSelectionConfig(method="horseshoe")) — which, by construction here,
never shrinks the confounders (shrinking a confounder re-opens the
back-door). Refit and compare:
Measured — posterior mean |coefficient| for every control under the Normal prior vs the horseshoe, grouped by true role. The horseshoe drives the irrelevant noise controls markedly closer to zero (e.g. noise_3: 0.113 → 0.022, noise_2: 0.023 → 0.003) while the confounders keep — or grow — their full coefficients (category_demand 0.308 → 0.306, distribution 0.191 → 0.217). Honest caveat: in this low-SNR world it also shrinks the genuinely-weak precision controls, which is why the framework keeps selection off by default. The horseshoe fit sampled clean (0 divergences) but slower (11.6 s vs 10.2 s) — it adds a per-control tau/lambda/c2/z hierarchy. More on this trade in Variable Selection and stress 03.
But v2 isn't done — and the model tells you about one remaining problem and hides the other:
- Radio and Print are unidentified, and the model says so: their contribution intervals are ~1.9× and ~2.2× wider than the estimates themselves — the honest signal of the collinear pair EDA flagged in §1.3. A wide interval is the model routing you to an experiment.
- TV is still ~18% low, and nothing flags it: residual confounding from a noisy demand proxy that the control only partly removes. In this run the grader's coverage column does show the miss (truth just outside the 90% interval) — but that column needs the answer key a real analyst never has; no observational diagnostic catches a noisy-proxy bias (stress 03 measures this trap in isolation: ~+35% residual bias with green checks).
Both need randomized evidence.
Iteration 3 — v3, bring in experiments
You can't fix the above with more observational modeling. The notebook calibrates with
geo-lift experiments on the channels the model flagged as unidentified
(Radio, Print) plus — as standard practice — the biggest spenders
(TV, Search), since a confident-but-wrong estimate is invisible to every
observational check. Each lift test is well-powered (SE = 12% of the true lift) and fed to
ExperimentCalibrator(...).calibrate(lifts, refit=True).
Measured — posterior SD of each channel's coefficient before (v2) and after (v3) calibration. The four anchored channels contract sharply: TV 1.05 → 0.39, Search 0.44 → 0.10, Radio 0.68 → 0.10, Print 0.70 → 0.07. The three un-anchored channels move far less (Social 0.68 → 0.57, Display 0.71 → 0.72, Video 0.67 → 0.71) — calibration is surgical, not contagious.
⚠️ The honest limit — what survives even calibration
Toggle the grade chart to v3: TV's contribution barely improves (−19%, and truth still sits outside the narrowed interval; learning verdict: weak). Pinning a coefficient is not enough when the channel is heavily confounded through a noisy proxy — the lift-to-coefficient conversion inherits the residual bias. TV needs a better demand proxy or a cleaner, TV-specific experiment; until then its number is provisional. A single lift test pins a slope; it does not launder residual confounding out of a contribution. See Measurement & Calibration for how to design the test around the estimand.
Step 9 — Decision outputs: communicate with honest uncertainty
The deliverable is never a bare point estimate. It states what we know, how well, and on what evidence — measured final table:
| Channel | True | Est (v3) | Err | In 90% CI | Evidence | Confidence |
|---|---|---|---|---|---|---|
| TV | 8948 | 7218 | −19% | ✗ | lift-anchored | PROVISIONAL (residual bias) |
| Search | 3978 | 4471 | +12% | ✓ | lift-anchored | usable |
| Social | 3948 | 4597 | +16% | ✓ | observational + DAG | usable |
| Display | 3090 | 3627 | +17% | ✓ | observational + DAG | usable |
| Video | 8286 | 8970 | +8% | ✓ | observational + DAG | usable |
| Radio | 3301 | 1789 | −46% | ✓ | lift-anchored | wide — report as a pair |
| 2097 | 2041 | −3% | ✓ | lift-anchored | wide — report as a pair |
Budget guidance (the notebook's closing line): shift toward well-identified winners (Search, Video); treat the collinear Radio/Print pair as one line item; HOLD provisional channels (TV) pending a cleaner experiment. Note Radio's −46% point error sits inside an interval ~2× the estimate — the pair's individual numbers are honestly wide, which is exactly why they're reported as one line item.
How much compute did this take?
Measured wall-clock from the baked run (156 obs, 7 channels, 1000 draws × 4 chains, NumPyro): v1 naive 11.4 s, v2 causal 10.2 s, v2 + horseshoe 11.6 s, v3 calibration refit 9.8 s — 43 s total across 4 fits. Cost drivers from the robustness study: time scales with observations and draws, super-linearly in channels (the adstock kernel sits inside every gradient step); NumPyro runs ~2× faster than the PyMC sampler at equal draws; the horseshoe's per-control hierarchy costs extra; and the refutation suite (skipped in the notebook for speed) refits the whole model ~4×.
Part 3 — Failure-mode quick reference
| Failure mode | EDA signal (Part 1) | What catches it in modeling | Recovery |
|---|---|---|---|
| Unobserved confounding | spend correlates with demand (§1.5) | nothing observational — converges & passes PPC | include the confounder (don't shrink); anchor with a lift test |
| Bad control (mediator) | a control is downstream of media (§1.6) | contribution drops vs expectation; DAG review | drop the post-treatment variable |
| Collinearity | |corr| > 0.9 / VIF > 10 (§1.3) | huge contribution CIs; sometimes divergences | group the channels, or anchor one with an experiment |
| Always-on / weak | low CoV (§1.2) | prior-dominated / wide CI | experiment, or report a wide interval honestly |
| Spend outliers | max ≫ median (§1.4) | attenuated effects; 0% CI coverage | cap/winsorize; normalize by a percentile |
| Structural break | level shift in KPI (§1.1) | poor PPC on the break window | dummy or piecewise trend (stress 02) |
| Functional-form misspec | (not visible in EDA) | wrong-shape PPC; see stress 01 | richer adstock/saturation; experiments |
Meta-lessons. (1) Modeling is iterative — the DAG drove v2, the model's own uncertainty drove v3. (2) Diagnostics check the sampler, not the truth. (3) Causal structure is the spec. (4) Let the model tell you what it can't do — wide intervals route you to the right experiment. (5) Experiments identify, but must be designed for the channel you're trying to rescue.
When the world is not kind — confounding plus mediation plus the wrong saturation form, all at once — the same arc still works, but every step gets harder and the naive fit gets much worse (ranking fully inverted, Search 8× overstated). That is stress 05 · The Gauntlet.
What to take away
1. EDA decides identifiability before any fit: CoV, VIF, the
max/median screen, spend-vs-demand correlation, and control classification each take
seconds and catch what sampling never will.
2. The naive fit was +101% wrong on Search with r-hat 1.021 — green
diagnostics are a statement about the sampler, never about attribution.
3. The single biggest improvement (Search +101% → +7%, TV −45% → −18%)
came from classifying controls — adding confounders un-shrunk, dropping the mediator —
not from more data or tuning.
4. Calibration is surgical: anchored coefficient SDs contract
several-fold (Print 0.70 → 0.07) while un-anchored channels barely move — but a lift
test pins a slope, and TV's −19% residual bias survives it.
5. The deliverable carries confidence labels: usable / wide — report
as a pair / PROVISIONAL. Never a bare point estimate.
💡 Run it yourself
This page mirrors nbs/demos/mmm_walkthrough.ipynb, generated by
nbs/builders/build_mmm_walkthrough.py and executed end-to-end against the scenario
harness in tests/synth/ (the world is
dgp.make_realistic(seed=42)). The stress-series README
(nbs/README_stress.md) positions it as the same workflow arc as the
Gauntlet capstone, run on the tamer
realistic world. Companions:
technical-docs/mmm-modeling-walkthrough.md and
technical-docs/mmm-robustness-report.md. Source:
github.com/redam94/mmm-framework.