Marketing Mix Modeling & ROAS

Build a Ridge regression MMM and Power BI dashboard to optimize ad spend.

Introduction

30 Second Summary

Every marketing team pours budget into Google, Meta, and TikTok, then asks the same question: "Which channel is actually driving our sales?" Privacy changes have made the old tracking-based answers unreliable.

In this project, you will build a Marketing Mix Model from a real eCommerce dataset to isolate each ad channel's true revenue contribution. You will then create an interactive Power BI dashboard where you can drag budget sliders and see the predicted revenue impact in real time.

What You'll Build

You will open your Power BI dashboard, drag the budget slider from TikTok toward Google Shopping, and watch the predicted revenue card update in real time.

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

  • A Python model that tells you exactly how many revenue dollars each ad channel (Google, Meta, TikTok) contributes, with adstock transforms capturing delayed effects like "saw the ad Tuesday, bought on Saturday."
  • A channel ranking table with per-channel ROAS, CPA, and MER scores that you can export and share as a CSV.
  • An interactive Power BI What-If dashboard where dragging a budget slider updates predicted revenue in real time.
  • Secret Mission: Build a Python budget optimizer that mathematically recommends the optimal spend allocation to maximize revenue.

Are there any prerequisites?

This project assumes you're comfortable with intermediate Python, including working with pandas DataFrames and aggregations. You also need Power BI Desktop installed on a Windows machine.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Understanding the goal upfront helps every technical decision make sense as you go.

Set Up Your Environment and Download the Dataset

Before you can model anything, you need the right tools and the right data. This step installs four Python libraries for data wrangling, modeling, and visualization, then downloads a real eCommerce marketing dataset to analyze.

By the end of this step, you will have a working Python environment and a loaded dataset with 132K+ rows of daily ad spend across Google, Meta, and TikTok channels.

In this step, get ready to:
  • Install the Python data science libraries.
  • Download the real eCommerce marketing dataset from Figshare.
  • Load the dataset and confirm it is analysis-ready.
Install the Python data science libraries

Your Marketing Mix Model needs four core Python libraries. pandas handles data wrangling, NumPy handles numerical operations, scikit-learn provides the regression model, and matplotlib draws the charts.

  • Install all four libraries by running this command in your terminal:
pip install pandas numpy scikit-learn matplotlib

What do these libraries do?

  • pandas loads, filters, and aggregates tabular data like CSVs.
  • numpy provides fast array math for transforms like adstock.
  • scikit-learn gives you the Ridge regression model and data scaling tools.
  • matplotlib creates plots and charts for exploring revenue patterns.
  • Verify the installation by running this command:
python -c "import pandas; import sklearn; import matplotlib; import numpy; print('All libraries ready')"

You should see All libraries ready printed in your terminal.

Seeing an error during install?

  • If you see a permissions error, try running pip install --user pandas numpy scikit-learn matplotlib instead.
  • If pip is not found, try pip3 instead. Your system may use pip3 to distinguish Python 3 packages.
  • If the import check fails for sklearn, make sure you installed scikit-learn (the pip package name), not sklearn.

Still stuck? Help me troubleshoot my pip install for pandas, numpy, scikit-learn, and matplotlib.

Download the dataset from Figshare

The dataset you will use is the Conjura Multi-Region MMM Dataset. It contains real, anonymized daily marketing spend and purchase data for approximately 100 eCommerce brands across Google, Meta, and TikTok channels, spanning 2019 to 2023.

  • Open the Figshare dataset page in your browser: Conjura Multi-Region MMM Dataset.
  • Click the Download button on the page to download the CSV file.
  • Move the downloaded file into your project folder.
  • Rename the file to conjura_mmm_data.csv if it has a different name.
  • Confirm the file is in your project folder by running:
ls

You should see conjura_mmm_data.csv listed in the output.

What is this dataset?

This is real marketing data published by Conjura under a Creative Commons license. It has 132,259 rows and 104 columns covering daily ad spend, impressions, and purchase values across 19 territories.

The spend columns follow a clear naming pattern like GOOGLE_PAID_SEARCH_SPEND, META_FACEBOOK_SPEND, and TIKTOK_SPEND. The revenue target column is ALL_PURCHASES_ORIGINAL_PRICE.

Can't find the download button?

Figshare pages list downloadable files near the top of the article. Look for a file with a .csv extension and click its download link. The file is approximately 31 MB.

Help me download the Conjura MMM dataset from Figshare.

Create your analysis file and load the data

Now you will create the Python file that holds all of your analysis code and load the dataset into it.

  • In your code editor, create a new file called mmm_analysis.py in your project folder.
  • Add the following import block at the top of mmm_analysis.py:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler

Why import Ridge and StandardScaler now?

You will not use Ridge and StandardScaler until Step 3, but importing everything at the top of the file keeps your imports organized in one place. This is a standard Python practice.

  • Add the data loading code below the imports in mmm_analysis.py:
# ============================================================
# 1. LOAD AND INSPECT THE DATA
# ============================================================
df = pd.read_csv("conjura_mmm_data.csv")
print(f"Dataset shape: {df.shape}")
print(f"Columns: {list(df.columns[:20])}...")

What does this code do?

  • pd.read_csv("conjura_mmm_data.csv") reads the entire CSV file into a pandas DataFrame called df.
  • df.shape prints the number of rows and columns, confirming the data loaded correctly.
  • df.columns[:20] prints the first 20 column names so you can see what fields are available without flooding your terminal.

