Skip to content
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

Make optimization based on the entire posterior and not on the marginal mean parameters. #1151

Merged
merged 28 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
279755a
New Budget Optimizer with Risk Assessment
juanitorduz Nov 21, 2024
7fc573e
Final notebooks
cetagostini Nov 2, 2024
aaf48b0
Notebook
cetagostini Nov 2, 2024
94831ae
Notebook index
cetagostini Nov 2, 2024
413987c
Updating tests
cetagostini Nov 2, 2024
b84bad5
Title change
cetagostini Nov 2, 2024
329e7d9
Modify text
cetagostini Nov 2, 2024
4e9f8b8
Notebook and adjustments
cetagostini Nov 11, 2024
ce265dc
Hints
cetagostini Nov 11, 2024
bde3957
Unit tests
cetagostini Nov 11, 2024
6a63a0e
pre-commit
cetagostini Nov 11, 2024
a7ab178
notebook possible issue
cetagostini Nov 11, 2024
c1ab051
seed
cetagostini Nov 12, 2024
4410a1c
pre commit
cetagostini Nov 12, 2024
cc748ee
Changes based on feedback
cetagostini Nov 15, 2024
7fbe4ae
Solving unit tests
cetagostini Nov 15, 2024
6caeebc
Final notebook
cetagostini Nov 19, 2024
9c6b702
Last notebook adjustment to utility
cetagostini Nov 21, 2024
e8e9588
pre-commit
cetagostini Nov 21, 2024
2178565
Updating notebooks
cetagostini Nov 21, 2024
65876fa
Merge branch 'main' into new_budget_optimizer
cetagostini Nov 21, 2024
8dd75d7
support for newer pydantic message
wd60622 Nov 25, 2024
9e0f2b4
support for newer pydantic message for adstock
wd60622 Nov 25, 2024
ec39db6
Merge branch 'main' into new_budget_optimizer
juanitorduz Nov 27, 2024
915ff64
Merge branch 'main' into new_budget_optimizer
wd60622 Dec 2, 2024
d9192ce
Merge branch 'main' into new_budget_optimizer
cetagostini Dec 4, 2024
22f8d26
Requested changes
cetagostini Dec 4, 2024
45334e0
Restoring version from main example notebook
cetagostini Dec 4, 2024
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
1 change: 1 addition & 0 deletions docs/source/notebooks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Here you will find a collection of examples and how-to guides for using PyMC-Mar

mmm/mmm_example
mmm/mmm_budget_allocation_example
mmm/mmm_allocation_assessment
mmm/mmm_lift_test
mmm/mmm_counterfactuals
mmm/mmm_tvp_example
Expand Down
1,351 changes: 1,351 additions & 0 deletions docs/source/notebooks/mmm/mmm_allocation_assessment.ipynb

Large diffs are not rendered by default.

314 changes: 185 additions & 129 deletions docs/source/notebooks/mmm/mmm_budget_allocation_example.ipynb

Large diffs are not rendered by default.

2,483 changes: 1,587 additions & 896 deletions docs/source/notebooks/mmm/mmm_case_study.ipynb

Large diffs are not rendered by default.

Binary file modified docs/source/notebooks/mmm/model.nc
Binary file not shown.
1 change: 1 addition & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,4 @@ dependencies:
- pytest-cov==3.0.0
- pytest-mock
- mlflow
- hatch
178 changes: 136 additions & 42 deletions pymc_marketing/mmm/budget_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Budget optimization module."""

import warnings
from typing import Any
from typing import Any, ClassVar

import numpy as np
import pytensor.tensor as pt
from pydantic import BaseModel, ConfigDict, Field
from pytensor import function
from scipy.optimize import minimize

from pymc_marketing.mmm.components.adstock import AdstockTransformation
from pymc_marketing.mmm.components.saturation import SaturationTransformation
from pymc_marketing.mmm.utility import UtilityFunctionType, average_response


class MinimizeException(Exception):
Expand Down Expand Up @@ -53,9 +57,15 @@
The number of time units.
parameters : dict
A dictionary of parameters for each channel.
scales : np.ndarray
The scale parameter for each channel variable.
response_scaler : float, optional
The scaling factor for the target response variable. Default is 1.
adstock_first : bool, optional
Whether to apply adstock transformation first or saturation transformation first.
Default is True.
utility_function : UtilityFunctionType, optional
The utility function to maximize. Default is the mean of the response distribution.

"""

