Aurora 04 · Reporting — putting it in front of the board

Chapter 4 of the Aurora Coffee Co. story. The analysis is done — the base MMM produced contributions, ROAS and marginal ROAS with credible intervals, and the extended models explained what TV is really doing. Now it has to land with people who don't read trace plots. mmm_framework renders a self-contained, themed HTML report straight from a fitted model: executive summary, ROAS with uncertainty, additive decomposition, diagnostics, and a causal-assumptions section so stakeholders see what the number rests on. This page mirrors nbs/aurora/04_reporting.ipynb — and because Aurora's world has a known ground truth, we can grade the report's own numbers.

💡 Skip ahead: the docs ship a live example

The fastest way to understand this chapter is to open a generated report: mmm-example-report.html is a real MMMReportGenerator artifact shipped with these docs. Everything below explains what's in it and why.

🧭 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 MMMReportGenerator call turns Aurora's fitted model into a single self-contained, board-ready HTML report with interactive charts.
  • Why uncertainty is load-bearing in the translation from posterior to boardroom — every headline ships an interval, and intervals, diagnostics, and causal assumptions must never be stripped.
  • How ReportBuilder toggles sections for an executive cut and built-in palettes rebrand everything — and which biases (like Search's graded-against-truth miss) no generator can detect from the fit alone.

Run it: nbs/aurora/04_reporting.ipynb on GitHub.

What a decision-grade report must carry

A dashboard hands the board a point estimate. A posterior hands the analyst ten thousand draws. The report is the translation layer between them, and three things must survive the translation:

And one thing a report can never do for itself: know whether the numbers are causal. Good fit, tight intervals and green diagnostics are all compatible with confounding bias (the stress series measures exactly this). So the report ships a Causal Assumptions section that states, in stakeholder language, what identification rests on — and tells the reader to anchor high-stakes channels with experiments.

One call: the MMMReportGenerator API

The generator builds directly from a fitted BayesianMMM — it extracts contributions, ROAS, fit statistics and diagnostics for you. The notebook fits the Aurora panel (demo-sized: 2 chains × 400 draws, 20 seconds) and renders in two lines:

from mmm_framework.reporting import (
    MMMReportGenerator, ReportConfig, SectionConfig, ColorScheme, ColorPalette)

config = ReportConfig(
    title="Aurora Coffee Co. — Marketing Effectiveness",
    subtitle="Q3 Planning Review",
    client="Aurora Coffee Co.",
    analysis_period="2023 – 2024 (weekly)",
    color_scheme=ColorScheme.from_palette(ColorPalette.CORPORATE),
    confidential=True,
    diagnostics=SectionConfig(enabled=True),
)
report = MMMReportGenerator(model=mmm, panel=panel, results=results, config=config)
html = report.render()                          # the full HTML as a string
out  = report.to_html("artifacts/aurora_report.html")   # ...or written to disk
1
file — self-contained HTML, 925,354 chars (measured)
32
interactive Plotly charts in the generated report
8
sections, each toggleable via SectionConfig
80%
credible interval on every headline number (configurable)

The baked run prints wrote artifacts/aurora_report.html (925,354 chars of HTML). One caveat the notebook flags: report charts load Plotly from a CDN, so rendering the file needs a network connection; it is otherwise fully self-contained — no server, no Python, just a file you can email to the CMO.

What's inside the report

The generated Aurora report contains these sections, in order (headings extracted from the baked artifact). The toggle shows the notebook's second build — the executive cut via the fluent ReportBuilder, which drops methodology and diagnostics for a shorter deck:

Measured — section headings extracted from the baked artifacts/aurora_report.html. The executive cut mirrors the notebook's ReportBuilder call: .enable_all_sections().disable_section("methodology") .disable_section("diagnostics").with_credible_interval(0.8). Note what the exec cut keeps: the intervals and the causal-assumptions section travel with every build.

from mmm_framework.reporting import ReportBuilder

exec_report = (ReportBuilder()
    .with_model(mmm, panel=panel, results=results)
    .with_title("Aurora — Executive Summary").with_client("Aurora Coffee Co.")
    .enable_all_sections().disable_section("methodology").disable_section("diagnostics")
    .with_credible_interval(0.8)
    .build())
exec_report.to_html("artifacts/aurora_exec_summary.html")

The report's own executive summary, from the baked artifact (which prints the revenue series in its native $-thousands units; converted to dollars here): marketing-attributed revenue $9.6M (80% CI [$1.1M – $22.0M]), blended marketing ROI 0.62 (80% CI [0.07 – 1.43]), marketing contribution 11.0% (80% CI [1.3% – 25.4%]) of $87.3M total revenue. Notice that the blended-ROI interval straddles 1.0 — the report's own headline admits the program might not pay for itself, which is exactly the kind of sentence a dashboard never produces.

⚠️ The diagnostics it refuses to hide

This is a demo-sized fit (2 chains × 400 draws), and the report says so where it counts: the diagnostics table prints R-hat (max) 1.010 and ESS bulk (min) 152 — each flagged with a warning badge (divergences: 0, the one clean row). A real engagement re-runs with ≥4 chains and ≥1000 draws; the point is that the report surfaces the problem instead of laundering it. (And remember the stress-series lesson: green diagnostics validate the sampler, not the attribution — every silent failure in the matrix sampled cleanly.)

The ROI table, recreated — and graded against the truth

The heart of the report is the Detailed ROI Estimates table: per-channel ROI with an 80% credible interval and a plain-language evidence assessment. Below is that table as a chart, with three views: how the report presents it, what's left after an executive strips the intervals, and how it grades against Aurora's known ground truth.

Measured — ROI means, 80% CIs and assessments extracted from the baked report artifact (nbs/artifacts/aurora_report.html, the file 04_reporting.ipynb writes); ground-truth ROAS from the Aurora generator (nbs/builders/aurora.py). Toggle the views and watch what each one would make you decide.

The graded view is the honest part of this page. The report — correctly, given what the base model can see — labels Search 1.04 [0.13, 2.22] "Uncertain" and TV 0.33 [0.05, 0.78] "Underperforming". The oracle says Search's true ROAS is 0.66 and TV's is 2.14. Search is a demand-chasing mirage (chapter 1, stress 03); TV works through awareness, which the base model structurally cannot credit (chapter 3). The generator cannot know any of that — no report generator can. What it can do is ship the caveat:

What the report's Causal Assumptions section actually says

Quoting the generated artifact: "The channel effects below are causal only if every common cause of media spend and the KPI is measured and adjusted for (no unobserved confounding)… In marketing the dominant hidden confounder is unobserved demand — budgets rise when demand is expected to rise — which no adjustment set can remove. Good fit, tight intervals and passing posterior-predictive checks are all compatible with confounding bias." It then prints per-channel robustness values (TV 0.123, Search 0.140, Social 0.123, Display 0.120 — all assessed "Robust") and immediately undercuts its own table: the robustness value is an OLS-analogy approximation, and the channels it certifies "Robust" include TV and Display — the two the oracle says are most wrong, because their bias is unmodeled mediation, invisible to a confounding-sensitivity number. The section's last word: anchor high-stakes channels with a geo-lift experiment. That is what chapter 5 does.

Theming & design tokens

Reports are branded with a ColorScheme built from named palettes or custom tokens. The notebook's measured output lists the five built-ins: ['SAGE', 'CORPORATE', 'WARM', 'MONOCHROME', 'AUGUR'] (AUGUR is the editorial "Media Performance Readout" theme). Every color in the report — header band, stat callouts, chart traces, borders — flows from one token set, so a rebrand is one argument, not a CSS hunt. Click a palette to restyle the preview:

Aurora Coffee Co. — Marketing Effectiveness
Q3 Planning Review · 2023 – 2024 (weekly) · CONFIDENTIAL
0.62
80% CI: [0.07 – 1.43]
Blended Marketing ROI

Measured tokens, sketched layout — the palette hex values are the framework's actual ColorScheme.from_palette(...) constants (src/mmm_framework/reporting/config.py), and the numbers are the baked report's executive-summary and ROI figures. The mini-layout is a stylized sketch of the report header + stat-callout pattern, not the real template — open the live example for the genuine article. The notebook's report uses CORPORATE; the exec cut uses the warm palette.

"Just give me the number" — what you must not strip

Every analyst gets this request. The report is built so that complying honestly is possible — and complying dishonestly takes deliberate effort. The doctrine, in order of how expensive each omission is:

  1. Never strip the interval. Social's point estimate is 0.71 — reads as "below break-even, cut it." Its 80% interval is [0.09, 1.69]: the data is consistent with Social returning nine cents or $1.69 per dollar. Those are opposite decisions. The honest sentence is a probability statement — \( P(\mathrm{ROAS}_{\text{Social}} > 1) \) — not a point. Interpreting Results covers how to read these; the stakeholder guide covers how to say them out loud.
  2. Never strip the diagnostics line. One row — divergences, R-hat, ESS — is the difference between "the posterior is real" and "the sampler gave up." This demo's report flags all three. If you delete the row, you own the consequences.
  3. Never strip the causal-assumptions section. It is the only part of the report that says what the numbers mean. The report's badges are statements about posterior precision, not identification — TV's "Underperforming" badge has the truth (2.14) sitting far above its interval [0.05, 0.78] because the bias is structural, not statistical.
  4. Keep the decomposition additive. Components sum to predicted revenue by construction. The moment someone hand-edits one bar "for the narrative," the report stops reconciling and every other number becomes negotiable.

💡 What the generator automates — and what it can't

Automated: extraction (contributions, ROAS, fit stats, robustness, convergence), 32 charts, theming, section assembly, the uncertainty framing, and the causal-assumptions boilerplate. Still on the analyst: the recommendation itself, the experiment-calibration story (chapter 1), the mediation explanation for why TV looks weak (chapter 3), and the budget plan (chapter 5). The report's auto-generated "Key Finding" singles out Search's positive returns (while asking for more experimentation) — defensible from the posterior, still a mirage in Aurora's world. The generator renders evidence; it does not adjudicate causality. That's your job, and chapters 1, 3 and 5 are the tools.

See a real generated report

Live artifact

Open the example MMM report →

A genuine MMMReportGenerator output shipped with these docs — executive summary with credible intervals, model fit, ROI estimates, additive decomposition, saturation & adstock curves, causal assumptions, and the full diagnostics section. Everything this page described, in the form a stakeholder would actually receive.

The notebook itself writes two more: artifacts/aurora_report.html (the full corporate-palette build) and artifacts/aurora_exec_summary.html (the exec cut) — both regenerate when you run it.

What to take away

  • One call, board-ready: MMMReportGenerator(model=, panel=, results=, config=).to_html(...) turns a fitted model into a single self-contained HTML file (measured: 925,354 chars, 32 interactive charts).
  • Uncertainty is load-bearing: every headline ships an 80% CI; the demo report's own blended ROI, 0.62 [0.07 – 1.43], straddles break-even — and says so.
  • Sections toggle, palettes brand: ReportBuilder one-liners produce an exec cut; five built-in palettes (SAGE, CORPORATE, WARM, MONOCHROME, AUGUR) restyle everything via design tokens.
  • The report is honest about its limits: it prints its diagnostics and ships a causal-assumptions section — because TV's "Underperforming" (0.33) vs truth (2.14) is a bias no generator can detect from the fit alone.
  • Never strip: intervals, diagnostics, causal assumptions, additivity. The narrative — and the causal adjudication — remains the analyst's job.

💡 Run it yourself

This page mirrors nbs/aurora/04_reporting.ipynb (committed with baked outputs), regenerated from nbs/builders/build_aurora_notebooks.py; the shared Aurora world lives in nbs/builders/aurora.py and the series guide in nbs/README_aurora.md. Generated artifacts land in nbs/artifacts/. Source: github.com/redam94/mmm-framework. Next — tie the whole story into one decision worth ~$12M/year: 05 · Unified Workflow.