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.
@@ -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. The order matches `InputData.get_all_paid_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
- # TODO: b/402950014 - Add per-channel constraints parameter.
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` with shape `(n_paid_channels,)` containing the
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
- if (
161
- isinstance(scenario, FixedBudgetScenario)
162
- and scenario.total_budget is None
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
- if selected_times is not None:
1154
- start_date, end_date = selected_times
1155
- selected_time_dims = self._meridian.expand_selected_time_dims(
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
- historical_spend=hist_spend,
1198
- spend_bound_lower=optimization_lower_bound,
1199
- spend_bound_upper=optimization_upper_bound,
1200
- selected_times=selected_time_dims,
1201
- round_factor=round_factor,
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
- total_budget = None if use_historical_budget else np.sum(rounded_spend)
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=hist_spend,
1408
+ hist_spend=optimization_grid.historical_spend,
1228
1409
  spend=rounded_spend,
1229
- selected_times=selected_time_dims,
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=hist_spend,
1418
+ hist_spend=optimization_grid.historical_spend,
1238
1419
  spend=rounded_spend,
1239
- selected_times=selected_time_dims,
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=hist_spend,
1256
- spend=optimal_spend,
1257
- selected_times=selected_time_dims,
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
- self._raise_warning_if_target_constraints_not_met(
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
- hist_spend,
1275
- out=np.zeros_like(hist_spend, dtype=float),
1276
- where=hist_spend != 0,
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=historical_spend,
1368
- spend_bound_lower=spend_bound_lower,
1369
- spend_bound_upper=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=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=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=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
- [c.GRID_SPEND_INDEX],
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
- [c.CHANNEL],
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
+ )