Analytics

Marketing Mix Modeling: adstock, saturation and ROI by channel

A practical introduction to Marketing Mix Modeling, covering adstock transformations, diminishing-returns saturation curves, a regression implementation in Python, and how to turn the coefficients into a defensible budget-allocation view.

Ahmed Khalil Ben Smida7 min read

Attribution models explain what happened inside a browser session; they are blind to a billboard, a podcast sponsorship, or the brand awareness a display campaign built two months before someone finally searched your name. Marketing Mix Modeling answers a different, older question: given weekly spend across channels and weekly outcomes, what was each channel’s actual contribution, including the channels that never leave a click to track. It is econometrics applied to marketing, and it remains the most defensible way to allocate budget across channels that behave very differently from one another.

This guide covers the two transformations that make MMM work, adstock and saturation, builds a regression model in Python, and shows how to turn the resulting coefficients into a budget-allocation view a finance team will accept.

Why raw spend is the wrong input

Feeding raw weekly spend directly into a regression against weekly revenue produces a model that misses two real effects. The first is carryover: an advert seen this week can still be influencing a purchase three weeks later, so this week’s revenue partly reflects last month’s spend, not just this week’s. The second is diminishing returns: doubling spend on a channel rarely doubles its output, because you exhaust the responsive audience and start paying more to reach people who were always less likely to convert. Both effects need to be modelled explicitly, or the regression will misattribute their impact to the wrong channel or the wrong week.

Adstock: modelling carryover

Adstock transforms a raw spend series into an effective-exposure series that decays geometrically over time, so a pound spent this week still contributes a shrinking amount to the model for several weeks afterward. The decay rate is the parameter that controls how long a channel’s memory lasts; a channel like search typically has a short memory, while brand television or sponsorship can carry for months.

import numpy as np

def adstock(spend, decay=0.5):
    """decay: 0 = no carryover, close to 1 = long memory."""
    out = np.zeros_like(spend, dtype=float)
    out[0] = spend[0]
    for t in range(1, len(spend)):
        out[t] = spend[t] + decay * out[t - 1]
    return out

The decay parameter is not something you guess once and forget; it is usually estimated alongside the regression coefficients, either by grid-searching a small range per channel or letting a Bayesian model fit it jointly. A reasonable starting grid for a weekly model is decay values from 0.1 to 0.7 in steps of 0.1, evaluated by how well each choice improves the overall model fit.

Saturation: modelling diminishing returns

Once spend has been adstocked, it still needs a transformation that compresses large values relatively more than small ones, capturing the fact that a channel’s response curve bends as spend grows. A Hill-type saturation function is the standard choice, because it is bounded, smooth, and controlled by two interpretable parameters: a half-saturation point and a steepness.

def saturate(x, half_sat, steepness=1.0):
    x = np.asarray(x, dtype=float)
    return x**steepness / (x**steepness + half_sat**steepness)

Setting half_sat near the median historical spend for a channel is a sensible starting point, so the transformed values span a meaningful range of the curve rather than sitting flat near zero or one. The steepness parameter controls how sharply returns diminish; a value near one gives a gentle curve, higher values create a sharper knee.

Fit the regression

With both transformations applied per channel, the modelling step itself is an ordinary regression: outcome as a function of the transformed channel variables, plus controls for seasonality and any known one-off events such as a price change or a competitor stockout. Ridge regression is a sensible default over ordinary least squares, because marketing spend series are often correlated with each other, and ridge handles that collinearity more gracefully.

import pandas as pd
from sklearn.linear_model import Ridge

def build_features(df, channels, decay_by_channel, half_sat_by_channel):
    X = pd.DataFrame(index=df.index)
    for ch in channels:
        ads = adstock(df[ch].values, decay=decay_by_channel[ch])
        X[ch] = saturate(ads, half_sat=half_sat_by_channel[ch])
    X["week_of_year"] = df.index.isocalendar().week
    return X

def fit_mmm(df, channels, decay_by_channel, half_sat_by_channel, target="revenue"):
    X = build_features(df, channels, decay_by_channel, half_sat_by_channel)
    model = Ridge(alpha=1.0, positive=True)   # positive: spend cannot hurt revenue
    model.fit(X[channels + ["week_of_year"]], df[target])
    return model, X

The positive=True constraint is a deliberate modelling choice, not a technical default. Marketing spend should not carry a negative coefficient in a well-specified model, and forcing that constraint prevents the regression from assigning an implausible negative contribution to a channel purely because it happened to be collinear with a stronger one.

Turn coefficients into contribution and ROI

A raw regression coefficient is not directly interpretable as revenue; it needs to be multiplied through the transformed feature to recover each channel’s estimated contribution in the same units as the target, and then divided by spend to get a return figure that a budget conversation can actually use.

def channel_contribution(model, X, channels):
    contrib = {}
    for i, ch in enumerate(channels):
        contrib[ch] = model.coef_[i] * X[ch]
    return pd.DataFrame(contrib, index=X.index)

def channel_roi(contrib_df, spend_df, channels):
    roi = {}
    for ch in channels:
        total_contribution = contrib_df[ch].sum()
        total_spend = spend_df[ch].sum()
        roi[ch] = total_contribution / total_spend if total_spend else 0
    return roi

The resulting ROI figures are what make MMM valuable in a room full of stakeholders who each favour a different channel. Because the model accounts for carryover and diminishing returns, a channel that looks weak on last-click attribution can show real, defensible contribution here, and a channel that dominates last-click can turn out to be riding on demand that other channels created.

Simulate the budget reallocation before you spend it

