learn-marketing-attribution-with-phoebe / Builder session 8 of 10
Learn Marketing Attribution with Phoebe · Builder session 8 of 10

Marketing Mix Modeling: adstock and saturation

Multi-touch attribution needs user identity - and in 2026 that identity is half gone. Marketing Mix Modeling sidesteps the whole problem: it regresses a weekly outcome KPI on aggregate channel spend, no cookies, no user IDs, nothing to lose to a browser update. But raw spend is a lie until you bend it two ways - adstock (an ad keeps working after it runs) and saturation (the tenth dollar buys less than the first). Today you build both transforms in numpy, fit a simple MMM on Lumen's spend_weekly table, and read a real budget-response curve off it.

🟠 Advanced Builders Meridian · PyMC 45 min
0-4 · Why MMM 4-20 · Adstock + saturation 20-40 · Build it on Lumen 40-45 · Wrap
Part 0

Why MMM, and why now

Every model you have built so far - heuristics, Markov, Shapley, GA4 DDA - needs to follow a person across touches. That means a user identity, and in 2026 that identity leaks through Safari, Firefox, ATT and SKAN aggregation. Marketing Mix Modeling throws the person away on purpose. It works on aggregate weekly spend - one row per week per channel, two to three years deep - and asks a top-down question: as spend moved, how did revenue move? No identity means nothing to break. That is why MMM, a technique from the 1960s, came roaring back.

Live - built in session Self-study - read after class ★ Build-along - run the code yourself Table: Lumen spend_weekly
★ What you walk out with today A working numpy adstock function, a Hill saturation transform, a fitted MMM on Lumen's spend_weekly, and a response curve you can read as "the next dollar into paid social is worth this much" - the raw material every budget-optimization argument is built from.
The one idea under everything today MMM never sees a customer. It only ever sees weekly totals - spend in, revenue out - so it is privacy-resilient by construction. The craft is entirely in how you transform spend before the regression sees it.
Part 1 · the carryover transform

Adstock: an ad keeps working after it runs 7 min live

Run a CTV burst this week and sales lift for weeks. That lag is adstock (carryover): the effect of spend decays across subsequent weeks rather than vanishing when the campaign ends. If you feed raw weekly spend into a regression you assume the effect dies the instant the week closes - which is false for every brand channel. Adstock spreads this week's spend forward before the model ever sees it.

One $100k spend spike in week 1 - where does its effect land? effect wk1 CTV · theta 0.7 (slow) wk1 Paid search · theta 0.1 (fast) One theta per channel. Brand/CTV carries for weeks; performance search is nearly spent the same week.
🔍 Click to zoom - the same spike decays slowly for CTV, fast for paid search (geometric adstock, one theta each)
LiveGeometric vs Weibull adstock3 min

Two ways to shape the decay, from simple to flexible:

  • Geometric decay - one number per channel, theta, the fraction of last week's effect that carries into this week. High theta (0.7) = slow-burning brand media like CTV; low theta (0.1) = fast performance media like paid search. This is the workhorse and what you will code today.
  • Weibull adstock - two parameters (shape and scale) that let the peak effect land after week zero and the tail bend how you like. More faithful for channels whose impact builds before it fades, at the cost of harder fitting. Meta Robyn uses Weibull; PyMC-Marketing offers both.
Real world

A brand fed raw (un-adstocked) spend into a regression and concluded CTV "did nothing" - because the sales it drove showed up two and three weeks later, credited to whatever ran then. Adding a theta of 0.65 to CTV moved its estimated contribution from near-zero to the third-largest channel. Same data, one transform, opposite decision.

Self-studyWhy carryover is not the same as saturation2 min read

Keep these two separate in your head - they are the two independent transforms of MMM and they answer different questions:

  • Adstock is about time: where does this week's effect land - now, or spread across the next several weeks?
  • Saturation is about amount: for a given week's effect, does doubling the spend double the response? (No - that is Part 2.)

In a real MMM pipeline you apply them in order: adstock first (spread spend over time), then saturation (bend the response). Get the order backwards and the curves fight each other.

Part 2 · diminishing returns

