Budget Allocation with PyMC-Marketing — Open Source Marketing Analytics Solution (original) (raw)

The purpose of this notebook is to explore the recently included function in the PyMC-Marketing library that focuses on budget allocation. This function’s underpinnings are based on the methodologies inspired by Bolt work in his article, “Budgeting with Bayesian Models”.

Prerequisite Knowledge#

The notebook assumes the reader has knowledge of the essential functionalities of PyMC-Marketing. If one is unfamiliar, the “MMM Example Notebook” serves as an excellent starting point, offering a comprehensive introduction to media mix models in this context.

Context#

The emphasis of this notebook is on enhancing marketing budgets. Contrarily to broader issues addressed in prior notebooks, our primary aim here is to unravel specialized knowledge on budget allocation tactics using the functionality.

Objectives#

To elucidate more efficient ways of resource allocation across diverse media channels. To deliver data-driven, actionable insights for budgeting decisions.

Introducing the budget allocator#

This notebook instigates an examination of the function within the PyMC-Marketing library, which addresses these challenges using Bayesian models. The function intends to provide:

  1. Quantitative measures of the effectiveness of different media channels.
  2. Probabilistic ROI estimates under a range of budget scenarios.

What to Anticipate#

Upon completing this notebook, readers should get a comprehensive understanding of the budget allocation function. They will then be equipped to incorporate this analytic tool into their marketing analytics routines for data-driven decision-making.

Installing PyMC-Marketing#

Before delving into the specifics of budget allocation, the initial step is to install the PyMC-Marketing library and ascertain its version. This step will confirm support for the budget allocation function. The following pip command can be run on your Jupyter Notebook:

Basic Setup#

Like previous notebooks revolving around PyMC-Marketing, this relies on a specific library set. Here are the requisite imports necessary for executing the provided code snippets subsequently.

import warnings

import arviz as az import matplotlib.pyplot as plt import numpy as np import pandas as pd

from pymc_marketing.mmm import MMM

warnings.filterwarnings("ignore")

az.style.use("arviz-darkgrid") plt.rcParams["figure.figsize"] = [12, 7] plt.rcParams["figure.dpi"] = 100

%load_ext autoreload %autoreload 2 %config InlineBackend.figure_format = "retina"

These imports and configurations form the fundamental setup necessary for the entire span of this notebook.

The expectation is that a model has already been trained using the functionalities provided in prior versions of the PyMC-Marketing library. Thus, the data generation and training processes will be replicated in a different notebook. Those unfamiliar with these procedures are advised to refer to the “MMM Example Notebook.”

Employing ModelBuilder: A Feature in PyMC-Marketing#

The ModelBuilder feature, introduced in version 0.2.0 of PyMC-Marketing, empowers users to easily save and load pre-trained models. The capability to load a pre-existing model is especially advantageous for accelerating analyses, mainly when dealing with expansive data sets or intricate models.

Saving Model#

Once the model has been trained, it is easy to save for later use. An example of the “.save” method is demonstrated below to store the model at a designated location.

Loading a Pre-Trained Model#

To utilize a saved model, load it into a new instance of the MMM class using the load method below.

mmm = MMM.load("model.nc")

For more details on the save() and load() methods, consult the pymc-marketing documentation on Model Deployment.

Alternatively, load a model that has been saved to MLflow via pymc_marketing.mlflow.log_inference_data or has been autologged to MLflow via pymc_marketing.mlflow.autolog(log_mmm=True), from the PyMC-Marketing MLflow module.

If you have a hosted MLflow server, you will of course need to authenticate first.

RUN_ID = "your_run_id"

from pymc_marketing.mlflow import load_mmm

mmm = load_mmm(RUN_ID)

# Load the full model with the InferenceData

mmm = load_mmm(

run_id=RUN_ID, # The MLflow run ID from which to load the model

full_model=True, # Set to True to get the full MMM model with InferenceData

keep_idata=True, # Set to True if you want to keep the downloaded InferenceData saved locally

)

Problem Statement#