The real payoff of MMM is not the historical explanation; it is the ability to simulate a different spend mix and predict the outcome before committing budget. Because saturation is explicit in the model, you can see exactly where a channel is nearing its ceiling and where headroom remains.

def simulate_reallocation(model, base_X, channels, new_spend, decay_by_channel, half_sat_by_channel):
    X = base_X.copy()
    for ch, spend_series in new_spend.items():
        ads = adstock(np.array(spend_series), decay=decay_by_channel[ch])
        X[ch] = saturate(ads, half_sat=half_sat_by_channel[ch])
    return model.predict(X[channels + ["week_of_year"]])

Running a handful of realistic reallocation scenarios, such as shifting ten percent of spend from a saturated channel to one with headroom, and comparing the predicted outcomes, is what turns MMM from an academic exercise into a tool that directly informs next quarter’s plan.

Validate before you trust it

An MMM is only as good as its fit to reality, and it is easy to build one that looks plausible but is quietly wrong. Hold out the most recent eight to twelve weeks, fit only on the earlier data, and check that the model’s predictions on the held-out period track actual outcomes reasonably well. Sense-check every coefficient against what your team already knows: a channel your sales team swears is driving pipeline should not come out with a near-zero contribution without a good explanation. A model that fails either check needs more work before it drives a real budget decision, not because the mathematics is wrong, but because the inputs, the decay assumptions or the saturation points likely need revisiting.

Account for the channels you cannot buy your way out of

A well-specified MMM needs control variables beyond media spend, because revenue moves for reasons that have nothing to do with any channel’s performance. Seasonality, a price change, a competitor’s stockout, or a public holiday all shift the baseline the model would otherwise misattribute to whichever channel happened to be active that week. Leaving these out does not make the model simpler; it makes the model wrong in a way that is hard to detect, because the coefficients still come out looking plausible.

def add_controls(df):
    df = df.copy()
    df["month"] = df.index.month
    df["is_holiday_week"] = df.index.isin(KNOWN_HOLIDAYS).astype(int)
    df["price_index"] = df["avg_price"] / df["avg_price"].iloc[0]
    return df

Include these as additional regression features alongside the transformed channel variables, and the model’s channel coefficients become considerably more trustworthy, because seasonal and pricing effects are no longer forced into whichever media variable happens to correlate with them by coincidence.

Revisit the model on a cadence, not once a year

An MMM built once and left untouched decays in accuracy as market conditions, channel costs and consumer behaviour shift underneath it. Treat the model as a living artefact that gets refit on a rolling window, typically quarterly, with the same validation discipline applied every time: hold out recent weeks, check the fit, and sense-check coefficients against what the team is observing in-market. A model refit quarterly against fresh data will catch a channel’s fatigue or a new channel’s emergence far faster than an annual exercise ever could, and the incremental cost of refitting is a fraction of the cost of a full initial build.

What MMM gets right that attribution never will

It is worth being precise about why this older, econometric approach still earns its place alongside modern digital attribution, rather than being superseded by it. Digital attribution can only ever see what a tracking pixel or a cookie observed, which means it is structurally blind to every channel that operates outside a clickable, trackable session: television, radio, out-of-home advertising, sponsorship, and increasingly, any interaction on a platform that restricts tracking for privacy reasons. As more of the advertising ecosystem moves toward exactly that kind of restriction, the share of genuine marketing influence that attribution can see keeps shrinking, while the share MMM can estimate, because it only needs aggregate weekly spend and outcomes rather than individual-level tracking, stays intact. This is not a reason to abandon attribution, which still offers a precision MMM cannot match for the channels it can see. It is a reason to run both, deliberately, and to treat MMM as the check that keeps a digitally-biased view of marketing performance honest about the channels a cookie was never going to observe in the first place.

Key takeaways

  1. Raw spend is the wrong input; transform it with adstock for carryover and saturation for diminishing returns first.
  2. Fit decay and half-saturation per channel, since a channel like search and one like sponsorship behave very differently.
  3. Use a constrained regression so spend cannot carry an implausible negative coefficient.
  4. Convert coefficients into contribution and ROI in the same units your budget conversation already uses.
  5. Simulate reallocation scenarios before committing spend, using the same saturation curves the model learned.
  6. Hold out recent weeks and sense-check coefficients against what your team already knows before trusting the output.

Marketing Mix Modeling will not replace the precision of a well-tracked digital funnel, and it is not meant to. What it gives you is the piece attribution cannot see: a defensible estimate of what every channel, tracked or not, actually contributed, and a way to test a budget decision before you make it.

References

Apply this to your business

01Strategy and growth

Marketing strategy

A clear positioning, channel plan and KPI framework that the whole organisation can execute against.

Market analysis, positioning and a growth plan your team can actually execute, built by someone who also implements the systems behind it.

  • GA4
  • Semrush
  • HubSpot
  • Looker Studio
View capability
04Measurement and optimisation

Tracking and analytics

Trustworthy measurement from first click to revenue, visible in dashboards the team actually uses.

Conversion tracking, GA4, Tag Manager and dashboards implemented properly, so every marketing decision is made on data you can trust.

  • Google Tag Manager
  • GA4
  • Looker Studio
  • Microsoft Clarity
View capability
08Strategy and growth

Paid media

Paid campaigns measured to pipeline, optimised on lead quality, and governed by explicit budget rules.

Google and LinkedIn campaigns run with tracking readiness, honest reporting and lead quality analysis, not vanity metrics.

  • Google Ads
  • LinkedIn Ads
  • Meta Ads
  • TikTok Ads
View capability