Saturation: the tenth dollar buys less than the first 6 min live

No channel scales forever. The first $10k into paid social reaches your hottest prospects; the hundredth reaches people who would have bought anyway or never will. That bend is saturation (diminishing returns), and it is modeled with an S-shaped or concave curve - the Hill function, Michaelis-Menten, or a simple log transform. This curve is not a footnote: it is the budget-response curve, the thing that tells you where the next dollar should go.

Response vs spend - the curve every budget decision reads from response spend → efficient steep = cheap wins saturation point flat = wasted spend next dollar barely moves response Doubling spend never doubles response. The marginal (slope) is what you optimize - shift dollars from flat channels to steep ones.
🔍 Click to zoom - a Hill saturation curve: steep and cheap early, flat and wasteful past the saturation point
LiveHill, Michaelis-Menten, or log?3 min

Three common shapes, same idea (response rises then flattens):

TransformShapeWhen to reach for it
HillFlexible S-curve (half-point + slope)Default in Robyn & Meridian; can be concave or S-shaped
Michaelis-MentenConcave, one saturation constantSimple diminishing returns, no S-bend needed
Log / powerAlways concaveQuick on-ramp; least faithful at the extremes

The Hill curve wins in practice because its two parameters (a half-saturation point and a slope) let it be gently concave for search and sharply S-shaped for a channel that needs a threshold of spend before anything happens.

The read that matters The slope of the saturation curve at your current spend is the marginal return. Optimize a budget by moving dollars off channels whose slope has flattened and onto channels still on the steep part. That single sentence is what MMM exists to enable.
Self-studyThe full MMM equation in one line2 min read

Putting the pieces together, a channel's contribution to weekly revenue is roughly:

revenue_week = base + Σ_channel [ coef · saturate( adstock( spend ) ) ] + seasonality + noise

Read it right to left: take raw weekly spend, apply adstock (spread over time), apply saturation (bend for diminishing returns), multiply by a learned coefficient, sum across channels, add a baseline and seasonality. Everything today builds toward fitting exactly this - just with a simple linear estimator instead of a full Bayesian one.

Part 3 · the toolbox

Robyn vs Meridian vs PyMC-Marketing 5 min live

You could hand-roll everything (and today you partly will, to understand it), but production MMM runs on one of three tools. The dividing line is point estimates vs Bayesian uncertainty: does the tool hand you one number per channel, or a whole distribution with a credible interval?

LiveThe three tools, and why Bayesian uncertainty matters3 min
ToolLanguage / engineWhat you get
Meta RobynR · ridge regression + Nevergrad evolutionary searchHill saturation + Weibull adstock; point estimates, many candidate models
Google MeridianPython · TensorFlow-Probability, Bayesian MCMCEstimates adstock + saturation inside the model; every parameter has an uncertainty interval; ingests reach & frequency; calibrate with experiment priors
PyMC-MarketingPython · PyMC, BayesianFull Bayesian MMM, geometric & Weibull adstock, Hill/logistic saturation, priors you can set

Robyn gives you a point estimate - "paid social contributed $1.2M". Meridian and PyMC-Marketing give you a distribution - "paid social contributed $1.2M, 90% credible interval $0.9M to $1.5M". For a budget decision that moves millions, the interval is the honest answer. It stops you from reallocating on noise.

Real world

Google deprecated LightweightMMM in January 2025 and pointed everyone to Meridian, its Bayesian successor. If you inherit an old LightweightMMM pipeline, the migration target is Meridian - do not build new work on the deprecated library.

Self-studyBayesian priors as the calibration hook2 min read

The reason the Bayesian tools matter for this whole course: because every parameter is a distribution with a prior, you can inject outside knowledge as that prior. Ran a geo-lift experiment that measured paid social's true incremental effect? Feed that as a prior on paid social's coefficient and the MMM is now calibrated to a causal result instead of pure correlation. That is exactly the loop Session 9 builds. Robyn's point estimates cannot receive a prior the same way - another reason the field tilted Bayesian.

Build-along 1 of 3

Prep spend_weekly + apply geometric adstock ★ 8 min · run it

