Build a Marketing Mix Model

Measure ad effectiveness and optimize budget with Bayesian MMM.

Introduction

30 Second Summary

Every company that runs ads faces the same question: which channels actually drive sales, and which ones are wasting money? Marketing teams spend millions across search, display, and social without knowing the true return on each dollar.

In this project, you will build a Bayesian Marketing Mix Model that measures advertising effectiveness across channels, calculates return on ad spend, and generates executive-ready recommendations for budget reallocation.

What You'll Build

A professional Marketing Mix Modeling pipeline in Python that uses Bayesian statistics to attribute revenue to advertising channels and optimize budget allocation.

By the end of this project, you'll have:

  • A modular Python codebase with documented, type-hinted functions for data loading, modeling, and visualization that follows production software engineering practices.
  • An executive summary dashboard designed for non-technical stakeholders to grasp key metrics like ROAS and channel contribution within 5 seconds.
  • A complete GitHub repository with professional documentation demonstrating the full model lifecycle from measurement to forecasting to optimization.
  • Secret Mission: Build a What-If Scenario Planner that lets stakeholders see projected revenue under different budget allocation strategies.

Are there any prerequisites?

You should have basic Python proficiency and some familiarity with pandas and matplotlib. A basic understanding of statistics (regression and probability distributions) will help you follow the modeling steps.

Set Up Your Python Environment and Project Structure

Production data science projects are not single scripts. They are modular codebases with separated concerns: configuration in one place, data loading in another, modeling logic in its own module.

In this step, you will create a professional project structure that mirrors how a Marketing Analytic Solutions team organizes their code. You will also install the PyMC-Marketing framework and externalize all model parameters into a configuration file.

In this step, get ready to:
  • Create a PyCharm project with a Python 3.12 virtual environment and initialize a Git repository.
  • Install the Bayesian modeling packages and create a dependency manifest.
  • Build the configuration file and package structure for modular code.
Create the PyCharm project and directory structure

Every professional project starts with a clean environment. A virtual environment isolates your project's packages from the rest of your system so nothing conflicts.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type PyCharm and press Enter to open it.
  • On the Welcome screen, click New Project.
  • In the Location field, set the path to your Desktop and name the project marketing-mix-model. For example: ~/Desktop/marketing-mix-model on macOS or C:\Users\YourName\Desktop\marketing-mix-model on Windows.
  • Under Python Interpreter, select Create Virtualenv.
  • For the base interpreter, choose your Python 3.12 installation from the dropdown.
  • Click Create.

What just happened?

PyCharm created a new folder on your Desktop called marketing-mix-model and set up a virtual environment inside it. The virtual environment is a self-contained Python installation where you can install packages without affecting the rest of your system.

Now you need to initialize Git for version control and create the folder structure for your modular codebase.

  • Open PyCharm's built-in terminal by pressing Alt+F12, or click Terminal in the bottom toolbar.
  • Initialize a Git repository by running this command:
git init
  • Create the project directory structure by running this command:
mkdir -p src plots/diagnostics plots/executive docs
  • Confirm the directories exist by running:
ls

You should see src, plots, and docs listed in the output alongside any files PyCharm created automatically.

Don't see the folders?

Make sure the terminal is open in the correct directory. The terminal prompt should show marketing-mix-model in the path. If it does not, PyCharm may have opened the terminal in a parent folder.

On Windows, use mkdir src and mkdir plots\diagnostics separately if the -p flag is not recognized.

Help me create the directory structure for my marketing mix model project.

Install the required packages

Your model depends on several scientific Python packages. The central one is pymc-marketing, which provides the Bayesian Marketing Mix Modeling framework. You will pin it to a specific version so your results are reproducible.

  • Create a file called requirements.txt in the project root. In PyCharm, right-click the marketing-mix-model folder in the Project panel on the left, then select New and File.
  • Name the file requirements.txt and press Enter.
  • Paste the following dependency list into requirements.txt:
pymc-marketing==0.19.4
arviz
matplotlib
pandas
numpy
seaborn

What does this file do?

A requirements.txt file is a manifest of all packages your project needs. Pinning pymc-marketing==0.19.4 ensures anyone who clones your repo gets the exact same version you used. The other packages (arviz for diagnostics, matplotlib and seaborn for charts, pandas and numpy for data) are left unpinned to avoid version conflicts.

  • Save requirements.txt with Cmd+S (macOS) or Ctrl+S (Windows).
  • Install all packages by running this command in the terminal:
pip install -r requirements.txt

This installation takes 2-3 minutes. You will see packages downloading and installing in the terminal output.

Installation failing?

If you see permission errors, confirm your virtual environment is active. Look for (venv) or (.venv) at the beginning of your terminal prompt. PyCharm usually activates this automatically, but if it is missing, run source venv/bin/activate (macOS) or venv\Scripts\activate (Windows).

If a specific package fails to build, make sure you are running Python 3.12 by running python --version in the terminal.

Help me fix my pip install error for pymc-marketing.

  • Verify the installation succeeded by running:
python -c "import pymc_marketing; print(pymc_marketing.__version__)"

You should see 0.19.4 printed in the terminal.

Create the configuration and package files

In production code, model parameters live in a configuration file, not scattered throughout the codebase. This makes it easy to tune parameters without hunting through logic.

  • Create a new file called config.py in the project root. Right-click the marketing-mix-model folder in the Project panel, then select New and Python File.
  • Name it config and press Enter.
  • Paste the following configuration into config.py:
"""
Configuration file for Marketing Mix Model.

All model parameters are externalized here for easy tuning
and reproducibility. This follows production best practices
where model configuration is separated from model logic.
"""

# --- Data Configuration ---
DATA_URL = (
    "https://raw.githubusercontent.com/pymc-labs/pymc-marketing/"
    "main/data/mmm_example.csv"
)
DATE_COLUMN = "date_week"
TARGET_COLUMN = "y"
CHANNEL_COLUMNS = ["x1", "x2"]
CONTROL_COLUMNS = ["event_1", "event_2", "t"]

# Business-friendly names for reporting
CHANNEL_DISPLAY_NAMES = {
    "x1": "Paid Search",
    "x2": "Display Advertising",
}

# --- Model Configuration ---
ADSTOCK_MAX_LAG = 8  # weeks of carryover effect
YEARLY_SEASONALITY = 2  # number of Fourier terms
N_CHAINS = 4
TARGET_ACCEPT = 0.85
RANDOM_SEED_PHRASE = "mmm"

# --- Visualization Configuration ---
FIGURE_DPI = 150
EXECUTIVE_DASHBOARD_FIGSIZE = (16, 12)
PLOT_STYLE = "arviz-darkgrid"

# --- Output Paths ---
PLOTS_DIR = "plots"
DIAGNOSTICS_DIR = "plots/diagnostics"
EXECUTIVE_DIR = "plots/executive"
DOCS_DIR = "docs"

What does this configuration do?

This file centralizes every tunable parameter in your project. The data section points to the dataset and names its columns. The model section defines MCMC settings like the number of chains and the adstock lag window.

Separating config from logic means you can change the number of MCMC chains from 4 to 8 by editing one line here, instead of searching through model code. This is how production teams manage experiments.

  • Save config.py with Cmd+S (macOS) or Ctrl+S (Windows).

Now create the package initializer so Python recognizes src/ as an importable module.

  • Create a new file called __init__.py inside the src folder. Right-click src in the Project panel, then select New and Python File.
  • Name it __init__ and press Enter.
  • Paste this docstring into src/__init__.py:
"""
Marketing Mix Model package.

This package provides modular functions for building, fitting,
and analyzing Bayesian Marketing Mix Models for retail media
measurement and optimization.
"""

Why does src/ need an __init__.py?

Python uses __init__.py files to recognize a folder as a package. Without this file, you cannot import modules from src/ in your main analysis script. The docstring documents what the package does for anyone reading your code.

  • Save src/__init__.py.
  • Verify the package structure works by running this in the terminal:
python -c "from config import DATA_URL; print(DATA_URL[:50])"

You should see the beginning of the dataset URL printed, confirming Python can import your configuration.

Seeing a ModuleNotFoundError?

Make sure your terminal is in the marketing-mix-model root directory, not inside the src/ folder. Python looks for config.py relative to where you run the command.

Also check that you saved config.py in the project root (directly inside marketing-mix-model/), not inside the src/ folder.

Help me fix my config import error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both config.py and src/__init__.py.

ⓧ I'd like to double check the full code

Here is the complete config.py:

"""
Configuration file for Marketing Mix Model.

All model parameters are externalized here for easy tuning
and reproducibility. This follows production best practices
where model configuration is separated from model logic.
"""

# --- Data Configuration ---
DATA_URL = (
    "https://raw.githubusercontent.com/pymc-labs/pymc-marketing/"
    "main/data/mmm_example.csv"
)
DATE_COLUMN = "date_week"
TARGET_COLUMN = "y"
CHANNEL_COLUMNS = ["x1", "x2"]
CONTROL_COLUMNS = ["event_1", "event_2", "t"]

# Business-friendly names for reporting
CHANNEL_DISPLAY_NAMES = {
    "x1": "Paid Search",
    "x2": "Display Advertising",
}

# --- Model Configuration ---
ADSTOCK_MAX_LAG = 8  # weeks of carryover effect
YEARLY_SEASONALITY = 2  # number of Fourier terms
N_CHAINS = 4
TARGET_ACCEPT = 0.85
RANDOM_SEED_PHRASE = "mmm"

# --- Visualization Configuration ---
FIGURE_DPI = 150
EXECUTIVE_DASHBOARD_FIGSIZE = (16, 12)
PLOT_STYLE = "arviz-darkgrid"

# --- Output Paths ---
PLOTS_DIR = "plots"
DIAGNOSTICS_DIR = "plots/diagnostics"
EXECUTIVE_DIR = "plots/executive"
DOCS_DIR = "docs"

Here is the complete src/__init__.py:

"""
Marketing Mix Model package.

This package provides modular functions for building, fitting,
and analyzing Bayesian Marketing Mix Models for retail media
measurement and optimization.
"""

And the complete requirements.txt:

pymc-marketing==0.19.4
arviz
matplotlib
pandas
numpy
seaborn

Your environment is ready and your project has a clean, modular structure. Next up, you will load the marketing dataset and start exploring how advertising spend relates to revenue.

Load, Explore, and Document the Marketing Data

Your project structure is ready. Now you need actual marketing data to work with.

In this step, you will build a modular data loading module, generate your first business-context visualization, and write plain-language documentation that explains the data to non-technical stakeholders.

In this step, get ready to:
  • Create a documented data loader module with validation and summary functions.
  • Generate an exploratory visualization with business-context annotations.
  • Write a methodology document explaining the model in plain business language.
Create the data loader module

Production data science teams never load data with ad-hoc scripts scattered across notebooks. They centralize data loading into validated, documented modules so that every downstream step gets clean, predictable inputs.

Why a separate data_loader module?

