Math 01 · The Mathematics of Adstock
When you spend on advertising in week \(t\), the effect does not vanish in week \(t\).
A TV flight seen this week still nudges a purchase three weeks from now; a brand campaign
builds for a while before it peaks. Adstock (carryover) is the
function that turns a raw spend series \(x_t\) into an effective exposure series
that carries the past forward. This page derives every formula the framework uses, charts
every kernel live in your browser, and ends with the single most-misread fact in the
codebase: the legacy model's adstock_<channel> parameter is a
blend weight between two fixed kernels — not a decay rate
(the default since June 2026 estimates the kernel itself in-graph).
It mirrors nbs/math/math_01_adstock.ipynb, where every identity below is
encoded as an executable assert against the real framework functions.
The framework offers three related representations of adstock, plus a fourth thing that is easy to misread — the legacy blend mechanism that was the default before June 2026 (the current default is the parametric path, (c)):
| Representation | Where | Estimated? | |
|---|---|---|---|
| (a) | Geometric recurrence (IIR) | transforms.geometric_adstock | fixed \(\alpha\) |
| (b) | Normalized FIR kernels (geometric / delayed / Weibull) | transforms.adstock.adstock_weights + apply_adstock | fixed shape |
| (c) | In-graph PyTensor mirror of (b) | transforms.adstock_pt | differentiable, learned |
| (d) | The legacy BayesianMMM mechanism (pre-2026-06 default) | model.base | learns a blend weight, not a decay rate; the current default is (c) |
🧭 Before you start
Prerequisites: comfort with calculus & probability; the Workshop series recommended first. · Time: ~30–45 min.
You will learn:
- Why the geometric recurrence \(a_t = x_t + \alpha a_{t-1}\) is a convolution with kernel \(\alpha^k\), and why its total-effect multiplier \(1/(1-\alpha)\) explodes near \(\alpha = 1\).
- How normalized FIR kernels (geometric, delayed, Weibull) separate timing from size — the kernel sums to exactly 1, pushing the magnitude into \(\beta\).
- Why the legacy model's
adstock_<channel>parameter is a blend weight between two fixed kernels — not a decay rate (the current default, part (c), estimates the kernel itself).
Run it:
nbs/math/math_01_adstock.ipynb on GitHub.
(a) Carryover as a convolution: the geometric recurrence
The classic form is a one-line recurrence — an infinite impulse
response (IIR) filter, exactly what geometric_adstock(x, alpha) computes:
The decay rate \(\alpha\) is the share of last week's effective exposure that survives into this week. \(\alpha = 0\) means no carryover (the transform is the identity); \(\alpha \to 1\) means the effect barely fades.
Unrolling it: where the lag weights come from
Substitute the recurrence into itself, starting from \(a_0 = x_0\):
\[ \begin{aligned} a_1 &= x_1 + \alpha x_0,\\ a_2 &= x_2 + \alpha x_1 + \alpha^2 x_0,\\ &\;\;\vdots\\ a_t &= \sum_{k=0}^{t} \alpha^{k}\, x_{t-k}. \end{aligned} \]So the recurrence is secretly a causal convolution with the closed-form kernel \(w_k = \alpha^k\). A unit impulse at \(t=0\) — one unit of spend, then nothing — therefore produces the sequence \(1,\ \alpha,\ \alpha^2,\ \alpha^3,\dots\): the impulse response is the kernel, literally readable off the output.
Computed live in your browser — the same math as the notebook. Each line
is geometric_adstock applied to a unit impulse at \(t=0\). The response at
lag \(k\) is exactly \(\alpha^k\) — the notebook asserts this to \(10^{-9}\) against the
real framework function.
The total carryover multiplier \(1/(1-\alpha)\)
Summing the kernel over all lags is a geometric series:
\[ \sum_{k=0}^{\infty} \alpha^{k} \;=\; \frac{1}{1-\alpha}. \]This is the framework docstring's claim that "the total effect of a unit spend is scaled by \(1/(1-\alpha)\)." At \(\alpha = 0.5\) a single dollar of spend contributes the equivalent of two dollars of effective exposure once you add up all its future echoes; the notebook's measured sums are \(\{\alpha{=}0.2 \to 1.25,\ \alpha{=}0.5 \to 2.0,\ \alpha{=}0.8 \to 5.0\}\). Crucially this kernel is un-normalized: its weights sum to \(1/(1-\alpha) > 1\), not to \(1\). Hold that thought for part (b).
Computed live — same math as the notebook. The multiplier explodes as \(\alpha \to 1\), which is why \(\alpha\) is constrained to \([0,1)\) and why an estimated \(\alpha\) near the boundary is a red flag (infinite memory). The marked points are the notebook's asserted values.
💡 One recurrence, many channels
geometric_adstock_2d(X, alpha) applies the same recurrence independently
to each column of a \((T, n_\text{channels})\) spend matrix. It is the workhorse the
legacy blend path precomputes with — see part (d).
(b) Normalized FIR kernels: separating timing from size
The recurrence in (a) has infinite memory and can only ever peak at lag
0 — the current period is always the largest weight. The framework's
adstock_weights(kind, l_max, ...) instead builds an explicit
finite weight vector \(w = (w_0, w_1, \dots, w_{L-1})\) of length
l_max, and apply_adstock(x, w) performs the causal convolution
with zero left-padding (weeks before the series start count as \(0\)). The wrapper
parametric_adstock(x, kind, l_max=8, ...) does both steps at once.
normalize=True vs normalize=False — a magnitude convention, not a shape change
This is the conceptual hinge between (a) and (b). The shape of a geometric kernel is the same either way; what differs is where the total magnitude lives:
-
normalize=Falsekeeps the raw weights \(w_k = \alpha^k\). They sum to \(1/(1-\alpha) > 1\) — exactly the IIR multiplier from part (a). The carryover magnitude is inside the kernel. -
normalize=Truerescales to \(\tilde w_k = w_k \big/ \sum_j w_j\), so the weights sum to exactly \(1\). The kernel becomes a weighted moving average that only redistributes spend across time; the total magnitude is folded into the channel coefficient \(\beta\) downstream — which keeps \(\beta\) interpretable as the channel's total effect size.
Assert-encoded identities (notebook, verified against the real code)
- \(\texttt{normalize=True} \Rightarrow \sum_k \tilde w_k = 1\) for every \(\alpha\) tested.
- \(\texttt{normalize=False}\) geometric kernel applied to an impulse \(=\alpha^k\) exactly.
parametric_adstock(x, ...)\(\equiv\)apply_adstock(x, adstock_weights(...))(it is the wrapper).- The raw and normalized kernels have a constant ratio — identical shape, different area.
Computed live — same math as the notebook. Raw (\(\sum = 1/(1-\alpha)\)) vs normalized (\(\sum = 1\)) geometric kernels over a 20-lag window. Drag \(\alpha\): the bars rescale but the silhouettes stay proportional. At \(\alpha = 0.7\) the notebook measures raw sum 3.331 vs the infinite-series value 3.333 — the tiny gap is the truncation tail, the subject of the l_max section below.
Normalizing is the standard MMM choice because it cleanly separates timing (the kernel) from size (the coefficient) — but, as the identifiability section shows, that separation is exactly what creates the decay-vs-saturation tug-of-war.
(c) The kernel families: geometric, delayed, Weibull
Three kernel shapes are available, all returned by
adstock_weights and all mirrored symbolically in
transforms.adstock_pt (adstock_weights_pt,
apply_adstock_pt, parametric_adstock_pt) so NUTS can
differentiate through them and learn the parameters when
use_parametric_adstock=True:
Geometric — \(w_k = \alpha^k\). Same shape as (a) but truncated to \(L\) lags. Peak always at lag \(0\); it cannot express a delayed build.
Delayed geometric (Jin et al., 2017) — \(w_k = \alpha^{(k-\theta)^2}\). The squared exponent makes the weight maximal where \((k-\theta)^2\) is smallest, so the peak moves to lag \(\theta\). At \(\theta = 0\) the peak returns to lag 0, but the weights are then \(\alpha^{k^2}\) — decaying faster than geometric, so it is not literally the geometric kernel, only the same peak location. This is the kernel that can model a genuinely delayed peak.
Weibull (PDF form, lags shifted by 1 so lag 0 is well-defined for every shape):
\[ w_k \;\propto\; \frac{s}{\lambda}\!\left(\frac{k+1}{\lambda}\right)^{\,s-1} \exp\!\left[-\left(\frac{k+1}{\lambda}\right)^{s}\right], \]with shape \(s\) and scale \(\lambda\). The shape governs the silhouette: \(s < 1\) front-loads (most mass at lag 0, then a long thin tail — heavier than any geometric), \(s = 1\) is exponential, and \(s > 1\) pushes the peak out to a later lag as a smooth hump.
Computed live in your browser — the same math as the notebook's
adstock_weights. Normalized weights inside the chosen window
(faded bars: lags the window cuts off, shown out to lag 26). Watch the delayed kernel's
mass walk right as \(\theta\) grows (peak lag \(= \theta\), asserted in
the notebook) and the Weibull flip from front-load (\(s<1\)) through exponential
(\(s=1\)) to a delayed hump (\(s>2\)). The "mass beyond window" chip turns red when
l_max amputates more than 10% of the kernel's untruncated mass.
⚠️ What each family can and cannot express
Geometric can only decay from lag 0 — no delayed peak, ever. Delayed-geometric buys the peak shift but its \( \alpha^{(k-\theta)^2}\) tails die fast on both sides. Weibull with \(s<1\) is the only one with genuinely heavy tails; with \(s>1\) it gives a smooth asymmetric hump. When the world's carryover is delayed-Weibull and the model only has geometric, attribution does not degrade gracefully — see Stress 01, where exactly that mismatch produced an 88% median contribution error with green diagnostics.
(d) The legacy blend path — read this carefully
Here is the single most common point of confusion. Since June 2026 the model's
default is the parametric path of part (c)
(use_parametric_adstock=True): it estimates the actual kernel parameters
in-graph. But the framework still ships — and many older fits used — the
legacy blend path (use_parametric_adstock=False, the
default before the change), and on that path the model does not
estimate a decay rate at all. Instead it:
-
Precomputes geometric adstock at two fixed decay rates — a
low \(\alpha_\text{low}\) and a high \(\alpha_\text{high}\), taken from
adstock_alphas(defaults \([0.0,\ 0.3,\ 0.5,\ 0.7,\ 0.9]\), so \(\alpha_\text{low}=0\) is no carryover and \(\alpha_\text{high}=0.9\) is strong carryover) — giving two exposure series \(x^\text{low}\) and \(x^\text{high}\) per channel. -
Learns a blend weight per channel,
\(m \sim \text{Beta}(2,2)\) — the model variable named
adstock_<CHANNEL>— and mixes the two precomputed series:
Because convolution is linear, blending the series is the same as convolving with the blended kernel:
\[ w^{\text{blend}}_k \;=\; (1-m)\,w^{\text{low}}_k + m\,w^{\text{high}}_k \;=\; (1-m)\,\delta_{k0} \;+\; m\,\tilde w^{(0.9)}_k , \]
a convex combination of a spike at lag 0 and one fixed normalized geometric tail. So
adstock_<CHANNEL> is a mixing weight between two fixed-\(\alpha\)
geometric adstocks — it is not a decay rate. \(m \to 0\) pins the channel
to the short-memory series, \(m \to 1\) to the long-memory series. The notebook asserts the
endpoints exactly: \(m=0\) recovers \(x^\text{low}\), \(m=1\) recovers \(x^\text{high}\),
and any \(m \in (0,1)\) stays pointwise between the two. The \(\text{Beta}(2,2)\) prior is
symmetric and gently concentrated toward \(m = 0.5\). This design is cheap (no per-draw
recurrence) and well-behaved for the sampler.
Computed live — the literal model formula \((1-m)\,x^{low} + m\,x^{high}\) on a seeded bursty spend plan (flights of activity separated by dark weeks, as in the notebook). At \(m=0\) the exposure is the raw flights; at \(m=1\) each flight keeps "glowing" for weeks after it ends. Every intermediate curve is a convex blend of these two endpoints — nothing else is reachable.
The reachable kernel set — and what lives outside it
Computed live. The shaded region is every kernel the legacy blend can express as \(m\) sweeps \([0,1]\) (spike-at-0 plus a scaled \(\alpha=0.9\) tail). The dashed lines are a delayed kernel (\(\theta=3\)) and a Weibull hump (\(s=2.5\)): their peaks sit far above the band at lags 2–5. No value of \(m\) can bend the blend into a delayed peak — the family is structurally monotone from lag 0.
⚠️ Honest limits of the blend (measured, not theoretical)
- It cannot bend. In Stress 01, a world with delayed-Weibull carryover fit with the geometric family produced an 88% median contribution error (recorded matrix row: 80%) with r-hat ≤ 1.02 and zero divergences. The Weibull pivot roughly halved the error and doubled coverage — form was the actual problem.
-
Open finding (Stress 00): even on the clean world — drawn
from the model's own generative family — the legacy two-point blend recovers at
~28% median error vs 7% for the parametric path. The default
underperforms on its own home turf; until the default changes, prefer
use_parametric_adstock=True. See Stress 00 §7.
The parametric path is the alternative: set
use_parametric_adstock=True and the model estimates the actual kernel
parameters in-graph — \(\alpha\) (adstock_alpha_<CHANNEL>
\(\sim \text{Beta}(2,2)\)) for geometric/delayed, plus \(\theta\)
(adstock_theta_<CHANNEL> \(\sim\) HalfNormal) for delayed, or
\((s, \lambda)\) (adstock_shape/scale_<CHANNEL> \(\sim\) Gamma) for
Weibull. Both are legitimate; they answer different questions. The blend asks "how
much memory, on a 2-point scale?"; the parametric path asks "what is the precise
decay shape?" The framework even reuses the learned-kernel closure at predict time,
so perturbed spend is re-adstocked with the same kernel for marginal-ROAS
analysis. Workshop 03 fits a first model with
these settings end-to-end. This recommendation is stress-validated, not aesthetic:
Stress 01 measures exactly what happens when
the world's carryover is truly Weibull but the model assumes geometric.
IIR vs FIR truncation: what l_max cuts off
The recurrence (a) carries mass to infinity; an FIR kernel stops at lag \(L-1\). For the geometric family the amputated tail has a closed form: the un-normalized mass beyond the window is
\[ \sum_{k=L}^{\infty} \alpha^{k} \;=\; \frac{\alpha^{L}}{1-\alpha}, \qquad\text{i.e. a fraction } \alpha^{L} \text{ of the total } \frac{1}{1-\alpha}. \]
That fraction is what the notebook's measured 3.331 vs 3.333
(\(\alpha=0.7\), \(L=20\)) is showing: \(0.7^{20} \approx 0.08\%\) of the mass —
negligible. But at the framework default l_max=8 with \(\alpha = 0.9\),
\(0.9^{8} \approx 43\%\) of the kernel's mass is cut off. Normalization then silently
redistributes the surviving 57% to sum to 1, so the shape inside the window is
distorted too, not just shortened. The explorer above reports this number live for all
three families.
Computed live — exact formula \(\alpha^{L}\). Fraction of geometric
kernel mass beyond an l_max window, for four decay rates. The vertical line
marks the framework default l_max=8: harmless for fast decay, severe for
\(\alpha \ge 0.9\). Rule of thumb: choose \(L\) so \(\alpha^{L}\) is small — at
\(\alpha = 0.9\) you need \(L \approx 22\) just to get below 10%.
💡 Long windows are not free
A 26-week kernel has 26 weights to pin down from finitely many flights —
Stress 01 measures that even the correct
Weibull family at l_max=26 stays weakly identified from three years of
weekly data (error halves but remains above 20%, with honestly wide posteriors).
Window length trades truncation bias against estimation variance.
Identifiability: why decay and saturation fight each other
The framework's own adstock_weights docstring flags this, and it matters more
than any single formula. In an additive MMM each channel contributes
and with a normalized kernel the carryover magnitude lives entirely in \(\beta\). That leaves three knobs — decay shape, saturation strength, and \(\beta\) — competing to explain the same wiggle in the data. A long-carryover + weak-saturation fit and a short-carryover + strong-saturation fit can be nearly indistinguishable in-sample. This is equifinality: inherent to additive MMM, not a bug.
Computed live (seeded) — same construction as the notebook. Regime A: longer carryover (\(\alpha=0.70\)) + weak saturation (\(\lambda=4\)); regime B: shorter carryover (\(\alpha=0.50\)) + strong saturation (\(\lambda=8\)), both peak-normalized (\(\beta\) would absorb the scale). The notebook's seeded run measures their shape correlation at 0.9509 — the likelihood can barely tell them apart from observational data alone.
The framework's recommended mitigations, in increasing order of power:
- Informative priors on \(\alpha\)/saturation — don't let them roam to the boundary.
- Anchoring saturation to data percentiles (e.g.
SaturationConfig.compute_kappa_bounds_from_datafor the Hill path), so the half-saturation point is tied to observed spend levels — see Math 02 for the curve this feeds. - Experiment-calibrated coefficient priors (
mmm_framework.calibration) — the most powerful fix. A geo-lift test pins \(\beta\) directly, which breaks the trade-off: once magnitude is nailed down by randomized evidence, the decay/saturation split is far better determined. Math 05 derives the machinery; Stress 05 measures it under fire.
Recap: the four representations, reconciled
| Form | Equation | Weight sum | Estimated knob |
|---|---|---|---|
| (a) IIR recurrence | \(a_t = x_t + \alpha a_{t-1}\) | \(1/(1-\alpha)\) | fixed \(\alpha\) |
(b) FIR, normalize=False | \(y_t = \sum_k \alpha^k x_{t-k}\) | \(1/(1-\alpha)\) (minus tail) | fixed shape |
(b) FIR, normalize=True | \(y_t = \sum_k \tilde w_k x_{t-k}\) | \(1\) (magnitude \(\to \beta\)) | fixed shape |
| (c) PyTensor mirror | same as (b) | per normalize | learned \(\alpha, \theta, s, \lambda\) |
| (d) default model blend | \((1-m)x^{\text{low}} + m\,x^{\text{high}}\) | — | learned mix \(m\) |
What to take away
- The geometric recurrence \(a_t = x_t + \alpha a_{t-1}\) is a convolution with kernel \(\alpha^k\); its total-effect multiplier is \(1/(1-\alpha)\), which explodes near \(\alpha = 1\).
- Geometric weights are un-normalized (\(\sum = 1/(1-\alpha)\)) unless you normalize; the normalized FIR kernel sums to exactly 1 and pushes the magnitude into \(\beta\) — that's what keeps \(\beta\) interpretable.
- Only the delayed and Weibull (\(s>1\)) families can express a delayed peak; only Weibull (\(s<1\)) has genuinely heavy tails. Geometric can never bend — and when the world bends, the answer bends instead (88% median error, Stress 01).
- The default model's
adstock_<CHANNEL>\(\sim \text{Beta}(2,2)\) is a blend weight between fixed \(\alpha=0\) and \(\alpha=0.9\) geometric adstocks — not a decay rate. The blend recovers even the clean world at ~28% vs 7% parametric; preferuse_parametric_adstock=True. l_maxtruncation cuts a fraction \(\alpha^{L}\) of geometric mass: negligible at \(\alpha=0.7,\ L=20\) (0.08%), brutal at \(\alpha=0.9,\ L=8\) (43%).
Check your understanding
Commit to an answer before opening each one.
In what sense is the geometric recurrence at = xt + α at−1 secretly a convolution?
Unrolling the recurrence gives a causal convolution whose kernel is the closed form wk = αk, so a unit impulse of spend produces exactly the sequence 1, α, α2, … — the impulse response is the kernel. Summing that kernel is a geometric series, which is where the total-effect multiplier 1/(1 − α) comes from, and why it explodes as α approaches 1. Section (a) derives both facts.
On the legacy blend path, what does the model variable named adstock_<CHANNEL> actually represent — and what is it commonly misread as?
It is a Beta(2,2) blend weight m that mixes two precomputed geometric adstock series at fixed decay rates (α = 0, no carryover, and α = 0.9, strong carryover) — it is not a decay rate, though it is often misread as one. Because the reachable family is a convex combination of a lag-0 spike and one fixed tail, no value of m can express a delayed peak. Section (d) covers this; the current parametric default estimates a real per-channel α in-graph instead.
What does l_max truncation cut off, and when does it distort the kernel rather than merely shorten it?
A finite FIR window drops a fraction αL of the geometric kernel's mass — negligible for fast decay (0.08% at α = 0.7, L = 20) but severe for slow decay (about 43% at α = 0.9 with the default l_max of 8). With normalization on, the surviving weights are rescaled to sum to 1, so the shape inside the window is distorted too, not only shortened. See the "IIR vs FIR truncation" section.
💡 Run it yourself
This page mirrors nbs/math/math_01_adstock.ipynb, authored by
nbs/builders/build_math_01_adstock.py and documented in
nbs/README_math.md. Every identity above is an executable
assert against the real mmm_framework.transforms functions —
if the notebook runs clean, the math on this page is still true. Source:
github.com/redam94/mmm-framework.
Next: Math 02 · Saturation, the other
nonlinearity — the curve adstock feeds into and fights for identifiability.