Causal Inference 03: Structural Mediation

Veranda Home's CMO believes something no flat MMM can even express: TV doesn't sell patio sets this week — it builds awareness, awareness feeds consideration, and consideration converts to sales over months. Notebook 02 ended with a warning: conditioning on a funnel variable amputates the effect flowing through it. The fix is not to ignore the funnel — it's to model it. This guide mirrors nbs/causal/causal_03_structural_mediation.ipynb: the brand-funnel world is posited as a structural causal model and fit jointly against binomial and Likert survey data with StructuralNestedMMM — then every structural claim is graded against the world's sealed answer key.

5 / 5
structural parameters recovered inside their 90% posterior intervals
0.95
correlation with the true latent demand series — a state with no survey at all
4.4 wk
estimated brand-stock half-life (sealed truth: ρ = 0.85 ⇒ 4.3 weeks)
≈ 0.00
Search's estimated mediated share — the sealed key says exactly 0

🧭 Before you start

Prerequisites: Causal 02 (the MMM as a causal model, and the bad-control trap this page resolves).  ·  Time: ~25–35 min.

You will learn:

  • Why the answer to "awareness is a bad control" is a state equation, not a column — modeling the mediator keeps the causal path intact and lets survey data discipline it.
  • How one PyMC graph joins media, an AR(1) awareness state observed through a varying-n binomial tracker, a Likert-measured consideration index, a latent demand confounder, and sales — so survey information flows backwards into the media coefficients.
  • What structure buys (mechanism parameters, flighting cadence, funnel-stage attribution) and what it honestly does not (tighter totals).

Run it: nbs/causal/causal_03_structural_mediation.ipynb on GitHub.

1 — The world: spend, sales, and two surveys

The brand-funnel world (make_brand_funnel in src/mmm_framework/synth/dgp.py) ships the standard dashboard — 156 weeks, four channels, sales, price — plus the two survey tracks a real brand team would run. The structure the sealed key encodes:

The awareness state equation, in the model's own terms: \( z_t = \mu + \rho\,(z_{t-1} - \mu) + \beta_{\mathrm{TV}}\, f(s_t) + \varepsilon_t \), with the tracker observing \( \mathrm{count}_t \sim \mathrm{Binomial}(n_t,\, \sigma(z_t)) \). Persistence ρ is the parameter a media planner actually wants: the implied brand-stock half-life is \( t_{1/2} = \ln 0.5 / \ln \rho \).

2 — The wrong model first: a flat MMM

Hand this world to the standard four-channel MMM (surveys ignored — a flat model has nowhere to put them). Two honest observations, both measured in the notebook run:

  1. The flat model's totals are not crazy. Mediation by itself doesn't bias a total-effect estimate — the effect still flows from spend to sales. Graded against the sealed key, the flat contribution errors are TV +27%, Display −25%, Social +39%, Search −0%, and all four truths land inside their 90% intervals. (The latent demand confounder does the damage here, mildly.)
  2. The flat model has no opinion about anything the CMO actually asked. How long does brand stock last? Which funnel stage does each channel move? What is a survey point worth? Those aren't parameters it has.

3 — Positing the structure

Now say what we believe, as a graph. StructuralNestedMMM takes the funnel as configuration: each mediator declares its drivers (channels, parent mediators, controls, latent factors), its state dynamics, and its measurement — binomial for the awareness tracker, cumulative-logit ordered for the Likert. The latent demand factor is declared with no measurement of its own: it is identified purely by the co-movement it induces between consideration and sales.

from mmm_framework.mmm_extensions import (
    StructuralNestedConfig,
    binary_survey_mediator,
    likert_mediator,
    latent_demand_factor,
)

cfg = StructuralNestedConfig(
    mediators=(
        binary_survey_mediator(
            "awareness", ["TV"], persistence="high", affects_outcome=False
        ),
        likert_mediator(
            "consideration",
            ["Display", "Social"],
            parents=["awareness"],
            controls=["Price"],
            latent_factors=["demand"],
            n_categories=5,
        ),
    ),
    latent_factors=(latent_demand_factor("demand"),),
)

Everything — media effects, funnel edges, survey measurement, latent demand, sales — becomes one PyMC graph, so survey information flows backwards to discipline the media coefficients, and uncertainty flows forward from the surveys into every causal claim:

from mmm_framework.mmm_extensions import StructuralNestedMMM
from mmm_framework.synth.dgp import make_brand_funnel

world = make_brand_funnel()               # sealed answer key in world.notes
funnel = StructuralNestedMMM(
    world.spend.to_numpy(float), world.y.to_numpy(float),
    world.channels, cfg,
    mediator_data={"awareness": world.notes["awareness_counts"],
                   "consideration": world.notes["consideration_counts"]},
    mediator_trials={"awareness": world.notes["awareness_trials"]},
    X_controls=world.controls.to_numpy(float), control_names=["Price"],
    index=world.weeks,
)
funnel.fit(draws=500, tune=1000, chains=4, nuts_sampler="numpyro")

mediation = funnel.get_mediation_effects()   # exact posterior counterfactuals
pathways = funnel.get_pathway_effects()      # per-path decomposition
roas = funnel.get_channel_roas()

4 — The joint fit, graded parameter by parameter

This is the one genuinely expensive fit of the series: full NUTS, 4 chains × 500 draws after 1,000 tuning steps — 741 seconds (~12 minutes) in the notebook run, max r-hat 1.067, under the 1.1 bar the framework's own recovery test pins. (Approximate methods like MAP are documented-unstable for latent-state models; the series never uses them here.) Here is what a flat MMM could never produce — the mechanism itself, graded against the sealed key:

Measured — posterior mean and 90% interval per structural parameter vs the sealed true value (near-black tick). ρ = 0.854 vs 0.85 true; TV→awareness 1.00 vs 0.9; awareness→consideration 1.99 vs 2.5; price→consideration −0.98 vs −0.8; demand→consideration 0.59 vs 0.6. All five truths inside their 90% intervals. Numbers: nbs/artifacts/causal_03_mediation_recovery.json.

The persistence posterior is the headline for the media plan: ρ = 0.854 implies a brand-stock half-life of ≈ 4.4 weeks (sealed truth: ρ = 0.85 ⇒ 4.3 weeks). That single number — how fast the awareness a TV flight builds decays to half — is a flighting-cadence decision no flat MMM could have produced, recovered here from a noisy, gappy survey.

5 — Three unobserved series, recovered through their footprints

The model also had to infer three time series it never observed directly: the true awareness probability (through the binomial tracker — corr with truth 0.985), the latent consideration level (through the Likert counts — corr 0.995), and — hardest — the latent demand factor, which has no survey at all and is identified only by the co-movement it induces between consideration and sales (|corr| 0.951):

Measured — the recovered latent demand series (posterior mean, 90% band) against the true series from the sealed key, aligned for display (a latent with no measurement is identified up to sign/scale). Correlation with truth: 0.951. The awareness and consideration states recover even more tightly (0.985, 0.995); the notebook plots all three.

6 — The mediation decomposition

The headline for the CMO: how much of each channel's effect flows through the funnel? These are exact posterior counterfactuals — the model re-runs its own world with each channel off, holding the inferred shocks fixed — not back-of-envelope path products.

Measured — direct vs through-the-funnel contribution per channel (KPI units, notebook run). Estimated proportion mediated: TV 1.26, Display 0.69, Social 0.78, Search −0.00 — against sealed truths of 1.0, 1.0, 1.0, 0.0. TV's direct effect is −78.7k ± 57.5k: indistinguishable from zero, so its mediated-share posterior brackets 1.0. Search shows exactly no funnel path.

The funnel story survives grading: the brand channels flow through the funnel; Search doesn't. This is also the design-review guard the framework itself tests — mediated totals must be genuinely nonzero for brand channels (an in-graph centering bug would make them identically zero), and Search's mediated share must sit at ~0.

7 — What the structure bought (and what it didn't)

question flat MMM structural model sealed truth
total effects respectable (all 4 covered) comparable, wider (honest) ✓ graded above
how long does brand stock last? (no such parameter) ρ = 0.854 ⇒ ~4.4-week half-life ρ = 0.85 ⇒ 4.3 wks
which stage does each channel move? (no such parameter) TV→awareness; Display/Social→consideration
mediated share (no such parameter) ≈ 1.0 brand channels, ~0.00 Search 1.0 / 0.0
what is a survey point worth? ignored week-precision weighted, backwards-flowing

📌 The honest framing

The structural model is not a magic accuracy upgrade on totals — its total-effect intervals are wider than the flat model's, because it is honest about survey noise and latent-state uncertainty. What it buys is the mechanism: the parameters the CMO's actual decisions (flighting cadence, funnel-stage investment, survey program budget) depend on. And it resolves rung 2's trap cleanly: instead of conditioning on awareness (a bad control that amputates the mediated path), it models awareness — the path stays intact, and the surveys make it measurable. If you only need totals, rung 2 was enough. If your decisions mention the funnel, only structure answers the question you're asking.

Check your understanding

Commit to an answer before opening each one.

Notebook 02 called conditioning on awareness a "bad control." Why is modeling awareness different?

Conditioning on a mediator blocks the very path you are trying to measure — a regression that holds awareness fixed attributes none of TV's effect to TV. Modeling awareness as a latent state equation keeps the TV→awareness→consideration→sales path intact inside the graph; the surveys then discipline the state rather than amputating the path. The mediation numbers come from counterfactuals run through the funnel, not from a coefficient that pretends the funnel is frozen.

The structural model's total-effect intervals came out wider than the flat MMM's. Is that a defect?

No — it's the point. The flat model's narrower intervals are borrowed confidence: it silently ignores survey noise and latent-state uncertainty, sources of doubt that genuinely exist. The structural model carries them in the same posterior as the media effects. Both models' totals graded acceptably against the sealed key here; only one of them prices in what it doesn't know — and only one has mechanism parameters at all.

The latent demand factor has no survey. What identifies it — and how well did that work?

Its footprints: demand is declared as a smooth AR(1) factor loading on both consideration (which has a Likert survey) and sales. The co-movement between those two series that media, price and awareness cannot explain is attributed to the factor, with a sign anchor pinning its orientation. Measured recovery: |corr| = 0.951 with the true demand series, and the demand→consideration loading posterior (0.59, 90% CI [0.54, 0.66]) covers the sealed 0.6.

💡 Run it yourself

This guide mirrors nbs/causal/causal_03_structural_mediation.ipynb (Notebook 3 of the Causal Inference in Practice series), backed by the brand-funnel world in src/mmm_framework/synth/dgp.py. Every computational cell ends in a seeded assert encoding the claim it demonstrates — if the notebook executes clean, this page is still true. All numbers from nbs/artifacts/causal_03_mediation_recovery.json, produced by nbs/causal/causal_03_structural_mediation.ipynb. Next in the series: Causal 04 — Latent Confounders, the other structural move: when the confounder itself is invisible but leaves fingerprints on things you can measure.