Stress Test 06: Geography & Hierarchy
Notebooks 00–05 stress-tested the model on national time series. Real engagements increasingly arrive as panels — KPI by geography, often by geography × product — and panels change the stress surface three ways at once. This notebook found (and fixed) a structural bug in how adstock treated stacked panel data; then it built worlds with per-geography ground truth and discovered the headline failure of the series' panel chapter: a model that is green and accurate nationally while its per-geo errors reach +330%, per-geo coverage collapses to 31%, and TV's regional ROI ranking comes out fully inverted (Spearman \(\rho = -1\)). Same posterior, same clean diagnostics — it depends entirely on the level at which you grade it.
What a panel adds, and what can go wrong with each addition:
| What a panel adds | What can go wrong | |
|---|---|---|
| Mechanics | Observations are stacked (week, geo[, product]) cells | Any transform that treats the observation axis as time silently mixes geographies — Act 0, a real bug found and fixed by this notebook's build |
| Resolution | Stakeholders read regional ROI, not just national | The hierarchy gives each geo an intercept offset while every response parameter is global — per-geo readouts can fail while the national view stays green (Act 3) |
| Identification | Cross-sectional replication: every week observed in every geo | Aggregate it away and you pay in interval width and drifting estimates (Act 2) |
🧭 Before you start
Prerequisites: Workshop 03–05 or equivalent MMM experience; the Aurora series helpful. · Time: ~30–40 min.
You will learn:
- Why stacked panel observations are not a time axis — and how a five-minute impulse test caught a cross-geo adstock bleed that years of smoke tests missed.
- Why a model can be green and accurate nationally (5% median error) while its per-geo readouts reach +330% error and TV's regional ROI ranking comes out perfectly inverted — grade at the level the decisions are made.
- What an intercept-only hierarchy pools and what it cannot: level-shift vs effectiveness heterogeneity, the split-fit check before regional tables drive money, and why aggregating geo data away roughly doubles interval widths.
Run it:
nbs/stress/stress_06_geography_and_hierarchy.ipynb on GitHub.
Act 0 — The bug the panel surfaced: carryover across the geo boundary
A panel model receives its observations as one stacked vector.
The MFF loader orders them period-major:
(week 1, North), (week 1, South), …, (week 2, North), …
Adstock is a convolution over time:
\[ a_t \;=\; \sum_{\ell=0}^{L-1} w_\ell \, x_{t-\ell} \]
where \(t\) must index weeks within one geography. Before this notebook's build, both adstock paths — the parametric in-graph convolution and the legacy two-point blend — convolved straight down the stacked observation vector. On a geo panel, an observation's "lag 1" was therefore a different geography at the same week: TV aired in the North adstocked into the South's Tuesday. A geography with zero spend in every week still received media contributions.
Nothing crashed. Sampling converged. The model simply estimated kernels and contributions for a physically meaningless quantity — the series' definition of a silent failure, this time structural rather than statistical. The notebook demonstrates it with the simplest possible probe: one impulse of TV spend in the North at week 5, zero everywhere else.
Faithful pure-JS recreation of the notebook's pure-NumPy demonstration (geometric kernel \(w_\ell \propto 0.6^\ell\), \(L=8\), 16 weeks, two geos, impulse at week 5 in the North) — same arithmetic, computed live in your browser. Pre-fix, the stacked convolution hands the South 38% of the total carryover weight — a geography that never spent a dollar — and chops the North's tail in half. Post-fix the South is exactly 0. The notebook then runs the same impulse through the shipped model: across 50 prior draws, the dark geo's largest absolute contribution is 0.000000 (measured in the notebook, seeded run).
🔧 Framework fix surfaced by this notebook
Before: both adstock paths (parametric in-graph and the legacy
two-point blend) convolved the stacked period-major observation vector, bleeding
carryover across geographies on any geo or geo×product panel.
After (fixed 2026-06-10): per-cell convolution — observations
are scattered into a (weeks × cells) matrix, convolved along time only, and
gathered back (transforms/adstock_pt.py::apply_adstock_panel_pt
plus a per-cell legacy path). National 1-D model graphs are
bit-identical (the 1-D path is untouched when
n_cells == 1). Regression-tested by impulse isolation in
tests/test_panel_adstock.py — geometric, delayed, Weibull, the
legacy path, and geo×product.
⚠️ Why years of tests never caught it
The smoke tests that existed fit geo panels on random data and asserted shapes; random data has no story to contradict, so the bleed survived until a world with known per-geo truth demanded recovery. The audit is a five-minute impulse test: one unit of spend in one cell, zero everywhere else — anything nonzero outside that cell is a structural bug no convergence diagnostic will ever flag. Positive controls are bug detectors, not formalities.
The panel worlds
Worlds come from src/mmm_framework/synth/dgp_geo.py — balanced panels with the
same estimand discipline as the rest of the series (see the
hub page), except the counterfactual ground
truth is recorded per geography / per cell
(true_contribution_by_geo), so the model can be graded at the level
the budget decisions actually happen. Helpers national_scenario()
(the aggregated view) and geo_scenario(g) (per-geo slices) power
Acts 2 and the split-fit pivot.
| World | Design | Role |
|---|---|---|
geo_clean |
4 geographies × 130 weeks (520 obs). Shared response parameters; geos differ in baseline level and budget scale only — exactly the model's intercept hierarchy. | panel positive control |
geo_heterogeneous |
Same skeleton, but channel effectiveness differs by geography (multipliers 0.3–1.8×) and budgets chase performance the way real regional allocation does. | outside the global-beta hypothesis space |
geo_product |
3 geographies × 2 products × 104 weeks (624 obs). Additive geo + product offsets; product lines tilt their channel mix. | cross-classified positive control |
Act 1 — The panel positive control: four geographies, one truth each
geo_clean is the model's exact hypothesis space, panel edition: four
geographies share every response parameter (betas, adstock, saturation, trend,
seasonality) and differ in baseline offset and market size. Each geo flights on
its own calendar, so the panel carries genuine cross-sectional variation. If the
panel machinery is sound, recovery should be clean at the national level
and inside every geography — and it is:
The notebook also slices the stress 00 uncertainty lenses by geography: per-geo posterior-predictive fit bands (the national R² of a panel fit can hide a geo it systematically misses) and per-geo contribution-path bands graded against each geo's true weekly path — the truth sits inside the 90% band in every spot-checked geo×channel pair. Inside a representable world, the per-geo bands are honest.
💡 Why this act matters
Every later failure on this page is the broken assumption, not the panel plumbing. That claim is only available because the positive control passed at both levels — and, per Act 0, it is also how the adstock bug was found: the first world with per-geo truth contradicted the bleed.
Act 2 — What aggregation throws away
Most teams have geo-level data and model nationally anyway. The notebook
sums the geo_clean world across its four geographies — same weeks,
same total spend, same causal truth — and refits. Two structural things are lost:
replication (520 observations become 130; four independent
flighting calendars blur into one smoother national curve) and
the curve itself — each geo responds at its own saturation
operating point, and a sum of four saturation curves evaluated at four different
points is not a saturation curve of the summed spend (Jensen's
inequality). The aggregated world is, strictly, outside the model's family even
though every geo individually was inside it.
Measured in the notebook (seeded run) — 90% interval width ratio, national fit ÷ panel fit, per channel: TV 1.06×, Search 2.53×, Social 2.33×, Display 1.97× — about 2.1× wider on the median channel. The aggregate fit stays green (median error 8%, full coverage), but its worst-channel point error is 21% vs the panel's 9%.
Aggregation here doesn't break the model — coverage survives because the intervals widen to admit what was lost — but you pay twice: materially wider intervals on most channels, and the largest point errors drifting to the channels whose geo-level operating points differed most. If geo data exists, the panel fit is not a luxury; it is the cheaper way to buy precision you would otherwise need more years of history for. And note the quiet doctrine point: both fits are green; only the known truth reveals which numbers drifted.
Act 3 — The regional ROI trap: one beta, four markets
geo_heterogeneous keeps Act 1's skeleton and changes what the model
cannot see: channel effectiveness now differs by geography —
multipliers from 0.3× to 1.8× on the shared response. TV is a powerhouse in the
North (1.8×) and nearly dead in the West (0.3×); Search inverts (0.6× North,
1.7× West). And budgets chase performance the way real regional
allocation does: geos where a channel works get a larger share of its budget, so
spend and response heterogeneity are coupled by construction.
In the default pooled fit the hierarchy is an intercept per geo and
one global beta per channel, so per-geo effectiveness falls outside
that fit's hypothesis space — the pooled beta lands near a spend-weighted
average, and every geo's readout inherits it. It is not structurally
impossible: the opt-in HierarchicalConfig.vary_media_by_geo=True
partial-pools each channel's beta across geos (_build_channel_betas_geo),
the in-model version of the per-geo-betas-with-shrinkage fix this act motivates.
Read that strip again: nationally this model is excellent — small errors, full coverage, clean sampler. An analyst grading it nationally would ship it. Now grade the same posterior at the level the budget meeting actually uses. The centerpiece: regional ROI ranking, the one-number summary regional teams are judged on.
Measured in the notebook (seeded run) — TV's per-geo ROI ranking, true vs estimated under the pooled fit: Spearman \(\rho = -1.0\), a perfect inversion. True TV ROI (from the DGP): North 0.25, East 0.16, South 0.15, West 0.05 per unit spend. The model ranks them exactly backwards. Across channels (measured): TV \(\rho = -1.0\), Search \(+0.8\), Social \(-0.8\), Display \(0.0\) — the channel with the widest true heterogeneity gets the most confidently wrong regional ranking.
Illustrative reconstruction in your browser of the pooled-beta mechanism: each geo's error is the spend-weighted pooled effectiveness divided by its own true multiplier, minus one — computed live from the DGP's published multipliers and budget rule. The notebook's measured anchors match this pattern: per-geo median |error| 25%, per-geo coverage 31%, worst cell West/TV at +330%, and corr(true multiplier, per-geo error) of −0.87 to −0.97 across all four channels. National totals (black diamonds) stay essentially exact throughout.
Why the sign is the worst possible sign. The pooled beta is a compromise: above the truth in weak geos, below it in strong ones. So the per-geo table over-credits exactly the markets where the channel works least — a budget optimizer reading it would move money toward the geos the channel is failing in and away from where it prints. The readout isn't merely noisy; it argues for the value-destroying reallocation. The notebook's contribution-path band makes it visceral: in the West (true TV multiplier 0.3×), the pooled 90% band floats entirely above the true path — truth below the band 100% of weeks (measured).
The pivot: grade at the decision level, then split per geography
Two moves, both measured. First, grade at the decision level: the
national table is an average over the readouts people actually use. If regional
tables drive money, the per-geo grade is the grade. Second, the structural pivot
available today: by default the framework pools intercepts only
(beta_<ch> is shared per channel), but enabling
vary_media_by_geo (off by default) gives each channel a partial-pooled
per-geo coefficient with shrinkage in a single fit. The other option — the one this
notebook demonstrates — is to refit per geography: four small models,
each free to find its own beta, at the price of 4× fewer observations per fit.
Measured in the notebook (seeded run) — pooled panel fit vs four split per-geo refits, graded against the same per-geo truth: median |error| 25% → 13%, 90% coverage 31% → 88%, worst cell +330% → 44%. TV's regional ROI ranking goes from \(\rho = -1.0\) to \(\rho = +0.8\). Four small honest models beat one large confidently-wrong one — for the regional question.
💡 The tells, in order of availability
(1) Design knowledge — if budgets are allocated on
performance, heterogeneity is coupled to spend by construction; assume you are
in Act 3's world, not Act 1's. (2) The split-sample check —
refit per geo (or region group) and compare against the pooled per-geo table,
exactly as above. (3) Per-geo lift tests where the money
justifies them (see stress 05 on
calibration). The durable fix is hierarchical response pooling —
per-geo betas with shrinkage — which the framework offers as an opt-in setting:
HierarchicalConfig.vary_media_by_geo=True (with geo data) replaces the
single shared channel beta with a non-centered partial-pooled per-geo effectiveness
hierarchy that shrinks toward the population mean (media_geo_sigma
controls between-geo spread). It is off by default and bypassed when a channel has
an explicit/calibrated prior; this act is the design case that motivates turning it
on. The per-geo uncertainty
lenses (fit bands, contribution-path bands vs truth) are what made the failure
visible at a glance.
Heterogeneity playground: how much spread breaks the regional readout?
The mechanism is general, so you can drive it yourself. The simulation below pools one global beta across four geos whose true effectiveness spreads around 1× by the slider amount (at full spread it reproduces the TV pattern of the notebook's world: 1.8× / 0.9× / 1.0× / 0.3×). Toggle whether budgets chase performance. Watch the per-geo readout errors grow with spread — while the national total stays accurate at every setting, because the pooled beta is precisely the spend-weighted compromise that gets the national sum right.
Illustrative simulation in your browser — pooled-beta arithmetic on four geos (market sizes 1.30 / 0.70 / 1.00 / 0.55, the DGP's), no sampling. Two things to notice: the weakest geo's over-credit explodes as spread grows (at full spread with chasing budgets it reaches roughly +340%, the same order as the notebook's measured +330% for West/TV); and performance-chasing budgets make it worse — they pull the pooled beta toward the strong geos, pushing the weak-geo readout further from its truth. The national bar never moves.
Act 4 — Geography × product: six cells, end to end
geo_product is the positive control at the full cross-classification:
3 geographies × 2 products, two years weekly (624 observations). Baseline level is
geo offset plus product offset — exactly the model's additive geo +
product hierarchy — response parameters are shared, and each product line flights
independently with its own channel tilt (Core is TV-heavy, Premium is
Search/Social-heavy). Truth is recorded per cell.
Measured in the notebook (seeded run) — posterior correlation of channel totals on the geo×product fit (the stress 00 separability lens, panel edition): every pairwise correlation sits within ±0.08. Six independent cell calendars keep the channels cleanly separable — contrast the collinear seesaw in stress 03, where every pairwise correlation is negative (min ≈ −0.5).
The closing scorecard line, asserted in the notebook: the panel machinery is sound; the regional claim is only as good as the pooling assumption behind it. Acts 1 and 4 recover per cell because their worlds match the intercept-only hierarchy; Act 3 fails regionally because its world doesn't — and nothing in the data volunteers which world you are in.
What to take away
What to take away
- Impulse-test every time-structured transform on panel data. Stacked observations are not a time axis. The cross-geo adstock bleed survived years of shape-checking smoke tests and fell to a five-minute impulse test the first time a world had per-geo truth to contradict it.
- Grade panels at the level the decisions are made. Act 3's model was excellent nationally (5% median error, full coverage) and inverted regionally (+330% worst cell, \(\rho = -1\) on TV's ROI ranking) — same posterior, same green diagnostics.
- Know what your hierarchy pools. By default this framework
pools geo/product intercepts and shares one response parameter per
channel across geos — right for level-shift heterogeneity and wrong for
effectiveness heterogeneity (and performance-chased budgets make the wrong case
the expected case). When regional effectiveness varies, enable
vary_media_by_geo, which partial-pools each channel's beta across geos (log-scale non-centered hierarchy, controlled bymedia_geo_sigma), so per-geo ROI is estimable instead of collapsing to a spend-weighted average. - The heterogeneity bias points the worst possible way. The pooled beta over-credits channels exactly where they are weakest. If regional tables drive money, run the split-fit check (25% → 13% median error, 31% → 88% coverage here) before anyone reads them.
- Geo data buys identification — don't aggregate it away. Same world, same weeks: aggregating to national roughly doubled interval widths (≈2.1× on the median channel). Cross-sectional replication is the cheapest sample size you will ever get.
💡 Run it yourself
This guide mirrors nbs/stress/stress_06_geography_and_hierarchy.ipynb
(authored by nbs/builders/build_stress_06_geo_hierarchy.py), backed by the
panel scenario harness in src/mmm_framework/synth/dgp_geo.py and the adstock
isolation regression tests in tests/test_panel_adstock.py. 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.