Aurora 02: The Base MMM

Chapter 2 of the Aurora Coffee Co. story. The causal groundwork is laid (Chapter 1 drew the DAG and identified demand as the back-door to close), so now we fit mmm_framework's core Bayesian MMM and read it the way an analyst would: channel contributions and ROAS with honest uncertainty, where the next dollar goes, and a what-if. Because Aurora is a synthetic world with a known ground truth, every claim gets graded — including the ones the base model gets wrong. It will get two channels badly wrong, and that failure is the cliffhanger.

104
weeks of Aurora panel data, 4 media channels + demand control
1.016
max R̂ on the demo fit (2 chains × 600 draws)
0.35
estimated TV ROAS — the truth is 2.14
−0.30%
the model's verdict on a budget move that is actually good

🧭 Before you start

Prerequisites: Workshop 00–05 (Bayesian fluency assumed) — or equivalent.  ·  Time: ~35–45 min reading + fit time if running

You will learn:

  • How one demand-controlled, builder-configured fit delivers contributions, ROAS, and marginal ROAS for Aurora — all with credible intervals.
  • How to gate on diagnostics (R̂, ESS, divergences, PPC) and then check what the data actually learned versus what the priors supplied.
  • Why a well-converged model can still be structurally wrong twice — Search inflated by residual demand-chasing, TV/Display gutted by unmodeled mediation — and why that's the cliffhanger, not the conclusion.

Run it: nbs/aurora/02_base_mmm.ipynb on GitHub.

One model, demand-controlled

