library(tidyverse)
library(knitr)Media Mix Modeling Analysis
Regression-Based MMM with Diminishing Returns and Budget Reallocation
Project Overview
This project builds a simplified Media Mix Model (MMM) using simulated weekly marketing spend data. The model estimates how different paid media channels contribute to weekly revenue while accounting for promotion activity, pricing, competitor pressure, and seasonality.
The project demonstrates how marketing analytics can be used to support budget allocation decisions across multiple channels.
Business Objective
The goal of this analysis is to answer three core marketing questions:
- Which marketing channels appear to generate the strongest marginal return?
- Which channels show signs of diminishing returns?
- How should the budget be reallocated to improve marketing efficiency?
Data Description
The dataset contains 52 weeks of simulated marketing performance data across six media channels:
- Paid Search
- Paid Social
- Display
- Video
- Influencer
It also includes control variables for promotional activity, pricing, competitor pressure, and seasonality.
mmm_data <- read.csv("data/mmm_simulated_data.csv")
glimpse(mmm_data)Rows: 52
Columns: 20
$ week <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 1…
$ date <chr> "2025-01-06", "2025-01-13", "2025-01-20", "2025-01-2…
$ spend_paid_search <int> 74111, 76286, 66914, 66142, 78933, 77325, 76432, 711…
$ spend_paid_social <int> 42188, 47051, 47360, 43216, 53840, 55222, 57823, 541…
$ spend_display <int> 35779, 32172, 27636, 34612, 38639, 35750, 28436, 318…
$ spend_video <int> 40804, 41996, 48282, 47250, 46656, 46826, 52030, 390…
$ spend_influencer <int> 25243, 21919, 22643, 19421, 28071, 27316, 29627, 207…
$ spend_email <int> 8595, 9561, 8039, 8872, 10608, 10963, 11140, 7988, 7…
$ promo <int> 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0…
$ price_index <dbl> 1.022, 1.005, 0.997, 0.991, 0.956, 0.978, 0.986, 1.0…
$ competitor_index <dbl> 0.972, 0.987, 0.984, 0.941, 1.012, 1.010, 1.000, 0.9…
$ seasonality_sin <dbl> 0.1205, 0.2393, 0.3546, 0.4647, 0.5681, 0.6631, 0.74…
$ seasonality_cos <dbl> 0.9927, 0.9709, 0.9350, 0.8855, 0.8230, 0.7485, 0.66…
$ revenue <int> 693576, 755359, 787243, 793593, 895214, 877194, 8717…
$ search_feat <dbl> 0.4443854, 0.5749757, 0.6068851, 0.6211625, 0.651435…
$ social_feat <dbl> 0.3731559, 0.5047419, 0.5509441, 0.5540998, 0.592831…
$ disp_feat <dbl> 0.4372743, 0.4981276, 0.4892788, 0.5276211, 0.560116…
$ video_feat <dbl> 0.4005806, 0.5636243, 0.6540966, 0.6919225, 0.709966…
$ influ_feat <dbl> 0.3701092, 0.4690173, 0.5159593, 0.5115717, 0.573752…
$ email_feat <dbl> 0.4132824, 0.4935731, 0.4763750, 0.4914095, 0.531099…
First Look at the Data
head(mmm_data) |>
kable(caption = "First Six Rows of the MMM Dataset")| week | date | spend_paid_search | spend_paid_social | spend_display | spend_video | spend_influencer | spend_email | promo | price_index | competitor_index | seasonality_sin | seasonality_cos | revenue | search_feat | social_feat | disp_feat | video_feat | influ_feat | email_feat |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 2025-01-06 | 74111 | 42188 | 35779 | 40804 | 25243 | 8595 | 0 | 1.022 | 0.972 | 0.1205 | 0.9927 | 693576 | 0.4443854 | 0.3731559 | 0.4372743 | 0.4005806 | 0.3701092 | 0.4132824 |
| 2 | 2025-01-13 | 76286 | 47051 | 32172 | 41996 | 21919 | 9561 | 0 | 1.005 | 0.987 | 0.2393 | 0.9709 | 755359 | 0.5749757 | 0.5047419 | 0.4981276 | 0.5636243 | 0.4690173 | 0.4935731 |
| 3 | 2025-01-20 | 66914 | 47360 | 27636 | 48282 | 22643 | 8039 | 0 | 0.997 | 0.984 | 0.3546 | 0.9350 | 787243 | 0.6068851 | 0.5509441 | 0.4892788 | 0.6540966 | 0.5159593 | 0.4763750 |
| 4 | 2025-01-27 | 66142 | 43216 | 34612 | 47250 | 19421 | 8872 | 0 | 0.991 | 0.941 | 0.4647 | 0.8855 | 793593 | 0.6211625 | 0.5540998 | 0.5276211 | 0.6919225 | 0.5115717 | 0.4914095 |
| 5 | 2025-02-03 | 78933 | 53840 | 38639 | 46656 | 28071 | 10608 | 1 | 0.956 | 1.012 | 0.5681 | 0.8230 | 895214 | 0.6514350 | 0.5928316 | 0.5601167 | 0.7099665 | 0.5737525 | 0.5310995 |
| 6 | 2025-02-10 | 77325 | 55222 | 35750 | 46826 | 27316 | 10963 | 1 | 0.978 | 1.010 | 0.6631 | 0.7485 | 877194 | 0.6637680 | 0.6125927 | 0.5567784 | 0.7203975 | 0.5956796 | 0.5466429 |
Marketing Spend by Channel
spend_summary <- mmm_data |>
summarise(
Paid_Search = mean(spend_paid_search),
Paid_Social = mean(spend_paid_social),
Display = mean(spend_display),
Video = mean(spend_video),
Influencer = mean(spend_influencer),
Email = mean(spend_email)
) |>
pivot_longer(everything(), names_to = "channel", values_to = "avg_weekly_spend")
spend_summary |>
mutate(avg_weekly_spend = round(avg_weekly_spend, 0)) |>
kable(caption = "Average Weekly Spend by Channel")| channel | avg_weekly_spend |
|---|---|
| Paid_Search | 66518 |
| Paid_Social | 46120 |
| Display | 30064 |
| Video | 38961 |
| Influencer | 23404 |
| 9296 |
spend_summary |>
ggplot(aes(x = reorder(channel, avg_weekly_spend), y = avg_weekly_spend)) +
geom_col() +
coord_flip() +
labs(
title = "Average Weekly Spend by Marketing Channel",
x = "Channel",
y = "Average Weekly Spend ($)"
) +
theme_minimal()MMM Methodology
This project uses two common transformations in media mix modeling:
Adstock
Adstock captures the idea that media spend can continue influencing customers after the week it was spent. For example, a video campaign may continue affecting brand awareness after the original ad exposure.
adstock <- function(x, theta = 0.5) {
out <- numeric(length(x))
for (i in seq_along(x)) {
out[i] <- x[i] + ifelse(i == 1, 0, out[i - 1] * theta)
}
out
}
hill_saturation <- function(x, alpha = 1.2, gamma = 50000) {
x <- pmax(x, 0)
(x^alpha) / (x^alpha + gamma^alpha)
}
hill_derivative <- function(x, alpha = 1.2, gamma = 50000) {
x <- pmax(x, 1e-9)
num <- alpha * (gamma^alpha) * (x^(alpha - 1))
den <- (x^alpha + gamma^alpha)^2
num / den
}Hill Saturation
Hill saturation captures diminishing returns. As spend increases, each additional dollar tends to produce a smaller incremental lift after the channel begins to saturate.
Model Specification
The model estimates weekly revenue using transformed media variables and business controls.
mmm_model <- lm(
revenue ~ search_feat + social_feat + disp_feat + video_feat + influ_feat + email_feat +
promo + price_index + competitor_index + seasonality_sin + seasonality_cos,
data = mmm_data
)
summary(mmm_model)
Call:
lm(formula = revenue ~ search_feat + social_feat + disp_feat +
video_feat + influ_feat + email_feat + promo + price_index +
competitor_index + seasonality_sin + seasonality_cos, data = mmm_data)
Residuals:
Min 1Q Median 3Q Max
-20263 -9414 -1376 7661 26716
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) 638613 113263 5.638 1.52e-06 ***
search_feat 419527 230309 1.822 0.07600 .
social_feat 82930 144968 0.572 0.57049
disp_feat 141729 115173 1.231 0.22567
video_feat -51604 123362 -0.418 0.67795
influ_feat 115046 79947 1.439 0.15792
email_feat 18640 124710 0.149 0.88194
promo 43096 7157 6.022 4.41e-07 ***
price_index -208752 73969 -2.822 0.00739 **
competitor_index -86078 52292 -1.646 0.10758
seasonality_sin 20475 8529 2.401 0.02110 *
seasonality_cos 32451 5898 5.502 2.36e-06 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 13350 on 40 degrees of freedom
Multiple R-squared: 0.9329, Adjusted R-squared: 0.9145
F-statistic: 50.6 on 11 and 40 DF, p-value: < 2.2e-16
Model Performance
model_fit <- summary(mmm_model)
fit_table <- tibble(
metric = c("R-squared", "Adjusted R-squared", "F-statistic"),
value = c(
round(model_fit$r.squared, 3),
round(model_fit$adj.r.squared, 3),
round(model_fit$fstatistic[1], 2)
)
)
fit_table |>
kable(caption = "Model Fit Summary")| metric | value |
|---|---|
| R-squared | 0.933 |
| Adjusted R-squared | 0.915 |
| F-statistic | 50.600 |
The model explains a strong share of revenue variation in the simulated dataset, with an R-squared above 0.90.
Diminishing Returns Curves
The chart below visualizes diminishing returns across the six channels. The curve shape shows how incremental revenue begins to flatten as spend increases.
knitr::include_graphics("plots/diminishing_returns_curves.png")Channel-Level Response Curves
knitr::include_graphics(c(
"plots/paid_search_curve.png",
"plots/paid_social_curve.png",
"plots/display_curve.png",
"plots/video_curve.png",
"plots/influencer_curve.png",
"plots/email_curve.png"
))Budget Reallocation Recommendation
The recommendation moves 10% of the average weekly marketing budget from lower marginal ROI channels toward higher marginal ROI channels, while keeping each channel between 70% and 130% of its current average weekly spend.
budget_rec <- read.csv("output/budget_reallocation_recommendation.csv")
budget_rec |>
kable(caption = "Recommended Weekly Budget Reallocation")| channel | current_avg_spend | recommended_avg_spend | weekly_delta | marginal_roi_rank |
|---|---|---|---|---|
| 9296 | 12085 | 2789 | 1 | |
| influencer | 23404 | 30425 | 7021 | 2 |
| video | 38961 | 50587 | 11626 | 3 |
| paid_social | 46120 | 46120 | 0 | 4 |
| display | 30064 | 28583 | -1481 | 5 |
| paid_search | 66518 | 46562 | -19955 | 6 |
budget_rec |>
select(channel, current_avg_spend, recommended_avg_spend) |>
pivot_longer(
cols = c(current_avg_spend, recommended_avg_spend),
names_to = "budget_type",
values_to = "spend"
) |>
ggplot(aes(x = reorder(channel, spend), y = spend, fill = budget_type)) +
geom_col(position = "dodge") +
coord_flip() +
labs(
title = "Current vs Recommended Weekly Spend",
x = "Channel",
y = "Weekly Spend ($)",
fill = "Budget Type"
) +
theme_minimal()Interpretation
The marginal ROI ranking suggests that Email, Influencer, and Video have the highest estimated marginal return at current spend levels. The recommendation therefore increases weekly investment in those channels.
The model recommends decreasing spend from lower marginal ROI channels, especially Paid Search and Display, while keeping the changes within practical limits.
Final Recommendation
Based on the model output, the recommended budget shift is:
- Increase Email spend
- Increase Influencer spend
- Increase Video spend
- Keep Paid Social approximately flat
- Reduce Display spend slightly
- Reduce Paid Search spend materially
This recommendation is based on marginal ROI at current average spend levels and assumes the simulated model structure accurately represents the campaign environment.