Real-Data Onboarding
Every other workflow on this site starts from a synthetic world with an answer key.
Real engagements start from a CSV export with three date formats, a duplicated week,
holes in the spend columns, a TOTAL row someone left in, and a negative
number nobody can explain. This page follows that file through
cleaning → MFF assembly → the pre-fit EDA gate → fitting → validation → report — and
spends most of its time on the question the rest of the site postpones:
which checks substitute for ground truth when none exists?
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— every number on this page comes from that notebook's executed run, recorded in
nbs/artifacts/real_data_onboarding.json. Nothing here is illustrative.
🧭 Before you start
Prerequisites: Workshop 03 or equivalent — this page assumes you know what a fit looks like. · Time: ~45–60 min.
You will learn:
- How to take a genuinely broken client export to a model-ready
PanelDataset— with every cleaning rule stated as a counted, logged policy rather than a silent fix. - What the pre-fit EDA gate (
mmm_framework.eda) flags on real-shaped data, and what acting on each flag concretely changes downstream. - The no-truth-column validation battery — prior→posterior learning, rolling-origin backtesting, split-window stability, and the lift-test plan — what each check can and cannot catch, and where non-identification actually shows up (hint: not where you'd expect).
Run it:
nbs/demos/real_data_onboarding.ipynb on GitHub.
⚖️ Honesty note: this "client file" is synthetic under the hood — disclosed, not hidden
The messy CSV is manufactured in-notebook (seeded) from the realistic
synthetic world used across the
pressure-testing series, then damaged the way
client files actually arrive. Why build it this way instead of using a public
dataset? Because the entire point of the page is the validation battery — and only
a world with a known answer key can grade that battery. The disclosure is
structural: every check in §6 runs exactly as it would on your data, blind to the
truth, and then §7 opens the answer key to show what the battery did and didn't
catch. On your real data you will not get §7; everything before it is what you get
instead. A pipeline demo that hides its synthetic origins is marketing; one that
uses them to audit itself is method.
1 — The mess (what the client actually sent)
158 rows × 16 columns, and almost every classic defect at once. The first six rows,
exactly as pandas reads them:
| Week Ending | TV | search_spend | Social Spend | display | Sales |
|---|---|---|---|---|---|
| 2021-01-04 | 34,281.72 | 26.019952 | 9.935614 | NaN | 760.238575 |
| 01/11/2021 | 16,945.31 | 12.217789 | 10.245149 | 4.923548 | 754.436412 |
| 2021-Jan-18 | 19,841.66 | 21.369530 | 11.521636 | 13.463184 | 738.363872 |
| 2021-01-25 | 142,611.61 | 106.968807 | 12.841576 | 17.022850 | 830.480108 |
| 02/01/2021 | 109,300.22 | 98.626616 | 10.105508 | 8.132816 | 794.688187 |
| 2021-Feb-08 | 144,591.95 | 20.396468 | 28.968467 | 20.895525 | 667.195161 |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— the printed head of artifacts/client_export_messy.csv. Red cells are
defects pandas has already swallowed silently: dates in three formats, TV as
comma-separated text (and in dollars while every other channel is in $000s),
a blank spend cell.
The full defect inventory, all of it visible in the file's dtypes and row count:
- Mixed date formats, rotating row by row: ISO (53 rows), US
01/11/2021(52), month-name2021-Jan-18(52); - a fully duplicated week (copy-paste artifact);
- ~3% missing spend cells in Search and Display, and Social logging dark weeks inconsistently as 0 or blank;
- TV as text with thousands separators, in dollars (all 156 cells);
- a trailing
TOTALsummary row and an unnamed junk column (Unnamed: 15); - inconsistent headers:
'TV '(trailing space),'search_spend','Social Spend','display','VIDEO'…; - one negative spend value — a refund booked as negative exposure.
One decision happens before cleaning: the client also offered a brand-tracker
awareness series, and we decline it. In this world brand_awareness is a
post-treatment mediator (media → awareness → sales); conditioning on it
blocks part of TV and Video's effect — the classic bad control. The
walkthrough measures the damage of getting this
wrong; here we just get it right at the door. What stays: two
confounders (category_demand, distribution)
and four precision controls (price,
competitor_promo, weather, holiday).
2 — Triage & cleaning: every rule counted, every policy stated
Cleaning is modeling — each rule changes the data the model sees. So the notebook prints each fix with the number of cells it touched, and the run records the ledger:
| Fix | Rule / policy | Touched |
|---|---|---|
| 1 | Drop unnamed junk column (Unnamed: 15) | 1 column |
| 2 | Parse dates robustly (format="mixed"); rows that refuse to parse are not data → the TOTAL row drops out | 156 parsed, 1 row dropped |
| 3 | De-duplicate weeks (keep first occurrence) | 1 row |
| 4 | Normalize headers — strip whitespace, one explicit rename map to canonical names | 14 headers |
| 5 | TV: strip thousands separators, coerce text→float, rescale dollars→$000s (unit mismatch confirmed with the client) | 156 cells |
| 6 | Policy: negative spend is a bookkeeping artifact (refund netted against exposure), not negative advertising → flag, then zero. Measured: Display @ 2022-09-26, −8.24 | 1 cell |
| 7 | Policy: missing media spend = 0 (a blank almost always means "didn't buy"), with a logged caveat: if a blank hid real spend, that channel is under-credited | 14 cells (Search 5, Social 4, Display 5) |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— the cleaning ledger as recorded in real_data_onboarding.json. Exit
asserts: 156 unique, consecutive weekly rows; no NaNs; no negative spend.
And because this world has a (disclosed) answer key, the notebook can do something a real engagement can't: price the cleaning itself. Comparing cleaned spend to the file's secret generating spend, the policies perturbed Search by 3.1% of its total, Display by 1.9%, Social by 0.9% — and every other channel by ~0%. Cleaning policies are small interventions with quantifiable cost; on real data you can't measure that cost, which is exactly why each policy gets logged rather than applied silently.
3 — Assemble the Master Flat File
The framework's loaders consume the MFF long format — one row per
variable × period (× geography × product when they exist).
mff_from_wide_format does the reshape; the head of the result:
Period Geography Product Campaign Outlet Creative VariableName VariableValue
0 2021-01-04 TV 34.281720
1 2021-01-04 Search 26.019952
2 2021-01-04 Social 9.935614
3 2021-01-04 Display 0.000000
4 2021-01-04 Video 57.246752
The MFFConfig declares what each variable is — including each
control's causal role, written down before the first fit:
mff_config = MFFConfig(
kpi=KPIConfig(name="Sales", dimensions=[DimensionType.PERIOD]),
media_channels=[MediaChannelConfig(name=c, dimensions=[DimensionType.PERIOD])
for c in CHANNELS],
controls=[ControlVariableConfig(
name=c, dimensions=[DimensionType.PERIOD],
causal_role=ROLES.get(c, CausalControlRole.PRECISION_CONTROL)) # confounders: wide, un-shrunk priors
for c in CONTROLS],
)
panel = load_mff(mff_df, mff_config) # -> PanelDataset(156 obs, 7 channels, 6 controls)
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— the reshape is lossless: 14 variables × 156 weeks = 2,184 MFF rows; the panel sees
7 channels + 6 controls + the KPI (mean 814, sd 137). category_demand and
distribution carry CausalControlRole.CONFOUNDER, which routes
them to wide, un-shrunk coefficient priors — shrinking a confounder re-introduces the
bias it exists to remove (why).
4 — The EDA gate: what it flagged, and what acting on it means
mmm_framework.eda is the pre-fit quality gate — validators, missingness,
collinearity, and robust outlier detection, all run before BayesianMMM
ever sees the data. On the cleaned panel it returned 0 errors and exactly the
flags this data deserves:
| Finding | Measured | Action taken |
|---|---|---|
warning short_history |
156 periods vs ~42 effective parameters → 3.7 obs/param | Don't "fix" — verify: the prior→posterior learning diagnostic in §6a checks whether thin data became prior-domination (measured: it didn't), and tempers how strongly channel claims are phrased. |
| warning Radio ~ Print collinearity | r = 0.996, both VIF-flagged, condition number 747 | Keep both (dropping one silently credits it to the other); expect wide individual intervals (§6a confirms, precisely); put a Radio/Print lift test on the measurement docket (§6d). |
flags outlier detector: 11 flags,
9 of them isolated_drop |
the 9 drops are exactly the zero-filled cells from fix 7 (+ the zeroed refund) | The detector is second-guessing our own cleaning policy — its job. Each flagged week was reviewed in §2; the fills stand with the caveat logged. If the client later confirms real spend there, the file changes, not the model. |
| pass missingness | 100% availability, 156 weeks × 14 variables | Nothing — cleaning closed every hole, and the matrix proves it. |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— validate_dataset / missingness_matrix /
collinearity_analysis / detect_outliers on the assembled MFF.
A gate you don't act on is decoration; each row's third column is the acting.
5 — The fit
The pre-specified configuration used across the series: parametric geometric adstock, logistic saturation, yearly seasonality, linear trend, NumPyro NUTS — 4 chains × 500 draws after 500 tuning steps. Measured: r-hat max 1.006, 0 divergences, 20 s wall-clock. Convergence is the entry ticket for everything below, not evidence of correct attribution — the rosy-picture demo exists because green sampler diagnostics say nothing about whether the answer is right.
6 — What replaces the truth column
On synthetic worlds we grade against a known answer key. Client data has no answer key, so this is the battery that substitutes for one. None of these checks can prove the attribution is right — that's what experiments are for — but each catches a specific way of being wrong:
| Check | The failure it catches |
|---|---|
| (a) prior→posterior learning | a posterior that just re-states the prior |
| (b) rolling-origin backtest | a model that can't predict data it hasn't seen |
| (c) split-window stability | attributions that depend on which data you fit |
| (d) lift-test calibration | confounding no observational check can see |
6a — Did the data teach us anything — and where does non-identification actually show?
| Channel | contraction | verdict | 90% CI width ÷ estimate |
|---|---|---|---|
| TV | 0.24 | moderate | 0.41× |
| Search | 0.77 | strong | 0.67× |
| Social | 0.69 | strong | 0.71× |
| Display | 0.55 | strong | 1.12× |
| Video | 0.56 | strong | 0.57× |
| Radio | 0.53 | strong | 1.98× |
| 0.56 | strong | 2.24× |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— compute_parameter_learning (contraction/verdict) next to each channel's
relative contribution-interval width.
Two verdicts, one of them counter-intuitive. First, the gate's short-history warning did not become prior-domination: 7/7 channel betas landed strong/moderate. Second — the instructive part — you might expect the collinear Radio/Print pair to show up here as prior-dominated betas. It doesn't. Their marginal posteriors contract respectably, because the likelihood does constrain each coefficient through the pair's shared calendar. Marginal learning cannot see a joint pathology. Where the non-identification does surface — with no truth column needed — is interval width: Radio and Print's 90% contribution intervals run 2.0–2.2× their point estimates, against 0.4–0.7× for the well-identified channels. The pair's split is unpinned, and the posterior says so in width. Reporting their point ROIs without those intervals would be the lie.
6b — Can it predict data it has never seen?
Rolling-origin backtest (protocol in depth): first training window 104 weeks, four refits at 13-week steps, 52 graded out-of-time weekly forecasts. This check needs no ground truth — it runs on any engagement.
| Forecaster | MAPE | MASE | 80% PI coverage |
|---|---|---|---|
| MMM | 3.1% | 0.25 | 78.8% |
| seasonal-naive (copy last year) | 13.8% | 1.19 | — |
| naive (last value) | 13.2% | 1.07 | — |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— all four refits converged (r-hat ≤ 1.008, 0 divergences). MASE < 1 means
the model beats "copy last year" where that baseline is computable; the 80% interval
covering 78.8% of 52 held-out weeks is calibration checked where it counts, out of
time. Caveat from the doctrine: forecast skill validates the predictive
model, not the causal one — the confounded worlds in
stress 03 forecast fine while
attributing wrongly.
6c — Does the story survive fitting on less data?
Refit the identical specification on the first 104 weeks only and compare channel shares of total media contribution (graded over the same window) against the full-156-week fit. Measured: rank correlation ρ = 0.89; the top-3 set kept 2 of 3 members (Video and TV held #1–#2 in both fits; Social and Search traded the #3 spot); Radio and Print stayed #6–#7 in both. If the ranking had reshuffled when a third of the data left, the "story" would have been a property of the sample, not the market. Note what honest expectations look like here: the identified channels hold steady, while the Radio/Print region — which §6a showed the data never pinned down — is where wobble is allowed and expected.
6d — What only an experiment can add
Everything above checks internal consistency and predictive validity. None of it can
detect a confounder nobody measured: a model can learn sharply, forecast at 3% MAPE,
and replicate across windows while attributing demand to the channel that
merely chased it. The fix is randomized evidence, not more diagnostics:
a geo-lift test on Radio vs Print to resolve the split observational
data cannot; a holdout on TV or Search (the demand-chasing channels
in this world) to bound confounding no within-sample check sees. Measured lifts feed
back as coefficient-scale priors via mmm_framework.calibration — the
calibration loop. On a real engagement this
subsection is not optional reading; it is the plan.
7 — The reveal: the cell your real data will not have
Because the file is (disclosed) synthetic, the notebook ends §6 by opening the answer key and grading the entire pipeline — mess, cleaning policies, MFF, model — against each channel's true causal contribution:
| Channel | true | estimated | error | truth in 90% CI | §6a verdict |
|---|---|---|---|---|---|
| TV | 8,948 | 7,430 | −17% | ✓ | moderate |
| Search | 3,978 | 4,079 | +3% | ✓ | strong |
| Social | 3,948 | 4,362 | +10% | ✓ | strong |
| Display | 3,090 | 3,168 | +2% | ✓ | strong |
| Video | 8,286 | 8,630 | +4% | ✓ | strong |
| Radio | 3,301 | 2,059 | −38% | ✓ | strong |
| 2,097 | 1,867 | −11% | ✓ | strong |
Measured (from the baked notebook nbs/demos/real_data_onboarding.ipynb)
— total media contribution 31,595 vs true 33,648 (−6.1%); the 90%
intervals covered truth on 7/7 channels. The biggest point-estimate
misses (Radio −38%, TV −17%) are exactly where the battery had already withdrawn
confidence: Radio sits inside a 2.0× interval the model refused to narrow, and TV was
the one channel §6a graded only moderate. Part of the residual error is the
cleaning itself — §2 measured up to 3.1% of a channel's spend perturbed by the
policies, and truth is computed on the undamaged spend.
On your real data you will not get this table. Sections 6a–6d are what you get instead — and on this run, their verdicts pointed at the same channels the answer key did.
8 — The deliverable
The run closes by rendering the client-facing artifact:
MMMReportGenerator(model=mmm, panel=panel, results=results, config=ReportConfig(...))
.to_html(...) produced a self-contained 2.0 MB HTML report
(artifacts/onboarding_report.html) — decomposition, ROI, response curves,
diagnostics. It ships alongside the §6 battery, never instead of it: the
report states the answers; the battery delimits which answers the report is allowed
to state confidently. See the example report for
what the rendered output looks like.
What to take away
- Cleaning is modeling. Missing-spend→0, zeroing the refund, the unit fix — each changed the data the model saw, so each was a stated, counted policy. The honest pipeline logs its interventions and (here, uniquely) measures their cost: ≤3.1% of any channel's spend.
- The EDA gate earns its keep before the first fit — and its flags map to actions: the obs/param warning became §6a's verification, the collinearity warning became §6a's width finding plus §6d's lift test, and the outlier detector audited the cleaning policy itself.
- No truth column? Run the battery. Learning, backtest, stability — none proves causality; each catches a specific way of being wrong; together they delimit what the report may claim.
- Know where non-identification shows. Not in the collinear pair's beta marginals (they contracted fine) — in contribution intervals 2× the estimate. Wide intervals are the correct answer; the lift test is the fix.
- Experiments are the plan, not an appendix. Only randomized evidence catches unmeasured confounding; the calibration loop turns measured lifts into priors for the next fit.
- The reveal is a luxury of synthetic data — disclosed, not hidden. Graded against the answer key: totals −6%, truth covered 7/7, and the misses concentrated exactly where the battery had already said "don't trust the point estimate."
📓 Run it yourself
This page mirrors
nbs/demos/real_data_onboarding.ipynb
(authored by nbs/builders/build_real_data_onboarding.py), built on
mmm_framework.data_loader, mmm_framework.eda,
mmm_framework.validation.backtest, and
mmm_framework.reporting. 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. Measured headlines:
nbs/artifacts/real_data_onboarding.json; the messy file itself:
nbs/artifacts/client_export_messy.csv.