google-meridian 1.1.5__py3-none-any.whl → 1.2.0__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.
@@ -24,6 +24,7 @@ import warnings
24
24
 
25
25
  import altair as alt
26
26
  import jinja2
27
+ from meridian import backend
27
28
  from meridian import constants as c
28
29
  from meridian.analysis import analyzer
29
30
  from meridian.analysis import formatter
@@ -32,7 +33,6 @@ from meridian.data import time_coordinates as tc
32
33
  from meridian.model import model
33
34
  import numpy as np
34
35
  import pandas as pd
35
- import tensorflow as tf
36
36
  import xarray as xr
37
37
 
38
38
 
@@ -40,6 +40,10 @@ __all__ = [
40
40
  'BudgetOptimizer',
41
41
  'OptimizationGrid',
42
42
  'OptimizationResults',
43
+ 'FixedBudgetScenario',
44
+ 'FlexibleBudgetScenario',
45
+ 'get_optimization_bounds',
46
+ 'get_round_factor',
43
47
  ]
44
48
 
45
49
  # Disable max row limitations in Altair.
@@ -139,12 +143,12 @@ class OptimizationGrid:
139
143
  return self._grid_dataset
140
144
 
141
145
  @property
142
- def spend_grid(self) -> np.ndarray:
146
+ def spend_grid(self) -> xr.DataArray:
143
147
  """The spend grid."""
144
148
  return self.grid_dataset.spend_grid
145
149
 
146
150
  @property
147
- def incremental_outcome_grid(self) -> np.ndarray:
151
+ def incremental_outcome_grid(self) -> xr.DataArray:
148
152
  """The incremental outcome grid."""
149
153
  return self.grid_dataset.incremental_outcome_grid
150
154
 
@@ -231,11 +235,7 @@ class OptimizationGrid:
231
235
  spend_constraint_upper=spend_constraint_upper,
232
236
  )
233
237
  )
234
- self.check_optimization_bounds(
235
- lower_bound=optimization_lower_bound,
236
- upper_bound=optimization_upper_bound,
237
- )
238
- round_factor = _get_round_factor(budget, self.gtol)
238
+ round_factor = get_round_factor(budget, self.gtol)
239
239
  if round_factor != self.round_factor:
240
240
  warnings.warn(
241
241
  'Optimization accuracy may suffer owing to budget level differences.'
@@ -244,7 +244,7 @@ class OptimizationGrid:
244
244
  ' It is only a problem when you use a much smaller budget, '
245
245
  ' for which the intended step size is smaller. '
246
246
  )
247
- (spend_grid, incremental_outcome_grid) = self._trim_grid(
247
+ (spend_grid, incremental_outcome_grid) = self.trim_grids(
248
248
  spend_bound_lower=optimization_lower_bound,
249
249
  spend_bound_upper=optimization_upper_bound,
250
250
  )
@@ -267,86 +267,12 @@ class OptimizationGrid:
267
267
  },
268
268
  )
269
269
 
