Skip to content

Create wrapper to make budget optimizer compatible with multidimensional class #1652

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,815 changes: 1,724 additions & 1,091 deletions docs/source/notebooks/mmm/mmm_multidimensional_example.ipynb

Large diffs are not rendered by default.

15 changes: 12 additions & 3 deletions pymc_marketing/mmm/budget_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,9 @@ class BudgetOptimizer(BaseModel):
def __init__(self, **data):
super().__init__(**data)
# 1. Prepare model with time dimension for optimization
pymc_model = self.mmm_model._set_predictors_for_optimization(self.num_periods)
pymc_model = self.mmm_model._set_predictors_for_optimization(
self.num_periods
) # TODO: Once multidimensional class becomes the main class.

# 2. Shared variable for total_budget: Use annotation to avoid type checking
self._total_budget: SharedVariable = shared(
Expand Down Expand Up @@ -270,13 +272,20 @@ def _replace_channel_data_by_optimization_variable(self, model: Model) -> Model:
repeated_budgets_with_carry_over_shape.insert(
date_dim_idx, num_periods + max_lag
)

# Get the dtype from the model's channel_data to ensure type compatibility
channel_data_dtype = model["channel_data"].dtype

repeated_budgets_with_carry_over = pt.zeros(
repeated_budgets_with_carry_over_shape
repeated_budgets_with_carry_over_shape,
dtype=channel_data_dtype, # Use the same dtype as channel_data
)
set_idxs = (*((slice(None),) * date_dim_idx), slice(None, num_periods))
repeated_budgets_with_carry_over = repeated_budgets_with_carry_over[
set_idxs
].set(repeated_budgets)
].set(
pt.cast(repeated_budgets, channel_data_dtype)
) # Cast to ensure type compatibility
repeated_budgets_with_carry_over.name = "repeated_budgets_with_carry_over"

# Freeze dims & data in the underlying PyMC model
Expand Down
140 changes: 135 additions & 5 deletions pymc_marketing/mmm/multidimensional.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import json
import warnings
from collections.abc import Sequence
from copy import deepcopy
from typing import Any, Literal

Expand All @@ -29,9 +30,11 @@
import xarray as xr
from pymc.model.fgraph import clone_model as cm
from pymc.util import RandomState
from scipy.optimize import OptimizeResult

