Marketing mix modeling in Python: a from-scratch tutorial with numpy and scipy

Build a complete marketing mix model in about 150 lines of numpy, pandas and scipy, check it against a known answer, then see what PyMC-Marketing and Meridian add on top.

Mike Taylor By Mike Taylor · Updated · 15 min read

Marketing mix modeling in Python comes down to four steps: transform each channel’s spend for carryover (adstock) and diminishing returns (saturation), add controls for trend, seasonality and promotions, regress sales on all of it, and then read ROI and budget recommendations off the fitted model. You can do the whole thing with numpy, pandas and scipy in about 150 lines, which is what this tutorial does on a 104-week practice dataset.

When I built the earlier Vexpower MMM path, Python came after Excel for a reason: it’s much easier to trust a library like Meridian or PyMC-Marketing once you’ve built the same model by hand and seen where the judgment calls are. Every model result quoted on this page is printed by the downloadable notebook, and the code blocks below are the notebook’s cells, so you can run along and check you get the same answer. The practice data also has a known answer key, which almost never happens in real life, so at the end you’ll find out whether the method actually worked. If you need the background first, read what marketing mix modeling is.

ipynb
MMM Python tutorial notebookExecuted Jupyter notebook with every output saved. Needs numpy, pandas, scipy and matplotlib.
Download

What you need for marketing mix modeling in Python

You need Python 3.10 or later with numpy, pandas, scipy and matplotlib (pip install numpy pandas scipy matplotlib), and the practice CSV in the same folder as the notebook. If the file isn’t there, the first cell downloads it from this site. The dataset is invented for teaching: 104 weeks from January 2024 to December 2025, with revenue, spend on search, social and video, and a flag for promotion weeks. It’s deliberately small and clean, so it’s good for learning the mechanics and bad for ranking methods, and the data requirements page covers what a real dataset needs. It’s worth knowing up front that the data was generated from a straight linear equation with no noise, no carryover and no saturation, so the adstock and Hill steps below are there to teach the method: a correct model should find decay near zero and push saturation out to its bound, and that’s what happens.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import minimize

DATA_URL = "https://vexpower.com/downloads/mmm/mmm-practice-synthetic.csv"
CHANNELS = ["search_spend", "social_spend", "video_spend"]

try:
    df = pd.read_csv("mmm-practice-synthetic.csv", parse_dates=["week"])
except FileNotFoundError:
    df = pd.read_csv(DATA_URL, parse_dates=["week"])

np.set_printoptions(suppress=True)
pd.set_option("display.width", 120)

Step 1: load and audit the data

The first job in any MMM is to check the data, because the most common way a model goes wrong is a gap, a duplicate week or a column in the wrong units, not a bad algorithm. Here that means the shape, missing values, the spacing between weeks, the range of each column and a first look at correlations.

print(df.shape)
print("Missing values:", int(df.isna().sum().sum()))
print("Week gaps:", df["week"].diff().dropna().value_counts().to_dict())
print(df["week"].min().date(), "to", df["week"].max().date())
print(df.drop(columns="week").describe().round(1).T[["mean", "min", "max"]])
print(df[["revenue"] + CHANNELS + ["promotion"]].corr().round(2)["revenue"])
(104, 6)
Missing values: 0
Week gaps: {Timedelta('7 days 00:00:00'): 103}
2024-01-01 to 2025-12-22
                 mean      min      max
revenue       25639.3  21720.0  30957.0
search_spend   2425.4   1565.0   3308.0
social_spend   1716.9   1000.0   2400.0
video_spend    1219.4    701.0   1700.0
promotion         0.1      0.0      1.0
revenue         1.00
search_spend    0.05
social_spend    0.19
video_spend    -0.10
promotion       0.22
Name: revenue, dtype: float64

The audit is clean: 104 consecutive weekly rows with no missing values. The correlations are the interesting part. Search has the biggest budget, yet its correlation with revenue is only 0.05, and video’s is negative. That doesn’t mean search does nothing; it means something else (here, a strong yearly seasonal cycle) is moving revenue by more than the media is, and a raw correlation can’t separate the two. This is the whole reason we build a regression with controls rather than eyeballing charts, and it’s a mild version of the confounding problem that makes MMM hard.

Step 2: geometric adstock and the Hill saturation curve

Two transformations do most of the work in a marketing mix model. Adstock captures carryover, the idea that some of this week’s advertising effect shows up next week and the week after. The geometric version keeps a fixed share of the previous week’s adstocked value, so a decay of 0.5 means half of last week’s effect survives into this week. The Hill saturation curve captures diminishing returns: it rises roughly in a straight line at low spend and flattens as spend grows, reaching half of its maximum at the half-saturation point.

def geometric_adstock(x, decay):
    """adstock[t] = x[t] + decay * adstock[t-1]"""
    out = np.zeros(len(x))
    carry = 0.0
    for t, value in enumerate(x):
        carry = value + decay * carry
        out[t] = carry
    return out


def hill(x, half_sat):
    """Share of the maximum effect reached at x. Equals 0.5 when x == half_sat."""
    return x / (x + half_sat)

I’ve fixed the Hill shape parameter at 1 to keep the search small, which makes it the same curve as Michaelis-Menten. The adstock here isn’t normalized, so a channel with high decay gets a bigger adstocked value; that only changes the scale of the coefficient, not the fit. If you want a vectorised version, scipy.signal.lfilter([1], [1, -decay], x) computes the same recursion without the loop (give it a float array; SciPy 1.18 raises an error on integer input).

Step 3: build the design matrix

The design matrix is every column the regression can use to explain revenue. The controls are an intercept (the baseline level of sales), a linear trend, a sine and cosine pair with a 52-week period to capture seasonality, and the promotion flag as a dummy variable. Each channel then gets one column: its spend, adstocked, then pushed through the Hill curve.

n = len(df)
t = np.arange(n)
controls = np.column_stack([
    np.ones(n),                   # intercept (baseline)
    t,                            # trend
    np.sin(2 * np.pi * t / 52),   # yearly seasonality
    np.cos(2 * np.pi * t / 52),
    df["promotion"].values,
])
control_names = ["intercept", "trend", "season_sin", "season_cos", "promotion"]
y = df["revenue"].values.astype(float)
spend = df[CHANNELS].values.astype(float)
mean_spend = spend.mean(axis=0)


def media_columns(params, spend=spend):
    decays, half_mults = params[:3], params[3:]
    cols = []
    for j in range(3):
        a = geometric_adstock(spend[:, j], decays[j])
        cols.append(hill(a, half_mults[j] * mean_spend[j]))
    return np.column_stack(cols)


def fit_ols(params, rows=slice(None)):
    X = np.column_stack([controls, media_columns(params)])
    beta, *_ = np.linalg.lstsq(X[rows], y[rows], rcond=None)
    return beta, X


def sse(params, rows=slice(None)):
    beta, X = fit_ols(params, rows)
    resid = y[rows] - X[rows] @ beta
    return float(resid @ resid)

Expressing half-saturation as a multiple of each channel’s average spend is a small trick that keeps the search well behaved. A multiple of 1 means the channel is already at half its maximum effect at average spend (heavily saturated); a multiple of 10 means average spend is far down the straight part of the curve. Once the six nonlinear parameters are fixed, the coefficients are just an ordinary least squares regression, which numpy solves in one line.

Step 4: search for decay and half-saturation

This is the part libraries hide from you. We have six nonlinear parameters (a decay and a half-saturation per channel) and, for any choice of them, OLS gives the best coefficients and a sum of squared errors. So we let scipy search the six parameters to minimize that error, starting from the best point on a coarse grid so the optimizer doesn’t get stuck somewhere silly.

bounds = [(0.0, 0.9)] * 3 + [(0.2, 10.0)] * 3

grid = [(d, k) for d in np.arange(0, 0.91, 0.1) for k in [0.5, 1, 2, 5, 10]]
start = min(([d] * 3 + [k] * 3 for d, k in grid), key=sse)

result = minimize(sse, x0=start, bounds=bounds, method="L-BFGS-B")
best = result.x
beta, X = fit_ols(best)
fitted = X @ beta

params_table = pd.DataFrame({
    "decay": best[:3].round(3),
    "half_sat_x_mean": best[3:].round(2),
    "half_sat_dollars": (best[3:] * mean_spend).round(0),
}, index=CHANNELS)
print(params_table)

r2 = 1 - ((y - fitted) ** 2).sum() / ((y - y.mean()) ** 2).sum()
mape = np.mean(np.abs((y - fitted) / y))
print(f"R-squared: {r2:.4f}   MAPE: {mape:.2%}")
print(pd.Series(beta, index=control_names + CHANNELS).round(1))
              decay  half_sat_x_mean  half_sat_dollars
