Skip to main content

marketing-mix-modeling-pipeline

End-to-end Marketing Mix Modeling pipeline with adstock, saturation, OLS regression, and budget optimization

Jump to install

Source facts

Repository
reason-machines/marketing-skills
Last source activity
June 26, 2026 at 20:39
Detected SKILL.md language
English
Stars
10
Forks
1

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
marketing-mix-modeling-pipeline
description
End-to-end Marketing Mix Modeling pipeline with adstock, saturation, OLS regression, and budget optimization
triggers
["build a marketing mix model","calculate channel ROI with adstock","optimize marketing budget allocation","apply saturation curves to media spend","marketing mix modeling pipeline","measure incremental revenue by channel","adstock transformation for advertising","budget optimizer for marketing channels"]
# Marketing Mix Modeling Pipeline > Skill by [ara.so](https://ara.so) — Marketing Skills collection. This project provides an end-to-end Marketing Mix Modeling (MMM) pipeline in Python. It transforms raw weekly marketing spend into channel-level revenue contributions using adstock (carryover) and saturation (diminishing returns) transformations, fits an OLS regression, and optimizes budget allocation to maximize revenue. ## What It Does - **Adstock transformation**: models the carryover effect of advertising (geometric decay) - **Hill saturation**: models diminishing returns as spend increases - **OLS regression**: decomposes revenue into channel contributions + baseline - **Budget optimization**: reallocates spend across channels to maximize predicted revenue - **Privacy-safe**: uses aggregate historical data, no user-level tracking ## 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** (add to `requirements.txt` if missing): ``` pandas>=1.5.0 numpy>=1.23.0 statsmodels>=0.14.0 scipy>=1.10.0 jupyter>=1.0.0 matplotlib>=3.6.0 seaborn>=0.12.0 ``` ## Project Structure ``` mmm_project/ ├── data/ │ ├── dt_simulated_weekly.csv # raw input (Robyn demo data) │ ├── dt_transformed.csv # with adstock + saturation │ ├── coefficients.csv # model coefficients │ └── optimized_budget.csv # optimization results ├── notebooks/ │ ├── 01_exploration.ipynb │ ├── 02_transformations.ipynb │ ├── 03_model.ipynb │ └── 04_optimizer.ipynb ├── src/ │ ├── adstock.py # adstock transformation │ ├── saturation.py # Hill saturation │ ├── model.py # OLS wrapper │ └── optimizer.py # budget allocation └── requirements.txt ``` ## Core Transformations ### 1. Geometric Adstock Models carryover effect: today's exposure includes decayed contribution from previous periods. **Formula**: `X_t = x_t + θ × X_{t-1}` where θ ∈ [0,1] is decay rate. ```python # src/adstock.py import numpy as np import pandas as pd def geometric_adstock(x, theta): """ Apply geometric adstock transformation. Args: x: array-like, raw media spend theta: float [0,1], decay rate (0.5 = 50% carryover) Returns: array of same shape with adstock applied """ x = np.array(x, dtype=float) adstocked = np.zeros_like(x) adstocked[0] = x[0] for t in range(1, len(x)): adstocked[t] = x[t] + theta * adstocked[t-1] return adstocked # Usage spend = [100, 0, 0, 100, 0] adstocked = geometric_adstock(spend, theta=0.5) # [100.0, 50.0, 25.0, 112.5, 56.25] ``` **Applying to multiple channels**: ```python import pandas as pd df = pd.read_csv('data/dt_simulated_weekly.csv') # Channel columns channels = ['tv_S', 'ooh_S', 'print_S', 'facebook_S', 'search_S'] # Apply adstock with channel-specific decay rates adstock_params = { 'tv_S': 0.5, 'ooh_S': 0.4, 'print_S': 0.3, 'facebook_S': 0.2, 'search_S': 0.1 } for channel in channels: theta = adstock_params[channel] df[f'{channel}_adstocked'] = geometric_adstock(df[channel].values, theta) ``` ### 2. Hill Saturation Models diminishing returns: incremental revenue decreases as spend increases. **Formula**: `y = α × x^s / (K^s + x^s)` where: - α = maximum response - K = half-saturation point - s = slope (steepness) ```python # src/saturation.py import numpy as np def hill_saturation(x, alpha, K, s): """ Apply Hill saturation curve. Args: x: array-like, adstocked media signal alpha: float, maximum response (scale) K: float, half-saturation point s: float, slope/steepness (typically 1-3) Returns: saturated signal """ x = np.array(x, dtype=float) return alpha * np.power(x, s) / (np.power(K, s) + np.power(x, s)) # Usage adstocked = [0, 50, 100, 200, 500] saturated = hill_saturation(adstocked, alpha=1.0, K=100, s=2) ``` **Combining adstock + saturation** (order matters!): ```python # CORRECT: adstock first, then saturation for channel in channels: # Step 1: adstock adstocked = geometric_adstock(df[channel].values, theta=0.5) # Step 2: saturation on adstocked values saturated = hill_saturation(adstocked, alpha=1.0, K=100, s=2) df[f'{channel}_transformed'] = saturated ``` ## OLS Regression Model Fit a regression: `revenue = intercept + Σ(β_i × channel_i_transformed) + controls` ```python # src/model.py or in notebook import statsmodels.api as sm import pandas as pd def fit_mmm(df, channel_cols, control_cols, target='revenue'): """ Fit OLS regression for MMM. Args: df: DataFrame with transformed channels channel_cols: list of transformed channel column names control_cols: list of control variables (competitor_sales, events) target: target variable (revenue) Returns: statsmodels RegressionResults object """ # Build feature matrix X = df[channel_cols + control_cols].copy() # Add intercept X = sm.add_constant(X) # Target y = df[target] # Fit OLS model = sm.OLS(y, X).fit() return model # Example usage transformed_channels = [ 'tv_S_transformed', 'ooh_S_transformed', 'print_S_transformed', 'facebook_S_transformed', 'search_S_transformed' ] controls = ['competitor_sales_B'] model = fit_mmm(df, transformed_channels, controls, target='revenue') print(model.summary()) print(f"\nR²: {model.rsquared:.3f}") # Extract coefficients coef_df = pd.DataFrame({ 'channel': model.params.index, 'coefficient': model.params.values, 'p_value': model.pvalues.values }) ``` ## Channel Contribution & ROI ```python def calculate_contributions(df, model, channel_cols): """ Calculate each channel's total contribution to revenue. Returns DataFrame with channel, contribution, avg_spend, proxy_roi """ contributions = [] for col in channel_cols: coef = model.params[col] total_contrib = (df[col] * coef).sum() # Map back to original spend column raw_col = col.replace('_transformed', '') avg_spend = df[raw_col].mean() contributions.append({ 'channel': raw_col, 'coefficient': coef, 'total_contribution': total_contrib, 'avg_weekly_spend': avg_spend, 'proxy_roi': total_contrib / df[raw_col].sum() if df[raw_col].sum() > 0 else 0 }) return pd.DataFrame(contributions) contrib_df = calculate_contributions(df, model, transformed_channels) print(contrib_df.sort_values('proxy_roi', ascending=False)) ``` ## Budget Optimization Reallocate spend to maximize predicted revenue. ```python # src/optimizer.py from scipy.optimize import minimize import numpy as np def optimize_budget(model, channel_cols, total_budget, adstock_params, saturation_params): """ Find optimal budget allocation across channels. Args: model: fitted statsmodels model channel_cols: list of transformed channel names total_budget: total weekly budget to allocate adstock_params: dict {channel: theta} saturation_params: dict {channel: {'alpha': ..., 'K': ..., 's': ...}} Returns: dict with optimized budget per channel """ n_channels = len(channel_cols) def objective(budget_allocation): """Negative revenue (we minimize)""" revenue = model.params['const'] # intercept for i, col in enumerate(channel_cols): raw_col = col.replace('_transformed', '') spend = budget_allocation[i] # Apply transformations theta = adstock_params[raw_col] adstocked = spend # simplified single-period sat = saturation_params[raw_col] saturated = sat['alpha'] * (adstocked ** sat['s']) / \ (sat['K'] ** sat['s'] + adstocked ** sat['s']) revenue += model.params[col] * saturated return -revenue # minimize negative = maximize revenue # Constraints constraints = [ {'type': 'eq', 'fun': lambda x: np.sum(x) - total_budget} # sum = budget ] # Bounds: each channel >= 0 bounds = [(0, total_budget) for _ in range(n_channels)] # Initial guess: equal split x0 = np.full(n_channels, total_budget / n_channels) # Optimize result = minimize( objective, x0, method='SLSQP', bounds=bounds, constraints=constraints, options={'maxiter': 1000} ) if not result.success: print(f"Warning: optimization did not converge: {result.message}") # Build result dict optimal = {} for i, col in enumerate(channel_cols): raw_col = col.replace('_transformed', '') optimal[raw_col] = result.x[i] return optimal, -result.fun # return allocation and predicted revenue # Example usage total_weekly_budget = 100000 adstock_params = { 'tv_S': 0.5, 'ooh_S': 0.4, 'print_S': 0.3, 'facebook_S': 0.2, 'search_S': 0.1 } saturation_params = { 'tv_S': {'alpha': 1.0, 'K': 5000, 's': 2}, 'ooh_S': {'alpha': 1.0, 'K': 3000, 's': 2}, 'print_S': {'alpha': 1.0, 'K': 2000, 's': 2}, 'facebook_S': {'alpha': 1.0, 'K': 4000, 's': 2}, 'search_S': {'alpha': 1.0, 'K': 3500, 's': 2} } optimal_budget, predicted_revenue = optimize_budget( model, transformed_channels, total_weekly_budget, adstock_params, saturation_params ) print("Optimized budget allocation:") for channel, amount in optimal_budget.items(): print(f" {channel}: ${amount:,.0f}") print(f"\nPredicted revenue: ${predicted_revenue:,.0f}") ``` ## Full Workflow Example ```python import pandas as pd import numpy as np import statsmodels.api as sm from src.adstock import geometric_adstock from src.saturation import hill_saturation # 1. Load data df = pd.read_csv('data/dt_simulated_weekly.csv', parse_dates=['DATE']) # Clean events column (Robyn encodes "no event" as string "na") df['events'] = df['events'].replace('na', np.nan) # 2. Apply transformations channels = ['tv_S', 'ooh_S', 'print_S', 'facebook_S', 'search_S'] adstock_params = { 'tv_S': 0.5, 'ooh_S': 0.4, 'print_S': 0.3, 'facebook_S': 0.2, 'search_S': 0.1 } saturation_params = { 'tv_S': {'alpha': 1.0, 'K': 5000, 's': 2}, 'ooh_S': {'alpha': 1.0, 'K': 3000, 's': 2}, 'print_S': {'alpha': 1.0, 'K': 2000, 's': 2}, 'facebook_S': {'alpha': 1.0, 'K': 4000, 's': 2}, 'search_S': {'alpha': 1.0, 'K': 3500, 's': 2} } for channel in channels: # Adstock adstocked = geometric_adstock(df[channel].values, adstock_params[channel]) # Saturation
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub