- name
- marketing-mix-modeling-end-to-end-pipeline
- description
- Build end-to-end Marketing Mix Models with adstock, saturation, OLS regression, and budget optimization in Python
- triggers
- ["build a marketing mix model","implement MMM with adstock and saturation","optimize marketing budget allocation","calculate channel ROI for marketing spend","apply geometric adstock transformation","run marketing attribution analysis","create budget optimizer for marketing channels","decompose revenue by marketing channel"]
# Marketing Mix Modeling End-to-End Pipeline
> Skill by [ara.so](https://ara.so) — Marketing Skills collection.
This project provides a complete Marketing Mix Modeling (MMM) pipeline in Python that transforms raw marketing spend data into actionable channel insights and optimized budget allocations. It includes geometric adstock transformation, Hill saturation curves, OLS regression modeling, and constrained budget optimization.
## Installation
```bash
# Clone the repository
git clone https://github.com/francescaetnom-wq/Marketing-Mix-Modeling-End-to-End-Pipeline.git
cd Marketing-Mix-Modeling-End-to-End-Pipeline
# Create virtual environment
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
**Key Dependencies:**
- `pandas` — data manipulation
- `numpy` — numerical operations
- `statsmodels` — OLS regression
- `scipy` — optimization algorithms
- `matplotlib` / `seaborn` — visualization
## Project Structure
```
mmm_project/
├── data/
│ ├── dt_simulated_weekly.csv # Input: weekly spend & revenue
│ ├── dt_transformed.csv # Output: with adstock/saturation
│ ├── coefficients.csv # Output: model coefficients
│ └── optimized_budget.csv # Output: budget recommendations
├── src/
│ ├── adstock.py # Geometric adstock
│ ├── saturation.py # Hill saturation
│ ├── model.py # OLS wrapper
│ └── optimizer.py # Budget allocation
└── notebooks/
├── 01_exploration.ipynb
├── 02_transformations.ipynb
├── 03_model.ipynb
└── 04_optimizer.ipynb
```
## Core Transformations
### 1. Geometric Adstock
Models the carryover effect of advertising — this week's exposure includes decayed contributions from previous weeks.
**Implementation (`src/adstock.py`):**
```python
import numpy as np
import pandas as pd
def geometric_adstock(x, theta, L_max=8):
"""
Apply geometric adstock transformation.
Parameters:
-----------
x : array-like
Raw media spend or impressions
theta : float
Decay rate (0 to 1). Higher = longer carryover
L_max : int
Maximum lag window
Returns:
--------
array : Adstocked signal
"""
x = np.array(x)
adstocked = np.zeros_like(x, dtype=float)
for t in range(len(x)):
for lag in range(min(t + 1, L_max)):
adstocked[t] += x[t - lag] * (theta ** lag)
return adstocked
# Example usage
spend = [100, 150, 120, 90, 110]
adstocked_spend = geometric_adstock(spend, theta=0.5, L_max=4)
# adstocked_spend accounts for carryover from previous weeks
```
**Apply to DataFrame:**
```python
import pandas as pd
df = pd.read_csv('data/dt_simulated_weekly.csv')
# Define adstock parameters per channel
adstock_params = {
'tv_S': 0.7, # High carryover
'search_S': 0.3, # Low carryover
'facebook_S': 0.5,
'print_S': 0.6,
'ooh_S': 0.4
}
# Apply adstock to each channel
for channel, theta in adstock_params.items():
df[f'{channel}_adstocked'] = geometric_adstock(
df[channel].values,
theta=theta,
L_max=8
)
```
### 2. Hill Saturation
Models diminishing returns — the first dollar spent is more effective than the millionth.
**Implementation (`src/saturation.py`):**
```python
import numpy as np
def hill_saturation(x, alpha, gamma):
"""
Apply Hill saturation transformation.
Parameters:
-----------
x : array-like
Input signal (usually adstocked spend)
alpha : float
Half-saturation point (inflection)
gamma : float
Shape parameter (> 1 for S-curve)
Returns:
--------
array : Saturated signal
"""
x = np.array(x)
return (x ** gamma) / (alpha ** gamma + x ** gamma)
# Example usage
adstocked = np.array([50, 100, 150, 200, 250])
saturated = hill_saturation(adstocked, alpha=100, gamma=2.0)
# saturated shows diminishing marginal returns
```
**Apply After Adstock (Order Matters!):**
```python
# Saturation parameters per channel
saturation_params = {
'tv_S': {'alpha': 150, 'gamma': 2.0},
'search_S': {'alpha': 80, 'gamma': 1.8},
'facebook_S': {'alpha': 100, 'gamma': 1.9},
'print_S': {'alpha': 120, 'gamma': 2.1},
'ooh_S': {'alpha': 90, 'gamma': 2.0}
}
# Apply saturation to adstocked columns
for channel, params in saturation_params.items():
adstocked_col = f'{channel}_adstocked'
df[f'{channel}_transformed'] = hill_saturation(
df[adstocked_col].values,
alpha=params['alpha'],
gamma=params['gamma']
)
# Save transformed data
df.to_csv('data/dt_transformed.csv', index=False)
```
## OLS Regression Model
### Training the Model
**Implementation (`src/model.py`):**
```python
import pandas as pd
import statsmodels.api as sm
def fit_mmm_model(df, transformed_channels, controls, target='revenue'):
"""
Fit OLS regression for MMM.
Parameters:
-----------
df : DataFrame
Input data with transformed channels
transformed_channels : list
Column names of transformed media variables
controls : list
Control variables (e.g., competitor_sales_B, events)
target : str
Revenue or sales column
Returns:
--------
model : statsmodels RegressionResults
"""
# Prepare feature matrix
X = df[transformed_channels + controls].copy()
# Handle categorical controls (one-hot encode events)
if 'events' in controls:
X = pd.get_dummies(X, columns=['events'], drop_first=True)
# Add constant
X = sm.add_constant(X)
# Target variable
y = df[target]
# Fit OLS
model = sm.OLS(y, X).fit()
return model
# Example usage
df = pd.read_csv('data/dt_transformed.csv')
transformed_channels = [
'tv_S_transformed',
'search_S_transformed',
'facebook_S_transformed',
'print_S_transformed',
'ooh_S_transformed'
]
controls = ['competitor_sales_B', 'events']
model = fit_mmm_model(df, transformed_channels, controls, target='revenue')
print(model.summary())
print(f"\nR-squared: {model.rsquared:.3f}")
```
### Extract Coefficients & ROI
```python
import pandas as pd
def extract_coefficients(model, channel_names):
"""Extract coefficients and calculate proxy ROI."""
coefs = model.params[channel_names]
pvalues = model.pvalues[channel_names]
results = pd.DataFrame({
'channel': channel_names,
'coefficient': coefs.values,
'p_value': pvalues.values
})
return results
# Get coefficients
channel_names = [col for col in model.params.index if '_transformed' in col]
coef_df = extract_coefficients(model, channel_names)
# Calculate proxy ROI (revenue per unit spend)
# Match transformed columns to original spend columns
spend_mapping = {
'tv_S_transformed': 'tv_S',
'search_S_transformed': 'search_S',
'facebook_S_transformed': 'facebook_S',
'print_S_transformed': 'print_S',
'ooh_S_transformed': 'ooh_S'
}
for idx, row in coef_df.iterrows():
channel_transformed = row['channel']
channel_raw = spend_mapping[channel_transformed]
# Average contribution / average spend
avg_spend = df[channel_raw].mean()
avg_contribution = row['coefficient'] * df[channel_transformed].mean()
coef_df.loc[idx, 'avg_spend'] = avg_spend
coef_df.loc[idx, 'avg_contribution'] = avg_contribution
coef_df.loc[idx, 'proxy_roi'] = avg_contribution / avg_spend if avg_spend > 0 else 0
coef_df.to_csv('data/coefficients.csv', index=False)
print(coef_df)
```
## Budget Optimization
Find the optimal budget allocation to maximize revenue under a fixed total budget constraint.
**Implementation (`src/optimizer.py`):**
```python
import numpy as np
from scipy.optimize import minimize
def optimize_budget(model, df, channel_mapping, total_budget,
adstock_params, saturation_params):
"""
Optimize budget allocation across channels.
Parameters:
-----------
model : statsmodels RegressionResults
Fitted OLS model
df : DataFrame
Historical data (for baseline calculation)
channel_mapping : dict
Maps transformed column names to raw spend columns
total_budget : float
Total budget constraint
adstock_params : dict
Adstock theta per channel
saturation_params : dict
Saturation alpha/gamma per channel
Returns:
--------
dict : Optimized budget allocation
"""
from adstock import geometric_adstock
from saturation import hill_saturation
channels = list(channel_mapping.keys())
n_channels = len(channels)
# Extract coefficients
coefs = {col: model.params[col] for col in channels}
def predict_revenue(budget_allocation):
"""Predict revenue given budget allocation."""
revenue = 0
for i, channel_raw in enumerate(channels):
spend = budget_allocation[i]
channel_transformed = channel_mapping[channel_raw]
# Apply transformations
adstocked = geometric_adstock(
[spend],
theta=adstock_params[channel_raw],
L_max=1 # Single period optimization
)[0]
saturated = hill_saturation(
[adstocked],
alpha=saturation_params[channel_raw]['alpha'],
gamma=saturation_params[channel_raw]['gamma']
)[0]
# Multiply by coefficient
revenue += saturated * coefs[channel_transformed]
return revenue
# Objective: negative revenue (minimize = maximize revenue)
def objective(budget_allocation):
return -predict_revenue(budget_allocation)
# Constraints
constraints = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - total_budget} # Sum = total
]
# Bounds: non-negative spend
bounds = [(0, total_budget) for _ in range(n_channels)]
# Initial guess: equal split
x0 = np.array([total_budget / n_channels] * n_channels)
# Optimize
result = minimize(
objective,
x0,
method='SLSQP',
bounds=bounds,
constraints=constraints
)
# Format results
optimized_budget = {
channel: result.x[i]
for i, channel in enumerate(channels)
}
return optimized_budget, -result.fun # Return revenue (negate objective)
# Example usage
channel_mapping = {
'tv_S': 'tv_S_transformed',
'search_S': 'search_S_transformed',
'facebook_S': 'facebook_S_transformed',
'print_S': 'print_S_transformed',
'ooh_S': 'ooh_S_transformed'
}
# Calculate current total budget
current_budget = df[list(channel_mapping.keys())].sum(axis=1).mean()
# Optimize
optimized, projected_revenue = optimize_budget(
model=model,
df=df,
channel_mapping=channel_mapping,
total_budget=current_budget,
adstock_params=adstock_params,
saturation_params=saturation_params
)
# Compare current vs optimized
current_allocation = {ch: df[ch].mean() for ch in channel_mapping.keys()}
comparison = pd.DataFrame({
'channel': list(channel_mapping.keys()),
'current_budget': [current_allocation[ch] for ch in channel_mapping.keys()],
'optimized_budget': [optimized[ch] for ch in channel_mapping.keys()]
})
comparison['change_pct'] = (
(comparison['optimized_budget'] - comparison['current_budget']) /
comparison['current_budget'] * 100
)
comparison.to_csv('data/optimized_budget.csv', index=False)
print(comparison)
```
## Complete Workflow
```python
import pandas as pd
import numpy as np
from src.adstock import geometric_adstock
from src.saturation import hill_saturation
from src.model import fit_mmm_model
from src.optimizer import optimize_budget
# 1. Load data
df = pd.read_csv('data/dt_simulated_weekly.csv')
df['DATE'] = pd.to_datetime(df['DATE'])
# Clean events column (handle literal "na" string)
df['events'] = df['events'].replace('na', np.nan)
# 2. Define transformation parameters
Ver no GitHub