search_spend  0.000             10.0           24254.0
social_spend  0.007             10.0           17169.0
video_spend   0.000             10.0           12194.0
R-squared: 1.0000   MAPE: 0.04%
intercept       19477.3
trend               0.0
season_sin       3000.8
season_cos          1.7
promotion        1803.8
search_spend    41131.8
social_spend    16488.7
video_spend      8799.9
dtype: float64

Two things in that output deserve attention. First, the search found almost no carryover: decay is 0 for search and video and 0.007 for social, which is nothing. Second, all three half-saturation multiples finished at 10.0, the top of the range I allowed. A parameter sitting on its bound is the model telling you it wanted to go further, and here it means the data shows no sign of diminishing returns at the spend levels we’ve seen. The fit itself is essentially perfect (R-squared 1.0000, MAPE 0.04 percent) because this synthetic data has no noise beyond rounding. On real data, an R-squared above 0.9 for weekly revenue is normal and anything near 1.0 should make you suspicious of overfitting.

The widget below runs the same model in your browser, on the same CSV, with the same transforms and the same OLS. Start from the notebook’s best fit and check the ROI column matches the notebook (1.54, 0.87 and 0.65), then try adding carryover or saturation and watch the fit get worse.

Interactive
Re-fit the tutorial model in your browser

The same data, transforms and regression as the notebook. Move a channel's decay or half-saturation and the coefficients are re-estimated by OLS, so you can see what each parameter does to fit and ROI.

Search
Social
Video
R-squared
–
MAPE
–
Residual SD
–
ChannelSpendContributionROIMarginal ROI

Model: revenue ~ intercept + trend + yearly sine/cosine + promotion + β·Hill(adstock(spend)) per channel, fitted by ordinary least squares on all 104 weeks of mmm-practice-synthetic.csv (Vexpower synthetic data). Half-saturation is a multiple of the channel's average weekly spend, as in the notebook. ROI = modeled contribution ÷ spend over two years; marginal ROI is at average spend. The data was generated from a linear equation with no carryover, saturation or noise, so the best fit has decay at zero and half-saturation at its upper bound.

Step 5: decompose revenue into baseline and channels

A decomposition multiplies each column of the design matrix by its coefficient, so every week’s revenue is split into the pieces that add up to the model’s prediction. Adding the intercept, trend and seasonality together gives the baseline, the revenue you’d expect with no marketing at all.

contrib = pd.DataFrame(X * beta, columns=control_names + CHANNELS, index=df["week"])
decomp = pd.DataFrame({
    "baseline": contrib[["intercept", "trend", "season_sin", "season_cos"]].sum(axis=1),
    "promotion": contrib["promotion"],
    "search": contrib["search_spend"],
    "social": contrib["social_spend"],
    "video": contrib["video_spend"],
})
share = decomp.sum() / decomp.sum().sum()
print(share.round(3))

fig, ax = plt.subplots(figsize=(10, 4))
ax.stackplot(decomp.index, decomp.T.values, labels=decomp.columns, alpha=0.85)
ax.plot(df["week"], y, color="black", lw=1, label="actual revenue")
ax.set_ylim(15000, None)
ax.legend(loc="upper left", ncol=6, fontsize=8)
ax.set_title("Weekly revenue decomposition (baseline axis starts at 15,000)")
plt.tight_layout()
plt.show()
baseline     0.760
promotion    0.005
search       0.145
social       0.058
video        0.031
dtype: float64

According to the model, 76.0 percent of two years’ revenue is baseline, 14.5 percent comes from search, 5.8 percent from social, 3.1 percent from video and 0.5 percent from promotions. A large baseline share is typical; most of a business’s sales would happen without this week’s ads. The chart in the notebook stacks these pieces week by week, and it’s the first thing I’d show a stakeholder, because they can sanity-check it against what they remember (did the summer peak really happen, are the promotion spikes in the right weeks).

Step 6: calculate ROI and marginal ROI per channel

Average ROI is total modeled contribution divided by total spend. Marginal ROI is the return on the next dollar at today’s spend level, which is what you actually need for a budget decision, and the gap between the two is a direct read of how saturated a channel is. The marginal ROAS page explains why those two numbers are so often confused.

decays, half = best[:3], best[3:] * mean_spend
media_beta = beta[-3:]


def weekly_response(s, j):
    """Steady-state weekly contribution if channel j spends s every week."""
    a = s / (1 - decays[j])
    return media_beta[j] * hill(a, half[j])


def marginal_roi(s, j):
    a = s / (1 - decays[j])
    return media_beta[j] * half[j] / (a + half[j]) ** 2 / (1 - decays[j])


roi = pd.DataFrame({
    "spend": spend.sum(axis=0).round(0),
    "contribution": contrib[CHANNELS].sum().values.round(0),
}, index=CHANNELS)
roi["roi"] = (roi["contribution"] / roi["spend"]).round(2)
roi["marginal_roi"] = [round(marginal_roi(mean_spend[j], j), 2) for j in range(3)]
print(roi)
                 spend  contribution   roi  marginal_roi
search_spend  252245.0      387889.0  1.54           1.4
social_spend  178558.0      155805.0  0.87           0.8
video_spend   126822.0       82614.0  0.65           0.6

Search returns 1.54 in revenue per dollar on average and 1.40 at the margin, social 0.87 and 0.80, and video 0.65 and 0.60. Remember these are revenue returns, not profit, so with a 50 percent gross margin even search would be losing money on its own; the breakeven ROAS page covers that conversion. The fact that average ROI is about 10 percent higher than marginal ROI for every channel is a consequence of the Hill curve bending slightly at a half-saturation of 10, and we’ll come back to whether that bend is real.

Step 7: draw the response curves

A response curve shows the weekly revenue contribution a channel would produce at a steady weekly spend level. In steady state, geometric adstock settles at spend divided by one minus decay, so the curve is just the coefficient times the Hill function of that.

fig, ax = plt.subplots(figsize=(8, 4))
for j, name in enumerate(CHANNELS):
    s = np.linspace(0, 3 * mean_spend[j], 100)
    ax.plot(s, weekly_response(s, j), label=name)
    ax.scatter(mean_spend[j], weekly_response(mean_spend[j], j), zorder=3)
ax.set_xlabel("Weekly spend")
ax.set_ylabel("Weekly revenue contribution")
ax.set_title("Response curves (dots = average spend; beyond the highest observed spend is extrapolation)")
ax.legend()
plt.tight_layout()
plt.show()

On this data the curves are nearly straight lines across the range of spend we’ve observed, which is consistent with what the parameter search told us. The further right you read, the more you’re extrapolating: the maximum observed search spend was 3,308 a week, so anything the curve says about 6,000 a week is the Hill function’s opinion, not the data’s. That’s the most important caveat on any response curve, and it’s why the next step limits how far the budget can move.

Step 8: reallocate the same budget across channels

Budget optimization asks how to split a fixed total to get the most revenue. With the response curves in hand, it’s a constrained optimization: maximize the sum of the three channels’ weekly responses, keep the total equal to today’s average weekly spend, and limit each channel to plus or minus 30 percent of its current level so we don’t extrapolate wildly. The budget allocation page goes deeper on choosing those constraints.

total = mean_spend.sum()


def neg_total_response(s):
    return -sum(weekly_response(s[j], j) for j in range(3))


plan = minimize(
    neg_total_response,
    x0=mean_spend,
    bounds=[(0.7 * m, 1.3 * m) for m in mean_spend],
    constraints=[{"type": "eq", "fun": lambda s: s.sum() - total}],
    method="SLSQP",
).x

allocation = pd.DataFrame({
    "current": mean_spend.round(0),
    "proposed": plan.round(0),
    "change": (plan - mean_spend).round(0),
}, index=CHANNELS)
print(allocation)
uplift = -neg_total_response(plan) + neg_total_response(mean_spend)
print(f"Extra weekly revenue for the same budget: {uplift:,.0f}")
              current  proposed  change
search_spend   2425.0    3153.0   728.0
social_spend   1717.0    1355.0  -362.0
video_spend    1219.0     854.0  -366.0
Extra weekly revenue for the same budget: 474

The optimizer moves 728 a week into search, taking it to its 30 percent ceiling of 3,153, and funds that by cutting social by 362 and video by 366 (video hits its 30 percent floor). The model says that’s worth an extra 474 in weekly revenue for the same total spend of about 5,362 a week. Because the response curves are almost linear, the answer is close to “move as much as you’re allowed into the channel with the highest marginal ROI”, which is exactly what you’d expect. On real data with genuine saturation, the optimizer stops moving money once marginal returns are equal across channels, and that’s where it earns its keep.

