Media Mix Modeling Analysis

Regression-Based MMM with Diminishing Returns and Budget Reallocation

Author

Mickyas Shawel

Published

February 12, 2026

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:

  1. Which marketing channels appear to generate the strongest marginal return?
  2. Which channels show signs of diminishing returns?
  3. 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
  • Email

It also includes control variables for promotional activity, pricing, competitor pressure, and seasonality.

library(tidyverse)
library(knitr)
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")
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")
Average Weekly Spend by Channel
channel avg_weekly_spend
Paid_Search 66518
Paid_Social 46120
Display 30064
Video 38961
Influencer 23404
Email 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")
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")
Recommended Weekly Budget Reallocation
channel current_avg_spend recommended_avg_spend weekly_delta marginal_roi_rank
email 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.