Load Lumen's aggregate MMM table, pivot it to a weekly spend matrix, and write the geometric adstock function by hand. Notice there is not a single customer_id in sight - this is the privacy-resilient part.

Python · adstockimport numpy as np import pandas as pd # Lumen's aggregate MMM table: one row per week x channel (no user identity) spend = pd.read_csv("lumen_spend_weekly.csv", parse_dates=["week"]) # pivot to a weekly spend matrix: rows = weeks, columns = the 9 channels wide = (spend .pivot_table(index="week", columns="channel", values="spend", aggfunc="sum") .sort_index() .fillna(0.0)) def geometric_adstock(x, theta): """Carryover: each week keeps a fraction theta of last week's effect.""" y = np.zeros(len(x)) y[0] = x[0] for t in range(1, len(x)): y[t] = x[t] + theta * y[t - 1] return y # TV/CTV decays slowly (high theta), paid search fast (low theta) theta = {"ctv": 0.7, "display": 0.5, "paid_social": 0.4, "email": 0.3, "organic_search": 0.2, "paid_search": 0.1} adstocked = wide.copy() for ch in wide.columns: adstocked[ch] = geometric_adstock(wide[ch].values, theta.get(ch, 0.3)) print(adstocked[["ctv", "paid_search"]].head())

See the pivot. wide is a matrix of weeks by channels - the entire input to an MMM. Two to three years of these rows is all it needs. No journeys, no identity resolution, nothing a browser can revoke.

Read the loop. Each week adds this week's spend plus theta times last week's carried effect. That single recurrence is geometric adstock - the whole thing.

Compare the columns. Print CTV next to paid search and watch CTV's adstocked value stay elevated for weeks after a spike while paid search snaps back. That is the theta difference made visible.

Sanity check One theta per channel, always between 0 and 1. If a theta ever fits above 1 your effect grows forever - a bug, not a brand. Real tools bound it; your hand-rolled version should too.
Build-along 2 of 3

Hill saturation + fit a simple MMM ★ 8 min · run it

Apply a Hill saturation transform on top of the adstocked spend, then fit a simple ridge regression of Lumen's weekly revenue on the transformed channels. This is a real (if minimal) MMM.

Python · saturation + fitfrom sklearn.linear_model import Ridge def hill(x, half, slope=1.0): """Diminishing returns: response saturates as spend grows. half = spend at which we reach half of max response.""" xs = np.power(x, slope) return xs / (xs + np.power(half, slope)) # apply saturation on top of the adstocked spend (adstock first, then saturate) transformed = adstocked.copy() for ch in adstocked.columns: positive = adstocked[ch][adstocked[ch] > 0] half = positive.median() if len(positive) else 1.0 # rough anchor transformed[ch] = hill(adstocked[ch].values, half=half, slope=1.3) # weekly revenue is the outcome KPI - aggregate, no user identity weekly_rev = spend.groupby("week")["revenue"].sum().sort_index() X = transformed.loc[weekly_rev.index] # positive=True keeps channel contributions non-negative (spend cannot hurt) mmm = Ridge(alpha=1.0, positive=True).fit(X, weekly_rev) contrib = pd.Series(mmm.coef_, index=X.columns).sort_values(ascending=False) print(contrib.round(1))

The Hill function. At low spend the response climbs fast; as spend passes half, each extra dollar returns less. Change slope to 2.0 and the curve becomes S-shaped (a spend threshold before liftoff).

Order matters. Adstock ran first (Build-along 1), saturation second, regression third - the canonical MMM pipeline. Swap the first two and the transforms fight.

Read the coefficients. contrib ranks channels by their fitted contribution to weekly revenue. On Lumen this typically lifts paid social and CTV above where last-touch ever put them - because MMM finally credits the carryover and the demand they create.

Real world

A minimal ridge MMM like this is a teaching model, not a board deck. Production MMM adds seasonality controls, price and promo variables, a baseline term, and - critically - uncertainty. But the shape of the argument is identical: transform spend, regress on the KPI, read the contributions. You just built the skeleton the real thing hangs on.

Build-along 3 of 3

Response curve → a budget-optimization read ★ 7 min · run it

