google-meridian 1.0.3__py3-none-any.whl → 1.0.5__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.
@@ -110,6 +110,24 @@ def bar_chart_width(num_bars: int) -> int:
110
110
  return (c.BAR_SIZE + c.PADDING_20) * num_bars
111
111
 
112
112
 
113
+ def format_percent(percent: float) -> str:
114
+ """Formats a percentage value into a string format.
115
+
116
+ Percentage values between 0 and 1 are formatted with 1 decimal place.
117
+ Percentage values greater than 1 are formatted with 0 decimal places.
118
+
119
+ Args:
120
+ percent: The percentage value to format.
121
+
122
+ Returns:
123
+ A formatted string.
124
+ """
125
+ if percent >= 0.01:
126
+ return '{:.0%}'.format(percent)
127
+ else:
128
+ return '{:.1g}%'.format(percent * 100)
129
+
130
+
113
131
  def compact_number(n: float, precision: int = 0, currency: str = '') -> str:
114
132
  """Formats a number into a compact notation to the specified precision.
115
133
 
@@ -45,6 +45,64 @@ alt.data_transformers.disable_max_rows()
45
45
  _SpendConstraint: TypeAlias = float | Sequence[float]
46
46
 
47
47
 
48
+ @dataclasses.dataclass(frozen=True)
49
+ class OptimizationGrid:
50
+ """Optimization grid information.
51
+
52
+ Attributes:
53
+ spend: ndarray of shape `(n_paid_channels,)` containing the spend allocation
54
+ for spend for all media and RF channels. The order matches
55
+ `InputData.get_all_paid_channels`.
56
+ use_kpi: Whether using generic KPI or revenue.
57
+ use_posterior: Whether posterior distributions were used, or prior.
58
+ use_optimal_frequency: Whether optimal frequency was used.
59
+ round_factor: The round factor used for the optimization grid.
60
+ optimal_frequency: Optional ndarray of shape `(n_paid_channels,)`,
61
+ containing the optimal frequency per channel. Value is `None` if the model
62
+ does not contain reach and frequency data, or if the model does contain
63
+ reach and frequency data, but historical frequency is used for the
64
+ optimization scenario.
65
+ selected_times: The time coordinates from the model used in this grid.
66
+ """
67
+
68
+ _grid_dataset: xr.Dataset
69
+
70
+ spend: np.ndarray
71
+ use_kpi: bool
72
+ use_posterior: bool
73
+ use_optimal_frequency: bool
74
+ round_factor: int
75
+ optimal_frequency: np.ndarray | None
76
+ selected_times: list[str] | None
77
+
78
+ @property
79
+ def grid_dataset(self) -> xr.Dataset:
80
+ """Dataset holding the grid information used for optimization.
81
+
82
+ The dataset contains the following:
83
+
84
+ - Coordinates: `grid_spend_index`, `channel`
85
+ - Data variables: `spend_grid`, `incremental_outcome_grid`
86
+ - Attributes: `spend_step_size`
87
+ """
88
+ return self._grid_dataset
89
+
90
+ @property
91
+ def spend_grid(self) -> np.ndarray:
92
+ """The spend grid."""
93
+ return self.grid_dataset.spend_grid
94
+
95
+ @property
96
+ def incremental_outcome_grid(self) -> np.ndarray:
97
+ """The incremental outcome grid."""
98
+ return self.grid_dataset.incremental_outcome_grid
99
+
100
+ @property
101
+ def spend_step_size(self) -> float:
102
+ """The spend step size."""
103
+ return self.grid_dataset.attrs[c.SPEND_STEP_SIZE]
104
+
105
+
48
106
  @dataclasses.dataclass(frozen=True)
49
107
  class OptimizationResults:
50
108
  """The optimized budget allocation.
@@ -69,10 +127,6 @@ class OptimizationResults:
69
127
  meridian: The fitted Meridian model that was used to create this budget
70
128
  allocation.
71
129
  analyzer: The analyzer bound to the model above.