The configuration is deliberately boring: the MFF-style Aurora panel with the demand control included (the back-door from Chapter 1), parametric geometric adstock with a learned decay rate \(\alpha_c \sim \mathrm{Beta}(1,3)\) and saturation per channel (the framework's defaults — both learned; the legacy fixed-decay blend is an opt-out), yearly seasonality, and a linear trend. Bayesian sampling returns a full posterior, not a point estimate. The builder makes the whole specification one readable expression:

from mmm_framework import BayesianMMM, ModelConfigBuilder, SeasonalityConfigBuilder, TrendConfig, TrendType
from mmm_framework.analysis import MMMAnalyzer

panel = aurora.base_panel(control_demand=True)          # the back-door, closed
model_config = (ModelConfigBuilder().bayesian_pymc()
                .with_chains(2).with_draws(600).with_tune(600).with_target_accept(0.9)
                .with_seasonality_builder(SeasonalityConfigBuilder().with_yearly(order=2))
                .build())

mmm = BayesianMMM(panel, model_config, TrendConfig(type=TrendType.LINEAR))
results = mmm.fit(draws=600, tune=600, chains=2, cores=1, random_seed=0)
# fitted: 4 channels · 104 weeks

💡 Why control_demand=True matters

Without it, Search — whose spend chases demand — soaks up the demand signal and looks like a hero (demand-blind ROI ≈ 2.5 against a true ROAS of 0.66). The control closes the back-door the DAG identified. Stress 03 shows what happens when you can only control for a noisy proxy: the bias survives.

The diagnostics gate — honest uncertainty starts here

Before trusting any number, check that the computation worked: \(\hat{R} \approx 1\) and healthy effective sample size mean the chains agree; divergences flag geometry problems. The demo run reports (measured):

CheckMeasuredGateVerdict
R̂ max1.016< 1.01marginally over — small-draw demo run
ESS bulk min167> 400well under — small-draw demo run
Divergences80flagged — raise target_accept / draws in production

The notebook is explicit that these 2-chain, 600-draw fits trade rigor for speed; real analyses use ≥4 chains and ≥1000 draws/tune. The deeper doctrine — established in Stress 00 — is that this gate validates the sampler, never the causal claim. We will limp through the gate and still end this page with two channels undervalued three- to six-fold.

Every ROAS number below is a summary of these posterior coefficient distributions. Wide intervals are honest uncertainty, not indecision:

Measured — posterior mean and 94% HDI for each channel coefficient \(\beta_c\), from the baked notebook's results.summary(). Every coefficient is wide and none is pinned clearly away from zero — even Search, the largest mean (1.74), has an HDI reaching down to 0.12 — the model genuinely isn't sure any single channel does much. Hold that thought.

How much came from the data vs the priors?

A posterior interval tells you what to believe after the fit — not how much of that belief is the data talking versus the prior. The framework's compute_parameter_learning reports, per parameter, the contraction \( c = 1 - \mathrm{Var}_{\text{post}}/\mathrm{Var}_{\text{prior}} \) (\(c \to 1\): the data pinned it; \(c \approx 0\): prior-dominated) plus prior↔posterior overlap and a standardized shift.

Measured — contraction per parameter from the baked notebook's learning table. Read the bars honestly: all four adstock decay rates \(\alpha_c\) show negative contraction (adstock_alpha_TV −0.91 down to adstock_alpha_Social −0.57) with high prior overlap (0.48–0.75) — the posterior comes back wider than the \(\mathrm{Beta}(1,3)\) prior, a weak-identification / prior↔data-tension flag: observational data barely pins carryover (the carryover/saturation equifinality; see Stress 01). Three of the four carry verdict weak; adstock_alpha_Search (purple bar) is verdict relocated — shift z +1.60, the posterior moved 1.6 prior-sd without narrowing, so the evidence dominated the location and the width reflects likelihood flatness, not "the data taught nothing". But the pieces that drive ROI — saturation rates, intercept, controls, seasonality, \(\sigma\) — are strongly learned (\(c\) up to 0.99). And beta_Search shows negative contraction too (−0.11, with 0.88 prior overlap — verdict prior-dominated): the one coefficient the data cannot pin is exactly the demand-confounded channel Chapter 1 warned about.

Does the model track revenue?

The last gate before reading contributions: posterior-predictive fit. The notebook overlays observed weekly revenue on the prediction with its 90% credible band; the fit hugs the data.

Illustrative recreation (seeded) — the shape of the notebook's actual-vs-fitted chart (trend + yearly seasonality + media bumps, 104 weeks), not the real Aurora panel values. The point survives the recreation: a model can track revenue this well and still misattribute it — fit quality is necessary, nowhere near sufficient.

Contributions & ROAS — with credible intervals

The estimand is counterfactual: a channel's contribution is the revenue that disappears if you turn it off. compute_counterfactual_contributions(compute_uncertainty=True) attaches a posterior interval to every number, and MMMAnalyzer turns contributions into ROAS — revenue per $1 of spend:

\[ \mathrm{ROAS}_c \;=\; \frac{\text{counterfactual contribution}_c}{\text{spend}_c} \]
ChannelTotal spend ($k)Total contribution ($k)ROAS (est.)True ROAS
TV6,053.782,106.140.352.14
Search3,312.383,476.731.050.66
Social3,095.751,992.490.640.49
Display2,853.731,914.140.672.11

The same table as the centerpiece picture — estimated ROAS with its credible interval, and the true ROAS (known, because Aurora is synthetic) as a diamond:

Measured bar heights and truth diamonds (baked ROI table vs aurora.true_roas); whisker widths are approximated by scaling each channel's measured 94% β-posterior HDI onto its ROAS (contribution is near-proportional to β) — the notebook computes them exactly from contribution draws. What to see: the intervals are doing honest work (Search's wide band straddles its truth at the low end; Social's covers 0.49), but the truth diamonds for TV and Display sit far above even the top whisker. That is not sampling noise. It is structure the model cannot see — section below.

Where does the next dollar go? Marginal vs average ROAS

Total ROAS prices the average dollar already spent. Budgeting needs the marginal dollar — what a +10% bump returns — because saturation means channels don't scale forever. compute_marginal_contributions(spend_increase_pct=10) measures it:

ChannelCurrent spend ($k)Marginal contribution ($k)Marginal ROASAverage ROAS
TV6,053.78153.640.250.35
Search3,312.38266.060.801.05
Social3,095.75149.620.480.64
Display2,853.73136.880.480.67

Measured — average vs marginal ROAS from the baked tables. Every channel's next dollar earns less than its average dollar (Search: 1.05 → 0.80): all four are on the concave part of their saturation curves. The gap between the pair of bars is the budgeting signal — and it is why "Search has the best ROAS, give it everything" is wrong even when you believe the model.

The geometry behind that gap: average ROAS is the slope of the chord from zero to the current operating point on the channel's response curve; marginal ROAS is the slope of the tangent there. On a saturating curve the tangent is always flatter than the chord.

Illustrative — a saturating response curve \(R(s) = A\,(1 - e^{-k s})\) computed live in your browser, with \(A\) and \(k\) calibrated per channel so that the chord and tangent at current spend equal the measured average and marginal ROAS above. The marked point is the channel's actual two-year spend. The notebook derives the same picture from the fitted saturation posteriors.

A what-if: move 20% of Search into TV

The scenario API answers the planner's question directly: mmm.what_if_scenario({"Search": 0.8, "TV": 1.2}) re-runs the fitted model under the new spend path. Measured result:

Revenue
Baseline$87,302k
Scenario (Search −20%, TV +20%)$87,037k
Change−0.30% (−$265k)

⚠️ Watch the sign

The base model judges this move a loss. In Aurora's ground truth it is a good move — TV's true ROAS (2.14) is more than three times Search's (0.66). The model is internally consistent and wrong, because it undervalues TV for a structural reason the next section names. Acting on this number would steer the budget exactly the wrong way.

Try the move yourself. The slider shifts a share of budget between Search and TV (the notebook's multiplier convention: Search × (1 − x), TV × (1 + x)) and re-prices the scenario on the response curves calibrated above — i.e. this is the base model's worldview, mirage included:

Illustrative — computed live from the calibrated response-curve approximation, anchored to measured numbers (baseline revenue $87,302k; per-channel average and marginal ROAS). At the notebook's setting (+20%) the approximation gives ≈ −0.31% against the measured −0.30% — close enough to trust the shape. Every point on this curve inherits the base model's blind spot: it prices TV at 0.35, not 2.14.

The honest residual — what the base model gets wrong here

Grade the model against the oracle. The measured gap, channel by channel:

Measured — estimated minus true ROAS per channel (baked chk table): TV −1.79, Search +0.39, Social +0.16, Display −1.44. Two failure modes, opposite signs, one fit.

Two distinct things are happening, and neither is noise:

💡 Bonus: fitted models are serializable

The expensive fit round-trips to disk — MMMSerializer.save(mmm, "artifacts/aurora_base") / MMMSerializer.load(...) restores the trace (2 chains × 600 draws, verified in the baked run) — so Chapters 3–5 reuse this model instead of re-sampling.

Takeaways

What to take away

  • The base Bayesian MMM delivers contributions, ROAS, and marginal ROAS — all with credible intervals — from one demand-controlled, builder-configured fit.
  • Gate on diagnostics first (R̂, ESS, divergences, PPC), then check what the data actually learned: here the ROI-driving parameters contracted strongly while the adstock decay rates came back with negative contraction (three verdict weak; Search verdict relocated — it moved 1.6 prior-sd without narrowing) and beta_Search stayed prior-dominated.
  • Marginal ≠ average: every Aurora channel's next dollar earns less than its average dollar (Search 0.80 vs 1.05) — the saturation gap is the budgeting signal, not the ROAS ranking.
  • The model is honest about statistical uncertainty yet still structurally wrong twice: Search overstated by residual demand-chasing (+0.39 ROAS), TV/Display gutted by unmodeled mediation (−1.79 / −1.44).
  • Hence the what-if's wrong sign (−0.30% on a truly good move): a base MMM is a starting point for interpretation, not the final word — experiments and mediation models finish the story.

💡 Run it yourself

This page mirrors nbs/aurora/02_base_mmm.ipynb (committed with baked outputs), regenerated by nbs/builders/build_aurora_notebooks.py on the shared synthetic world in nbs/builders/aurora.py. The series README is nbs/README_aurora.md. Source: github.com/redam94/mmm-framework.