Skip to content

Commit 78d5ef2

Browse files
viktoriiasThe Meridian Authors
authored andcommitted
Add optimize_grid() function to the OptimizationGrid dataclass.
PiperOrigin-RevId: 734413174
1 parent 0b53e25 commit 78d5ef2

File tree

3 files changed

+156
-121
lines changed

3 files changed

+156
-121
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ To release a new version (e.g. from `1.0.0` -> `2.0.0`):
2323

2424
## [Unreleased]
2525

26+
* Add `optimize_grid()` function to the `OptimizationGrid` dataclass.
27+
2628
## [1.0.5] - 2025-03-06
2729

2830
* Add technical support for python 3.10.

meridian/analysis/optimizer.py

Lines changed: 100 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,96 @@ def spend_step_size(self) -> float:
102102
"""The spend step size."""
103103
return self.grid_dataset.attrs[c.SPEND_STEP_SIZE]
104104

105+
def optimize_grid(
106+
self,
107+
budget: float | None = None,
108+
fixed_budget: bool = True,
109+
target_mroi: float | None = None,
110+
target_roi: float | None = None,
111+
) -> np.ndarray:
112+
"""Hill-climbing search algorithm for budget optimization.
113+
114+
Args:
115+
budget: Number indicating the total budget for the fixed budget scenario.
116+
Defaults to the budget used in the grid.
117+
fixed_budget: Bool indicating whether it's a fixed budget optimization or
118+
flexible budget optimization.
119+
target_mroi: Optional float indicating the target marginal return on
120+
investment (mroi) constraint. This can be translated into "How much can
121+
I spend when I have flexible budget until the mroi of each channel hits
122+
the target mroi". It's still possible that the mroi of some channels
123+
will not be equal to the target mroi due to the feasible range of media
124+
spend. However, the mroi will effectively shrink toward the target mroi.
125+
target_roi: Optional float indicating the target return on investment
126+
(roi) constraint. This can be translated into "How much can I spend when
127+
I have a flexible budget until the roi of total media spend hits the
128+
target roi".
129+
130+
Returns:
131+
optimal_spend: np.ndarry of dimension `(n_total_channels,)` containing the
132+
media spend that maximizes incremental outcome based on spend
133+
constraints for all media and RF channels.
134+
"""
135+
_validate_budget(
136+
fixed_budget=fixed_budget,
137+
budget=budget,
138+
target_mroi=target_mroi,
139+
target_roi=target_roi,
140+
)
141+
if fixed_budget and budget is None:
142+
rounded_spend = np.round(self.spend, self.round_factor).astype(int)
143+
budget = np.sum(rounded_spend)
144+
145+
spend = self.spend_grid[0, :].copy()
146+
incremental_outcome = self.incremental_outcome_grid[0, :].copy()
147+
spend_grid = self.spend_grid[1:, :]
148+
incremental_outcome_grid = self.incremental_outcome_grid[1:, :]
149+
iterative_roi_grid = np.round(
150+
tf.math.divide_no_nan(
151+
incremental_outcome_grid - incremental_outcome, spend_grid - spend
152+
),
153+
decimals=8,
154+
)
155+
while True:
156+
spend_optimal = spend.astype(int)
157+
# If none of the exit criteria are met roi_grid will eventually be filled
158+
# with all nans.
159+
if np.isnan(iterative_roi_grid).all():
160+
break
161+
point = np.unravel_index(
162+
np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
163+
)
164+
row_idx = point[0]
165+
media_idx = point[1]
166+
spend[media_idx] = spend_grid[row_idx, media_idx]
167+
incremental_outcome[media_idx] = incremental_outcome_grid[
168+
row_idx, media_idx
169+
]
170+
roi_grid_point = iterative_roi_grid[row_idx, media_idx]
171+
if _exceeds_optimization_constraints(
172+
fixed_budget=fixed_budget,
173+
budget=budget,
174+
spend=spend,
175+
incremental_outcome=incremental_outcome,
176+
roi_grid_point=roi_grid_point,
177+
target_mroi=target_mroi,
178+
target_roi=target_roi,
179+
):
180+
break
181+
182+
iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
183+
iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
184+
tf.math.divide_no_nan(
185+
incremental_outcome_grid[row_idx + 1 :, media_idx]
186+
- incremental_outcome_grid[row_idx, media_idx],
187+
spend_grid[row_idx + 1 :, media_idx]
188+
- spend_grid[row_idx, media_idx],
189+
),
190+
decimals=8,
191+
)
192+
193+
return spend_optimal
194+
105195

106196
@dataclasses.dataclass(frozen=True)
107197
class OptimizationResults:
@@ -1051,6 +1141,9 @@ def optimize(
10511141
include_rf=self._meridian.n_rf_channels > 0,
10521142
).data
10531143