270
- def _grid_search(
271
- self,
272
- spend_grid: np.ndarray,
273
- incremental_outcome_grid: np.ndarray,
274
- scenario: FixedBudgetScenario | FlexibleBudgetScenario,
275
- ) -> np.ndarray:
276
- """Hill-climbing search algorithm for budget optimization.
277
-
278
- Args:
279
- spend_grid: Discrete grid with dimensions (`grid_length` x
280
- `n_total_channels`) containing spend by channel for all media and RF
281
- channels, used in the hill-climbing search algorithm.
282
- incremental_outcome_grid: Discrete grid with dimensions (`grid_length` x
283
- `n_total_channels`) containing incremental outcome by channel for all
284
- media and RF channels, used in the hill-climbing search algorithm.
285
- scenario: The optimization scenario with corresponding parameters.
286
-
287
- Returns:
288
- optimal_spend: `np.ndarray` of dimension (`n_total_channels`) containing
289
- the media spend that maximizes incremental outcome based on spend
290
- constraints for all media and RF channels.
291
- optimal_inc_outcome: `np.ndarray` of dimension (`n_total_channels`)
292
- containing the post optimization incremental outcome per channel for all
293
- media and RF channels.
294
- """
295
- spend = spend_grid[0, :].copy()
296
- incremental_outcome = incremental_outcome_grid[0, :].copy()
297
- spend_grid = spend_grid[1:, :]
298
- incremental_outcome_grid = incremental_outcome_grid[1:, :]
299
- iterative_roi_grid = np.round(
300
- tf.math.divide_no_nan(
301
- incremental_outcome_grid - incremental_outcome, spend_grid - spend
302
- ),
303
- decimals=8,
304
- )
305
- while True:
306
- spend_optimal = spend.astype(int)
307
- # If none of the exit criteria are met roi_grid will eventually be filled
308
- # with all nans.
309
- if np.isnan(iterative_roi_grid).all():
310
- break
311
- point = np.unravel_index(
312
- np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
313
- )
314
- row_idx = point[0]
315
- media_idx = point[1]
316
- spend[media_idx] = spend_grid[row_idx, media_idx]
317
- incremental_outcome[media_idx] = incremental_outcome_grid[
318
- row_idx, media_idx
319
- ]
320
- roi_grid_point = iterative_roi_grid[row_idx, media_idx]
321
- if _exceeds_optimization_constraints(
322
- spend=spend,
323
- incremental_outcome=incremental_outcome,
324
- roi_grid_point=roi_grid_point,
325
- scenario=scenario,
326
- ):
327
- break
328
-
329
- iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
330
- iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
331
- tf.math.divide_no_nan(
332
- incremental_outcome_grid[row_idx + 1 :, media_idx]
333
- - incremental_outcome_grid[row_idx, media_idx],
334
- spend_grid[row_idx + 1 :, media_idx]
335
- - spend_grid[row_idx, media_idx],
336
- ),
337
- decimals=8,
338
- )
339
- return spend_optimal
340
-
341
- def _trim_grid(
270
+ def trim_grids(
342
271
  self,
343
272
  spend_bound_lower: np.ndarray,
344
273
  spend_bound_upper: np.ndarray,
345
- ) -> tuple[np.ndarray, np.ndarray]:
346
- """Trim the grids based on a more restricted spend bound.
347
-
348
- It is assumed that spend bounds are validated: their values are within the
349
- grid coverage and they are rounded using this grid's round factor.
274
+ ) -> tuple[xr.DataArray, xr.DataArray]:
275
+ """Trims the grids based on a more restricted spend bound.
350
276
 
351
277
  Args:
352
278
  spend_bound_lower: The lower bound of spend for each channel. Must be in
@@ -355,12 +281,15 @@ class OptimizationGrid:
355
281
  the same order as `self.channels`.
356
282
 
357
283
  Returns:
358
- updated_spend: The updated spend grid with valid spend values moved up to
359
- the first row and invalid spend values filled with NaN.
360
- updated_incremental_outcome: The updated incremental outcome grid with the
361
- corresponding incremental outcome values moved up to the first row and
362
- invalid incremental outcome values filled with NaN.
284
+ updated_spend: The updated spend grid with only valid spend values.
285
+ updated_incremental_outcome: The updated incremental outcome grid
286
+ containing only the corresponding incremental outcome values for the
287
+ updated spend grid.
363
288
  """
289
+ self.check_optimization_bounds(
290
+ lower_bound=spend_bound_lower,
291
+ upper_bound=spend_bound_upper,
292
+ )
364
293
  spend_grid = self.spend_grid
365
294
  updated_spend = self.spend_grid.copy()
366
295
  updated_incremental_outcome = self.incremental_outcome_grid.copy()
@@ -387,6 +316,12 @@ class OptimizationGrid:
387
316
  updated_spend[nan_indices:, ch] = np.nan
388
317
  updated_incremental_outcome[nan_indices:, ch] = np.nan
389
318
 
319
+ # Drop the rows with all NaN values.
320
+ updated_spend = updated_spend.dropna(dim=c.GRID_SPEND_INDEX, how='all')
321
+ updated_incremental_outcome = updated_incremental_outcome.dropna(
322
+ dim=c.GRID_SPEND_INDEX, how='all'
323
+ )
324
+
390
325
  return (updated_spend, updated_incremental_outcome)
391
326
 
392
327
  def check_optimization_bounds(
@@ -429,6 +364,74 @@ class OptimizationGrid:
429
364
  + '\n'.join(errors)
430
365
  )
431
366
 
367
+ def _grid_search(
368
+ self,
369
+ spend_grid: xr.DataArray,
370
+ incremental_outcome_grid: xr.DataArray,
371
+ scenario: FixedBudgetScenario | FlexibleBudgetScenario,
372
+ ) -> np.ndarray:
373
+ """Hill-climbing search algorithm for budget optimization.
374
+
375
+ Args:
376
+ spend_grid: Discrete grid with dimensions (`grid_length` x
377
+ `n_total_channels`) containing spend by channel for all media and RF
378
+ channels, used in the hill-climbing search algorithm.
379
+ incremental_outcome_grid: Discrete grid with dimensions (`grid_length` x
380
+ `n_total_channels`) containing incremental outcome by channel for all
381
+ media and RF channels, used in the hill-climbing search algorithm.
382
+ scenario: The optimization scenario with corresponding parameters.
383
+
384
+ Returns:
385
+ `np.ndarray` of dimension (`n_total_channels`) containing the optimal
386
+ media spend that maximizes incremental outcome based on spend constraints
387
+ for all media and RF channels.
388
+ """
389
+ spend = spend_grid[0, :].copy()
390
+ incremental_outcome = incremental_outcome_grid[0, :].copy()
391
+ spend_grid = spend_grid[1:, :]
392
+ incremental_outcome_grid = incremental_outcome_grid[1:, :]
393
+ iterative_roi_grid = np.round(
394
+ backend.divide_no_nan(
395
+ incremental_outcome_grid - incremental_outcome, spend_grid - spend
396
+ ),
397
+ decimals=8,
398
+ )
399
+ while True:
400
+ spend_optimal = spend.astype(int)
401
+ # If none of the exit criteria are met roi_grid will eventually be filled
402
+ # with all nans.
403
+ if np.isnan(iterative_roi_grid).all():
404
+ break
405
+ point = np.unravel_index(
406
+ np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
407
+ )
408
+ row_idx = point[0]
409
+ media_idx = point[1]
410
+ spend[media_idx] = spend_grid[row_idx, media_idx]
411
+ incremental_outcome[media_idx] = incremental_outcome_grid[
412
+ row_idx, media_idx
413
+ ]
414
+ roi_grid_point = iterative_roi_grid[row_idx, media_idx]
415
+ if _exceeds_optimization_constraints(
416
+ spend=spend,
417
+ incremental_outcome=incremental_outcome,
418
+ roi_grid_point=roi_grid_point,
419
+ scenario=scenario,
420
+ ):
421
+ break
422
+
423
+ iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
424
+ iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
425
+ backend.divide_no_nan(
426
+ incremental_outcome_grid[row_idx + 1 :, media_idx]
427
+ - incremental_outcome_grid[row_idx, media_idx],
428
+ spend_grid[row_idx + 1 :, media_idx]
429
+ - spend_grid[row_idx, media_idx],
430
+ ),
431
+ decimals=8,
432
+ )
433
+ return spend_optimal
434
+
432
435
 
433
436
  @dataclasses.dataclass(frozen=True)
434
437
  class OptimizationResults:
@@ -881,9 +884,9 @@ class OptimizationResults:
881
884
  In particular:
882
885
 
883
886
  1. `spend_multiplier` matches the discrete optimization grid, considering
884
- the grid step size and any channel-level constraint bounds.
887
+ the grid step size and any channel-level constraint bounds.
885
888
  2. `selected_times`, `by_reach`, and `use_optimal_frequency` match the
886
- values set in `BudgetOptimizer.optimize()`.
889
+ values set in `BudgetOptimizer.optimize()`.
887
890
 
888
891
  Returns:
889
892
  A dataset returned by `Analyzer.response_curves()`, per budget
@@ -1329,25 +1332,25 @@ class BudgetOptimizer:
1329
1332
  The following optimization parameters are assigned default values based on
1330
1333
  the model input data:
1331
1334
  1. Flighting pattern. This is the relative allocation of a channel's media
1332
- units across geos and time periods. By default, the historical flighting
1333
- pattern is used. The default can be overridden by passing
1334
- `new_data.media`. The flighting pattern is held constant during
1335
- optimization and does not depend on the overall budget assigned to the
1336
- channel.
1335
+ units across geos and time periods. By default, the historical flighting
1336
+ pattern is used. The default can be overridden by passing
1337
+ `new_data.media`. The flighting pattern is held constant during
1338
+ optimization and does not depend on the overall budget assigned to the
1339
+ channel.
1337
1340
  2. Cost per media unit. By default, the historical spend divided by
1338
- historical media units is used. This can optionally vary by geo or time
1339
- period or both depending on whether the spend data has geo and time
1340
- dimensions. The default can be overridden by passing `new_data.spend`.
1341
- The cost per media unit is held constant during optimization and does not
1342
- depend on the overall budget assigned to the channel.
1341
+ historical media units is used. This can optionally vary by geo or time
1342
+ period or both depending on whether the spend data has geo and time
1343
+ dimensions. The default can be overridden by passing `new_data.spend`.
1344
+ The cost per media unit is held constant during optimization and does not
1345
+ depend on the overall budget assigned to the channel.
1343
1346
  3. Center of the spend box constraint for each channel. By default, the
1344
- historical percentage of spend within `selected_geos` and between
1345
- `start_date` and `end_date` is used. This can be overridden by passing
1346
- `pct_of_spend`.
1347
+ historical percentage of spend within `selected_geos` and between
1348
+ `start_date` and `end_date` is used. This can be overridden by passing
1349
+ `pct_of_spend`.
1347
1350
  4. Total budget to be allocated (for fixed budget scenarios only). By
1348
- default, the historical spend within `selected_geos` and between
1349
- `start_date` and `end_date` is used. This can be overridden by passing
1350
- `budget`.
1351
+ default, the historical spend within `selected_geos` and between
1352
+ `start_date` and `end_date` is used. This can be overridden by passing
1353
+ `budget`.
1351
1354
 
1352
1355
  Passing `new_data.media` (or `new_data.reach` or `new_data.frequency`) will
1353
1356
  override both the flighting pattern and cost per media unit. Passing
@@ -1530,7 +1533,8 @@ class BudgetOptimizer:
1530
1533
  use_kpi=use_kpi,
1531
1534
  hist_spend=optimization_grid.historical_spend,
1532
1535
  spend=spend.non_optimized,
1533
- selected_times=optimization_grid.selected_times,
1536
+ start_date=start_date,
1537
+ end_date=end_date,
1534
1538
  confidence_level=confidence_level,
1535
1539
  batch_size=batch_size,
1536
1540
  use_historical_budget=use_historical_budget,
@@ -1541,7 +1545,8 @@ class BudgetOptimizer:
1541
1545
  use_kpi=use_kpi,
1542
1546
  hist_spend=optimization_grid.historical_spend,
1543
1547
  spend=spend.non_optimized,
1544
- selected_times=optimization_grid.selected_times,
1548
+ start_date=start_date,
1549
+ end_date=end_date,
1545
1550
  optimal_frequency=optimization_grid.optimal_frequency,
1546
1551
  confidence_level=confidence_level,
1547
1552
  batch_size=batch_size,
@@ -1560,7 +1565,8 @@ class BudgetOptimizer:
1560
1565
  use_kpi=use_kpi,
1561
1566
  hist_spend=optimization_grid.historical_spend,
1562
1567
  spend=spend.optimized,
1563
- selected_times=optimization_grid.selected_times,
1568
+ start_date=start_date,
1569
+ end_date=end_date,
1564
1570
  optimal_frequency=optimization_grid.optimal_frequency,
1565
1571
  attrs=constraints,
1566
1572
  confidence_level=confidence_level,
@@ -1601,15 +1607,15 @@ class BudgetOptimizer:
1601
1607
 
1602
1608
  def create_optimization_tensors(
1603
1609
  self,
1604
- time: Sequence[str] | tf.Tensor,
1605
- cpmu: tf.Tensor | None = None,
1606
- media: tf.Tensor | None = None,
1607
- media_spend: tf.Tensor | None = None,
1608
- cprf: tf.Tensor | None = None,
1609
- rf_impressions: tf.Tensor | None = None,
1610
- frequency: tf.Tensor | None = None,
1611
- rf_spend: tf.Tensor | None = None,
1612
- revenue_per_kpi: tf.Tensor | None = None,
1610
+ time: Sequence[str] | backend.Tensor,
1611
+ cpmu: backend.Tensor | None = None,
1612
+ media: backend.Tensor | None = None,
1613
+ media_spend: backend.Tensor | None = None,
1614
+ cprf: backend.Tensor | None = None,
1615
+ rf_impressions: backend.Tensor | None = None,
1616
+ frequency: backend.Tensor | None = None,
1617
+ rf_spend: backend.Tensor | None = None,
1618
+ revenue_per_kpi: backend.Tensor | None = None,
1613
1619
  use_optimal_frequency: bool = True,
1614
1620
  ) -> analyzer.DataTensors:
1615
1621
  """Creates a `DataTensors` for optimizations from CPM and flighting data.
@@ -1689,7 +1695,7 @@ class BudgetOptimizer:
1689
1695
  revenue_per_kpi=revenue_per_kpi,
1690
1696
  use_optimal_frequency=use_optimal_frequency,
1691
1697
  )