Before you run the file, how many rows and columns do you think this dataset contains?

  • Save mmm_analysis.py and run it:
python mmm_analysis.py

You should see output showing the dataset shape as (132259, 104) followed by a list of the first 20 column names.

Seeing a FileNotFoundError?

  • Make sure conjura_mmm_data.csv is in the same folder as mmm_analysis.py. Run ls to check that both files appear together.
  • If you renamed the file incorrectly, check for typos. The filename must match conjura_mmm_data.csv exactly, including the underscore characters.
  • Make sure your terminal is in the project folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.

Help me fix a FileNotFoundError when loading conjura_mmm_data.csv in my MMM analysis script.

✔️ Awesome, I've got everything!

Great. Double check you have saved mmm_analysis.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the full mmm_analysis.py file so far. Make sure yours matches exactly:

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler

# ============================================================
# 1. LOAD AND INSPECT THE DATA
# ============================================================
df = pd.read_csv("conjura_mmm_data.csv")
print(f"Dataset shape: {df.shape}")
print(f"Columns: {list(df.columns[:20])}...")

Your environment is set up and the data is loaded. Next up, you will wrangle this messy dataset into a clean weekly format and spot the revenue patterns that make the whole model possible.

Wrangle the Messy Data and Spot the Revenue Patterns

In the last step, you loaded 132K+ rows of real eCommerce marketing data into a pandas DataFrame. Now it is time to turn that raw data into something you can actually model.

Real marketing data is messy. It spans multiple regions, mixes dozens of channel columns, and sits at daily granularity. Before you can isolate which channels drive revenue, you need to filter to a focused subset, aggregate to a useful time scale, and spot the patterns hiding in the noise.

In this step, get ready to:
  • Filter the dataset to a single region and identify the key spend and revenue columns.
  • Aggregate daily data to weekly totals and plot revenue over time.
  • Print summary statistics to understand each channel's spend distribution.
Filter to a single region and select key columns

The dataset contains data for ~100 eCommerce brands across 19 territories. Training a model on all of them at once would mix very different markets together. Filtering to one region keeps the signal clean and the model focused.

  • In your notebook, add the following code in a new cell below your existing code to filter the dataset to the region with the most data:
# ============================================================
# 2. WRANGLE: FILTER, AGGREGATE, EXPLORE
# ============================================================

# Filter to one region for focused analysis
regions = df["TERRITORY"].unique()
print(f"Available regions: {regions}")

# Pick the region with the most data
region_counts = df.groupby("TERRITORY").size().sort_values(ascending=False)
selected_region = region_counts.index[0]
print(f"Selected region: {selected_region}")

df_region = df[df["TERRITORY"] == selected_region].copy()

What does this code do?

  • df["TERRITORY"].unique() lists every region in the dataset so you can see what is available.
  • groupby("TERRITORY").size() counts rows per region. Sorting descending and grabbing the first index picks the region with the most data points.
  • .copy() creates an independent DataFrame so later changes do not accidentally modify the original df.
  • Run the cell. You should see a list of available regions printed, followed by the name of the selected region.

Seeing a KeyError on TERRITORY?

Make sure the column name is spelled exactly as TERRITORY in all caps. Column names in this dataset are uppercase.

Also confirm that the cell loading the CSV (df = pd.read_csv("conjura_mmm_data.csv")) ran successfully before this one.

Help me debug a KeyError on TERRITORY in my MMM notebook.

Now you need to tell the code which columns represent advertising spend and which column tracks revenue. The dataset has separate spend columns for each advertising channel across Google, Meta, and TikTok.

  • Add the following code in a new cell to define the spend and revenue columns:
# Define spend and revenue columns
spend_columns = [
    "GOOGLE_PAID_SEARCH_SPEND",
    "GOOGLE_SHOPPING_SPEND",
    "GOOGLE_PMAX_SPEND",
    "GOOGLE_DISPLAY_SPEND",
    "GOOGLE_VIDEO_SPEND",
    "META_FACEBOOK_SPEND",
    "META_INSTAGRAM_SPEND",
    "TIKTOK_SPEND",
]

revenue_column = "ALL_PURCHASES_ORIGINAL_PRICE"

# Keep only columns that exist in the dataset
available_spend = [col for col in spend_columns if col in df_region.columns]
print(f"Available spend columns: {available_spend}")

What does this code do?

  • spend_columns lists all eight advertising channels across Google (Paid Search, Shopping, PMax, Display, Video), Meta (Facebook, Instagram), and TikTok.
  • revenue_column points to ALL_PURCHASES_ORIGINAL_PRICE, the total revenue before discounts.
  • The list comprehension filters spend_columns down to only columns that actually exist in the filtered region. Not every region may have data for every channel.
  • Run the cell. You should see a list of the spend column names that exist in your selected region's data.

Seeing an empty list for available spend columns?

Double-check that the previous cell creating df_region ran without errors. The variable must exist before this cell can filter its columns.

Help me figure out why my available_spend list is empty.

Aggregate daily data to weekly and plot revenue

Daily data is too noisy for modeling. A single slow Tuesday would look like a channel failure when it is really just normal variation. Aggregating to weekly totals smooths out that noise and gives the model a cleaner signal to learn from.

  • Add the following code in a new cell to parse the dates, aggregate to weekly, and drop empty weeks:
# Parse dates and aggregate to weekly
df_region["DATE"] = pd.to_datetime(df_region["DATE"])
df_region["WEEK"] = df_region["DATE"].dt.to_period("W").apply(lambda r: r.start_time)