1144+
use_historical_budget = budget is None or round(budget) == round(
1145+
np.sum(hist_spend)
1146+
)
10541147
budget = budget or np.sum(hist_spend)
10551148
pct_of_spend = self._validate_pct_of_spend(hist_spend, pct_of_spend)
10561149
spend = budget * pct_of_spend
@@ -1089,18 +1182,16 @@ def optimize(
10891182
optimal_frequency=optimal_frequency,
10901183
batch_size=batch_size,
10911184
)
1092-
# TODO: b/375644691) - Move grid search to a OptimizationGrid class.
1093-
optimal_spend = self._grid_search(
1094-
spend_grid=optimization_grid.spend_grid,
1095-
incremental_outcome_grid=optimization_grid.incremental_outcome_grid,
1096-
budget=np.sum(rounded_spend),
1185+
if use_historical_budget:
1186+
optimization_budget = None
1187+
else:
1188+
optimization_budget = np.sum(rounded_spend)
1189+
optimal_spend = optimization_grid.optimize_grid(
1190+
budget=optimization_budget,
10971191
fixed_budget=fixed_budget,
10981192
target_mroi=target_mroi,
10991193
target_roi=target_roi,
11001194
)
1101-
use_historical_budget = budget is None or round(budget) == round(
1102-
np.sum(hist_spend)
1103-
)
11041195
nonoptimized_data = self._create_budget_dataset(
11051196
use_posterior=use_posterior,
11061197
use_kpi=use_kpi,
@@ -1168,7 +1259,7 @@ def create_optimization_grid(
11681259
round_factor: int,
11691260
use_posterior: bool = True,
11701261
use_kpi: bool = False,
1171-
use_optimal_frequency: bool = True,
1262+
use_optimal_frequency: bool = False,
11721263
optimal_frequency: xr.DataArray | None = None,
11731264
batch_size: int = c.DEFAULT_BATCH_SIZE,
11741265
) -> OptimizationGrid:
@@ -1810,96 +1901,6 @@ def _create_grids(
18101901
)
18111902
return (spend_grid, incremental_outcome_grid)
18121903

1813-
def _grid_search(
1814-
self,
1815-
spend_grid: np.ndarray,
1816-
incremental_outcome_grid: np.ndarray,
1817-
budget: float,
1818-
fixed_budget: bool,
1819-
target_mroi: float | None = None,
1820-
target_roi: float | None = None,
1821-
) -> np.ndarray:
1822-
"""Hill-climbing search algorithm for budget optimization.
1823-
1824-
Args:
1825-
spend_grid: Discrete grid with dimensions (`grid_length` x
1826-
`n_total_channels`) containing spend by channel for all media and RF
1827-
channels, used in the hill-climbing search algorithm.
1828-
incremental_outcome_grid: Discrete grid with dimensions (`grid_length` x
1829-
`n_total_channels`) containing incremental outcome by channel for all
1830-
media and RF channels, used in the hill-climbing search algorithm.
1831-
budget: Integer indicating the total budget.
1832-
fixed_budget: Bool indicating whether it's a fixed budget optimization or
1833-
flexible budget optimization.
1834-
target_mroi: Optional float indicating the target marginal return on
1835-
investment (mroi) constraint. This can be translated into "How much can
1836-
I spend when I have flexible budget until the mroi of each channel hits
1837-
the target mroi". It's still possible that the mroi of some channels
1838-
will not be equal to the target mroi due to the feasible range of media
1839-
spend. However, the mroi will effectively shrink toward the target mroi.
1840-
target_roi: Optional float indicating the target return on investment
1841-
(roi) constraint. This can be translated into "How much can I spend when
1842-
I have a flexible budget until the roi of total media spend hits the
1843-
target roi".
1844-
1845-
Returns:
1846-
optimal_spend: np.ndarry of dimension (`n_total_channels`) containing the
1847-
media spend that maximizes incremental outcome based on spend
1848-
constraints for all media and RF channels.
1849-
optimal_inc_outcome: np.ndarry of dimension (`n_total_channels`)
1850-
containing the post optimization incremental outcome per channel for all
1851-
media and RF channels.
1852-
"""
1853-
spend = spend_grid[0, :].copy()
1854-
incremental_outcome = incremental_outcome_grid[0, :].copy()
1855-
spend_grid = spend_grid[1:, :]
1856-
incremental_outcome_grid = incremental_outcome_grid[1:, :]
1857-
iterative_roi_grid = np.round(
1858-
tf.math.divide_no_nan(
1859-
incremental_outcome_grid - incremental_outcome, spend_grid - spend
1860-
),
1861-
decimals=8,
1862-
)
1863-
while True:
1864-
spend_optimal = spend.astype(int)
1865-
# If none of the exit criteria are met roi_grid will eventually be filled
1866-
# with all nans.
1867-
if np.isnan(iterative_roi_grid).all():
1868-
break
1869-
point = np.unravel_index(
1870-
np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
1871-
)
1872-
row_idx = point[0]
1873-
media_idx = point[1]
1874-
spend[media_idx] = spend_grid[row_idx, media_idx]
1875-
incremental_outcome[media_idx] = incremental_outcome_grid[
1876-
row_idx, media_idx
1877-
]
1878-
roi_grid_point = iterative_roi_grid[row_idx, media_idx]
1879-
if _exceeds_optimization_constraints(
1880-
fixed_budget,
1881-
budget,
1882-
spend,
1883-
incremental_outcome,
1884-
roi_grid_point,
1885-
target_mroi,
1886-
target_roi,
1887-
):
1888-
break
1889-
1890-
iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
1891-
iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
1892-
tf.math.divide_no_nan(
1893-
incremental_outcome_grid[row_idx + 1 :, media_idx]
1894-
- incremental_outcome_grid[row_idx, media_idx],
1895-
spend_grid[row_idx + 1 :, media_idx]
1896-
- spend_grid[row_idx, media_idx],
1897-
),
1898-
decimals=8,
1899-
)
1900-
return spend_optimal
1901-
1902-
19031904
def _validate_budget(
19041905
fixed_budget: bool,
19051906
budget: float | None,

0 commit comments

Comments
 (0)