Marketing mix modeling in R: base R lm, adstock and optim, then Robyn

A complete marketing mix model in about 200 lines of base R, with no packages to install, on the same practice data as the Python and Excel versions. Then where Robyn fits in.

Mike Taylor By Mike Taylor · Updated · 3 min read

Marketing mix modeling in R needs nothing beyond base R: stats::filter computes geometric adstock, a one-line function gives you a Hill saturation curve, lm fits the regression of sales on transformed spend plus controls, and optim searches for the decay and saturation parameters that fit best. This tutorial builds that model step by step on a 104-week practice dataset, then shows where Meta’s Robyn package takes over.

R is where a lot of marketing mix modeling has historically lived, because econometricians and marketing scientists tend to work in it, and Robyn made it the default for many in-house teams. When I built the earlier Vexpower MMM path, Robyn was the first package students met after building a model by hand in a spreadsheet. However, I’d still start with base R, because Robyn makes dozens of decisions for you, and you’ll get much more out of its output once you’ve made those decisions yourself. The model here is the same one built in the Python tutorial and the Excel template, on the same data, so you can compare numbers across all three. For the concepts behind it, start with what marketing mix modeling is.

r
MMM R tutorial scriptBase R only, no packages. Run it with Rscript mmm-r-tutorial.R or source it in RStudio from the folder with the practice CSV.
Download

Marketing mix modeling in R starts with a data audit

The script expects mmm-practice-synthetic.csv in the working directory and downloads it from this site if it isn’t there. It’s an invented dataset: 104 weeks from January 2024 to December 2025 with revenue, spend on search, social and video, and a promotion flag. It 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 rather than to discover anything, and a correct fit should find decay near zero and push saturation to its bound. The first job is an audit, because a missing week or a column in the wrong units will break a model long before the statistics do.

d <- read.csv(csv)
d$week <- as.Date(d$week)

channels <- c("search_spend", "social_spend", "video_spend")

print(dim(d))
cat("Missing values:", sum(is.na(d)), "\n")
print(table(diff(d$week)))              # every gap should be 7 days
print(range(d$week))
print(summary(d[, c("revenue", channels, "promotion")]))
print(round(cor(d[, c("revenue", channels, "promotion")])[, "revenue"], 2))

You should see 104 rows and six columns, no missing values, 103 gaps of seven days, and correlations with revenue of 0.05 for search, 0.19 for social, minus 0.10 for video and 0.22 for promotion. Those correlations are a useful warning. Search has the biggest budget and, as we’ll see, the biggest effect, but its raw correlation with revenue is close to zero because a yearly seasonal cycle moves revenue by more than search does. Only a regression with seasonality in it can separate the two, which is the confounding problem in miniature.

Geometric adstock with stats::filter

Adstock is the carryover of advertising from one week into the next. The geometric version says this week’s adstock is this week’s spend plus a fixed share (the decay) of last week’s adstock. That’s a recursive filter, and R has one built in: stats::filter with method = "recursive" computes y[t] = x[t] + decay * y[t-1], starting from zero, which is exactly what we need.

adstock <- function(x, decay) {
  as.numeric(stats::filter(x, filter = decay, method = "recursive"))
}

# Hill curve with shape 1: share of the maximum effect reached at x; 0.5 when x == half_sat.
hill <- function(x, half_sat) {
  x / (x + half_sat)
}

Two small details matter. stats::filter returns a time-series object, so as.numeric turns it back into a plain vector. And it’s worth writing stats::filter in full every time, because if you’ve loaded dplyr, plain filter means something completely different and you’ll get a baffling error. The hill function is the Hill saturation curve with its shape fixed at 1, so it rises almost linearly at low spend and flattens towards a maximum, reaching half of it at the half-saturation point.

Build the controls and the design matrix

The regression needs controls for everything other than media that moves revenue: a trend counter, a sine and cosine pair with a 52-week period for seasonality, and the promotion flag. Each channel then gets one column, its spend adstocked and saturated. Half-saturation is expressed as a multiple of each channel’s average weekly spend, which keeps the three channels on the same scale for the optimizer.