from pymc_marketing.mmm import SoftPlusHSGP
from pymc_marketing.mmm.additive_effect import MuEffect, create_event_mu_effect
from pymc_marketing.mmm.budget_optimizer import OptimizerCompatibleModelWrapper
from pymc_marketing.mmm.components.adstock import (
AdstockTransformation,
adstock_from_dict,
Expand All @@ -45,6 +48,11 @@
from pymc_marketing.mmm.plot import MMMPlotSuite
from pymc_marketing.mmm.scaling import Scaling, VariableScaling
from pymc_marketing.mmm.tvp import infer_time_index
from pymc_marketing.mmm.utility import UtilityFunctionType, average_response
from pymc_marketing.mmm.utils import (
add_noise_to_channel_allocation,
create_zero_dataset,
)
from pymc_marketing.model_builder import ModelBuilder, _handle_deprecate_pred_argument
from pymc_marketing.model_config import parse_model_config
from pymc_marketing.model_graph import deterministics_to_flat
Expand Down Expand Up @@ -947,12 +955,12 @@

## Hot fix for target data meanwhile pymc allows for internal scaling `https://github.com/pymc-devs/pymc/pull/7656`
target_dim_handler = create_dim_handler(("date", *self.dims))
target_data_scaled = pm.Deterministic(
name="target_scaled",
var=_target
/ target_dim_handler(_target_scale, self.scalers._target.dims),
dims=("date", *self.dims),

target_data_scaled = _target / target_dim_handler(

Check warning on line 959 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L959

Added line #L959 was not covered by tests
_target_scale, self.scalers._target.dims
)
target_data_scaled.name = "target_data_scaled"
target_data_scaled.dims = ("date", *self.dims)

Check warning on line 963 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L962-L963

Added lines #L962 - L963 were not covered by tests

for mu_effect in self.mu_effects:
mu_effect.create_data(self)
Expand Down Expand Up @@ -1417,3 +1425,125 @@
# Update with additional keyword arguments
sampler_config.update(kwargs)
return sampler_config


class MultiDimensionalBudgetOptimizerWrapper(OptimizerCompatibleModelWrapper):
"""Wrapper for the BudgetOptimizer to handle multi-dimensional model."""

def __init__(self, model: MMM, start_date: str, end_date: str):
self.model_class = model
self.start_date = start_date
self.end_date = end_date

Check warning on line 1436 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1434-L1436

Added lines #L1434 - L1436 were not covered by tests
# Compute the number of periods to allocate budget for
self.zero_data = create_zero_dataset(

Check warning on line 1438 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1438

Added line #L1438 was not covered by tests
model=self.model_class, start_date=start_date, end_date=end_date
)
self.num_periods = len(self.zero_data[self.model_class.date_column].unique())

Check warning on line 1441 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1441

Added line #L1441 was not covered by tests
# Adding missing dependencies for compatibility with BudgetOptimizer
self._channel_scales = 1.0

Check warning on line 1443 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1443

Added line #L1443 was not covered by tests

def __getattr__(self, name):
"""Delegate attribute access to the wrapped MMM model."""
try:

Check warning on line 1447 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1447

Added line #L1447 was not covered by tests
# First, try to get the attribute from the wrapper itself
return object.__getattribute__(self, name)
except AttributeError:

Check warning on line 1450 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1449-L1450

Added lines #L1449 - L1450 were not covered by tests
# If not found, delegate to the wrapped model
try:
return getattr(self.model_class, name)
except AttributeError as e:

Check warning on line 1454 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1452-L1454

Added lines #L1452 - L1454 were not covered by tests
# Raise an AttributeError if the attribute is not found in either
raise AttributeError(

Check warning on line 1456 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1456

Added line #L1456 was not covered by tests
f"'{type(self).__name__}' object and its wrapped 'MMM' object have no attribute '{name}'"
) from e

def _set_predictors_for_optimization(self, num_periods: int) -> pm.Model:
"""Return the respective PyMC model with any predictors set for optimization."""
# Use the model's method for transformation
dataset_xarray = self._posterior_predictive_data_transformation(

Check warning on line 1463 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1463

Added line #L1463 was not covered by tests
X=self.zero_data,
include_last_observations=False,
)

# Use the model's method to set data
pymc_model = self._set_xarray_data(

Check warning on line 1469 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1469

Added line #L1469 was not covered by tests
dataset_xarray=dataset_xarray,
clone_model=True, # Ensure we work on a clone
)

# Use the model's mu_effects and set data using the model instance
for mu_effect in self.mu_effects:
mu_effect.set_data(self, pymc_model, dataset_xarray)

Check warning on line 1476 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1475-L1476

Added lines #L1475 - L1476 were not covered by tests

return pymc_model

Check warning on line 1478 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1478

Added line #L1478 was not covered by tests

def optimize_budget(
self,
budget: float | int,
budget_bounds: xr.DataArray | dict[str, tuple[float, float]] | None = None,
response_variable: str = "total_media_contribution_original_scale",
utility_function: UtilityFunctionType = average_response,
constraints: Sequence[dict[str, Any]] = (),
default_constraints: bool = True,
**minimize_kwargs,
) -> tuple[xr.DataArray, OptimizeResult]:
"""Optimize the budget allocation for the model."""
from pymc_marketing.mmm.budget_optimizer import BudgetOptimizer

Check warning on line 1491 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1491

Added line #L1491 was not covered by tests

allocator = BudgetOptimizer(

Check warning on line 1493 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1493

Added line #L1493 was not covered by tests
num_periods=self.num_periods,
utility_function=utility_function,
response_variable=response_variable,
custom_constraints=constraints,
default_constraints=default_constraints,
model=self, # Pass the wrapper instance itself to the BudgetOptimizer
)

return allocator.allocate_budget(

Check warning on line 1502 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1502

Added line #L1502 was not covered by tests
total_budget=budget,
budget_bounds=budget_bounds,
**minimize_kwargs,
)

def sample_response_distribution(
self,
allocation_strategy: xr.DataArray,
noise_level: float = 0.001,
) -> az.InferenceData:
"""Generate synthetic dataset and sample posterior predictive based on allocation.

Parameters
----------
allocation_strategy : DataArray
The allocation strategy for the channels.
noise_level : float
The relative level of noise to add to the data allocation.

Returns
-------
az.InferenceData
The posterior predictive samples based on the synthetic dataset.
"""
data = create_zero_dataset(

Check warning on line 1527 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1527

Added line #L1527 was not covered by tests
model=self,
start_date=self.start_date,
end_date=self.end_date,
channel_xr=allocation_strategy.to_dataset(dim="channel"),
)

data_with_noise = add_noise_to_channel_allocation(

Check warning on line 1534 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1534

Added line #L1534 was not covered by tests
df=data,
channels=self.channel_columns,
rel_std=noise_level,
seed=42,
)

constant_data = allocation_strategy.to_dataset(name="allocation")

Check warning on line 1541 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1541

Added line #L1541 was not covered by tests

return self.sample_posterior_predictive(

Check warning on line 1543 in pymc_marketing/mmm/multidimensional.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/multidimensional.py#L1543

Added line #L1543 was not covered by tests
X=data_with_noise,
extend_idata=False,
include_last_observations=True,
var_names=["y", "channel_contribution_original_scale"],
progressbar=False,
).merge(constant_data)
Loading
Loading