Before jumping into the data, let’s first define the business problem we are trying to solve.In a progressively competitive scenario, marketers are tasked with distributing a predetermined marketing budget across various channels for optimizing Return on Investment (ROI). Consider a forthcoming quarter wherein a marketing team must decide the division of its operations between two advertising channels, represented as x1 and x2. These could effectively symbolize any medium, such as TV, digital advertising, print, etc.

The task lies in making decisions that invoke data, comply with factual evidence, and align with business logic. For instance, how can one incorporate prior information like budget restrictions, platform trends, constraints, or even distinctive features of each channel into the decision-making process?

Introducing Budget Allocation Function#

The updated budget allocation function in PyMC-Marketing aims to tackle this issue by offering a Bayesian framework for optimal allocation. This enables marketers to:

By utilizing this function, marketers can guarantee that the budget spread not only obeys the mathematical rigor furnished by the MMM outcomes but also incorporates business-specific factors, thereby achieving a balanced and optimized budget plan.

Getting started#

Media Mix Modeling (MMM) acts as a dependable method to estimate the contribution of each channel (e.g., x1, x2) to a target variable like sales or any variable. The function plot_direct_contribution_curves() allows for visualization of this direct channel impact. However, it is crucial to remember that this only unveils the “observable space” for values of X (spend) and Y (contribution).

response_curve_fig = mmm.plot_direct_contribution_curves();

../../_images/ee84ab74870066df7486905d2e03557fa4a0ff52cdeeacf2baf5cd99ce816265.png

The observable space only encompasses our data points and does not illustrate what transpires beyond those points. As a result, it is not assured that the maximum contribution point for each channel lies within this observable range.

By using the xlim_max parameter, we can predict the shape of the model fitting curve for the amount spent that was not previously observed.

mmm.plot_direct_contribution_curves(show_fit=True, xlim_max=1.5);

../../_images/395d529dd4a0dcf4ffcc4bea5340492663b529bf86f8d32f7b21ef37a6124585.png

The fit of the model comes from the saturation function selected at the time of training. You can verify this by directly accessing the model method and plotting the class name.

print(f"Model was train using the {mmm.saturation.class.name} function") print(f"and the {mmm.adstock.class.name} function")

Model was train using the LogisticSaturation function and the GeometricAdstock function

Within PyMC-Marketing we have different saturation functions, you can observe all in the transformer module.

Let’s understand a few saturation functions with a few examples!

Understanding Saturation Functions: Sigmoid and Michaelis-Menten#

What do we mean by saturation functions? We assume the effect of spend on sales is not linear and saturates at some point. Two prevalent functions deployed to comprehend and estimate the saturation effects in advertising channels are the sigmoid and the Michaelis-Menten functions.

Sigmoid Function#

The sigmoid function is formulated as:

\[ \beta \cdot \frac{\exp(-\lambda x)}{1 + \exp(-\lambda x)} \]

Key Elements:

Michaelis-Menten Function#

The Michaelis-Menten function is formulated as:

\[ \frac{\alpha \times x}{\lambda + x} \]

Key Elements:

Which Function to Use?#

The preference between the sigmoid and Michaelis-Menten functions ought to be steered by the data’s goodness of fit. But it really comes down to your assumptions about where the peak might be and the speed at which it saturates the curve.

Tip: When you’re choosing a saturation function, it’s helpful to consider the data you already have. While saturation functions may have different parameterizations, they all have the same effect. If you already know about any relationship between spending and response, it’s a good idea to pick a function with the same parameter values. This will help you understand and analyze its operation and outcomes better, which is essential within the Bayesian framework.

Once these parameters are obtained, you can visualize it using the arviz.summary function (each parameter has the prefix saturation or adstock respectively) and, if desired, you can recreate the curves for each channel independently based on them. More crucially, these parameter values are indispensable when using the budget_allocator function, which leverages this information to optimize your marketing budget across distinct channels. This section is fundamental to budget optimization.

az.summary( data=mmm.fit_result, var_names=[ "saturation_beta", "saturation_lam", "adstock_alpha", ], )

mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
saturation_beta[x1] 0.362 0.020 0.326 0.403 0.000 0.000 2108.0 2403.0 1.00
saturation_beta[x2] 0.269 0.081 0.192 0.396 0.003 0.002 1301.0 1057.0 1.01
saturation_lam[x1] 3.948 0.381 3.228 4.653 0.008 0.005 2455.0 2316.0 1.00
saturation_lam[x2] 3.137 1.155 1.092 5.325 0.030 0.021 1341.0 1108.0 1.01
adstock_alpha[x1] 0.402 0.031 0.345 0.461 0.001 0.000 2441.0 2597.0 1.00
adstock_alpha[x2] 0.187 0.040 0.109 0.262 0.001 0.001 1798.0 1972.0 1.00

Example Use-Cases#

The budget_allocator function within PyMC-Marketing boasts a myriad of applications that can solve various business predicaments. Here, we present five critical use cases that exemplify its utility in real-world marketing scenarios.

What are we optimizing?#

Before jumping into the examples, we need to understand the basis of our optimizer.

We aim to optimize the allocation of budgets across multiple channels to maximize the overall contribution to key performance indicators (KPIs), such as sales or conversions. Each channel has its own sigmoid or michaelis-menten curve, representing the relationship between the amount spent and the resultant performance.

These curves vary in characteristics: some channels saturate quickly, meaning that additional spending yields diminishing returns, while others may offer more linear growth in contribution with increased spending.

To solve this optimization problem, we employ the Sequential Least Squares Quadratic Programming (SLSQP) algorithm, a gradient-based optimization technique. SLSQP is well-suited for this application as it allows for the imposition of both equality and inequality constraints, ensuring that the budget allocation adheres to business rules or limitations.

The algorithm works by iteratively approximating the objective function and constraints using quadratic functions and solving the resulting sub-problems to find a local minimum. This enables us to effectively navigate the multidimensional space of budget allocations to find the most efficient distribution of resources.

The optimizer aims to maximize the total contribution from all channels while adhering to the following constraints:

  1. Budget Limitations: The total spending across all channels should not exceed the overall marketing budget.
  2. Channel-specific Constraints: Some channels may have minimum or maximum spending limits.

By leveraging the SLSQP algorithm, we can optimize the multi-channel budget allocation in a rigorous, mathematically sound manner, ensuring that we get the highest possible return on investment.

Maximizing Contribution#

Assume you’re managing the marketing for a retail company with a substantial budget to allocate for advertising across multiple channels. Suppose you’re already apportioning funds to channels x1 and x2. Still, you’re contemplating ways to optimize the forthcoming quarter’s outlay to maximize the overall contribution.

Without, you might have considered scattering your money linearly without an MMM model - equal investments in each channel. However, you wish to explore better alternatives now that you possess an MMM model. Given that you lack prior knowledge, you impose the same restrictions on both channels. They must each expend a minimum of 1 million euros and no more than 5 million, equating to your total budget.

from pymc_marketing.mmm.budget_optimizer import optimizer_xarray_builder

time_unit_budget = 5 # Imagine is 5K or 5M campaign_period = 8 # Imagine 8 weeks for this case print( f"Total budget for the {campaign_period} Weeks: {time_unit_budget * campaign_period}M" )

Define your channels

channels = ["x1", "x2"]

The initial split per channel

budget_per_channel = time_unit_budget / len(channels)

Initial budget per channel as dictionary.

initial_budget = optimizer_xarray_builder( np.array([budget_per_channel, budget_per_channel]), channel=["x1", "x2"], ) # Using this function we can create the iniall allocation strategy for each channel

bounds for each channel

min_budget, max_budget = 1, 5 budget_bounds = optimizer_xarray_builder( np.array([[min_budget, max_budget], [min_budget, max_budget]]), channel=["x1", "x2"], bound=["lower", "upper"], ) # Using this function we can create a budget bounds for each channel as well

Total budget for the 8 Weeks: 40M

We can use our function and see the results with this information saved.

model_granularity = "weekly" allocation_strategy, optimization_result = mmm.optimize_budget( budget=time_unit_budget, num_periods=campaign_period, budget_bounds=None, )

