Causal Features Showcase

An MMM is only causal under assumptions plus experimental calibration. This page is a feature catalog, not a story: thirteen causal capabilities from the framework's P0 → P1 → P2 roadmap, each demonstrated on one small synthetic world with a deliberately planted confounder and a collinear channel pair. For every feature you get the problem it solves, the actual API call, the measured output from the baked run of nbs/demos/causal_features_showcase.ipynb, and — honestly — where its limits are. For the story version of the same ideas, see Aurora 01 · Causality; for the doctrine, Causal Inference and Measurement Calibration.

Scale of the demo

The notebook runs on small MCMC settings (60 weekly observations, 1,000 draws) so it executes in minutes. All numbers quoted here are measured from that baked run but are illustrative of the mechanics, not production-grade estimates.

The feature map

Every feature answers one question an analyst (or a stakeholder) should be asking about an MMM number. The tier is the roadmap wave: P0 features attack unobserved-demand confounding directly, P1 features quantify and guard, P2 features check and document.

FeatureQuestion it answersTier§
Causal role typing + DAG auto-classificationIs this control safe, required, or actively harmful?P1§1
Front-door & IV identification checksCan the effect be identified at all, given my graph?P2§2
Graph-engine cross-check vs networkxCan I trust the d-separation engine those checks rest on?P2§3
Baseline Bayesian MMM fit(The shared fit every demo below reuses.)§4
Marginal ROAS with uncertaintyHow sure are we about the return on the next dollar?P1§5
Sensitivity to unobserved confoundingHow strong a hidden confounder would overturn this number?P0§6
Weak-identification detectionCan the data even separate these channels' ROIs?P2§7
Geo-based identification diagnosticIs there enough cross-geo variation to lean on?P2§8
Equifinality guardrails (data-anchored κ)Is the saturation curve extrapolating beyond my data?P1§9
Pre-specification lock + diffDid the spec drift after we registered it?P2§10
Experiment-calibrated priorsHow do I fold a lift test into the model? (headline)P0§11
Experiment likelihood calibrationCan the experiment update β, the s-curve, and adstock jointly — on ROAS or mROAS?P0§11b
Causal refutation suiteDoes the model react correctly when the data is perturbed?P0§12
Causal-assumptions reportingWhat assumptions does this number rest on, in the report itself?P0/P2§13

The demo world

One synthetic dataset with deliberate causal structure, reused by every model-dependent demo: 60 weekly observations, three channels, and two planted traps. A latent Demand series drives both TV spend and Sales (the classic unobserved-demand confounder — marketers spend more on TV when demand is high), and Digital/Search move together (|r| up to 0.93) so their individual ROIs are barely separable.

# Latent demand confounds TV: spend more on TV when demand is high.
demand = np.sin(2 * np.pi * t / 52) + rng.normal(0, 0.3, n)
tv      = np.abs(40 + 18 * demand + rng.normal(0, 8, n))   # confounded
search  = np.abs(0.92 * digital + rng.normal(0, 4, n))     # collinear

controls=[ControlVariableConfig(
    name="Demand", dimensions=[DimensionType.PERIOD],
    causal_role=CausalControlRole.CONFOUNDER)]   # typed as a confounder (P1)

Note the last line: the control variable carries a declared causal role. That declaration is what powers §1's enforcement and §13's reporting.

§1–3 · Causal roles & identification

§1 — Causal role typing & DAG auto-classification P1

Problem: non-experts shouldn't need causal-inference training to avoid bad controls. Given a DAG, classify_dag_roles auto-detects each control's role using the back-door criterion: confounder (keep, un-shrunk), mediator/collider (refuse), precision control (safe).

from mmm_framework.dag_model_builder.identification import classify_dag_roles
cls = classify_dag_roles(dag, treatment_ids=["tv"], outcome_id="sales",
                         control_ids=["demand", "price", "instore"])

Measured: Demand → confounder ("common cause of media and the KPI"), Price → precision_control, InStore → mediator ("a consequence of media 'tv' — post-treatment"). And the model refuses to fit when a mediator is passed as a control:

ValueError: Refusing to condition on post-treatment / collider variables
used as controls: 'InStore' [mediator]. Conditioning on a mediator blocks
part of the media effect ... either biases a total-effect estimate.

Limit: the classification is only as good as the DAG you drew. Declare the mediator as a plain control in your graph and nothing fires — the stress series measured exactly this in the mediation worlds (stress-04: a wrong-mediator probe produced proportion_mediated = 1.000 with clean sampling). Mediator validity is external knowledge.

§2 — Front-door & instrumental-variable identification P2

Problem: back-door adjustment isn't the only identification strategy. The identification layer checks Pearl's front-door criterion (mediation models) and the IV criterion (declared instruments). These are identification checks for reasoning and reporting — they do not change the likelihood.

from mmm_framework.dag_model_builder.identification import frontdoor_criterion, iv_criterion
fd    = frontdoor_criterion(dag, "tv", ["aware"], "sales")
iv_ok = iv_criterion(dag_iv, "z", "tv", "sales")

Measured: front-door identified for TV → Awareness → Sales despite an unobserved U: True. IV valid for Z → TV → Sales: True. IV with the exclusion restriction violated (Z also → Sales): False — the check correctly rejects it.

Limit: a passing check certifies the graph, not the world. Full Tian–Pearl identification is deliberately deferred (technical-docs/deferred-causal-features.md).

§3 — Cross-checking the graph engine P2

Problem: the back-door/front-door/IV checks all rest on a hand-rolled d-separation engine. If it's wrong, everything above is wrong. So it is validated against networkx for every conditioning set on a battery of DAGs.

agree = all(ours_dsep(mbias, "t", "y", set(zz)) ==
            nx.is_d_separator(g, {"t"}, {"y"}, set(zz))
            for zz in all_subsets(["a", "b", "c"]))   # M-bias DAG, all 8 sets

Measured: on the classic M-bias DAG, our d-separation matches networkx for all conditioning sets: True.

Limit: agreement on test DAGs is evidence, not proof — but it is the right kind of paranoia for code that gates model fitting.

§4–5 · The baseline fit, and ROAS that carries its uncertainty

§4 — One small Bayesian fit, reused everywhere

model = BayesianMMM(panel,
    ModelConfig(inference_method=InferenceMethod.BAYESIAN_PYMC,
                n_chains=4, n_draws=1000, n_tune=500, target_accept=0.92),
    TrendConfig(type=TrendType.LINEAR))
results = model.fit(random_seed=0)

Measured: 4 chains × 1,000 draws in 16 seconds; in-sample R² ≈ 0.984 (with 12 post-tuning divergences at these small settings — the notebook flags them honestly).

Limit: a 0.98 R² proves nothing about attribution. Stress-00 measured a confounded fit at +111% bias on one channel with identical green diagnostics to the clean fit. Everything below exists because fit quality is not causal validity.

§5 — Marginal ROAS with uncertainty P1

Problem: the headline efficiency number — the return on the next 10% of spend — used to be a bare point estimate. It is now propagated per-draw through the posterior and reported with a credible interval.

mc = model.compute_marginal_contributions(
    spend_increase_pct=10.0, hdi_prob=0.9, random_seed=0)

Measured from the baked run: marginal ROAS with 90% HDI per channel. Digital's interval (0.07–1.00) and Search's (0.08–1.12) both span from near zero up to break-even — exactly the honesty the point estimate alone would have hidden. TV's interval (0.03–0.78) sits entirely below break-even: the model is telling you the next TV dollar does not pay back.

Limit: the interval quantifies posterior uncertainty under the model's assumptions. It does not widen for unobserved confounding or a wrong saturation form — that is what §6 and §9 are for.

§6–9 · Quantifying what the number rests on

§6 — Sensitivity to unobserved confounding P0

Problem: no adjustment set can remove an unobserved confounder (e.g. latent demand the analyst never measured). The robustness value (RV) is the share of variance a hidden confounder would need to explain in both a channel's spend and the KPI to nullify its effect. Low RV = fragile estimate.