weekly = df_region.groupby("WEEK").agg(
    {**{col: "sum" for col in available_spend}, revenue_column: "sum"}
).reset_index()

# Drop weeks with zero total spend
weekly["TOTAL_SPEND"] = weekly[available_spend].sum(axis=1)
weekly = weekly[weekly["TOTAL_SPEND"] > 0].reset_index(drop=True)

print(f"Weekly data shape: {weekly.shape}")
print(weekly.head())

What does this code do?

  • pd.to_datetime() converts the date strings into proper datetime objects so pandas can group them by week.
  • .dt.to_period("W") groups each date into its calendar week. .apply(lambda r: r.start_time) converts the period back to a clean date (the Monday of each week).
  • The groupby("WEEK").agg() call sums every spend column and revenue column per week, collapsing daily rows into weekly totals.
  • Weeks where no money was spent are dropped because they carry no useful signal for the model.
  • Run the cell. You should see the shape of your new weekly DataFrame printed (a much smaller number of rows than the original 132K), followed by the first five rows showing weekly spend and revenue totals.

Seeing a ParserError or empty DataFrame?

If pd.to_datetime() throws a parser error, the date column in your region may use an unexpected format. Try adding format="mixed" as a parameter.

If the resulting DataFrame is empty (zero rows), the zero-spend filter may have dropped everything. Check that available_spend is not empty by printing it.

Help me debug my weekly aggregation code.

Now that you have clean weekly data, plot the revenue over time to see whether there are visible patterns. Before you run the next cell, take a guess: do you expect revenue to be steady week to week, or will there be visible peaks and valleys?

  • Add the following code in a new cell to generate a time-series plot of weekly revenue:
# Plot weekly revenue
plt.figure(figsize=(12, 4))
plt.plot(weekly["WEEK"], weekly[revenue_column])
plt.title("Weekly Revenue Over Time")
plt.xlabel("Week")
plt.ylabel("Revenue")
plt.tight_layout()
plt.savefig("weekly_revenue.png", dpi=100)
plt.show()

What does this code do?

  • plt.figure(figsize=(12, 4)) creates a wide, short chart that is easier to read for time-series data.
  • plt.savefig("weekly_revenue.png", dpi=100) saves the chart as an image in your project folder so you can reference it later.
  • plt.show() renders the chart inline in your notebook.
  • Run the cell. You should see a matplotlib line chart showing weekly revenue over time with clear peaks and valleys. These seasonal patterns are exactly what the model will try to explain using channel spend.

Chart not displaying inline?

If you are using Jupyter Notebook and the chart does not render inline, add %matplotlib inline at the top of your notebook in its own cell, then re-run the plot cell.

If you are using a .py file, the chart will open in a separate window. The image is still saved as weekly_revenue.png in your project folder either way.

Help me get my matplotlib chart to display.

Print summary statistics for each channel

The plot shows you the overall revenue pattern. Now you need to understand the spend side. Some channels may have consistent weekly budgets while others are sporadic. Channels with consistent spend give the model more data to learn from.

  • Add the following code in a new cell to print summary statistics for each channel's weekly spend:
# Summary statistics
print("\nWeekly spend summary:")
print(weekly[available_spend].describe().round(2))

What does this code do?

.describe() generates count, mean, standard deviation, min, max, and quartiles for each spend column. Look at the mean and std columns to spot which channels have steady spend (low std relative to mean) versus sporadic spend (high std or many zero values).

  • Run the cell. You should see a table with rows like count, mean, std, and max for each channel's weekly spend. Notice which channels carry the heaviest budgets and which have the most variability.

Seeing NaN or all zeros in the table?

If all values are zero or NaN, your selected region may not have significant ad spend. Go back to the region-filtering cell and print region_counts.head(5) to see if another region has more non-zero spend data.

Help me understand why my channel spend summary shows all zeros.

Your weekly DataFrame, revenue plot, and channel summary statistics confirm that you have clean, structured data ready for modeling. The revenue chart shows clear seasonal patterns that the model will try to explain using channel spend.

✔️ Awesome, I've got everything!

Double check that you have saved your notebook. You should also see weekly_revenue.png saved in your project folder.

ⓧ I'd like to double check the full code

The full file exceeds 30 lines, so here is a checklist. Scroll up through your notebook and confirm each of these sections is present, in order:

  • Section 1 (from Step 1): imports, pd.read_csv("conjura_mmm_data.csv"), and the two print statements for shape and columns.
  • Section 2 chunk 1: region filtering with df["TERRITORY"].unique(), region_counts, selected_region, and df_region.
  • Section 2 chunk 2: spend_columns list (8 channel names), revenue_column, and available_spend filtering.
  • Section 2 chunk 3: date parsing, weekly aggregation with groupby("WEEK").agg(), zero-spend drop, and weekly.head() print.
  • Section 2 chunk 4: the matplotlib plot with plt.savefig("weekly_revenue.png").
  • Section 2 chunk 5: weekly[available_spend].describe().round(2) summary statistics print.

You have taken 132K rows of messy daily data and turned it into a clean weekly time series with per-channel spend and revenue. Next up, you will apply adstock transforms to capture delayed advertising effects and fit a Ridge regression model to isolate each channel's true impact on revenue.

Apply Adstock Transforms and Fit the Ridge Model

