Welcome to Part 3 of my series on marketing mix modeling (MMM), a practical guide to help you master MMM. Throughout this series, we will cover key topics such as model training, validation, calibration, and budget optimization, all using the powerful pymc marketing Python package. Whether you're new to MMM or looking to sharpen your skills, this series will equip you with practical tools and ideas to improve your marketing strategies.
If you missed Part 2, check it out here:
In the third installment of the series, we will cover how we can start to get business value from our marketing mix models covering the following areas:
- Why do organizations want to optimize their marketing budgets?
- How can we use the results of our marketing mix model to optimize budgets?
- A Python tutorial demonstrating how to optimize budgets using pymc marketing.
The complete notebook can be found here:
This famous quote (from John Wanamaker, I think?) illustrates both the challenge and opportunity in marketing. While modern analytics have come a long way, the challenge remains relevant: understanding which parts of your marketing budget offer value.
Marketing channels can vary significantly in terms of their performance and ROI due to several factors:
- Audience Reach and Engagement – Some channels are more effective at reaching specific prospects aligned with your target audience.
- Acquisition cost – The cost of reaching prospects differs between channels.
- Channel saturation – Overuse of a marketing channel can lead to diminishing returns.
This variability creates the opportunity to ask critical questions that can transform your marketing strategy:
Effective budget optimization is a critical component of modern marketing strategies. By leveraging MMM results, companies can make informed decisions about where to allocate their resources for maximum impact. MMM provides insights into how various channels contribute to overall sales, allowing us to identify opportunities for improvement and optimization. In the following sections, we will explore how we can translate MMM results into actionable budget allocation strategies.
2.1 Response curves
A response curve can translate MMM results into a comprehensive form, showing how sales respond to expenses for each marketing channel.
Response curves alone are very powerful, allowing us to run what-if scenarios. Using the response curve above as an example, we could estimate how the sales contribution of social changes as we spend more. We can also visually see where diminishing returns begin to emerge. But what if we want to try to answer more complex scenarios, such as optimizing budgets at the channel level given a fixed overall budget? This is where linear programming comes in – let's explore this in the next section!
2.2 Linear programming
Linear programming is an optimization method that can be used to find the optimal solution of a linear function given some constraints. It is a very versatile tool in the operations research area, but it often does not get the recognition it deserves. It is used to solve scheduling, transportation, and resource allocation problems. Let's explore how we can use it to optimize marketing budgets.
Let's try to understand linear programming with a simple budget optimization problem:
- Decision variables (x): These are the unknown quantities that we want to estimate optimal values for, for example, marketing spend on each channel.
- Objective function (z): The linear equation we are trying to minimize or maximize, for example, maximize the sum of the sales contribution of each channel.
- Restrictions: Some constraints on decision variables, usually represented by linear inequalities, for example total marketing budget equal to £50 million, channel level budgets between £5 million and £15 million.
The intersection of all the constraints forms a feasible region, which is the set of all possible solutions that satisfy the given constraints. The goal of linear programming is to find the point within the feasible region that optimizes the objective function.
Given the saturation transformation we apply to each marketing channel, optimizing budgets at the channel level is actually a nonlinear programming problem. Sequential least squares programming (SLSQP) is an algorithm used to solve nonlinear programming problems. It allows for equality and inequality constraints, making it a sensible choice for our use case.
- Equality constraints For example, the total marketing budget is equal to £50 million
- Inequality constraints for example, channel level budgets between £5 million and £15 million
Scipy has a great implementation of SLSQP:
The following example illustrates how we could use it:
from scipy.optimize import minimizeresult = minimize(
fun=objective_function, # Define your ROI function here
x0=initial_guess, # Initial guesses for spends
bounds=bounds, # Channel-level budget constraints
constraints=constraints, # Equality and inequality constraints
method='SLSQP'
)
print(result)
Writing budget optimization code from scratch is a complex but very rewarding exercise. Fortunately, the pymc marketing The team has done the heavy lifting, providing a robust framework for running budget optimization scenarios. In the next section, we'll explore how your package can streamline the budget allocation process and make it more accessible to analysts.
Now we understand how we can use the output of MMM to optimize budgets, let's see how much value we can drive using our model from the last article! In this tutorial we will cover:
- Simulating data
- Training the model
- Validating the model
- Response curves
- Budget optimization
3.1 Simulating data
We are going to reuse the data generation process from the first article. If you want a reminder about the data generation process, take a look at the first article where we did a detailed tutorial:
np.random.seed(10)# Set parameters for data generator
start_date = "2021-01-01"
periods = 52 * 3
channels = ("tv", "social", "search")
adstock_alphas = (0.50, 0.25, 0.05)
saturation_lamdas = (1.5, 2.5, 3.5)
betas = (350, 150, 50)
spend_scalars = (10, 15, 20)
df = dg.data_generator(start_date, periods, channels, spend_scalars, adstock_alphas, saturation_lamdas, betas)
# Scale betas using maximum sales value - this is so it is comparable to the fitted beta from pymc (pymc does feature and target scaling using MaxAbsScaler from sklearn)
betas_scaled = (
((df("tv_sales") / df("sales").max()) / df("tv_saturated")).mean(),
((df("social_sales") / df("sales").max()) / df("social_saturated")).mean(),
((df("search_sales") / df("sales").max()) / df("search_saturated")).mean()
)
# Calculate contributions
contributions = np.asarray((
round((df("tv_sales").sum() / df("sales").sum()), 2),
round((df("social_sales").sum() / df("sales").sum()), 2),
round((df("search_sales").sum() / df("sales").sum()), 2),
round((df("demand").sum() / df("sales").sum()), 2)
))
df(("date", "demand", "demand_proxy", "tv_spend_raw", "social_spend_raw", "search_spend_raw", "sales"))
3.2 Model training
Now we are going to retrain the model from the first article. We will prepare the training data in the same way as last time by:
- Split data into characteristics and objective.
- Creating indexes for out-of-time trains and slices.
However, since the focus of this article is not on model calibration, we will include demand as a control variable instead of demand_proxy. This means that the model will be very well calibrated, although this is not very realistic, it will give us some good results to illustrate how we can optimize budgets.
# set date column
date_col = "date"# set outcome column
y_col = "sales"
# set marketing variables
channel_cols = ("tv_spend_raw",
"social_spend_raw",
"search_spend_raw")
# set control variables
control_cols = ("demand")
# create arrays
x = df((date_col) + channel_cols + control_cols)
y = df(y_col)
# set test (out-of-sample) length
test_len = 8
# create train and test indexs
train_idx = slice(0, len(df) - test_len)
out_of_time_idx = slice(len(df) - test_len, len(df))
mmm_default = MMM(
adstock=GeometricAdstock(l_max=8),
saturation=LogisticSaturation(),
date_column=date_col,
channel_columns=channel_cols,
control_columns=control_cols,
)
fit_kwargs = {
"tune": 1_000,
"chains": 4,
"draws": 1_000,
"target_accept": 0.9,
}
mmm_default.fit(x(train_idx), y(train_idx), **fit_kwargs)
3.3 Model validation
Before we get into optimization, let's check that our model fits well. First we verify the true contributions:
channels = np.array(("tv", "social", "search", "demand"))true_contributions = pd.DataFrame({'Channels': channels, 'Contributions': contributions})
true_contributions= true_contributions.sort_values(by='Contributions', ascending=False).reset_index(drop=True)
true_contributions = true_contributions.style.bar(subset=('Contributions'), color='lightblue')
true_contributions
As expected, our model aligns very closely with the true contributions:
mmm_default.plot_waterfall_components_decomposition(figsize=(10,6));
3.4 Response curves
Before we get into budget optimization, let's take a look at the response curves. There are two ways to view the response curves in the pymc marketing package:
- Direct response curves
- Cost-sharing response curves
Let's start with direct response curves. In direct response curves, we simply create a scatterplot of weekly spend versus weekly contribution for each channel.
Below we plot the direct response curves:
fig = mmm_default.plot_direct_contribution_curves(show_fit=True, xlim_max=1.2)
(ax.set(xlabel="spend") for ax in fig.axes);
Shared cost response curves are an alternative way to compare channel effectiveness. When δ = 1.0, the channel expenditure remains at the same level as the training data. When δ = 1.2, the channel spend increases by 20%.
Below we plot the cost-sharing response curves:
mmm_default.plot_channel_contributions_grid(start=0, stop=1.5, num=12, figsize=(15, 7));
We can also change the x axis to show absolute spending values:
mmm_default.plot_channel_contributions_grid(start=0, stop=1.5, num=12, absolute_xrange=True, figsize=(15, 7));
Response curves are great tools to help think about planning future marketing budgets at the channel level. Then let's get into action and run some budget optimization scenarios!
3.5 Budget optimization
To start, let's set a couple of parameters:
- Perc_change: This is used to set the restriction around min and max spend on each channel. This restriction helps us keep the scenario realistic and means we don't extrapolate the response curves too far from what the model has seen in training.
- Budget_len: This is the duration of the budget scenario in weeks.
We will begin by using the desired duration of the budget scenario to select the most recent period of data.
perc_change = 0.20
budget_len = 12
budget_idx = slice(len(df) - test_len, len(df))
recent_period = x(budget_idx)(channel_cols)recent_period
We then use this recent period to set overall budget constraints and channel constraints at a weekly level:
# set overall budget constraint (to the nearest £1k)
budget = round(recent_period.sum(axis=0).sum() / budget_len, -3)# record the current budget split by channel
current_budget_split = round(recent_period.mean() / recent_period.mean().sum(), 2)
# set channel level constraints
lower_bounds = round(recent_period.min(axis=0) * (1 - perc_change))
upper_bounds = round(recent_period.max(axis=0) * (1 + perc_change))
budget_bounds = {
channel: (lower_bounds(channel), upper_bounds(channel))
for channel in channel_cols
}
print(f'Overall budget constraint: {budget}')
print('Channel constraints:')
for channel, bounds in budget_bounds.items():
print(f' {channel}: Lower Bound = {bounds(0)}, Upper Bound = {bounds(1)}')
Now it's time to run our scenario! We feed ourselves with the relevant data and parameters and recover the optimal expenditure. We compare this to taking the total budget and dividing it by the actual divided proportions of the budget (which we have called actual spending).
model_granularity = "weekly"# run scenario
allocation_strategy, optimization_result = mmm_default.optimize_budget(
budget=budget,
num_periods=budget_len,
budget_bounds=budget_bounds,
minimize_kwargs={
"method": "SLSQP",
"options": {"ftol": 1e-9, "maxiter": 5_000},
},
)
response = mmm_default.sample_response_distribution(
allocation_strategy=allocation_strategy,
time_granularity=model_granularity,
num_periods=budget_len,
noise_level=0.05,
)
# extract optimal spend
opt_spend = pd.Series(allocation_strategy, index=recent_period.mean().index).to_frame(name="opt_spend")
opt_spend("avg_spend") = budget * current_budget_split
# plot actual vs optimal spend
fig, ax = plt.subplots(figsize=(9, 4))
opt_spend.plot(kind='barh', ax=ax, color=('blue', 'orange'))
plt.xlabel("Spend")
plt.ylabel("Channel")
plt.title("Actual vs Optimal Spend by Channel")
plt.legend(("Optimal Spend", "Actual Spend"))
plt.legend(("Optimal Spend", "Actual Spend"), loc='lower right', bbox_to_anchor=(1.5, 0.0))
plt.show()
We can see that the suggestion is to move the budget from digital channels to television. But what is the impact on sales?
To calculate the optimal spend contribution, we need to feed the new spend value per channel plus any other variables into the model. We only have demand, so we feed the average value of the recent period for this. We will also calculate the average expense contribution in the same way.
# create dataframe with optimal spend
last_date = mmm_default.x("date").max()
new_dates = pd.date_range(start=last_date, periods=1 + budget_len, freq="W-MON")(1:)
budget_scenario_opt = pd.DataFrame({"date": new_dates,})
budget_scenario_opt("tv_spend_raw") = opt_spend("opt_spend")("tv_spend_raw")
budget_scenario_opt("social_spend_raw") = opt_spend("opt_spend")("social_spend_raw")
budget_scenario_opt("search_spend_raw") = opt_spend("opt_spend")("search_spend_raw")
budget_scenario_opt("demand") = x(budget_idx)(control_cols).mean()(0)# calculate overall contribution
scenario_contrib_opt = mmm_default.sample_posterior_predictive(
X_pred=budget_scenario_opt, extend_idata=False
)
opt_contrib = scenario_contrib_opt.mean(dim="sample").sum()("y").values
# create dataframe with avg spend
last_date = mmm_default.x("date").max()
new_dates = pd.date_range(start=last_date, periods=1 + budget_len, freq="W-MON")(1:)
budget_scenario_avg = pd.DataFrame({"date": new_dates,})
budget_scenario_avg("tv_spend_raw") = opt_spend("avg_spend")("tv_spend_raw")
budget_scenario_avg("social_spend_raw") = opt_spend("avg_spend")("social_spend_raw")
budget_scenario_avg("search_spend_raw") = opt_spend("avg_spend")("search_spend_raw")
budget_scenario_avg("demand") = x(budget_idx)(control_cols).mean()(0)
# calculate overall contribution
scenario_contrib_avg = mmm_default.sample_posterior_predictive(
X_pred=budget_scenario_avg , extend_idata=False
)
avg_contrib = scenario_contrib_avg.mean(dim="sample").sum()("y").values
# calculate % increase in sales
print(f'% increase in sales: {round((opt_contrib / avg_contrib) - 1, 2)}')
Optimal spending gives us a 6% increase in sales! That's impressive, especially since we've set the overall budget!
Today we have seen how powerful budget optimization can be. Can assist organizations with monthly/quarterly/annual budget planning and forecasting. As always, the key to making good recommendations is once again having a robust and well-calibrated model.