72
- use_posterior: Whether the posterior distribution was used to optimize the
73
- budget. If `False`, the prior distribution was used.
74
- use_optimal_frequency: Whether optimal frequency was used to optimize the
75
- budget.
76
130
  spend_ratio: The spend ratio used to scale the non-optimized budget metrics
77
131
  to the optimized budget metrics.
78
132
  spend_bounds: The spend bounds used to scale the non-optimized budget
@@ -88,10 +142,6 @@ class OptimizationResults:
88
142
  meridian: model.Meridian
89
143
  # The analyzer bound to the model above.
90
144
  analyzer: analyzer.Analyzer
91
-
92
- # The intermediate values used to derive the optimized budget allocation.
93
- use_posterior: bool
94
- use_optimal_frequency: bool
95
145
  spend_ratio: np.ndarray # spend / historical spend
96
146
  spend_bounds: tuple[np.ndarray, np.ndarray]
97
147
 
@@ -99,7 +149,7 @@ class OptimizationResults:
99
149
  _nonoptimized_data: xr.Dataset
100
150
  _nonoptimized_data_with_optimal_freq: xr.Dataset
101
151
  _optimized_data: xr.Dataset
102
- _optimization_grid: xr.Dataset
152
+ _optimization_grid: OptimizationGrid
103
153
 
104
154
  # TODO: Move this, and the plotting methods, to a summarizer.
105
155
  @functools.cached_property
@@ -174,23 +224,15 @@ class OptimizationResults:
174
224
  return self._optimized_data
175
225
 
176
226
  @property
177
- def optimization_grid(self) -> xr.Dataset:
178
- """Dataset holding the grid information used for optimization.
179
-
180
- The dataset contains the following:
181
-
182
- - Coordinates: `grid_spend_index`, `channel`
183
- - Data variables: `spend_grid`, `incremental_outcome_grid`
184
- - Attributes: `spend_step_size`
185
- """
227
+ def optimization_grid(self) -> OptimizationGrid:
228
+ """The grid information used for optimization."""
186
229
  return self._optimization_grid
187
230
 
188
231
  def output_optimization_summary(self, filename: str, filepath: str):
189
232
  """Generates and saves the HTML optimization summary output."""
190
- if self.optimized_data:
191
- os.makedirs(filepath, exist_ok=True)
192
- with open(os.path.join(filepath, filename), 'w') as f:
193
- f.write(self._gen_optimization_summary())
233
+ os.makedirs(filepath, exist_ok=True)
234
+ with open(os.path.join(filepath, filename), 'w') as f:
235
+ f.write(self._gen_optimization_summary())
194
236
 
195
237
  def plot_incremental_outcome_delta(self) -> alt.Chart:
196
238
  """Plots a waterfall chart showing the change in incremental outcome."""
@@ -540,10 +582,10 @@ class OptimizationResults:
540
582
  # response curve computation might take a significant amount of time.
541
583
  return self.analyzer.response_curves(
542
584
  spend_multipliers=spend_multiplier,
543
- use_posterior=self.use_posterior,
585
+ use_posterior=self.optimization_grid.use_posterior,
544
586
  selected_times=selected_times,
545
587
  by_reach=True,
546
- use_optimal_frequency=self.use_optimal_frequency,
588
+ use_optimal_frequency=self.optimization_grid.use_optimal_frequency,
547
589
  )
548
590
 
549
591
  def _get_plottable_response_curves_df(
@@ -675,7 +717,6 @@ class OptimizationResults:
675
717
  id=summary_text.SCENARIO_PLAN_CARD_ID,
676
718
  title=summary_text.SCENARIO_PLAN_CARD_TITLE,
677
719
  )