The saturation curve for one channel, turned into the sentence a media planner actually wants: where is the next dollar worth the most? Then the note on why you would graduate this to Meridian or PyMC-Marketing.

Python · response curve# sweep a spend grid through the same transforms, for one channel ch = "paid_social" grid = np.linspace(0, wide[ch].max() * 1.5, 60) positive = adstocked[ch][adstocked[ch] > 0] half = positive.median() coef = mmm.coef_[list(X.columns).index(ch)] # modeled response across the spend grid = the budget-response curve response = coef * hill(grid, half=half, slope=1.3) # marginal return = slope of the curve; invest where marginal is highest marginal = np.gradient(response, grid) knee = grid[np.argmax(marginal < marginal.max() * 0.2)] print(f"{ch}: response flattens near ${knee:,.0f}/wk") print(f"current spend ${wide[ch].iloc[-1]:,.0f}/wk - " f"{'room to grow' if wide[ch].iloc[-1] < knee else 'past the knee, pull back'}")

Build the curve. Push a grid of hypothetical spends through the exact same Hill transform and coefficient. The output is the response curve you saw in Part 2 - now a real array you can plot.

Find the knee. The marginal (slope) tells you where returns flatten. Below the knee, add budget; past it, you are buying the flat part of the curve - reallocate.

The budget read. Compare current spend to the knee for every channel and you have the first draft of a reallocation: pull from the saturated channels, push into the ones still climbing.

Where this goes next - Meridian / PyMC-Marketing Your knee is a single point estimate. In Meridian or PyMC-Marketing the response curve comes with a credible interval - a fan, not a line - so the read becomes "the knee is probably here, and here is how sure we are." And those priors are where Session 9's geo-lift results plug in to make the whole curve causal, not just correlational.
Before Session 9

This week ◐ 45 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Adstock in an MMM models...

Adstock is about time: this week's spend keeps working (decaying) over following weeks. Geometric adstock uses one theta per channel - high for slow-burn CTV, low for fast paid search.

2 · Saturation (the Hill curve) captures the fact that...

Saturation is about amount. The Hill / Michaelis-Menten / log curve bends over as spend grows, and its slope (the marginal return) is exactly what you optimize a budget against.

3 · You inherit a LightweightMMM pipeline in 2026. The right move is...

Google deprecated LightweightMMM in January 2025 in favour of Meridian, its Bayesian successor - every parameter gets a credible interval and you can calibrate with experiment priors. Do not build new work on the deprecated library.

Source material

What this session covers

This session distills the MMM foundations - adstock, saturation, and the modern Bayesian toolchain - into a build-it-yourself first pass on Lumen's aggregate data. Full model internals and vendor docs stay with their official sources.

PyMC Labs - Bayesian Marketing Analyticsadstock, saturation, Bayesian MMM - Parts 1-3, Build-alongs
Google Meridian docsBayesian MMM, uncertainty, priors - Part 3
Udemy - Fundamentals of MMM: Learn by Doingthe transforms and a first fit - Build-alongs 1-2
Full Bayesian MCMC internalsnamed here; PyMC-Marketing homework goes deeper
Calibrating MMM with experiment priorsbuilt in Builder Session 9 (geo-lift → priors)

Builder Session 8 cheat sheet · pin this

MMM =Regress a weekly outcome KPI on aggregate channel spend (2-3 yrs). No user identity - privacy-resilient by construction.
Adstock (carryover)An ad's effect decays over later weeks. Geometric = one theta per channel; Weibull = flexible shape/scale. CTV slow, search fast.
SaturationDiminishing returns. Hill / Michaelis-Menten / log curve. Doubling spend never doubles response. This IS the budget-response curve.
Pipeline orderAdstock first (spread over time), saturation second (bend for returns), regression third. Never swap the first two.
Robyn vs Meridian vs PyMCRobyn = R, ridge + Nevergrad, point estimates. Meridian = Python, Bayesian, uncertainty intervals. PyMC-Marketing = Python, Bayesian.
2026 noteGoogle deprecated LightweightMMM (Jan 2025) → Meridian (Bayesian). Priors are where geo-lift results calibrate the model.