google-meridian 1.0.5__py3-none-any.whl → 1.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {google_meridian-1.0.5.dist-info → google_meridian-1.0.6.dist-info}/METADATA +3 -3
- {google_meridian-1.0.5.dist-info → google_meridian-1.0.6.dist-info}/RECORD +15 -15
- {google_meridian-1.0.5.dist-info → google_meridian-1.0.6.dist-info}/WHEEL +1 -1
- meridian/__init__.py +1 -1
- meridian/analysis/analyzer.py +677 -817
- meridian/analysis/optimizer.py +192 -134
- meridian/analysis/summarizer.py +7 -3
- meridian/analysis/test_utils.py +72 -20
- meridian/analysis/visualizer.py +10 -10
- meridian/constants.py +3 -0
- meridian/data/input_data.py +49 -3
- meridian/data/test_utils.py +18 -11
- meridian/data/time_coordinates.py +38 -17
- {google_meridian-1.0.5.dist-info → google_meridian-1.0.6.dist-info}/LICENSE +0 -0
- {google_meridian-1.0.5.dist-info → google_meridian-1.0.6.dist-info}/top_level.txt +0 -0
meridian/analysis/optimizer.py
CHANGED
|
@@ -20,6 +20,7 @@ import functools
|
|
|
20
20
|
import math
|
|
21
21
|
import os
|
|
22
22
|
from typing import Any, TypeAlias
|
|
23
|
+
import warnings
|
|
23
24
|
|
|
24
25
|
import altair as alt
|
|
25
26
|
import jinja2
|
|
@@ -45,14 +46,53 @@ alt.data_transformers.disable_max_rows()
|
|
|
45
46
|
_SpendConstraint: TypeAlias = float | Sequence[float]
|
|
46
47
|
|
|
47
48
|
|
|
49
|
+
@dataclasses.dataclass(frozen=True)
|
|
50
|
+
class FixedBudgetScenario:
|
|
51
|
+
"""A fixed budget optimization scenario.
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
total_budget: The total budget for the optimization period. Must be
|
|
55
|
+
non-negative. If unspecified, it represents historical total spend.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
total_budget: float | None = None
|
|
59
|
+
|
|
60
|
+
def __post_init__(self):
|
|
61
|
+
if self.total_budget is not None and self.total_budget < 0:
|
|
62
|
+
raise ValueError('Total budget must be non-negative.')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclasses.dataclass(frozen=True)
|
|
66
|
+
class FlexibleBudgetScenario:
|
|
67
|
+
"""A flexible budget optimization scenario.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
target_metric: The target metric to optimize for. This should be ROI or
|
|
71
|
+
mROI.
|
|
72
|
+
target_value: The target value for the above metric. Must be non-negative.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
target_metric: str
|
|
76
|
+
target_value: float
|
|
77
|
+
|
|
78
|
+
def __post_init__(self):
|
|
79
|
+
if self.target_metric not in (c.ROI, c.MROI):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f'Unsupported target metric: {self.target_metric} for flexible budget'
|
|
82
|
+
' scenario.'
|
|
83
|
+
)
|
|
84
|
+
if self.target_value < 0:
|
|
85
|
+
raise ValueError('Target value must be non-negative.')
|
|
86
|
+
|
|
87
|
+
|
|
48
88
|
@dataclasses.dataclass(frozen=True)
|
|
49
89
|
class OptimizationGrid:
|
|
50
90
|
"""Optimization grid information.
|
|
51
91
|
|
|
52
92
|
Attributes:
|
|
53
|
-
|
|
54
|
-
for spend for all media and RF
|
|
55
|
-
`InputData.get_all_paid_channels`.
|
|
93
|
+
historical_spend: ndarray of shape `(n_paid_channels,)` containing
|
|
94
|
+
aggregated historical spend allocation for spend for all media and RF
|
|
95
|
+
channels. The order matches `InputData.get_all_paid_channels`.
|
|
56
96
|
use_kpi: Whether using generic KPI or revenue.
|
|
57
97
|
use_posterior: Whether posterior distributions were used, or prior.
|
|
58
98
|
use_optimal_frequency: Whether optimal frequency was used.
|
|
@@ -67,7 +107,7 @@ class OptimizationGrid:
|
|
|
67
107
|
|
|
68
108
|
_grid_dataset: xr.Dataset
|
|
69
109
|
|
|
70
|
-
|
|
110
|
+
historical_spend: np.ndarray
|
|
71
111
|
use_kpi: bool
|
|
72
112
|
use_posterior: bool
|
|
73
113
|
use_optimal_frequency: bool
|
|
@@ -102,6 +142,78 @@ class OptimizationGrid:
|
|
|
102
142
|
"""The spend step size."""
|
|
103
143
|
return self.grid_dataset.attrs[c.SPEND_STEP_SIZE]
|
|
104
144
|
|
|
145
|
+
# TODO: b/402950014 - Add per-channel constraints parameter.
|
|
146
|
+
def optimize(
|
|
147
|
+
self,
|
|
148
|
+
scenario: FixedBudgetScenario | FlexibleBudgetScenario,
|
|
149
|
+
) -> np.ndarray:
|
|
150
|
+
"""Hill-climbing search algorithm for budget optimization.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
scenario: The optimization scenario with corresponding parameters.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
optimal_spend: `np.ndarray` with shape `(n_paid_channels,)` containing the
|
|
157
|
+
media spend that maximizes incremental outcome based on spend
|
|
158
|
+
constraints for all media and RF channels.
|
|
159
|
+
"""
|
|
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:, :]
|
|
174
|
+
iterative_roi_grid = np.round(
|
|
175
|
+
tf.math.divide_no_nan(
|
|
176
|
+
incremental_outcome_grid - incremental_outcome, spend_grid - spend
|
|
177
|
+
),
|
|
178
|
+
decimals=8,
|
|
179
|
+
)
|
|
180
|
+
while True:
|
|
181
|
+
spend_optimal = spend.astype(int)
|
|
182
|
+
# If none of the exit criteria are met roi_grid will eventually be filled
|
|
183
|
+
# with all nans.
|
|
184
|
+
if np.isnan(iterative_roi_grid).all():
|
|
185
|
+
break
|
|
186
|
+
point = np.unravel_index(
|
|
187
|
+
np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
|
|
188
|
+
)
|
|
189
|
+
row_idx = point[0]
|
|
190
|
+
media_idx = point[1]
|
|
191
|
+
spend[media_idx] = spend_grid[row_idx, media_idx]
|
|
192
|
+
incremental_outcome[media_idx] = incremental_outcome_grid[
|
|
193
|
+
row_idx, media_idx
|
|
194
|
+
]
|
|
195
|
+
roi_grid_point = iterative_roi_grid[row_idx, media_idx]
|
|
196
|
+
if _exceeds_optimization_constraints(
|
|
197
|
+
spend=spend,
|
|
198
|
+
incremental_outcome=incremental_outcome,
|
|
199
|
+
roi_grid_point=roi_grid_point,
|
|
200
|
+
scenario=scenario,
|
|
201
|
+
):
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
|
|
205
|
+
iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
|
|
206
|
+
tf.math.divide_no_nan(
|
|
207
|
+
incremental_outcome_grid[row_idx + 1 :, media_idx]
|
|
208
|
+
- incremental_outcome_grid[row_idx, media_idx],
|
|
209
|
+
spend_grid[row_idx + 1 :, media_idx]
|
|
210
|
+
- spend_grid[row_idx, media_idx],
|
|
211
|
+
),
|
|
212
|
+
decimals=8,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return spend_optimal
|
|
216
|
+
|
|
105
217
|
|
|
106
218
|
@dataclasses.dataclass(frozen=True)
|
|
107
219
|
class OptimizationResults:
|
|
@@ -377,7 +489,8 @@ class OptimizationResults:
|
|
|
377
489
|
.properties(
|
|
378
490
|
title=formatter.custom_title_params(
|
|
379
491
|
summary_text.SPEND_ALLOCATION_CHART_TITLE
|
|
380
|
-
)
|
|
492
|
+
),
|
|
493
|
+
width=c.VEGALITE_FACET_DEFAULT_WIDTH
|
|
381
494
|
)
|
|
382
495
|
)
|
|
383
496
|
|
|
@@ -416,7 +529,7 @@ class OptimizationResults:
|
|
|
416
529
|
tooltip=True, size=c.BAR_SIZE, cornerRadiusEnd=c.CORNER_RADIUS
|
|
417
530
|
).encode(
|
|
418
531
|
color=alt.condition(
|
|
419
|
-
alt.
|
|
532
|
+
alt.datum.spend > 0,
|
|
420
533
|
alt.value(c.CYAN_400),
|
|
421
534
|
alt.value(c.RED_300),
|
|
422
535
|
),
|
|
@@ -461,7 +574,7 @@ class OptimizationResults:
|
|
|
461
574
|
title = summary_text.INC_KPI_LABEL
|
|
462
575
|
df = self._get_plottable_response_curves_df(n_top_channels=n_top_channels)
|
|
463
576
|
base = (
|
|
464
|
-
alt.Chart(df)
|
|
577
|
+
alt.Chart(df, width=c.VEGALITE_FACET_DEFAULT_WIDTH)
|
|
465
578
|
.transform_calculate(
|
|
466
579
|
spend_constraint=(
|
|
467
580
|
'datum.spend_multiplier >= datum.lower_bound &&'
|
|
@@ -1051,6 +1164,9 @@ class BudgetOptimizer:
|
|
|
1051
1164
|
include_rf=self._meridian.n_rf_channels > 0,
|
|
1052
1165
|
).data
|
|
1053
1166
|
|
|
1167
|
+
use_historical_budget = budget is None or round(budget) == round(
|
|
1168
|
+
np.sum(hist_spend)
|
|
1169
|
+
)
|
|
1054
1170
|
budget = budget or np.sum(hist_spend)
|
|
1055
1171
|
pct_of_spend = self._validate_pct_of_spend(hist_spend, pct_of_spend)
|
|
1056
1172
|
spend = budget * pct_of_spend
|
|
@@ -1078,7 +1194,7 @@ class BudgetOptimizer:
|
|
|
1078
1194
|
)
|
|
1079
1195
|
)
|
|
1080
1196
|
optimization_grid = self.create_optimization_grid(
|
|
1081
|
-
|
|
1197
|
+
historical_spend=hist_spend,
|
|
1082
1198
|
spend_bound_lower=optimization_lower_bound,
|
|
1083
1199
|
spend_bound_upper=optimization_upper_bound,
|
|
1084
1200
|
selected_times=selected_time_dims,
|
|
@@ -1089,17 +1205,21 @@ class BudgetOptimizer:
|
|
|
1089
1205
|
optimal_frequency=optimal_frequency,
|
|
1090
1206
|
batch_size=batch_size,
|
|
1091
1207
|
)
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1208
|
+
|
|
1209
|
+
if fixed_budget:
|
|
1210
|
+
total_budget = None if use_historical_budget else np.sum(rounded_spend)
|
|
1211
|
+
scenario = FixedBudgetScenario(total_budget=total_budget)
|
|
1212
|
+
elif target_roi:
|
|
1213
|
+
scenario = FlexibleBudgetScenario(
|
|
1214
|
+
target_metric=c.ROI, target_value=target_roi
|
|
1215
|
+
)
|
|
1216
|
+
else:
|
|
1217
|
+
scenario = FlexibleBudgetScenario(
|
|
1218
|
+
target_metric=c.MROI, target_value=target_mroi
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
optimal_spend = optimization_grid.optimize(
|
|
1222
|
+
scenario=scenario,
|
|
1103
1223
|
)
|
|
1104
1224
|
nonoptimized_data = self._create_budget_dataset(
|
|
1105
1225
|
use_posterior=use_posterior,
|
|
@@ -1141,6 +1261,14 @@ class BudgetOptimizer:
|
|
|
1141
1261
|
batch_size=batch_size,
|
|
1142
1262
|
use_historical_budget=use_historical_budget,
|
|
1143
1263
|
)
|
|
1264
|
+
|
|
1265
|
+
if not fixed_budget:
|
|
1266
|
+
self._raise_warning_if_target_constraints_not_met(
|
|
1267
|
+
target_roi=target_roi,
|
|
1268
|
+
target_mroi=target_mroi,
|
|
1269
|
+
optimized_data=optimized_data,
|
|
1270
|
+
)
|
|
1271
|
+
|
|
1144
1272
|
spend_ratio = np.divide(
|
|
1145
1273
|
spend,
|
|
1146
1274
|
hist_spend,
|
|
@@ -1159,23 +1287,51 @@ class BudgetOptimizer:
|
|
|
1159
1287
|
_optimization_grid=optimization_grid,
|
|
1160
1288
|
)
|
|
1161
1289
|
|
|
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
|
+
|
|
1162
1317
|
def create_optimization_grid(
|
|
1163
1318
|
self,
|
|
1164
|
-
|
|
1319
|
+
historical_spend: np.ndarray,
|
|
1165
1320
|
spend_bound_lower: np.ndarray,
|
|
1166
1321
|
spend_bound_upper: np.ndarray,
|
|
1167
1322
|
selected_times: Sequence[str] | None,
|
|
1168
1323
|
round_factor: int,
|
|
1169
1324
|
use_posterior: bool = True,
|
|
1170
1325
|
use_kpi: bool = False,
|
|
1171
|
-
use_optimal_frequency: bool =
|
|
1326
|
+
use_optimal_frequency: bool = False,
|
|
1172
1327
|
optimal_frequency: xr.DataArray | None = None,
|
|
1173
1328
|
batch_size: int = c.DEFAULT_BATCH_SIZE,
|
|
1174
1329
|
) -> OptimizationGrid:
|
|
1175
1330
|
"""Creates a OptimizationGrid for optimization.
|
|
1176
1331
|
|
|
1177
1332
|
Args:
|
|
1178
|
-
|
|
1333
|
+
historical_spend: ndarray of shape `(n_paid_channels,)` with arrgegated
|
|
1334
|
+
historical spend per paid channel.
|
|
1179
1335
|
spend_bound_lower: ndarray of dimension `(n_total_channels,)` containing
|
|
1180
1336
|
the lower constraint spend for each channel.
|
|
1181
1337
|
spend_bound_upper: ndarray of dimension `(n_total_channels,)` containing
|
|
@@ -1208,7 +1364,7 @@ class BudgetOptimizer:
|
|
|
1208
1364
|
|
|
1209
1365
|
step_size = 10 ** (-round_factor)
|
|
1210
1366
|
(spend_grid, incremental_outcome_grid) = self._create_grids(
|
|
1211
|
-
spend=
|
|
1367
|
+
spend=historical_spend,
|
|
1212
1368
|
spend_bound_lower=spend_bound_lower,
|
|
1213
1369
|
spend_bound_upper=spend_bound_upper,
|
|
1214
1370
|
step_size=step_size,
|
|
@@ -1226,7 +1382,7 @@ class BudgetOptimizer:
|
|
|
1226
1382
|
|
|
1227
1383
|
return OptimizationGrid(
|
|
1228
1384
|
_grid_dataset=grid_dataset,
|
|
1229
|
-
|
|
1385
|
+
historical_spend=historical_spend,
|
|
1230
1386
|
use_kpi=use_kpi,
|
|
1231
1387
|
use_posterior=use_posterior,
|
|
1232
1388
|
use_optimal_frequency=use_optimal_frequency,
|
|
@@ -1810,95 +1966,6 @@ class BudgetOptimizer:
|
|
|
1810
1966
|
)
|
|
1811
1967
|
return (spend_grid, incremental_outcome_grid)
|
|
1812
1968
|
|
|
1813
|
-
def _grid_search(
|
|
1814
|
-
self,
|
|
1815
|
-
spend_grid: np.ndarray,
|
|
1816
|
-
incremental_outcome_grid: np.ndarray,
|
|
1817
|
-
budget: float,
|
|
1818
|
-
fixed_budget: bool,
|
|
1819
|
-
target_mroi: float | None = None,
|
|
1820
|
-
target_roi: float | None = None,
|
|
1821
|
-
) -> np.ndarray:
|
|
1822
|
-
"""Hill-climbing search algorithm for budget optimization.
|
|
1823
|
-
|
|
1824
|
-
Args:
|
|
1825
|
-
spend_grid: Discrete grid with dimensions (`grid_length` x
|
|
1826
|
-
`n_total_channels`) containing spend by channel for all media and RF
|
|
1827
|
-
channels, used in the hill-climbing search algorithm.
|
|
1828
|
-
incremental_outcome_grid: Discrete grid with dimensions (`grid_length` x
|
|
1829
|
-
`n_total_channels`) containing incremental outcome by channel for all
|
|
1830
|
-
media and RF channels, used in the hill-climbing search algorithm.
|
|
1831
|
-
budget: Integer indicating the total budget.
|
|
1832
|
-
fixed_budget: Bool indicating whether it's a fixed budget optimization or
|
|
1833
|
-
flexible budget optimization.
|
|
1834
|
-
target_mroi: Optional float indicating the target marginal return on
|
|
1835
|
-
investment (mroi) constraint. This can be translated into "How much can
|
|
1836
|
-
I spend when I have flexible budget until the mroi of each channel hits
|
|
1837
|
-
the target mroi". It's still possible that the mroi of some channels
|
|
1838
|
-
will not be equal to the target mroi due to the feasible range of media
|
|
1839
|
-
spend. However, the mroi will effectively shrink toward the target mroi.
|
|
1840
|
-
target_roi: Optional float indicating the target return on investment
|
|
1841
|
-
(roi) constraint. This can be translated into "How much can I spend when
|
|
1842
|
-
I have a flexible budget until the roi of total media spend hits the
|
|
1843
|
-
target roi".
|
|
1844
|
-
|
|
1845
|
-
Returns:
|
|
1846
|
-
optimal_spend: np.ndarry of dimension (`n_total_channels`) containing the
|
|
1847
|
-
media spend that maximizes incremental outcome based on spend
|
|
1848
|
-
constraints for all media and RF channels.
|
|
1849
|
-
optimal_inc_outcome: np.ndarry of dimension (`n_total_channels`)
|
|
1850
|
-
containing the post optimization incremental outcome per channel for all
|
|
1851
|
-
media and RF channels.
|
|
1852
|
-
"""
|
|
1853
|
-
spend = spend_grid[0, :].copy()
|
|
1854
|
-
incremental_outcome = incremental_outcome_grid[0, :].copy()
|
|
1855
|
-
spend_grid = spend_grid[1:, :]
|
|
1856
|
-
incremental_outcome_grid = incremental_outcome_grid[1:, :]
|
|
1857
|
-
iterative_roi_grid = np.round(
|
|
1858
|
-
tf.math.divide_no_nan(
|
|
1859
|
-
incremental_outcome_grid - incremental_outcome, spend_grid - spend
|
|
1860
|
-
),
|
|
1861
|
-
decimals=8,
|
|
1862
|
-
)
|
|
1863
|
-
while True:
|
|
1864
|
-
spend_optimal = spend.astype(int)
|
|
1865
|
-
# If none of the exit criteria are met roi_grid will eventually be filled
|
|
1866
|
-
# with all nans.
|
|
1867
|
-
if np.isnan(iterative_roi_grid).all():
|
|
1868
|
-
break
|
|
1869
|
-
point = np.unravel_index(
|
|
1870
|
-
np.nanargmax(iterative_roi_grid), iterative_roi_grid.shape
|
|
1871
|
-
)
|
|
1872
|
-
row_idx = point[0]
|
|
1873
|
-
media_idx = point[1]
|
|
1874
|
-
spend[media_idx] = spend_grid[row_idx, media_idx]
|
|
1875
|
-
incremental_outcome[media_idx] = incremental_outcome_grid[
|
|
1876
|
-
row_idx, media_idx
|
|
1877
|
-
]
|
|
1878
|
-
roi_grid_point = iterative_roi_grid[row_idx, media_idx]
|
|
1879
|
-
if _exceeds_optimization_constraints(
|
|
1880
|
-
fixed_budget,
|
|
1881
|
-
budget,
|
|
1882
|
-
spend,
|
|
1883
|
-
incremental_outcome,
|
|
1884
|
-
roi_grid_point,
|
|
1885
|
-
target_mroi,
|
|
1886
|
-
target_roi,
|
|
1887
|
-
):
|
|
1888
|
-
break
|
|
1889
|
-
|
|
1890
|
-
iterative_roi_grid[0 : row_idx + 1, media_idx] = np.nan
|
|
1891
|
-
iterative_roi_grid[row_idx + 1 :, media_idx] = np.round(
|
|
1892
|
-
tf.math.divide_no_nan(
|
|
1893
|
-
incremental_outcome_grid[row_idx + 1 :, media_idx]
|
|
1894
|
-
- incremental_outcome_grid[row_idx, media_idx],
|
|
1895
|
-
spend_grid[row_idx + 1 :, media_idx]
|
|
1896
|
-
- spend_grid[row_idx, media_idx],
|
|
1897
|
-
),
|
|
1898
|
-
decimals=8,
|
|
1899
|
-
)
|
|
1900
|
-
return spend_optimal
|
|
1901
|
-
|
|
1902
1969
|
|
|
1903
1970
|
def _validate_budget(
|
|
1904
1971
|
fixed_budget: bool,
|
|
@@ -1958,13 +2025,10 @@ def _get_round_factor(budget: float, gtol: float) -> int:
|
|
|
1958
2025
|
|
|
1959
2026
|
|
|
1960
2027
|
def _exceeds_optimization_constraints(
|
|
1961
|
-
fixed_budget: bool,
|
|
1962
|
-
budget: float,
|
|
1963
2028
|
spend: np.ndarray,
|
|
1964
2029
|
incremental_outcome: np.ndarray,
|
|
1965
2030
|
roi_grid_point: float,
|
|
1966
|
-
|
|
1967
|
-
target_roi: float | None = None,
|
|
2031
|
+
scenario: FixedBudgetScenario | FlexibleBudgetScenario,
|
|
1968
2032
|
) -> bool:
|
|
1969
2033
|
"""Checks optimization scenario constraints.
|
|
1970
2034
|
|
|
@@ -1972,36 +2036,30 @@ def _exceeds_optimization_constraints(
|
|
|
1972
2036
|
flexibility, target_roi, and target_mroi.
|
|
1973
2037
|
|
|
1974
2038
|
Args:
|
|
1975
|
-
fixed_budget: bool indicating whether it's a fixed budget optimization or
|
|
1976
|
-
flexible budget optimization.
|
|
1977
|
-
budget: integer indicating the total budget.
|
|
1978
2039
|
spend: np.ndarray with dimensions (`n_total_channels`) containing spend per
|
|
1979
2040
|
channel for all media and RF channels.
|
|
1980
2041
|
incremental_outcome: np.ndarray with dimensions (`n_total_channels`)
|
|
1981
2042
|
containing incremental outcome per channel for all media and RF channels.
|
|
1982
2043
|
roi_grid_point: float roi for non-optimized optimation step.
|
|
1983
|
-
|
|
1984
|
-
investment (mroi) constraint. This can be translated into "How much can I
|
|
1985
|
-
spend when I have flexible budget until the mroi of each channel hits the
|
|
1986
|
-
target mroi". It's still possible that the mroi of some channels will not
|
|
1987
|
-
be equal to the target mroi due to the feasible range of spend. However,
|
|
1988
|
-
the mroi will effectively shrink toward the target mroi.
|
|
1989
|
-
target_roi: Optional float indicating the target return on investment (roi)
|
|
1990
|
-
constraint. This can be translated into "How much can I spend when I have
|
|
1991
|
-
a flexible budget until the roi of total spend hits the target roi.
|
|
2044
|
+
scenario: FixedBudgetScenario or FlexibleBudgetScenario.
|
|
1992
2045
|
|
|
1993
2046
|
Returns:
|
|
1994
2047
|
bool indicating whether optimal spend and incremental outcome have been
|
|
1995
2048
|
found, given the optimization constraints.
|
|
1996
2049
|
"""
|
|
1997
|
-
if
|
|
1998
|
-
|
|
1999
|
-
|
|
2050
|
+
if isinstance(scenario, FixedBudgetScenario):
|
|
2051
|
+
# total_budget is guaranteed to be not None.
|
|
2052
|
+
return np.sum(spend) > scenario.total_budget
|
|
2053
|
+
elif (
|
|
2054
|
+
isinstance(scenario, FlexibleBudgetScenario)
|
|
2055
|
+
and scenario.target_metric == c.ROI
|
|
2056
|
+
):
|
|
2000
2057
|
cur_total_roi = np.sum(incremental_outcome) / np.sum(spend)
|
|
2001
2058
|
# In addition to the total roi being less than the target roi, the roi of
|
|
2002
2059
|
# the current optimization step should also be less than the total roi.
|
|
2003
2060
|
# Without the second condition, the optimization algorithm may not have
|
|
2004
2061
|
# found the roi point close to the target roi yet.
|
|
2005
|
-
|
|
2062
|
+
target_value = scenario.target_value
|
|
2063
|
+
return cur_total_roi < target_value and roi_grid_point < cur_total_roi
|
|
2006
2064
|
else:
|
|
2007
|
-
return roi_grid_point <
|
|
2065
|
+
return roi_grid_point < scenario.target_value
|
meridian/analysis/summarizer.py
CHANGED
|
@@ -85,7 +85,7 @@ class Summarizer:
|
|
|
85
85
|
filepath: The path to the directory where the file will be saved.
|
|
86
86
|
start_date: Optional start date selector, *inclusive*, in _yyyy-mm-dd_
|
|
87
87
|
format.
|
|
88
|
-
end_date: Optional end date selector, *
|
|
88
|
+
end_date: Optional end date selector, *inclusive* in _yyyy-mm-dd_ format.
|
|
89
89
|
"""
|
|
90
90
|
os.makedirs(filepath, exist_ok=True)
|
|
91
91
|
with open(os.path.join(filepath, filename), 'w') as f:
|
|
@@ -128,8 +128,12 @@ class Summarizer:
|
|
|
128
128
|
template_env.globals[c.START_DATE] = start_date.strftime(
|
|
129
129
|
f'%b {start_date.day}, %Y'
|
|
130
130
|
)
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
|
|
132
|
+
interval_days = self._meridian.input_data.time_coordinates.interval_days
|
|
133
|
+
end_date_adjusted = end_date + pd.Timedelta(days=interval_days)
|
|
134
|
+
|
|
135
|
+
template_env.globals[c.END_DATE] = end_date_adjusted.strftime(
|
|
136
|
+
f'%b {end_date_adjusted.day}, %Y'
|
|
133
137
|
)
|
|
134
138
|
|
|
135
139
|
html_template = template_env.get_template('summary.html.jinja')
|
meridian/analysis/test_utils.py
CHANGED
|
@@ -1813,6 +1813,32 @@ MROI_MEDIA_AND_RF_USE_POSTERIOR_BY_REACH = np.array([
|
|
|
1813
1813
|
[0.2282, 0.3271, 0.1354, 1.8194, 0.5598],
|
|
1814
1814
|
],
|
|
1815
1815
|
])
|
|
1816
|
+
MROI_MEDIA_AND_RF_NEW_TIMES_DATA = np.array([
|
|
1817
|
+
[
|
|
1818
|
+
[1.4194793, 1.4562954, 0.42110616, 0.46693847, 2.224705],
|
|
1819
|
+
[1.4079778, 1.4408227, 0.42190424, 0.46729392, 2.2684476],
|
|
1820
|
+
[1.4133729, 1.455379, 0.42965493, 0.46928722, 2.2630167],
|
|
1821
|
+
[1.3786758, 1.4669218, 0.4399548, 0.466466, 2.2499163],
|
|
1822
|
+
[1.3667594, 1.4595027, 0.430115, 0.46791035, 2.2718313],
|
|
1823
|
+
[1.3636469, 1.4838042, 0.43774834, 0.46620503, 2.228661],
|
|
1824
|
+
[1.3479362, 1.5108197, 0.42130333, 0.46699694, 2.2348554],
|
|
1825
|
+
[1.3684787, 1.5252702, 0.4246083, 0.4682973, 2.2124028],
|
|
1826
|
+
[1.3500556, 1.5139565, 0.42371163, 0.46387878, 2.2052019],
|
|
1827
|
+
[1.3509449, 1.5152782, 0.42376328, 0.46421173, 2.2056532],
|
|
1828
|
+
],
|
|
1829
|
+
[
|
|
1830
|
+
[0.19265468, 0.3131754, 0.11835674, 1.7264867, 0.45867893],
|
|
1831
|
+
[0.19271582, 0.31310934, 0.11833327, 1.7265227, 0.45878735],
|
|
1832
|
+
[0.1927399, 0.3133164, 0.11834618, 1.7260367, 0.45711297],
|
|
1833
|
+
[0.19282141, 0.31354108, 0.11858677, 1.724129, 0.45773327],
|
|
1834
|
+
[0.19184875, 0.31352347, 0.11844476, 1.724147, 0.4579198],
|
|
1835
|
+
[0.19213778, 0.3136204, 0.11846119, 1.7245249, 0.4575424],
|
|
1836
|
+
[0.19298446, 0.3144178, 0.11840369, 1.7243268, 0.45999327],
|
|
1837
|
+
[0.19473709, 0.3146425, 0.1182382, 1.7254068, 0.46479526],
|
|
1838
|
+
[0.19482231, 0.31446627, 0.11806685, 1.7242908, 0.46568453],
|
|
1839
|
+
[0.19479823, 0.31506982, 0.11783329, 1.7254248, 0.46606192],
|
|
1840
|
+
],
|
|
1841
|
+
])
|
|
1816
1842
|
MROI_MEDIA_ONLY_USE_PRIOR = np.array([[
|
|
1817
1843
|
[1.0740, 1.3019, 0.7984],
|
|
1818
1844
|
[0.8990, 0.4201, 0.7120],
|
|
@@ -1999,6 +2025,32 @@ SAMPLE_ROI_NEW_DATA = np.array([
|
|
|
1999
2025
|
[4.11828271, 1.58598067],
|
|
2000
2026
|
],
|
|
2001
2027
|
])
|
|
2028
|
+
ROI_NEW_TIMES_DATA = np.array([
|
|
2029
|
+
[
|
|
2030
|
+
[1.6587073, 1.6422542, 0.5339541, 0.46695647, 2.2247107],
|
|
2031
|
+
[1.6448834, 1.6238545, 0.5349834, 0.46725115, 2.268456],
|
|
2032
|
+
[1.6512104, 1.6399834, 0.54364294, 0.46939594, 2.2630484],
|
|
2033
|
+
[1.6124083, 1.6505513, 0.55594736, 0.46645072, 2.2499137],
|
|
2034
|
+
[1.597995, 1.6415955, 0.5437353, 0.46789172, 2.2718024],
|
|
2035
|
+
[1.5946435, 1.6684949, 0.5538136, 0.46621037, 2.2286298],
|
|
2036
|
+
[1.5757265, 1.6986614, 0.5317232, 0.4669749, 2.2348094],
|
|
2037
|
+
[1.5993005, 1.7154359, 0.535261, 0.4682574, 2.2124054],
|
|
2038
|
+
[1.5770503, 1.7027655, 0.5336275, 0.4639002, 2.2051802],
|
|
2039
|
+
[1.5781163, 1.704347, 0.53381217, 0.4641966, 2.2056897],
|
|
2040
|
+
],
|
|
2041
|
+
[
|
|
2042
|
+
[0.22893494, 0.54465544, 0.17262405, 1.726445, 0.4586947],
|
|
2043
|
+
[0.22897178, 0.54465, 0.17261527, 1.7264631, 0.45878458],
|
|
2044
|
+
[0.22901268, 0.5448365, 0.17263621, 1.7260604, 0.45709604],
|
|
2045
|
+
[0.22900137, 0.54496145, 0.17280822, 1.7242719, 0.4577607],
|
|
2046
|
+
[0.22795935, 0.54490256, 0.17272605, 1.7241275, 0.4579781],
|
|
2047
|
+
[0.22818668, 0.5450325, 0.1726518, 1.7245975, 0.45759043],
|
|
2048
|
+
[0.22910665, 0.5459215, 0.17257686, 1.7243998, 0.4600002],
|
|
2049
|
+
[0.23114449, 0.54561883, 0.17233835, 1.7253716, 0.46480042],
|
|
2050
|
+
[0.23121877, 0.5451343, 0.17214388, 1.7242151, 0.46571606],
|
|
2051
|
+
[0.23116706, 0.54571074, 0.17181544, 1.7254194, 0.4660912],
|
|
2052
|
+
],
|
|
2053
|
+
])
|
|
2002
2054
|
SAMPLE_ROI_KPI = np.array([
|
|
2003
2055
|
[
|
|
2004
2056
|
[0.4906, 0.3487],
|
|
@@ -2073,34 +2125,34 @@ SAMPLE_MROI = np.array([
|
|
|
2073
2125
|
])
|
|
2074
2126
|
SAMPLE_MROI_NEW_DATA = np.array([
|
|
2075
2127
|
[
|
|
2076
|
-
[0.
|
|
2077
|
-
[0.
|
|
2078
|
-
[0.
|
|
2079
|
-
[2.
|
|
2128
|
+
[0.86685735, 0.9019572],
|
|
2129
|
+
[0.5474438, 0.88566875],
|
|
2130
|
+
[0.33536345, 0.23084486],
|
|
2131
|
+
[2.325597, 1.615559],
|
|
2080
2132
|
],
|
|
2081
2133
|
[
|
|
2082
|
-
[
|
|
2083
|
-
[0.
|
|
2084
|
-
[0.
|
|
2085
|
-
[2.
|
|
2134
|
+
[0.98578817, 1.1203848],
|
|
2135
|
+
[0.79026884, 1.0930933],
|
|
2136
|
+
[0.09857258, 0.306825],
|
|
2137
|
+
[2.1813164, 1.9769008],
|
|
2086
2138
|
],
|
|
2087
2139
|
[
|
|
2088
|
-
[1.
|
|
2089
|
-
[0.
|
|
2090
|
-
[0.
|
|
2091
|
-
[
|
|
2140
|
+
[1.1169803, 0.29080662],
|
|
2141
|
+
[0.65436214, 0.28769404],
|
|
2142
|
+
[0.4310956, 0.12189759],
|
|
2143
|
+
[2.9086902, 0.46900335],
|
|
2092
2144
|
],
|
|
2093
2145
|
[
|
|
2094
|
-
[0.
|
|
2095
|
-
[0.
|
|
2096
|
-
[0.
|
|
2097
|
-
[2.
|
|
2146
|
+
[0.9376944, 0.11894181],
|
|
2147
|
+
[0.6223348, 0.11948171],
|
|
2148
|
+
[0.23284341, 0.09595118],
|
|
2149
|
+
[2.408213, 0.13983254],
|
|
2098
2150
|
],
|
|
2099
2151
|
[
|
|
2100
|
-
[0.
|
|
2101
|
-
[0.
|
|
2102
|
-
[0.
|
|
2103
|
-
[1.
|
|
2152
|
+
[0.501675, 0.15347004],
|
|
2153
|
+
[0.33212814, 0.15300572],
|
|
2154
|
+
[0.20545325, 0.14246973],
|
|
2155
|
+
[1.0451934, 0.16632313],
|
|
2104
2156
|
],
|
|
2105
2157
|
[
|
|
2106
2158
|
# mROI metric don't make sense for "All Channels".
|