678
-
679
720
  scenario_type = (
680
721
  summary_text.FIXED_BUDGET_LABEL.lower()
681
722
  if self.optimized_data.fixed_budget
@@ -892,6 +933,14 @@ class BudgetOptimizer:
892
933
  self._meridian = meridian
893
934
  self._analyzer = analyzer.Analyzer(self._meridian)
894
935
 
936
+ def _validate_model_fit(self, use_posterior: bool):
937
+ """Validates that the model is fit."""
938
+ dist_type = c.POSTERIOR if use_posterior else c.PRIOR
939
+ if dist_type not in self._meridian.inference_data.groups():
940
+ raise model.NotFittedModelError(
941
+ 'Running budget optimization scenarios requires fitting the model.'
942
+ )
943
+
895
944
  def optimize(
896
945
  self,
897
946
  use_posterior: bool = True,
@@ -930,24 +979,33 @@ class BudgetOptimizer:
930
979
  specify either `target_roi` or `target_mroi`.
931
980
  budget: Number indicating the total budget for the fixed budget scenario.
932
981
  Defaults to the historical budget.
933
- pct_of_spend: Numeric list of size `n_total_channels` containing the
982
+ pct_of_spend: Numeric list of size `n_paid_channels` containing the
934
983
  percentage allocation for spend for all media and RF channels. The order
935
- must match `InputData.media` with values between 0-1, summing to 1. By
936
- default, the historical allocation is used. Budget and allocation are
937
- used in conjunction to determine the non-optimized media-level spend,
938
- which is used to calculate the non-optimized performance metrics (for
939
- example, ROI) and construct the feasible range of media-level spend with
940
- the spend constraints.
941
- spend_constraint_lower: Numeric list of size `n_total_channels` or float
984
+ must match `(InputData.media + InputData.reach)` with values between
985
+ 0-1, summing to 1. By default, the historical allocation is used. Budget
986
+ and allocation are used in conjunction to determine the non-optimized
987
+ media-level spend, which is used to calculate the non-optimized
988
+ performance metrics (for example, ROI) and construct the feasible range
989
+ of media-level spend with the spend constraints. Consider using
990
+ `InputData.get_paid_channels_argument_builder()` to construct this
991
+ argument.
992
+ spend_constraint_lower: Numeric list of size `n_paid_channels` or float
942
993
  (same constraint for all channels) indicating the lower bound of
943
- media-level spend. The lower bound of media-level spend is `(1 -
944
- spend_constraint_lower) * budget * allocation)`. The value must be
945
- between 0-1. Defaults to `0.3` for fixed budget and `1` for flexible.
946
- spend_constraint_upper: Numeric list of size `n_total_channels` or float
994
+ media-level spend. If given as a channel-indexed array, the order must
995
+ match `(InputData.media + InputData.reach)`. The lower bound of
996
+ media-level spend is `(1 - spend_constraint_lower) * budget *
997
+ allocation)`. The value must be between 0-1. Defaults to `0.3` for fixed
998
+ budget and `1` for flexible. Consider using
999
+ `InputData.get_paid_channels_argument_builder()` to construct this
1000
+ argument.
1001
+ spend_constraint_upper: Numeric list of size `n_paid_channels` or float
947
1002
  (same constraint for all channels) indicating the upper bound of
948
- media-level spend. The upper bound of media-level spend is `(1 +
949
- spend_constraint_upper) * budget * allocation)`. Defaults to `0.3` for
950
- fixed budget and `1` for flexible.
1003
+ media-level spend. If given as a channel-indexed array, the order must
1004
+ match `(InputData.media + InputData.reach)`. The upper bound of
1005
+ media-level spend is `(1 + spend_constraint_upper) * budget *
1006
+ allocation)`. Defaults to `0.3` for fixed budget and `1` for flexible.
1007
+ Consider using `InputData.get_paid_channels_argument_builder()` to
1008
+ construct this argument.
951
1009
  target_roi: Float indicating the target ROI constraint. Only used for
952
1010
  flexible budget scenarios. The budget is constrained to when the ROI of
953
1011
  the total spend hits `target_roi`.
@@ -972,12 +1030,13 @@ class BudgetOptimizer:
972
1030
  An `OptimizationResults` object containing optimized budget allocation
973
1031
  datasets, along with some of the intermediate values used to derive them.
974
1032
  """
975
- dist_type = c.POSTERIOR if use_posterior else c.PRIOR
976
- if dist_type not in self._meridian.inference_data.groups():
977
- raise model.NotFittedModelError(
978
- 'Running budget optimization scenarios requires fitting the model.'
979
- )
980
- self._validate_budget(fixed_budget, budget, target_roi, target_mroi)
1033
+ _validate_budget(
1034
+ fixed_budget=fixed_budget,
1035
+ budget=budget,
1036
+ target_roi=target_roi,
1037
+ target_mroi=target_mroi,
1038
+ )
1039
+
981
1040
  if selected_times is not None:
982
1041
  start_date, end_date = selected_times
983
1042
  selected_time_dims = self._meridian.expand_selected_time_dims(
@@ -986,23 +1045,17 @@ class BudgetOptimizer:
986
1045
  )
987
1046
  else:
988
1047
  selected_time_dims = None
989
-
990
1048
  hist_spend = self._analyzer.get_historical_spend(
991
1049
  selected_time_dims,
992
1050
  include_media=self._meridian.n_media_channels > 0,
993
1051
  include_rf=self._meridian.n_rf_channels > 0,
994
1052
  ).data
995
1053
 
996
- use_historical_budget = budget is None or round(budget) == round(
997
- np.sum(hist_spend)
998
- )
999
1054
  budget = budget or np.sum(hist_spend)
1000
1055
  pct_of_spend = self._validate_pct_of_spend(hist_spend, pct_of_spend)
1001
1056
  spend = budget * pct_of_spend
1002
1057
  round_factor = _get_round_factor(budget, gtol)
1003
- step_size = 10 ** (-round_factor)
1004
1058
  rounded_spend = np.round(spend, round_factor).astype(int)
1005
- spend_ratio = spend / hist_spend
1006
1059
  if self._meridian.n_rf_channels > 0 and use_optimal_frequency:
1007
1060
  optimal_frequency = tf.convert_to_tensor(
1008
1061
  self._analyzer.optimal_freq(
@@ -1024,34 +1077,30 @@ class BudgetOptimizer:
1024
1077
  fixed_budget=fixed_budget,
1025
1078
  )
1026
1079
  )
1027
- (spend_grid, incremental_outcome_grid) = self._create_grids(
1080
+ optimization_grid = self.create_optimization_grid(
1028
1081
  spend=hist_spend,
1029
1082
  spend_bound_lower=optimization_lower_bound,
1030
1083
  spend_bound_upper=optimization_upper_bound,
1031
- step_size=step_size,
1032
1084
  selected_times=selected_time_dims,
1085
+ round_factor=round_factor,
1033
1086
  use_posterior=use_posterior,
1034
1087
  use_kpi=use_kpi,
1088
+ use_optimal_frequency=use_optimal_frequency,
1035
1089
  optimal_frequency=optimal_frequency,
1036
1090
  batch_size=batch_size,
1037
1091
  )
1092
+ # TODO: b/375644691) - Move grid search to a OptimizationGrid class.
1038
1093
  optimal_spend = self._grid_search(
1039
- spend_grid=spend_grid,
1040
- incremental_outcome_grid=incremental_outcome_grid,
1094
+ spend_grid=optimization_grid.spend_grid,
1095
+ incremental_outcome_grid=optimization_grid.incremental_outcome_grid,
1041
1096
  budget=np.sum(rounded_spend),
1042
1097
  fixed_budget=fixed_budget,
1043
1098
  target_mroi=target_mroi,
1044
1099
  target_roi=target_roi,
1045
1100
  )
1046
-
1047
- constraints = {
1048
- c.FIXED_BUDGET: fixed_budget,
1049
- }
1050
- if target_roi:
1051
- constraints[c.TARGET_ROI] = target_roi
1052
- elif target_mroi:
1053
- constraints[c.TARGET_MROI] = target_mroi
1054
-
1101
+ use_historical_budget = budget is None or round(budget) == round(
1102
+ np.sum(hist_spend)
1103
+ )
1055
1104
  nonoptimized_data = self._create_budget_dataset(
1056
1105
  use_posterior=use_posterior,
1057
1106
  use_kpi=use_kpi,
@@ -1073,6 +1122,13 @@ class BudgetOptimizer:
1073
1122
  batch_size=batch_size,
1074
1123
  use_historical_budget=use_historical_budget,
1075
1124
  )
1125
+ constraints = {
1126
+ c.FIXED_BUDGET: fixed_budget,
1127
+ }
1128
+ if target_roi:
1129
+ constraints[c.TARGET_ROI] = target_roi
1130
+ elif target_mroi:
1131
+ constraints[c.TARGET_MROI] = target_mroi
1076
1132
  optimized_data = self._create_budget_dataset(
1077
1133
  use_posterior=use_posterior,
1078
1134
  use_kpi=use_kpi,
@@ -1085,18 +1141,16 @@ class BudgetOptimizer:
1085
1141
  batch_size=batch_size,
1086
1142
  use_historical_budget=use_historical_budget,
1087
1143
  )
1088
-
1089
- optimization_grid = self._create_optimization_grid(
1090
- spend_grid=spend_grid,
1091
- spend_step_size=step_size,
1092
- incremental_outcome_grid=incremental_outcome_grid,
1144
+ spend_ratio = np.divide(
1145
+ spend,
1146
+ hist_spend,
1147
+ out=np.zeros_like(hist_spend, dtype=float),
1148
+ where=hist_spend != 0,
1093
1149
  )
1094
1150
 
1095
1151
  return OptimizationResults(
1096
1152
  meridian=self._meridian,
1097
1153
  analyzer=self._analyzer,
1098
- use_posterior=use_posterior,
1099
- use_optimal_frequency=use_optimal_frequency,
1100
1154
  spend_ratio=spend_ratio,
1101
1155
  spend_bounds=spend_bounds,
1102
1156
  _nonoptimized_data=nonoptimized_data,
@@ -1105,7 +1159,83 @@ class BudgetOptimizer:
1105
1159
  _optimization_grid=optimization_grid,
1106
1160
  )
1107
1161
 
1108
- def _create_optimization_grid(
1162
+ def create_optimization_grid(
1163
+ self,
1164
+ spend: np.ndarray,
1165
+ spend_bound_lower: np.ndarray,
1166
+ spend_bound_upper: np.ndarray,
1167
+ selected_times: Sequence[str] | None,
1168
+ round_factor: int,
1169
+ use_posterior: bool = True,
1170
+ use_kpi: bool = False,
1171
+ use_optimal_frequency: bool = True,
1172
+ optimal_frequency: xr.DataArray | None = None,
1173
+ batch_size: int = c.DEFAULT_BATCH_SIZE,
1174
+ ) -> OptimizationGrid:
1175
+ """Creates a OptimizationGrid for optimization.
1176
+
1177
+ Args:
1178
+ spend: ndarray of shape `(n_paid_channels,)` with spend per paid channel.
1179
+ spend_bound_lower: ndarray of dimension `(n_total_channels,)` containing
1180
+ the lower constraint spend for each channel.
1181
+ spend_bound_upper: ndarray of dimension `(n_total_channels,)` containing
1182
+ the upper constraint spend for each channel.
1183
+ selected_times: Sequence of strings representing the time dimensions in
1184
+ `meridian.input_data.time` to use for optimization.
1185
+ round_factor: The round factor used for the optimization grid.
1186
+ use_posterior: Boolean. If `True`, then the incremental outcome is derived
1187
+ from the posterior distribution of the model. Otherwise, the prior
1188
+ distribution is used.
1189
+ use_kpi: Boolean. If `True`, then the incremental outcome is derived from
1190
+ the KPI impact. Otherwise, the incremental outcome is derived from the
1191
+ revenue impact.
1192
+ use_optimal_frequency: Boolean. Whether optimal frequency was used.
1193
+ optimal_frequency: `xr.DataArray` with dimension `n_rf_channels`,
1194
+ containing the optimal frequency per channel, that maximizes mean ROI
1195
+ over the corresponding prior/posterior distribution. Value is `None` if
1196
+ the model does not contain reach and frequency data, or if the model
1197
+ does contain reach and frequency data, but historical frequency is used
1198
+ for the optimization scenario.
1199
+ batch_size: Max draws per chain in each batch. The calculation is run in
1200
+ batches to avoid memory exhaustion. If a memory error occurs, try
1201
+ reducing `batch_size`. The calculation will generally be faster with
1202
+ larger `batch_size` values.
1203
+
1204
+ Returns:
1205
+ An OptimizationGrid object containing the grid data for optimization.
1206
+ """
1207
+ self._validate_model_fit(use_posterior)
1208
+
1209
+ step_size = 10 ** (-round_factor)
1210
+ (spend_grid, incremental_outcome_grid) = self._create_grids(
1211
+ spend=spend,
1212
+ spend_bound_lower=spend_bound_lower,
1213
+ spend_bound_upper=spend_bound_upper,
1214
+ step_size=step_size,
1215
+ selected_times=selected_times,
1216
+ use_posterior=use_posterior,
1217
+ use_kpi=use_kpi,
1218
+ optimal_frequency=optimal_frequency,
1219
+ batch_size=batch_size,
1220
+ )
1221
+ grid_dataset = self._create_grid_dataset(
1222
+ spend_grid=spend_grid,
1223
+ spend_step_size=step_size,
1224
+ incremental_outcome_grid=incremental_outcome_grid,
1225
+ )
1226
+
1227
+ return OptimizationGrid(
1228
+ _grid_dataset=grid_dataset,
1229
+ spend=spend,
1230
+ use_kpi=use_kpi,
1231
+ use_posterior=use_posterior,
1232
+ use_optimal_frequency=use_optimal_frequency,
1233
+ round_factor=round_factor,
1234
+ optimal_frequency=optimal_frequency,
1235
+ selected_times=selected_times,
1236
+ )
1237
+
1238
+ def _create_grid_dataset(
1109
1239
  self,
1110
1240
  spend_grid: np.ndarray,
1111
1241
  spend_step_size: float,
@@ -1151,39 +1281,6 @@ class BudgetOptimizer:
1151
1281
  attrs={c.SPEND_STEP_SIZE: spend_step_size},
1152
1282
  )
1153
1283
 
1154
- def _validate_budget(
1155
- self,
1156
- fixed_budget: bool,
1157
- budget: float | None,
1158
- target_roi: float | None,
1159
- target_mroi: float | None,
1160
- ):
1161
- """Validates the budget optimization arguments."""
1162
- if fixed_budget:
1163
- if target_roi is not None:
1164
- raise ValueError(
1165
- '`target_roi` is only used for flexible budget scenarios.'
1166
- )
1167
- if target_mroi is not None:
1168
- raise ValueError(
1169
- '`target_mroi` is only used for flexible budget scenarios.'
1170
- )
1171
- if budget is not None and budget <= 0:
1172
- raise ValueError('`budget` must be greater than zero.')
1173
- else:
1174
- if budget is not None:
1175
- raise ValueError('`budget` is only used for fixed budget scenarios.')
1176
- if target_roi is None and target_mroi is None:
1177
- raise ValueError(
1178
- 'Must specify either `target_roi` or `target_mroi` for flexible'
1179
- ' budget optimization.'
1180
- )
1181
- if target_roi is not None and target_mroi is not None:
1182
- raise ValueError(
1183
- 'Must specify only one of `target_roi` or `target_mroi` for'
1184
- 'flexible budget optimization.'
1185
- )
1186
-
1187
1284
  def _validate_pct_of_spend(
1188
1285
  self, hist_spend: np.ndarray, pct_of_spend: Sequence[float] | None
1189
1286
  ) -> np.ndarray:
@@ -1377,27 +1474,6 @@ class BudgetOptimizer:
1377
1474
  incremental_outcome_with_mean_median_and_ci[:, 0]
1378
1475
  )
1379
1476
 
1380
- # expected_outcome here is a tensor with the shape (n_chains, n_draws)
1381
- expected_outcome = self._analyzer.expected_outcome(
1382
- use_posterior=use_posterior,
1383
- new_data=analyzer.DataTensors(
1384
- media=new_media,
1385
- reach=new_reach,
1386
- frequency=new_frequency,
1387
- ),
1388
- selected_times=selected_times,
1389
- use_kpi=use_kpi,
1390
- batch_size=batch_size,
1391
- )
1392
- mean_expected_outcome = tf.reduce_mean(expected_outcome, (0, 1)) # a scalar
1393
-
1394
- pct_contrib = incremental_outcome / mean_expected_outcome[..., None] * 100
1395
- pct_contrib_with_mean_median_and_ci = analyzer.get_central_tendency_and_ci(
1396
- data=pct_contrib,
1397
- confidence_level=confidence_level,
1398
- include_median=True,
1399
- )
1400
-
1401
1477
  aggregated_impressions = self._analyzer.get_aggregated_impressions(
1402
1478
  selected_times=selected_times,
1403
1479
  selected_geos=None,
@@ -1458,10 +1534,6 @@ class BudgetOptimizer:
1458
1534
  [c.CHANNEL, c.METRIC],
1459
1535
  incremental_outcome_with_mean_median_and_ci,
1460
1536
  ),
1461
- c.PCT_OF_CONTRIBUTION: (
1462
- [c.CHANNEL, c.METRIC],
1463
- pct_contrib_with_mean_median_and_ci,
1464
- ),
1465
1537
  c.EFFECTIVENESS: (
1466
1538
  [c.CHANNEL, c.METRIC],
1467
1539
  effectiveness_with_mean_median_and_ci,
@@ -1651,11 +1723,11 @@ class BudgetOptimizer:
1651
1723
  """Creates spend and incremental outcome grids for optimization algorithm.
1652
1724
 
1653
1725
  Args:
1654
- spend: np.ndarray with actual spend per media or RF channel.
1655
- spend_bound_lower: np.ndarray of dimension (`n_total_channels`) containing
1656
- the lower constraint spend for each channel.
1657
- spend_bound_upper: np.ndarray of dimension (`n_total_channels`) containing
1658
- the upper constraint spend for each channel.
1726
+ spend: `np.ndarray` with actual spend per media or RF channel.
1727
+ spend_bound_lower: `np.ndarray` of dimension (`n_total_channels`)
1728
+ containing the lower constraint spend for each channel.
1729
+ spend_bound_upper: `np.ndarray` of dimension (`n_total_channels`)
1730
+ containing the upper constraint spend for each channel.
1659
1731
  step_size: Integer indicating the step size, or interval, between values
1660
1732
  in the spend grid. All media channels have the same step size.
1661
1733
  selected_times: Sequence of strings representing the time dimensions in
@@ -1666,11 +1738,12 @@ class BudgetOptimizer:
1666
1738
  use_kpi: Boolean. If `True`, then the incremental outcome is derived from
1667
1739
  the KPI impact. Otherwise, the incremental outcome is derived from the
1668
1740
  revenue impact.
1669
- optimal_frequency: xr.DataArray with dimension `n_rf_channels`, containing
1670
- the optimal frequency per channel, that maximizes posterior mean roi.
1671
- Value is `None` if the model does not contain reach and frequency data,
1672
- or if the model does contain reach and frequency data, but historical
1673
- frequency is used for the optimization scenario.
1741
+ optimal_frequency: `xr.DataArray` with dimension `n_rf_channels`,
1742
+ containing the optimal frequency per channel, that maximizes mean ROI
1743
+ over the corresponding prior/posterior distribution. Value is `None` if
1744
+ the model does not contain reach and frequency data, or if the model
1745
+ does contain reach and frequency data, but historical frequency is used
1746
+ for the optimization scenario.
1674
1747
  batch_size: Max draws per chain in each batch. The calculation is run in
1675
1748
  batches to avoid memory exhaustion. If a memory error occurs, try
1676
1749
  reducing `batch_size`. The calculation will generally be faster with
@@ -1678,11 +1751,11 @@ class BudgetOptimizer:
1678
1751
 
1679
1752
  Returns:
1680
1753
  spend_grid: Discrete two-dimensional grid with the number of rows
1681
- determined by the `spend_constraints` and `step_size`, and the number of
1754
+ determined by the `spend_bound_**` and `step_size`, and the number of
1682
1755
  columns is equal to the number of total channels, containing spend by
1683
1756
  channel.
1684
1757
  incremental_outcome_grid: Discrete two-dimensional grid with the number of
1685
- rows determined by the `spend_constraints` and `step_size`, and the
1758
+ rows determined by the `spend_bound_**` and `step_size`, and the
1686
1759
  number of columns is equal to the number of total channels, containing
1687
1760
  incremental outcome by channel.
1688
1761
  """
@@ -1700,9 +1773,12 @@ class BudgetOptimizer:
1700
1773
  )
1701
1774
  spend_grid[: len(spend_grid_m), i] = spend_grid_m
1702
1775
  incremental_outcome_grid = np.full([n_grid_rows, n_grid_columns], np.nan)
1703
- multipliers_grid = tf.cast(
1776
+ multipliers_grid_base = tf.cast(
1704
1777
  tf.math.divide_no_nan(spend_grid, spend), dtype=tf.float32
1705
1778
  )
1779
+ multipliers_grid = np.where(
1780
+ np.isnan(spend_grid), np.nan, multipliers_grid_base
1781
+ )
1706
1782
  for i in range(n_grid_rows):
1707
1783
  self._update_incremental_outcome_grid(
1708
1784
  i=i,
@@ -1824,6 +1900,39 @@ class BudgetOptimizer:
1824
1900
  return spend_optimal
1825
1901
 
1826
1902
 
1903
+ def _validate_budget(
1904
+ fixed_budget: bool,
1905
+ budget: float | None,
1906
+ target_roi: float | None,
1907
+ target_mroi: float | None,
1908
+ ):
1909
+ """Validates the budget optimization arguments."""
1910
+ if fixed_budget:
1911
+ if target_roi is not None:
1912
+ raise ValueError(
1913
+ '`target_roi` is only used for flexible budget scenarios.'
1914
+ )
1915
+ if target_mroi is not None:
1916
+ raise ValueError(
1917
+ '`target_mroi` is only used for flexible budget scenarios.'
1918
+ )
1919
+ if budget is not None and budget <= 0:
1920
+ raise ValueError('`budget` must be greater than zero.')
1921
+ else:
1922
+ if budget is not None:
1923
+ raise ValueError('`budget` is only used for fixed budget scenarios.')
1924
+ if target_roi is None and target_mroi is None:
1925
+ raise ValueError(
1926
+ 'Must specify either `target_roi` or `target_mroi` for flexible'
1927
+ ' budget optimization.'
1928
+ )
1929
+ if target_roi is not None and target_mroi is not None:
1930
+ raise ValueError(
1931
+ 'Must specify only one of `target_roi` or `target_mroi` for'
1932
+ 'flexible budget optimization.'
1933
+ )
1934
+
1935
+
1827
1936
  def _get_round_factor(budget: float, gtol: float) -> int:
1828
1937
  """Function for obtaining number of integer digits to round off of budget.
1829
1938
 
@@ -1888,6 +1997,11 @@ def _exceeds_optimization_constraints(
1888
1997
  if fixed_budget:
1889
1998
  return np.sum(spend) > budget
1890
1999
  elif target_roi is not None:
1891
- return (np.sum(incremental_outcome) / np.sum(spend)) < target_roi
2000
+ cur_total_roi = np.sum(incremental_outcome) / np.sum(spend)
2001
+ # In addition to the total roi being less than the target roi, the roi of
2002
+ # the current optimization step should also be less than the total roi.
2003
+ # Without the second condition, the optimization algorithm may not have
2004
+ # found the roi point close to the target roi yet.
2005
+ return cur_total_roi < target_roi and roi_grid_point < cur_total_roi
1892
2006
  else:
1893
2007
  return roi_grid_point < target_mroi
@@ -227,8 +227,8 @@ class Summarizer:
227
227
  ]
228
228
  row_values = [
229
229
  '{:.2f}'.format(sliced_table_by_eval_set[c.R_SQUARED].item()),
230
- '{:.0%}'.format(sliced_table_by_eval_set[c.MAPE].item()),
231
- '{:.0%}'.format(sliced_table_by_eval_set[c.WMAPE].item()),
230
+ formatter.format_percent(sliced_table_by_eval_set[c.MAPE].item()),
231
+ formatter.format_percent(sliced_table_by_eval_set[c.WMAPE].item()),
232
232
  ]
233
233
  return row_values
234
234