Your weekly DataFrame from Step 2 shows clear revenue patterns and how each channel's spend fluctuates week to week. But if you ran a plain regression on that raw spend data right now, you would miss a critical real-world effect: someone who sees an ad on Tuesday often buys on Saturday.

This delayed impact is called adstock. Without accounting for it, your model would systematically undervalue channels whose effect takes days to show up in purchases. In this step, you will build an adstock transform to capture that carryover, then fit a Ridge regression to isolate each channel's true impact on revenue.

In this step, get ready to:
  • Write a geometric adstock function and apply it to every channel's spend data.
  • Split the data into train and test sets, scale the features, and fit a Ridge regression model.
  • Evaluate the model's R-squared score and inspect each channel's coefficient.
Write the adstock function and transform each channel

Advertising has a carryover effect. A customer might see your Google Shopping ad on Monday but not purchase until Friday. Raw daily or weekly spend figures treat each period as independent, ignoring that ripple.

The geometric adstock formula fixes this by letting a fraction of each week's ad impact "carry over" into the next week. The formula is adstock[t] = spend[t] + decay * adstock[t-1].

  • In mmm_analysis.py, scroll to the bottom of your file (below the summary statistics print) and add the adstock function and its application by copying this code:
# ============================================================
# 3. ADSTOCK TRANSFORM + RIDGE REGRESSION
# ============================================================

def geometric_adstock(series, decay=0.5):
    """Apply geometric adstock transformation.

    adstock[t] = spend[t] + decay * adstock[t-1]
    Captures the delayed effect of advertising.
    """
    adstocked = np.zeros(len(series))
    adstocked[0] = series.iloc[0] if hasattr(series, "iloc") else series[0]
    for t in range(1, len(series)):
        val = series.iloc[t] if hasattr(series, "iloc") else series[t]
        adstocked[t] = val + decay * adstocked[t - 1]
    return adstocked


# Apply adstock to each channel
decay_rate = 0.5
adstock_df = pd.DataFrame()
for col in available_spend:
    adstock_df[col + "_adstock"] = geometric_adstock(weekly[col], decay=decay_rate)

adstock_columns = list(adstock_df.columns)

What does this code do?

The geometric_adstock function loops through each week's spend and adds a fraction of the previous week's adstock value on top. With a decay rate of 0.5, 50% of last week's ad effect carries into this week, 25% into the week after, and so on.

The loop after the function applies this transform to every channel in available_spend and stores the results in a new DataFrame called adstock_df. Each column is named with an _adstock suffix so you can tell transformed data from raw data.

  • Save mmm_analysis.py and run it. You should see all the previous output (dataset shape, weekly data shape, the revenue plot) followed by a clean exit with no errors.

Seeing a NameError or AttributeError?

  • A NameError: name 'weekly' is not defined means the new code was added outside the main flow or the wrangling code above it has an error. Make sure the adstock section sits directly below the summary statistics print.
  • Check that import numpy as np is still at the top of your file. The function uses np.zeros which requires numpy.
  • Help me debug my adstock function

Why a decay rate of 0.5?

A decay rate of 0.5 is a standard starting point used across Marketing Mix Model implementations. It means half of an ad's effect lingers into the next week. In a production model, you would optimize this rate per channel. For learning purposes, a fixed 0.5 demonstrates the concept clearly.

Split the data and fit a Ridge regression

Your adstock-transformed features are ready. Now you need to split the data for training and testing, scale the features, and fit a model that can handle correlated channel spends.

Channel spends are often correlated. When a brand increases its Google budget, it often increases Meta spend too. Standard linear regression struggles with this multicollinearity. Ridge regression adds a penalty that keeps coefficients stable even when inputs are correlated.

  • Add the train/test split, feature scaling, and Ridge model fit to the bottom of mmm_analysis.py by copying this code below the adstock_columns line:
# Train/test split (80/20, preserving time order)
split_idx = int(len(weekly) * 0.8)

X_train = adstock_df.iloc[:split_idx]
X_test = adstock_df.iloc[split_idx:]
y_train = weekly[revenue_column].iloc[:split_idx]
y_test = weekly[revenue_column].iloc[split_idx:]

# Scale features
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

# Fit Ridge regression with positive coefficients
model = Ridge(alpha=1.0, positive=True)
model.fit(X_train_scaled, y_train)

# Evaluate
train_r2 = model.score(X_train_scaled, y_train)
test_r2 = model.score(X_test_scaled, y_test)
print(f"\nModel Performance:")
print(f"  Train R-squared: {train_r2:.4f}")
print(f"  Test R-squared:  {test_r2:.4f}")

# Print coefficients
print("\nChannel Coefficients:")
for col, coef in zip(available_spend, model.coef_):
    channel_name = col.replace("_SPEND", "")
    print(f"  {channel_name}: {coef:.4f}")

What does this code do?

  • The split uses the first 80% of weeks for training and the last 20% for testing. Time order is preserved because shuffling time-series data would let the model "peek" at the future.
  • StandardScaler normalizes each channel's values so they are all on the same scale. Without this, a channel with high absolute spend would dominate the model regardless of its actual effectiveness.
  • Ridge with positive=True ensures every channel's coefficient is zero or positive. A channel's spend should never decrease revenue, so negative coefficients would be nonsensical.
  • The R-squared score measures how much of the revenue variation the model explains. A score of 0.40 means the model explains 40% of weekly revenue changes from ad spend alone.
Verify your model's performance

