Skip to content

Commit 4e85e6e

Browse files
committed
Add confidence interval for relative lift.
PiperOrigin-RevId: 480275918
1 parent 1306a48 commit 4e85e6e

File tree

6 files changed

+146
-55
lines changed

6 files changed

+146
-55
lines changed

matched_markets/methodology/tbr_iroas.py

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import pandas as pd
2424

2525

26-
class TBRiROAS(object):
26+
class TBRiROAS():
2727
"""Time Based Regression geoexperiment methodology.
2828
2929
This class estimates the incremental Return on Ad Spend (iROAS)
@@ -138,7 +138,7 @@ def summary(self,
138138
- estimate. The median estimate of iROAS.
139139
- precision. Distance between the (1-level)/tails and 0.5 quantiles.
140140
- lower. The value of the (1-level)/tails quantile.
141-
- upper. If tails=2, the level/tails quantile, otherwise inf.
141+
- upper. If tails=2, the 1 - 0.5 * (1 - level) quantile, otherwise inf.
142142
- probability. The probability that Delta > posterior_threshold.
143143
- level. Records the level parameter used to generate the report.
144144
- posterior_threshold. Records the posterior_threshold parameter.
@@ -158,6 +158,28 @@ def summary(self,
158158
else:
159159
periods = (self.periods.test,)
160160

161+
tail_probability = (1 - level) / tails
162+
163+
response_data = self.tbr_response.analysis_data.copy().reset_index()
164+
observed_treatment_response = response_data.loc[
165+
(response_data[self.df_names.group] == self.groups.treatment) &
166+
(response_data['period'].isin(periods)), self.df_names.response].sum()
167+
168+
# Obtain the distributions of the causal effects on response
169+
delta_response = self.tbr_response.causal_cumulative_distribution(time=-1)
170+
# Simulate the incremental response and relative lift
171+
sims_response = delta_response.rvs(nsims, random_state=random_state)
172+
sims_relative_lift = sims_response / (
173+
observed_treatment_response - sims_response)
174+
relative_lift = np.median(sims_relative_lift)
175+
relative_lift_lower = np.percentile(sims_relative_lift,
176+
100 * tail_probability)
177+
if tails == 1:
178+
relative_lift_upper = np.inf
179+
else:
180+
relative_lift_upper = np.percentile(sims_relative_lift,
181+
100 * (1.0 - tail_probability))
182+
161183
# iROAS analysis assuming fixed costs.
162184
if self._is_fixed_cost_scenario():
163185
cost = np.sum(self.tbr_cost.causal_effect(periods))
@@ -168,28 +190,29 @@ def summary(self,
168190
report['incremental_cost'] = cost
169191
causal_effect = self.tbr_response.causal_effect(periods)
170192
report['incremental_response'] = np.sum(causal_effect)
193+
report['incremental_response_lower'] = report['lower'] * cost
194+
report['incremental_response_upper'] = report['upper'] * cost
195+
report['relative_lift'] = relative_lift
196+
report['relative_lift_lower'] = relative_lift_lower
197+
report['relative_lift_upper'] = relative_lift_upper
171198
report['scenario'] = 'fixed'
172199
# Return the report, less the scale column.
173200
return report.drop('scale', axis=1)
174201

175202
# iROAS analysis with variable costs modelled via TBR.
176203
else:
177-
alpha = (1 - level)/tails
178204

179-
# Obtain the distributions of the two sets of causal effects
180-
delta_response = self.tbr_response.causal_cumulative_distribution(time=-1)
205+
# Obtain the distributions of the causal effects on cost
181206
# We know that causal costs only arose during the test period.
182207
delta_cost = self.tbr_cost.causal_cumulative_distribution(
183208
periods=(self.periods.test,),
184209
time=-1)
185210

186211
# Simulate the iROAS
187212
sims_cost = delta_cost.rvs(nsims, random_state=random_state)
188-
sims_response = delta_response.rvs(nsims, random_state=random_state)
189213
sims_iroas = sims_response / sims_cost
190214

191-
# This needs to be used twice.
192-
ci_lower = np.percentile(sims_iroas, 100 * alpha)
215+
ci_lower = np.percentile(sims_iroas, 100 * tail_probability)
193216

194217
# Construct the report.
195218
causal_effect = self.tbr_cost.causal_effect(periods)
@@ -200,13 +223,22 @@ def summary(self,
200223
report['lower'] = ci_lower
201224
if tails == 1:
202225
report['upper'] = np.inf
226+
report['incremental_response_upper'] = np.inf
203227
else:
204-
report['upper'] = np.percentile(sims_iroas, 100 * (1 - alpha))
228+
report['upper'] = np.percentile(sims_iroas,
229+
100 * (1 - tail_probability))
230+
report['incremental_response_upper'] = delta_response.ppf(
231+
1 - tail_probability)
205232
report['probability'] = np.mean(sims_iroas > posterior_threshold)
206233
report['level'] = level
207234
report['posterior_threshold'] = posterior_threshold
208235
report['incremental_cost'] = delta_cost.kwds['loc']
209236
report['incremental_response'] = delta_response.kwds['loc']
237+
report['incremental_response_lower'] = delta_response.ppf(
238+
tail_probability)
239+
report['relative_lift'] = relative_lift
240+
report['relative_lift_lower'] = relative_lift_lower
241+
report['relative_lift_upper'] = relative_lift_upper
210242
report['scenario'] = 'variable'
211243

212244
return report
@@ -276,7 +308,7 @@ def estimate_pointwise_and_cumulative_effect(
276308
f'got {metric}')
277309

278310
periods = (self.periods.pre, self.periods.test, self.periods.cooldown)
279-
alpha = (1 - level) / tails
311+
tail_probability = (1 - level) / tails
280312

281313
metric_data = metric_df.analysis_data.copy().reset_index()
282314

@@ -321,11 +353,11 @@ def estimate_pointwise_and_cumulative_effect(
321353
delta_metric = metric_df.causal_cumulative_distribution()
322354
pointwise_difference = metric_df.causal_effect(
323355
periods).reset_index().rename(columns={0: 'metric'})
324-
lower = np.diff(delta_metric.ppf(alpha), prepend=0)
356+
lower = np.diff(delta_metric.ppf(tail_probability), prepend=0)
325357
lower = np.concatenate((pointwise_difference.loc[
326358
pointwise_difference['date'] < test_start_date,
327359
'metric'].values, lower))
328-
upper = np.diff(delta_metric.ppf(1 - alpha), prepend=0)
360+
upper = np.diff(delta_metric.ppf(1 - tail_probability), prepend=0)
329361
upper = np.concatenate((pointwise_difference.loc[
330362
pointwise_difference['date'] < test_start_date,
331363
'metric'].values, upper))
@@ -358,8 +390,8 @@ def estimate_pointwise_and_cumulative_effect(
358390
cumulative_effect_df = common_classes.EstimatedTimeSeriesWithConfidenceInterval(
359391
{
360392
'date': experiment_dates,
361-
'lower': delta_metric.ppf(alpha),
362-
'upper': delta_metric.ppf(1 - alpha),
393+
'lower': delta_metric.ppf(tail_probability),
394+
'upper': delta_metric.ppf(1 - tail_probability),
363395
'estimate': cumulative_effect
364396
})
365397

matched_markets/methodology/tbrmmdiagnostics.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def bbtest(self) -> Optional[BBTestResult]:
329329
return None
330330

331331
if self._bbtest is None:
332-
a, _, sigma, resid = self.pretestfit
332+
a, _, sigma, resid = self.pretestfit # pytype: disable=attribute-error # strict-namedtuple-checks
333333
# Failure to fit the regression implies failure of the test.
334334
if np.isnan(a):
335335
self._bbtest = BBTestResult(False, None, None)
@@ -434,7 +434,7 @@ def aatest(self) -> Optional[AATestResult]:
434434
diag.x = x[:-n_test]
435435
yt = y[-n_test:].mean()
436436
xt = x[-n_test:].mean()
437-
estimate, cihw, sigma, _ = diag.tbrfit(xt, yt)
437+
estimate, cihw, sigma, _ = diag.tbrfit(xt, yt) # pytype: disable=attribute-error # strict-namedtuple-checks
438438
bounds = (estimate - cihw, estimate + cihw)
439439
lower, upper = bounds
440440
if lower * upper < 0:

matched_markets/methodology/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ def expand_time_windows(periods: List[TimeWindow]) -> List[pd.Timestamp]:
201201
"""
202202
days_exclude = []
203203
for window in periods:
204-
days_exclude += pd.date_range(window.first_day, window.last_day, freq='D')
204+
days_exclude += pd.date_range(
205+
window.first_day, window.last_day, freq='D').to_list()
205206

206207
return list(set(days_exclude))
207208

matched_markets/notebook/post_analysis_colab_for_tbrmm.ipynb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,15 +378,12 @@
378378
" f'{results.posterior_threshold.values[0]}:' +\n",
379379
" f' {results.probability.values[0]}')\n",
380380
"\n",
381-
" treatment_response = geox_data.loc[(geox_data[\"assignment\"] == 1) \u0026 (\n",
382-
" geox_data[\"period\"].isin(period_to_use[ind])), \"response\"].sum()\n",
383-
"\n",
384381
" print('\\nincremental cost = {}'.format(\n",
385382
" human_readable_number(results.incremental_cost.values[0])))\n",
386383
" print('\\nincremental response = {}'.format(\n",
387384
" human_readable_number(results.incremental_response.values[0])))\n",
388385
" print('\\nincremental response as % of treatment response = {:.2f}%\\n'.format(\n",
389-
" results.incremental_response.values[0] * 100 / treatment_response))"
386+
" results.relative_lift.values[0] * 100))"
390387
]
391388
},
392389
{

matched_markets/tests/test_tbr_iroas.py

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ def setUp(self):
6262
key_group=self.key_group,
6363
key_period=self.key_period,
6464
key_date=self.key_date)
65+
self.treatment_response = self.data.loc[(self.data[self.key_group] == 2) & (
66+
self.data[self.key_period] == 1), self.key_response].sum()
6567

6668
def testFixedCostIROASSummary(self):
6769
"""Checks the TBR results for an holdback experiment."""
@@ -86,16 +88,25 @@ def testFixedCostIROASSummary(self):
8688
r_incr_resp = 147337.122
8789
r_incr_cost = 50000
8890
r_probability = 1.0
91+
r_incr_response_lower = r_lower * r_incr_cost
92+
r_lift = 0.173998
93+
r_lift_lower = 0.165686
8994

9095
# Summary values from python.
9196
iroas = self.iroas_model.summary(
9297
level=level, posterior_threshold=posterior_threshold, tails=tails)
9398
py_estimate = iroas['estimate'].iloc[0]
9499
py_precision = iroas['precision'].iloc[0]
95100
py_lower = iroas['lower'].iloc[0]
101+
py_upper = iroas['upper'].iloc[0]
96102
py_incr_resp = iroas['incremental_response'].iloc[0]
97103
py_incr_cost = iroas['incremental_cost'].iloc[0]
98104
py_probability = iroas['probability'].iloc[0]
105+
py_incr_resp_lower = iroas['incremental_response_lower'].iloc[0]
106+
py_incr_resp_upper = iroas['incremental_response_upper'].iloc[0]
107+
py_lift = iroas['relative_lift'].iloc[0]
108+
py_lift_lower = iroas['relative_lift_lower'].iloc[0]
109+
py_lift_upper = iroas['relative_lift_upper'].iloc[0]
99110

100111
# Must do it like this as the R value is given with lower number of dps.
101112
order_estimate = utils.float_order(r_estimate - py_estimate)
@@ -104,14 +115,23 @@ def testFixedCostIROASSummary(self):
104115
order_iresp = utils.float_order(r_incr_resp - py_incr_resp)
105116
order_icost = utils.float_order(r_incr_cost - py_incr_cost)
106117
order_probability = utils.float_order(r_probability - py_probability)
107-
118+
order_iresp_lower = utils.float_order(r_incr_response_lower -
119+
py_incr_resp_lower)
120+
order_lift = utils.float_order(r_lift - py_lift)
121+
order_lift_lower = utils.float_order(r_lift_lower - py_lift_lower)
108122
# Conduct the tests.
109123
self.assertLess(order_estimate, -5)
110124
self.assertLess(order_precision, -5)
111125
self.assertLess(order_lower, -5)
126+
self.assertEqual(py_upper, np.inf)
112127
self.assertLess(order_iresp, -2) # incremental_response is a larger number.
113128
self.assertLess(order_icost, -5)
114129
self.assertLess(order_probability, -5)
130+
self.assertLessEqual(order_iresp_lower, -2)
131+
self.assertEqual(py_incr_resp_upper, np.inf)
132+
self.assertLessEqual(order_lift, -4)
133+
self.assertLessEqual(order_lift_lower, -4)
134+
self.assertEqual(py_lift_upper, np.inf)
115135

116136
def testVariableCostIROASSummary(self):
117137
"""Checks the TBR results for an go-dark/heavy-up experiment."""
@@ -134,14 +154,15 @@ def testVariableCostIROASSummary(self):
134154
tails = 1
135155

136156
# Summary values from R, treated as constants.
137-
# pylint: disable=invalid-name
138-
R_ESTIMATE = 2.946742
139-
R_PRECISION = 0.120548
140-
R_LOWER = 2.826194
141-
R_INCR_RESP = 147337.122
142-
R_INCR_COST = 50000
143-
R_PROBABILITY = 1.0
144-
# pylint: enable=invalid-name
157+
r_estimate = 2.946742
158+
r_precision = 0.120548
159+
r_lower = 2.826194
160+
r_incr_resp = 147337.122
161+
r_incr_cost = 50000
162+
r_probability = 1.0
163+
r_incr_resp_lower = r_lower * r_incr_cost
164+
r_lift = 0.173998
165+
r_lift_lower = 0.165686
145166

146167
# Summary values from python. Specify random_state to make results
147168
# deterministic.
@@ -153,25 +174,40 @@ def testVariableCostIROASSummary(self):
153174
py_estimate = iroas['estimate'].iloc[0]
154175
py_precision = iroas['precision'].iloc[0]
155176
py_lower = iroas['lower'].iloc[0]
177+
py_upper = iroas['upper'].iloc[0]
156178
py_incr_resp = iroas['incremental_response'].iloc[0]
157179
py_incr_cost = iroas['incremental_cost'].iloc[0]
158180
py_probability = iroas['probability'].iloc[0]
181+
py_incr_resp_lower = iroas['incremental_response_lower'].iloc[0]
182+
py_incr_resp_upper = iroas['incremental_response_upper'].iloc[0]
183+
py_lift = iroas['relative_lift'].iloc[0]
184+
py_lift_lower = iroas['relative_lift_lower'].iloc[0]
185+
py_lift_upper = iroas['relative_lift_upper'].iloc[0]
159186

160187
# Must do it like this as the R value is given with lower number of dps.
161-
order_estimate = utils.float_order(R_ESTIMATE - py_estimate)
162-
order_precision = utils.float_order(R_PRECISION - py_precision)
163-
order_lower = utils.float_order(R_LOWER - py_lower)
164-
order_iresp = utils.float_order(R_INCR_RESP - py_incr_resp)
165-
order_icost = utils.float_order(R_INCR_COST - py_incr_cost)
166-
order_probability = utils.float_order(R_PROBABILITY - py_probability)
167-
188+
order_estimate = utils.float_order(r_estimate - py_estimate)
189+
order_precision = utils.float_order(r_precision - py_precision)
190+
order_lower = utils.float_order(r_lower - py_lower)
191+
self.assertEqual(py_upper, np.inf)
192+
order_iresp = utils.float_order(r_incr_resp - py_incr_resp)
193+
order_icost = utils.float_order(r_incr_cost - py_incr_cost)
194+
order_probability = utils.float_order(r_probability - py_probability)
195+
order_iresp_lower = utils.float_order(r_incr_resp_lower -
196+
py_incr_resp_lower)
197+
order_lift = utils.float_order(r_lift - py_lift)
198+
order_lift_lower = utils.float_order(r_lift_lower - py_lift_lower)
168199
# Conduct the tests. Easier threshold as we added some noise.
169200
self.assertLess(order_estimate, -2)
170201
self.assertLess(order_precision, -2)
171202
self.assertLess(order_lower, -2)
172203
self.assertLess(order_iresp, -2) # incremental_response is a larger number.
173204
self.assertLess(order_icost, -2)
174205
self.assertLess(order_probability, -2)
206+
self.assertLessEqual(order_iresp_lower, -2)
207+
self.assertEqual(py_incr_resp_upper, np.inf)
208+
self.assertLessEqual(order_lift, -4)
209+
self.assertLessEqual(order_lift_lower, -4)
210+
self.assertEqual(py_lift_upper, np.inf)
175211

176212
def testVariableCostIROASSummaryTwoTails(self):
177213
"""Checks the TBR results when reporting two sided CI."""
@@ -194,15 +230,18 @@ def testVariableCostIROASSummaryTwoTails(self):
194230
tails = 2
195231

196232
# Summary values from R, treated as constants.
197-
# pylint: disable=invalid-name
198-
R_ESTIMATE = 2.947012
199-
R_PRECISION = 0.1557932
200-
R_LOWER = 2.79135
201-
R_UPPER = 3.102936
202-
R_INCR_RESP = 147337.122
203-
R_INCR_COST = 50000
204-
R_PROBABILITY = 1.0
205-
# pylint: enable=invalid-name
233+
r_estimate = 2.947012
234+
r_precision = 0.1557932
235+
r_lower = 2.79135
236+
r_upper = 3.102936
237+
r_incr_resp = 147337.122
238+
r_incr_cost = 50000
239+
r_probability = 1.0
240+
r_incr_resp_lower = r_lower * r_incr_cost
241+
r_incr_resp_upper = r_upper * r_incr_cost
242+
r_lift = 0.173998
243+
r_lift_lower = 0.163273
244+
r_lift_upper = 0.184885
206245

207246
# Summary values from python. Specify random_state to make results
208247
# deterministic.
@@ -218,15 +257,31 @@ def testVariableCostIROASSummaryTwoTails(self):
218257
py_incr_resp = iroas['incremental_response'].iloc[0]
219258
py_incr_cost = iroas['incremental_cost'].iloc[0]
220259
py_probability = iroas['probability'].iloc[0]
221-
260+
py_incr_resp_lower = iroas['incremental_response_lower'].iloc[0]
261+
py_incr_resp_upper = iroas['incremental_response_upper'].iloc[0]
262+
py_lift = iroas['relative_lift'].iloc[0]
263+
py_lift_lower = iroas['relative_lift_lower'].iloc[0]
264+
py_lift_upper = iroas['relative_lift_upper'].iloc[0]
265+
266+
print(r_lift_lower)
267+
print(py_lift_lower)
222268
# Must do it like this as the R value is given with lower number of dps.
223-
order_estimate = utils.float_order(R_ESTIMATE - py_estimate)
224-
order_precision = utils.float_order(R_PRECISION - py_precision)
225-
order_lower = utils.float_order(R_LOWER - py_lower)
226-
order_upper = utils.float_order(R_UPPER - py_upper)
227-
order_iresp = utils.float_order(R_INCR_RESP - py_incr_resp)
228-
order_icost = utils.float_order(R_INCR_COST - py_incr_cost)
229-
order_probability = utils.float_order(R_PROBABILITY - py_probability)
269+
order_estimate = utils.float_order(r_estimate - py_estimate)
270+
order_precision = utils.float_order(r_precision - py_precision)
271+
order_lower = utils.float_order(r_lower - py_lower)
272+
order_upper = utils.float_order(r_upper - py_upper)
273+
order_iresp = utils.float_order(r_incr_resp - py_incr_resp)
274+
order_icost = utils.float_order(r_incr_cost - py_incr_cost)
275+
order_probability = utils.float_order(r_probability - py_probability)
276+
# Use relative error for incremental response due to different RNG in R and
277+
# Python
278+
order_iresp_lower = utils.float_order(
279+
(r_incr_resp_lower - py_incr_resp_lower) * 100 / r_incr_resp_lower)
280+
order_iresp_upper = utils.float_order(
281+
(r_incr_resp_upper - py_incr_resp_upper) * 100 / r_incr_resp_upper)
282+
order_lift = utils.float_order(r_lift - py_lift)
283+
order_lift_lower = utils.float_order(r_lift_lower - py_lift_lower)
284+
order_lift_upper = utils.float_order(r_lift_upper - py_lift_upper)
230285

231286
# Conduct the tests. Easier threshold as we added some noise.
232287
self.assertLess(order_estimate, -2)
@@ -236,6 +291,11 @@ def testVariableCostIROASSummaryTwoTails(self):
236291
self.assertLess(order_iresp, -2) # incremental_response is a larger number.
237292
self.assertLess(order_icost, -2)
238293
self.assertLess(order_probability, -2)
294+
self.assertLessEqual(order_iresp_lower, -2)
295+
self.assertLessEqual(order_iresp_upper, -2)
296+
self.assertLessEqual(order_lift, -4)
297+
self.assertLessEqual(order_lift_lower, -4)
298+
self.assertLessEqual(order_lift_upper, -4)
239299

240300
def testIROASSummaryWithCooldown(self):
241301
"""Checks the TBR results when including the cooldown period in the analysis."""

0 commit comments

Comments
 (0)