google-meridian 1.0.7__py3-none-any.whl → 1.0.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {google_meridian-1.0.7.dist-info → google_meridian-1.0.8.dist-info}/METADATA +2 -2
- {google_meridian-1.0.7.dist-info → google_meridian-1.0.8.dist-info}/RECORD +17 -17
- {google_meridian-1.0.7.dist-info → google_meridian-1.0.8.dist-info}/WHEEL +1 -1
- meridian/__init__.py +1 -1
- meridian/analysis/analyzer.py +383 -320
- meridian/analysis/optimizer.py +531 -269
- meridian/analysis/summarizer.py +21 -3
- meridian/analysis/summary_text.py +20 -1
- meridian/analysis/templates/chart.html.jinja +1 -0
- meridian/analysis/test_utils.py +47 -99
- meridian/analysis/visualizer.py +407 -83
- meridian/constants.py +31 -0
- meridian/data/input_data.py +49 -5
- meridian/model/model.py +5 -4
- meridian/model/posterior_sampler.py +15 -5
- {google_meridian-1.0.7.dist-info → google_meridian-1.0.8.dist-info}/licenses/LICENSE +0 -0
- {google_meridian-1.0.7.dist-info → google_meridian-1.0.8.dist-info}/top_level.txt +0 -0
meridian/analysis/optimizer.py
CHANGED
|
@@ -37,6 +37,7 @@ import xarray as xr
|
|
|
37
37
|
|
|
38
38
|
__all__ = [
|
|
39
39
|
'BudgetOptimizer',
|
|
40
|
+
'OptimizationGrid',
|
|
40
41
|
'OptimizationResults',
|
|
41
42
|
]
|
|
42
43
|
|
|
@@ -92,10 +93,14 @@ class OptimizationGrid:
|
|
|
92
93
|
Attributes:
|
|
93
94
|
historical_spend: ndarray of shape `(n_paid_channels,)` containing
|
|
94
95
|
aggregated historical spend allocation for spend for all media and RF
|
|
95
|
-
channels.
|
|
96
|
+
channels.
|
|
96
97
|
use_kpi: Whether using generic KPI or revenue.
|
|
97
98
|
use_posterior: Whether posterior distributions were used, or prior.
|
|
98
99
|
use_optimal_frequency: Whether optimal frequency was used.
|
|
100
|
+
gtol: Float indicating the acceptable relative error for the budget used in
|
|
101
|
+
the grid setup. The budget is rounded by `10*n`, where `n` is the smallest
|
|
102
|
+
integer such that `(budget - rounded_budget)` is less than or equal to
|
|
103
|
+
`(budget * gtol)`.
|
|
99
104
|
round_factor: The round factor used for the optimization grid.
|
|
100
105
|
optimal_frequency: Optional ndarray of shape `(n_paid_channels,)`,
|
|
101
106
|
containing the optimal frequency per channel. Value is `None` if the model
|
|
@@ -111,6 +116,7 @@ class OptimizationGrid:
|
|
|
111
116
|
use_kpi: bool
|
|
112
117
|
use_posterior: bool
|
|
113
118
|
use_optimal_frequency: bool
|
|
119
|
+
gtol: float
|
|
114
120
|
round_factor: int
|
|
115
121
|
optimal_frequency: np.ndarray | None
|
|
116
122
|
selected_times: list[str] | None
|
|
@@ -142,35 +148,149 @@ class OptimizationGrid:
|
|
|
142
148
|
"""The spend step size."""
|
|
143
149
|
return self.grid_dataset.attrs[c.SPEND_STEP_SIZE]
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
@property
|
|
152
|
+
def channels(self) -> list[str]:
|
|
153
|
+
"""The spend channels in the grid."""
|
|
154
|
+
return self.grid_dataset.channel.data.tolist()
|
|
155
|
+
|
|
146
156
|
def optimize(
|
|
147
157
|
self,
|
|
148
158
|
scenario: FixedBudgetScenario | FlexibleBudgetScenario,
|
|
159
|
+
pct_of_spend: Sequence[float] | None = None,
|
|
160
|
+
spend_constraint_lower: _SpendConstraint | None = None,
|
|
161
|
+
spend_constraint_upper: _SpendConstraint | None = None,
|
|
162
|
+
) -> xr.Dataset:
|
|
163
|
+
"""Finds the optimal budget allocation that maximizes outcome.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
scenario: The optimization scenario with corresponding parameters.
|
|
167
|
+
pct_of_spend: Numeric list of size `channels` containing the percentage
|
|
168
|
+
allocation for spend for all channels. The values must be between 0-1,
|
|
169
|
+
summing to 1. By default, the historical allocation is used. Budget and
|
|
170
|
+
allocation are used in conjunction to determine the non-optimized
|
|
171
|
+
media-level spend, which is used to calculate the non-optimized
|
|
172
|
+
performance metrics (for example, ROI) and construct the feasible range
|
|
173
|
+
of media-level spend with the spend constraints.
|
|
174
|
+
spend_constraint_lower: Numeric list of size `channels` or float (same
|
|
175
|
+
constraint for all channels) indicating the lower bound of media-level
|
|
176
|
+
spend. If given as a channel-indexed array, the order must match
|
|
177
|
+
`channels`. The lower bound of media-level spend is `(1 -
|
|
178
|
+
spend_constraint_lower) * budget * allocation)`. The value must be
|
|
179
|
+
between 0-1. Defaults to `0.3` for fixed budget and `1` for flexible.
|
|
180
|
+
spend_constraint_upper: Numeric list of size `channels` or float (same
|
|
181
|
+
constraint for all channels) indicating the upper bound of media-level
|
|
182
|
+
spend. If given as a channel-indexed array, the order must match
|
|
183
|
+
`channels`. The upper bound of media-level spend is `(1 +
|
|
184
|
+
spend_constraint_upper) * budget * allocation)`. Defaults to `0.3` for
|
|
185
|
+
fixed budget and `1` for flexible.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
An xarray Dataset with `channel` as the coordinate and the following data
|
|
189
|
+
variables:
|
|
190
|
+
* `optimized`: media spend that maximizes incremental outcome based
|
|
191
|
+
on spend constraints for all media and RF channels.
|
|
192
|
+
* `non_optimized`: Channel-level spend.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
A warning if the budget's rounding should be different from the grid's
|
|
196
|
+
round factor.'.
|
|
197
|
+
ValueError: If spend allocation is not within the grid coverage.
|
|
198
|
+
"""
|
|
199
|
+
total_budget = (
|
|
200
|
+
scenario.total_budget
|
|
201
|
+
if isinstance(scenario, FixedBudgetScenario)
|
|
202
|
+
else None
|
|
203
|
+
)
|
|
204
|
+
budget = total_budget or np.sum(self.historical_spend)
|
|
205
|
+
valid_pct_of_spend = _validate_pct_of_spend(
|
|
206
|
+
n_channels=len(self.channels),
|
|
207
|
+
hist_spend=self.historical_spend,
|
|
208
|
+
pct_of_spend=pct_of_spend,
|
|
209
|
+
)
|
|
210
|
+
spend = budget * valid_pct_of_spend
|
|
211
|
+
spend_constraint_default = (
|
|
212
|
+
c.SPEND_CONSTRAINT_DEFAULT_FIXED_BUDGET
|
|
213
|
+
if isinstance(scenario, FixedBudgetScenario)
|
|
214
|
+
else c.SPEND_CONSTRAINT_DEFAULT_FLEXIBLE_BUDGET
|
|
215
|
+
)
|
|
216
|
+
if spend_constraint_lower is None:
|
|
217
|
+
spend_constraint_lower = spend_constraint_default
|
|
218
|
+
if spend_constraint_upper is None:
|
|
219
|
+
spend_constraint_upper = spend_constraint_default
|
|
220
|
+
(optimization_lower_bound, optimization_upper_bound) = (
|
|
221
|
+
_get_optimization_bounds(
|
|
222
|
+
n_channels=len(self.channels),
|
|
223
|
+
spend=spend,
|
|
224
|
+
round_factor=self.round_factor,
|
|
225
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
226
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
self._check_optimization_bounds(
|
|
230
|
+
lower_bound=optimization_lower_bound,
|
|
231
|
+
upper_bound=optimization_upper_bound,
|
|
232
|
+
)
|
|
233
|
+
round_factor = _get_round_factor(budget, self.gtol)
|
|
234
|
+
if round_factor != self.round_factor:
|
|
235
|
+
warnings.warn(
|
|
236
|
+
'Optimization accuracy may suffer owing to budget level differences.'
|
|
237
|
+
' Consider creating a new grid with smaller `gtol` if you intend to'
|
|
238
|
+
" shrink budgets significantly. It's only a problem when you use a"
|
|
239
|
+
' smaller budget, for which the intended step size is meant to be'
|
|
240
|
+
' smaller for one or more channels.'
|
|
241
|
+
)
|
|
242
|
+
(spend_grid, incremental_outcome_grid) = self._trim_grid(
|
|
243
|
+
spend_bound_lower=optimization_lower_bound,
|
|
244
|
+
spend_bound_upper=optimization_upper_bound,
|
|
245
|
+
)
|
|
246
|
+
if isinstance(scenario, FixedBudgetScenario):
|
|
247
|
+
rounded_spend = np.round(spend, self.round_factor)
|
|
248
|
+
scenario = dataclasses.replace(
|
|
249
|
+
scenario, total_budget=np.sum(rounded_spend)
|
|
250
|
+
)
|
|
251
|
+
optimal_spend = self._grid_search(
|
|
252
|
+
spend_grid=spend_grid,
|
|
253
|
+
incremental_outcome_grid=incremental_outcome_grid,
|
|
254
|
+
scenario=scenario,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
return xr.Dataset(
|
|
258
|
+
coords={c.CHANNEL: self.channels},
|
|
259
|
+
data_vars={
|
|
260
|
+
c.OPTIMIZED: ([c.CHANNEL], optimal_spend.data),
|
|
261
|
+
c.NON_OPTIMIZED: ([c.CHANNEL], spend),
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def _grid_search(
|
|
266
|
+
self,
|
|
267
|
+
spend_grid: np.ndarray,
|
|
268
|
+
incremental_outcome_grid: np.ndarray,
|
|
269
|
+
scenario: FixedBudgetScenario | FlexibleBudgetScenario,
|
|
149
270
|
) -> np.ndarray:
|
|
150
271
|
"""Hill-climbing search algorithm for budget optimization.
|
|
151
272
|
|
|
152
273
|
Args:
|
|
274
|
+
spend_grid: Discrete grid with dimensions (`grid_length` x
|
|
275
|
+
`n_total_channels`) containing spend by channel for all media and RF
|
|
276
|
+
channels, used in the hill-climbing search algorithm.
|
|
277
|
+
incremental_outcome_grid: Discrete grid with dimensions (`grid_length` x
|
|
278
|
+
`n_total_channels`) containing incremental outcome by channel for all
|
|
279
|
+
media and RF channels, used in the hill-climbing search algorithm.
|
|
153
280
|
scenario: The optimization scenario with corresponding parameters.
|
|
154
281
|
|
|
155
282
|
Returns:
|
|
156
|
-
optimal_spend: `np.ndarray`
|
|
157
|
-
media spend that maximizes incremental outcome based on spend
|
|
283
|
+
optimal_spend: `np.ndarray` of dimension (`n_total_channels`) containing
|
|
284
|
+
the media spend that maximizes incremental outcome based on spend
|
|
158
285
|
constraints for all media and RF channels.
|
|
286
|
+
optimal_inc_outcome: `np.ndarray` of dimension (`n_total_channels`)
|
|
287
|
+
containing the post optimization incremental outcome per channel for all
|
|
288
|
+
media and RF channels.
|
|
159
289
|
"""
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
rounded_spend = np.round(self.historical_spend, self.round_factor).astype(
|
|
165
|
-
int
|
|
166
|
-
)
|
|
167
|
-
budget = np.sum(rounded_spend)
|
|
168
|
-
scenario = dataclasses.replace(scenario, total_budget=budget)
|
|
169
|
-
|
|
170
|
-
spend = self.spend_grid[0, :].copy()
|
|
171
|
-
incremental_outcome = self.incremental_outcome_grid[0, :].copy()
|
|
172
|
-
spend_grid = self.spend_grid[1:, :]
|
|
173
|
-
incremental_outcome_grid = self.incremental_outcome_grid[1:, :]
|
|
290
|
+
spend = spend_grid[0, :].copy()
|
|
291
|
+
incremental_outcome = incremental_outcome_grid[0, :].copy()
|
|
292
|
+
spend_grid = spend_grid[1:, :]
|
|
293
|
+
incremental_outcome_grid = incremental_outcome_grid[1:, :]
|
|
174
294
|
iterative_roi_grid = np.round(
|
|
175
295
|
tf.math.divide_no_nan(
|
|
176
296
|
incremental_outcome_grid - incremental_outcome, spend_grid - spend
|
|
@@ -211,9 +331,97 @@ class OptimizationGrid:
|
|
|
211
331
|
),
|
|
212
332
|
decimals=8,
|
|
213
333
|
)
|
|
214
|
-
|
|
215
334
|
return spend_optimal
|
|
216
335
|
|
|
336
|
+
def _trim_grid(
|
|
337
|
+
self,
|
|
338
|
+
spend_bound_lower: np.ndarray,
|
|
339
|
+
spend_bound_upper: np.ndarray,
|
|
340
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
341
|
+
"""Trim the grids based on a more restricted spend bound.
|
|
342
|
+
|
|
343
|
+
It is assumed that spend bounds are validated: their values are within the
|
|
344
|
+
grid coverage and they are rounded using this grid's round factor.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
spend_bound_lower: The lower bound of spend for each channel.
|
|
348
|
+
spend_bound_upper: The upper bound of spend for each channel.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
updated_spend: The updated spend grid with valid spend values moved up to
|
|
352
|
+
the first row and invalid spend values filled with NaN.
|
|
353
|
+
updated_incremental_outcome: The updated incremental outcome grid with the
|
|
354
|
+
corresponding incremental outcome values moved up to the first row and
|
|
355
|
+
invalid incremental outcome values filled with NaN.
|
|
356
|
+
"""
|
|
357
|
+
spend_grid = self.spend_grid
|
|
358
|
+
updated_spend = self.spend_grid.copy()
|
|
359
|
+
updated_incremental_outcome = self.incremental_outcome_grid.copy()
|
|
360
|
+
|
|
361
|
+
for ch in range(len(self.channels)):
|
|
362
|
+
valid_indices = np.where(
|
|
363
|
+
(spend_grid[:, ch] >= spend_bound_lower[ch])
|
|
364
|
+
& (spend_grid[:, ch] <= spend_bound_upper[ch])
|
|
365
|
+
)[0]
|
|
366
|
+
first_valid_index = valid_indices[0]
|
|
367
|
+
last_valid_index = valid_indices[-1]
|
|
368
|
+
|
|
369
|
+
# Move the smallest spend to the first row.
|
|
370
|
+
updated_spend[:, ch] = np.roll(
|
|
371
|
+
updated_spend[:, ch], shift=-first_valid_index
|
|
372
|
+
)
|
|
373
|
+
# Move the corresponding incremental outcome to the first row.
|
|
374
|
+
updated_incremental_outcome[:, ch] = np.roll(
|
|
375
|
+
updated_incremental_outcome[:, ch], shift=-first_valid_index
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Fill the invalid indices with NaN.
|
|
379
|
+
nan_indices = last_valid_index - first_valid_index + 1
|
|
380
|
+
updated_spend[nan_indices:, ch] = np.nan
|
|
381
|
+
updated_incremental_outcome[nan_indices:, ch] = np.nan
|
|
382
|
+
|
|
383
|
+
return (updated_spend, updated_incremental_outcome)
|
|
384
|
+
|
|
385
|
+
def _check_optimization_bounds(
|
|
386
|
+
self,
|
|
387
|
+
lower_bound: np.ndarray,
|
|
388
|
+
upper_bound: np.ndarray,
|
|
389
|
+
) -> None:
|
|
390
|
+
"""Checks if the spend grid fits within the optimization bounds.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
lower_bound: `np.ndarray` of shape `(n_channels,)` containing the lower
|
|
394
|
+
bound for each channel.
|
|
395
|
+
upper_bound: `np.ndarray` of shape `(n_channels,)` containing the upper
|
|
396
|
+
bound for each channel.
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ValueError: If the spend grid does not fit within the optimization bounds.
|
|
400
|
+
"""
|
|
401
|
+
min_spend = np.min(self.spend_grid, axis=0)
|
|
402
|
+
max_spend = np.max(self.spend_grid, axis=0)
|
|
403
|
+
errors = []
|
|
404
|
+
for i, channel_min_spend in enumerate(min_spend.data):
|
|
405
|
+
if lower_bound[i] < channel_min_spend:
|
|
406
|
+
errors.append(
|
|
407
|
+
f'Lower bound {lower_bound[i]} for channel'
|
|
408
|
+
f' {self.channels[i]} is below the mimimum spend of the grid'
|
|
409
|
+
f' {channel_min_spend}.'
|
|
410
|
+
)
|
|
411
|
+
for i, channel_max_spend in enumerate(max_spend.data):
|
|
412
|
+
if upper_bound[i] > channel_max_spend:
|
|
413
|
+
errors.append(
|
|
414
|
+
f'Upper bound {upper_bound[i]} for channel'
|
|
415
|
+
f' {self.channels[i]} is above the maximum spend of the grid'
|
|
416
|
+
f' {channel_max_spend}.'
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
if errors:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
'Spend allocation is not within the grid coverage:\n'
|
|
422
|
+
+ '\n'.join(errors)
|
|
423
|
+
)
|
|
424
|
+
|
|
217
425
|
|
|
218
426
|
@dataclasses.dataclass(frozen=True)
|
|
219
427
|
class OptimizationResults:
|
|
@@ -490,7 +698,7 @@ class OptimizationResults:
|
|
|
490
698
|
title=formatter.custom_title_params(
|
|
491
699
|
summary_text.SPEND_ALLOCATION_CHART_TITLE
|
|
492
700
|
),
|
|
493
|
-
width=c.VEGALITE_FACET_DEFAULT_WIDTH
|
|
701
|
+
width=c.VEGALITE_FACET_DEFAULT_WIDTH,
|
|
494
702
|
)
|
|
495
703
|
)
|
|
496
704
|
|
|
@@ -698,6 +906,7 @@ class OptimizationResults:
|
|
|
698
906
|
use_posterior=self.optimization_grid.use_posterior,
|
|
699
907
|
selected_times=selected_times,
|
|
700
908
|
by_reach=True,
|
|
909
|
+
use_kpi=not self.nonoptimized_data.attrs[c.IS_REVENUE_KPI],
|
|
701
910
|
use_optimal_frequency=self.optimization_grid.use_optimal_frequency,
|
|
702
911
|
)
|
|
703
912
|
|
|
@@ -1149,66 +1358,29 @@ class BudgetOptimizer:
|
|
|
1149
1358
|
target_roi=target_roi,
|
|
1150
1359
|
target_mroi=target_mroi,
|
|
1151
1360
|
)
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
start_date=start_date,
|
|
1157
|
-
end_date=end_date,
|
|
1158
|
-
)
|
|
1159
|
-
else:
|
|
1160
|
-
selected_time_dims = None
|
|
1161
|
-
hist_spend = self._analyzer.get_historical_spend(
|
|
1162
|
-
selected_time_dims,
|
|
1163
|
-
include_media=self._meridian.n_media_channels > 0,
|
|
1164
|
-
include_rf=self._meridian.n_rf_channels > 0,
|
|
1165
|
-
).data
|
|
1166
|
-
|
|
1167
|
-
use_historical_budget = budget is None or round(budget) == round(
|
|
1168
|
-
np.sum(hist_spend)
|
|
1169
|
-
)
|
|
1170
|
-
budget = budget or np.sum(hist_spend)
|
|
1171
|
-
pct_of_spend = self._validate_pct_of_spend(hist_spend, pct_of_spend)
|
|
1172
|
-
spend = budget * pct_of_spend
|
|
1173
|
-
round_factor = _get_round_factor(budget, gtol)
|
|
1174
|
-
rounded_spend = np.round(spend, round_factor).astype(int)
|
|
1175
|
-
if self._meridian.n_rf_channels > 0 and use_optimal_frequency:
|
|
1176
|
-
optimal_frequency = tf.convert_to_tensor(
|
|
1177
|
-
self._analyzer.optimal_freq(
|
|
1178
|
-
use_posterior=use_posterior,
|
|
1179
|
-
selected_times=selected_time_dims,
|
|
1180
|
-
use_kpi=use_kpi,
|
|
1181
|
-
).optimal_frequency,
|
|
1182
|
-
dtype=tf.float32,
|
|
1183
|
-
)
|
|
1184
|
-
else:
|
|
1185
|
-
optimal_frequency = None
|
|
1186
|
-
|
|
1187
|
-
(optimization_lower_bound, optimization_upper_bound, spend_bounds) = (
|
|
1188
|
-
self._get_optimization_bounds(
|
|
1189
|
-
spend=rounded_spend,
|
|
1190
|
-
spend_constraint_lower=spend_constraint_lower,
|
|
1191
|
-
spend_constraint_upper=spend_constraint_upper,
|
|
1192
|
-
round_factor=round_factor,
|
|
1193
|
-
fixed_budget=fixed_budget,
|
|
1194
|
-
)
|
|
1361
|
+
spend_constraint_default = (
|
|
1362
|
+
c.SPEND_CONSTRAINT_DEFAULT_FIXED_BUDGET
|
|
1363
|
+
if fixed_budget
|
|
1364
|
+
else c.SPEND_CONSTRAINT_DEFAULT_FLEXIBLE_BUDGET
|
|
1195
1365
|
)
|
|
1366
|
+
if spend_constraint_lower is None:
|
|
1367
|
+
spend_constraint_lower = spend_constraint_default
|
|
1368
|
+
if spend_constraint_upper is None:
|
|
1369
|
+
spend_constraint_upper = spend_constraint_default
|
|
1196
1370
|
optimization_grid = self.create_optimization_grid(
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1371
|
+
selected_times=selected_times,
|
|
1372
|
+
budget=budget,
|
|
1373
|
+
pct_of_spend=pct_of_spend,
|
|
1374
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
1375
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
1376
|
+
gtol=gtol,
|
|
1202
1377
|
use_posterior=use_posterior,
|
|
1203
1378
|
use_kpi=use_kpi,
|
|
1204
1379
|
use_optimal_frequency=use_optimal_frequency,
|
|
1205
|
-
optimal_frequency=optimal_frequency,
|
|
1206
1380
|
batch_size=batch_size,
|
|
1207
1381
|
)
|
|
1208
|
-
|
|
1209
1382
|
if fixed_budget:
|
|
1210
|
-
|
|
1211
|
-
scenario = FixedBudgetScenario(total_budget=total_budget)
|
|
1383
|
+
scenario = FixedBudgetScenario(total_budget=budget)
|
|
1212
1384
|
elif target_roi:
|
|
1213
1385
|
scenario = FlexibleBudgetScenario(
|
|
1214
1386
|
target_metric=c.ROI, target_value=target_roi
|
|
@@ -1217,16 +1389,25 @@ class BudgetOptimizer:
|
|
|
1217
1389
|
scenario = FlexibleBudgetScenario(
|
|
1218
1390
|
target_metric=c.MROI, target_value=target_mroi
|
|
1219
1391
|
)
|
|
1220
|
-
|
|
1221
|
-
optimal_spend = optimization_grid.optimize(
|
|
1392
|
+
spend = optimization_grid.optimize(
|
|
1222
1393
|
scenario=scenario,
|
|
1394
|
+
pct_of_spend=pct_of_spend,
|
|
1395
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
1396
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
1397
|
+
)
|
|
1398
|
+
|
|
1399
|
+
use_historical_budget = budget is None or np.isclose(
|
|
1400
|
+
budget, np.sum(optimization_grid.historical_spend)
|
|
1223
1401
|
)
|
|
1402
|
+
rounded_spend = np.round(
|
|
1403
|
+
spend.non_optimized, optimization_grid.round_factor
|
|
1404
|
+
).astype(int)
|
|
1224
1405
|
nonoptimized_data = self._create_budget_dataset(
|
|
1225
1406
|
use_posterior=use_posterior,
|
|
1226
1407
|
use_kpi=use_kpi,
|
|
1227
|
-
hist_spend=
|
|
1408
|
+
hist_spend=optimization_grid.historical_spend,
|
|
1228
1409
|
spend=rounded_spend,
|
|
1229
|
-
selected_times=
|
|
1410
|
+
selected_times=optimization_grid.selected_times,
|
|
1230
1411
|
confidence_level=confidence_level,
|
|
1231
1412
|
batch_size=batch_size,
|
|
1232
1413
|
use_historical_budget=use_historical_budget,
|
|
@@ -1234,10 +1415,10 @@ class BudgetOptimizer:
|
|
|
1234
1415
|
nonoptimized_data_with_optimal_freq = self._create_budget_dataset(
|
|
1235
1416
|
use_posterior=use_posterior,
|
|
1236
1417
|
use_kpi=use_kpi,
|
|
1237
|
-
hist_spend=
|
|
1418
|
+
hist_spend=optimization_grid.historical_spend,
|
|
1238
1419
|
spend=rounded_spend,
|
|
1239
|
-
selected_times=
|
|
1240
|
-
optimal_frequency=optimal_frequency,
|
|
1420
|
+
selected_times=optimization_grid.selected_times,
|
|
1421
|
+
optimal_frequency=optimization_grid.optimal_frequency,
|
|
1241
1422
|
confidence_level=confidence_level,
|
|
1242
1423
|
batch_size=batch_size,
|
|
1243
1424
|
use_historical_budget=use_historical_budget,
|
|
@@ -1252,10 +1433,10 @@ class BudgetOptimizer:
|
|
|
1252
1433
|
optimized_data = self._create_budget_dataset(
|
|
1253
1434
|
use_posterior=use_posterior,
|
|
1254
1435
|
use_kpi=use_kpi,
|
|
1255
|
-
hist_spend=
|
|
1256
|
-
spend=
|
|
1257
|
-
selected_times=
|
|
1258
|
-
optimal_frequency=optimal_frequency,
|
|
1436
|
+
hist_spend=optimization_grid.historical_spend,
|
|
1437
|
+
spend=spend.optimized,
|
|
1438
|
+
selected_times=optimization_grid.selected_times,
|
|
1439
|
+
optimal_frequency=optimization_grid.optimal_frequency,
|
|
1259
1440
|
attrs=constraints,
|
|
1260
1441
|
confidence_level=confidence_level,
|
|
1261
1442
|
batch_size=batch_size,
|
|
@@ -1263,17 +1444,23 @@ class BudgetOptimizer:
|
|
|
1263
1444
|
)
|
|
1264
1445
|
|
|
1265
1446
|
if not fixed_budget:
|
|
1266
|
-
|
|
1447
|
+
_raise_warning_if_target_constraints_not_met(
|
|
1267
1448
|
target_roi=target_roi,
|
|
1268
1449
|
target_mroi=target_mroi,
|
|
1269
1450
|
optimized_data=optimized_data,
|
|
1270
1451
|
)
|
|
1271
1452
|
|
|
1272
1453
|
spend_ratio = np.divide(
|
|
1273
|
-
spend,
|
|
1274
|
-
|
|
1275
|
-
out=np.zeros_like(
|
|
1276
|
-
where=
|
|
1454
|
+
spend.non_optimized,
|
|
1455
|
+
optimization_grid.historical_spend,
|
|
1456
|
+
out=np.zeros_like(optimization_grid.historical_spend, dtype=float),
|
|
1457
|
+
where=optimization_grid.historical_spend != 0,
|
|
1458
|
+
)
|
|
1459
|
+
n_paid_channels = len(self._meridian.input_data.get_all_paid_channels())
|
|
1460
|
+
spend_bounds = _get_spend_bounds(
|
|
1461
|
+
n_channels=n_paid_channels,
|
|
1462
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
1463
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
1277
1464
|
)
|
|
1278
1465
|
|
|
1279
1466
|
return OptimizationResults(
|
|
@@ -1287,71 +1474,68 @@ class BudgetOptimizer:
|
|
|
1287
1474
|
_optimization_grid=optimization_grid,
|
|
1288
1475
|
)
|
|
1289
1476
|
|
|
1290
|
-
def _raise_warning_if_target_constraints_not_met(
|
|
1291
|
-
self,
|
|
1292
|
-
target_roi: float | None,
|
|
1293
|
-
target_mroi: float | None,
|
|
1294
|
-
optimized_data: xr.Dataset,
|
|
1295
|
-
) -> None:
|
|
1296
|
-
"""Raises a warning if the target constraints are not met."""
|
|
1297
|
-
if target_roi:
|
|
1298
|
-
# Total ROI is a scalar value.
|
|
1299
|
-
optimized_roi = optimized_data.attrs[c.TOTAL_ROI]
|
|
1300
|
-
if optimized_roi < target_roi:
|
|
1301
|
-
warnings.warn(
|
|
1302
|
-
f'Target ROI constraint was not met. The target ROI is {target_roi}'
|
|
1303
|
-
f', but the actual ROI is {optimized_roi}.'
|
|
1304
|
-
)
|
|
1305
|
-
elif target_mroi:
|
|
1306
|
-
# Compare each channel's marginal ROI to the target.
|
|
1307
|
-
# optimized_data[c.MROI] is an array of shape (n_channels, 4), where the
|
|
1308
|
-
# last dimension is [mean, median, ci_lo, ci_hi].
|
|
1309
|
-
optimized_mroi = optimized_data[c.MROI][:, 0]
|
|
1310
|
-
if np.any(optimized_mroi < target_mroi):
|
|
1311
|
-
warnings.warn(
|
|
1312
|
-
'Target marginal ROI constraint was not met. The target marginal'
|
|
1313
|
-
f' ROI is {target_mroi}, but the actual channel marginal ROIs are'
|
|
1314
|
-
f' {optimized_mroi}.'
|
|
1315
|
-
)
|
|
1316
|
-
|
|
1317
1477
|
def create_optimization_grid(
|
|
1318
1478
|
self,
|
|
1319
|
-
historical_spend: np.ndarray,
|
|
1320
|
-
spend_bound_lower: np.ndarray,
|
|
1321
|
-
spend_bound_upper: np.ndarray,
|
|
1322
|
-
selected_times: Sequence[str] | None,
|
|
1323
|
-
round_factor: int,
|
|
1324
1479
|
use_posterior: bool = True,
|
|
1480
|
+
selected_times: tuple[str | None, str | None] | None = None,
|
|
1481
|
+
budget: float | None = None,
|
|
1482
|
+
pct_of_spend: Sequence[float] | None = None,
|
|
1483
|
+
spend_constraint_lower: _SpendConstraint = c.SPEND_CONSTRAINT_DEFAULT,
|
|
1484
|
+
spend_constraint_upper: _SpendConstraint = c.SPEND_CONSTRAINT_DEFAULT,
|
|
1485
|
+
gtol: float = 0.0001,
|
|
1486
|
+
use_optimal_frequency: bool = True,
|
|
1325
1487
|
use_kpi: bool = False,
|
|
1326
|
-
use_optimal_frequency: bool = False,
|
|
1327
|
-
optimal_frequency: xr.DataArray | None = None,
|
|
1328
1488
|
batch_size: int = c.DEFAULT_BATCH_SIZE,
|
|
1329
1489
|
) -> OptimizationGrid:
|
|
1330
1490
|
"""Creates a OptimizationGrid for optimization.
|
|
1331
1491
|
|
|
1332
1492
|
Args:
|
|
1333
|
-
historical_spend: ndarray of shape `(n_paid_channels,)` with arrgegated
|
|
1334
|
-
historical spend per paid channel.
|
|
1335
|
-
spend_bound_lower: ndarray of dimension `(n_total_channels,)` containing
|
|
1336
|
-
the lower constraint spend for each channel.
|
|
1337
|
-
spend_bound_upper: ndarray of dimension `(n_total_channels,)` containing
|
|
1338
|
-
the upper constraint spend for each channel.
|
|
1339
|
-
selected_times: Sequence of strings representing the time dimensions in
|
|
1340
|
-
`meridian.input_data.time` to use for optimization.
|
|
1341
|
-
round_factor: The round factor used for the optimization grid.
|
|
1342
1493
|
use_posterior: Boolean. If `True`, then the incremental outcome is derived
|
|
1343
1494
|
from the posterior distribution of the model. Otherwise, the prior
|
|
1344
1495
|
distribution is used.
|
|
1496
|
+
selected_times: Tuple containing the start and end time dimension
|
|
1497
|
+
coordinates for the duration to run the optimization on. Selected time
|
|
1498
|
+
values should align with the Meridian time dimension coordinates in the
|
|
1499
|
+
underlying model. By default, all times periods are used. Either start
|
|
1500
|
+
or end time component can be `None` to represent the first or the last
|
|
1501
|
+
time coordinate, respectively.
|
|
1502
|
+
budget: Number indicating the total budget for the fixed budget scenario.
|
|
1503
|
+
Defaults to the historical budget.
|
|
1504
|
+
pct_of_spend: Numeric list of size `n_paid_channels` containing the
|
|
1505
|
+
percentage allocation for spend for all media and RF channels. The order
|
|
1506
|
+
must match `(InputData.media + InputData.reach)` with values between
|
|
1507
|
+
0-1, summing to 1. By default, the historical allocation is used. Budget
|
|
1508
|
+
and allocation are used in conjunction to determine the non-optimized
|
|
1509
|
+
media-level spend, which is used to calculate the non-optimized
|
|
1510
|
+
performance metrics (for example, ROI) and construct the feasible range
|
|
1511
|
+
of media-level spend with the spend constraints. Consider using
|
|
1512
|
+
`InputData.get_paid_channels_argument_builder()` to construct this
|
|
1513
|
+
argument.
|
|
1514
|
+
spend_constraint_lower: Numeric list of size `n_paid_channels` or float
|
|
1515
|
+
(same constraint for all channels) indicating the lower bound of
|
|
1516
|
+
media-level spend. If given as a channel-indexed array, the order must
|
|
1517
|
+
match `(InputData.media + InputData.reach)`. The lower bound of
|
|
1518
|
+
media-level spend is `(1 - spend_constraint_lower) * budget *
|
|
1519
|
+
allocation)`. The value must be between 0-1. Defaults to `0.3` for fixed
|
|
1520
|
+
budget and `1` for flexible. Consider using
|
|
1521
|
+
`InputData.get_paid_channels_argument_builder()` to construct this
|
|
1522
|
+
argument.
|
|
1523
|
+
spend_constraint_upper: Numeric list of size `n_paid_channels` or float
|
|
1524
|
+
(same constraint for all channels) indicating the upper bound of
|
|
1525
|
+
media-level spend. If given as a channel-indexed array, the order must
|
|
1526
|
+
match `(InputData.media + InputData.reach)`. The upper bound of
|
|
1527
|
+
media-level spend is `(1 + spend_constraint_upper) * budget *
|
|
1528
|
+
allocation)`. Defaults to `0.3` for fixed budget and `1` for flexible.
|
|
1529
|
+
Consider using `InputData.get_paid_channels_argument_builder()` to
|
|
1530
|
+
construct this argument.
|
|
1531
|
+
gtol: Float indicating the acceptable relative error for the budget used
|
|
1532
|
+
in the grid setup. The budget will be rounded by `10*n`, where `n` is
|
|
1533
|
+
the smallest integer such that `(budget - rounded_budget)` is less than
|
|
1534
|
+
or equal to `(budget * gtol)`. `gtol` must be less than 1.
|
|
1535
|
+
use_optimal_frequency: Boolean. Whether optimal frequency was used.
|
|
1345
1536
|
use_kpi: Boolean. If `True`, then the incremental outcome is derived from
|
|
1346
1537
|
the KPI impact. Otherwise, the incremental outcome is derived from the
|
|
1347
1538
|
revenue impact.
|
|
1348
|
-
use_optimal_frequency: Boolean. Whether optimal frequency was used.
|
|
1349
|
-
optimal_frequency: `xr.DataArray` with dimension `n_rf_channels`,
|
|
1350
|
-
containing the optimal frequency per channel, that maximizes mean ROI
|
|
1351
|
-
over the corresponding prior/posterior distribution. Value is `None` if
|
|
1352
|
-
the model does not contain reach and frequency data, or if the model
|
|
1353
|
-
does contain reach and frequency data, but historical frequency is used
|
|
1354
|
-
for the optimization scenario.
|
|
1355
1539
|
batch_size: Max draws per chain in each batch. The calculation is run in
|
|
1356
1540
|
batches to avoid memory exhaustion. If a memory error occurs, try
|
|
1357
1541
|
reducing `batch_size`. The calculation will generally be faster with
|
|
@@ -1361,14 +1545,56 @@ class BudgetOptimizer:
|
|
|
1361
1545
|
An OptimizationGrid object containing the grid data for optimization.
|
|
1362
1546
|
"""
|
|
1363
1547
|
self._validate_model_fit(use_posterior)
|
|
1548
|
+
if selected_times is not None:
|
|
1549
|
+
start_date, end_date = selected_times
|
|
1550
|
+
selected_time_dims = self._meridian.expand_selected_time_dims(
|
|
1551
|
+
start_date=start_date,
|
|
1552
|
+
end_date=end_date,
|
|
1553
|
+
)
|
|
1554
|
+
else:
|
|
1555
|
+
selected_time_dims = None
|
|
1556
|
+
hist_spend = self._analyzer.get_historical_spend(
|
|
1557
|
+
selected_time_dims,
|
|
1558
|
+
include_media=self._meridian.n_media_channels > 0,
|
|
1559
|
+
include_rf=self._meridian.n_rf_channels > 0,
|
|
1560
|
+
).data
|
|
1561
|
+
n_paid_channels = len(self._meridian.input_data.get_all_paid_channels())
|
|
1562
|
+
budget = budget or np.sum(hist_spend)
|
|
1563
|
+
valid_pct_of_spend = _validate_pct_of_spend(
|
|
1564
|
+
n_channels=n_paid_channels,
|
|
1565
|
+
hist_spend=hist_spend,
|
|
1566
|
+
pct_of_spend=pct_of_spend,
|
|
1567
|
+
)
|
|
1568
|
+
spend = budget * valid_pct_of_spend
|
|
1569
|
+
round_factor = _get_round_factor(budget, gtol)
|
|
1570
|
+
(optimization_lower_bound, optimization_upper_bound) = (
|
|
1571
|
+
_get_optimization_bounds(
|
|
1572
|
+
n_channels=n_paid_channels,
|
|
1573
|
+
spend=spend,
|
|
1574
|
+
round_factor=round_factor,
|
|
1575
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
1576
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
1577
|
+
)
|
|
1578
|
+
)
|
|
1579
|
+
if self._meridian.n_rf_channels > 0 and use_optimal_frequency:
|
|
1580
|
+
optimal_frequency = tf.convert_to_tensor(
|
|
1581
|
+
self._analyzer.optimal_freq(
|
|
1582
|
+
use_posterior=use_posterior,
|
|
1583
|
+
selected_times=selected_time_dims,
|
|
1584
|
+
use_kpi=use_kpi,
|
|
1585
|
+
).optimal_frequency,
|
|
1586
|
+
dtype=tf.float32,
|
|
1587
|
+
)
|
|
1588
|
+
else:
|
|
1589
|
+
optimal_frequency = None
|
|
1364
1590
|
|
|
1365
1591
|
step_size = 10 ** (-round_factor)
|
|
1366
1592
|
(spend_grid, incremental_outcome_grid) = self._create_grids(
|
|
1367
|
-
spend=
|
|
1368
|
-
spend_bound_lower=
|
|
1369
|
-
spend_bound_upper=
|
|
1593
|
+
spend=hist_spend,
|
|
1594
|
+
spend_bound_lower=optimization_lower_bound,
|
|
1595
|
+
spend_bound_upper=optimization_upper_bound,
|
|
1370
1596
|
step_size=step_size,
|
|
1371
|
-
selected_times=
|
|
1597
|
+
selected_times=selected_time_dims,
|
|
1372
1598
|
use_posterior=use_posterior,
|
|
1373
1599
|
use_kpi=use_kpi,
|
|
1374
1600
|
optimal_frequency=optimal_frequency,
|
|
@@ -1382,13 +1608,14 @@ class BudgetOptimizer:
|
|
|
1382
1608
|
|
|
1383
1609
|
return OptimizationGrid(
|
|
1384
1610
|
_grid_dataset=grid_dataset,
|
|
1385
|
-
historical_spend=
|
|
1611
|
+
historical_spend=hist_spend,
|
|
1386
1612
|
use_kpi=use_kpi,
|
|
1387
1613
|
use_posterior=use_posterior,
|
|
1388
1614
|
use_optimal_frequency=use_optimal_frequency,
|
|
1615
|
+
gtol=gtol,
|
|
1389
1616
|
round_factor=round_factor,
|
|
1390
1617
|
optimal_frequency=optimal_frequency,
|
|
1391
|
-
selected_times=
|
|
1618
|
+
selected_times=selected_time_dims,
|
|
1392
1619
|
)
|
|
1393
1620
|
|
|
1394
1621
|
def _create_grid_dataset(
|
|
@@ -1425,78 +1652,12 @@ class BudgetOptimizer:
|
|
|
1425
1652
|
return xr.Dataset(
|
|
1426
1653
|
data_vars=data_vars,
|
|
1427
1654
|
coords={
|
|
1428
|
-
c.GRID_SPEND_INDEX: (
|
|
1429
|
-
|
|
1430
|
-
np.arange(0, len(spend_grid)),
|
|
1431
|
-
),
|
|
1432
|
-
c.CHANNEL: (
|
|
1433
|
-
[c.CHANNEL],
|
|
1434
|
-
self._meridian.input_data.get_all_paid_channels(),
|
|
1435
|
-
),
|
|
1655
|
+
c.GRID_SPEND_INDEX: np.arange(0, len(spend_grid)),
|
|
1656
|
+
c.CHANNEL: self._meridian.input_data.get_all_paid_channels(),
|
|
1436
1657
|
},
|
|
1437
1658
|
attrs={c.SPEND_STEP_SIZE: spend_step_size},
|
|
1438
1659
|
)
|
|
1439
1660
|
|
|
1440
|
-
def _validate_pct_of_spend(
|
|
1441
|
-
self, hist_spend: np.ndarray, pct_of_spend: Sequence[float] | None
|
|
1442
|
-
) -> np.ndarray:
|
|
1443
|
-
"""Validates and returns the percent of spend."""
|
|
1444
|
-
if pct_of_spend is not None:
|
|
1445
|
-
if len(pct_of_spend) != len(
|
|
1446
|
-
self._meridian.input_data.get_all_paid_channels()
|
|
1447
|
-
):
|
|
1448
|
-
raise ValueError('Percent of spend must be specified for all channels.')
|
|
1449
|
-
if not math.isclose(np.sum(pct_of_spend), 1.0, abs_tol=0.001):
|
|
1450
|
-
raise ValueError('Percent of spend must sum to one.')
|
|
1451
|
-
return np.array(pct_of_spend)
|
|
1452
|
-
else:
|
|
1453
|
-
return hist_spend / np.sum(hist_spend)
|
|
1454
|
-
|
|
1455
|
-
def _validate_spend_constraints(
|
|
1456
|
-
self,
|
|
1457
|
-
fixed_budget: bool,
|
|
1458
|
-
const_lower: _SpendConstraint | None,
|
|
1459
|
-
const_upper: _SpendConstraint | None,
|
|
1460
|
-
) -> tuple[np.ndarray, np.ndarray]:
|
|
1461
|
-
"""Validates and returns the spend constraint requirements."""
|
|
1462
|
-
|
|
1463
|
-
def get_const_array(const: _SpendConstraint | None) -> np.ndarray:
|
|
1464
|
-
if const is None:
|
|
1465
|
-
const = (
|
|
1466
|
-
np.array([c.SPEND_CONSTRAINT_DEFAULT_FIXED_BUDGET])
|
|
1467
|
-
if fixed_budget
|
|
1468
|
-
else np.array([c.SPEND_CONSTRAINT_DEFAULT_FLEXIBLE_BUDGET])
|
|
1469
|
-
)
|
|
1470
|
-
elif isinstance(const, (float, int)):
|
|
1471
|
-
const = np.array([const])
|
|
1472
|
-
else:
|
|
1473
|
-
const = np.array(const)
|
|
1474
|
-
return const
|
|
1475
|
-
|
|
1476
|
-
const_lower = get_const_array(const_lower)
|
|
1477
|
-
const_upper = get_const_array(const_upper)
|
|
1478
|
-
|
|
1479
|
-
if any(
|
|
1480
|
-
len(const)
|
|
1481
|
-
not in (1, len(self._meridian.input_data.get_all_paid_channels()))
|
|
1482
|
-
for const in [const_lower, const_upper]
|
|
1483
|
-
):
|
|
1484
|
-
raise ValueError(
|
|
1485
|
-
'Spend constraints must be either a single constraint or be specified'
|
|
1486
|
-
' for all channels.'
|
|
1487
|
-
)
|
|
1488
|
-
|
|
1489
|
-
for const in const_lower:
|
|
1490
|
-
if not 0.0 <= const <= 1.0:
|
|
1491
|
-
raise ValueError(
|
|
1492
|
-
'The lower spend constraint must be between 0 and 1 inclusive.'
|
|
1493
|
-
)
|
|
1494
|
-
for const in const_upper:
|
|
1495
|
-
if const < 0:
|
|
1496
|
-
raise ValueError('The upper spend constraint must be positive.')
|
|
1497
|
-
|
|
1498
|
-
return (const_lower, const_upper)
|
|
1499
|
-
|
|
1500
1661
|
def _get_incremental_outcome_tensors(
|
|
1501
1662
|
self,
|
|
1502
1663
|
hist_spend: np.ndarray,
|
|
@@ -1717,67 +1878,12 @@ class BudgetOptimizer:
|
|
|
1717
1878
|
return xr.Dataset(
|
|
1718
1879
|
data_vars=data_vars,
|
|
1719
1880
|
coords={
|
|
1720
|
-
c.CHANNEL: (
|
|
1721
|
-
|
|
1722
|
-
self._meridian.input_data.get_all_paid_channels(),
|
|
1723
|
-
),
|
|
1724
|
-
c.METRIC: (
|
|
1725
|
-
[c.METRIC],
|
|
1726
|
-
[c.MEAN, c.MEDIAN, c.CI_LO, c.CI_HI],
|
|
1727
|
-
),
|
|
1881
|
+
c.CHANNEL: self._meridian.input_data.get_all_paid_channels(),
|
|
1882
|
+
c.METRIC: [c.MEAN, c.MEDIAN, c.CI_LO, c.CI_HI],
|
|
1728
1883
|
},
|
|
1729
1884
|
attrs=attributes | (attrs or {}),
|
|
1730
1885
|
)
|
|
1731
1886
|
|
|
1732
|
-
def _get_optimization_bounds(
|
|
1733
|
-
self,
|
|
1734
|
-
spend: np.ndarray,
|
|
1735
|
-
spend_constraint_lower: _SpendConstraint | None,
|
|
1736
|
-
spend_constraint_upper: _SpendConstraint | None,
|
|
1737
|
-
round_factor: int,
|
|
1738
|
-
fixed_budget: bool,
|
|
1739
|
-
) -> tuple[np.ndarray, np.ndarray, tuple[np.ndarray, np.ndarray]]:
|
|
1740
|
-
"""Get optimization bounds from spend and spend constraints.
|
|
1741
|
-
|
|
1742
|
-
Args:
|
|
1743
|
-
spend: np.ndarray with size `n_total_channels` containing media-level
|
|
1744
|
-
spend for all media and RF channels.
|
|
1745
|
-
spend_constraint_lower: Numeric list of size `n_total_channels` or float
|
|
1746
|
-
(same constraint for all media) indicating the lower bound of
|
|
1747
|
-
media-level spend. The lower bound of media-level spend is `(1 -
|
|
1748
|
-
spend_constraint_lower) * budget * allocation)`. The value must be
|
|
1749
|
-
between 0-1.
|
|
1750
|
-
spend_constraint_upper: Numeric list of size `n_total_channels` or float
|
|
1751
|
-
(same constraint for all media) indicating the upper bound of
|
|
1752
|
-
media-level spend. The upper bound of media-level spend is `(1 +
|
|
1753
|
-
spend_constraint_upper) * budget * allocation)`.
|
|
1754
|
-
round_factor: Integer number of digits to round optimization bounds.
|
|
1755
|
-
fixed_budget: Boolean indicating whether it's a fixed budget optimization
|
|
1756
|
-
or flexible budget optimization.
|
|
1757
|
-
|
|
1758
|
-
Returns:
|
|
1759
|
-
lower_bound: np.ndarray of size `n_total_channels` containing the treated
|
|
1760
|
-
lower bound spend for each media and RF channel.
|
|
1761
|
-
upper_bound: np.ndarray of size `n_total_channels` containing the treated
|
|
1762
|
-
upper bound spend for each media and RF channel.
|
|
1763
|
-
spend_bounds: tuple of np.ndarray of size `n_total_channels` containing
|
|
1764
|
-
the untreated lower and upper bound spend for each media and RF channel.
|
|
1765
|
-
"""
|
|
1766
|
-
(spend_const_lower, spend_const_upper) = self._validate_spend_constraints(
|
|
1767
|
-
fixed_budget, spend_constraint_lower, spend_constraint_upper
|
|
1768
|
-
)
|
|
1769
|
-
spend_bounds = (
|
|
1770
|
-
np.maximum((1 - spend_const_lower), 0),
|
|
1771
|
-
(1 + spend_const_upper),
|
|
1772
|
-
)
|
|
1773
|
-
|
|
1774
|
-
lower_bound = np.round(
|
|
1775
|
-
(spend_bounds[0] * spend),
|
|
1776
|
-
round_factor,
|
|
1777
|
-
).astype(int)
|
|
1778
|
-
upper_bound = np.round(spend_bounds[1] * spend, round_factor).astype(int)
|
|
1779
|
-
return (lower_bound, upper_bound, spend_bounds)
|
|
1780
|
-
|
|
1781
1887
|
def _update_incremental_outcome_grid(
|
|
1782
1888
|
self,
|
|
1783
1889
|
i: int,
|
|
@@ -1967,6 +2073,135 @@ class BudgetOptimizer:
|
|
|
1967
2073
|
return (spend_grid, incremental_outcome_grid)
|
|
1968
2074
|
|
|
1969
2075
|
|
|
2076
|
+
def _validate_pct_of_spend(
|
|
2077
|
+
n_channels: int,
|
|
2078
|
+
hist_spend: np.ndarray,
|
|
2079
|
+
pct_of_spend: Sequence[float] | None,
|
|
2080
|
+
) -> np.ndarray:
|
|
2081
|
+
"""Validates and returns the percent of spend."""
|
|
2082
|
+
if pct_of_spend is not None:
|
|
2083
|
+
if len(pct_of_spend) != n_channels:
|
|
2084
|
+
raise ValueError('Percent of spend must be specified for all channels.')
|
|
2085
|
+
if not math.isclose(np.sum(pct_of_spend), 1.0, abs_tol=0.001):
|
|
2086
|
+
raise ValueError('Percent of spend must sum to one.')
|
|
2087
|
+
return np.array(pct_of_spend)
|
|
2088
|
+
else:
|
|
2089
|
+
return hist_spend / np.sum(hist_spend)
|
|
2090
|
+
|
|
2091
|
+
|
|
2092
|
+
def _validate_spend_constraints(
|
|
2093
|
+
n_channels: int,
|
|
2094
|
+
const_lower: _SpendConstraint,
|
|
2095
|
+
const_upper: _SpendConstraint,
|
|
2096
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
2097
|
+
"""Validates and returns the spend constraint requirements."""
|
|
2098
|
+
|
|
2099
|
+
def get_const_array(const: _SpendConstraint) -> np.ndarray:
|
|
2100
|
+
if isinstance(const, (float, int)):
|
|
2101
|
+
const = np.array([const])
|
|
2102
|
+
else:
|
|
2103
|
+
const = np.array(const)
|
|
2104
|
+
return const
|
|
2105
|
+
|
|
2106
|
+
const_lower = get_const_array(const_lower)
|
|
2107
|
+
const_upper = get_const_array(const_upper)
|
|
2108
|
+
|
|
2109
|
+
if any(
|
|
2110
|
+
len(const) not in (1, n_channels) for const in [const_lower, const_upper]
|
|
2111
|
+
):
|
|
2112
|
+
raise ValueError(
|
|
2113
|
+
'Spend constraints must be either a single constraint or be specified'
|
|
2114
|
+
' for all channels.'
|
|
2115
|
+
)
|
|
2116
|
+
|
|
2117
|
+
for const in const_lower:
|
|
2118
|
+
if not 0.0 <= const <= 1.0:
|
|
2119
|
+
raise ValueError(
|
|
2120
|
+
'The lower spend constraint must be between 0 and 1 inclusive.'
|
|
2121
|
+
)
|
|
2122
|
+
for const in const_upper:
|
|
2123
|
+
if const < 0:
|
|
2124
|
+
raise ValueError('The upper spend constraint must be positive.')
|
|
2125
|
+
|
|
2126
|
+
return (const_lower, const_upper)
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def _get_spend_bounds(
|
|
2130
|
+
n_channels: int,
|
|
2131
|
+
spend_constraint_lower: _SpendConstraint,
|
|
2132
|
+
spend_constraint_upper: _SpendConstraint,
|
|
2133
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
2134
|
+
"""Get spend bounds from spend constraints.
|
|
2135
|
+
|
|
2136
|
+
Args:
|
|
2137
|
+
n_channels: Integer number of total channels.
|
|
2138
|
+
spend_constraint_lower: Numeric list of size `n_total_channels` or float
|
|
2139
|
+
(same constraint for all media) indicating the lower bound of media-level
|
|
2140
|
+
spend. The lower bound of media-level spend is `(1 -
|
|
2141
|
+
spend_constraint_lower) * budget * allocation)`. The value must be between
|
|
2142
|
+
0-1.
|
|
2143
|
+
spend_constraint_upper: Numeric list of size `n_total_channels` or float
|
|
2144
|
+
(same constraint for all media) indicating the upper bound of media-level
|
|
2145
|
+
spend. The upper bound of media-level spend is `(1 +
|
|
2146
|
+
spend_constraint_upper) * budget * allocation)`.
|
|
2147
|
+
|
|
2148
|
+
Returns:
|
|
2149
|
+
spend_bounds: tuple of np.ndarray of size `n_total_channels` containing
|
|
2150
|
+
the untreated lower and upper bound spend for each media and RF channel.
|
|
2151
|
+
"""
|
|
2152
|
+
(spend_const_lower, spend_const_upper) = _validate_spend_constraints(
|
|
2153
|
+
n_channels,
|
|
2154
|
+
spend_constraint_lower,
|
|
2155
|
+
spend_constraint_upper,
|
|
2156
|
+
)
|
|
2157
|
+
spend_bounds = (
|
|
2158
|
+
np.maximum((1 - spend_const_lower), 0),
|
|
2159
|
+
(1 + spend_const_upper),
|
|
2160
|
+
)
|
|
2161
|
+
return spend_bounds
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
def _get_optimization_bounds(
|
|
2165
|
+
n_channels: int,
|
|
2166
|
+
spend: np.ndarray,
|
|
2167
|
+
round_factor: int,
|
|
2168
|
+
spend_constraint_lower: _SpendConstraint,
|
|
2169
|
+
spend_constraint_upper: _SpendConstraint,
|
|
2170
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
2171
|
+
"""Get optimization bounds from spend and spend constraints.
|
|
2172
|
+
|
|
2173
|
+
Args:
|
|
2174
|
+
n_channels: Integer number of total channels.
|
|
2175
|
+
spend: np.ndarray with size `n_total_channels` containing media-level spend
|
|
2176
|
+
for all media and RF channels.
|
|
2177
|
+
round_factor: Integer number of digits to round optimization bounds.
|
|
2178
|
+
spend_constraint_lower: Numeric list of size `n_total_channels` or float
|
|
2179
|
+
(same constraint for all media) indicating the lower bound of media-level
|
|
2180
|
+
spend. The lower bound of media-level spend is `(1 -
|
|
2181
|
+
spend_constraint_lower) * budget * allocation)`. The value must be between
|
|
2182
|
+
0-1.
|
|
2183
|
+
spend_constraint_upper: Numeric list of size `n_total_channels` or float
|
|
2184
|
+
(same constraint for all media) indicating the upper bound of media-level
|
|
2185
|
+
spend. The upper bound of media-level spend is `(1 +
|
|
2186
|
+
spend_constraint_upper) * budget * allocation)`.
|
|
2187
|
+
|
|
2188
|
+
Returns:
|
|
2189
|
+
lower_bound: np.ndarray of size `n_total_channels` containing the treated
|
|
2190
|
+
lower bound spend for each media and RF channel.
|
|
2191
|
+
upper_bound: np.ndarray of size `n_total_channels` containing the treated
|
|
2192
|
+
upper bound spend for each media and RF channel.
|
|
2193
|
+
"""
|
|
2194
|
+
spend_bounds = _get_spend_bounds(
|
|
2195
|
+
n_channels=n_channels,
|
|
2196
|
+
spend_constraint_lower=spend_constraint_lower,
|
|
2197
|
+
spend_constraint_upper=spend_constraint_upper,
|
|
2198
|
+
)
|
|
2199
|
+
rounded_spend = np.round(spend, round_factor).astype(int)
|
|
2200
|
+
lower = np.round((spend_bounds[0] * rounded_spend), round_factor).astype(int)
|
|
2201
|
+
upper = np.round(spend_bounds[1] * rounded_spend, round_factor).astype(int)
|
|
2202
|
+
return (lower, upper)
|
|
2203
|
+
|
|
2204
|
+
|
|
1970
2205
|
def _validate_budget(
|
|
1971
2206
|
fixed_budget: bool,
|
|
1972
2207
|
budget: float | None,
|
|
@@ -2063,3 +2298,30 @@ def _exceeds_optimization_constraints(
|
|
|
2063
2298
|
return cur_total_roi < target_value and roi_grid_point < cur_total_roi
|
|
2064
2299
|
else:
|
|
2065
2300
|
return roi_grid_point < scenario.target_value
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def _raise_warning_if_target_constraints_not_met(
|
|
2304
|
+
target_roi: float | None,
|
|
2305
|
+
target_mroi: float | None,
|
|
2306
|
+
optimized_data: xr.Dataset,
|
|
2307
|
+
) -> None:
|
|
2308
|
+
"""Raises a warning if the target constraints are not met."""
|
|
2309
|
+
if target_roi:
|
|
2310
|
+
# Total ROI is a scalar value.
|
|
2311
|
+
optimized_roi = optimized_data.attrs[c.TOTAL_ROI]
|
|
2312
|
+
if optimized_roi < target_roi:
|
|
2313
|
+
warnings.warn(
|
|
2314
|
+
f'Target ROI constraint was not met. The target ROI is {target_roi}'
|
|
2315
|
+
f', but the actual ROI is {optimized_roi}.'
|
|
2316
|
+
)
|
|
2317
|
+
elif target_mroi:
|
|
2318
|
+
# Compare each channel's marginal ROI to the target.
|
|
2319
|
+
# optimized_data[c.MROI] is an array of shape (n_channels, 4), where the
|
|
2320
|
+
# last dimension is [mean, median, ci_lo, ci_hi].
|
|
2321
|
+
optimized_mroi = optimized_data[c.MROI][:, 0]
|
|
2322
|
+
if np.any(optimized_mroi < target_mroi):
|
|
2323
|
+
warnings.warn(
|
|
2324
|
+
'Target marginal ROI constraint was not met. The target marginal'
|
|
2325
|
+
f' ROI is {target_mroi}, but the actual channel marginal ROIs are'
|
|
2326
|
+
f' {optimized_mroi}.'
|
|
2327
|
+
)
|