response = mmm.sample_response_distribution( # allocation_strategy can be a dict or an xarray.DataArray # (e.g. optimizer_xarray_builder(value=[1, 2], channel=["x1", "x2"])) allocation_strategy=allocation_strategy, time_granularity=model_granularity, num_periods=campaign_period, noise_level=0.01, )

print("\n=== Budget Allocation Summary ===\n") for channel, budget in response.allocation.to_dataframe().iterrows(): print(f"Channel {channel:>2}: {budget['allocation']:>8.2f}M") print("\n" + "-" * 30) print(f"Total Budget: {sum(response.allocation.to_numpy()):>8.2f}M") print("-" * 30)

=== Budget Allocation Summary ===

Channel x1: 2.80M Channel x2: 2.20M


Total Budget: 5.00M

fig, ax = mmm.plot_budget_allocation(samples=response, figsize=(12, 8)) ax.set_title("Response vs spent per channel", fontsize=18, fontweight="bold");

../../_images/39247d66045a90d53dbbaa802a566379539abefd5a302716529ab3c83d8c2bda.png

fig = mmm.plot_allocated_contribution_by_channel(samples=response) fig.suptitle( "Estimated Contribution per channel over time", fontsize=18, fontweight="bold" );

../../_images/353d5586d245597f34ec97b96dc5a73ad0a7650248846808935ca9bed13cbc10.png

fig, ax = plt.subplots(figsize=(12, 6))

response["y"].mean(dim="sample").plot(ax=ax) ax.fill_between( x=response.y.date.values, y1=response.y.quantile(0.025, dim="sample"), y2=response.y.quantile(0.95, dim="sample"), alpha=0.1, ) ax.set_title("Optimal total marketing contribution", fontsize=18, fontweight="bold");

../../_images/d4c67b35426e973bd8b38cc3c4f38981decd32649c4e020283eb4a182c9721b3.png

These results are expected based on the estimates from the curve and our estimated average contribution from the posterior distribution.

However, based on our main assumptions, how can we ensure this result is optimal? How can we compare this outcome to what we would initially have if we followed our first setup?

initial_budget.sel(channel="x1").item()

last_date = mmm.X["date_week"].max()

New dates starting from last in dataset

n_new = 8 new_dates = pd.date_range(start=last_date, periods=1 + n_new, freq="W-MON")[1:]

initial_budget_scenario = pd.DataFrame( { "date_week": new_dates, } )

Same channel spends as last day

initial_budget_scenario["x1"] = initial_budget.sel(channel="x1").item() initial_budget_scenario["x2"] = initial_budget.sel(channel="x2").item()

Other features

initial_budget_scenario["event_1"] = 0 initial_budget_scenario["event_2"] = 0

initial_budget_scenario["t"] = 0

response_initial_budget = mmm.sample_posterior_predictive( initial_budget_scenario, extend_idata=False )

response_initial_budget

<xarray.Dataset> Size: 352kB Dimensions: (sample: 4000, date: 8) Coordinates:

y_response_original_scale_optimize = ( response["y"] * mmm.get_target_transformer()["scaler"].scale_ )

Plotting optimized response

y_response_original_scale_optimize.mean(dim="sample").plot( label=f"Optimized Response | Daily spent {allocation_strategy.sum():.0f}M" ) plt.fill_between( x=y_response_original_scale_optimize.date.values, y1=y_response_original_scale_optimize.quantile(0.025, dim="sample"), y2=y_response_original_scale_optimize.quantile(0.95, dim="sample"), alpha=0.1, )

Plotting initial budget response

response_initial_budget["y"].mean(dim="sample").plot( label="Initial Budget Response | Daily spent 5.00M" ) plt.fill_between( x=response_initial_budget.y.date.values, y1=response_initial_budget.y.quantile(0.025, dim="sample"), y2=response_initial_budget.y.quantile(0.95, dim="sample"), alpha=0.1, )

Adding labels, legend, and title

plt.xlabel("Date") plt.ylabel("Response") plt.legend() plt.title( "Comparison of Optimized and Initial Budget Responses", fontsize=18, fontweight="bold", );

../../_images/b4d683d4d5d87ba24cfc4d5f7e3fd806891bc4674c59254c554e44efb712c727.png

This information will allow you to compare the optimization results against what could have been your initial configuration and budget distribution. While our budget distribution changes, the contribution estimate remains almost the same for this budget. Could we be spending more than needed? Furthermore, how can we identify the optimal spent amount if this situation arises?

One approach to explore this could be to create various scenarios and observe how our estimated contribution fluctuates according to each scenario.

Creating Budget Scenarios#

Envision the subsequent situation: You’re managing marketing operations for a rapidly growing retail company. The management team has allocated a considerable advertising budget and anticipates substantial results in the next quarter. However, given the uncertainty in economic trends, you are tasked with designing a budget allocation strategy capable of accommodating various scenarios.

Before the advent of a robust MMM model, your approach might have been simplistic yet naive: distribute the funds equally across the two primary channels, x1 and x2. This would result in a linear, evenly split distribution of 2.5 million euros each, given a total budget of 5 million euros.

Nevertheless, with the MMM model now at your disposal, you realize a more sophisticated approach is feasible. You’re eager to investigate how this budget could be optimized across multiple scenarios:

  1. Status Quo Scenario: What if the market stays stable? What is the best allocation?
  2. Growth Scenario: What if the market suddenly becomes more favorable? How should the extra budget be allocated?
  3. Recession Scenario: What if there is a market downturn and the budget gets cut by 40%?

Given that you are treading into uncertain waters, you set certain constraints. Each channel must have a minimum spending of 1 million euros, ensuring base-level visibility. The maximum cap is ±5 million euros (depending on your scenario), respecting the total budget.

scenarios = np.array([0.6, 0.8, 1.2, 1.8])

fig, axes = plt.subplots( nrows=2, ncols=2, figsize=(15, 10), sharex=True, sharey=True, layout="constrained" ) for i, scenario in enumerate(scenarios): tmp_budget = time_unit_budget * scenario print(f"Optimization for budget: {tmp_budget:.2f}M") tmp_allocation_strategy, tmp_optimization_result = mmm.optimize_budget( budget=tmp_budget, num_periods=campaign_period, budget_bounds=budget_bounds, )

tmp_response = mmm.sample_response_distribution(
    allocation_strategy=tmp_allocation_strategy,
    time_granularity=model_granularity,
    num_periods=campaign_period,
    noise_level=0.01,
)

row = i // 2
col = i % 2
_, ax = mmm.plot_budget_allocation(samples=tmp_response, ax=axes[row, col])
ax.set_title(f"Budget Allocation for Scenario {tmp_budget:.0f}M")

fig.suptitle( "Budget Allocation for Different scenarios", fontsize=18, fontweight="bold" );

Optimization for budget: 3.00M

Optimization for budget: 4.00M

Optimization for budget: 6.00M

Optimization for budget: 9.00M

../../_images/220e872ddebbdb0d25d633e31bec85bdaaf61c47d5c92af69dc8795fb5e3b68e.png

The graph indicates that boosting the budget beyond an spend level greater than 3 Million induces extremely marginal changes in the potential outcome. Therefore, one can use the budget detailed in scenario three as a cap for our budget.

However, is this the best method to invest our resources? So far, we have considered general constraints for each channel. However, since our curve saturates and beyond a specific point, it does not significantly elevate its contribution. Wouldn’t it be crucial to incorporate these limitations?

Adding Business or Channel Constraints#

There may be instances where, despite the recommendation to invest more in a particular channel than another, it may not be feasible.

Consider this: Given your current budget, you have already maxed out the number of people you can reach within a specific platform. Therefore, further spending will only increase the frequency without adding new reach. In this scenario, does it make sense to raise the budget? This is a classic example of budget limitations based on platform restrictions.

In such cases, a dictionary can be created with limits for each channel, and the optimization function can be reapplied.

new_budget_bounds = optimizer_xarray_builder( np.array([[0, 4], [0, 3]]), channel=["x1", "x2"], bound=["lower", "upper"], )

constrained_allocation_strategy, constrained_optimization_result = mmm.optimize_budget( budget=time_unit_budget, num_periods=campaign_period, budget_bounds=new_budget_bounds, )

constrained_response = mmm.sample_response_distribution( allocation_strategy=constrained_allocation_strategy, time_granularity=model_granularity, num_periods=campaign_period, noise_level=0.01, )

fig, ax = mmm.plot_budget_allocation(samples=constrained_response, figsize=(12, 8)) ax.set_title("Response vs spent per channel", fontsize=18, fontweight="bold");

../../_images/39247d66045a90d53dbbaa802a566379539abefd5a302716529ab3c83d8c2bda.png

Utilizing bounds is crucial, as all saturation curves in PyMC-Marketing are asymptotic. Therefore, without a constraint on your factors, the optimizer will consistently seek to use 100% of the funds because it will always observe an increase within y (target).

If you have yet to notice, this is why, in the previous example, the optimizer always utilized the whole budget in all scenarios. However, should you provide these limits, the optimizer can verify whether it is necessary to use your entire budget.

Let us examine the subsequent plot closely!

sigmoid_response_curve_fig = mmm.plot_direct_contribution_curves( show_fit=True, xlim_max=3 );

../../_images/28092a436faf80d8f46d4b6c2b4691cf4b6b3073334ded7d135adadc01a1eb5d.png

Although in principle we assign a limit of 1.5 spending for each channel, we can observe that at least for x1 after spending more than 1.2, we enter the plateau effect of the curve where we get almost zero return in y for each unit extra of x.

Additionally, based on the information above, a 9M budget or a 3M budget did not represent a big change in contribution. So let’s adjust the budget as well.

constrained_bounds = optimizer_xarray_builder( np.array([[0, 1.2], [0, 1.5]]), channel=["x1", "x2"], bound=["lower", "upper"], )

limit_constrained_allocation_strategy, limit_constrained_optimization_result = ( mmm.optimize_budget( budget=2, num_periods=campaign_period, budget_bounds=constrained_bounds, ) )

limit_constrained_response = mmm.sample_response_distribution( allocation_strategy=limit_constrained_allocation_strategy, time_granularity=model_granularity, num_periods=campaign_period, noise_level=0.01, )

fig, ax = mmm.plot_budget_allocation( samples=limit_constrained_response, figsize=(12, 8) ) ax.set_title("Response vs spent per channel", fontsize=18, fontweight="bold");

../../_images/61c6353ff827ffa1332cb84dbed1df61c21f23396066b59bfebdebf98d79e4b7.png

y_response_original_scale_optimize = ( limit_constrained_response["y"] * mmm.get_target_transformer()["scaler"].scale_ )

Plotting optimized response

y_response_original_scale_optimize.mean(dim="sample").plot( label=f"Optimized Response | Daily spent {limit_constrained_allocation_strategy.sum():.2f}M" ) plt.fill_between( x=y_response_original_scale_optimize.date.values, y1=y_response_original_scale_optimize.quantile(0.025, dim="sample"), y2=y_response_original_scale_optimize.quantile(0.95, dim="sample"), alpha=0.1, )

Plotting initial budget response

response_initial_budget["y"].mean(dim="sample").plot( label=f"Initial Budget Response | Daily spent {5.00}M" ) plt.fill_between( x=response_initial_budget.y.date.values, y1=response_initial_budget.y.quantile(0.025, dim="sample"), y2=response_initial_budget.y.quantile(0.95, dim="sample"), alpha=0.1, )

Adding labels, legend, and title

plt.xlabel("Date") plt.ylabel("Response") plt.legend() plt.title( "Comparison of Optimized and Initial Budget Responses", fontsize=18, fontweight="bold", );

../../_images/80445dc1e1914b7c62a22ca0d37287ed7a1f4d192c6a93957e28afbb66c2cd36.png

The new result is much clearer. By using less than half of the budget with an optimal distribution, we could achieve the same outcomes as our initial setup, which distributed the budget evenly across all channels. On the other hand, the optimal allocation distributes the budget in levels known by the model, reducing the uncertainty around the estimated impact. This is because the initial allocation was at unprecedented levels of spend in the model, resulting in greater uncertainty.

By default, if you need more prior knowledge about the limits, we recommend using alpha (the plateau) as the limit for each channel.

Please note that the estimate provided assumes consistent spending each week. However, in the field of marketing, even with a fixed spending level, the actual spending can fluctuate based on factors such as the number of people bidding on your ad or viewing ads on a given day.

To account for this unpredictable variation, we have included a parameter called noise_level that allows you to introduce white noise into the projection. This can provide a sense of what the outcome might look like if the recommended budget could potentially fluctuate by a certain extent. The default value for noise_level is 1%, but you can adjust it as needed. In the example below, we have used a value of 10%.

( limit_constrained_allocation_strategy_with_noise, limit_constrained_optimization_result_with_noise, ) = mmm.optimize_budget( budget=2, num_periods=campaign_period, budget_bounds=constrained_bounds, )

limit_constrained_response_with_noise = mmm.sample_response_distribution( allocation_strategy=limit_constrained_allocation_strategy_with_noise, time_granularity=model_granularity, num_periods=campaign_period, noise_level=0.1, )

fig, ax = mmm.plot_budget_allocation( samples=limit_constrained_response_with_noise, figsize=(12, 8) ) ax.set_title("Response vs spent per channel", fontsize=18, fontweight="bold");

../../_images/4ff5265ad472cf2257340ea49a38e2601c8852bbb2ed81ff1223ce4681574f91.png

y_response_original_scale_optimize = ( limit_constrained_response_with_noise["y"] * mmm.get_target_transformer()["scaler"].scale_ )

Plotting optimized response

y_response_original_scale_optimize.mean(dim="sample").plot( label=f"Optimized Response | Daily spent {limit_constrained_allocation_strategy_with_noise.sum():.2f}M" ) plt.fill_between( x=y_response_original_scale_optimize.date.values, y1=y_response_original_scale_optimize.quantile(0.025, dim="sample"), y2=y_response_original_scale_optimize.quantile(0.95, dim="sample"), alpha=0.1, )

Plotting initial budget response

response_initial_budget["y"].mean(dim="sample").plot( label=f"Initial Budget Response | Daily spent {5.00}M" ) plt.fill_between( x=response_initial_budget.y.date.values, y1=response_initial_budget.y.quantile(0.025, dim="sample"), y2=response_initial_budget.y.quantile(0.95, dim="sample"), alpha=0.1, )

Adding labels, legend, and title

plt.xlabel("Date") plt.ylabel("Response") plt.legend() plt.title( "Comparison of Optimized and Initial Budget Responses", fontsize=18, fontweight="bold", );

../../_images/2ddf162af49e327f73b5bc2504a849dc72f78a219b961cacfe69c11299466ff0.png

Benefits and Limitations#

In marketing analytics, curve-fitted Media Mix Models (MMMs) provide enriching insights by simplifying intricate systems, facilitating optimization, aiding scenario planning, and delivering quantifiable metrics for strategy evaluation. Each of these advantages presents compelling reasons to incorporate such models. The optimization consider the delay effect overtime, meaning the optimization is generated across num_periods.

Nevertheless, it is pivotal to acknowledge that these models do possess their own set of limitations. The primary ones are the assumptions of time invariance and generalized behavior, signifying an incomplete comprehension of market dynamics. The no-impact variance assumptions are not specific to the curves, as the model does not account for these effects. Hence, curves should always be carefully considered.

We can explore other methods to optimize our resource differently. What happens if we don’t have an specific budget? What happens if we want to know how much money we need to spend to reach a certain sales?

Creating custom constraints (adjusting towards a target response)#

Another way to approach optimization is to adjust towards a target response. This can be useful if you want to ensure that the response is above a certain level. Instead of optimize a given budget, we can optimize find the right budget to reach a target response.

The following example shows how to create a custom constraint to minimize the budget to reach a target response. In short words, we are asking to the optimizer, what’t the minimum budget to reach a certain response?

import pytensor import pytensor.tensor as pt

from pymc_marketing.mmm.budget_optimizer import BudgetOptimizer from pymc_marketing.mmm.constraints import Constraint from pymc_marketing.mmm.utility import _check_samples_dimensionality

target_response = 5

def mean_response_eq_constraint_fun(budgets_sym, total_budget_sym, optimizer): """Enforces mean_response(budgets_sym) = target_response, i.e. returns (mean_resp - target_response).""" resp_dist = optimizer.extract_response_distribution("total_contributions") mean_resp = pt.mean(_check_samples_dimensionality(resp_dist)) return mean_resp - target_response

def minimize_budget_utility(samples, budgets): return -pt.sum(budgets)

optimizer = BudgetOptimizer( num_periods=campaign_period, model=mmm, response_variable="total_contributions", utility_function=minimize_budget_utility, default_constraints=False, custom_constraints=[ Constraint( key="target_response_constraint", constraint_fun=mean_response_eq_constraint_fun, constraint_type="eq", ) ], )

allocation, res = optimizer.allocate_budget( total_budget=10, budget_bounds=None, )

print("Optimal allocation:", allocation) print("Solver result:", res)

Optimal allocation: <xarray.DataArray (channel: 2)> Size: 16B array([1.05215684, 0.92872675]) Coordinates:

Great the optimization, reveals that with a budget of 1.9M we can reach a response of 5M response. We can also check the response distribution to see how the response would look like. As in other occasions, we can plot and get the full posterior.

resp_dist_sym = optimizer.extract_response_distribution("total_contributions") resp_mean_sym = pt.mean(_check_samples_dimensionality(resp_dist_sym)) test_fn = pytensor.function([optimizer._budgets_flat], resp_mean_sym)

Try different budgets

test_val_1 = test_fn(res.x) print(f"Mean response for {res.x} = {test_val_1}")

Mean response for [1.05215684 0.92872675] = 4.999999999999525

target_constrained_response = mmm.sample_response_distribution( allocation_strategy=allocation, time_granularity=model_granularity, num_periods=campaign_period, noise_level=0.01, )

fig, ax = mmm.plot_budget_allocation( samples=limit_constrained_response, figsize=(12, 8) ) ax.set_title("Response vs spent per channel", fontsize=18, fontweight="bold");

../../_images/61c6353ff827ffa1332cb84dbed1df61c21f23396066b59bfebdebf98d79e4b7.png

Other methods to explore#

Even if the method is promising, use other optimization options which includes the full posterior could be a powerful and interesting solution as it’s described on the following blog “Using bayesian decision making to optimize supply chains”

The current methodology is similar to the ones used on other libraries as Robyn from Meta and Google Lightweight from Google. You can explore the solutions and compare if needed.

Conclusion#

MMM models and methodologies used here are designed to bridge the gap between theoretical rigor and actionable marketing insights. They represent a significant stride towards a more data-driven, analytical approach to marketing budget allocation, which could change how organizations invest in customer acquisition and retention.

Although it is a promising tool, it is essential to highlight that this methodology and software release is still experimental. Like any emerging technology, it comes with inherent limitations and assumptions that users should be aware of. The models can offer actionable insights, but they should be cautiously used and in tandem with various forms of analysis. Context is crucial, and while models aim to encapsulate general trends, they might not account for all nuances.

Consequently, your engagements, feedback, and thoughts are not merely welcomed but actively solicited to make this tool as practical and universally applicable as possible.

%load_ext watermark %watermark -n -u -v -iv -w -p pytensor

Last updated: Sun Jan 19 2025

Python implementation: CPython Python version : 3.10.16 IPython version : 8.31.0

pytensor: 2.26.4

pytensor : 2.26.4 matplotlib : 3.10.0 arviz : 0.20.0 pymc_marketing: 0.10.0 pandas : 2.2.3 numpy : 1.26.4

Watermark: 2.5.0