|
17 | 17 |
|
18 | 18 | import json |
19 | 19 | import warnings |
| 20 | +from collections.abc import Sequence |
20 | 21 | from copy import deepcopy |
21 | 22 | from typing import Any, Literal |
22 | 23 |
|
|
29 | 30 | import xarray as xr |
30 | 31 | from pymc.model.fgraph import clone_model as cm |
31 | 32 | from pymc.util import RandomState |
| 33 | +from scipy.optimize import OptimizeResult |
32 | 34 |
|
33 | 35 | from pymc_marketing.mmm import SoftPlusHSGP |
34 | 36 | from pymc_marketing.mmm.additive_effect import MuEffect, create_event_mu_effect |
| 37 | +from pymc_marketing.mmm.budget_optimizer import OptimizerCompatibleModelWrapper |
35 | 38 | from pymc_marketing.mmm.components.adstock import ( |
36 | 39 | AdstockTransformation, |
37 | 40 | adstock_from_dict, |
|
45 | 48 | from pymc_marketing.mmm.plot import MMMPlotSuite |
46 | 49 | from pymc_marketing.mmm.scaling import Scaling, VariableScaling |
47 | 50 | from pymc_marketing.mmm.tvp import infer_time_index |
| 51 | +from pymc_marketing.mmm.utility import UtilityFunctionType, average_response |
| 52 | +from pymc_marketing.mmm.utils import ( |
| 53 | + add_noise_to_channel_allocation, |
| 54 | + create_zero_dataset, |
| 55 | +) |
48 | 56 | from pymc_marketing.model_builder import ModelBuilder, _handle_deprecate_pred_argument |
49 | 57 | from pymc_marketing.model_config import parse_model_config |
50 | 58 | from pymc_marketing.model_graph import deterministics_to_flat |
@@ -945,14 +953,15 @@ def build_model( |
945 | 953 | channel_data_.name = "channel_data_scaled" |
946 | 954 | channel_data_.dims = ("date", *self.dims, "channel") |
947 | 955 |
|
948 | | - ## Hot fix for target data meanwhile pymc allows for internal scaling `https://github.com/pymc-devs/pymc/pull/7656` |
949 | 956 | target_dim_handler = create_dim_handler(("date", *self.dims)) |
950 | | - target_data_scaled = pm.Deterministic( |
951 | | - name="target_scaled", |
952 | | - var=_target |
953 | | - / target_dim_handler(_target_scale, self.scalers._target.dims), |
954 | | - dims=("date", *self.dims), |
| 957 | + |
| 958 | + target_data_scaled = _target / target_dim_handler( |
| 959 | + _target_scale, self.scalers._target.dims |
955 | 960 | ) |
| 961 | + target_data_scaled.name = "target_scaled" |
| 962 | + target_data_scaled.dims = ("date", *self.dims) |
| 963 | + ## TODO: Find a better way to save it or access it in the pytensor graph. |
| 964 | + self.target_data_scaled = target_data_scaled |
956 | 965 |
|
957 | 966 | for mu_effect in self.mu_effects: |
958 | 967 | mu_effect.create_data(self) |
@@ -1417,3 +1426,125 @@ def create_sample_kwargs( |
1417 | 1426 | # Update with additional keyword arguments |
1418 | 1427 | sampler_config.update(kwargs) |
1419 | 1428 | return sampler_config |
| 1429 | + |
| 1430 | + |
| 1431 | +class MultiDimensionalBudgetOptimizerWrapper(OptimizerCompatibleModelWrapper): |
| 1432 | + """Wrapper for the BudgetOptimizer to handle multi-dimensional model.""" |
| 1433 | + |
| 1434 | + def __init__(self, model: MMM, start_date: str, end_date: str): |
| 1435 | + self.model_class = model |
| 1436 | + self.start_date = start_date |
| 1437 | + self.end_date = end_date |
| 1438 | + # Compute the number of periods to allocate budget for |
| 1439 | + self.zero_data = create_zero_dataset( |
| 1440 | + model=self.model_class, start_date=start_date, end_date=end_date |
| 1441 | + ) |
| 1442 | + self.num_periods = len(self.zero_data[self.model_class.date_column].unique()) |
| 1443 | + # Adding missing dependencies for compatibility with BudgetOptimizer |
| 1444 | + self._channel_scales = 1.0 |
| 1445 | + |
| 1446 | + def __getattr__(self, name): |
| 1447 | + """Delegate attribute access to the wrapped MMM model.""" |
| 1448 | + try: |
| 1449 | + # First, try to get the attribute from the wrapper itself |
| 1450 | + return object.__getattribute__(self, name) |
| 1451 | + except AttributeError: |
| 1452 | + # If not found, delegate to the wrapped model |
| 1453 | + try: |
| 1454 | + return getattr(self.model_class, name) |
| 1455 | + except AttributeError as e: |
| 1456 | + # Raise an AttributeError if the attribute is not found in either |
| 1457 | + raise AttributeError( |
| 1458 | + f"'{type(self).__name__}' object and its wrapped 'MMM' object have no attribute '{name}'" |
| 1459 | + ) from e |
| 1460 | + |
| 1461 | + def _set_predictors_for_optimization(self, num_periods: int) -> pm.Model: |
| 1462 | + """Return the respective PyMC model with any predictors set for optimization.""" |
| 1463 | + # Use the model's method for transformation |
| 1464 | + dataset_xarray = self._posterior_predictive_data_transformation( |
| 1465 | + X=self.zero_data, |
| 1466 | + include_last_observations=False, |
| 1467 | + ) |
| 1468 | + |
| 1469 | + # Use the model's method to set data |
| 1470 | + pymc_model = self._set_xarray_data( |
| 1471 | + dataset_xarray=dataset_xarray, |
| 1472 | + clone_model=True, # Ensure we work on a clone |
| 1473 | + ) |
| 1474 | + |
| 1475 | + # Use the model's mu_effects and set data using the model instance |
| 1476 | + for mu_effect in self.mu_effects: |
| 1477 | + mu_effect.set_data(self, pymc_model, dataset_xarray) |
| 1478 | + |
| 1479 | + return pymc_model |
| 1480 | + |
| 1481 | + def optimize_budget( |
| 1482 | + self, |
| 1483 | + budget: float | int, |
| 1484 | + budget_bounds: xr.DataArray | dict[str, tuple[float, float]] | None = None, |
| 1485 | + response_variable: str = "total_media_contribution_original_scale", |
| 1486 | + utility_function: UtilityFunctionType = average_response, |
| 1487 | + constraints: Sequence[dict[str, Any]] = (), |
| 1488 | + default_constraints: bool = True, |
| 1489 | + **minimize_kwargs, |
| 1490 | + ) -> tuple[xr.DataArray, OptimizeResult]: |
| 1491 | + """Optimize the budget allocation for the model.""" |
| 1492 | + from pymc_marketing.mmm.budget_optimizer import BudgetOptimizer |
| 1493 | + |
| 1494 | + allocator = BudgetOptimizer( |
| 1495 | + num_periods=self.num_periods, |
| 1496 | + utility_function=utility_function, |
| 1497 | + response_variable=response_variable, |
| 1498 | + custom_constraints=constraints, |
| 1499 | + default_constraints=default_constraints, |
| 1500 | + model=self, # Pass the wrapper instance itself to the BudgetOptimizer |
| 1501 | + ) |
| 1502 | + |
| 1503 | + return allocator.allocate_budget( |
| 1504 | + total_budget=budget, |
| 1505 | + budget_bounds=budget_bounds, |
| 1506 | + **minimize_kwargs, |
| 1507 | + ) |
| 1508 | + |
| 1509 | + def sample_response_distribution( |
| 1510 | + self, |
| 1511 | + allocation_strategy: xr.DataArray, |
| 1512 | + noise_level: float = 0.001, |
| 1513 | + ) -> az.InferenceData: |
| 1514 | + """Generate synthetic dataset and sample posterior predictive based on allocation. |
| 1515 | +
|
| 1516 | + Parameters |
| 1517 | + ---------- |
| 1518 | + allocation_strategy : DataArray |
| 1519 | + The allocation strategy for the channels. |
| 1520 | + noise_level : float |
| 1521 | + The relative level of noise to add to the data allocation. |
| 1522 | +
|
| 1523 | + Returns |
| 1524 | + ------- |
| 1525 | + az.InferenceData |
| 1526 | + The posterior predictive samples based on the synthetic dataset. |
| 1527 | + """ |
| 1528 | + data = create_zero_dataset( |
| 1529 | + model=self, |
| 1530 | + start_date=self.start_date, |
| 1531 | + end_date=self.end_date, |
| 1532 | + channel_xr=allocation_strategy.to_dataset(dim="channel"), |
| 1533 | + ) |
| 1534 | + |
| 1535 | + data_with_noise = add_noise_to_channel_allocation( |
| 1536 | + df=data, |
| 1537 | + channels=self.channel_columns, |
| 1538 | + rel_std=noise_level, |
| 1539 | + seed=42, |
| 1540 | + ) |
| 1541 | + |
| 1542 | + constant_data = allocation_strategy.to_dataset(name="allocation") |
| 1543 | + |
| 1544 | + return self.sample_posterior_predictive( |
| 1545 | + X=data_with_noise, |
| 1546 | + extend_idata=False, |
| 1547 | + include_last_observations=True, |
| 1548 | + var_names=["y", "channel_contribution_original_scale"], |
| 1549 | + progressbar=False, |
| 1550 | + ).merge(constant_data) |
0 commit comments