Skip to Content
Course content

167: Marketing Mix Modeling in R Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

If you've ever been asked by a marketing manager to "figure out which channel is driving the most sales," your first instinct in R might be to reach for a simple linear model. It's clean, it's fast, and the coefficients give you a nice, neat number for "Return on Ad Spend." But if you've done this before, you know that a raw lm() call on marketing spend almost always lies to you. I've seen a lot of junior analysts present these results, only to have the CMO point out that the model suggests spending zero on a channel that they know for a fact is their primary growth engine.

The Seduction of the Simple Linear Model

Let's say we're modeling sales for a high-end espresso machine. We have weekly data on spend across three channels: Instagram, Google Search, and Email. The naive approach looks like this:

# The "Quick and Dirty" way
model_naive <- lm(sales ~ ig_spend + google_spend + email_spend, data = mkt_data)
summary(model_naive)

On the surface, this is great. You get a p-value and a coefficient. But this model assumes a "perfectly linear, instantaneous" world. It assumes that a dollar spent on Instagram today drives a sale today, and that spending $10,000 will produce exactly ten times the result of spending $1,000. In the real world, marketing doesn't work that way. I call this the "vacuum model" because it assumes your ads exist in a vacuum, devoid of time or human psychology.

Where the Linear Assumption Hits a Wall

The reason this approach breaks is twofold: adstock and saturation. First, there's the "carryover effect." If a customer sees a gorgeous ad for an espresso machine on Tuesday, they might not buy it until Friday after they've discussed it with their spouse. A linear model treats that Friday sale as "organic" or attributes it to whatever ad ran on Friday, completely ignoring the Tuesday trigger. This is adstock.

Then there's saturation. There is a finite number of people interested in espresso machines in a given week. Once you've reached the bulk of your audience, spending another $5,000 on Google Search doesn't give you a linear increase in sales; it just makes you pay more for the same clicks. Your response curve is concave, not a straight line. If you use lm(), you're trying to fit a straight ruler to a curved hill, and the resulting coefficients will be biased and misleading.

Accounting for Memory and Fatigue

The better way is to transform your independent variables before they hit the model. We need to account for the decay of the ad's impact over time and the diminishing returns of spend. Instead of feeding raw spend into the model, we create "transformed spend" variables.

For adstock, we use a decay function. A simple geometric decay looks like this in R:

# A simple function to calculate adstock
apply_adstock <- function(x, decay_rate = 0.5) {
  return(stats::filter(x, filter = decay_rate, method = "recursive"))
}

mkt_data$ig_adstocked <- apply_adstock(mkt_data$ig_spend, 0.7)

Now, for saturation, we can use a Hill function or a simple log transformation if the curvature is mild. By transforming the data first, we are essentially telling the model: "The impact of this spend is delayed and it eventually plateaus."

The trade-off here is complexity. You no longer have a single coefficient you can point to; you now have hyperparameters (like the decay_rate) that you need to optimize. I usually handle this by iterating through a grid of possible decay rates and saturation parameters to find the combination that minimizes the root mean squared error (RMSE) of the model. It's more work, but it's the difference between giving your boss a guess and giving them a strategy.




📋 Practical Task

Implementing Geometric Adstock for the Coffee Machine Dataset

You have been provided with a dataframe mkt_data containing columns sales and tv_spend. Your current model lm(sales ~ tv_spend)` is underperforming because it ignores the carryover effect of TV advertising.

Write a script that does the following:

  • Defines a function calc_adstock that takes a vector of spend and a decay value (between 0 and 1), returning the adstocked spend using a recursive filter.
  • Applies this function to tv_spend using a decay rate of 0.6 to create a new column called tv_adstocked.
  • Fits a new linear model using tv_adstocked instead of tv_spend.
  • Prints the R-squared value of the new model to compare it against the original.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.