The Input Format: One Long Table
All data enters the framework as a single Master Flat File (MFF)—a long-format table with one row per period (and optionally geography, product, campaign, outlet, creative) per variable.
Instead of one column per metric, the MFF uses two columns—VariableName
says which series a row belongs to (the KPI, a media channel, a control), and
VariableValue holds its value. The same file therefore carries national and
geo-level series side by side, and adding a channel means adding rows, not redesigning a schema.
This matches the definition in the glossary. Data is loaded
with MFFLoader (or the one-call load_mff()), which validates the file
against a declared MFFConfig before anything is fit. If your data lives in the more
common wide format (one column per metric), mff_from_wide_format() converts it.
Column Dictionary
Eight columns. All eight must exist in the file—the loader raises
MFFValidationError: Missing required columns otherwise
code-enforced. But the five extra dimension columns
may be left blank for data that doesn't use them: the loader treats a dimension as
active only when it has more than one distinct non-null value. Column names are remappable
via MFFColumnConfig; the defaults are shown.
| Column (default name) | Required content? | What it is |
|---|---|---|
Period |
Yes — every row | Date of the observation. Parsed with MFFConfig.date_format
(default %Y-%m-%d); unparseable dates fail loading. |
Geography |
Only for geo-level series | Region / DMA / market label. Blank for national series. |
Product |
Only for product-level series | Product or brand line. Blank otherwise. |
Campaign |
Optional | Campaign label, for campaign-split media. |
Outlet |
Optional | Sub-channel split (e.g. social platform within "Social"). |
Creative |
Optional | Creative-level split. |
VariableName |
Yes — every row | Which series this row belongs to. Must match a declared variable; undeclared names are ignored with a warning, missing declared names fail loading. |
VariableValue |
Yes — every row | The numeric value (KPI units, spend, index, …). |
Frequency. MFFConfig.frequency accepts exactly
"W" (weekly, aligned to weeks ending Sunday), "D" (daily), or
"M" (monthly, month-start) code-enforced.
Weekly is the default and the frequency all of the framework's published benchmarks and
pressure tests use.
Gaps. Missing media values are filled with 0.0 by default
(no record of spend = no spend); missing control values are forward-filled by default.
Both behaviors are configurable (fill_missing_media,
fill_missing_controls) code-enforced.
Worked Example
Two weeks of a small national model—one KPI (Sales), two media channels,
one price control. Note the dimension columns sit empty for national data; a geo-level KPI
would simply repeat the Sales rows once per region with Geography
filled in.
| Period | Geography | Product | Campaign | Outlet | Creative | VariableName | VariableValue |
|---|---|---|---|---|---|---|---|
| 2024-01-07 | Sales | 1,204,300 | |||||
| 2024-01-07 | TV | 85,000 | |||||
| 2024-01-07 | Paid_Search | 22,400 | |||||
| 2024-01-07 | Price_Index | 1.02 | |||||
| 2024-01-14 | Sales | 1,187,950 | |||||
| 2024-01-14 | TV | 0 | |||||
| 2024-01-14 | Paid_Search | 24,100 | |||||
| 2024-01-14 | Price_Index | 1.02 |
That TV = 0 week is not a data error—it's information. Dark weeks are
what let the model see what sales look like without TV (see
media variation below).
How Variables Are Declared
The file alone doesn't say which series is the KPI, which are media, and which are controls.
That's declared in an MFFConfig: one required KPIConfig, a list of
MediaChannelConfigs (each carrying its own adstock, saturation, and coefficient-prior
settings), and a list of ControlVariableConfigs (sign constraints, shrinkage, and
an optional causal role—declared mediators and colliders are refused as controls
at model-construction time). Each variable also declares its DimensionTypes
(PERIOD, GEOGRAPHY, PRODUCT, CAMPAIGN,
OUTLET, CREATIVE), and the loader checks the data actually has the
dimensions you claimed code-enforced. National media is
automatically allocated down to a geo-level KPI (population weights by default; configurable).
import pandas as pd
from mmm_framework import MFFConfigBuilder, load_mff
# Declare what each VariableName means
mff_config = (
MFFConfigBuilder()
.with_kpi_name("Sales")
.add_national_media("TV", adstock_lmax=8)
.add_national_media("Paid_Search", adstock_lmax=2)
.add_price_control("Price_Index") # control allowed a negative sign
.build()
)
# Load + validate in one call -> aligned panel, ready to fit
panel = load_mff(pd.read_csv("weekly_mff.csv"), mff_config)
print(panel.summary())
Minimum Data Requirements
The loader enforces structure, not statistical sufficiency. The thresholds below are guidance—the same checklist the Modeling Guide uses—not hard gates. A model will fit on less; its intervals will just be honest about how little it learned.
| Requirement | Threshold | Why | Status |
|---|---|---|---|
| History length | 104+ weekly observations (2 years) | Two full seasonal cycles are needed to separate seasonality from trend and from media timing. One cycle leaves them confounded. | guidance |
| Zero-spend share | < 50% zero-spend weeks per channel | A channel that's almost always dark carries too little signal to estimate a response curve. | guidance |
| Media variation | Channels should sometimes go dark or near-dark, and channels should not all flight together | ROI is a do(spend = 0) counterfactual. A channel that never goes dark gives the model no observations near zero—a positivity violation—so the “what if we turned it off” answer is extrapolated from the prior, not the data. Synchronized flighting (all channels pulsing together) makes the split between channels unidentifiable even when total media is fine. | guidance |
| Controls coverage | Controls present for the entire time series | Gaps in confounders (price, distribution, promo) become gaps in causal adjustment exactly where you need it. | guidance |
| Sanity | No negative spend, consistent frequency, parseable dates | Structural errors—the loader and the EDA module catch most of these. | code / eda |
The variation requirements are not hypothetical: the framework's own pressure-testing worlds include a synchronized-flighting scenario and a near-collinear channel pair built precisely to demonstrate what happens when they're violated—the model's job in those worlds is to report wide intervals, not to invent a split. See Variable Selection for how collinearity is handled and when only an experiment can break the tie.
Spend or Impressions?
Either works as the media input. Internally each channel is scaled by its own maximum after
adstock code-enforced
,
so units don't affect the fit—the response curve is learned on a normalized scale either
way. Prefer spend when you want ROI and budget-allocation readouts directly:
contributions-per-dollar require dollars in the denominator. Use impressions (or GRPs) when CPMs
shifted materially over the window and you want the response curve in exposure units—then
convert to ROI with your own cost data. The framework applies that conversion for you: set the
per-channel measurement_unit to "impressions" (or "clicks")
on MediaChannelConfig and give it a cost source—a cpm/cpc
constant or a spend_column pointing at a separate dollar series—and it reports
ROI/marginal-ROAS internally (cpm → (impressions/1000)×cpm; cpc → clicks×cpc).
With no cost declared it instead reports an efficiency metric (contribution per 1,000 impressions /
per click, break-even reference 0). See technical-docs/impression-level-roi.md.
guidance
How Many Channels? How Many Geographies?
Channels. The framework's synthetic evaluation worlds use 4 channels
(the clean and violation worlds) to 7 (the “realistic” world, which deliberately
includes a near-collinear Radio/Print pair)
, and the runtime
benchmark sweeps 3–7. Nothing caps the channel count, but identification degrades before
runtime does: every added channel is another response curve competing to explain the same KPI
variance, and collinearity risk grows with count. Past roughly this range, consider grouping
minor channels under a parent_channel hierarchy or letting
shrinkage-based selection do the pruning.
guidance
Geographies. Hierarchical geo models need at least 2 geographies
code-enforced; the synthetic geo worlds use 4
. The binding
constraint is not the count but per-geo spend variation: the built-in diagnostic
(validation.geo_spend_variation_diagnostic) computes each channel's coefficient
of variation of spend across geos and flags channels below a 0.15 default threshold as too
uniform to carry geo-level identifying information
.
It also ships with a caveat worth repeating verbatim in spirit: cross-geo variation identifies
effects only if geo-level spend is exogenous—not the result of targeting
higher-demand markets.
Cost to Run: Measured Runtimes
These are measured numbers, not vendor estimates—every row below comes
from nbs/validation/runtime_benchmark.ipynb, run on an Apple M3 laptop (8 cores,
16 GB RAM; Python 3.12, PyMC 6.0.1, PyTensor 3.0, ArviZ 1.2, NumPyro 0.19, JAX 0.8).
The honest caveat: the ratios transfer to other hardware; the
absolute seconds don't (and run-to-run variance is ~15%).
| Workload | Wall-clock | Notes | Status |
|---|---|---|---|
| Production-shaped national fit 156 weeks × 7 channels |
≈ 17 s | NumPyro NUTS, 4 chains × 500 draws (500 tune); R̂ ≤ 1.01. | measured |
| Full size grid 104–156 weeks × 3–7 channels |
8–17 s | Runtime is nearly flat in problem size at this scale—sampler overhead dominates, not data volume. | measured |
| Default PyMC sampler (same model, 104w × 7ch) | ~32 s vs. ~12 s | Sequential PyMC NUTS is ~3× slower than NumPyro at equal draws (PyMC 6's
native sampler narrowed this from ~6× under PyMC 5). Select the
NumPyro backend on the model config (ModelConfigBuilder().bayesian_numpyro(),
the default). |
measured |
| Doubling draws (500 → 1000, 156w × 7ch) | 14.5 s → 19.2 s | 1.32×, not 2×—compilation and warmup amortize. | measured |
| Hierarchical geo panel 4 geos × 130 weeks × 4 channels |
≈ 20 s | 2.9× its national twin (6.9 s) in raw time—but 4× the rows, so 0.72× per observation. Pooling is cheap. | measured |
| Extended models (nested mediation, multivariate) | ≈ 0.5–0.6× of core | On the same data and sampling budget, NestedMMM ran at 0.6× and MultivariateMMM at 0.45× the base model's time. Extensions are not the runtime bottleneck. | measured |
Fit Budgets
What this means for a working day:
| Stage | Sampling budget | Expected wall-clock | Status |
|---|---|---|---|
| Exploration — iterating on specification | 4 chains × 500 draws | ≈ 10–15 s per fit | measured |
| Production — the fit you report | 4 chains × 2000 draws | ≈ 30 s (about half a minute) | extrapolated |
The production figure is extrapolated from the measured draws-scaling line (500 → 1000 draws cost 1.32×; per-draw cost ≈ 9 ms on this hardware), not measured directly—treat it as an order of magnitude, not a quote. The practical point survives any hardware change: a full Bayesian MMM refit is a coffee break, not an overnight job, which is what makes the closed measurement loop (refit after every experiment) economically viable.
Even faster during exploration: for model-checking — catching bad priors,
broken geometry, or pathological saturation/adstock before paying for a full sample —
fit(method="map"|"laplace"|"advi"|"fullrank_advi"|"pathfinder")
(builder .with_fit_method(…) / agent fit_mmm_model(method=…))
returns an approximate posterior in seconds, faster than the ≈ 10–15 s
NUTS fit, and works on the extension models too (shared run_approximate_fit engine).
The catch: R-hat/ESS are unavailable and the uncertainty is not calibrated, so
re-fit with NUTS before reporting numbers or making decisions. (A second exact
sampler, fit(method="smc"), exists for a different job — confirming
multimodal posteriors and computing model evidence; see
troubleshooting.)
What Happens to Bad Data
Two lines of defense, both before any model is fit:
The loader refuses structural errors
code-enforced. MFFLoader raises
MFFValidationError for missing required columns, declared variables absent
from the file, dates that don't parse under the configured format, and variables whose
actual dimensions contradict their declared ones. It warns (rather than fails) on
undeclared extra variables—which are ignored—and on null values in
VariableValue. Nothing silently reshapes your data into something fittable.
The EDA module screens statistical problems. mmm_framework.eda
is the pre-fit sibling of the post-fit validation suite: load_eda_panel builds
the panel view, validate_dataset runs the automated rule checks,
missingness_matrix maps gaps, and detect_outliers flags anomalies
and pairs them with recommended treatments (recommend_treatments /
apply_treatments)—plus collinearity_analysis for the
channel-separation problem above. The design stance is the same throughout: flag and
explain, never silently fix. Outlier remediation is proposed, reviewed, and applied
explicitly—not baked invisibly into the fit.
Data-ready checklist
Before you fit, walk this list. Building the MFF from raw platform exports? The Data-Prep Cookbook is the step-by-step code version.
- ☐ Data is in MFF long format —
Period, dimension columns, and aVariableName/VariableValuepair. - ☐ Every variable shares one weekly (or daily/monthly) anchor; the KPI has no missing periods.
- ☐ Media values are non-negative; a dark week is a real zero, not a blank.
- ☐ Confounding controls are present — anything that moves spend and sales (price, promotions, seasonality drivers, distribution).
- ☐ You have enough history (≈ two years of weekly data for a national model).
- ☐ Each channel is modeled on the right variable — spend, or impressions/clicks with a spend column for dollar ROI.
- ☐ The EDA screen is clean —
validate_datasetanddetect_outliersraise nothing you haven't reviewed. - ☐ The MFF loads without validation errors or contiguity warnings.
Next Steps
Every box checked? The Getting Started guide takes you from install to first fit. Starting from raw exports, walk the Data-Prep Cookbook. For the full pre-specified workflow this data feeds, see the Modeling Guide; for the priors that fill the gaps your data leaves, see the Technical Guide’s prior specification. Evaluating the framework for procurement? The Evaluator’s Guide collects the facts in RFP order.