Skip to content

Commit f51f9f5

Browse files
authored
Add support for time-based budget distribution in optimizer (#1841)
* Add support for time-based budget distribution in optimizer Introduces the `budget_distribution_over_period` parameter to BudgetOptimizer and MultiDimensionalBudgetOptimizerWrapper, allowing users to specify how budgets are distributed across time periods for each budget dimension. Adds validation, processing, and application logic for these factors, and includes comprehensive tests for correct and incorrect usage, integration, and multidimensional scenarios. * Correction on test * Ensure total spend preserved with time distribution patterns Fixes budget stacking in BudgetOptimizer to account for number of periods and updates MultiDimensionalBudgetOptimizerWrapper to allow additional variable names and merge allocation data. Adds a test to verify that total spend remains consistent with and without time distribution patterns during budget optimization. * Modifying default values to sample
1 parent ca0c420 commit f51f9f5

File tree

4 files changed

+1151
-63
lines changed

4 files changed

+1151
-63
lines changed

pymc_marketing/mmm/budget_optimizer.py

Lines changed: 159 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,10 @@ class BudgetOptimizer(BaseModel):
127127
Custom constraints for the optimizer.
128128
default_constraints : bool, optional
129129
Whether to add a default sum constraint on the total budget. Default is True.
130+
budget_distribution_over_period : xarray.DataArray, optional
131+
Distribution factors for budget allocation over time. Should have dims ("date", *budget_dims)
132+
where date dimension has length num_periods. Values along date dimension should sum to 1 for
133+
each combination of other dimensions. If None, budget is distributed evenly across periods.
130134
"""
131135

132136
num_periods: int = Field(
@@ -169,6 +173,15 @@ class BudgetOptimizer(BaseModel):
169173
description="Whether to add a default sum constraint on the total budget.",
170174
)
171175

176+
budget_distribution_over_period: DataArray | None = Field(
177+
default=None,
178+
description=(
179+
"Distribution factors for budget allocation over time. Should have dims ('date', *budget_dims) "
180+
"where date dimension has length num_periods. Values along date dimension should sum to 1 for "
181+
"each combination of other dimensions. If None, budget is distributed evenly across periods."
182+
),
183+
)
184+
172185
model_config = ConfigDict(arbitrary_types_allowed=True)
173186

174187
DEFAULT_MINIMIZE_KWARGS: ClassVar[dict] = {
@@ -230,16 +243,26 @@ def __init__(self, **data):
230243
bool_mask = np.asarray(self.budgets_to_optimize).astype(bool)
231244
self._budgets = budgets_zeros[bool_mask].set(self._budgets_flat)
232245

233-
# 5. Replace channel_data with budgets in the PyMC model
246+
# 5. Validate and process budget_distribution_over_period
247+
self._budget_distribution_over_period_tensor = (
248+
self._validate_and_process_budget_distribution(
249+
budget_distribution_over_period=self.budget_distribution_over_period,
250+
num_periods=self.num_periods,
251+
budget_dims=self._budget_dims,
252+
budgets_to_optimize=self.budgets_to_optimize,
253+
)
254+
)
255+
256+
# 6. Replace channel_data with budgets in the PyMC model
234257
self._pymc_model = self._replace_channel_data_by_optimization_variable(
235258
pymc_model
236259
)
237260

238-
# 6. Compile objective & gradient
261+
# 7. Compile objective & gradient
239262
self._compiled_functions = {}
240263
self._compile_objective_and_grad()
241264

242-
# 7. Build constraints
265+
# 8. Build constraints
243266
self._constraints = {}
244267
self.set_constraints(
245268
default=self.default_constraints, constraints=self.custom_constraints
@@ -272,6 +295,126 @@ def set_constraints(self, constraints, default=None) -> None:
272295
constraints=self._constraints, optimizer=self
273296
)
274297

298+
def _validate_and_process_budget_distribution(
299+
self,
300+
budget_distribution_over_period: DataArray | None,
301+
num_periods: int,
302+
budget_dims: list[str],
303+
budgets_to_optimize: DataArray,
304+
) -> pt.TensorVariable | None:
305+
"""Validate and process budget distribution over periods.
306+
307+
Parameters
308+
----------
309+
budget_distribution_over_period : DataArray | None
310+
Distribution factors for budget allocation over time.
311+
num_periods : int
312+
Number of time periods to allocate budget for.
313+
budget_dims : list[str]
314+
List of budget dimensions (excluding 'date').
315+
budgets_to_optimize : DataArray
316+
Mask defining which budgets to optimize.
317+
318+
Returns
319+
-------
320+
pt.TensorVariable | None
321+
Processed tensor containing masked time factors, or None if no distribution provided.
322+
"""
323+
if budget_distribution_over_period is None:
324+
return None
325+
326+
# Validate dimensions - date should be first
327+
expected_dims = ("date", *budget_dims)
328+
if set(budget_distribution_over_period.dims) != set(expected_dims):
329+
raise ValueError(
330+
f"budget_distribution_over_period must have dims {expected_dims}, "
331+
f"but got {budget_distribution_over_period.dims}"
332+
)
333+
334+
# Validate date dimension length
335+
if len(budget_distribution_over_period.coords["date"]) != num_periods:
336+
raise ValueError(
337+
f"budget_distribution_over_period date dimension must have length {num_periods}, "
338+
f"but got {len(budget_distribution_over_period.coords['date'])}"
339+
)
340+
341+
# Validate that factors sum to 1 along date dimension
342+
sums = budget_distribution_over_period.sum(dim="date")
343+
if not np.allclose(sums.values, 1.0, rtol=1e-5):
344+
raise ValueError(
345+
"budget_distribution_over_period must sum to 1 along the date dimension "
346+
"for each combination of other dimensions"
347+
)
348+
349+
# Pre-process: Apply the mask to get only factors for optimized budgets
350+
# This avoids shape mismatches during gradient computation
351+
time_factors_full = budget_distribution_over_period.transpose(
352+
*expected_dims
353+
).values
354+
355+
# Reshape to (num_periods, flat_budget_dims) and apply mask
356+
time_factors_flat = time_factors_full.reshape((num_periods, -1))
357+
bool_mask = budgets_to_optimize.values.flatten()
358+
time_factors_masked = time_factors_flat[:, bool_mask]
359+
360+
# Store only the masked tensor
361+
return pt.constant(time_factors_masked, name="budget_distribution_over_period")
362+
363+
def _apply_budget_distribution_over_period(
364+
self,
365+
budgets: pt.TensorVariable,
366+
num_periods: int,
367+
date_dim_idx: int,
368+
) -> pt.TensorVariable:
369+
"""Apply budget distribution over periods to budgets across time periods.
370+
371+
Parameters
372+
----------
373+
budgets : pt.TensorVariable
374+
The scaled budget tensor with shape matching budget dimensions.
375+
num_periods : int
376+
Number of time periods to distribute budget across.
377+
date_dim_idx : int
378+
Index position where the date dimension should be inserted.
379+
380+
Returns
381+
-------
382+
pt.TensorVariable
383+
Budget tensor repeated across time periods with distribution factors applied.
384+
Shape will be (*budget_dims[:date_dim_idx], num_periods, *budget_dims[date_dim_idx:])
385+
"""
386+
# Apply time distribution factors
387+
# The time factors are already masked and have shape (num_periods, num_optimized_budgets)
388+
# budgets has full shape (e.g., (2, 2) for geo x channel)
389+
# We need to extract only the optimized budgets
390+
391+
# Get the optimized budget values
392+
bool_mask = np.asarray(self.budgets_to_optimize).astype(bool)
393+
budgets_optimized = budgets[bool_mask] # Shape: (num_optimized_budgets,)
394+
395+
# Now multiply budgets by time factors
396+
budgets_expanded = pt.expand_dims(
397+
budgets_optimized, 0
398+
) # Shape: (1, num_optimized_budgets)
399+
repeated_budgets_flat = (
400+
budgets_expanded * self._budget_distribution_over_period_tensor
401+
) # Shape: (num_periods, num_optimized_budgets)
402+
403+
# Reconstruct the full shape for each time period
404+
repeated_budgets_list = []
405+
for t in range(num_periods):
406+
# Create a zero tensor with the full budget shape
407+
budgets_t = pt.zeros_like(budgets)
408+
# Set the optimized values
409+
budgets_t = budgets_t[bool_mask].set(repeated_budgets_flat[t])
410+
repeated_budgets_list.append(budgets_t)
411+
412+
# Stack the time periods
413+
repeated_budgets = pt.stack(repeated_budgets_list, axis=date_dim_idx)
414+
repeated_budgets *= num_periods
415+
416+
return repeated_budgets
417+
275418
def _replace_channel_data_by_optimization_variable(self, model: Model) -> Model:
276419
"""Replace `channel_data` in the model graph with our newly created `_budgets` variable."""
277420
num_periods = self.num_periods
@@ -287,10 +430,19 @@ def _replace_channel_data_by_optimization_variable(self, model: Model) -> Model:
287430
# Repeat budgets over num_periods
288431
repeated_budgets_shape = list(tuple(budgets.shape))
289432
repeated_budgets_shape.insert(date_dim_idx, num_periods)
290-
repeated_budgets = pt.broadcast_to(
291-
pt.expand_dims(budgets, date_dim_idx),
292-
shape=repeated_budgets_shape,
293-
)
433+
434+
if self._budget_distribution_over_period_tensor is not None:
435+
# Apply time distribution factors
436+
repeated_budgets = self._apply_budget_distribution_over_period(
437+
budgets, num_periods, date_dim_idx
438+
)
439+
else:
440+
# Default behavior: distribute evenly across periods
441+
repeated_budgets = pt.broadcast_to(
442+
pt.expand_dims(budgets, date_dim_idx),
443+
shape=repeated_budgets_shape,
444+
)
445+
294446
repeated_budgets.name = "repeated_budgets"
295447

296448
# Pad the repeated budgets with zeros to account for carry-over effects

pymc_marketing/mmm/multidimensional.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1772,6 +1772,7 @@ def optimize_budget(
17721772
constraints: Sequence[dict[str, Any]] = (),
17731773
default_constraints: bool = True,
17741774
budgets_to_optimize: xr.DataArray | None = None,
1775+
budget_distribution_over_period: xr.DataArray | None = None,
17751776
callback: bool = False,
17761777
**minimize_kwargs,
17771778
) -> (
@@ -1796,6 +1797,10 @@ def optimize_budget(
17961797
Whether to add default constraints.
17971798
budgets_to_optimize : xr.DataArray | None
17981799
Mask defining which budgets to optimize.
1800+
budget_distribution_over_period : xr.DataArray | None
1801+
Distribution factors for budget allocation over time. Should have dims ("date", *budget_dims)
1802+
where date dimension has length num_periods. Values along date dimension should sum to 1 for
1803+
each combination of other dimensions. If None, budget is distributed evenly across periods.
17991804
callback : bool
18001805
Whether to return callback information tracking optimization progress.
18011806
**minimize_kwargs
@@ -1816,6 +1821,7 @@ def optimize_budget(
18161821
custom_constraints=constraints,
18171822
default_constraints=default_constraints,
18181823
budgets_to_optimize=budgets_to_optimize,
1824+
budget_distribution_over_period=budget_distribution_over_period,
18191825
model=self, # Pass the wrapper instance itself to the BudgetOptimizer
18201826
)
18211827

@@ -1830,6 +1836,7 @@ def sample_response_distribution(
18301836
self,
18311837
allocation_strategy: xr.DataArray,
18321838
noise_level: float = 0.001,
1839+
additional_var_names: list[str] | None = None,
18331840
) -> az.InferenceData:
18341841
"""Generate synthetic dataset and sample posterior predictive based on allocation.
18351842
@@ -1860,11 +1867,26 @@ def sample_response_distribution(
18601867
)
18611868

18621869
constant_data = allocation_strategy.to_dataset(name="allocation")
1863-
1864-
return self.sample_posterior_predictive(
1865-
X=data_with_noise,
1866-
extend_idata=False,
1867-
include_last_observations=True,
1868-
var_names=["y", "channel_contribution_original_scale"],
1869-
progressbar=False,
1870-
).merge(constant_data)
1870+
_dataset = data_with_noise.set_index([self.date_column, *list(self.dims)])[
1871+
self.channel_columns
1872+
].to_xarray()
1873+
1874+
var_names = [
1875+
"y",
1876+
"channel_contribution",
1877+
"total_media_contribution_original_scale",
1878+
]
1879+
if additional_var_names is not None:
1880+
var_names.extend(additional_var_names)
1881+
1882+
return (
1883+
self.sample_posterior_predictive(
1884+
X=data_with_noise,
1885+
extend_idata=False,
1886+
include_last_observations=True,
1887+
var_names=var_names,
1888+
progressbar=False,
1889+
)
1890+
.merge(constant_data)
1891+
.merge(_dataset)
1892+
)

0 commit comments

Comments
 (0)