Build MMMs of increasing complexity: adstock and saturation transforms, marketing funnel mediation, and hierarchical geo-varying effects.
Author
Benjamin Vincent
A marketing analyst runs a regression of weekly sales on TV and digital spend. The model says TV is weak and digital is strong. Based on this, the team shifts budget from TV to digital — and six months later, sales are down. What went wrong?
Several things might have been missed:
TV effects carry over across weeks and saturate at high spend levels — a linear regression captures neither
TV spend also drove search traffic, which drove sales — the regression gave search credit for TV’s work
TV was twice as effective in urban markets, but the model averaged over all regions
This notebook addresses all three problems with progressively richer models.
Model
What it adds
Key pathmc features
1. Transforms
Adstock carry-over + logistic saturation
adstock(), logistic_saturation(), panel mode
2. Marketing funnel
Direct vs indirect effects through a mediator
Labeled coefficients, := defined parameters, effect()
3. Hierarchical geo
Region-varying media coefficients
pooling={"slopes": [...]}, random slopes
Each model adds a new dimension to the analysis. You can stop after any section and apply what you’ve learned.
import numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport arviz as azimport pymc as pmimport pathmcFIG_WIDTH =8FIG_HEIGHT =4COLOR_TV ="#2171b5"COLOR_DIGITAL ="#e6550d"COLOR_BASELINE ="#636363"COLOR_BRAND ="#2171b5"COLOR_PERF ="#e6550d"COLOR_SEARCH ="#31a354"COLOR_INDIRECT ="#756bb1"
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Model 1: Adstock and saturation transforms
Two empirical regularities shape how advertising affects sales:
Adstock: effects carry over across time periods — a TV ad this week still influences next week’s sales. Geometric carry-over: y_t = x_t + \text{decay} \cdot y_{t-1}.
Logistic saturation: each additional unit of spend produces less incremental effect. y = (1 - \exp(-\lambda \cdot x)) / (1 + \exp(-\lambda \cdot x)), which is \tanh(\lambda x / 2).
This model captures both transforms in a panel of 4 regions over 30 weeks.
The causal structure
Figure 1: MMM DAG with adstock and logistic saturation transforms applied to TV and digital spend before entering the sales regression.
REGION_COLORS = {"North": "#2171b5","South": "#e6550d","East": "#31a354","West": "#756bb1",}fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))for region in regions: d = df[df["region"] == region] ax.plot(d["week"], d["sales"], label=region, color=REGION_COLORS[region], alpha=0.8)ax.set_xlabel("Week")ax.set_ylabel("Sales")ax.legend()plt.tight_layout()plt.show()
Figure 2: Simulated weekly sales for four regions with adstock carry-over and saturation effects.
Specify and fit the model
The DSL supports transforms directly in the formula. Adstock and logistic saturation compose naturally: logistic_saturation(adstock(tv, decay=theta_tv), lam=lam_tv). pooling="partial" adds a random intercept per region and drops the fixed intercept. The formula starts with 0 + so equations() does not print a phantom 1 + intercept that is not in the design matrix.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
/Users/benjamv/git/pathmc-worktrees/issue-538/.venv/lib/python3.12/site-packages/pytensor/link/c/cmodule.py:2053: UserWarning: `pytensor.config.cxx` is not an identifiable `g++` compiler. PyTensor will disable compiler optimizations specific to `g++`. At worst, this could cause slow downs.
Those parameters can be added manually via the `cxxflags` setting.
warnings.warn(
PyMC plate diagram for the panel MMM with adstock and saturation transforms.
Sample
NoteSaturation–coefficient degeneracy
The b * saturation(lam * x) parameterization creates a funnel-shaped degeneracy between the coefficient and saturation steepness. This can cause divergences and max tree depth warnings. Consider tighter priors, reparameterization, or a non-centered saturation formulation if sampling is difficult.
model.fit(random_seed=42)
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
NUTS[nutpie]: [alpha_sales_offset, sigma_alpha_sales, mu_alpha_sales, lam_dig, theta_dig, lam_tv, theta_tv, beta_sales, sigma_sales]
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Figure 3: Posterior predictive check by region: observed sales (points) vs posterior predictive mean (line).
Counterfactual: what if we doubled TV spend?
Using time-forward do(), we simulate what sales would have been under a different spend scenario. The transforms (adstock + saturation) are automatically recomputed under the intervention.
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/2237643227.py:4: UserWarning: Intervention value 60.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_double_tv = model.do(
Figure 4: Counterfactual analysis in data space. Observed sales (points) and model fit (solid line) alongside scenario predictions from do(). The vertical gap between bands is the causal effect of doubling TV spend.
The horizontal bands show the do() operator’s predictions: what the model expects average sales to be under each fixed-spend scenario.
Figure 5: Left: posterior distributions of mean sales under baseline and doubled TV spend. Right: posterior distribution of the causal effect (incremental sales from doubling TV) with 94% HDI.
With saturation transforms, doubling TV spend does not double the effect — diminishing returns kick in.
Channel contribution
How much does each channel contribute to sales at the same spend level? We set each channel to 30 while holding the other at zero, then compare the lift over a no-spend baseline.
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1376975462.py:1: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_no_spend = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1376975462.py:1: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_no_spend = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1376975462.py:4: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv_only = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1376975462.py:7: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig_only = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1376975462.py:7: UserWarning: Intervention value 30.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig_only = model.do(
Figure 6: Posterior distributions of incremental sales lift from TV and digital channels at equal spend (30 units each), relative to a no-spend baseline.
Diminishing returns curve
The saturation transform means each additional unit of spend produces less incremental sales. We trace out this curve by running do() at multiple spend levels.
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 5.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 5.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 10.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 30.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 40.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 50.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 50.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 60.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 60.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 80.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:6: UserWarning: Intervention value 0.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_tv = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 0.00 for 'tv' is outside the observed data range [10.71, 48.65]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1829389347.py:12: UserWarning: Intervention value 80.00 for 'digital' is outside the observed data range [5.18, 29.81]. Results are extrapolations and should be interpreted with caution.
r_dig = model.do(
Figure 7: Posterior mean incremental sales as a function of spend for each channel. The concave shape reflects logistic saturation.
Model 1 captures the nonlinear response of each channel — adstock and saturation transform raw spend into effective exposure. But it treats each channel as an independent input. In reality, upper-funnel spend (brand campaigns, TV) doesn’t just drive sales directly — it also lifts search traffic, website visits, and other lower-funnel indicators that drive purchases in turn. Ignoring this funnel structure means upper-funnel spend looks weak while the downstream indicators it generated get all the credit.
Model 2: Marketing funnel with mediation
Upper-funnel advertising (brand campaigns, TV, YouTube) doesn’t just drive sales directly. It also lifts awareness, which surfaces as increased organic search traffic, website visits, and lower-funnel engagement — and those indicators drive purchases in turn.
This causal chain is a mediation structure: brand spend affects sales both directly and indirectly through a lower-funnel mediator. Standard MMMs that ignore this funnel face an attribution problem:
Brand spend looks weak — part of its effect is “stolen” by search/organic variables
By modelling the funnel explicitly as a path model, we can decompose brand’s total effect into its direct and indirect components, and make better budget allocation decisions.
This model uses cross-sectional data to focus on the mediation structure itself — the funnel logic applies equally to panel and cross-sectional settings.
The causal structure
Figure 8: Marketing funnel DAG. Brand spend drives sales directly (c) and indirectly through search traffic (a × b). Performance spend has a direct effect only. Seasonality is a common cause of both search traffic and sales.
Effect
Formula
Interpretation
Direct
c
Brand → Sales, not through search
Indirect
a × b
Brand → Search → Sales
Total
c + a × b
Full causal effect of brand spend
Seasonality is a confounder of search traffic and sales — it must appear in both equations to avoid bias, but it is not on the causal path from brand spend to sales.
Figure 9: Pairwise scatter plots of brand spend, search traffic, and sales. The positive association between brand spend and search traffic reflects the upper-to-lower funnel link.
The attribution trap
WarningWhy naive regression misleads
Regressing sales ~ brand_spend + perf_spend + search_traffic + season estimates the direct effect of brand spend (≈ 0.25), not the total effect (≈ 0.55). By conditioning on the mediator search_traffic, the regression blocks the indirect path brand → search → sales.
This is correct if you want the direct effect. But for budget allocation — “how much total revenue does brand spend generate?” — you need the total effect, which requires modelling the funnel as a causal structure.
Specify and fit the path model
The spec encodes both equations in the funnel, with labeled coefficients for the paths we care about and a defined parameter for the indirect effect.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
PyMC plate diagram for the marketing funnel path model.
Sample
idata = model.fit(random_seed=42)
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
NUTS[nutpie]: [beta_sales, sigma_search_traffic, beta_search_traffic, sigma_sales]
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Results
model.summary()
mean
sd
eti89_lb
eti89_ub
ess_bulk
ess_tail
r_hat
mcse_mean
mcse_sd
beta_sales[Intercept]
0.082508
0.119687
-0.111247
0.272075
2353.949909
2589.308904
1.000677
0.002466
0.001742
beta_sales[search_traffic]
0.480797
0.056485
0.392478
0.572599
1214.665163
1425.753515
1.000525
0.001623
0.001184
beta_sales[brand_spend]
0.264196
0.037691
0.202557
0.323914
1176.472505
1546.068704
1.000928
0.001103
0.000806
beta_sales[perf_spend]
0.673370
0.019663
0.641903
0.705246
2652.147477
2713.501299
1.000843
0.000382
0.000275
beta_sales[season]
0.265064
0.050934
0.184085
0.348701
3282.209749
3022.040658
0.999869
0.000891
0.000624
...
...
...
...
...
...
...
...
...
...
mu_search_traffic[495]
4.298691
0.055507
4.209698
4.385626
4973.440710
3561.104854
1.000298
0.000787
0.000551
mu_search_traffic[496]
4.362853
0.087283
4.221610
4.506116
4597.811582
3109.240903
1.000339
0.001289
0.000887
mu_search_traffic[497]
-0.098858
0.067698
-0.205729
0.006947
2657.122110
2649.520260
1.000851
0.001313
0.000957
mu_search_traffic[498]
1.852374
0.082187
1.722139
1.985987
5396.942356
3271.651578
0.999540
0.001118
0.000816
mu_search_traffic[499]
4.769232
0.081254
4.639807
4.900159
4212.959167
3369.305338
1.000458
0.001251
0.000855
1010 rows × 9 columns
model.effects_summary()
mean
sd
hdi_3%
hdi_97%
name
a
0.619181
0.012270
0.597171
0.643336
b
0.480797
0.056485
0.382269
0.595773
c
0.264196
0.037691
0.191312
0.334436
d
0.673370
0.019663
0.636183
0.709723
indirect
0.297681
0.035282
0.234848
0.366869
TipReading the effects table
a: brand → search (true = 0.6)
b: search → sales (true = 0.5)
c: brand → sales, direct (true = 0.25)
d: perf → sales (true = 0.7)
indirect: a × b (true = 0.3)
The total brand effect is c + indirect ≈ 0.55.
Path-specific effects
The effect() method multiplies posterior draws along a specified path, giving the full posterior distribution of path-specific effects.
Figure 10: Posterior distributions of brand spend’s direct effect (c), indirect effect (a × b), and total effect (c + a × b). True values shown as dashed lines.
The indirect effect is comparable in magnitude to the direct effect — a naive regression that conditions on search traffic would miss roughly half of brand’s total contribution.
Is brand_spend → sales identifiable? True
Valid adjustment sets: [set()]
The empty set is a valid adjustment set because brand spend has no confounders (it is exogenous in this DAG).
model.standardized()
predictor
outcome
mean
sd
hdi_3%
hdi_97%
name
a
brand_spend
search_traffic
0.904982
0.017933
0.872812
0.940286
b
search_traffic
sales
0.378172
0.044428
0.300674
0.468606
c
brand_spend
sales
0.303721
0.043329
0.219934
0.384470
d
perf_spend
sales
0.611731
0.017863
0.577948
0.644756
Causal queries
Total causal effect of brand spend
The .ate() method computes the average treatment effect by contrasting two interventions — including both the direct path and the indirect path through search traffic:
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Sampling: [sales, search_traffic]
P(sales > 0 | do(brand_spend=8)): 1.00
Comparing channel effectiveness
Which channel produces more incremental sales per unit of spend?
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1173457870.py:1: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_base = model.do(set={"brand_spend": 0.0, "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1173457870.py:1: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_base = model.do(set={"brand_spend": 0.0, "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1173457870.py:3: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_brand_only = model.do(set={"brand_spend": 5.0, "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1173457870.py:4: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_perf_only = model.do(set={"brand_spend": 0.0, "perf_spend": 5.0})
Incremental sales from 5 units of brand spend:
brand_lift
DoResult — 4000 draws, 5 variables
variable
mean
94% HDI
brand_spend
5.00
[5.00, 5.00]
season
0.00
[0.00, 0.00]
perf_spend
0.00
[0.00, 0.00]
search_traffic
3.10
[2.99, 3.22]
sales
2.81
[2.65, 2.96]
Incremental sales from 5 units of performance spend:
Figure 11: Posterior distributions of incremental sales from 5 units of brand spend vs 5 units of performance spend. Brand’s total effect includes the indirect path through search traffic.
What does brand spend do to search traffic?
Because the model includes the funnel equation, do() also gives us the downstream effect on the mediator.
The expected lift in search traffic from 6 units of brand spend is true_a × 6.
Budget allocation
Code
spend_levels = np.array([0, 1, 2, 3, 5, 7, 10])brand_draws_list, perf_draws_list = [], []for s in spend_levels: r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0}) lift_b = r_b - r_base brand_draws_list.append(lift_b.draws("sales")) r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)}) lift_p = r_p - r_base perf_draws_list.append(lift_p.draws("sales"))brand_draws_2d = np.column_stack(brand_draws_list)perf_draws_2d = np.column_stack(perf_draws_list)fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT))ax.plot( spend_levels, brand_draws_2d.mean(axis=0),"o-", color=COLOR_BRAND, label="Brand (total)", lw=2, ms=5,)hdi_3 = az.hdi(brand_draws_2d, prob=0.94, axis=0)ax.fill_between(spend_levels, hdi_3[:, 0], hdi_3[:, 1], alpha=0.15, color=COLOR_BRAND)ax.plot( spend_levels, perf_draws_2d.mean(axis=0),"s-", color=COLOR_PERF, label="Performance", lw=2, ms=5,)hdi_4 = az.hdi(perf_draws_2d, prob=0.94, axis=0)ax.fill_between(spend_levels, hdi_4[:, 0], hdi_4[:, 1], alpha=0.15, color=COLOR_PERF)ax.plot( spend_levels, [true_total_brand * s for s in spend_levels],"--", color=COLOR_BRAND, alpha=0.5, label=f"True brand slope ({true_total_brand})",)ax.plot( spend_levels, [true_d * s for s in spend_levels],"--", color=COLOR_PERF, alpha=0.5, label=f"True perf slope ({true_d})",)ax.set_xlabel("Channel spend (units)")ax.set_ylabel("Incremental sales vs zero spend")ax.legend(fontsize=8)ax.axhline(0, color="black", ls=":", alpha=0.3)plt.tight_layout()plt.show()
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 10.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:6: UserWarning: Intervention value 0.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_b = model.do(set={"brand_spend": float(s), "perf_spend": 0.0})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 0.00 for 'brand_spend' is outside the observed data range [0.01, 9.99]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
/var/folders/pd/p2qnky2x3xl4w3mgc4lct2200000gn/T/ipykernel_23316/1959139574.py:10: UserWarning: Intervention value 10.00 for 'perf_spend' is outside the observed data range [0.00, 8.00]. Results are extrapolations and should be interpreted with caution.
r_p = model.do(set={"brand_spend": 0.0, "perf_spend": float(s)})
Figure 12: Sales response curves for brand and performance channels via do() at multiple spend levels. Brand’s curve includes the indirect effect through search. Dashed lines show true slopes.
In this linear model the response curves are straight lines. Performance spend has a steeper marginal effect (0.7 vs 0.55 per unit), but brand spend is not far behind — and a flat regression that conditions on search traffic would underestimate brand at 0.25.
Model 2 separates direct from indirect effects, solving the attribution problem. But it treats all observations identically — a single coefficient for TV effectiveness everywhere. In practice, TV might drive more sales in one region due to higher viewership, while digital outperforms in another where the audience skews younger. A hierarchical model lets each region have its own media coefficients while borrowing strength from the full panel.
Model 3: Hierarchical geo-varying effects
Marketing channels rarely have the same effectiveness everywhere. This model fits a hierarchical panel MMM with geo-varying media coefficients using random slopes. Each region gets its own TV and digital effect, partially pooled toward a shared mean — borrowing strength across geos while allowing genuine heterogeneity.
No transforms (adstock, saturation) are used here; the focus is on the hierarchical structure itself. In practice, you would combine random slopes with the transforms from Model 1.
where \alpha_g is a random intercept and u_{g,\text{tv}}, u_{g,\text{dig}} are random slopes — each drawn from a shared normal with estimated mean and scale.
Simulate panel data
rng = np.random.default_rng(42)regions = ["North", "South", "East", "West", "Central", "Coast"]n_weeks =40true_mu_tv =0.6true_sigma_tv =0.15true_mu_dig =0.4true_sigma_dig =0.10true_tv_effects = {r: rng.normal(true_mu_tv, true_sigma_tv) for r in regions}true_dig_effects = {r: rng.normal(true_mu_dig, true_sigma_dig) for r in regions}true_intercepts = {r: rng.uniform(40, 70) for r in regions}rows = []for region in regions:for week inrange(1, n_weeks +1): tv = rng.uniform(5, 40) digital = rng.uniform(3, 25) sales = ( true_intercepts[region]+ true_tv_effects[region] * tv+ true_dig_effects[region] * digital+0.1* week+ rng.normal(scale=2.0) ) rows.append({"region": region,"week": week,"tv": tv,"digital": digital,"trend": week,"sales": sales, })df = pd.DataFrame(rows)print(f"Panel: {len(regions)} regions × {n_weeks} weeks = {len(df)} rows")df.head()
Panel: 6 regions × 40 weeks = 240 rows
region
week
tv
digital
trend
sales
0
North
1
33.967091
16.896617
1
87.953690
1
North
2
17.408409
24.355357
2
80.501138
2
North
3
32.243422
7.282052
3
84.506308
3
North
4
6.533132
6.394369
4
67.435582
4
North
5
31.066675
24.285214
5
88.876004
true_effects = pd.DataFrame({"region": regions,"tv_effect": [true_tv_effects[r] for r in regions],"digital_effect": [true_dig_effects[r] for r in regions],})true_effects
Figure 13: Simulated weekly sales for six regions. Differences in level reflect random intercepts; differences in responsiveness to spend reflect random slopes.
Specify and fit the model
The pooling argument controls the hierarchical structure:
"intercept": True gives each region its own baseline sales level. The compiler drops the fixed intercept either way; 0 + keeps equations() from printing a phantom 1 +
"slopes": ["tv", "digital"] gives each region its own media coefficients, partially pooled toward a shared mean
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
PyMC plate diagram for the hierarchical panel MMM with random intercepts and random slopes for TV and digital.
Sample
This fit pins nuts_sampler="nutpie", which is the sampler that produced the published sample. Unit intercepts and slopes are sampled as a unit-normal offset, so a full model.summary() leads with the free *_offset rows and truncates the centered slopes. The nutpie run below has not converged; the centered digital-slope table in the results section is the one with the poor diagnostics.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
NUTS[nutpie]: [slope_sales_digital_offset, sigma_slope_sales_digital, mu_slope_sales_digital, slope_sales_tv_offset, sigma_slope_sales_tv, mu_slope_sales_tv, alpha_sales_offset, sigma_alpha_sales, mu_alpha_sales, beta_sales, sigma_sales]
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Results
Population-level coefficients
The centered mu_slope_sales_digital and slope_sales_digital rows in the table below have r-hat up to 1.10 and ESS down to 79. Those rows are not geo-level digital effects, and a mean of -0.13 is not digital effectiveness. The violins and the digital ATE below are the sums of the fixed digital coefficient and the region slope, not these rows and not the free offset rows.
dig_sum
mean
sd
eti89_lb
eti89_ub
ess_bulk
ess_tail
r_hat
mcse_mean
mcse_sd
mu_slope_sales_digital
-0.1300
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[Central]
-0.0049
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[Coast]
-0.0650
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[East]
-0.2100
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[North]
-0.1100
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[South]
-0.1600
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
slope_sales_digital[West]
-0.2600
7.5
-12.0
12.0
79.0
130.0
1.1
0.85
0.6
A full model.summary() leads with the free *_offset rows. The centered slopes above are Deterministics, so that truncation omits them.
model.summary()
mean
sd
eti89_lb
eti89_ub
ess_bulk
ess_tail
r_hat
mcse_mean
mcse_sd
slope_sales_digital_offset[Central]
1.074407
0.625883
0.106653
2.091971
3800.410709
3184.459452
1.001309
0.010161
0.007379
slope_sales_digital_offset[Coast]
0.585805
0.590172
-0.321137
1.538282
4516.742085
3384.609401
1.000161
0.008802
0.006754
slope_sales_digital_offset[East]
-0.627445
0.592062
-1.606242
0.278678
4236.854533
3223.289025
1.002422
0.009161
0.006873
slope_sales_digital_offset[North]
0.184496
0.571846
-0.697980
1.121711
4751.143005
3281.436068
1.002060
0.008283
0.006448
slope_sales_digital_offset[South]
-0.232237
0.558328
-1.140999
0.641637
4793.347403
3518.234152
1.001893
0.008062
0.005867
...
...
...
...
...
...
...
...
...
...
alpha_sales[Coast]
41.712281
1.104655
39.931861
43.484389
4671.229048
3346.011794
1.000589
0.016186
0.011712
alpha_sales[East]
54.552780
0.869535
53.149499
55.963285
4684.781767
3535.545719
0.999776
0.012718
0.009202
alpha_sales[North]
59.412911
0.955428
57.896224
60.905045
4443.834403
3609.662074
0.999872
0.014362
0.010417
alpha_sales[South]
64.537690
1.016773
62.949577
66.166719
4586.194859
3757.129135
1.000583
0.015017
0.010751
alpha_sales[West]
48.953645
1.000628
47.331915
50.565678
4597.520207
3519.740580
1.001402
0.014783
0.010523
286 rows × 9 columns
Geo-level TV effects
The random slopes capture how each region deviates from the population mean. The posterior for each region’s TV effect is the sum of the fixed effect and the region-specific deviation.
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT *1.2))beta_tv = idata.posterior["beta_sales"].sel(sales_predictors="tv").values.flatten()slope_tv = idata.posterior["slope_sales_tv"]for i, region inenumerate(regions): region_slope = slope_tv.sel(unit=region).values.flatten() region_total = beta_tv + region_slope parts = ax.violinplot([region_total], positions=[i], showmedians=True, widths=0.7)for pc in parts["bodies"]: pc.set_facecolor(COLOR_TV) pc.set_alpha(0.4)for key in ["cmins", "cmaxes", "cbars", "cmedians"]:if key in parts: parts[key].set_color(COLOR_TV) ax.plot(i, true_tv_effects[region], "D", color="black", ms=6, zorder=5)ax.set_xticks(range(len(regions)))ax.set_xticklabels(regions)ax.set_ylabel("TV effect on sales")ax.axhline( true_mu_tv, color=COLOR_TV, ls="--", alpha=0.5, label=f"True pop. mean ({true_mu_tv})",)ax.legend(fontsize=8)plt.tight_layout()plt.show()
Figure 14: Posterior distributions of geo-level TV effects (violins) vs true values (black diamonds). Each region’s effect is the population mean plus a region-specific deviation, partially pooled toward the group mean.
Black diamonds show the true geo-level effects used in the DGP.
Geo-level digital effects
Code
fig, ax = plt.subplots(figsize=(FIG_WIDTH, FIG_HEIGHT *1.2))beta_dig = ( idata.posterior["beta_sales"].sel(sales_predictors="digital").values.flatten())slope_dig = idata.posterior["slope_sales_digital"]for i, region inenumerate(regions): region_slope = slope_dig.sel(unit=region).values.flatten() region_total = beta_dig + region_slope parts = ax.violinplot([region_total], positions=[i], showmedians=True, widths=0.7)for pc in parts["bodies"]: pc.set_facecolor(COLOR_DIGITAL) pc.set_alpha(0.4)for key in ["cmins", "cmaxes", "cbars", "cmedians"]:if key in parts: parts[key].set_color(COLOR_DIGITAL) ax.plot(i, true_dig_effects[region], "D", color="black", ms=6, zorder=5)ax.set_xticks(range(len(regions)))ax.set_xticklabels(regions)ax.set_ylabel("Digital effect on sales")ax.axhline( true_mu_dig, color=COLOR_DIGITAL, ls="--", alpha=0.5, label=f"True pop. mean ({true_mu_dig})",)ax.legend(fontsize=8)plt.tight_layout()plt.show()
Figure 15: Posterior of each region’s digital effect, the sum of the fixed digital coefficient and the region slope, against planted values (black diamonds). These sums are not the centered slope_sales_digital rows in the table above.
Shrinkage
The points below are posterior means of the fixed coefficient plus the region slope, against the planted geo effect. This panel is balanced, so the figure is not an example of shrinking regions that have less data. The horizontal lines are the fixed-coefficient means, not the group mean of those sums and not channel effectiveness.
Figure 16: True geo-level effect (x) versus posterior mean of the fixed coefficient plus the region slope (y), for TV (blue) and digital (orange). The diagonal is perfect recovery. The horizontal lines are fixed-coefficient means, not the group mean the points should shrink toward.
Hierarchical scale recovery
The estimated between-geo standard deviation tells us how much heterogeneity the model detected.
sigma_tv_post = idata.posterior["sigma_slope_sales_tv"].values.flatten()sigma_dig_post = idata.posterior["sigma_slope_sales_digital"].values.flatten()print(f"sigma_tv: posterior mean = {sigma_tv_post.mean():.3f} (true = {true_sigma_tv})")print(f"sigma_dig: posterior mean = {sigma_dig_post.mean():.3f} (true = {true_sigma_dig})")
sigma_tv: posterior mean = 0.245 (true = 0.15)
sigma_dig: posterior mean = 0.139 (true = 0.1)
Causal queries with geo-varying effects
The do() operator propagates both random intercepts and random slopes, so interventional queries reflect the full heterogeneity across geos.
Average treatment effect
.ate() computes the population-average causal effect by contrasting two intervention levels:
Only clang++ is supported. With g++, we end up with strange g++/OSX bugs.
Sampling: [sales]
P(sales > 10 | do(tv=30)): 1.00
Summary
Adstock and saturation transforms capture the nonlinear response of advertising channels — carry-over across time periods and diminishing returns at high spend levels.
Posterior predictive checks verify that the model can reproduce observed data patterns before running causal queries.
do() queries with transforms automatically recompute the full transform pipeline under the intervention, capturing how effects change nonlinearly with spend.
The marketing funnel is a mediation structure. Upper-funnel spend drives sales both directly and through lower-funnel indicators like search traffic. Conditioning on the mediator blocks the indirect path and underestimates upper-funnel’s contribution.
Path analysis with labeled coefficients gives the full posterior of direct, indirect, and total effects — the quantities that matter for budget allocation.
Hierarchical panel models let each region have its own media coefficient while borrowing strength from the full panel through partial pooling.
Random slopes are not a regional budget. TV intervals separate some regions. Digital intervals overlap, and the slope_sales_digital summary rows are not those effects.
NoteReflection
In your own marketing data:
Which channels might have indirect effects through downstream indicators like search traffic, app installs, or social engagement? If your current model includes these as independent predictors alongside the spend variables, it may be underestimating the channels that drive them.
Do channel effects vary by region, customer segment, or time period? Hierarchical models can capture this heterogeneity while borrowing strength where data is sparse.
Could diminishing returns mean your highest-spend channels are past the point of efficient spending? Response curves from do() queries can reveal this.