n <- nrow(d)
d$t <- 0:(n - 1)
d$season_sin <- sin(2 * pi * d$t / 52)
d$season_cos <- cos(2 * pi * d$t / 52)

spend <- as.matrix(d[, channels])
mean_spend <- colMeans(spend)
y <- d$revenue

# params = c(decay_search, decay_social, decay_video, halfsat_search, halfsat_social, halfsat_video)
# Half-saturation is a multiple of each channel's average weekly spend.
media_matrix <- function(params) {
  sapply(1:3, function(j) {
    hill(adstock(spend[, j], params[j]), params[3 + j] * mean_spend[j])
  })
}

design_matrix <- function(params) {
  m <- media_matrix(params)
  colnames(m) <- channels
  cbind(intercept = 1, t = d$t, season_sin = d$season_sin, season_cos = d$season_cos,
        promotion = d$promotion, m)
}

sse <- function(params, rows = 1:n) {
  X <- design_matrix(params)[rows, , drop = FALSE]
  sum(lm.fit(X, y[rows])$residuals^2)
}

The sse function is the heart of it. For any six parameters it builds the design matrix, fits ordinary least squares with lm.fit (the fast engine underneath lm, which takes a matrix instead of a formula) and returns the residual sum of squares. Fitting the model then means finding the parameters that make that number as small as possible.

Fit the parameters with optim, then the model with lm

optim with method = "L-BFGS-B" minimizes a function within box bounds, which is what we want: decays between 0 and 0.9, and half-saturation multiples between 0.2 and 10. A coarse grid gives it a sensible place to start, so it doesn’t wander off into a bad local minimum.

lower <- c(0, 0, 0, 0.2, 0.2, 0.2)
upper <- c(0.9, 0.9, 0.9, 10, 10, 10)

# Coarse grid for a starting point (same decay and multiple for every channel) ...
grid <- expand.grid(decay = seq(0, 0.9, by = 0.1), mult = c(0.5, 1, 2, 5, 10))
grid_sse <- apply(grid, 1, function(g) sse(c(rep(g["decay"], 3), rep(g["mult"], 3))))
start <- unlist(c(rep(grid$decay[which.min(grid_sse)], 3), rep(grid$mult[which.min(grid_sse)], 3)))

# ... then optim polishes it within the bounds.
fit_params <- optim(start, sse, method = "L-BFGS-B", lower = lower, upper = upper)
best <- fit_params$par

You could do the same with nls and algorithm = "port", which also accepts bounds and gives you standard errors for the nonlinear parameters. In my experience it’s fussier about starting values and tends to fail exactly when a parameter wants to sit on its bound, which, as you’re about to see, is what happens here. With optim, the linear coefficients are handled by least squares inside the objective, so only the six nonlinear parameters are searched, and that’s much more stable.

Once the parameters are chosen, the script refits the final model with lm so you get the familiar summary table:

model_data <- data.frame(revenue = y, design_matrix(best)[, -1])
model <- lm(revenue ~ t + season_sin + season_cos + promotion +
              search_spend + social_spend + video_spend, data = model_data)
print(summary(model))

The search should leave all three decays at or within a hair of zero and push all three half-saturation multiples to 10, the top of their range. That’s the model telling you there’s no detectable carryover and no detectable diminishing returns in this data, and it’s correct: the practice data was generated with neither. A parameter on its bound is always worth reporting, because it means your assumption, not the data, decided the value. The fit is essentially perfect, with an R-squared that rounds to 1.0000, a MAPE of 0.04 percent and a residual standard error of about 13.6, because the synthetic data has no noise beyond rounding. Real weekly revenue won’t fit like this, and an R-squared this close to 1 on real data should worry you.

Decomposition, ROI and marginal ROI

Multiplying each column of the design matrix by its coefficient splits every week’s revenue into baseline (intercept, trend and seasonality), promotion and each channel. sweep does that in one line. Note that lm names the intercept (Intercept), so the script renames it to match the design matrix before lining the coefficients up.