1692
- n_times = time.shape[0] if isinstance(time, tf.Tensor) else len(time)
1698
+ n_times = time.shape[0] if isinstance(time, backend.Tensor) else len(time)
1693
1699
  n_geos = self._meridian.n_geos
1694
1700
  revenue_per_kpi = (
1695
1701
  _expand_tensor(revenue_per_kpi, (n_geos, n_times))
@@ -1714,25 +1720,25 @@ class BudgetOptimizer:
1714
1720
  )
1715
1721
  tensors[c.RF_SPEND] = allocated_impressions * cprf
1716
1722
  if use_optimal_frequency:
1717
- frequency = tf.ones_like(allocated_impressions)
1723
+ frequency = backend.ones_like(allocated_impressions)
1718
1724
  tensors[c.FREQUENCY] = _expand_tensor(frequency, shape)
1719
- tensors[c.REACH] = tf.math.divide_no_nan(
1725
+ tensors[c.REACH] = backend.divide_no_nan(
1720
1726
  allocated_impressions, tensors[c.FREQUENCY]
1721
1727
  )
1722
1728
  if rf_spend is not None:
1723
1729
  shape = (n_geos, n_times, rf_spend.shape[-1])
1724
1730
  cprf = _expand_tensor(cprf, shape)
1725
1731
  tensors[c.RF_SPEND] = self._allocate_tensor_by_population(rf_spend)
1726
- impressions = tf.math.divide_no_nan(tensors[c.RF_SPEND], cprf)
1732
+ impressions = backend.divide_no_nan(tensors[c.RF_SPEND], cprf)
1727
1733
  if use_optimal_frequency:
1728
- frequency = tf.ones_like(impressions)
1734
+ frequency = backend.ones_like(impressions)
1729
1735
  tensors[c.FREQUENCY] = _expand_tensor(frequency, shape)
1730
- tensors[c.REACH] = tf.math.divide_no_nan(
1736
+ tensors[c.REACH] = backend.divide_no_nan(
1731
1737
  impressions, tensors[c.FREQUENCY]
1732
1738
  )
1733
1739
  if revenue_per_kpi is not None:
1734
1740
  tensors[c.REVENUE_PER_KPI] = revenue_per_kpi
1735
- tensors[c.TIME] = tf.convert_to_tensor(time)
1741
+ tensors[c.TIME] = backend.to_tensor(time)
1736
1742
  return analyzer.DataTensors(**tensors)
1737
1743
 
1738
1744
  def _validate_grid(
@@ -1848,7 +1854,7 @@ class BudgetOptimizer:
1848
1854
  )
1849
1855
  return False
1850
1856
 
1851
- round_factor = _get_round_factor(budget, gtol)
1857
+ round_factor = get_round_factor(budget, gtol)
1852
1858
  if round_factor != optimization_grid.round_factor:
1853
1859
  warnings.warn(
1854
1860
  'Optimization accuracy may suffer owing to budget level differences.'
@@ -1991,7 +1997,7 @@ class BudgetOptimizer:
1991
1997
  pct_of_spend=pct_of_spend,
1992
1998
  )
1993
1999
  spend = budget * valid_pct_of_spend
1994
- round_factor = _get_round_factor(budget, gtol)
2000
+ round_factor = get_round_factor(budget, gtol)
1995
2001
  (optimization_lower_bound, optimization_upper_bound) = (
1996
2002
  get_optimization_bounds(
1997
2003
  n_channels=n_paid_channels,
@@ -2007,14 +2013,14 @@ class BudgetOptimizer:
2007
2013
  rf_spend=filled_data.rf_spend,
2008
2014
  revenue_per_kpi=filled_data.revenue_per_kpi,
2009
2015
  )
2010
- optimal_frequency = tf.convert_to_tensor(
2016
+ optimal_frequency = backend.to_tensor(
2011
2017
  self._analyzer.optimal_freq(
2012
2018
  new_data=opt_freq_data,
2013
2019
  use_posterior=use_posterior,
2014
2020
  selected_times=selected_times,
2015
2021
  use_kpi=use_kpi,
2016
2022
  ).optimal_frequency,
2017
- dtype=tf.float32,
2023
+ dtype=backend.float32,
2018
2024
  )
2019
2025
  else:
2020
2026
  optimal_frequency = None
@@ -2110,7 +2116,7 @@ class BudgetOptimizer:
2110
2116
  )
2111
2117
  else:
2112
2118
  assert new_data.time is not None
2113
- new_times_str = new_data.time.numpy().astype(str).tolist()
2119
+ new_times_str = np.asarray(new_data.time).astype(str).tolist()
2114
2120
  time_coordinates = tc.TimeCoordinates.from_dates(new_times_str)
2115
2121
  expanded_dates = time_coordinates.expand_selected_time_dims(
2116
2122
  start_date=start_date,
@@ -2126,9 +2132,9 @@ class BudgetOptimizer:
2126
2132
  new_data: analyzer.DataTensors | None = None,
2127
2133
  optimal_frequency: Sequence[float] | None = None,
2128
2134
  ) -> tuple[
2129
- tf.Tensor | None,
2130
- tf.Tensor | None,
2131
- tf.Tensor | None,
2135
+ backend.Tensor | None,
2136
+ backend.Tensor | None,
2137
+ backend.Tensor | None,
2132
2138
  ]:
2133
2139
  """Gets the tensors for incremental outcome, based on spend data.
2134
2140
 
@@ -2157,7 +2163,7 @@ class BudgetOptimizer:
2157
2163
  frequency is used for the optimization scenario.
2158
2164
 
2159
2165
  Returns:
2160
- Tuple of tf.tensors (new_media, new_reach, new_frequency).
2166
+ Tuple of backend.tensors (new_media, new_reach, new_frequency).
2161
2167
  """
2162
2168
  new_data = new_data or analyzer.DataTensors()
2163
2169
  filled_data = new_data.validate_and_fill_missing_data(
@@ -2166,7 +2172,7 @@ class BudgetOptimizer:
2166
2172
  )
2167
2173
  if self._meridian.n_media_channels > 0:
2168
2174
  new_media = (
2169
- tf.math.divide_no_nan(
2175
+ backend.divide_no_nan(
2170
2176
  spend[: self._meridian.n_media_channels],
2171
2177
  hist_spend[: self._meridian.n_media_channels],
2172
2178
  )
@@ -2177,7 +2183,7 @@ class BudgetOptimizer:
2177
2183
  if self._meridian.n_rf_channels > 0:
2178
2184
  rf_impressions = filled_data.reach * filled_data.frequency
2179
2185
  new_rf_impressions = (
2180
- tf.math.divide_no_nan(
2186
+ backend.divide_no_nan(
2181
2187
  spend[-self._meridian.n_rf_channels :],
2182
2188
  hist_spend[-self._meridian.n_rf_channels :],
2183
2189
  )
@@ -2188,8 +2194,8 @@ class BudgetOptimizer:
2188
2194
  if optimal_frequency is None
2189
2195
  else optimal_frequency
2190
2196
  )
2191
- new_reach = tf.math.divide_no_nan(new_rf_impressions, frequency)
2192
- new_frequency = tf.math.divide_no_nan(new_rf_impressions, new_reach)
2197
+ new_reach = backend.divide_no_nan(new_rf_impressions, frequency)
2198
+ new_frequency = backend.divide_no_nan(new_rf_impressions, new_reach)
2193
2199
  else:
2194
2200
  new_reach = None
2195
2201
  new_frequency = None
@@ -2203,7 +2209,8 @@ class BudgetOptimizer:
2203
2209
  new_data: analyzer.DataTensors | None = None,
2204
2210
  use_posterior: bool = True,
2205
2211
  use_kpi: bool = False,
2206
- selected_times: Sequence[str] | Sequence[bool] | None = None,
2212
+ start_date: tc.Date = None,
2213
+ end_date: tc.Date = None,
2207
2214
  optimal_frequency: Sequence[float] | None = None,
2208
2215
  attrs: Mapping[str, Any] | None = None,
2209
2216
  confidence_level: float = c.DEFAULT_CONFIDENCE_LEVEL,
@@ -2216,8 +2223,11 @@ class BudgetOptimizer:
2216
2223
  c.PAID_DATA + (c.TIME,),
2217
2224
  self._meridian,
2218
2225
  )
2219
- spend_tensor = tf.convert_to_tensor(spend, dtype=tf.float32)
2220
- hist_spend = tf.convert_to_tensor(hist_spend, dtype=tf.float32)
2226
+ selected_times = self._validate_selected_times(
2227
+ start_date=start_date, end_date=end_date, new_data=new_data
2228
+ )
2229
+ spend_tensor = backend.to_tensor(spend, dtype=backend.float32)
2230
+ hist_spend = backend.to_tensor(hist_spend, dtype=backend.float32)
2221
2231
  (new_media, new_reach, new_frequency) = (
2222
2232
  self._get_incremental_outcome_tensors(
2223
2233
  hist_spend,
@@ -2283,7 +2293,7 @@ class BudgetOptimizer:
2283
2293
  )
2284
2294
  effectiveness_with_mean_median_and_ci = (
2285
2295
  analyzer.get_central_tendency_and_ci(
2286
- data=tf.math.divide_no_nan(
2296
+ data=backend.divide_no_nan(
2287
2297
  incremental_outcome, aggregated_impressions
2288
2298
  ),
2289
2299
  confidence_level=confidence_level,
@@ -2292,12 +2302,12 @@ class BudgetOptimizer:
2292
2302
  )
2293
2303
 
2294
2304
  roi = analyzer.get_central_tendency_and_ci(
2295
- data=tf.math.divide_no_nan(incremental_outcome, spend_tensor),
2305
+ data=backend.divide_no_nan(incremental_outcome, spend_tensor),
2296
2306
  confidence_level=confidence_level,
2297
2307
  include_median=True,
2298
2308
  )
2299
2309
  marginal_roi = analyzer.get_central_tendency_and_ci(
2300
- data=tf.math.divide_no_nan(
2310
+ data=backend.divide_no_nan(
2301
2311
  mroi_numerator, spend_tensor * incremental_increase
2302
2312
  ),
2303
2313
  confidence_level=confidence_level,
@@ -2305,13 +2315,13 @@ class BudgetOptimizer:
2305
2315
  )
2306
2316
 
2307
2317
  cpik = analyzer.get_central_tendency_and_ci(
2308
- data=tf.math.divide_no_nan(spend_tensor, incremental_outcome),
2318
+ data=backend.divide_no_nan(spend_tensor, incremental_outcome),
2309
2319
  confidence_level=confidence_level,
2310
2320
  include_median=True,
2311
2321
  )
2312
- total_inc_outcome = np.sum(incremental_outcome, -1)
2313
- total_cpik = np.mean(
2314
- tf.math.divide_no_nan(budget, total_inc_outcome),
2322
+ total_inc_outcome = backend.reduce_sum(incremental_outcome, -1)
2323
+ total_cpik = backend.reduce_mean(
2324
+ backend.divide_no_nan(budget, total_inc_outcome),
2315
2325
  axis=(0, 1),
2316
2326
  )
2317
2327
 
@@ -2333,21 +2343,11 @@ class BudgetOptimizer:
2333
2343
  c.CPIK: ([c.CHANNEL, c.METRIC], cpik),
2334
2344
  }
2335
2345
 
2336
- all_times = (
2337
- filled_data.time.numpy().astype(str).tolist()
2338
- if filled_data.time is not None
2339
- else self._meridian.input_data.time.values.tolist()
2340
- )
2341
- if selected_times is not None and all(
2342
- isinstance(time, bool) for time in selected_times
2343
- ):
2344
- selected_times = [
2345
- time for time, selected in zip(all_times, selected_times) if selected
2346
- ]
2346
+ all_times = np.asarray(filled_data.time).astype(str).tolist()
2347
2347
 
2348
2348
  attributes = {
2349
- c.START_DATE: min(selected_times) if selected_times else all_times[0],
2350
- c.END_DATE: max(selected_times) if selected_times else all_times[-1],
2349
+ c.START_DATE: start_date if start_date else all_times[0],
2350
+ c.END_DATE: end_date if end_date else all_times[-1],
2351
2351
  c.BUDGET: budget,
2352
2352
  c.PROFIT: total_incremental_outcome - budget,
2353
2353
  c.TOTAL_INCREMENTAL_OUTCOME: total_incremental_outcome,
@@ -2373,7 +2373,7 @@ class BudgetOptimizer:
2373
2373
  self,
2374
2374
  i: int,
2375
2375
  incremental_outcome_grid: np.ndarray,
2376
- multipliers_grid: tf.Tensor,
2376
+ multipliers_grid: backend.Tensor,
2377
2377
  new_data: analyzer.DataTensors | None = None,
2378
2378
  selected_times: Sequence[str] | Sequence[bool] | None = None,
2379
2379
  use_posterior: bool = True,
@@ -2432,8 +2432,10 @@ class BudgetOptimizer:
2432
2432
  new_frequency = None
2433
2433
  new_reach = None
2434
2434
  elif optimal_frequency is not None:
2435
- new_frequency = tf.ones_like(filled_data.frequency) * optimal_frequency
2436
- new_reach = tf.math.divide_no_nan(
2435
+ new_frequency = (
2436
+ backend.ones_like(filled_data.frequency) * optimal_frequency
2437
+ )
2438
+ new_reach = backend.divide_no_nan(
2437
2439
  multipliers_grid[i, -self._meridian.n_rf_channels :]
2438
2440
  * filled_data.reach
2439
2441
  * filled_data.frequency,
@@ -2450,20 +2452,22 @@ class BudgetOptimizer:
2450
2452
  # (n_chains x n_draws x n_total_channels). Incremental_outcome_grid requires
2451
2453
  # incremental outcome by channel.
2452
2454
  incremental_outcome_grid[i, :] = np.mean(
2453
- self._analyzer.incremental_outcome(
2454
- use_posterior=use_posterior,
2455
- new_data=analyzer.DataTensors(
2456
- media=new_media,
2457
- reach=new_reach,
2458
- frequency=new_frequency,
2459
- revenue_per_kpi=filled_data.revenue_per_kpi,
2460
- ),
2461
- selected_times=selected_times,
2462
- use_kpi=use_kpi,
2463
- include_non_paid_channels=False,
2464
- batch_size=batch_size,
2455
+ np.asarray(
2456
+ self._analyzer.incremental_outcome(
2457
+ use_posterior=use_posterior,
2458
+ new_data=analyzer.DataTensors(
2459
+ media=new_media,
2460
+ reach=new_reach,
2461
+ frequency=new_frequency,
2462
+ revenue_per_kpi=filled_data.revenue_per_kpi,
2463
+ ),
2464
+ selected_times=selected_times,
2465
+ use_kpi=use_kpi,
2466
+ include_non_paid_channels=False,
2467
+ batch_size=batch_size,
2468
+ )
2465
2469
  ),
2466
- (c.CHAINS_DIMENSION, c.DRAWS_DIMENSION),
2470
+ axis=(c.CHAINS_DIMENSION, c.DRAWS_DIMENSION),
2467
2471
  dtype=np.float64,
2468
2472
  )
2469
2473
 
@@ -2541,8 +2545,8 @@ class BudgetOptimizer:
2541
2545
  )
2542
2546
  spend_grid[: len(spend_grid_m), i] = spend_grid_m
2543
2547
  incremental_outcome_grid = np.full([n_grid_rows, n_grid_columns], np.nan)
2544
- multipliers_grid_base = tf.cast(
2545
- tf.math.divide_no_nan(spend_grid, spend), dtype=tf.float32
2548
+ multipliers_grid_base = backend.cast(
2549
+ backend.divide_no_nan(spend_grid, spend), dtype=backend.float32
2546
2550
  )
2547
2551
  multipliers_grid = np.where(
2548
2552
  np.isnan(spend_grid), np.nan, multipliers_grid_base
@@ -2573,7 +2577,7 @@ class BudgetOptimizer:
2573
2577
  rf_spend_max = np.nanmax(
2574
2578
  spend_grid[:, -self._meridian.n_rf_channels :], axis=0
2575
2579
  )
2576
- rf_roi = tf.math.divide_no_nan(rf_incremental_outcome_max, rf_spend_max)
2580
+ rf_roi = backend.divide_no_nan(rf_incremental_outcome_max, rf_spend_max)
2577
2581
  incremental_outcome_grid[:, -self._meridian.n_rf_channels :] = (
2578
2582
  rf_roi * spend_grid[:, -self._meridian.n_rf_channels :]
2579
2583
  )
@@ -2581,14 +2585,14 @@ class BudgetOptimizer:
2581
2585
 
2582
2586
  def _validate_optimization_tensors(
2583
2587
  self,
2584
- cpmu: tf.Tensor | None = None,
2585
- cprf: tf.Tensor | None = None,
2586
- media: tf.Tensor | None = None,
2587
- rf_impressions: tf.Tensor | None = None,
2588
- frequency: tf.Tensor | None = None,
2589
- media_spend: tf.Tensor | None = None,
2590
- rf_spend: tf.Tensor | None = None,
2591
- revenue_per_kpi: tf.Tensor | None = None,
2588
+ cpmu: backend.Tensor | None = None,
2589
+ cprf: backend.Tensor | None = None,
2590
+ media: backend.Tensor | None = None,
2591
+ rf_impressions: backend.Tensor | None = None,
2592
+ frequency: backend.Tensor | None = None,
2593
+ media_spend: backend.Tensor | None = None,
2594
+ rf_spend: backend.Tensor | None = None,
2595
+ revenue_per_kpi: backend.Tensor | None = None,
2592
2596
  use_optimal_frequency: bool = True,
2593
2597
  ):
2594
2598
  """Validates the tensors needed for optimization."""
@@ -2642,7 +2646,7 @@ class BudgetOptimizer:
2642
2646
  )
2643
2647
 
2644
2648
  def _allocate_tensor_by_population(
2645
- self, tensor: tf.Tensor, required_ndim: int = 3
2649
+ self, tensor: backend.Tensor, required_ndim: int = 3
2646
2650
  ):
2647
2651
  """Allocates a tensor of shape (time,) or (time, channel) by the population.
2648
2652
 
@@ -2664,16 +2668,83 @@ class BudgetOptimizer:
2664
2668
  )
2665
2669
 
2666
2670
  population = self._meridian.population
2667
- normalized_population = population / tf.reduce_sum(population)
2671
+ normalized_population = population / backend.reduce_sum(population)
2668
2672
  if tensor.ndim == 1:
2669
- reshaped_population = normalized_population[:, tf.newaxis]
2670
- reshaped_tensor = tensor[tf.newaxis, :]
2673
+ reshaped_population = normalized_population[:, backend.newaxis]
2674
+ reshaped_tensor = tensor[backend.newaxis, :]
2671
2675
  else:
2672
- reshaped_population = normalized_population[:, tf.newaxis, tf.newaxis]
2673
- reshaped_tensor = tensor[tf.newaxis, :, :]
2676
+ reshaped_population = normalized_population[
2677
+ :, backend.newaxis, backend.newaxis
2678
+ ]
2679
+ reshaped_tensor = tensor[backend.newaxis, :, :]
2674
2680
  return reshaped_tensor * reshaped_population
2675
2681
 
2676
2682
 
2683
+ def get_optimization_bounds(
2684
+ n_channels: int,
2685
+ spend: np.ndarray,
2686
+ round_factor: int,
2687
+ spend_constraint_lower: _SpendConstraint,
2688
+ spend_constraint_upper: _SpendConstraint,
2689
+ ) -> tuple[np.ndarray, np.ndarray]:
2690
+ """Get optimization bounds from spend and spend constraints.
2691
+
2692
+ Args:
2693
+ n_channels: Integer number of total channels.
2694
+ spend: np.ndarray with size `n_total_channels` containing media-level spend
2695
+ for all media and RF channels.
2696
+ round_factor: Integer number of digits to round optimization bounds.
2697
+ spend_constraint_lower: Numeric list of size `n_total_channels` or float
2698
+ (same constraint for all media) indicating the lower bound of media-level
2699
+ spend. The lower bound of media-level spend is `(1 -
2700
+ spend_constraint_lower) * budget * allocation)`. The value must be between
2701
+ 0-1.
2702
+ spend_constraint_upper: Numeric list of size `n_total_channels` or float
2703
+ (same constraint for all media) indicating the upper bound of media-level
2704
+ spend. The upper bound of media-level spend is `(1 +
2705
+ spend_constraint_upper) * budget * allocation)`.
2706
+
2707
+ Returns:
2708
+ lower_bound: np.ndarray of size `n_total_channels` containing the treated
2709
+ lower bound spend for each media and RF channel.
2710
+ upper_bound: np.ndarray of size `n_total_channels` containing the treated
2711
+ upper bound spend for each media and RF channel.
2712
+ """
2713
+ spend_bounds = _get_spend_bounds(
2714
+ n_channels=n_channels,
2715
+ spend_constraint_lower=spend_constraint_lower,
2716
+ spend_constraint_upper=spend_constraint_upper,
2717
+ )
2718
+ rounded_spend = np.round(spend, round_factor).astype(int)
2719
+ lower = np.round((spend_bounds[0] * rounded_spend), round_factor).astype(int)
2720
+ upper = np.round(spend_bounds[1] * rounded_spend, round_factor).astype(int)
2721
+ return (lower, upper)
2722
+
2723
+
2724
+ def get_round_factor(budget: float, gtol: float) -> int:
2725
+ """Gets the number of integer digits to round off of budget.
2726
+
2727
+ Args:
2728
+ budget: Float number for total advertising budget.
2729
+ gtol: Float indicating the acceptable relative error for the budget used in
2730
+ the grid setup. The budget will be rounded by `10*n`, where `n` is the
2731
+ smallest int such that `(budget - rounded_budget) <= (budget * gtol)`.
2732
+ `gtol` must be less than 1.
2733
+
2734
+ Returns:
2735
+ Integer number of digits to round budget to.
2736
+ """
2737
+ tolerance = budget * gtol
2738
+ if gtol >= 1.0:
2739
+ raise ValueError('gtol must be less than one.')
2740
+ elif budget <= 0.0:
2741
+ raise ValueError('`budget` must be greater than zero.')
2742
+ elif tolerance < 1.0:
2743
+ return 0
2744
+ else:
2745
+ return -int(math.log10(tolerance)) - 1
2746
+
2747
+
2677
2748
  def _validate_pct_of_spend(
2678
2749
  n_channels: int,
2679
2750
  hist_spend: np.ndarray,
@@ -2748,7 +2819,7 @@ def _get_spend_bounds(
2748
2819
 
2749
2820
  Returns:
2750
2821
  spend_bounds: tuple of np.ndarray of size `n_total_channels` containing
2751
- the untreated lower and upper bound spend for each media and RF channel.
2822
+ the untreated lower and upper bound spend for each media and RF channel.
2752
2823
  """
2753
2824
  (spend_const_lower, spend_const_upper) = _validate_spend_constraints(
2754
2825
  n_channels,
@@ -2762,47 +2833,6 @@ def _get_spend_bounds(
2762
2833
  return spend_bounds
2763
2834
 
2764
2835
 
2765
- def get_optimization_bounds(
2766
- n_channels: int,
2767
- spend: np.ndarray,
2768
- round_factor: int,
2769
- spend_constraint_lower: _SpendConstraint,
2770
- spend_constraint_upper: _SpendConstraint,
2771
- ) -> tuple[np.ndarray, np.ndarray]:
2772
- """Get optimization bounds from spend and spend constraints.
2773
-
2774
- Args:
2775
- n_channels: Integer number of total channels.
2776
- spend: np.ndarray with size `n_total_channels` containing media-level spend
2777
- for all media and RF channels.
2778
- round_factor: Integer number of digits to round optimization bounds.
2779
- spend_constraint_lower: Numeric list of size `n_total_channels` or float
2780
- (same constraint for all media) indicating the lower bound of media-level
2781
- spend. The lower bound of media-level spend is `(1 -
2782
- spend_constraint_lower) * budget * allocation)`. The value must be between
2783
- 0-1.
2784
- spend_constraint_upper: Numeric list of size `n_total_channels` or float
2785
- (same constraint for all media) indicating the upper bound of media-level
2786
- spend. The upper bound of media-level spend is `(1 +
2787
- spend_constraint_upper) * budget * allocation)`.
2788
-
2789
- Returns:
2790
- lower_bound: np.ndarray of size `n_total_channels` containing the treated
2791
- lower bound spend for each media and RF channel.
2792
- upper_bound: np.ndarray of size `n_total_channels` containing the treated
2793
- upper bound spend for each media and RF channel.
2794
- """
2795
- spend_bounds = _get_spend_bounds(
2796
- n_channels=n_channels,
2797
- spend_constraint_lower=spend_constraint_lower,
2798
- spend_constraint_upper=spend_constraint_upper,
2799
- )
2800
- rounded_spend = np.round(spend, round_factor).astype(int)
2801
- lower = np.round((spend_bounds[0] * rounded_spend), round_factor).astype(int)
2802
- upper = np.round(spend_bounds[1] * rounded_spend, round_factor).astype(int)
2803
- return (lower, upper)
2804
-
2805
-
2806
2836
  def _validate_budget(
2807
2837
  fixed_budget: bool,
2808
2838
  budget: float | None,
@@ -2836,30 +2866,6 @@ def _validate_budget(
2836
2866
  )
2837
2867
 
2838
2868
 
2839
- def _get_round_factor(budget: float, gtol: float) -> int:
2840
- """Function for obtaining number of integer digits to round off of budget.
2841
-
2842
- Args:
2843
- budget: float total advertising budget.
2844
- gtol: float indicating the acceptable relative error for the udget used in
2845
- the grid setup. The budget will be rounded by 10*n, where n is the
2846
- smallest int such that (budget - rounded_budget) is less than or equal to
2847
- (budget * gtol). gtol must be less than 1.
2848
-
2849
- Returns:
2850
- int number of integer digits to round budget to.
2851
- """
2852
- tolerance = budget * gtol
2853
- if gtol >= 1.0:
2854
- raise ValueError('gtol must be less than one.')
2855
- elif budget <= 0.0:
2856
- raise ValueError('`budget` must be greater than zero.')
2857
- elif tolerance < 1.0:
2858
- return 0
2859
- else:
2860
- return -int(math.log10(tolerance)) - 1
2861
-
2862
-
2863
2869
  def _exceeds_optimization_constraints(
2864
2870
  spend: np.ndarray,
2865
2871
  incremental_outcome: np.ndarray,
@@ -2928,12 +2934,12 @@ def _raise_warning_if_target_constraints_not_met(
2928
2934
  )
2929
2935
 
2930
2936
 
2931
- def _expand_tensor(tensor: tf.Tensor, required_shape: tuple[int, ...]):
2937
+ def _expand_tensor(tensor: backend.Tensor, required_shape: tuple[int, ...]):
2932
2938
  """Expands a tensor to the required number of dimensions."""
2933
2939
  if tensor.shape == required_shape:
2934
2940
  return tensor
2935
2941
  if tensor.ndim == 0:
2936
- return tf.fill(required_shape, tensor)
2942
+ return backend.fill(required_shape, tensor)
2937
2943
 
2938
2944
  # Tensor must be less than or equal to the required number of dimensions and
2939
2945
  # the shape must match the required shape excluding the difference in number
@@ -2943,8 +2949,10 @@ def _expand_tensor(tensor: tf.Tensor, required_shape: tuple[int, ...]):
2943
2949
  ):
2944
2950
  n_tile_dims = len(required_shape) - tensor.ndim
2945
2951
  repeats = list(required_shape[:n_tile_dims]) + [1] * tensor.ndim
2946
- reshaped_tensor = tf.reshape(tensor, [1] * n_tile_dims + list(tensor.shape))
2947
- return tf.tile(reshaped_tensor, repeats)
2952
+ reshaped_tensor = backend.reshape(
2953
+ tensor, [1] * n_tile_dims + list(tensor.shape)
2954
+ )
2955
+ return backend.tile(reshaped_tensor, repeats)
2948
2956
 
2949
2957
  raise ValueError(
2950
2958
  f'Cannot expand tensor with shape {tensor.shape} to target'