import numpyro
numpyro.enable_x64()
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
"seaborn-v0_8-whitegrid") plt.style.use(
Budget Optimization for Single and Multiple Time Series
In this tutorial, you’ll learn how to use Prophetverse’s budget-optimization module to:
- Allocate daily spend across channels to maximize a key performance indicator (KPI).
- Minimize total spend required to achieve a target KPI.
- Handle both single and multiple time series (e.g., different geographies) seamlessly.
You’ll also see how to switch between different parametrizations without hassle, such as:
- Daily-spend mode: Optimize the exact dollar amount for each day and channel.
- Share-of-budget mode: Fix your overall spending pattern and optimize only the channel shares.
By the end, you’ll know how to pick the right setup for your campaign goals and make adjustments in seconds.
Part 1: Optimizing for a Single Time Series
1.1. Setting Up the Problem
First, let’s set up our environment and load the data for a single time series optimization.
1.1.1. Load synthetic data
We will load a synthetic dataset and a pre-fitted Prophetverse model.
from prophetverse.datasets._mmm.dataset1 import get_dataset
= get_dataset() y, X, lift_tests, true_components, model
1.1.2. Utility plotting functions
This helper function will allow us to compare spend before and after optimization.
Code
def plot_spend_comparison(
X_baseline,
X_optimized,
channels,
indexer,*,
="Baseline Spend: Pre-Optimization",
baseline_title="Optimized Spend: Maximizing KPI",
optimized_title=(8, 4),
figsize
):= plt.subplots(1, 2, figsize=figsize)
fig, ax
=ax[0], linewidth=2)
X_baseline.loc[indexer, channels].plot(ax=ax[1], linewidth=2, linestyle="--")
X_optimized.loc[indexer, channels].plot(ax
0].set_title(baseline_title, fontsize=14, weight="bold")
ax[1].set_title(optimized_title, fontsize=14, weight="bold")
ax[
for a in ax:
"Spend")
a.set_ylabel("Date")
a.set_xlabel(="upper right", frameon=True)
a.legend(loc="x", visible=False)
a.grid(axis="y", linestyle="--", alpha=0.7)
a.grid(axis"%b"))
a.xaxis.set_major_formatter(mdates.DateFormatter(
# Align y-axis
= max(
y_max max().max(),
X_baseline.loc[indexer, channels].max().max(),
X_optimized.loc[indexer, channels].
)for a in ax:
0, y_max * 1.05)
a.set_ylim(
plt.tight_layout()return fig, ax
1.2. Budget Optimization
The budget-optimization module is composed of three main components:
- The objective function: What you want to optimize (e.g., maximize KPI).
- The constraints: Rules the optimization must follow (e.g., total budget).
- The parametrization transform: How the problem is parametrized (e.g., daily spend vs. channel shares).
1.2.1. Maximizing a KPI
The BudgetOptimizer
class is the main entry point. By default, it optimizes the daily spend for each channel to maximize a given KPI.
from prophetverse.budget_optimization import (
BudgetOptimizer,
TotalBudgetConstraint,
MaximizeKPI,
)
= BudgetOptimizer(
budget_optimizer =MaximizeKPI(),
objective=[TotalBudgetConstraint()],
constraints={"disp": True, "maxiter":1000},
options )
Let’s define our optimization horizon:
= pd.period_range("2004-12-01", "2004-12-31", freq="D") horizon
Now, we run the optimization:
import time
= time.time()
start_time = budget_optimizer.optimize(
X_opt =model,
model=X,
X=horizon,
horizon=["ad_spend_search", "ad_spend_social_media"],
columns
)= time.time() - start_time
optimization_time print(f"Optimization completed in {optimization_time:.2f} seconds")
Optimization terminated successfully (Exit mode 0)
Current function value: -754679704.6820625
Iterations: 108
Function evaluations: 125
Gradient evaluations: 104
Optimization completed in 2.71 seconds
Baseline vs. optimized spend
Let’s compare the model’s predictions before and after the optimization.
= model.predict(X=X, fh=horizon)
y_pred_baseline = model.predict(X=X_opt, fh=horizon)
y_pred_opt
= plot_spend_comparison(
fig, ax
X,
X_opt,"ad_spend_search", "ad_spend_social_media"],
[
horizon,
)
= y_pred_opt.sum() / y_pred_baseline.sum() - 1
kpi_gain f"KPI gain: +{kpi_gain:.2%}", fontsize=16,weight="bold", y=1.02)
fig.suptitle(
fig.tight_layout() fig.show()
1.2.3. Minimizing budget to reach a target
We can also change the objective to find the minimum investment required to achieve a specific KPI target. Let’s say we want a 30% increase in KPI compared to 2003.
from prophetverse.budget_optimization import (
MinimizeBudget,
MinimumTargetResponse,
)
= y.loc["2003-12"].sum() * 1.30
target
= BudgetOptimizer(
budget_optimizer_min =MinimizeBudget(),
objective=[MinimumTargetResponse(target_response=target, constraint_type="eq")],
constraints={"disp": True, "maxiter" : 300},
options
)
= X.copy()
X0 = budget_optimizer_min.optimize(
X_opt_min =model,
model=X0,
X=horizon,
horizon=["ad_spend_search", "ad_spend_social_media"],
columns )
Optimization terminated successfully (Exit mode 0)
Current function value: 3796555.321062599
Iterations: 201
Function evaluations: 204
Gradient evaluations: 201
Budget and prediction comparison
plot_spend_comparison(
X0,
X_opt_min,"ad_spend_search", "ad_spend_social_media"],
[=horizon,
indexer
)
plt.show()
= model.predict(X=X0, fh=horizon)
y_pred_baseline_min = model.predict(X=X_opt_min, fh=horizon)
y_pred_opt_min
print(
f"MMM Predictions \n",
f"Baseline KPI: {y_pred_baseline_min.sum()/1e9:.2f} B \n",
f"Optimized KPI: {y_pred_opt_min.sum()/1e9:.2f} B \n",
f"Target KPI: {target/1e9:.2f} B \n",
"Baseline spend: ",
"ad_spend_search", "ad_spend_social_media"]].sum().sum(),
X0.loc[horizon, ["\n",
"Optimized spend: ",
"ad_spend_search", "ad_spend_social_media"]].sum().sum(),
X_opt_min.loc[horizon, ["\n",
)
MMM Predictions
Baseline KPI: 0.73 B
Optimized KPI: 0.97 B
Target KPI: 0.97 B
Baseline spend: 1250679.3427392421
Optimized spend: 3796555.3210625993
Part 2: Optimizing for Multiple Time Series (Panel Data)
The same BudgetOptimizer
can be used for multiple time series (e.g., different geographies) without any changes to the API.
2.1. Setting Up the Problem for Panel Data
The main difference is that for panel data, we use a multi-index DataFrame, following sktime
conventions.
2.1.1. Load synthetic panel data
from prophetverse.datasets._mmm.dataset1_panel import get_dataset
= get_dataset()
y_panel, X_panel, lift_tests_panel, true_components_panel, fitted_model_panel
y_panel
0 | ||
---|---|---|
group | date | |
a | 2000-01-01 | 1.120218e+07 |
2000-01-02 | 1.146048e+07 | |
2000-01-03 | 1.156324e+07 | |
2000-01-04 | 1.161396e+07 | |
2000-01-05 | 1.162758e+07 | |
... | ... | ... |
b | 2004-12-28 | 2.473478e+07 |
2004-12-29 | 2.718986e+07 | |
2004-12-30 | 2.554932e+07 | |
2004-12-31 | 2.343510e+07 | |
2005-01-01 | 2.078426e+07 |
3656 rows × 1 columns
2.1.2. Utility plotting functions for panel data
We’ll define a new plotting function to handle the multi-indexed data.
Code
def plot_spend_comparison_panel(
X_baseline,
X_optimized,
channels,
indexer,*,
="Baseline Spend: Pre-Optimization",
baseline_title="Optimized Spend: Maximizing KPI",
optimized_title=(8, 4),
figsize
):= X_baseline.index.droplevel(-1).unique().tolist()
series_idx = plt.subplots(len(series_idx), 2, figsize=figsize, squeeze=False)
fig, axs
for i, series in enumerate(series_idx):
= X_baseline.loc[series]
_X_baseline = X_optimized.loc[series]
_X_optimized = axs[i]
ax_row =ax_row[0], linewidth=2)
_X_baseline.loc[indexer, channels].plot(ax
_X_optimized.loc[indexer, channels].plot(=ax_row[1], linewidth=2, linestyle="--"
ax
)
0].set_title(f"{series}: {baseline_title}", fontsize=14, weight="bold")
ax_row[1].set_title(f"{series}: {optimized_title}", fontsize=14, weight="bold")
ax_row[
for a in ax_row:
"Spend")
a.set_ylabel("Date")
a.set_xlabel(="upper right", frameon=True)
a.legend(loc="x", visible=False)
a.grid(axis="y", linestyle="--", alpha=0.7)
a.grid(axis"%b"))
a.xaxis.set_major_formatter(mdates.DateFormatter(
= max(
y_max max().max(),
_X_baseline.loc[indexer, channels].max().max(),
_X_optimized.loc[indexer, channels].
)for a in ax_row:
0, y_max * 1.05)
a.set_ylim(
plt.tight_layout()return fig, axs
2.2. Budget Optimization for Panel Data
2.2.1. Maximizing a KPI
By default, BudgetOptimizer
will optimize the daily spend for each channel for each series.
= BudgetOptimizer(
budget_optimizer_panel =MaximizeKPI(),
objective=[TotalBudgetConstraint()],
constraints={"disp": True, "maxiter": 1000},
options
)
= budget_optimizer_panel.optimize(
X_opt_panel =fitted_model_panel,
model=X_panel,
X=horizon,
horizon=["ad_spend_search", "ad_spend_social_media"],
columns )
Optimization terminated successfully (Exit mode 0)
Current function value: -1723014730.7615
Iterations: 198
Function evaluations: 198
Gradient evaluations: 197
Baseline vs. optimized spend
= fitted_model_panel.predict(X=X_panel, fh=horizon)
y_pred_baseline_panel = fitted_model_panel.predict(X=X_opt_panel, fh=horizon)
y_pred_opt_panel
= plot_spend_comparison_panel(
fig, ax
X_panel,
X_opt_panel,"ad_spend_search", "ad_spend_social_media"],
[
horizon,
)
= y_pred_opt_panel.values.sum() / y_pred_baseline_panel.values.sum() - 1
kpi_gain f"Total KPI gain: +{kpi_gain:.2%}", fontsize=16, weight="bold", y=1.03)
fig.suptitle(
fig.tight_layout() fig.show()
2.2.2. Reparametrization for Panel Data
With panel data, we have more reparametrization options.
Optimizing investment per series
This keeps the channel shares fixed within each series but optimizes the allocation of the total budget across the different series.
from prophetverse.budget_optimization.parametrization_transformations import InvestmentPerSeries
= BudgetOptimizer(
budget_optimizer_panel_s =MaximizeKPI(),
objective=[TotalBudgetConstraint()],
constraints=InvestmentPerSeries(),
parametrization_transform={"disp": True},
options
)
= budget_optimizer_panel_s.optimize(
X_opt_panel_s =fitted_model_panel,
model=X_panel,
X=horizon,
horizon=["ad_spend_search", "ad_spend_social_media"],
columns
)# You can plot the results using plot_spend_comparison_panel
Optimization terminated successfully (Exit mode 0)
Current function value: -1680449341.6035411
Iterations: 13
Function evaluations: 13
Gradient evaluations: 13
2.2.3. Minimizing budget to reach a target with Panel Data
Let’s find the minimum budget to achieve a 20% KPI increase across all series.
= y_panel.loc[pd.IndexSlice[:, horizon],].values.sum() * 1.2
target_panel
= BudgetOptimizer(
budget_optimizer_min_panel =MinimizeBudget(),
objective=[MinimumTargetResponse(target_response=target_panel, constraint_type="eq")],
constraints={"disp": True, "maxiter": 300},
options
)
= X_panel.copy()
X0_panel = budget_optimizer_min_panel.optimize(
X_opt_min_panel =fitted_model_panel,
model=X0_panel,
X=horizon,
horizon=["ad_spend_search", "ad_spend_social_media"],
columns )
Optimization terminated successfully (Exit mode 0)
Current function value: 7755666.679734466
Iterations: 285
Function evaluations: 287
Gradient evaluations: 285
Budget and prediction comparison
plot_spend_comparison_panel(
X0_panel,
X_opt_min_panel,"ad_spend_search", "ad_spend_social_media"],
[=horizon,
indexer
)
plt.show()
= fitted_model_panel.predict(X=X0_panel, fh=horizon)
y_pred_baseline_min_panel = fitted_model_panel.predict(X=X_opt_min_panel, fh=horizon)
y_pred_opt_min_panel
print(
f"MMM Predictions \n",
f"Baseline KPI: {y_pred_baseline_min_panel.values.sum()/1e9:.2f} B \n",
f"Optimized KPI: {y_pred_opt_min_panel.values.sum()/1e9:.2f} B \n",
f"Target KPI: {target_panel/1e9:.2f} B \n",
"Baseline spend: ",
X0_panel.loc["ad_spend_search", "ad_spend_social_media"]
pd.IndexSlice[:, horizon], [
]sum()
.sum(),
."\n",
"Optimized spend: ",
X_opt_min_panel.loc["ad_spend_search", "ad_spend_social_media"]
pd.IndexSlice[:, horizon], [
]sum()
.sum(),
."\n",
)
MMM Predictions
Baseline KPI: 1.63 B
Optimized KPI: 1.96 B
Target KPI: 1.96 B
Baseline spend: 4179163.3068621266
Optimized spend: 7755666.679734467
Conclusion
We have seen the capabilities of the budget-optimization module for both single and multiple time series. The three key components are the objective function, the constraints, and the parametrization transform. You can also create your own custom components to tailor the optimization to your specific needs.