X <- design_matrix(best)
beta <- coef(model)
names(beta)[1] <- "intercept"      # lm calls it "(Intercept)"
beta <- beta[colnames(X)]          # same order as the design matrix
contrib <- sweep(X, 2, beta, `*`)
decomp <- data.frame(
  baseline = rowSums(contrib[, c("intercept", "t", "season_sin", "season_cos")]),
  promotion = contrib[, "promotion"],
  search = contrib[, "search_spend"],
  social = contrib[, "social_spend"],
  video = contrib[, "video_spend"]
)
print(round(colSums(decomp) / sum(decomp), 3))

Over the two years the model attributes 76.0 percent of revenue to the baseline, 14.5 percent to search, 5.8 percent to social, 3.1 percent to video and 0.5 percent to promotions. ROI is each channel’s total contribution divided by its total spend, and marginal ROI, the return on the next dollar at average spend, is the slope of the response curve at that point:

marginal_roi <- function(s, j) {
  a <- s / (1 - decays[j])
  media_beta[j] * half[j] / (a + half[j])^2 / (1 - decays[j])
}

You should get an average ROI of 1.54 for search, 0.87 for social and 0.65 for video, and marginal ROIs of 1.40, 0.80 and 0.60. These are revenue per dollar, so convert them to profit with your margin before calling anything profitable; the breakeven ROAS page does that. The gap between average and marginal is the reason budget decisions should use marginal ROAS.

Response curves and a budget reallocation

The response curves plot each channel’s steady-state weekly contribution against weekly spend, with a dot at today’s average. On this data they’re nearly straight across the observed range, which matches what the parameter search found. For the reallocation, the script keeps the total weekly budget fixed and lets each channel move up to 30 percent either way. With three channels the simplest reliable approach in base R is a fine grid: choose search and social spend on a 300 by 300 grid, give video whatever is left, and keep only the plans where video is inside its limits too.

plans <- expand.grid(search = seq(lo[1], hi[1], length.out = 300),
                     social = seq(lo[2], hi[2], length.out = 300))
plans$video <- total - plans$search - plans$social
plans <- plans[plans$video >= lo[3] & plans$video <= hi[3], ]

The best plan puts search at its ceiling of about 3,153 a week (up 728), social at about 1,353 (down 363) and video at about 855 (down 364), for roughly 474 a week of extra revenue at the same total spend. The Python version, using a continuous optimizer instead of a grid, lands within two dollars of the same split. On real data with genuine saturation the answer would stop short of the limits, at the point where the channels’ marginal returns are equal, and the budget allocation page explains why the limits matter so much.

Holdout check and the answer key

The last two steps refit the whole model (parameters and coefficients) on the first 78 weeks and predict the last 26, which gives a training MAPE of 0.03 percent and a holdout MAPE of 0.09 percent, and then compare the ROI estimates with the effects the data was generated from: 1.4, 0.8 and 0.6 of revenue per dollar of search, social and video. The marginal ROIs match exactly. The average ROIs are about 10 percent too high, because the half-saturation bound of 10 forces the Hill curve to bend slightly; widening the bound to 100 brings them to 1.41, 0.81 and 0.61. The model validation page covers what else to check before you trust a model, and the general lesson is that the bounds you set on saturation are assumptions that change the ROI you report.

The viewer below shows each step side by side in R, Python and Excel, using the code from the three downloads, so you can translate between them or explain the model to a colleague who works in a different tool.

Interactive
The same model in R, Python and Excel

Pick a step and a language, or show all three at once, to see how each tool does the same job.

All three check the same things: row count, missing values, gaps between weeks and the correlation of each column with revenue. The Excel template doesn’t include these audit formulas; add them in spare cells on the Data sheet.

Rmmm-r-tutorial.R
d <- read.csv(csv)
d$week <- as.Date(d$week)