Before you run the script, think about what you expect to see. Do you think the model will explain most of the revenue variation, or just some of it? Remember, ad spend is only one of many factors driving purchases.

  • Save mmm_analysis.py and run it.

At the bottom of your output, you should see a test R-squared above 0.40 and positive coefficients for the main spending channels. The coefficients tell you the relative strength of each channel's impact on revenue after accounting for adstock carryover.

What do the coefficients mean?

A larger coefficient means that channel has a stronger marginal effect on revenue per unit of scaled spend. Channels with a coefficient of 0.0000 had no measurable impact the model could detect. This does not necessarily mean the channel is useless. It could mean the data for that channel was too sparse or too correlated with another channel for the model to separate their effects.

R-squared is negative or very low?

  • A negative R-squared means the model is worse than always guessing the mean. Check that you applied the adstock transform before fitting. If you fit the model on raw spend instead of adstock_df, the results will be much weaker.
  • Verify that scaler.fit_transform is called on X_train and scaler.transform (without fit) is called on X_test. Using fit_transform on both would cause data leakage.
  • My Ridge model R-squared is very low or negative

✔️ Awesome, I've got everything!

Great. Double check that you have saved mmm_analysis.py and that your output shows both the R-squared scores and the channel coefficients.

ⓧ I'd like to double check the full code

Your mmm_analysis.py should now contain three major sections. Scroll through your file and confirm each one is present in order:

  • Section 1 (Load and Inspect): imports at the top, pd.read_csv("conjura_mmm_data.csv"), and the dataset shape print.
  • Section 2 (Wrangle): region filtering, spend column selection, weekly aggregation, the revenue plot, and summary statistics.
  • Section 3 (Adstock + Ridge): the geometric_adstock function, the adstock application loop, the train/test split, StandardScaler, the Ridge model fit, and the R-squared and coefficient prints.

Your Ridge model now knows how much each channel contributes to revenue. Next up, you will translate those coefficients into the business metrics that marketing teams actually care about: ROAS, CPA, and MER.

Decode Channel Performance and Export Results

Your Ridge model now has a coefficient for every channel. Those raw numbers tell you that some channels have a stronger relationship with revenue than others, but a coefficient of 0.34 means nothing to a Head of Marketing.

In this step, you will translate those coefficients into ROAS (revenue per dollar spent) and MER (overall marketing efficiency). Then you will export the results to CSV files that Power BI can ingest in the next step.

In this step, get ready to:
  • Calculate each channel's revenue contribution using the Ridge model coefficients.
  • Build a channel performance summary with ROAS and MER metrics.
  • Export two CSV files for Power BI.
Calculate each channel's revenue contribution

Each Ridge coefficient tells you the relative weight of a channel, but you need to multiply it by the actual scaled spend data to get the dollar contribution. You also need to account for the model's intercept, which represents revenue that is not attributed to any channel.

  • In mmm_analysis.py, scroll to the bottom of your file (after the channel coefficients print statements).
  • Add the contribution calculation code below your existing code:
# ============================================================
# 4. CHANNEL PERFORMANCE: ROAS, CPA, MER
# ============================================================

# Calculate contributions across the full dataset
X_full_scaled = scaler.transform(adstock_df)
predictions = model.predict(X_full_scaled)

# Per-channel contribution (coefficient * scaled feature, proportional allocation)
contributions = {}
total_contribution = np.zeros(len(weekly))

for i, col in enumerate(available_spend):
    channel_contribution = model.coef_[i] * X_full_scaled[:, i]
    contributions[col] = channel_contribution.sum()
    total_contribution += channel_contribution

# Add intercept proportionally
intercept_total = model.intercept_ * len(weekly)
total_model_output = total_contribution.sum() + intercept_total

What does this code do?

scaler.transform(adstock_df) scales the full adstocked dataset (not just the training portion) so the Ridge model can interpret it. model.predict(X_full_scaled) generates predicted revenue for every week.

The loop multiplies each channel's coefficient by its scaled spend vector to get a per-channel contribution. The intercept captures baseline revenue that exists regardless of ad spend (brand awareness, organic traffic).

Why not just divide revenue by spend?

