@@ -102,6 +102,96 @@ def spend_step_size(self) -> float:
102
102
"""The spend step size."""
103
103
return self .grid_dataset .attrs [c .SPEND_STEP_SIZE ]
104
104
105
+ def optimize_grid (
106
+ self ,
107
+ budget : float | None = None ,
108
+ fixed_budget : bool = True ,
109
+ target_mroi : float | None = None ,
110
+ target_roi : float | None = None ,
111
+ ) -> np .ndarray :
112
+ """Hill-climbing search algorithm for budget optimization.
113
+
114
+ Args:
115
+ budget: Number indicating the total budget for the fixed budget scenario.
116
+ Defaults to the budget used in the grid.
117
+ fixed_budget: Bool indicating whether it's a fixed budget optimization or
118
+ flexible budget optimization.
119
+ target_mroi: Optional float indicating the target marginal return on
120
+ investment (mroi) constraint. This can be translated into "How much can
121
+ I spend when I have flexible budget until the mroi of each channel hits
122
+ the target mroi". It's still possible that the mroi of some channels
123
+ will not be equal to the target mroi due to the feasible range of media
124
+ spend. However, the mroi will effectively shrink toward the target mroi.
125
+ target_roi: Optional float indicating the target return on investment
126
+ (roi) constraint. This can be translated into "How much can I spend when
127
+ I have a flexible budget until the roi of total media spend hits the
128
+ target roi".
129
+
130
+ Returns:
131
+ optimal_spend: np.ndarry of dimension `(n_total_channels,)` containing the
132
+ media spend that maximizes incremental outcome based on spend
133
+ constraints for all media and RF channels.
134
+ """
135
+ _validate_budget (
136
+ fixed_budget = fixed_budget ,
137
+ budget = budget ,
138
+ target_mroi = target_mroi ,
139
+ target_roi = target_roi ,
140
+ )
141
+ if fixed_budget and budget is None :
142
+ rounded_spend = np .round (self .spend , self .round_factor ).astype (int )
143
+ budget = np .sum (rounded_spend )
144
+
145
+ spend = self .spend_grid [0 , :].copy ()
146
+ incremental_outcome = self .incremental_outcome_grid [0 , :].copy ()
147
+ spend_grid = self .spend_grid [1 :, :]
148
+ incremental_outcome_grid = self .incremental_outcome_grid [1 :, :]
149
+ iterative_roi_grid = np .round (
150
+ tf .math .divide_no_nan (
151
+ incremental_outcome_grid - incremental_outcome , spend_grid - spend
152
+ ),
153
+ decimals = 8 ,
154
+ )
155
+ while True :
156
+ spend_optimal = spend .astype (int )
157
+ # If none of the exit criteria are met roi_grid will eventually be filled
158
+ # with all nans.
159
+ if np .isnan (iterative_roi_grid ).all ():
160
+ break
161
+ point = np .unravel_index (
162
+ np .nanargmax (iterative_roi_grid ), iterative_roi_grid .shape
163
+ )
164
+ row_idx = point [0 ]
165
+ media_idx = point [1 ]
166
+ spend [media_idx ] = spend_grid [row_idx , media_idx ]
167
+ incremental_outcome [media_idx ] = incremental_outcome_grid [
168
+ row_idx , media_idx
169
+ ]
170
+ roi_grid_point = iterative_roi_grid [row_idx , media_idx ]
171
+ if _exceeds_optimization_constraints (
172
+ fixed_budget = fixed_budget ,
173
+ budget = budget ,
174
+ spend = spend ,
175
+ incremental_outcome = incremental_outcome ,
176
+ roi_grid_point = roi_grid_point ,
177
+ target_mroi = target_mroi ,
178
+ target_roi = target_roi ,
179
+ ):
180
+ break
181
+
182
+ iterative_roi_grid [0 : row_idx + 1 , media_idx ] = np .nan
183
+ iterative_roi_grid [row_idx + 1 :, media_idx ] = np .round (
184
+ tf .math .divide_no_nan (
185
+ incremental_outcome_grid [row_idx + 1 :, media_idx ]
186
+ - incremental_outcome_grid [row_idx , media_idx ],
187
+ spend_grid [row_idx + 1 :, media_idx ]
188
+ - spend_grid [row_idx , media_idx ],
189
+ ),
190
+ decimals = 8 ,
191
+ )
192
+
193
+ return spend_optimal
194
+
105
195
106
196
@dataclasses .dataclass (frozen = True )
107
197
class OptimizationResults :
@@ -1051,6 +1141,9 @@ def optimize(
1051
1141
include_rf = self ._meridian .n_rf_channels > 0 ,
1052
1142
).data
1053
1143
1144
+ use_historical_budget = budget is None or round (budget ) == round (
1145
+ np .sum (hist_spend )
1146
+ )
1054
1147
budget = budget or np .sum (hist_spend )
1055
1148
pct_of_spend = self ._validate_pct_of_spend (hist_spend , pct_of_spend )
1056
1149
spend = budget * pct_of_spend
@@ -1089,18 +1182,16 @@ def optimize(
1089
1182
optimal_frequency = optimal_frequency ,
1090
1183
batch_size = batch_size ,
1091
1184
)
1092
- # TODO: b/375644691) - Move grid search to a OptimizationGrid class.
1093
- optimal_spend = self ._grid_search (
1094
- spend_grid = optimization_grid .spend_grid ,
1095
- incremental_outcome_grid = optimization_grid .incremental_outcome_grid ,
1096
- budget = np .sum (rounded_spend ),
1185
+ if use_historical_budget :
1186
+ optimization_budget = None
1187
+ else :
1188
+ optimization_budget = np .sum (rounded_spend )
1189
+ optimal_spend = optimization_grid .optimize_grid (
1190
+ budget = optimization_budget ,
1097
1191
fixed_budget = fixed_budget ,
1098
1192
target_mroi = target_mroi ,
1099
1193
target_roi = target_roi ,
1100
1194
)
1101
- use_historical_budget = budget is None or round (budget ) == round (
1102
- np .sum (hist_spend )
1103
- )
1104
1195
nonoptimized_data = self ._create_budget_dataset (
1105
1196
use_posterior = use_posterior ,
1106
1197
use_kpi = use_kpi ,
@@ -1168,7 +1259,7 @@ def create_optimization_grid(
1168
1259
round_factor : int ,
1169
1260
use_posterior : bool = True ,
1170
1261
use_kpi : bool = False ,
1171
- use_optimal_frequency : bool = True ,
1262
+ use_optimal_frequency : bool = False ,
1172
1263
optimal_frequency : xr .DataArray | None = None ,
1173
1264
batch_size : int = c .DEFAULT_BATCH_SIZE ,
1174
1265
) -> OptimizationGrid :
@@ -1810,96 +1901,6 @@ def _create_grids(
1810
1901
)
1811
1902
return (spend_grid , incremental_outcome_grid )
1812
1903
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
-
1903
1904
def _validate_budget (
1904
1905
fixed_budget : bool ,
1905
1906
budget : float | None ,
0 commit comments