If a column name changes or a new validation rule is needed, you fix it in one place. Every script that imports from data_loader gets the fix automatically.

This pattern demonstrates the Best Buy JD requirement: "technical development of the software and processes needed to bring solutions to life."

  • Create a new file called data_loader.py inside the src/ folder.
  • Add the module docstring, imports, and the two core loading functions by pasting this code:
"""
Data loading and validation module.

Handles loading marketing spend and sales data,
validating schema, and preparing features for modeling.
"""

from typing import Tuple

import pandas as pd

from config import (
    CHANNEL_COLUMNS,
    CONTROL_COLUMNS,
    DATA_URL,
    DATE_COLUMN,
    TARGET_COLUMN,
)


def load_marketing_data() -> pd.DataFrame:
    """Load the marketing dataset from the official PyMC-Marketing repository.

    Returns:
        DataFrame with columns: date_week, y, x1, x2, event_1, event_2, dayofyear, t
        where y = weekly sales, x1/x2 = channel spend, and the rest are controls.
    """
    data = pd.read_csv(DATA_URL, parse_dates=[DATE_COLUMN])
    _validate_data(data)
    return data


def _validate_data(data: pd.DataFrame) -> None:
    """Validate that the dataset has the expected schema."""
    required_cols = [DATE_COLUMN, TARGET_COLUMN] + CHANNEL_COLUMNS + CONTROL_COLUMNS
    missing = set(required_cols) - set(data.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    if data[TARGET_COLUMN].isnull().any():
        raise ValueError("Target column contains null values")

What does this code do?

  • load_marketing_data() fetches the official PyMC-Marketing sample dataset from GitHub and parses the date column into proper datetime objects.
  • _validate_data() is a private helper that checks all required columns exist and the target has no null values. If validation fails, it raises an error immediately instead of letting bad data silently corrupt the model.
  • The imports pull column names from config.py so that renaming a column only requires one change in one place.
  • Now add the two utility functions below the existing code in the same file:

def prepare_features(data: pd.DataFrame) -> Tuple[pd.DataFrame, pd.Series]:
    """Split data into features (X) and target (y) for model fitting.

    Args:
        data: Full marketing dataset.

    Returns:
        Tuple of (X features DataFrame, y target Series).
    """
    X = data.drop(TARGET_COLUMN, axis=1)
    y = data[TARGET_COLUMN]
    return X, y


def compute_spend_summary(data: pd.DataFrame) -> dict:
    """Compute summary statistics about channel spend for reporting.

    Returns:
        Dictionary with spend totals, shares, and basic stats.
    """
    total_spend = data[CHANNEL_COLUMNS].sum()
    spend_share = total_spend / total_spend.sum()
    return {
        "total_spend_by_channel": total_spend.to_dict(),
        "spend_share": spend_share.to_dict(),
        "total_weeks": len(data),
        "date_range": (
            data[DATE_COLUMN].min().strftime("%Y-%m-%d"),
            data[DATE_COLUMN].max().strftime("%Y-%m-%d"),
        ),
        "mean_weekly_sales": data[TARGET_COLUMN].mean(),
    }

What does this code do?

  • prepare_features() splits the data into an X (features) DataFrame and a y (target) Series. This is the standard format that the MMM model expects for fitting.
  • compute_spend_summary() calculates budget allocation percentages, date ranges, and average sales. You will use this to print a quick summary and later for reporting.
  • Save src/data_loader.py.
  • Verify the module works by running this command in your terminal:
python -c "from src.data_loader import load_marketing_data, compute_spend_summary; data = load_marketing_data(); print(f'{len(data)} rows loaded successfully')"

You should see 179 rows loaded successfully printed in the terminal.

Seeing a ModuleNotFoundError?

  • Make sure your terminal is in the project root directory (the folder that contains config.py and the src/ folder).
  • Check that your virtual environment is active. You should see (venv) at the start of your terminal prompt.
  • Confirm that src/__init__.py exists. Python needs this file to treat src/ as a package.

Help me debug my ModuleNotFoundError when importing from src.data_loader

✔️ Awesome, I've got everything!

Great. Double check you have saved src/data_loader.py and move on to the next substep.

ⓧ I'd like to double check the full code

"""
Data loading and validation module.

Handles loading marketing spend and sales data,
validating schema, and preparing features for modeling.
"""

from typing import Tuple

import pandas as pd

from config import (
    CHANNEL_COLUMNS,
    CONTROL_COLUMNS,
    DATA_URL,
    DATE_COLUMN,
    TARGET_COLUMN,
)


def load_marketing_data() -> pd.DataFrame:
    """Load the marketing dataset from the official PyMC-Marketing repository.

    Returns:
        DataFrame with columns: date_week, y, x1, x2, event_1, event_2, dayofyear, t
        where y = weekly sales, x1/x2 = channel spend, and the rest are controls.
    """
    data = pd.read_csv(DATA_URL, parse_dates=[DATE_COLUMN])
    _validate_data(data)
    return data


def _validate_data(data: pd.DataFrame) -> None:
    """Validate that the dataset has the expected schema."""
    required_cols = [DATE_COLUMN, TARGET_COLUMN] + CHANNEL_COLUMNS + CONTROL_COLUMNS
    missing = set(required_cols) - set(data.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    if data[TARGET_COLUMN].isnull().any():
        raise ValueError("Target column contains null values")


def prepare_features(data: pd.DataFrame) -> Tuple[pd.DataFrame, pd.Series]:
    """Split data into features (X) and target (y) for model fitting.

    Args:
        data: Full marketing dataset.

    Returns:
        Tuple of (X features DataFrame, y target Series).
    """
    X = data.drop(TARGET_COLUMN, axis=1)
    y = data[TARGET_COLUMN]
    return X, y


def compute_spend_summary(data: pd.DataFrame) -> dict:
    """Compute summary statistics about channel spend for reporting.

    Returns:
        Dictionary with spend totals, shares, and basic stats.
    """
    total_spend = data[CHANNEL_COLUMNS].sum()
    spend_share = total_spend / total_spend.sum()
    return {
        "total_spend_by_channel": total_spend.to_dict(),
        "spend_share": spend_share.to_dict(),
        "total_weeks": len(data),
        "date_range": (
            data[DATE_COLUMN].min().strftime("%Y-%m-%d"),
            data[DATE_COLUMN].max().strftime("%Y-%m-%d"),
        ),
        "mean_weekly_sales": data[TARGET_COLUMN].mean(),
    }
Generate exploratory visualizations with business context

Raw time-series plots tell you what happened. Business-context annotations tell you what to ask. Instead of labeling a line "x1 spend", you annotate it with the percentage of total budget it represents.

This forces the chart to pose a question: "Channel 1 gets 66% of the budget. Does it deliver 66% of results?"

  • Create a new file called explore.py in your project root (the same folder as config.py).
  • Add the data loading and summary section by pasting this code:
"""Exploration script - test data loading and generate initial visualization."""

import matplotlib.pyplot as plt

from config import CHANNEL_DISPLAY_NAMES, FIGURE_DPI, PLOTS_DIR
from src.data_loader import compute_spend_summary, load_marketing_data

# Load and summarize the data
data = load_marketing_data()
summary = compute_spend_summary(data)
print(f"  Loaded {summary['total_weeks']} weeks of data")
print(f"  Date range: {summary['date_range'][0]} to {summary['date_range'][1]}")
print(f"  Mean weekly sales: ${summary['mean_weekly_sales']:,.0f}")
print(f"  Spend share: Paid Search {summary['spend_share']['x1']:.0%}, "
      f"Display {summary['spend_share']['x2']:.0%}")

What does this code do?

This calls your two data_loader functions and prints a quick summary. You will see the total number of weeks, date range, average weekly sales, and the budget split between channels.

  • Now add the visualization code below the existing code in explore.py:
# Generate exploratory visualization with business context annotations
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

# Sales trend
axes[0].plot(data["date_week"], data["y"], color="black", linewidth=2)
axes[0].set(ylabel="Weekly Sales ($)", title="Weekly Revenue Over 3.4 Years")
axes[0].axhline(
    data["y"].mean(), color="gray", linestyle="--", alpha=0.7, label="Average"
)
axes[0].legend()

# Channel 1 spend with budget share annotation
axes[1].plot(data["date_week"], data["x1"], color="#1f77b4", linewidth=2)
axes[1].set(
    ylabel="Weekly Spend",
    title=f"{CHANNEL_DISPLAY_NAMES['x1']} - Receives {data['x1'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
)

# Channel 2 spend with budget share annotation
axes[2].plot(data["date_week"], data["x2"], color="#ff7f0e", linewidth=2)
axes[2].set(
    xlabel="Date",
    ylabel="Weekly Spend",
    title=f"{CHANNEL_DISPLAY_NAMES['x2']} - Receives {data['x2'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
)

fig.suptitle(
    "THE QUESTION: Does our budget split match actual revenue impact?",
    fontsize=14,
    fontweight="bold",
    y=1.02,
)
plt.tight_layout()
plt.savefig(
    f"{PLOTS_DIR}/01_exploratory_with_context.png",
    dpi=FIGURE_DPI,
    bbox_inches="tight",
)
plt.close()
print(f"\n  Saved: {PLOTS_DIR}/01_exploratory_with_context.png")

What makes this chart different from a raw EDA plot?

  • Each subplot title includes the percentage of budget that channel receives. This forces the reader to ask: "Does this budget share match revenue contribution?"
  • The overall title poses a direct business question instead of a generic label like "Time Series Data".
  • The average line on the sales chart gives a baseline reference so readers can spot weeks above or below normal.
  • Save explore.py.
  • Run the exploration script by running this command:
python explore.py

You should see the data summary printed in your terminal and a new file at plots/01_exploratory_with_context.png.

  • Open plots/01_exploratory_with_context.png to confirm you see three subplots: weekly sales on top, Paid Search spend in the middle, and Display Advertising spend on the bottom. Each channel subplot should show the budget percentage in its title.

Script runs but no PNG file appears?

  • Check that the plots/ directory exists in your project root. If it is missing, create it manually and rerun.
  • If you see a matplotlib backend warning, add import matplotlib; matplotlib.use('Agg') at the very top of the script (before any pyplot imports).

My explore.py runs without errors but I cannot find the output PNG file

Write the methodology documentation

Marketing data science teams serve two audiences. Technical peers need model specifications and diagnostics. Business partners need plain-language explanations they can reference without a statistics background.

Why write documentation before the model is built?

Writing the methodology first forces you to articulate what the model will do in terms a VP of Marketing can understand. If you cannot explain it simply before coding, the model will be harder to communicate after.

  • Create a new file called methodology.md inside the docs/ folder.
  • Add the executive summary and plain-language model explanation by pasting this content:
# Methodology: Bayesian Marketing Mix Model

## Executive Summary (Non-Technical)

This model answers three questions for the marketing team:
1. **How much does each advertising channel contribute to sales?**
2. **What is the return on each dollar spent per channel?**
3. **How should we reallocate budget to maximize revenue?**

## How the Model Works (Plain Language)

### Adstock (Carryover Effect)
When you see an ad on Monday, you do not buy immediately. The ad "sticks"
in your memory and influences your purchase over the following days or weeks.
Our model captures this decay mathematically using a "geometric adstock"
function with a maximum memory window of 8 weeks.

### Saturation (Diminishing Returns)
The first $1,000 you spend on an ad campaign reaches people who have never
seen your brand. The next $1,000 reaches some of the same people again.
Eventually, you are paying more to reach fewer new customers. Our model
captures this curve using a "logistic saturation" function.

### Bayesian Approach
Instead of giving a single "best guess" for each number, we provide a
range (called a "credible interval") that tells you how confident we are.
For example: "We are 94% confident Channel X returns between $2.10 and $3.40
per dollar spent." This helps the team make decisions with full awareness
of uncertainty.

What does each section explain?

  • The Executive Summary states the three business questions this model answers. A CMO can read these in 10 seconds.
  • Adstock (Carryover Effect) explains why ad impact does not stop the day the ad runs. The 8-week window comes from the ADSTOCK_MAX_LAG setting in your config.py.
  • Saturation (Diminishing Returns) explains why doubling your ad budget does not double your sales.
  • The Bayesian Approach section explains credible intervals. This is what differentiates this model from a simple regression that only gives point estimates.
  • Save docs/methodology.md.
  • Confirm the file exists by running:
ls docs/

You should see methodology.md listed in the output.

Extra for Experts: Why plain language first?

You will add a Technical Specification section to this document in a later step after the model is fitted. Writing the business explanation first ensures you can always translate model outputs into terms your cross-functional partners understand.

Your data pipeline is in place and your first visualization already poses the key business question. Next up, you will build and fit the Bayesian MMM to actually answer it.

Build, Fit, and Validate the Bayesian MMM

Your data is loaded, explored, and documented. Now it is time to build the core of your marketing science solution: the Bayesian Marketing Mix Model itself.

The model needs to capture two real-world advertising phenomena. First, ads have a carryover effect where seeing an ad this week still influences purchases next week. Second, ads have diminishing returns where the 1,000th dollar spent is less impactful than the 1st. You will encode both of these into a single model that quantifies channel effectiveness with full uncertainty.

In this step, get ready to:
  • Configure the MMM with adstock and saturation transformations in a new module.
  • Fit the model using MCMC sampling with 4 chains.
  • Validate model quality by checking for zero divergences.
Configure the MMM model with adstock and saturation

Your model module will live in src/model.py. It needs to import the PyMC-Marketing components for adstock, saturation, and the MMM class itself, then configure everything using the parameters you already externalized in config.py.

  • Create a new file called src/model.py inside your src/ folder.
  • Add the module docstring, imports, and the create_rng() helper function by pasting this code:
"""Bayesian Marketing Mix Model module.

Builds and fits the MMM using PyMC-Marketing with
GeometricAdstock (carryover) and LogisticSaturation
(diminishing returns) transformations.
"""

import numpy as np
import pandas as pd
from pymc_extras.prior import Prior
from pymc_marketing.mmm import GeometricAdstock, LogisticSaturation
from pymc_marketing.mmm.multidimensional import MMM

from config import (
    ADSTOCK_MAX_LAG,
    CHANNEL_COLUMNS,
    CONTROL_COLUMNS,
    DATE_COLUMN,
    N_CHAINS,
    RANDOM_SEED_PHRASE,
    TARGET_ACCEPT,
    YEARLY_SEASONALITY,
)


def create_rng():
    """Create a reproducible random number generator."""
    seed = sum(map(ord, RANDOM_SEED_PHRASE))
    return np.random.default_rng(seed=seed)

What does this code do?

The imports bring in three key PyMC-Marketing components. GeometricAdstock models how advertising effects decay over time (carryover). LogisticSaturation models diminishing returns as spend increases. MMM is the main model class that ties everything together.

The create_rng() function creates a reproducible random number generator from the seed phrase in your config. This ensures every run produces identical results for debugging and reporting.

You will not see any output yet. The next chunk builds the model configuration function that uses these imports.

  • Below create_rng(), add the build_mmm() function that configures the model with spend-share-informed priors:

def build_mmm(data: pd.DataFrame) -> MMM:
    """Build and configure the Bayesian Marketing Mix Model.

    The model uses:
    - GeometricAdstock: captures how ad effects carry over and decay
      across weeks (e.g., a TV ad seen this week still drives sales next week)
    - LogisticSaturation: captures diminishing returns as spend increases
      (the 1st dollar is more impactful than the 1000th dollar)

    Priors are informed by spend share: channels that receive more budget
    get wider priors on their contribution, reflecting our uncertainty.

    Args:
        data: Full marketing dataset with channels and controls.

    Returns:
        Configured (but not yet fitted) MMM instance.
    """
    # Inform priors using spend share
    total_spend = data[CHANNEL_COLUMNS].sum(axis=0)
    spend_share = total_spend / total_spend.sum()
    n_channels = len(CHANNEL_COLUMNS)
    prior_sigma = n_channels * spend_share.to_numpy()

    model_config = {
        "intercept": Prior("Normal", mu=0.5, sigma=0.2),
        "saturation_beta": Prior("HalfNormal", sigma=prior_sigma, dims="channel"),
        "gamma_control": Prior("Normal", mu=0, sigma=0.05, dims="control"),
        "gamma_fourier": Prior("Laplace", mu=0, b=0.2, dims="fourier_mode"),
        "likelihood": Prior(
            "Normal", sigma=Prior("HalfNormal", sigma=6), dims="date"
        ),
    }

    sampler_config = {"progressbar": True}

    mmm = MMM(
        model_config=model_config,
        sampler_config=sampler_config,
        date_column=DATE_COLUMN,
        adstock=GeometricAdstock(l_max=ADSTOCK_MAX_LAG),
        saturation=LogisticSaturation(),
        channel_columns=CHANNEL_COLUMNS,
        control_columns=CONTROL_COLUMNS,
        yearly_seasonality=YEARLY_SEASONALITY,
    )

    return mmm

What does this code do?

The model_config dictionary defines Bayesian priors for each model component. Instead of giving flat, uninformed priors, you use spend share to set the saturation_beta prior width. Channels that receive more budget get wider priors on their contribution. This reflects genuine uncertainty: we expect higher-spend channels to potentially have larger effects, but we are not sure.

The MMM constructor takes the adstock transformation (GeometricAdstock with 8-week maximum lag), the saturation transformation (LogisticSaturation), and all column mappings from your config. This returns a configured but unfitted model.

Why spend-share-informed priors?

In production marketing science, you never start with completely flat priors. You know roughly how much each channel spends. A channel receiving 66% of total budget plausibly drives more revenue than one receiving 34%. Encoding this as a wider prior (not a fixed value) lets the data override your assumption if the evidence is strong enough. This is the Bayesian advantage over point estimates.

You still will not see any output. The next substep adds the fitting function that trains the model on your data.

Write the model fitting function

With the model configured, you need a function that executes the full Bayesian workflow: build the computation graph, run a prior predictive check, fit with MCMC sampling, and generate posterior predictions.

  • Below build_mmm(), add the fit_mmm() function:

def fit_mmm(mmm: MMM, X: pd.DataFrame, y: pd.Series) -> dict:
    """Fit the MMM model and return diagnostic information.

    Performs the full Bayesian workflow:
    1. Build the model graph
    2. Register original-scale contribution variables
    3. Run prior predictive check
    4. Fit with MCMC (4 chains)
    5. Check for divergences

    Args:
        mmm: Configured MMM instance.
        X: Feature DataFrame.
        y: Target Series.

    Returns:
        Dictionary with diagnostic info (divergences, etc.)
    """
    rng = create_rng()

    # Build model and register contribution variables
    mmm.build_model(X, y)
    mmm.add_original_scale_contribution_variable(
        [
            "y",
            "intercept_contribution",
            "control_contribution",
            "channel_contribution",
            "fourier_contribution",
            "yearly_seasonality_contribution",
        ]
    )

    # Prior predictive check
    mmm.sample_prior_predictive(X, y, samples=1_000, random_seed=rng)

    # Fit the model
    mmm.fit(
        X=X,
        y=y,
        chains=N_CHAINS,
        target_accept=TARGET_ACCEPT,
        random_seed=rng,
    )

    # Posterior predictive
    mmm.sample_posterior_predictive(X, extend_idata=True, combined=True)

    # Diagnostics
    n_divergences = mmm.idata["sample_stats"]["diverging"].sum().item()

    return {
        "n_divergences": n_divergences,
        "sampling_successful": n_divergences == 0,
    }

What does this code do?

This function runs five stages of the Bayesian modeling workflow. First, build_model() compiles the probabilistic graph. Then add_original_scale_contribution_variable() registers variables so you can later extract channel contributions in dollar terms (not transformed scale).

The prior predictive check (1,000 samples) verifies that your priors generate plausible sales values before seeing any data. Then mmm.fit() runs NUTS MCMC sampling with 4 independent chains. Finally, sample_posterior_predictive() generates model predictions you can compare against observed data.

The function returns a diagnostic dictionary. Zero divergences means the sampler explored the posterior without getting stuck. This is your primary quality check.

  • Save src/model.py.

MCMC sampling takes time

When you run this model, MCMC sampling with 4 chains on 179 data points typically takes 2-5 minutes on a modern CPU. This is expected. The sampler is exploring the full posterior distribution, not just finding a single best-fit line.

Run and validate the model

Your model module is complete. Now you will run it to verify that sampling succeeds with zero divergences. This confirms the model geometry is well-behaved and your priors are appropriate.

  • Open PyCharm's Python Console by clicking Python Console in the bottom toolbar (or go to Tools, then Python Console).
  • Run the following code in the console to load data, build the model, and fit it:
from src.data_loader import load_marketing_data, prepare_features
from src.model import build_mmm, fit_mmm

data = load_marketing_data()
X, y = prepare_features(data)
mmm = build_mmm(data)
diagnostics = fit_mmm(mmm, X, y)
print(diagnostics)

What should I see?

You will see progress bars as each of the 4 chains samples. After 2-5 minutes, the output should print a dictionary showing zero divergences:

{'n_divergences': 0, 'sampling_successful': True}

Zero divergences means the NUTS sampler explored the posterior distribution without encountering problematic geometry. This is the gold standard for MCMC quality.

Seeing divergences or errors?

If you see a ModuleNotFoundError, make sure your Python Console is using the virtual environment from your project (check the interpreter in the bottom-right of PyCharm).

If sampling produces divergences (the number is greater than 0), try increasing TARGET_ACCEPT to 0.90 in config.py and re-running. Higher acceptance targets make the sampler take smaller steps, avoiding tricky posterior regions.

If memory errors occur, close other applications to free RAM. MCMC with 4 chains requires holding multiple copies of the model in memory simultaneously.

help me debug my MMM model fitting

✔️ Awesome, I've got everything!

Great. Double check you have saved src/model.py and confirmed zero divergences in the console output.

ⓧ I'd like to double check the full code

Here is the complete src/model.py file:

"""Bayesian Marketing Mix Model module.

Builds and fits the MMM using PyMC-Marketing with
GeometricAdstock (carryover) and LogisticSaturation
(diminishing returns) transformations.
"""

import numpy as np
import pandas as pd
from pymc_extras.prior import Prior
from pymc_marketing.mmm import GeometricAdstock, LogisticSaturation
from pymc_marketing.mmm.multidimensional import MMM

from config import (
    ADSTOCK_MAX_LAG,
    CHANNEL_COLUMNS,
    CONTROL_COLUMNS,
    DATE_COLUMN,
    N_CHAINS,
    RANDOM_SEED_PHRASE,
    TARGET_ACCEPT,
    YEARLY_SEASONALITY,
)


def create_rng():
    """Create a reproducible random number generator."""
    seed = sum(map(ord, RANDOM_SEED_PHRASE))
    return np.random.default_rng(seed=seed)


def build_mmm(data: pd.DataFrame) -> MMM:
    """Build and configure the Bayesian Marketing Mix Model.

    The model uses:
    - GeometricAdstock: captures how ad effects carry over and decay
      across weeks (e.g., a TV ad seen this week still drives sales next week)
    - LogisticSaturation: captures diminishing returns as spend increases
      (the 1st dollar is more impactful than the 1000th dollar)

    Priors are informed by spend share: channels that receive more budget
    get wider priors on their contribution, reflecting our uncertainty.

    Args:
        data: Full marketing dataset with channels and controls.

    Returns:
        Configured (but not yet fitted) MMM instance.
    """
    # Inform priors using spend share
    total_spend = data[CHANNEL_COLUMNS].sum(axis=0)
    spend_share = total_spend / total_spend.sum()
    n_channels = len(CHANNEL_COLUMNS)
    prior_sigma = n_channels * spend_share.to_numpy()

    model_config = {
        "intercept": Prior("Normal", mu=0.5, sigma=0.2),
        "saturation_beta": Prior("HalfNormal", sigma=prior_sigma, dims="channel"),
        "gamma_control": Prior("Normal", mu=0, sigma=0.05, dims="control"),
        "gamma_fourier": Prior("Laplace", mu=0, b=0.2, dims="fourier_mode"),
        "likelihood": Prior(
            "Normal", sigma=Prior("HalfNormal", sigma=6), dims="date"
        ),
    }

    sampler_config = {"progressbar": True}

    mmm = MMM(
        model_config=model_config,
        sampler_config=sampler_config,
        date_column=DATE_COLUMN,
        adstock=GeometricAdstock(l_max=ADSTOCK_MAX_LAG),
        saturation=LogisticSaturation(),
        channel_columns=CHANNEL_COLUMNS,
        control_columns=CONTROL_COLUMNS,
        yearly_seasonality=YEARLY_SEASONALITY,
    )

    return mmm


def fit_mmm(mmm: MMM, X: pd.DataFrame, y: pd.Series) -> dict:
    """Fit the MMM model and return diagnostic information.

    Performs the full Bayesian workflow:
    1. Build the model graph
    2. Register original-scale contribution variables
    3. Run prior predictive check
    4. Fit with MCMC (4 chains)
    5. Check for divergences

    Args:
        mmm: Configured MMM instance.
        X: Feature DataFrame.
        y: Target Series.

    Returns:
        Dictionary with diagnostic info (divergences, etc.)
    """
    rng = create_rng()

    # Build model and register contribution variables
    mmm.build_model(X, y)
    mmm.add_original_scale_contribution_variable(
        [
            "y",
            "intercept_contribution",
            "control_contribution",
            "channel_contribution",
            "fourier_contribution",
            "yearly_seasonality_contribution",
        ]
    )

    # Prior predictive check
    mmm.sample_prior_predictive(X, y, samples=1_000, random_seed=rng)

    # Fit the model
    mmm.fit(
        X=X,
        y=y,
        chains=N_CHAINS,
        target_accept=TARGET_ACCEPT,
        random_seed=rng,
    )

    # Posterior predictive
    mmm.sample_posterior_predictive(X, extend_idata=True, combined=True)

    # Diagnostics
    n_divergences = mmm.idata["sample_stats"]["diverging"].sum().item()

    return {
        "n_divergences": n_divergences,
        "sampling_successful": n_divergences == 0,
    }

Your Bayesian MMM is fitted and validated. Next up, you will extract the channel contributions and ROAS from this model to build an executive-ready insights dashboard that follows the 5-second rule.

Generate Executive Insights with the 5-Second Rule

Your model is fitted and validated. Now comes the most critical part of a marketing data scientist's job: translating statistical results into decisions that non-technical stakeholders can act on.

Marketing leaders do not read trace plots or posterior distributions. They glance at a dashboard for five seconds and ask: "Should I shift budget?" Your visualizations need to answer that question instantly.

In this step, get ready to:
  • Create a visualization module with ROAS calculations and annotated charts.
  • Build a single-page executive dashboard following the 5-second rule.
  • Run budget sensitivity analysis showing where each channel hits diminishing returns.
Create the visualization module with ROAS extraction

The 5-second rule means your chart title states the insight, not just the metric. A title like "Budget Sensitivity" forces the reader to interpret. A title like "Where Does the Next Dollar Have Most Impact?" delivers the answer.

  • Create a new file called src/visualizations.py in your src/ folder.
  • Add the module docstring, imports, and the first three functions (setup_plotting, plot_exploratory_with_context, and plot_diagnostics) by copying the following code:
"""
Visualization module with executive-ready charts.

All charts follow the 5-second rule: the key insight must be
readable within 5 seconds by a non-technical stakeholder.
This means large KPI numbers, clear titles stating the insight
(not just the metric), and color-coded performance indicators.
"""

import os
from typing import Optional

import arviz as az
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
import seaborn as sns
from pymc_marketing.mmm.multidimensional import MMM

from config import (
    CHANNEL_DISPLAY_NAMES,
    DIAGNOSTICS_DIR,
    EXECUTIVE_DIR,
    EXECUTIVE_DASHBOARD_FIGSIZE,
    FIGURE_DPI,
    PLOT_STYLE,
    PLOTS_DIR,
)


def setup_plotting():
    """Configure matplotlib for professional output."""
    az.style.use(PLOT_STYLE)
    plt.rcParams["figure.figsize"] = [12, 7]
    plt.rcParams["figure.dpi"] = FIGURE_DPI
    for directory in [PLOTS_DIR, DIAGNOSTICS_DIR, EXECUTIVE_DIR]:
        os.makedirs(directory, exist_ok=True)


def plot_exploratory_with_context(data: pd.DataFrame) -> None:
    """Generate EDA plots with business context annotations.

    Instead of raw time series, each subplot includes an annotation
    that poses the business question the data helps answer.
    """
    fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

    # Sales
    axes[0].plot(data["date_week"], data["y"], color="black", linewidth=2)
    axes[0].set(ylabel="Weekly Sales ($)", title="Weekly Revenue Over 3.4 Years")
    axes[0].axhline(
        data["y"].mean(), color="gray", linestyle="--", alpha=0.7, label="Average"
    )
    axes[0].legend()

    # Channel 1
    axes[1].plot(data["date_week"], data["x1"], color="#1f77b4", linewidth=2)
    axes[1].set(
        ylabel="Weekly Spend",
        title=f"{CHANNEL_DISPLAY_NAMES['x1']} - Receives {data['x1'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
    )

    # Channel 2
    axes[2].plot(data["date_week"], data["x2"], color="#ff7f0e", linewidth=2)
    axes[2].set(
        xlabel="Date",
        ylabel="Weekly Spend",
        title=f"{CHANNEL_DISPLAY_NAMES['x2']} - Receives {data['x2'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
    )

    fig.suptitle(
        "THE QUESTION: Does our budget split match actual revenue impact?",
        fontsize=14,
        fontweight="bold",
        y=1.02,
    )
    plt.tight_layout()
    plt.savefig(
        f"{PLOTS_DIR}/01_exploratory_with_context.png",
        dpi=FIGURE_DPI,
        bbox_inches="tight",
    )
    plt.close()


def plot_diagnostics(mmm: MMM) -> None:
    """Save model diagnostic plots to diagnostics directory."""
    # Prior predictive
    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(mmm.prior.date, mmm.y, color="black", label="Observed")
    ax.plot(
        mmm.prior.date,
        mmm.prior.y_original_scale.mean(("chain", "draw")),
        color="C0",
        label="Prior Mean",
    )
    for hdi in (0.94, 0.5):
        az.plot_hdi(
            mmm.prior.date,
            mmm.prior.y_original_scale,
            hdi_prob=hdi,
            smooth=False,
            ax=ax,
            color="C0",
            fill_kwargs=dict(
                alpha=0.3 if hdi == 0.94 else 0.5, label=f"{hdi:.0%} HDI"
            ),
        )
    ax.legend()
    ax.set(title="Prior Predictive Check", xlabel="Date", ylabel="Sales")
    plt.savefig(f"{DIAGNOSTICS_DIR}/prior_predictive.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    # Trace plots
    az.plot_trace(
        data=mmm.fit_result,
        var_names=["saturation_beta", "saturation_lam", "adstock_alpha"],
        compact=True,
        backend_kwargs={"figsize": (10, 6), "layout": "constrained"},
    )
    plt.suptitle("MCMC Trace Plots - Convergence Diagnostics", fontsize=14)
    plt.savefig(f"{DIAGNOSTICS_DIR}/trace_plots.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    # Posterior predictive
    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(mmm.prior.date, mmm.y, color="black", label="Observed")
    ax.plot(
        mmm.prior.date,
        mmm.posterior_predictive.y_original_scale.mean(("chain", "draw")),
        color="C0",
        label="Model Prediction",
    )
    for hdi in (0.94, 0.5):
        az.plot_hdi(
            mmm.prior.date,
            mmm.posterior_predictive.y_original_scale,
            hdi_prob=hdi,
            smooth=False,
            ax=ax,
            color="C0",
            fill_kwargs=dict(
                alpha=0.3 if hdi == 0.94 else 0.5, label=f"{hdi:.0%} HDI"
            ),
        )
    ax.legend()
    ax.set(title="Posterior Predictive Check - Model Fit Quality", xlabel="Date", ylabel="Sales")
    plt.savefig(
        f"{DIAGNOSTICS_DIR}/posterior_predictive.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()

What does this code do?

This module consolidates all visualization logic into one place. The setup_plotting() function configures matplotlib styling and creates output directories.

The plot_exploratory_with_context() function generates your EDA charts from Step 2. The plot_diagnostics() function generates prior predictive, trace, and posterior predictive plots from Step 3. Having them in the module means your main pipeline script can call them cleanly.

Why the 5-second rule?

Marketing data scientists do not just build models. They communicate results to VPs and CMOs who have 30 meetings a day. If a chart takes longer than 5 seconds to understand, it gets ignored.

The module docstring encodes this principle. Every chart title in this file states the insight ("Where Does the Next Dollar Have Most Impact?") instead of just naming the metric ("Sensitivity Analysis").

  • Save src/visualizations.py.
Build the single-page executive dashboard

The executive dashboard combines four panels into one image that a CMO can glance at and immediately know which channel wins. Large font ROAS numbers, color-coded winners, and a plain-language recommendation box do the heavy lifting.

  • In src/visualizations.py, add the plot_executive_dashboard function below the plot_diagnostics function:
def plot_executive_dashboard(mmm: MMM, data: pd.DataFrame) -> None:
    """Generate the single-page executive summary dashboard.

    Designed for the 5-SECOND RULE: A CMO glancing at this chart
    should understand the key finding within 5 seconds.

    Layout:
    - Top left: ROAS comparison (big numbers)
    - Top right: Channel contribution share (pie/bar)
    - Bottom left: Saturation curves with "you are here" marker
    - Bottom right: One-sentence recommendation box
    """
    fig = plt.figure(figsize=EXECUTIVE_DASHBOARD_FIGSIZE)
    fig.suptitle(
        "MARKETING MIX MODEL - EXECUTIVE SUMMARY",
        fontsize=18,
        fontweight="bold",
        y=0.98,
    )

    # --- Top Left: ROAS Scorecard ---
    ax1 = fig.add_subplot(2, 2, 1)
    ax1.set_xlim(0, 10)
    ax1.set_ylim(0, 10)
    ax1.axis("off")
    ax1.set_title("Return on Ad Spend (ROAS)", fontsize=14, fontweight="bold", pad=20)

    # Extract channel contribution means from posterior
    post = mmm.idata.posterior
    ch_contrib = post["channel_contribution_original_scale"].sum(dim="date")

    ch1_contrib_mean = float(ch_contrib.sel(channel="x1").mean(("chain", "draw")))
    ch2_contrib_mean = float(ch_contrib.sel(channel="x2").mean(("chain", "draw")))
    ch1_spend = data["x1"].sum()
    ch2_spend = data["x2"].sum()

    roas_ch1 = ch1_contrib_mean / ch1_spend if ch1_spend > 0 else 0
    roas_ch2 = ch2_contrib_mean / ch2_spend if ch2_spend > 0 else 0

    # Big ROAS numbers
    ax1.text(
        2.5, 7, CHANNEL_DISPLAY_NAMES["x1"], fontsize=13, ha="center", fontweight="bold"
    )
    ax1.text(
        2.5,
        4.5,
        f"${roas_ch1:.2f}",
        fontsize=36,
        ha="center",
        fontweight="bold",
        color="#1f77b4",
    )
    ax1.text(2.5, 3, "per $1 spent", fontsize=11, ha="center", color="gray")

    ax1.text(
        7.5, 7, CHANNEL_DISPLAY_NAMES["x2"], fontsize=13, ha="center", fontweight="bold"
    )
    ax1.text(
        7.5,
        4.5,
        f"${roas_ch2:.2f}",
        fontsize=36,
        ha="center",
        fontweight="bold",
        color="#ff7f0e",
    )
    ax1.text(7.5, 3, "per $1 spent", fontsize=11, ha="center", color="gray")

    # Winner indicator
    winner = CHANNEL_DISPLAY_NAMES["x1"] if roas_ch1 > roas_ch2 else CHANNEL_DISPLAY_NAMES["x2"]
    ax1.text(
        5,
        1,
        f"Winner: {winner}",
        fontsize=12,
        ha="center",
        fontweight="bold",
        color="green",
        bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgreen", alpha=0.5),
    )

    # --- Top Right: Contribution Waterfall ---
    ax2 = fig.add_subplot(2, 2, 2)
    mmm.plot.waterfall_components_decomposition(original_scale=True, ax=ax2)
    ax2.set_title(
        "What Drives Sales? (Contribution Breakdown)", fontsize=14, fontweight="bold"
    )

    # --- Bottom Left: Saturation Curves ---
    ax3 = fig.add_subplot(2, 2, 3)
    fig_sat = mmm.plot.saturation_scatterplot(original_scale=True)
    # Close the auto-generated figure and recreate in our subplot
    plt.close(fig_sat)

    # Simplified saturation message
    ax3.text(
        0.5,
        0.5,
        "See plots/executive/saturation_detail.png\nfor full saturation curves",
        transform=ax3.transAxes,
        ha="center",
        va="center",
        fontsize=12,
    )
    ax3.set_title(
        "Diminishing Returns Analysis", fontsize=14, fontweight="bold"
    )
    ax3.axis("off")

    # --- Bottom Right: Recommendation Box ---
    ax4 = fig.add_subplot(2, 2, 4)
    ax4.axis("off")
    ax4.set_title("RECOMMENDATION", fontsize=14, fontweight="bold", pad=20)

    recommendation = (
        f"Based on ROAS analysis:\n\n"
        f"  {CHANNEL_DISPLAY_NAMES['x1']}: ${roas_ch1:.2f} return per $1\n"
        f"  {CHANNEL_DISPLAY_NAMES['x2']}: ${roas_ch2:.2f} return per $1\n\n"
        f"ACTION: Consider reallocating budget toward\n"
        f"{winner} which shows stronger efficiency.\n\n"
        f"Both channels show diminishing returns at\n"
        f"current spend levels. Budget optimization\n"
        f"analysis suggests room for reallocation."
    )
    ax4.text(
        0.1,
        0.8,
        recommendation,
        transform=ax4.transAxes,
        fontsize=12,
        verticalalignment="top",
        fontfamily="monospace",
        bbox=dict(boxstyle="round", facecolor="lightyellow", alpha=0.8),
    )

    plt.tight_layout(rect=[0, 0, 1, 0.96])
    plt.savefig(
        f"{EXECUTIVE_DIR}/executive_dashboard.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()

    # Also save standalone saturation curves
    fig_sat = mmm.plot.saturation_scatterplot(original_scale=True)
    plt.suptitle(
        "Where Are We on the Diminishing Returns Curve?",
        fontsize=14,
        fontweight="bold",
        y=1.02,
    )
    plt.savefig(
        f"{EXECUTIVE_DIR}/saturation_detail.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()

    # Channel share
    mmm.plot.channel_contribution_share_hdi(figsize=(10, 4))
    plt.suptitle(
        "Channel Contribution Share (with 94% Credible Interval)",
        fontsize=13,
        fontweight="bold",
        y=1.05,
    )
    plt.savefig(
        f"{EXECUTIVE_DIR}/channel_share.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()

What does this code do?

  • The ROAS scorecard (top-left panel) extracts channel_contribution_original_scale from the posterior, sums across dates, then divides by total spend. The result is displayed at font size 36 so it is readable from across a conference room.
  • The waterfall decomposition (top-right) uses PyMC-Marketing's built-in waterfall_components_decomposition to show how intercept, channels, controls, and seasonality combine to produce total sales.
  • The recommendation box (bottom-right) renders a plain-language action statement in monospace font. This is what the VP of Marketing reads first.
  • After saving the main dashboard, the function also saves standalone saturation curves and a channel contribution share chart with 94% credible intervals for supplementary detail.

Why credible intervals instead of point estimates?

A point estimate says "ROAS is $2.50." A credible interval says "We are 94% confident ROAS is between $2.10 and $3.40." The interval communicates how much the team should trust the number. A wide interval on a new channel means more data or a lift test is needed before committing budget.

  • Save src/visualizations.py.

Seeing an import error for matplotlib.patches?

The matplotlib.patches import is part of the standard matplotlib package. If you see an error, confirm your virtual environment is active and that you installed the dependencies from requirements.txt in Step 1.

help me debug an import error in my visualizations module

Add budget sensitivity analysis

Sensitivity analysis answers the question every marketing leader asks: "What happens if I spend more on this channel?" The curve flattens where diminishing returns kick in. Vertical markers show current spend so stakeholders can see exactly how much headroom remains.

  • In src/visualizations.py, add the plot_sensitivity function below plot_executive_dashboard:
def plot_sensitivity(mmm: MMM) -> None:
    """Run and plot sensitivity analysis showing budget optimization opportunity."""
    mmm.sensitivity.run_sweep(
        sweep_values=np.linspace(0, 1.5, 12),
        var_input="channel_data",
        var_names="channel_contribution_original_scale",
        extend_idata=True,
    )

    ax = mmm.plot.sensitivity_analysis(
        hue_dim="channel",
        x_sweep_axis="absolute",
        xlabel="Total Channel Spend",
        ylabel="Revenue Contribution ($)",
        subplot_title_fallback="How Does Revenue Change as We Adjust Spend?",
        plot_kwargs={"marker": "o"},
    )
    ax.axvline(
        mmm.X["x1"].sum(),
        color="#1f77b4",
        linestyle="--",
        linewidth=2,
        label=f"{CHANNEL_DISPLAY_NAMES['x1']} (current)",
    )
    ax.axvline(
        mmm.X["x2"].sum(),
        color="#ff7f0e",
        linestyle="--",
        linewidth=2,
        label=f"{CHANNEL_DISPLAY_NAMES['x2']} (current)",
    )
    ax.legend()
    plt.title(
        "Budget Sensitivity: Where Does the Next Dollar Have Most Impact?",
        fontsize=13,
        fontweight="bold",
    )
    plt.savefig(
        f"{EXECUTIVE_DIR}/budget_sensitivity.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()

What does this code do?

  • The run_sweep method scales channel spend from 0x to 1.5x of actual values in 12 steps, generating predicted revenue contribution at each level.
  • The sensitivity_analysis plot shows how revenue responds to spend changes for each channel. Where the curve flattens, you have hit diminishing returns.
  • The axvline calls add dashed vertical markers at current spend levels for each channel. This creates the "you are here" reference point so stakeholders can see how far current spend is from the saturation plateau.
  • Save src/visualizations.py.
  • You will not see visual output yet because you have not run the pipeline script. The next step wires everything together.

Unsure about the sensitivity sweep values?

The np.linspace(0, 1.5, 12) creates 12 evenly spaced multipliers between 0 (no spend) and 1.5 (50% above current spend). If you see a flat curve for both channels, your model may not have enough posterior variation. Re-check that MCMC sampling completed with zero divergences.

help me understand why my sensitivity curve is flat

✔️ Awesome, I've got everything!

Double check that you have saved src/visualizations.py with all five functions: setup_plotting, plot_exploratory_with_context, plot_diagnostics, plot_executive_dashboard, and plot_sensitivity.

ⓧ I'd like to double check the full code

"""
Visualization module with executive-ready charts.

All charts follow the 5-second rule: the key insight must be
readable within 5 seconds by a non-technical stakeholder.
This means large KPI numbers, clear titles stating the insight
(not just the metric), and color-coded performance indicators.
"""

import os
from typing import Optional

import arviz as az
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
import seaborn as sns
from pymc_marketing.mmm.multidimensional import MMM

from config import (
    CHANNEL_DISPLAY_NAMES,
    DIAGNOSTICS_DIR,
    EXECUTIVE_DIR,
    EXECUTIVE_DASHBOARD_FIGSIZE,
    FIGURE_DPI,
    PLOT_STYLE,
    PLOTS_DIR,
)


def setup_plotting():
    """Configure matplotlib for professional output."""
    az.style.use(PLOT_STYLE)
    plt.rcParams["figure.figsize"] = [12, 7]
    plt.rcParams["figure.dpi"] = FIGURE_DPI
    for directory in [PLOTS_DIR, DIAGNOSTICS_DIR, EXECUTIVE_DIR]:
        os.makedirs(directory, exist_ok=True)


def plot_exploratory_with_context(data: pd.DataFrame) -> None:
    """Generate EDA plots with business context annotations."""
    fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

    axes[0].plot(data["date_week"], data["y"], color="black", linewidth=2)
    axes[0].set(ylabel="Weekly Sales ($)", title="Weekly Revenue Over 3.4 Years")
    axes[0].axhline(
        data["y"].mean(), color="gray", linestyle="--", alpha=0.7, label="Average"
    )
    axes[0].legend()

    axes[1].plot(data["date_week"], data["x1"], color="#1f77b4", linewidth=2)
    axes[1].set(
        ylabel="Weekly Spend",
        title=f"{CHANNEL_DISPLAY_NAMES['x1']} - Receives {data['x1'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
    )

    axes[2].plot(data["date_week"], data["x2"], color="#ff7f0e", linewidth=2)
    axes[2].set(
        xlabel="Date",
        ylabel="Weekly Spend",
        title=f"{CHANNEL_DISPLAY_NAMES['x2']} - Receives {data['x2'].sum() / (data['x1'].sum() + data['x2'].sum()):.0%} of Total Budget",
    )

    fig.suptitle(
        "THE QUESTION: Does our budget split match actual revenue impact?",
        fontsize=14,
        fontweight="bold",
        y=1.02,
    )
    plt.tight_layout()
    plt.savefig(
        f"{PLOTS_DIR}/01_exploratory_with_context.png",
        dpi=FIGURE_DPI,
        bbox_inches="tight",
    )
    plt.close()


def plot_diagnostics(mmm: MMM) -> None:
    """Save model diagnostic plots to diagnostics directory."""
    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(mmm.prior.date, mmm.y, color="black", label="Observed")
    ax.plot(
        mmm.prior.date,
        mmm.prior.y_original_scale.mean(("chain", "draw")),
        color="C0",
        label="Prior Mean",
    )
    for hdi in (0.94, 0.5):
        az.plot_hdi(
            mmm.prior.date,
            mmm.prior.y_original_scale,
            hdi_prob=hdi,
            smooth=False,
            ax=ax,
            color="C0",
            fill_kwargs=dict(
                alpha=0.3 if hdi == 0.94 else 0.5, label=f"{hdi:.0%} HDI"
            ),
        )
    ax.legend()
    ax.set(title="Prior Predictive Check", xlabel="Date", ylabel="Sales")
    plt.savefig(f"{DIAGNOSTICS_DIR}/prior_predictive.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    az.plot_trace(
        data=mmm.fit_result,
        var_names=["saturation_beta", "saturation_lam", "adstock_alpha"],
        compact=True,
        backend_kwargs={"figsize": (10, 6), "layout": "constrained"},
    )
    plt.suptitle("MCMC Trace Plots - Convergence Diagnostics", fontsize=14)
    plt.savefig(f"{DIAGNOSTICS_DIR}/trace_plots.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(mmm.prior.date, mmm.y, color="black", label="Observed")
    ax.plot(
        mmm.prior.date,
        mmm.posterior_predictive.y_original_scale.mean(("chain", "draw")),
        color="C0",
        label="Model Prediction",
    )
    for hdi in (0.94, 0.5):
        az.plot_hdi(
            mmm.prior.date,
            mmm.posterior_predictive.y_original_scale,
            hdi_prob=hdi,
            smooth=False,
            ax=ax,
            color="C0",
            fill_kwargs=dict(
                alpha=0.3 if hdi == 0.94 else 0.5, label=f"{hdi:.0%} HDI"
            ),
        )
    ax.legend()
    ax.set(title="Posterior Predictive Check - Model Fit Quality", xlabel="Date", ylabel="Sales")
    plt.savefig(
        f"{DIAGNOSTICS_DIR}/posterior_predictive.png", dpi=FIGURE_DPI, bbox_inches="tight"
    )
    plt.close()


def plot_executive_dashboard(mmm: MMM, data: pd.DataFrame) -> None:
    """Generate the single-page executive summary dashboard."""
    fig = plt.figure(figsize=EXECUTIVE_DASHBOARD_FIGSIZE)
    fig.suptitle(
        "MARKETING MIX MODEL - EXECUTIVE SUMMARY",
        fontsize=18,
        fontweight="bold",
        y=0.98,
    )

    ax1 = fig.add_subplot(2, 2, 1)
    ax1.set_xlim(0, 10)
    ax1.set_ylim(0, 10)
    ax1.axis("off")
    ax1.set_title("Return on Ad Spend (ROAS)", fontsize=14, fontweight="bold", pad=20)

    post = mmm.idata.posterior
    ch_contrib = post["channel_contribution_original_scale"].sum(dim="date")

    ch1_contrib_mean = float(ch_contrib.sel(channel="x1").mean(("chain", "draw")))
    ch2_contrib_mean = float(ch_contrib.sel(channel="x2").mean(("chain", "draw")))
    ch1_spend = data["x1"].sum()
    ch2_spend = data["x2"].sum()

    roas_ch1 = ch1_contrib_mean / ch1_spend if ch1_spend > 0 else 0
    roas_ch2 = ch2_contrib_mean / ch2_spend if ch2_spend > 0 else 0

    ax1.text(2.5, 7, CHANNEL_DISPLAY_NAMES["x1"], fontsize=13, ha="center", fontweight="bold")
    ax1.text(2.5, 4.5, f"${roas_ch1:.2f}", fontsize=36, ha="center", fontweight="bold", color="#1f77b4")
    ax1.text(2.5, 3, "per $1 spent", fontsize=11, ha="center", color="gray")

    ax1.text(7.5, 7, CHANNEL_DISPLAY_NAMES["x2"], fontsize=13, ha="center", fontweight="bold")
    ax1.text(7.5, 4.5, f"${roas_ch2:.2f}", fontsize=36, ha="center", fontweight="bold", color="#ff7f0e")
    ax1.text(7.5, 3, "per $1 spent", fontsize=11, ha="center", color="gray")

    winner = CHANNEL_DISPLAY_NAMES["x1"] if roas_ch1 > roas_ch2 else CHANNEL_DISPLAY_NAMES["x2"]
    ax1.text(5, 1, f"Winner: {winner}", fontsize=12, ha="center", fontweight="bold", color="green",
             bbox=dict(boxstyle="round,pad=0.3", facecolor="lightgreen", alpha=0.5))

    ax2 = fig.add_subplot(2, 2, 2)
    mmm.plot.waterfall_components_decomposition(original_scale=True, ax=ax2)
    ax2.set_title("What Drives Sales? (Contribution Breakdown)", fontsize=14, fontweight="bold")

    ax3 = fig.add_subplot(2, 2, 3)
    fig_sat = mmm.plot.saturation_scatterplot(original_scale=True)
    plt.close(fig_sat)
    ax3.text(0.5, 0.5, "See plots/executive/saturation_detail.png\nfor full saturation curves",
             transform=ax3.transAxes, ha="center", va="center", fontsize=12)
    ax3.set_title("Diminishing Returns Analysis", fontsize=14, fontweight="bold")
    ax3.axis("off")

    ax4 = fig.add_subplot(2, 2, 4)
    ax4.axis("off")
    ax4.set_title("RECOMMENDATION", fontsize=14, fontweight="bold", pad=20)
    recommendation = (
        f"Based on ROAS analysis:\n\n"
        f"  {CHANNEL_DISPLAY_NAMES['x1']}: ${roas_ch1:.2f} return per $1\n"
        f"  {CHANNEL_DISPLAY_NAMES['x2']}: ${roas_ch2:.2f} return per $1\n\n"
        f"ACTION: Consider reallocating budget toward\n"
        f"{winner} which shows stronger efficiency.\n\n"
        f"Both channels show diminishing returns at\n"
        f"current spend levels. Budget optimization\n"
        f"analysis suggests room for reallocation."
    )
    ax4.text(0.1, 0.8, recommendation, transform=ax4.transAxes, fontsize=12,
             verticalalignment="top", fontfamily="monospace",
             bbox=dict(boxstyle="round", facecolor="lightyellow", alpha=0.8))

    plt.tight_layout(rect=[0, 0, 1, 0.96])
    plt.savefig(f"{EXECUTIVE_DIR}/executive_dashboard.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    fig_sat = mmm.plot.saturation_scatterplot(original_scale=True)
    plt.suptitle("Where Are We on the Diminishing Returns Curve?", fontsize=14, fontweight="bold", y=1.02)
    plt.savefig(f"{EXECUTIVE_DIR}/saturation_detail.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

    mmm.plot.channel_contribution_share_hdi(figsize=(10, 4))
    plt.suptitle("Channel Contribution Share (with 94% Credible Interval)", fontsize=13, fontweight="bold", y=1.05)
    plt.savefig(f"{EXECUTIVE_DIR}/channel_share.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()


def plot_sensitivity(mmm: MMM) -> None:
    """Run and plot sensitivity analysis showing budget optimization opportunity."""
    mmm.sensitivity.run_sweep(
        sweep_values=np.linspace(0, 1.5, 12),
        var_input="channel_data",
        var_names="channel_contribution_original_scale",
        extend_idata=True,
    )

    ax = mmm.plot.sensitivity_analysis(
        hue_dim="channel",
        x_sweep_axis="absolute",
        xlabel="Total Channel Spend",
        ylabel="Revenue Contribution ($)",
        subplot_title_fallback="How Does Revenue Change as We Adjust Spend?",
        plot_kwargs={"marker": "o"},
    )
    ax.axvline(mmm.X["x1"].sum(), color="#1f77b4", linestyle="--", linewidth=2,
               label=f"{CHANNEL_DISPLAY_NAMES['x1']} (current)")
    ax.axvline(mmm.X["x2"].sum(), color="#ff7f0e", linestyle="--", linewidth=2,
               label=f"{CHANNEL_DISPLAY_NAMES['x2']} (current)")
    ax.legend()
    plt.title("Budget Sensitivity: Where Does the Next Dollar Have Most Impact?", fontsize=13, fontweight="bold")
    plt.savefig(f"{EXECUTIVE_DIR}/budget_sensitivity.png", dpi=FIGURE_DPI, bbox_inches="tight")
    plt.close()

Your visualization module is complete. In the next step, you will wire everything together in the main pipeline script, run the full analysis, and push the results to GitHub.

Assemble the Main Pipeline and Push to GitHub

You have modular functions for data loading, modeling, and visualization spread across separate files. Now you need an orchestration script that calls them in the right order and produces the full analysis in a single run.

In this step, you will wire everything together into mmm_analysis.py, write professional documentation for both technical and business audiences, and push the complete project to GitHub so it is publicly accessible.

In this step, get ready to:
  • Create the main pipeline script that runs the entire MMM workflow end-to-end.
  • Write business-facing and technical documentation for your project.
  • Commit all files and push to a public GitHub repository.
Create the main pipeline script

A professional data science project needs a single entry point that runs the full analysis. This is what a reviewer (or hiring manager) runs to reproduce your results.

  • Create a new file called mmm_analysis.py in your project root (the same level as config.py).
  • Add the module docstring and imports by pasting this code:
"""
Marketing Mix Model - Main Analysis Pipeline.

This script orchestrates the full MMM workflow:
1. Load and explore retail marketing data
2. Build and fit a Bayesian Marketing Mix Model
3. Generate executive-ready insights and recommendations
4. Run budget optimization analysis

Usage:
    python mmm_analysis.py
"""

import os
import sys

from config import DIAGNOSTICS_DIR, EXECUTIVE_DIR, PLOTS_DIR
from src.data_loader import (
    compute_spend_summary,
    load_marketing_data,
    prepare_features,
)
from src.model import build_mmm, fit_mmm
from src.visualizations import (
    plot_diagnostics,
    plot_executive_dashboard,
    plot_exploratory_with_context,
    plot_sensitivity,
    setup_plotting,
)

What does this code do?

The docstring describes the pipeline's purpose and usage. The imports pull in every function you built across the previous steps.

  • config provides the output directory paths.
  • src.data_loader handles loading and summarizing the marketing data.
  • src.model builds and fits the Bayesian MMM.
  • src.visualizations generates all diagnostic and executive-ready charts.
  • Below the imports, add the main() function and entry point by pasting this code:

def main():
    """Run the complete Marketing Mix Model pipeline."""
    print("=" * 60)
    print("  MARKETING MIX MODEL - RETAIL MEDIA MEASUREMENT")
    print("  Bayesian Channel Attribution & Budget Optimization")
    print("=" * 60)

    setup_plotting()
    for directory in [PLOTS_DIR, DIAGNOSTICS_DIR, EXECUTIVE_DIR]:
        os.makedirs(directory, exist_ok=True)

    print("\n[1/5] Loading marketing data...")
    data = load_marketing_data()
    summary = compute_spend_summary(data)
    print(f"  Loaded {summary['total_weeks']} weeks of data")
    print(f"  Date range: {summary['date_range'][0]} to {summary['date_range'][1]}")
    print(f"  Mean weekly sales: ${summary['mean_weekly_sales']:,.0f}")
    print(f"  Spend share: Paid Search {summary['spend_share']['x1']:.0%}, "
          f"Display {summary['spend_share']['x2']:.0%}")

    print("\n[2/5] Generating exploratory visualizations...")
    plot_exploratory_with_context(data)
    print("  Saved: plots/01_exploratory_with_context.png")

    print("\n[3/5] Building and fitting Bayesian MMM...")
    print("  (MCMC sampling takes 2-5 minutes on CPU)")
    X, y = prepare_features(data)
    mmm = build_mmm(data)
    diagnostics = fit_mmm(mmm, X, y)
    print(f"  Divergences: {diagnostics['n_divergences']}")
    print(f"  Sampling successful: {diagnostics['sampling_successful']}")

    print("\n[4/5] Generating diagnostic and attribution plots...")
    plot_diagnostics(mmm)
    print("  Saved diagnostic plots to plots/diagnostics/")

    print("\n[5/5] Building executive dashboard and optimization analysis...")
    plot_executive_dashboard(mmm, data)
    plot_sensitivity(mmm)
    print("  Saved executive dashboard to plots/executive/")

    print("\n" + "=" * 60)
    print("  ANALYSIS COMPLETE")
    print("=" * 60)
    print(f"\n  Output files:")
    print(f"    plots/01_exploratory_with_context.png")
    print(f"    plots/diagnostics/  (prior, trace, posterior)")
    print(f"    plots/executive/executive_dashboard.png")
    print(f"    plots/executive/saturation_detail.png")
    print(f"    plots/executive/channel_share.png")
    print(f"    plots/executive/budget_sensitivity.png")
    print(f"\n  Next steps:")
    print(f"    1. Review executive dashboard for key findings")
    print(f"    2. Check diagnostics to confirm model quality")
    print(f"    3. Commit and push to GitHub")
    print(f"    4. (Secret Mission) Run scenario planner")


if __name__ == "__main__":
    main()

What does this code do?

The main() function is the orchestrator. It runs all five pipeline stages in order: load data, generate EDA charts, fit the model, produce diagnostics, and build the executive dashboard.

Each stage prints progress updates so you can watch the pipeline work. The if __name__ == "__main__" block ensures the pipeline only runs when you execute the script directly (not when importing from it).

  • Save mmm_analysis.py.
  • Open the terminal in PyCharm (click Terminal at the bottom of the window) and run the pipeline:
python mmm_analysis.py

You should see numbered progress messages printing as each stage completes. The MCMC sampling stage (step 3/5) takes 2-5 minutes on a modern CPU. When it finishes, you will see "ANALYSIS COMPLETE" and a list of output files.

Seeing an import error?

  • Make sure your virtual environment is active (you should see (venv) in your terminal prompt).
  • Confirm mmm_analysis.py is at the project root, the same folder that contains config.py and the src/ directory.
  • Check that you ran pip install -r requirements.txt earlier in this project.

Help me debug my import error in mmm_analysis.py

✔️ Awesome, I've got everything!

Great. Double check you have saved mmm_analysis.py and that the pipeline ran to completion.

ⓧ I'd like to double check the full code

"""
Marketing Mix Model - Main Analysis Pipeline.

This script orchestrates the full MMM workflow:
1. Load and explore retail marketing data
2. Build and fit a Bayesian Marketing Mix Model
3. Generate executive-ready insights and recommendations
4. Run budget optimization analysis

Usage:
    python mmm_analysis.py
"""

import os
import sys

from config import DIAGNOSTICS_DIR, EXECUTIVE_DIR, PLOTS_DIR
from src.data_loader import (
    compute_spend_summary,
    load_marketing_data,
    prepare_features,
)
from src.model import build_mmm, fit_mmm
from src.visualizations import (
    plot_diagnostics,
    plot_executive_dashboard,
    plot_exploratory_with_context,
    plot_sensitivity,
    setup_plotting,
)


def main():
    """Run the complete Marketing Mix Model pipeline."""
    print("=" * 60)
    print("  MARKETING MIX MODEL - RETAIL MEDIA MEASUREMENT")
    print("  Bayesian Channel Attribution & Budget Optimization")
    print("=" * 60)

    setup_plotting()
    for directory in [PLOTS_DIR, DIAGNOSTICS_DIR, EXECUTIVE_DIR]:
        os.makedirs(directory, exist_ok=True)

    print("\n[1/5] Loading marketing data...")
    data = load_marketing_data()
    summary = compute_spend_summary(data)
    print(f"  Loaded {summary['total_weeks']} weeks of data")
    print(f"  Date range: {summary['date_range'][0]} to {summary['date_range'][1]}")
    print(f"  Mean weekly sales: ${summary['mean_weekly_sales']:,.0f}")
    print(f"  Spend share: Paid Search {summary['spend_share']['x1']:.0%}, "
          f"Display {summary['spend_share']['x2']:.0%}")

    print("\n[2/5] Generating exploratory visualizations...")
    plot_exploratory_with_context(data)
    print("  Saved: plots/01_exploratory_with_context.png")

    print("\n[3/5] Building and fitting Bayesian MMM...")
    print("  (MCMC sampling takes 2-5 minutes on CPU)")
    X, y = prepare_features(data)
    mmm = build_mmm(data)
    diagnostics = fit_mmm(mmm, X, y)
    print(f"  Divergences: {diagnostics['n_divergences']}")
    print(f"  Sampling successful: {diagnostics['sampling_successful']}")

    print("\n[4/5] Generating diagnostic and attribution plots...")
    plot_diagnostics(mmm)
    print("  Saved diagnostic plots to plots/diagnostics/")

    print("\n[5/5] Building executive dashboard and optimization analysis...")
    plot_executive_dashboard(mmm, data)
    plot_sensitivity(mmm)
    print("  Saved executive dashboard to plots/executive/")

    print("\n" + "=" * 60)
    print("  ANALYSIS COMPLETE")
    print("=" * 60)
    print(f"\n  Output files:")
    print(f"    plots/01_exploratory_with_context.png")
    print(f"    plots/diagnostics/  (prior, trace, posterior)")
    print(f"    plots/executive/executive_dashboard.png")
    print(f"    plots/executive/saturation_detail.png")
    print(f"    plots/executive/channel_share.png")
    print(f"    plots/executive/budget_sensitivity.png")
    print(f"\n  Next steps:")
    print(f"    1. Review executive dashboard for key findings")
    print(f"    2. Check diagnostics to confirm model quality")
    print(f"    3. Commit and push to GitHub")
    print(f"    4. (Secret Mission) Run scenario planner")


if __name__ == "__main__":
    main()
Write the project documentation

Good documentation serves two audiences. A hiring manager or peer reviewer needs the technical specification. A marketing VP needs the business summary. You will write for both.

  • Open docs/methodology.md in PyCharm.
  • Scroll to the end of the file (after the "Bayesian Approach" section).
  • Add the following Technical Specification section at the bottom:

## Technical Specification

### Model Structure
- **Framework**: PyMC-Marketing v0.19.4 (Bayesian MMM)
- **Adstock**: GeometricAdstock with l_max=8 (maximum 8-week carryover)
- **Saturation**: LogisticSaturation (captures diminishing returns)
- **Priors**: Spend-share informed (channels with higher spend get wider priors)
- **Inference**: NUTS MCMC with 4 chains, target_accept=0.85
- **Seasonality**: 2 Fourier terms for yearly patterns

### Data
- **Granularity**: Weekly
- **Time span**: 179 weeks (~3.4 years)
- **Target**: Weekly sales (continuous)
- **Channels**: 2 advertising channels (x1 = Paid Search, x2 = Display)
- **Controls**: 2 event indicators, linear trend

### Assumptions and Limitations
1. **Linearity in contributions**: Channel effects combine additively after transformation
2. **Stationarity of effects**: Adstock/saturation parameters are constant over time
3. **No interaction effects**: Channels do not amplify or cannibalize each other
4. **Synthetic data**: Results are illustrative; real deployment requires actual spend data

### Recommended Next Steps for Production
1. **Lift test calibration**: Integrate experimental results to validate model-estimated ROAS
2. **Time-varying parameters**: Use Gaussian process priors for evolving media efficiency
3. **More channels**: Expand to 5+ channels covering the full media mix
4. **Geo-level data**: Move from national to geo-level for stronger causal identification
5. **Automated refresh**: Schedule monthly model re-fitting with new data

Why document assumptions and limitations?

Every statistical model makes simplifying assumptions. Stating them explicitly shows you understand where the model is strong and where it could break. A reviewer seeing "No interaction effects" knows you have thought about channel synergies and deliberately scoped them out.

The "Recommended Next Steps" section signals that you think beyond the current deliverable. This maps to the real-world lifecycle where a v1 model ships first, then improves with lift test data and time-varying parameters.

  • Save docs/methodology.md.
  • Now create a new file called README.md in the project root.
  • Paste the following content into README.md:
# Marketing Mix Model: Retail Media Measurement & Optimization

## Executive Summary

> **For Marketing Leaders (10-second read):**
>
> - We measured how each advertising channel contributes to revenue
> - Paid Search delivers higher return per dollar than Display Advertising
> - Both channels show diminishing returns at current spend levels
> - **Recommendation**: Reallocate 15-20% of Display budget to Paid Search for improved total ROAS

---

## What This Project Does

This is a **Bayesian Marketing Mix Model** that answers:

| Business Question | How We Answer It |
|---|---|
| Which channels drive sales? | Channel attribution analysis |
| What is the ROI per channel? | ROAS with confidence intervals |
| Where do returns diminish? | Saturation curve analysis |
| How should we reallocate? | Budget optimization simulation |

## How to Run

```bash
git clone https://github.com/YOUR_USERNAME/marketing-mix-model.git
cd marketing-mix-model
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python mmm_analysis.py
```

## Skills Demonstrated

| JD Requirement | How This Project Demonstrates It |
|---|---|
| Statistical and ML models for marketing | Bayesian MMM with MCMC inference |
| Marketing mix models, attribution | Full channel attribution with ROAS |
| Forecasting and optimization algorithms | Budget optimization via sensitivity analysis |
| Full lifecycle: ideation to production | Modular code, config separation, documentation |

## Methodology

See `docs/methodology.md` for full technical and business documentation.

Why does the README start with an Executive Summary?

A hiring manager reviewing your GitHub repository will spend 10-15 seconds on the README before deciding whether to look deeper. The Executive Summary blockquote at the top gives them the key business finding immediately, before any technical detail.

The "Skills Demonstrated" table directly maps your project to job description requirements. This makes it effortless for a reviewer to connect your work to the role's needs.

  • Save README.md.

Not sure where to save the file?

Both README.md and mmm_analysis.py must live at the same level as config.py and requirements.txt. In PyCharm's Project panel on the left, right-click the project root folder and select New then File.

Help me figure out where my files should go in the project structure

✔️ Awesome, I've got everything!

Great. Confirm you have saved both docs/methodology.md and README.md.

ⓧ I'd like to double check the full code

Here is the complete docs/methodology.md:

# Methodology: Bayesian Marketing Mix Model

## Executive Summary (Non-Technical)

This model answers three questions for the marketing team:
1. **How much does each advertising channel contribute to sales?**
2. **What is the return on each dollar spent per channel?**
3. **How should we reallocate budget to maximize revenue?**

## How the Model Works (Plain Language)

### Adstock (Carryover Effect)
When you see an ad on Monday, you do not buy immediately. The ad "sticks"
in your memory and influences your purchase over the following days or weeks.
Our model captures this decay mathematically using a "geometric adstock"
function with a maximum memory window of 8 weeks.

### Saturation (Diminishing Returns)
The first $1,000 you spend on an ad campaign reaches people who have never
seen your brand. The next $1,000 reaches some of the same people again.
Eventually, you are paying more to reach fewer new customers. Our model
captures this curve using a "logistic saturation" function.

### Bayesian Approach
Instead of giving a single "best guess" for each number, we provide a
range (called a "credible interval") that tells you how confident we are.
For example: "We are 94% confident Channel X returns between $2.10 and $3.40
per dollar spent." This helps the team make decisions with full awareness
of uncertainty.

## Technical Specification

### Model Structure
- **Framework**: PyMC-Marketing v0.19.4 (Bayesian MMM)
- **Adstock**: GeometricAdstock with l_max=8 (maximum 8-week carryover)
- **Saturation**: LogisticSaturation (captures diminishing returns)
- **Priors**: Spend-share informed (channels with higher spend get wider priors)
- **Inference**: NUTS MCMC with 4 chains, target_accept=0.85
- **Seasonality**: 2 Fourier terms for yearly patterns

### Data
- **Granularity**: Weekly
- **Time span**: 179 weeks (~3.4 years)
- **Target**: Weekly sales (continuous)
- **Channels**: 2 advertising channels (x1 = Paid Search, x2 = Display)
- **Controls**: 2 event indicators, linear trend

### Assumptions and Limitations
1. **Linearity in contributions**: Channel effects combine additively after transformation
2. **Stationarity of effects**: Adstock/saturation parameters are constant over time
3. **No interaction effects**: Channels do not amplify or cannibalize each other
4. **Synthetic data**: Results are illustrative; real deployment requires actual spend data

### Recommended Next Steps for Production
1. **Lift test calibration**: Integrate experimental results to validate model-estimated ROAS
2. **Time-varying parameters**: Use Gaussian process priors for evolving media efficiency
3. **More channels**: Expand to 5+ channels covering the full media mix
4. **Geo-level data**: Move from national to geo-level for stronger causal identification
5. **Automated refresh**: Schedule monthly model re-fitting with new data

And here is the complete README.md:

# Marketing Mix Model: Retail Media Measurement & Optimization

## Executive Summary

> **For Marketing Leaders (10-second read):**
>
> - We measured how each advertising channel contributes to revenue
> - Paid Search delivers higher return per dollar than Display Advertising
> - Both channels show diminishing returns at current spend levels
> - **Recommendation**: Reallocate 15-20% of Display budget to Paid Search for improved total ROAS

---

## What This Project Does

This is a **Bayesian Marketing Mix Model** that answers:

| Business Question | How We Answer It |
|---|---|
| Which channels drive sales? | Channel attribution analysis |
| What is the ROI per channel? | ROAS with confidence intervals |
| Where do returns diminish? | Saturation curve analysis |
| How should we reallocate? | Budget optimization simulation |

## How to Run

```bash
git clone https://github.com/YOUR_USERNAME/marketing-mix-model.git
cd marketing-mix-model
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python mmm_analysis.py
```

## Skills Demonstrated

| JD Requirement | How This Project Demonstrates It |
|---|---|
| Statistical and ML models for marketing | Bayesian MMM with MCMC inference |
| Marketing mix models, attribution | Full channel attribution with ROAS |
| Forecasting and optimization algorithms | Budget optimization via sensitivity analysis |
| Full lifecycle: ideation to production | Modular code, config separation, documentation |

## Methodology

See `docs/methodology.md` for full technical and business documentation.
Commit and push to GitHub

Your project is complete locally. Now you need to commit everything to Git and push it to a public GitHub repository so others can view and run your work.

  • In the PyCharm terminal, stage all project files and commit by running these commands:
git add .
git commit -m "Complete MMM pipeline with executive dashboard and documentation"

What does this command do?

git add . stages every file in the project directory. git commit -m saves a snapshot of those files with a descriptive message. Meaningful commit messages show professionalism in a portfolio repository.

  • Go to GitHub in your browser.
  • Click the + icon in the top-right corner, then select New repository.
  • Enter marketing-mix-model as the repository name.
  • Leave it set to Public.
  • Do NOT check any of the initialization boxes (no README, no .gitignore, no license). You already have these files locally.
  • Click Create repository.
  • Back in your PyCharm terminal, connect your local repo to GitHub and push by running these commands:
git remote add origin https://github.com/[[GITHUB_USERNAME="YOUR_USERNAME"]]/marketing-mix-model.git
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repository to the GitHub URL you just created.
  • git branch -M main renames your default branch to main (GitHub's default branch name).
  • git push -u origin main uploads all your committed files to GitHub and sets up tracking for future pushes.
  • Refresh your GitHub repository page in the browser. You should see all your project files listed with the README.md rendered as the landing page, showing the Executive Summary blockquote at the top.

Push rejected or authentication failed?

  • If you see an authentication error, GitHub requires a Personal Access Token (not your password) for HTTPS pushes. Generate one under Settings > Developer settings > Personal access tokens on GitHub.
  • If the push is rejected because the remote contains work you do not have locally, you may have checked one of the initialization boxes when creating the repo. Delete the repo on GitHub, recreate it empty, and try again.

Help me push my project to GitHub

Your Marketing Mix Model is live on GitHub. You have a complete, documented pipeline that loads data, fits a Bayesian model, generates executive-ready insights, and presents clear budget recommendations. Next up is the Secret Mission, where you will build a What-If Scenario Planner that lets stakeholders compare projected revenue under different budget allocation strategies.

Secret mission

What-If Scenario Planner

Marketing leaders want to know what happens if they shift budget between channels. Build a scenario comparison tool that projects revenue under three different budget allocations and quantifies the expected gain or loss in dollar terms with uncertainty bands.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free open-source tools and a free GitHub repository. There are no ongoing cloud costs or paid subscriptions to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local marketing-mix-model project folder on your Desktop (includes virtual environment, source code, and generated plots).
  • GitHub repository with your committed project code and documentation.

Keep everything running

No action needed. Choose this if you are still actively building or want to revisit the model later.

  • Your local project folder stays on your Desktop.
  • Your GitHub repository remains public (or private, depending on how you created it).
  • Your virtual environment stays intact with all installed packages.

Pause - I'll come back to this later

Free up memory by deactivating your virtual environment. Your files stay on disk for when you return.

  • If your virtual environment is still active in your terminal, deactivate it by running:
  • Close PyCharm to free up system memory.
  • Your project folder and GitHub repository remain untouched and ready for next time.
deactivate

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the local project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/marketing-mix-model
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\marketing-mix-model
  • Delete your GitHub repository by navigating to your repository page on GitHub.
  • Click Settings at the top of the repository page.
  • Scroll down to the Danger Zone section at the bottom.
  • Click Delete this repository and follow the confirmation prompts to permanently remove it.

Nice Work!

Nice Work!

You just built a production-quality Marketing Science solution from scratch. You now have a complete Bayesian Marketing Mix Model pipeline that measures advertising effectiveness, optimizes budget allocation, and communicates findings to non-technical stakeholders.

You've learned how to:

  • Build a Bayesian MMM with modular, production-quality code architecture that measures channel ROAS with credible intervals.
  • Create an executive dashboard designed for the 5-second rule so non-technical marketing leaders can immediately grasp channel performance and budget recommendations.
  • Push a fully documented GitHub repository demonstrating the full marketing data science lifecycle: measurement, forecasting, optimization, and cross-functional storytelling.
  • Built a What-If Scenario Planner enabling marketing leaders to see projected revenue under different budget allocation strategies.

Ready to quiz yourself?