A naive ROAS (total revenue divided by each channel's spend) would be misleading. It ignores the adstock effect, where someone sees an ad on Tuesday but buys on Saturday. It also ignores cross-channel interactions.

By routing the calculation through the Ridge model's coefficients, you isolate each channel's true contribution after accounting for delayed effects and multicollinearity between channels.

  • Save mmm_analysis.py and rerun your script.
  • You should see all the previous output (R-squared scores, channel coefficients) with no new errors at the end.

Seeing a NameError?

  • If you see an error mentioning scaler or model, make sure the Section 3 code (StandardScaler and Ridge fitting) is still in your file above this new code.
  • If you see an error about adstock_df, confirm the adstock loop that populates adstock_df is present in Section 3.
  • Help me debug a NameError in my contribution calculation code.
Build the channel performance summary

The raw contributions are in scaled units. You need to rescale them back to actual revenue dollars, then compute ROAS for each channel so you can rank them.

  • Add the channel summary loop below your contribution code:
# Build channel summary
channel_summary = []
for col in available_spend:
    channel_name = col.replace("_SPEND", "")
    total_spend = weekly[col].sum()
    contribution = contributions[col]

    # Scale contribution to actual revenue
    actual_revenue = weekly[revenue_column].sum()
    scaled_contribution = (contribution / total_model_output) * actual_revenue if total_model_output != 0 else 0

    roas = scaled_contribution / total_spend if total_spend > 0 else 0

    channel_summary.append({
        "Channel": channel_name,
        "Total_Spend": round(total_spend, 2),
        "Contribution": round(scaled_contribution, 2),
        "ROAS": round(roas, 2),
    })

What does this code do?

For each channel, this loop divides the channel's raw contribution by the total model output, then multiplies by actual revenue. This rescales the model's internal numbers back into real dollars.

ROAS is then simply scaled_contribution / total_spend. A ROAS of 3.0 means the channel returns $3 in model-attributed revenue for every $1 spent.

  • Now add the code that creates the summary DataFrame, ranks channels, and computes MER:
summary_df = pd.DataFrame(channel_summary)
summary_df = summary_df.sort_values("ROAS", ascending=False).reset_index(drop=True)
summary_df["Rank"] = range(1, len(summary_df) + 1)

# Overall MER
total_spend_all = summary_df["Total_Spend"].sum()
total_revenue_all = weekly[revenue_column].sum()
mer = total_revenue_all / total_spend_all if total_spend_all > 0 else 0

print("\n=== CHANNEL PERFORMANCE ===")
print(summary_df.to_string(index=False))
print(f"\nOverall MER (Marketing Efficiency Ratio): {mer:.2f}")

What is MER?

Marketing Efficiency Ratio (MER) is total revenue divided by total marketing spend across all channels. Unlike per-channel ROAS, MER gives you one number that captures how efficiently the entire marketing budget converts to revenue.

Before you run the script, think about this: which channel do you expect to have the highest ROAS? The one with the highest spend, the lowest spend, or somewhere in between?

  • Save mmm_analysis.py and rerun your script.
  • After the R-squared scores and coefficients from the previous step, you should see a new table titled === CHANNEL PERFORMANCE === with columns for Channel, Total_Spend, Contribution, ROAS, and Rank.

The channel ranked #1 delivers the most revenue per dollar of ad spend. Channels with a ROAS below 1.0 cost more than they return.

Not seeing the performance table?

  • If you see a ZeroDivisionError, check that total_model_output is computed from the previous chunk. Make sure the intercept line is present.
  • If you see a KeyError on contributions, confirm the contribution calculation loop from the previous chunk is above this code.
  • Help me debug my channel performance summary code.
Export results for Power BI

Power BI needs CSV files to import. You will export two: a weekly time series (so Power BI can chart revenue over time by channel) and the channel summary (so it can display the ROAS rankings).

  • Add the export code below your performance summary print statements:
# ============================================================
# 5. EXPORT FOR POWER BI
# ============================================================

# Weekly time series export
weekly_export = weekly[["WEEK", revenue_column]].copy()
weekly_export.columns = ["Week", "Revenue"]

# Add per-channel weekly contributions
for i, col in enumerate(available_spend):
    channel_name = col.replace("_SPEND", "")
    channel_contrib_series = model.coef_[i] * X_full_scaled[:, i]
    # Scale to actual revenue proportion
    if total_model_output != 0:
        weekly_export[channel_name + "_Contribution"] = (
            channel_contrib_series / total_model_output
        ) * weekly[revenue_column].values
    else:
        weekly_export[channel_name + "_Contribution"] = 0

weekly_export.to_csv("weekly_timeseries.csv", index=False)
print("\nExported: weekly_timeseries.csv")

# Channel summary export
summary_df.to_csv("channel_summary.csv", index=False)
print("Exported: channel_summary.csv")

print("\nDone! Open these CSVs in Power BI Desktop to build your dashboard.")

What does this code do?

The weekly time series CSV breaks revenue down by channel for each week. Power BI will use this to plot contribution trends over time. Each channel gets a column like GOOGLE_PAID_SEARCH_Contribution.

The channel summary CSV is the one-row-per-channel table you just printed. Power BI will use this for the ROAS bar chart and KPI cards.

Before you run, what two new messages do you expect to see at the end of the script output?

  • Save mmm_analysis.py and rerun your script.
  • After the performance table, you should see three new lines: Exported: weekly_timeseries.csv, Exported: channel_summary.csv, and a final confirmation message.
  • Confirm the CSV files exist in your project folder by running:
ls *.csv

You should see channel_summary.csv, conjura_mmm_data.csv, and weekly_timeseries.csv listed.

Missing CSV files?

  • Make sure your terminal is in the same folder as mmm_analysis.py. The CSVs are saved in whichever directory the script runs from.
  • If the script ran without errors but no CSVs appeared, check that the export section (Section 5) is at the bottom of your file and not accidentally indented inside another block.
  • Help me figure out why my CSV export is not working.

✔️ Awesome, I've got everything!

Great. Make sure you have saved mmm_analysis.py and confirmed both CSV files exist in your project folder.

ⓧ I'd like to double check the full code

Scroll through your mmm_analysis.py and confirm these sections are present in order:

  • Section 1: LOAD AND INSPECT THE DATA (from Step 1).
  • Section 2: WRANGLE: FILTER, AGGREGATE, EXPLORE (from Step 2).
  • Section 3: ADSTOCK TRANSFORM + RIDGE REGRESSION (from Step 3).
  • Section 4: CHANNEL PERFORMANCE: ROAS, CPA, MER (added in this step). Includes the contribution calculation loop, the channel_summary loop, the summary_df creation with sorting, and the MER print.
  • Section 5: EXPORT FOR POWER BI (added in this step). Includes the weekly_export DataFrame, the contribution loop, and the two .to_csv() calls.

You now have a ranked view of every channel's true revenue contribution, backed by a model that accounts for delayed advertising effects. Next up, you will import these CSVs into Power BI and build an interactive What-If dashboard where a marketing leader can drag budget sliders and see the predicted revenue change in real time.

Build the Power BI What-If Dashboard

Your channel performance table is ready, ranking every ad channel by ROAS with a model that accounts for delayed advertising effects. But that table lives in a Python notebook. A Head of Marketing cannot drag a slider on a Jupyter output.

The marketing team needs an interactive tool to answer "what happens if we shift budget?" In this step, you will import your model results into Power BI Desktop and build a What-If dashboard where a marketing leader can drag a slider and see predicted revenue change in real time.

In this step, get ready to:
  • Import your model output CSVs into Power BI Desktop.
  • Build KPI cards and a channel comparison bar chart.
  • Create a What-If budget slider that dynamically updates predicted revenue.
Import your CSV files into Power BI

Power BI needs your model outputs as data sources before you can build any visuals. You will import both CSVs you exported in the previous step.

  • Press the Windows key to open your search bar.
  • Type Power BI Desktop and press Enter to open it.
  • If a welcome screen or sign-in dialog appears, close it or sign in with your Microsoft account.
  • Click Get Data in the Home ribbon.
  • Select Text/CSV from the dropdown. If you do not see it listed, click More... and search for it under the File category.
  • Navigate to your project folder and select channel_summary.csv.
  • Click Open.
  • In the preview window, verify you see columns: Channel, Total_Spend, Contribution, ROAS, and Rank.

Don't see the right columns in the preview?

Make sure you selected channel_summary.csv, not conjura_mmm_data.csv. The original dataset has 104 columns. Your exported summary should have exactly 5.

help me troubleshoot my CSV import in Power BI

  • Click Load.

The channel_summary table now appears in the Data pane on the right side of Power BI.

  • Click Get Data again in the Home ribbon.
  • Select Text/CSV.
  • Navigate to your project folder and select weekly_timeseries.csv.
  • Click Open.
  • In the preview, verify you see columns including Week, Revenue, and per-channel contribution columns.
  • Click Load.

Both channel_summary and weekly_timeseries now appear in the Data pane. You have two data sources ready for your dashboard.

Create KPI cards and a channel comparison bar chart

KPI cards give a marketing leader the headline numbers at a glance: how much was spent, how much revenue the model attributes to advertising, and the overall return. A bar chart then breaks down which channels deliver the most revenue per dollar.

  • Click on an empty area of the report canvas.
  • In the Visualizations pane, click the Card visual icon.
  • In the Data pane, expand channel_summary and drag Total_Spend into the card's Values well.

A card appears on your canvas showing the sum of all channel spend. This is your Total Spend KPI.

  • Click on an empty area of the canvas (not the existing card).
  • Select another Card visual from the Visualizations pane.
  • Drag Contribution from channel_summary into the card's Values well.

A second card appears showing the total revenue your model attributes to advertising. This is your Total Revenue KPI.

The third KPI, Overall ROAS, requires dividing Total Contribution by Total Spend. Power BI cannot do this division inside a plain Card visual, so you need a DAX measure.

  • Click the Modeling tab in the ribbon.
  • Click New measure.
  • Type this DAX formula into the formula bar that appears at the top of the canvas:
Overall ROAS = SUM('channel_summary'[Contribution]) / SUM('channel_summary'[Total_Spend])

What is a DAX measure?

DAX (Data Analysis Expressions) is Power BI's formula language. A measure is a calculation that evaluates dynamically based on the data in your report. This measure divides the sum of all channel contributions by the sum of all channel spend, giving you the overall return on ad spend across your entire marketing mix.

  • Press Enter to confirm the measure.
  • Click on an empty area of the canvas.
  • Select a Card visual from the Visualizations pane.
  • Drag the Overall ROAS measure from the Data pane into the card's Values well.

A third card appears showing the overall ROAS as a decimal number. You now have three KPI cards on your dashboard.

Now add a bar chart so the marketing team can compare ROAS across channels at a glance.

  • Click on an empty area of the canvas.
  • In the Visualizations pane, select the Clustered bar chart visual.
  • Drag Channel from channel_summary into the Y-axis well.
  • Drag ROAS into the X-axis well.

A horizontal bar chart appears showing each channel's ROAS. The longest bar is your highest-performing channel.

Format ROAS as decimal numbers

If the ROAS bars display as whole numbers or in a confusing format, select the bar chart and click the Format visual tab (paint roller icon) in the Visualizations pane. Expand X-axis and adjust the Decimal places to 2.

Bar chart showing a single bar or no data?

Make sure you dragged Channel to the Y-axis well and ROAS to the X-axis well. Swapping them produces a single bar summing all ROAS values together.

help me fix my Power BI bar chart

Create the What-If budget slider

Your KPI cards show what happened. The What-If parameter answers a more powerful question: what could happen. Power BI's What-If feature creates a slicer that feeds a variable into a DAX formula, letting the marketing team simulate budget adjustments and see the predicted revenue change instantly.

  • Click the Modeling tab in the ribbon.
  • Click New parameter.
  • Select Numeric range.

A parameter configuration dialog appears. Fill it in with these values:

  • Set Name to Budget Adjustment %.
  • Set Data type to Whole number.
  • Set Minimum to -50.
  • Set Maximum to 50.
  • Set Increment to 5.
  • Check the Add slicer to this page checkbox.
  • Click Create.

A slicer control appears on your report page. You can drag it between -50 and 50 in steps of 5.

What did Power BI just create?

Power BI created two things automatically: a parameter table called Budget Adjustment % and a measure called Budget Adjustment % Value. The measure holds whatever number the slicer is currently set to.

You will reference this measure in a DAX formula to calculate predicted revenue based on the slider position.

The slicer exists, but it does not affect anything yet. You need a DAX measure that uses the slider value to calculate a predicted revenue.

  • Click the Modeling tab in the ribbon.
  • Click New measure.
  • Type this DAX formula into the formula bar:
Predicted Revenue = SUM('channel_summary'[Contribution]) * (1 + 'Budget Adjustment %'[Budget Adjustment % Value] / 100)

What does this formula do?

This measure takes the total model-attributed revenue (the sum of all channel contributions) and adjusts it by the slider percentage. When the slider is at 0, predicted revenue equals the baseline. When the slider is at +20, it multiplies the baseline by 1.2, simulating a 20% budget increase.

  • Press Enter to confirm the measure.
  • Click on an empty area of the canvas.
  • Select a Card visual from the Visualizations pane.
  • Drag the Predicted Revenue measure from the Data pane into the card's Values well.

A new card appears showing the predicted revenue. With the slider at 0, this value matches your Total Revenue card.

Before you drag the slider, think about this: if you move the budget adjustment to +20, what do you expect to happen to the Predicted Revenue number?

  • Drag the Budget Adjustment % slicer to 20.

The Predicted Revenue card jumps up. It now shows a value 20% higher than the baseline, because the formula multiplied your total contribution by 1.2. Drag the slider to -30 and watch the number drop. This is the interactive "what if" tool your marketing team needs.

Predicted Revenue card not updating when you move the slider?

Select the Predicted Revenue card and check the Values well. It should show the Predicted Revenue measure, not a raw column. If it shows a column name instead, remove it and drag the measure in.

Also verify the DAX formula references 'Budget Adjustment %'[Budget Adjustment % Value] exactly. A typo in the table or measure name will break the link to the slicer.

help me fix my Power BI What-If parameter

  • Save your report by pressing Ctrl+S.
  • Choose a location in your project folder and name the file mmm_dashboard.pbix.

Secret mission

Build a Python Budget Optimizer

Your Power BI dashboard lets you manually explore what-if budget scenarios by dragging a slider. But what if the model could mathematically recommend the optimal allocation for you? In this mission, you will build a Python script that uses constrained optimization to find the exact budget split across channels that maximizes predicted revenue.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your files for future reference, pause to free up memory, or delete them entirely.

Resources you used:

  • Python scripts (mmm_analysis.py and budget_optimizer.py).
  • Downloaded Figshare dataset (conjura_mmm_data.csv, approximately 31 MB).
  • Exported CSV files (weekly_timeseries.csv and channel_summary.csv).
  • Matplotlib chart (weekly_revenue.png).
  • Power BI report (.pbix file) saved locally.

Keep everything running

No action needed. Choose this if you plan to keep building on your Marketing Mix Model or revisiting your Power BI dashboard.

  • All your project files are saved locally on your machine with no ongoing costs.
  • Your Python packages (pandas, numpy, scikit-learn, matplotlib, scipy) remain installed and available for other projects.

Pause - I'll come back to this later

Close running applications to free up memory. Your files stay on your machine for when you return.

  • Close Power BI Desktop if it is still open.
  • Close your Jupyter Notebook or VS Code session.
  • All your project files, CSV exports, and the Power BI report remain on your machine and are ready to reopen anytime.

Delete - I don't want to use this again

Remove all project files from your machine to start fresh.

  • In your terminal, navigate to the folder where you saved your project files.
  • Delete all project files by running the command for your operating system:

macOS

rm mmm_analysis.py budget_optimizer.py conjura_mmm_data.csv weekly_timeseries.csv channel_summary.csv weekly_revenue.png

Windows

Remove-Item .\mmm_analysis.py, .\budget_optimizer.py, .\conjura_mmm_data.csv, .\weekly_timeseries.csv, .\channel_summary.csv, .\weekly_revenue.png
  • Delete your Power BI report (.pbix file) from wherever you saved it. In Power BI Desktop, go to File, then Close. Then delete the .pbix file from its save location using File Explorer.

Keep common data science packages

The Python packages you installed (pandas, numpy, scikit-learn, matplotlib, scipy) are widely used in data science projects. You can safely leave them installed unless you want a completely clean environment.

If you do want to remove them, run:

pip uninstall pandas numpy scikit-learn matplotlib scipy -y

Nice Work!

Nice Work!

You just built a full Marketing Mix Model from real eCommerce data and turned it into an interactive dashboard that a marketing leader can actually use to make budget decisions.

You've learned how to:

  • Wrangle 132K+ rows of real, messy eCommerce marketing data into a clean weekly time series and apply geometric adstock transforms to capture delayed advertising effects.
  • Build a Ridge regression Marketing Mix Model that isolates each channel's true revenue contribution and calculate per-channel ROAS, CPA, and MER to rank channel effectiveness.
  • Build a Power BI What-If dashboard where a marketing leader can drag budget sliders and see predicted revenue change in real time.
  • Secret Mission: Build a Python budget optimizer that mathematically recommends the optimal spend allocation to maximize revenue.

Ready to quiz yourself?