Step 9: check the model on weeks it hasn’t seen

A model that explains the past perfectly can still be useless for planning. The simplest test is a holdout: refit everything on the first 78 weeks (18 months) and predict the last 26 weeks, which the model never saw. The model validation page covers the other checks I’d run.

train = slice(0, 78)
test = slice(78, n)
holdout_fit = minimize(sse, x0=start, args=(train,), bounds=bounds, method="L-BFGS-B")
beta_train, X_all = fit_ols(holdout_fit.x, train)
pred = X_all @ beta_train
test_mape = np.mean(np.abs((y[test] - pred[test]) / y[test]))
train_mape = np.mean(np.abs((y[train] - pred[train]) / y[train]))
print(f"Train MAPE: {train_mape:.2%}   Holdout MAPE: {test_mape:.2%}")

fig, ax = plt.subplots(figsize=(10, 3.5))
ax.plot(df["week"], y, color="black", lw=1, label="actual")
ax.plot(df["week"], pred, color="tab:green", label="model trained on first 78 weeks")
ax.axvline(df["week"].iloc[78], ls="--", color="gray")
ax.legend()
plt.tight_layout()
plt.show()
Train MAPE: 0.03%   Holdout MAPE: 0.09%

Training MAPE is 0.03 percent and holdout MAPE is 0.09 percent, so the model predicts unseen weeks almost as well as the weeks it was trained on. On real data you’d expect both to be in the low single digits, and the useful signal is the gap: if holdout error is several times the training error, the model has memorized noise. Holdout accuracy is necessary but not sufficient, though; a model can forecast well and still split the credit between channels wrongly, which is why the next step matters.

Did the model get the right answer?

This practice dataset was generated with known effects. Each dollar of search, social and video added 1.4, 0.8 and 0.6 of revenue in the same week, with no carryover and no saturation, on top of a baseline of 20,000, a seasonal swing of 3,000 and a promotion lift of 1,800. That makes it a fair test of whether the pipeline works.

truth = pd.Series([1.4, 0.8, 0.6], index=CHANNELS)
wide = minimize(sse, x0=start, bounds=[(0.0, 0.9)] * 3 + [(0.2, 100.0)] * 3, method="L-BFGS-B")
beta_wide, X_wide = fit_ols(wide.x)
roi_wide = (X_wide[:, -3:] * beta_wide[-3:]).sum(axis=0) / spend.sum(axis=0)
print(pd.DataFrame({
    "roi_bound_10": roi["roi"],
    "roi_bound_100": roi_wide.round(2),
    "true_roi": truth,
}))
print("Half-saturation multiples with the wider bound:", wide.x[3:].round(1))
              roi_bound_10  roi_bound_100  true_roi
search_spend          1.54           1.41       1.4
social_spend          0.87           0.81       0.8
video_spend           0.65           0.61       0.6
Half-saturation multiples with the wider bound: [100. 100. 100.]

The marginal ROIs from Step 6 (1.40, 0.80 and 0.60) match the truth exactly, and the promotion coefficient of 1,803.8 is close to 1,800. The average ROIs, however, were about 10 percent too high, and the reason is the bound I set on half-saturation. Forcing the Hill curve to bend a little at a multiple of 10 makes the early dollars look more productive than they are, and the intercept drops to 19,477 to compensate. Widening the bound to 100 lets the curve straighten out, and the average ROIs fall to 1.41, 0.81 and 0.61, within a cent of the truth. The lesson is general: the bounds and priors you put on saturation are assumptions, they change the ROI you report, and a parameter sitting on its bound is a flag you should always mention.

Where to go next: PyMC-Marketing and Meridian

The from-scratch model gives you one best guess for each number. What it can’t do well is tell you how uncertain those numbers are, include what you already know from experiments, or pool information across regions. Those are the reasons to move to a Bayesian library, and the two I’d look at in Python are PyMC-Marketing and Google’s Meridian. The open-source MMM comparison has a picker if you’re not sure which.

The concepts map across directly. Your geometric_adstock becomes PyMC-Marketing’s GeometricAdstock (with an l_max for the maximum lag) or Meridian’s geometric adstock decay. Your hill becomes HillSaturation in PyMC-Marketing or Meridian’s Hill curve. The bounds you gave scipy become priors, which are a softer and more honest version of the same assumption: instead of “half-saturation can’t exceed 10 times average spend”, you say “it’s probably in this range, and the data can argue”. The OLS coefficients become posterior distributions, so ROI comes with an interval. And the answer-key step becomes calibration, where a lift test result is fed in as a prior (Meridian) or a likelihood term (PyMC-Marketing) so the model has to agree with it.