Expand All @@ -70,19 +80,82 @@
gt=0,
description="The number of time units at time granularity which the budget is to be allocated.",
)
parameters: dict[str, dict[str, dict[str, float]]] = Field(
parameters: dict[str, Any] = Field(
..., description="A dictionary of parameters for each channel."
)
scales: np.ndarray = Field(
..., description="The scale parameter for each channel variable"
)
response_scaler: float = Field(
default=1.0,
description="Scaling factor for the target response variable. Defaults to 1.",
)
adstock_first: bool = Field(
True,
description="Whether to apply adstock transformation first or saturation transformation first.",
)
model_config = ConfigDict(arbitrary_types_allowed=True)

def objective(self, budgets: list[float]) -> float:
response_scaler_sym: pt.TensorVariable = Field(
default=None,
exclude=True,
repr=False,
description="Response scaler tensor variable.",
)

utility_function: UtilityFunctionType = Field(
default=average_response,
description="Utility function to maximize.",
arbitrary_types_allowed=True,
)

DEFAULT_MINIMIZE_KWARGS: ClassVar[dict] = {
"method": "SLSQP",
"options": {"ftol": 1e-9, "maxiter": 1_000},
}

def __init__(self, **data):
super().__init__(**data)
self.response_scaler_sym = pt.as_tensor_variable(self.response_scaler)
self._compiled_functions = {}
self._compile_objective_and_grad()

def _compile_objective_and_grad(self):
"""Compile the objective function and its gradient using symbolic computation."""
budgets_sym = pt.vector("budgets")

_response_distribution = self._estimate_response(budgets=budgets_sym)

response_distribution = _response_distribution.sum(axis=(2, 3)).flatten()

objective_value = -self.utility_function(
samples=response_distribution, budgets=budgets_sym
)

# Compute gradient symbolically
grad_obj = pt.grad(objective_value, budgets_sym)

# Compile the functions
utility_func = function([budgets_sym], objective_value)
grad_func = function([budgets_sym], grad_obj)

# Cache the compiled functions
self._compiled_functions[self.utility_function] = {
"objective": utility_func,
"gradient": grad_func,
}

def _objective(self, budgets: pt.TensorVariable) -> float:
"""Objective function for the budget optimization."""
return self._compiled_functions[self.utility_function]["objective"](
budgets
).item()

def _gradient(self, budgets: pt.TensorVariable) -> pt.TensorVariable:
"""Gradient of the objective function."""
return self._compiled_functions[self.utility_function]["gradient"](budgets)

def _estimate_response(self, budgets: list[float]) -> np.ndarray:
"""Calculate the total response during a period of time given the budgets.

It considers the saturation and adstock transformations.
Expand All @@ -94,36 +167,54 @@

Returns
-------
float
The negative total response value.
np.ndarray
The estimated response distribution.

"""
total_response = 0
first_transform, second_transform = (
(self.adstock, self.saturation)
if self.adstock_first
else (self.saturation, self.adstock)
)
for idx, (_channel, params) in enumerate(self.parameters.items()):
budget = budgets[idx] / self.scales[idx]
first_params = (
params["adstock_params"]
if self.adstock_first
else params["saturation_params"]
)
second_params = (
params["saturation_params"]
if self.adstock_first
else params["adstock_params"]
)
spend = np.full(self.num_periods, budget)
spend_extended = np.concatenate([spend, np.zeros(self.adstock.l_max)])
transformed_spend = second_transform.function(
x=first_transform.function(x=spend_extended, **first_params),
**second_params,
).eval()
total_response += np.sum(transformed_spend)
return -total_response

# Convert scales to a tensor variable when needed
budget = budgets / pt.as_tensor_variable(self.scales)

# Convert parameters to tensor variables if necessary
def convert_params(params):
return {
k: (pt.as_tensor_variable(v) if isinstance(v, np.ndarray) else v)
for k, v in params.items()
}

first_params = convert_params(
self.parameters["adstock_params"]
if self.adstock_first
else self.parameters["saturation_params"]
)
second_params = convert_params(
self.parameters["saturation_params"]
if self.adstock_first
else self.parameters["adstock_params"]
)

spend = pt.tile(budget, (self.num_periods, 1))
spend_extended = pt.concatenate(
[spend, pt.zeros((self.adstock.l_max, spend.shape[1]))], axis=0
)

_response = first_transform.function(x=spend_extended, **first_params)

for param_name, param_value in second_params.items():
if isinstance(param_value, pt.TensorVariable) and param_value.ndim == 3:
param_value = param_value.dimshuffle(0, 1, "x", 2)
second_params[param_name] = param_value

# Multiply by the response_scaler_sym
return (
second_transform.function(x=_response, **second_params)
* self.response_scaler_sym
)

def allocate_budget(
self,
Expand All @@ -136,16 +227,13 @@

The default budget bounds are (0, total_budget) for each channel.

The default constraint is the sum of all budgets should be equal to the total budget.
The default constraint ensures the sum of all budgets equals the total budget.

The optimization is done using the Sequential Least Squares Quadratic Programming (SLSQP) method
and it's constrained such that:
1. The sum of budgets across all channels equals the total available budget.
2. The budget allocated to each individual channel lies within its specified range.

The purpose is to maximize the total expected objective based on the inequality
and equality constraints.

Parameters
----------
total_budget : float
Expand All @@ -161,18 +249,21 @@
Returns
-------
tuple[dict[str, float], float]
The optimal budgets for each channel and the negative total response value.
The optimal budgets for each channel and the optimization result object.

Raises
------
Exception
MinimizeException
If the optimization fails, an exception is raised with the reason for the failure.

"""
if budget_bounds is None:
budget_bounds = {channel: (0, total_budget) for channel in self.parameters}
budget_bounds = {

Check warning on line 261 in pymc_marketing/mmm/budget_optimizer.py

View check run for this annotation

Codecov / codecov/patch

pymc_marketing/mmm/budget_optimizer.py#L261

Added line #L261 was not covered by tests
channel: (0, total_budget) for channel in self.parameters["channels"]
}
warnings.warn(
"No budget bounds provided. Using default bounds (0, total_budget) for each channel.",
UserWarning,
stacklevel=2,
)
elif not isinstance(budget_bounds, dict):
Expand All @@ -182,43 +273,46 @@
constraints = {"type": "eq", "fun": lambda x: np.sum(x) - total_budget}
warnings.warn(
"Using default equality constraint: The sum of all budgets should be equal to the total budget.",
UserWarning,
stacklevel=2,
)
elif not isinstance(custom_constraints, dict):
raise TypeError("`custom_constraints` should be a dictionary.")
else:
constraints = custom_constraints

num_channels = len(self.parameters.keys())
num_channels = len(self.parameters["channels"])
initial_guess = np.ones(num_channels) * total_budget / num_channels
bounds = [
(
(budget_bounds[channel][0], budget_bounds[channel][1])
if channel in budget_bounds
else (0, total_budget)
)
for channel in self.parameters
for channel in self.parameters["channels"]
]

if minimize_kwargs is None:
minimize_kwargs = {
"method": "SLSQP",
"options": {"ftol": 1e-9, "maxiter": 1_000},
}
minimize_kwargs = self.DEFAULT_MINIMIZE_KWARGS.copy()
else:
minimize_kwargs = {**self.DEFAULT_MINIMIZE_KWARGS, **minimize_kwargs}

result = minimize(
fun=self.objective,
fun=self._objective,
x0=initial_guess,
bounds=bounds,
constraints=constraints,
jac=self._gradient,
**minimize_kwargs,
)

if result.success:
optimal_budgets = {
name: budget
for name, budget in zip(self.parameters.keys(), result.x, strict=False)
for name, budget in zip(
self.parameters["channels"], result.x, strict=False
)
}
return optimal_budgets, -result.fun
return optimal_budgets, result
else:
raise MinimizeException(f"Optimization failed: {result.message}")
Loading
Loading