from mmm_framework.validation import UnobservedConfoundingAnalysis
sens = UnobservedConfoundingAnalysis(model).run()
sens.fragile_channels   # channels a weak confounder could overturn

Measured: RV to nullify each channel's effect, against the 0.10 fragile threshold. TV 0.154, Digital 0.178, Search 0.186 — no channel flagged fragile (fragile_channels = []), so overturning any estimate would take a confounder explaining 15–19% of residual variance on both sides.

Limit — this is NOT an all-clear: the stress series measured that the RV is indistinguishable between a clean world and a confounded one (stress-00). A comfortable RV tells you how strong a confounder would have to be; it cannot tell you whether one exists. The demo world here has a planted demand confounder on TV, and the RV still reads "No" on fragile — because the analysis is conditional on the fitted model. Treat RV as a fragility ruler, never as evidence of cleanliness. See the pressure-testing scorecard.

§7 — Weak-identification detection P2

Problem: Digital and Search move together, so the data cannot separate their individual ROIs regardless of confounding. The diagnostic detects collinear clusters and the ill-conditioned design — reporting only, it never silently changes the model.

from mmm_framework.validation.channel_diagnostics import ChannelDiagnostics
cd = ChannelDiagnostics(model).run_all()

Measured: collinear cluster ['Digital', 'Search'] with |r| up to 0.93; design condition number 33.0; recommendation: "consider a grouped prior (shared group-level scale) or report their combined effect rather than overconfident per-channel ROIs."

Limit: detection only — grouped-prior fitting is deliberately deferred until validated against held-out experiments. Stress-00 measured multicollinearity as a silent failure mode (median error 39%) when the recommendation is ignored.

§8 — Geo-based identification diagnostic P2

Problem: cross-geo spend variation is quasi-experimental signal — but only if it exists and is exogenous. This necessary-not-sufficient diagnostic reports whether each channel has enough cross-geo variation to support geo-level inference.

from mmm_framework.validation import geo_spend_variation_diagnostic
diag = geo_spend_variation_diagnostic(geo_model)
diag.weak_channels

Measured (on a constructed 3-geo example where TV varies across geos and Radio is uniform): weak channels = ['Radio']. The diagnostic ships its own caveat: "Where spend follows local demand, geo variation does NOT remove unobserved-demand confounding; anchor geo-level claims with a randomized geo-lift experiment."

Limit: necessary, not sufficient — and geo hierarchies bring their own traps (stress-06 measured a regional ROI inversion).

§9 — Equifinality guardrails: data-anchored saturation P1

Problem: adstock decay, saturation shape, and the coefficient trade off against one another — many parameter combinations produce the same fit (equifinality). One mitigation: anchor the Hill half-saturation \( \kappa \) to the data's own spend percentiles so the curve can't park its bend where there is no data.

from mmm_framework.config import SaturationConfig
lo, hi = SaturationConfig.compute_kappa_bounds_from_data(tv, percentiles=(0.1, 0.9))
# pass as kappa_lower / kappa_upper to bound the Hill prior

Measured: data-anchored \( \kappa \) bounds for TV (10th–90th percentile of spend): [19.8, 58.8].

κ = 39

Computed live in your browser — the same math as the notebook: the Hill curve \( f(x) = x^2 / (x^2 + \kappa^2) \) as you drag κ. The shaded band is the measured data-anchored bound [19.8, 58.8]; the tick rug along the bottom is an illustrative recreation (seeded) of the TV spend distribution. Drag κ outside the band and watch the curve's bend leave the region where the data lives — that is the extrapolation the guardrail forbids.

Limit: anchoring κ constrains where the curve bends, not whether Hill is the right family. A wrong saturation form is one of the measured silent failures (stress-01: Hill truth fit with the wrong form gave +40% total bias, all diagnostics green).

§10 · Pre-specification lock + diff

Problem: pre-registration is only meaningful if divergences are detected. diff_spec compares a locked spec to the current one, identity-keyed — reordering channels is not a false alarm, but a re-labelled confounder or an added channel is caught.

from mmm_framework.config import diff_spec, summarize_spec_diff
print(summarize_spec_diff(diff_spec(frozen, current)))

Measured output:

3 divergence(s) from the pre-registered specification:
  ~ controls[name=Demand].causal_role: 'confounder' → 'precision_control'
  + media_channels[name=Search].name = 'Search' (added)
  ~ media_channels[name=TV].adstock.l_max: 8 → 13

Limit: the diff catches drift in the declared spec, not in the analyst's unstated choices (which dataset vintage, which holdout). It narrows researcher degrees of freedom; it does not eliminate them.

§11 / §11b · Anchoring to experiments — the headline

The single most important lever for causal validity: fold a randomized lift / incrementality result into the model, so the observational MMM is anchored to evidence that estimates the causal effect despite unobserved demand. The framework offers two routes to the same goal.

Prior route (§11)Likelihood route (§11b)
howderive a Gamma prior on β, refitadd an in-graph likelihood term, one fit
estimandscontribution onlycontribution · ROAS · mROAS
updatesβ (shape held fixed)β + saturation + adstock, jointly
modelscorecore · nested · multivariate · combined

§11 — Experiment-calibrated priors P0

Problem: the baseline fit attributes TV's effect under confounded data. A geo-lift experiment measured TV as ~1.6× as effective as the baseline fit implies. Two-stage flow: fit → derive prior → refit.

from mmm_framework.calibration import calibrate_with_experiments
from mmm_framework.validation import LiftTestResult

lift = LiftTestResult("TV", period, measured_lift, lift_se)
outcome = calibrate_with_experiments(model, [lift], refit=True,
                                     draws=1000, tune=1000, chains=4)

Measured: baseline-fit β(TV) mean 0.928 → experiment-implied β target 0.918; TV total contribution 1,016 (baseline) → 1,626 (experiment target) → 1,113 (calibrated refit). The refit moves toward the experiment but does not snap to it — the prior and the data negotiate, as they should.

§11b — Experiment likelihood calibration: ROAS, mROAS & joint anchoring P0

Problem: the prior route inverts a measured contribution to a target on β and holds the saturation/adstock shape fixed. The likelihood route folds the experiment into the graph: the measured value becomes a data point whose model expectation is the channel's estimand, so one fit updates β, the s-curve, and the adstock kernel jointly — and it generalizes to ROAS and marginal ROAS, which a coefficient prior cannot express.

from mmm_framework.calibration import ExperimentMeasurement, ExperimentEstimand

roas_exp = ExperimentMeasurement("TV", period, value=measured_roas,
                                 se=0.10 * measured_roas,
                                 estimand=ExperimentEstimand.ROAS)
model_lik = BayesianMMM(panel, model_cfg, trend_cfg,
                        experiments=[roas_exp])   # the only change vs §4
model_lik.fit(draws=1000, tune=1000, chains=4)

Measured: the graph now carries experiment_TV_roas_0_model_estimand — "what the model thinks the experiment should have measured." TV's model-implied ROAS: uncalibrated ≈ 0.43 → calibrated 0.68, against a measured 0.69. TV contribution 1,016 → 1,599, nearly on the 1,626 target — closer than the prior route's 1,113 because the whole media transform moved jointly.

Measured from the baked run, both views. Contribution view: baseline 1,016 vs the prior route's 1,113 and the likelihood route's 1,599, against the dashed experiment target of 1,626. ROAS view: the model-implied TV ROAS jumps from 0.43 to 0.68 once the experiment enters the likelihood; the shaded band is the experiment's ±1 SE (0.69 ± 0.069).

The same one-line API reaches further — both measured from the baked run: an mROAS experiment wires in as experiment_TV_mroas_0_model_estimand, and a MultivariateMMM accepts outcome="Premium" to target one outcome of a multi-outcome model (observed RV experiment_TV_Premium_roas_0).

mroas_exp = ExperimentMeasurement("TV", period, value=..., se=0.10,
    estimand=ExperimentEstimand.MROAS, spend_lift_pct=10.0)

mv.add_experiment_calibration([ExperimentMeasurement("TV", period,
    value=measured_roas, se=0.10, outcome="Premium",   # multi-outcome
    estimand=ExperimentEstimand.ROAS)])