print(dim(d))
cat("Missing values:", sum(is.na(d)), "\n")
print(table(diff(d$week)))              # every gap should be 7 days
print(round(cor(d[, c("revenue", channels, "promotion")])[, "revenue"], 2))
Snippets are excerpts from the three downloads on this site: mmm-r-tutorial.R, mmm-python-tutorial.py and mmm-excel-template.xlsx (the Excel audit and holdout formulas are ones you add yourself). They share one model and one synthetic dataset. The R script was checked by porting each step to Python; it was not executed in R when this page was built.

Where Robyn takes over

Once you’ve built the model by hand, Robyn is much easier to understand, because it’s doing the same job with more machinery. Instead of OLS it fits a ridge regression, which shrinks coefficients to cope with correlated channels. Instead of a geometric decay and a fixed Hill shape it offers geometric or Weibull adstock and a Hill curve with a free shape. Instead of one optim run minimizing squared error, it uses Nevergrad (a Python optimization library, called from R) to search thousands of hyperparameter combinations against two objectives at once, prediction error (NRMSE) and how far each channel’s share of effect strays from its share of spend (DECOMP.RSSD), and hands you a Pareto front of candidate models to choose from. Experiment results can enter as a third objective, and the budget allocator does what our grid did, with more options.

To get started, install.packages("Robyn") installs the CRAN version (3.12.1, July 2025), and the repository’s demo.R walks through a full run on simulated data; you’ll also need Python with Nevergrad, which the README explains how to set up. Robyn models a single national series, so if you have regional data, look at the open-source MMM comparison for the Bayesian options, which are mostly in Python. For experiments, GeoLift and CausalImpact both run in R, and the geo lift testing page explains when you’d use them. If you’d like to practise the judgment calls rather than the code, the Vexpower MMM course works through a messier invented brand from brief to budget decision.

Downloads

r
MMM R tutorial scriptBase R only. Load and audit, adstock, Hill saturation, lm, optim, decomposition, ROI, response curves, reallocation and a holdout check.
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 R?

Read weekly sales and spend with read.csv, transform each channel with an adstock function (stats::filter with method recursive does geometric adstock) and a saturation curve, add trend, seasonality and promotion controls, and fit lm. Use optim to search the adstock and saturation parameters that minimize the residual sum of squares, then use the coefficients for decomposition, ROI and budget scenarios.

What is the best R package for marketing mix modeling?

Meta's Robyn is the main open-source MMM package for R. It fits ridge regression with geometric or Weibull adstock and Hill saturation, searches hyperparameters with Nevergrad and includes a budget allocator. Its latest CRAN release is 3.12.1 from July 2025. For geo experiments in R, Meta's GeoLift and Google's CausalImpact are the usual companions.

How do you calculate adstock in R?

Use stats::filter(x, filter = decay, method = "recursive"), which computes y[t] = x[t] + decay * y[t-1], exactly geometric adstock. Wrap it in as.numeric() to drop the time-series class, and write stats::filter in full because dplyr also has a function called filter.

Should I use nls or optim to fit adstock parameters in R?

Either works. optim with method L-BFGS-B lets you put bounds on every parameter and fit the linear coefficients with lm inside the objective, which is simple and stable. nls with algorithm port also accepts bounds and gives standard errors, but it is more sensitive to starting values and can fail when a parameter sits on its bound.

Does Robyn need Python?

Yes, Robyn's hyperparameter search uses Nevergrad, a Python library, which R calls through the reticulate package. Robyn's repository includes an install_nevergrad.R guide for setting it up. There is also a beta Python port, robynpy, which its README says was rewritten from the R code with the help of LLMs such as Llama.

References

  1. R documentation: stats::filter Recursive filter y[i] = x[i] + f[1]*y[i-1] + ...
  2. R documentation: optim L-BFGS-B with lower and upper bounds.
  3. R documentation: nls The port algorithm accepts bounds.
  4. Robyn, GitHub repository and README (Meta Marketing Science) Ridge regression, Nevergrad dependency, demo.R, Python beta.
  5. Robyn features documentation Adstock and saturation options, objectives, allocator.
  6. Robyn on CRAN Version 3.12.1, 2 July 2025.
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