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.
- {google_meridian-1.1.5.dist-info → google_meridian-1.2.0.dist-info}/METADATA +8 -2
- google_meridian-1.2.0.dist-info/RECORD +52 -0
- meridian/__init__.py +1 -0
- meridian/analysis/analyzer.py +526 -362
- meridian/analysis/optimizer.py +275 -267
- meridian/analysis/test_utils.py +96 -94
- meridian/analysis/visualizer.py +37 -49
- meridian/backend/__init__.py +514 -0
- meridian/backend/config.py +59 -0
- meridian/backend/test_utils.py +95 -0
- meridian/constants.py +59 -3
- meridian/data/input_data.py +94 -0
- meridian/data/test_utils.py +144 -12
- meridian/model/adstock_hill.py +279 -33
- meridian/model/eda/__init__.py +17 -0
- meridian/model/eda/eda_engine.py +306 -0
- meridian/model/knots.py +525 -2
- meridian/model/media.py +62 -54
- meridian/model/model.py +224 -97
- meridian/model/model_test_data.py +323 -157
- meridian/model/posterior_sampler.py +84 -77
- meridian/model/prior_distribution.py +538 -168
- meridian/model/prior_sampler.py +65 -65
- meridian/model/spec.py +23 -3
- meridian/model/transformers.py +53 -47
- meridian/version.py +1 -1
- google_meridian-1.1.5.dist-info/RECORD +0 -47
- {google_meridian-1.1.5.dist-info → google_meridian-1.2.0.dist-info}/WHEEL +0 -0
- {google_meridian-1.1.5.dist-info → google_meridian-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {google_meridian-1.1.5.dist-info → google_meridian-1.2.0.dist-info}/top_level.txt +0 -0
meridian/analysis/optimizer.py
CHANGED
|
@@ -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) ->
|
|
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) ->
|
|
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.
|
|
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.
|
|
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
|
|
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[
|
|
346
|
-
"""
|
|
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
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] |
|
|
1605
|
-
cpmu:
|
|
1606
|
-
media:
|
|
1607
|
-
media_spend:
|
|
1608
|
-
cprf:
|
|
1609
|
-
rf_impressions:
|
|
1610
|
-
frequency:
|
|
1611
|
-
rf_spend:
|
|
1612
|
-
revenue_per_kpi:
|
|
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,
|
|
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 =
|
|
1723
|
+
frequency = backend.ones_like(allocated_impressions)
|
|
1718
1724
|
tensors[c.FREQUENCY] = _expand_tensor(frequency, shape)
|
|
1719
|
-
tensors[c.REACH] =
|
|
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 =
|
|
1732
|
+
impressions = backend.divide_no_nan(tensors[c.RF_SPEND], cprf)
|
|
1727
1733
|
if use_optimal_frequency:
|
|
1728
|
-
frequency =
|
|
1734
|
+
frequency = backend.ones_like(impressions)
|
|
1729
1735
|
tensors[c.FREQUENCY] = _expand_tensor(frequency, shape)
|
|
1730
|
-
tensors[c.REACH] =
|
|
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] =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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=
|
|
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
|
|
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
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
2192
|
-
new_frequency =
|
|
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
|
-
|
|
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
|
-
|
|
2220
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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 =
|
|
2313
|
-
total_cpik =
|
|
2314
|
-
|
|
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:
|
|
2350
|
-
c.END_DATE:
|
|
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:
|
|
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 =
|
|
2436
|
-
|
|
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
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
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 =
|
|
2545
|
-
|
|
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 =
|
|
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:
|
|
2585
|
-
cprf:
|
|
2586
|
-
media:
|
|
2587
|
-
rf_impressions:
|
|
2588
|
-
frequency:
|
|
2589
|
-
media_spend:
|
|
2590
|
-
rf_spend:
|
|
2591
|
-
revenue_per_kpi:
|
|
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:
|
|
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 /
|
|
2671
|
+
normalized_population = population / backend.reduce_sum(population)
|
|
2668
2672
|
if tensor.ndim == 1:
|
|
2669
|
-
reshaped_population = normalized_population[:,
|
|
2670
|
-
reshaped_tensor = tensor[
|
|
2673
|
+
reshaped_population = normalized_population[:, backend.newaxis]
|
|
2674
|
+
reshaped_tensor = tensor[backend.newaxis, :]
|
|
2671
2675
|
else:
|
|
2672
|
-
reshaped_population = normalized_population[
|
|
2673
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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 =
|
|
2947
|
-
|
|
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'
|