Limit — calibration is not contagious: stress-05 measured that calibrating one channel lands that channel on truth (Search 5.59 → 0.65 vs true 0.66) while an uncalibrated sibling stayed at −74% bias. One lift test fixes one channel. And stress-03 measured a single ROAS lift test cutting a confounded channel's bias from +110% to +14% — powerful, but the experiment itself must be trustworthy. See Measurement Calibration for the full decision guide.

§12–13 · Refutation & reporting

§12 — Causal refutation suite P0

Problem: good fit can coexist with severe bias. The suite perturbs the data and checks the model reacts correctly. The clearest test is the negative control: scramble the KPI and the model must become unfittable.

cfg = (ValidationConfigBuilder().silent()
       .with_causal_refutation(placebo=True, negative_control=True,
                               draws=120, tune=120, chains=2).build())
summary = ModelValidator(model).validate(cfg)
0.98
R² fitting the real KPI (measured)
0.03
R² fitting the scrambled KPI — collapses, as it must
ok
placebo-treatment test on this run (small draws; a flag here would be the suite working, not failing)

Measured: negative_control_outcome → ok (R² 0.98 → 0.03), placebo_treatment → ok. The notebook is explicit that a flagged test, when one fires, is the suite surfacing a problem loudly rather than letting it pass silently.

Limit — also NOT an all-clear: the stress series measured the refutation suite's placebo test with poor specificity — it fails on confounded worlds AND on some perfectly fine ones (stress-00). A pass does not certify the model and a flag does not condemn it; the suite is a tripwire, not a verdict. Use it alongside §6's sensitivity analysis and §11's calibration, never instead of them.

§13 — Causal-assumptions reporting P0 P2

Problem: stakeholders see a credible interval and assume the assumptions are handled. Reports now carry a Causal Assumptions section the extractor auto-populates from the model: the confounders adjusted for, an identification-strategy statement, and the robustness-value table.

from mmm_framework.reporting.extractors.bayesian import BayesianMMMExtractor
bundle = BayesianMMMExtractor(model).extract()
bundle.causal_assumptions["identification_strategy"]

Measured output: "Effects are identified by back-door adjustment for the designated confounder(s): Demand. SUTVA and no unobserved confounding are assumed; the estimate is causal only if every common cause of …" — with designated confounders ['Demand'] and the §6 robustness table rendered into the HTML report (see Aurora 04 · Reporting and the example report).

Limit: stating assumptions doesn't validate them — but it converts an implicit leap of faith into an explicit, reviewable claim. That is the point.

Honest limits: what this stack does and does not buy you

The pressure-testing series graded these features against synthetic worlds with known ground truth. The blunt summary:

The doctrine in one line

Diagnostics quantify how wrong you could be; only experiments tell you how wrong you are. Every feature on this page either makes an assumption explicit, prices its violation, or buys experimental evidence into the model — none of them replaces running the experiment.

Wrap-up

What to take away

  • Make assumptions explicit and enforce them — role typing refuses to fit with a mediator as a control (§1); identification checks certify the graph (§2–3); the report states the assumptions out loud (§13).
  • Quantify what the number rests on — robustness values (§6), collinearity and geo diagnostics (§7–8), data-anchored saturation (§9), and HDIs on marginal ROAS (§5) all price the fragility of the estimate.
  • Anchor to experiments — the prior route is simple and robust; the likelihood route updates β + saturation + adstock jointly, speaks ROAS/mROAS, and works on every model class. Measured: ROAS 0.43 → 0.68 against a 0.69 experiment.
  • No green light is an all-clear — the RV and the refutation suite are tripwires, not verdicts; the stress series measured both failing to distinguish clean from confounded worlds.
  • Make the process honest — lock the spec, diff against it, and catch the re-labelled confounder before it catches you (§10).

Run it yourself

The notebook is nbs/demos/causal_features_showcase.ipynb (no build script — it is authored directly and ships with baked outputs). The siblings: Aurora 01 tells the story version, Measurement Calibration covers the lift-test decision guide, Causal Inference the theory, and the stress series the measured failure modes. Source: github.com/redam94/mmm-framework.