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.

PeriodGeographyProductCampaign OutletCreativeVariableNameVariableValue
2024-01-07Sales1,204,300
2024-01-07TV85,000
2024-01-07Paid_Search22,400
2024-01-07Price_Index1.02
2024-01-14Sales1,187,950
2024-01-14TV0
2024-01-14Paid_Search24,100
2024-01-14Price_Index1.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.

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.