From-scratch stepPyMC-MarketingMeridian
geometric_adstock(x, decay)GeometricAdstock(l_max=...) and others (delayed, Weibull, binomial)Geometric or binomial adstock decay
hill(x, half_sat)HillSaturation, LogisticSaturation, MichaelisMentenSaturation and othersHill saturation
scipy bounds on parametersPriors on each parameterPriors, including directly on ROI
OLS coefficientsPosterior samples (MCMC)Posterior samples (MCMC)
Holdout MAPEOut-of-sample predictions with intervalsHoldout and model-fit checks
Answer keyLift-test calibrationROI priors from experiments, including GeoX
SLSQP reallocationBudget optimizerBudget optimizer and Scenario Planner

If you’d rather stay in another language, the same model is built step by step in R with base lm and in Excel with LINEST and Solver, so you can compare their numbers with this notebook’s. The Vexpower MMM course takes the ideas further on a messier invented brand, where the answer isn’t handed to you at the end.

py
MMM Python tutorial scriptThe same code as a single script with # %% cells. Run it with python mmm-python-tutorial.py.
Download

Downloads

ipynb
MMM Python tutorial notebookThe whole tutorial as a Jupyter notebook, already executed so you can compare your outputs.
Download
py
MMM Python tutorial scriptThe same code as one .py file with # %% cells for VS Code or Jupyter.
Download
csv
Practice dataset (CSV)104 weeks of synthetic revenue, three channels and a promotion flag.
Download

Frequently asked questions

How do you build a marketing mix model in Python?

Load weekly sales and spend into pandas, transform each channel's spend with an adstock function for carryover and a saturation curve such as the Hill function, add controls for trend, seasonality and promotions, and fit a regression of sales on all of them. Search the adstock and saturation parameters with scipy.optimize, then use the coefficients to decompose sales, calculate ROI and simulate budget changes.

Which Python library is best for marketing mix modeling?

For production work in 2026 the main choices are Google's Meridian and PyMC Labs' PyMC-Marketing, both Bayesian and actively maintained. Google's LightweightMMM is archived. Building a simple version yourself with numpy and scipy first is the best way to understand what those libraries are doing.

How do you code adstock in Python?

Geometric adstock is a loop that carries a share of last week's value forward: adstock[t] = spend[t] + decay * adstock[t-1]. With numpy you can write it in five lines, or use scipy.signal.lfilter([1], [1, -decay], spend.astype(float)), which computes the same recursion (recent SciPy versions refuse integer arrays, hence the conversion).

Do I need Bayesian methods for MMM in Python?

Not to get started. Ordinary least squares with adstock and saturation transforms gives a working model and makes every step visible. Bayesian libraries add priors, uncertainty intervals and a principled way to include experiment results, which matter once you are making real budget decisions.

How much data do you need for a marketing mix model in Python?

Two years of weekly data (about 104 rows) is a common minimum, because you need enough weeks to separate seasonality from media and enough variation in each channel's spend. The Python code is the same whatever the size; the data is what limits how many channels and controls you can estimate.

References

  1. Jin et al. (2017), Bayesian Methods for Media Mix Modeling with Carryover and Shape Effects Geometric and delayed adstock and the Hill saturation curve used here.
  2. SciPy documentation: scipy.optimize.minimize L-BFGS-B with bounds and SLSQP with constraints.
  3. PyMC-Marketing, GitHub repository (PyMC Labs) GeometricAdstock, HillSaturation, lift-test calibration, budget optimiser; version 1.3.0, October 2026.
  4. Meridian, GitHub repository and documentation (Google) Hill saturation, geometric and binomial adstock, ROI priors; version 2.1.0, September 2026.
Mike Taylor
Mike Taylor

I've built marketing mix models for Monzo Bank, Booking.com, Time Out, The AA and others, advised Google, Appsflyer and Recast on MMM, and teach marketing mix modeling on LinkedIn Learning. Before that I co-founded a 50-person growth marketing agency. I write the Vexpower MMM course.

Keep reading

Learn MMM by doing itThe Vexpower course walks through thirty lessons on one invented brand, with simulators, Python labs and a decision case you can defend.
See the course