otter 0.1.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.
- otter/__init__.py +81 -0
- otter/experiment.py +508 -0
- otter/plotter.py +596 -0
- otter/pond.py +494 -0
- otter/simulation.py +800 -0
- otter/transforms.py +137 -0
- otter-0.1.0.dist-info/METADATA +858 -0
- otter-0.1.0.dist-info/RECORD +11 -0
- otter-0.1.0.dist-info/WHEEL +5 -0
- otter-0.1.0.dist-info/licenses/LICENSE +7 -0
- otter-0.1.0.dist-info/top_level.txt +1 -0
otter/__init__.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""A readable pandas-based framework for research workflows: dataset
|
|
2
|
+
handling with tracked model specifications, reusable column transforms,
|
|
3
|
+
and a Monte Carlo simulation module."""
|
|
4
|
+
|
|
5
|
+
from .experiment import (
|
|
6
|
+
ExperimentSize,
|
|
7
|
+
assign_groups,
|
|
8
|
+
compare_groups,
|
|
9
|
+
cuped_adjust,
|
|
10
|
+
detectable_lift_for_mean,
|
|
11
|
+
detectable_lift_for_proportion,
|
|
12
|
+
pretest_balance,
|
|
13
|
+
sample_size_for_mean,
|
|
14
|
+
sample_size_for_proportion,
|
|
15
|
+
)
|
|
16
|
+
from .pond import ModelSpec, Pond, PondLoadFailedError
|
|
17
|
+
from .simulation import (
|
|
18
|
+
ConvergenceDiagnostics,
|
|
19
|
+
DistributionSpec,
|
|
20
|
+
InputManager,
|
|
21
|
+
ModelFunction,
|
|
22
|
+
MonteCarloEngine,
|
|
23
|
+
Scenario,
|
|
24
|
+
SensitivityAnalyzer,
|
|
25
|
+
Simulation,
|
|
26
|
+
SimulationResult,
|
|
27
|
+
)
|
|
28
|
+
from .transforms import (
|
|
29
|
+
demean_by_group,
|
|
30
|
+
interaction,
|
|
31
|
+
log1p_transform,
|
|
32
|
+
log_transform,
|
|
33
|
+
mean_center,
|
|
34
|
+
min_max_scale,
|
|
35
|
+
rank_transform,
|
|
36
|
+
row_mean,
|
|
37
|
+
row_sum,
|
|
38
|
+
safe_ratio,
|
|
39
|
+
square,
|
|
40
|
+
winsorize,
|
|
41
|
+
z_score,
|
|
42
|
+
)
|
|
43
|
+
from .plotter import SimulationPlotter
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ConvergenceDiagnostics",
|
|
47
|
+
"DistributionSpec",
|
|
48
|
+
"ExperimentSize",
|
|
49
|
+
"InputManager",
|
|
50
|
+
"ModelFunction",
|
|
51
|
+
"ModelSpec",
|
|
52
|
+
"MonteCarloEngine",
|
|
53
|
+
"Pond",
|
|
54
|
+
"PondLoadFailedError",
|
|
55
|
+
"Scenario",
|
|
56
|
+
"SensitivityAnalyzer",
|
|
57
|
+
"Simulation",
|
|
58
|
+
"SimulationPlotter",
|
|
59
|
+
"SimulationResult",
|
|
60
|
+
"assign_groups",
|
|
61
|
+
"compare_groups",
|
|
62
|
+
"cuped_adjust",
|
|
63
|
+
"demean_by_group",
|
|
64
|
+
"detectable_lift_for_mean",
|
|
65
|
+
"detectable_lift_for_proportion",
|
|
66
|
+
"interaction",
|
|
67
|
+
"log1p_transform",
|
|
68
|
+
"log_transform",
|
|
69
|
+
"mean_center",
|
|
70
|
+
"min_max_scale",
|
|
71
|
+
"pretest_balance",
|
|
72
|
+
"rank_transform",
|
|
73
|
+
"row_mean",
|
|
74
|
+
"row_sum",
|
|
75
|
+
"safe_ratio",
|
|
76
|
+
"sample_size_for_mean",
|
|
77
|
+
"sample_size_for_proportion",
|
|
78
|
+
"square",
|
|
79
|
+
"winsorize",
|
|
80
|
+
"z_score",
|
|
81
|
+
]
|
otter/experiment.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Designing and measuring controlled experiments.
|
|
2
|
+
|
|
3
|
+
The rest of otter describes a study you already have data for. This module
|
|
4
|
+
covers the two questions that come before and after that: how big does the
|
|
5
|
+
experiment need to be, and what did it actually show.
|
|
6
|
+
|
|
7
|
+
design sample_size_for_mean, sample_size_for_proportion
|
|
8
|
+
detectable_lift_for_mean, detectable_lift_for_proportion
|
|
9
|
+
assign assign_groups
|
|
10
|
+
measure compare_groups, cuped_adjust, pretest_balance
|
|
11
|
+
|
|
12
|
+
Everything takes and returns plain pandas objects, so it composes with
|
|
13
|
+
``Pond`` without needing to know about it.
|
|
14
|
+
|
|
15
|
+
A worked example, from planning to result::
|
|
16
|
+
|
|
17
|
+
from otter.experiment import sample_size_for_mean, assign_groups, compare_groups
|
|
18
|
+
|
|
19
|
+
# How many units do we need to see a 2% lift on a metric averaging 120
|
|
20
|
+
# with a standard deviation of 300, splitting the sample evenly?
|
|
21
|
+
size = sample_size_for_mean(
|
|
22
|
+
baseline_mean=120.0, baseline_sd=300.0, min_lift_pct=2.0,
|
|
23
|
+
)
|
|
24
|
+
size.treatment_n, size.control_n
|
|
25
|
+
|
|
26
|
+
# Randomise, then measure once the data is in.
|
|
27
|
+
assignment = assign_groups(user_ids, {"control": 0.5, "treatment": 0.5}, seed=1)
|
|
28
|
+
compare_groups(
|
|
29
|
+
data, group_col="group", metric_cols=["revenue"],
|
|
30
|
+
control="control", treatment="treatment",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
ON THE STATISTICS. Continuous comparisons use Welch's t-test, which does not
|
|
34
|
+
assume equal variances between arms, with Satterthwaite degrees of freedom.
|
|
35
|
+
Proportion sample sizes use the normal approximation, and the sensitivity
|
|
36
|
+
direction uses the arcsine variance-stabilising transform. Variance reduction
|
|
37
|
+
is CUPED (Deng, Xu, Kohavi and Walker, 2013), which regresses out a
|
|
38
|
+
pre-period covariate. When several metrics are compared at once the p-values
|
|
39
|
+
are corrected for multiplicity, Benjamini-Hochberg by default.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from dataclasses import dataclass
|
|
45
|
+
from typing import Iterable, Literal, Mapping, Sequence
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
import pandas as pd
|
|
49
|
+
from scipy import stats
|
|
50
|
+
from statsmodels.stats.multitest import multipletests
|
|
51
|
+
from statsmodels.stats.power import TTestIndPower
|
|
52
|
+
|
|
53
|
+
Alternative = Literal["two-sided", "larger", "smaller"]
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"ExperimentSize",
|
|
57
|
+
"assign_groups",
|
|
58
|
+
"compare_groups",
|
|
59
|
+
"cuped_adjust",
|
|
60
|
+
"detectable_lift_for_mean",
|
|
61
|
+
"detectable_lift_for_proportion",
|
|
62
|
+
"pretest_balance",
|
|
63
|
+
"sample_size_for_mean",
|
|
64
|
+
"sample_size_for_proportion",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ExperimentSize:
|
|
70
|
+
"""How many units each arm needs, and the effect that buys.
|
|
71
|
+
|
|
72
|
+
``effect_size`` is Cohen's d for a mean and the arcsine-transformed
|
|
73
|
+
effect for a proportion, so it is comparable within a metric type and not
|
|
74
|
+
across the two.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
treatment_n: int
|
|
78
|
+
control_n: int
|
|
79
|
+
effect_size: float
|
|
80
|
+
detectable_lift_pct: float
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def total_n(self) -> int:
|
|
84
|
+
"""Units needed across both arms."""
|
|
85
|
+
return self.treatment_n + self.control_n
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _check_share(treatment_share: float) -> float:
|
|
89
|
+
"""The control-to-treatment ratio, or a named error.
|
|
90
|
+
|
|
91
|
+
``treatment_share`` is the FRACTION of units in treatment, so 0.5 is an
|
|
92
|
+
even split and 0.1 puts a tenth in treatment. The source this replaced
|
|
93
|
+
called it a holdout percent and then solved for the arm it named
|
|
94
|
+
treatment, which read backwards; naming the fraction explicitly removes
|
|
95
|
+
the ambiguity rather than documenting it.
|
|
96
|
+
"""
|
|
97
|
+
if not 0.0 < treatment_share < 1.0:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"treatment_share must be strictly between 0 and 1, got "
|
|
100
|
+
f"{treatment_share!r}. It is a fraction, so an even split is 0.5, "
|
|
101
|
+
f"not 50."
|
|
102
|
+
)
|
|
103
|
+
return (1.0 - treatment_share) / treatment_share
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _z(alpha: float, power: float, alternative: Alternative) -> tuple[float, float]:
|
|
107
|
+
"""The critical values for a normal-approximation sample size."""
|
|
108
|
+
if alternative == "two-sided":
|
|
109
|
+
z_alpha = stats.norm.ppf(1.0 - alpha / 2.0)
|
|
110
|
+
elif alternative in ("larger", "smaller"):
|
|
111
|
+
z_alpha = stats.norm.ppf(1.0 - alpha)
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"alternative must be 'two-sided', 'larger' or 'smaller', got "
|
|
115
|
+
f"{alternative!r}"
|
|
116
|
+
)
|
|
117
|
+
return float(z_alpha), float(stats.norm.ppf(power))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sample_size_for_mean(
|
|
121
|
+
baseline_mean: float,
|
|
122
|
+
baseline_sd: float,
|
|
123
|
+
min_lift_pct: float,
|
|
124
|
+
treatment_share: float = 0.5,
|
|
125
|
+
power: float = 0.80,
|
|
126
|
+
alpha: float = 0.05,
|
|
127
|
+
alternative: Alternative = "two-sided",
|
|
128
|
+
) -> ExperimentSize:
|
|
129
|
+
"""Units per arm needed to detect ``min_lift_pct`` on a continuous metric.
|
|
130
|
+
|
|
131
|
+
``min_lift_pct`` is a percentage of the baseline mean, so 2.0 means a 2%
|
|
132
|
+
relative lift, which is how experiment targets are usually stated.
|
|
133
|
+
|
|
134
|
+
>>> size = sample_size_for_mean(100.0, 50.0, 5.0)
|
|
135
|
+
>>> size.treatment_n == size.control_n # an even split by default
|
|
136
|
+
True
|
|
137
|
+
"""
|
|
138
|
+
if baseline_sd <= 0:
|
|
139
|
+
raise ValueError(f"baseline_sd must be positive, got {baseline_sd!r}")
|
|
140
|
+
if baseline_mean == 0:
|
|
141
|
+
raise ValueError(
|
|
142
|
+
"baseline_mean is zero, so a percentage lift has no meaning. "
|
|
143
|
+
"Use an absolute effect size instead."
|
|
144
|
+
)
|
|
145
|
+
ratio = _check_share(treatment_share)
|
|
146
|
+
cohen_d = baseline_mean * (min_lift_pct / 100.0) / baseline_sd
|
|
147
|
+
treatment_n = TTestIndPower().solve_power(
|
|
148
|
+
effect_size=cohen_d,
|
|
149
|
+
power=power,
|
|
150
|
+
nobs1=None,
|
|
151
|
+
ratio=ratio,
|
|
152
|
+
alpha=alpha,
|
|
153
|
+
alternative=alternative,
|
|
154
|
+
)
|
|
155
|
+
return ExperimentSize(
|
|
156
|
+
treatment_n=int(np.ceil(treatment_n)),
|
|
157
|
+
control_n=int(np.ceil(treatment_n * ratio)),
|
|
158
|
+
effect_size=float(cohen_d),
|
|
159
|
+
detectable_lift_pct=float(min_lift_pct),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sample_size_for_proportion(
|
|
164
|
+
baseline_rate: float,
|
|
165
|
+
min_lift_pct: float,
|
|
166
|
+
treatment_share: float = 0.5,
|
|
167
|
+
power: float = 0.80,
|
|
168
|
+
alpha: float = 0.05,
|
|
169
|
+
alternative: Alternative = "two-sided",
|
|
170
|
+
) -> ExperimentSize:
|
|
171
|
+
"""Units per arm needed to detect ``min_lift_pct`` on a rate.
|
|
172
|
+
|
|
173
|
+
``baseline_rate`` is a proportion in (0, 1), and ``min_lift_pct`` is
|
|
174
|
+
relative to it: a baseline of 0.10 with a 10% lift targets 0.11, not 0.20.
|
|
175
|
+
"""
|
|
176
|
+
if not 0.0 < baseline_rate < 1.0:
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"baseline_rate is a proportion and must be strictly between 0 "
|
|
179
|
+
f"and 1, got {baseline_rate!r}"
|
|
180
|
+
)
|
|
181
|
+
ratio = _check_share(treatment_share)
|
|
182
|
+
z_alpha, z_beta = _z(alpha, power, alternative)
|
|
183
|
+
treated_rate = baseline_rate * (1.0 + min_lift_pct / 100.0)
|
|
184
|
+
if not 0.0 < treated_rate < 1.0:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"a {min_lift_pct}% lift on a baseline of {baseline_rate} implies "
|
|
187
|
+
f"a rate of {treated_rate:.4f}, which is not a proportion"
|
|
188
|
+
)
|
|
189
|
+
var_sum = (
|
|
190
|
+
baseline_rate * (1.0 - baseline_rate)
|
|
191
|
+
+ treated_rate * (1.0 - treated_rate)
|
|
192
|
+
)
|
|
193
|
+
n = var_sum / (baseline_rate - treated_rate) ** 2 * (z_alpha + z_beta) ** 2 * 2.0
|
|
194
|
+
n_prime = n * (1.0 + ratio) ** 2 / (4.0 * ratio)
|
|
195
|
+
treatment_n = n_prime / (1.0 + ratio)
|
|
196
|
+
pooled = (baseline_rate + treated_rate) / 2.0
|
|
197
|
+
effect = abs(treated_rate - baseline_rate) / np.sqrt(pooled * (1.0 - pooled))
|
|
198
|
+
return ExperimentSize(
|
|
199
|
+
treatment_n=int(np.ceil(treatment_n)),
|
|
200
|
+
control_n=int(np.ceil(n_prime - treatment_n)),
|
|
201
|
+
effect_size=float(effect),
|
|
202
|
+
detectable_lift_pct=float(min_lift_pct),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def detectable_lift_for_mean(
|
|
207
|
+
baseline_mean: float,
|
|
208
|
+
baseline_sd: float,
|
|
209
|
+
total_n: int,
|
|
210
|
+
treatment_share: float = 0.5,
|
|
211
|
+
power: float = 0.80,
|
|
212
|
+
alpha: float = 0.05,
|
|
213
|
+
alternative: Alternative = "two-sided",
|
|
214
|
+
) -> float:
|
|
215
|
+
"""The smallest relative lift a fixed sample can detect, as a percentage.
|
|
216
|
+
|
|
217
|
+
The other direction of ``sample_size_for_mean``: the sample is what it is,
|
|
218
|
+
so the question is whether the experiment can see anything worth seeing.
|
|
219
|
+
"""
|
|
220
|
+
if baseline_sd <= 0:
|
|
221
|
+
raise ValueError(f"baseline_sd must be positive, got {baseline_sd!r}")
|
|
222
|
+
if total_n <= 2:
|
|
223
|
+
raise ValueError(f"total_n must be greater than 2, got {total_n!r}")
|
|
224
|
+
ratio = _check_share(treatment_share)
|
|
225
|
+
treatment_n = total_n * treatment_share
|
|
226
|
+
effect = TTestIndPower().solve_power(
|
|
227
|
+
effect_size=None,
|
|
228
|
+
power=power,
|
|
229
|
+
nobs1=treatment_n,
|
|
230
|
+
ratio=ratio,
|
|
231
|
+
alpha=alpha,
|
|
232
|
+
alternative=alternative,
|
|
233
|
+
)
|
|
234
|
+
return float(effect * baseline_sd / baseline_mean * 100.0)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def detectable_lift_for_proportion(
|
|
238
|
+
baseline_rate: float,
|
|
239
|
+
total_n: int,
|
|
240
|
+
treatment_share: float = 0.5,
|
|
241
|
+
power: float = 0.80,
|
|
242
|
+
alpha: float = 0.05,
|
|
243
|
+
alternative: Alternative = "two-sided",
|
|
244
|
+
) -> float:
|
|
245
|
+
"""The smallest relative lift on a rate a fixed sample can detect.
|
|
246
|
+
|
|
247
|
+
Uses the arcsine variance-stabilising transform, which behaves near 0 and
|
|
248
|
+
1 where the plain normal approximation does not.
|
|
249
|
+
"""
|
|
250
|
+
if not 0.0 < baseline_rate < 1.0:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"baseline_rate is a proportion and must be strictly between 0 "
|
|
253
|
+
f"and 1, got {baseline_rate!r}"
|
|
254
|
+
)
|
|
255
|
+
if total_n <= 2:
|
|
256
|
+
raise ValueError(f"total_n must be greater than 2, got {total_n!r}")
|
|
257
|
+
_check_share(treatment_share)
|
|
258
|
+
z_alpha, z_beta = _z(alpha, power, alternative)
|
|
259
|
+
treatment_n = total_n * treatment_share
|
|
260
|
+
control_n = total_n - treatment_n
|
|
261
|
+
harmonic = 2.0 * treatment_n * control_n / (treatment_n + control_n)
|
|
262
|
+
effect = (z_alpha + z_beta) / np.sqrt(harmonic / 2.0)
|
|
263
|
+
treated = np.sin(2.0 * np.arcsin(np.sqrt(baseline_rate)) / 2.0 + effect / 2.0) ** 2
|
|
264
|
+
return float((treated - baseline_rate) / baseline_rate * 100.0)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def assign_groups(
|
|
268
|
+
units: Iterable[object],
|
|
269
|
+
ratios: Mapping[str, float],
|
|
270
|
+
seed: int | None = None,
|
|
271
|
+
) -> pd.DataFrame:
|
|
272
|
+
"""Randomly assign units to named groups in the given proportions.
|
|
273
|
+
|
|
274
|
+
Returns a frame of ``unit`` and ``group``. Pass ``seed`` to make an
|
|
275
|
+
assignment reproducible, which is what makes a published experiment
|
|
276
|
+
checkable by somebody else.
|
|
277
|
+
|
|
278
|
+
>>> frame = assign_groups(range(100), {"control": 0.5, "treatment": 0.5}, seed=0)
|
|
279
|
+
>>> sorted(frame["group"].unique())
|
|
280
|
+
['control', 'treatment']
|
|
281
|
+
"""
|
|
282
|
+
unit_list = list(units)
|
|
283
|
+
if not unit_list:
|
|
284
|
+
raise ValueError("no units to assign")
|
|
285
|
+
duplicates = len(unit_list) - len(set(unit_list))
|
|
286
|
+
if duplicates:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"{duplicates} duplicate unit(s) in the input. Assigning the same "
|
|
289
|
+
f"unit twice puts it in two arms, which silently breaks the test."
|
|
290
|
+
)
|
|
291
|
+
names = list(ratios)
|
|
292
|
+
weights = np.asarray([ratios[name] for name in names], dtype=float)
|
|
293
|
+
if np.any(weights <= 0):
|
|
294
|
+
raise ValueError(f"every ratio must be positive, got {dict(ratios)!r}")
|
|
295
|
+
total = weights.sum()
|
|
296
|
+
if not np.isclose(total, 1.0):
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"ratios must sum to 1, got {total!r}. They are proportions of the "
|
|
299
|
+
f"sample, not counts."
|
|
300
|
+
)
|
|
301
|
+
rng = np.random.default_rng(seed)
|
|
302
|
+
chosen = rng.choice(len(names), size=len(unit_list), p=weights)
|
|
303
|
+
return pd.DataFrame(
|
|
304
|
+
{"unit": unit_list, "group": [names[i] for i in chosen]}
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cuped_adjust(
|
|
309
|
+
data: pd.DataFrame,
|
|
310
|
+
metric_col: str,
|
|
311
|
+
pre_col: str,
|
|
312
|
+
) -> pd.Series:
|
|
313
|
+
"""Variance-reduced metric using a pre-period covariate (CUPED).
|
|
314
|
+
|
|
315
|
+
Returns an adjusted copy of ``metric_col`` with the same mean and lower
|
|
316
|
+
variance, so the same experiment detects a smaller effect. The covariate
|
|
317
|
+
must be measured BEFORE assignment: a post-period covariate can be
|
|
318
|
+
affected by the treatment, and adjusting on it biases the result.
|
|
319
|
+
|
|
320
|
+
A covariate with no variance carries no information, so the metric is
|
|
321
|
+
returned unchanged rather than raising, and a constant column cannot
|
|
322
|
+
silently produce a divide by zero.
|
|
323
|
+
"""
|
|
324
|
+
for column in (metric_col, pre_col):
|
|
325
|
+
if column not in data.columns:
|
|
326
|
+
raise KeyError(f"{column!r} is not a column of the data")
|
|
327
|
+
pre = data[pre_col].astype(float)
|
|
328
|
+
metric = data[metric_col].astype(float)
|
|
329
|
+
pre_var = float(pre.var(ddof=1))
|
|
330
|
+
if not np.isfinite(pre_var) or pre_var < 1e-12:
|
|
331
|
+
return metric.copy()
|
|
332
|
+
theta = float(pre.cov(metric)) / pre_var
|
|
333
|
+
return metric - theta * (pre - pre.mean())
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _welch(
|
|
337
|
+
treated: "pd.Series[float]", control: "pd.Series[float]", alpha: float,
|
|
338
|
+
alternative: Alternative,
|
|
339
|
+
) -> dict[str, object]:
|
|
340
|
+
"""One Welch comparison, as a row."""
|
|
341
|
+
n1, n2 = int(treated.count()), int(control.count())
|
|
342
|
+
if n1 < 2 or n2 < 2:
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"each arm needs at least 2 observations to have a variance, got "
|
|
345
|
+
f"treatment={n1} and control={n2}"
|
|
346
|
+
)
|
|
347
|
+
m1, m2 = float(treated.mean()), float(control.mean())
|
|
348
|
+
s1, s2 = float(treated.std(ddof=1)), float(control.std(ddof=1))
|
|
349
|
+
se = np.sqrt(s1**2 / n1 + s2**2 / n2)
|
|
350
|
+
if se == 0:
|
|
351
|
+
t_stat, pval, dof = np.nan, np.nan, np.nan
|
|
352
|
+
else:
|
|
353
|
+
dof = (s1**2 / n1 + s2**2 / n2) ** 2 / (
|
|
354
|
+
(s1**2 / n1) ** 2 / (n1 - 1) + (s2**2 / n2) ** 2 / (n2 - 1)
|
|
355
|
+
)
|
|
356
|
+
t_stat = (m1 - m2) / se
|
|
357
|
+
if alternative == "two-sided":
|
|
358
|
+
pval = float(2.0 * stats.t.sf(abs(t_stat), dof))
|
|
359
|
+
elif alternative == "larger":
|
|
360
|
+
pval = float(stats.t.sf(t_stat, dof))
|
|
361
|
+
else:
|
|
362
|
+
pval = float(stats.t.cdf(t_stat, dof))
|
|
363
|
+
diff = m1 - m2
|
|
364
|
+
if np.isnan(dof):
|
|
365
|
+
low, high = np.nan, np.nan
|
|
366
|
+
else:
|
|
367
|
+
crit = stats.t.ppf(1.0 - alpha / 2.0, dof)
|
|
368
|
+
low, high = diff - crit * se, diff + crit * se
|
|
369
|
+
return {
|
|
370
|
+
"treatment_n": n1,
|
|
371
|
+
"control_n": n2,
|
|
372
|
+
"treatment_mean": m1,
|
|
373
|
+
"control_mean": m2,
|
|
374
|
+
"treatment_sd": s1,
|
|
375
|
+
"control_sd": s2,
|
|
376
|
+
"mean_difference": diff,
|
|
377
|
+
"ci_low": float(low),
|
|
378
|
+
"ci_high": float(high),
|
|
379
|
+
"percent_lift": (diff / m2 * 100.0) if m2 != 0 else np.nan,
|
|
380
|
+
"t_statistic": float(t_stat),
|
|
381
|
+
"p_value": float(pval),
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def compare_groups(
|
|
386
|
+
data: pd.DataFrame,
|
|
387
|
+
group_col: str,
|
|
388
|
+
metric_cols: Sequence[str],
|
|
389
|
+
control: str,
|
|
390
|
+
treatment: str,
|
|
391
|
+
alpha: float = 0.05,
|
|
392
|
+
alternative: Alternative = "two-sided",
|
|
393
|
+
correction: str | None = "fdr_bh",
|
|
394
|
+
cuped_pre: Mapping[str, str] | None = None,
|
|
395
|
+
) -> pd.DataFrame:
|
|
396
|
+
"""Compare two arms across metrics, one row per metric.
|
|
397
|
+
|
|
398
|
+
Welch's t-test throughout, so unequal variances between arms are handled
|
|
399
|
+
rather than assumed away. ``correction`` applies a multiple-testing
|
|
400
|
+
correction across the metrics and adds a ``p_value_corrected`` column;
|
|
401
|
+
pass ``None`` to skip it, which is only right when there is one metric.
|
|
402
|
+
|
|
403
|
+
``cuped_pre`` maps a metric to its pre-period column and applies
|
|
404
|
+
:func:`cuped_adjust` before testing, which lowers the variance without
|
|
405
|
+
changing what is being estimated.
|
|
406
|
+
"""
|
|
407
|
+
if group_col not in data.columns:
|
|
408
|
+
raise KeyError(f"{group_col!r} is not a column of the data")
|
|
409
|
+
if not metric_cols:
|
|
410
|
+
raise ValueError("no metrics given to compare")
|
|
411
|
+
present = set(data[group_col].unique())
|
|
412
|
+
missing = {control, treatment} - present
|
|
413
|
+
if missing:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"group(s) {sorted(missing)!r} do not appear in {group_col!r}. "
|
|
416
|
+
f"Present: {sorted(map(str, present))!r}"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
rows = []
|
|
420
|
+
for metric in metric_cols:
|
|
421
|
+
if metric not in data.columns:
|
|
422
|
+
raise KeyError(f"{metric!r} is not a column of the data")
|
|
423
|
+
frame = data
|
|
424
|
+
column = metric
|
|
425
|
+
if cuped_pre and metric in cuped_pre:
|
|
426
|
+
frame = data.assign(
|
|
427
|
+
**{f"__cuped_{metric}": cuped_adjust(data, metric, cuped_pre[metric])}
|
|
428
|
+
)
|
|
429
|
+
column = f"__cuped_{metric}"
|
|
430
|
+
treated = frame.loc[frame[group_col] == treatment, column]
|
|
431
|
+
controlled = frame.loc[frame[group_col] == control, column]
|
|
432
|
+
row: dict[str, object] = {
|
|
433
|
+
"metric": metric,
|
|
434
|
+
"treatment_group": treatment,
|
|
435
|
+
"control_group": control,
|
|
436
|
+
"cuped": bool(cuped_pre and metric in cuped_pre),
|
|
437
|
+
}
|
|
438
|
+
row.update(_welch(treated, controlled, alpha, alternative))
|
|
439
|
+
rows.append(row)
|
|
440
|
+
|
|
441
|
+
result = pd.DataFrame(rows)
|
|
442
|
+
if correction is not None and len(result) > 1:
|
|
443
|
+
usable = result["p_value"].notna()
|
|
444
|
+
result["p_value_corrected"] = np.nan
|
|
445
|
+
if usable.any():
|
|
446
|
+
result.loc[usable, "p_value_corrected"] = multipletests(
|
|
447
|
+
result.loc[usable, "p_value"].to_numpy(),
|
|
448
|
+
alpha=alpha,
|
|
449
|
+
method=correction,
|
|
450
|
+
)[1]
|
|
451
|
+
return result
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def pretest_balance(
|
|
455
|
+
data: pd.DataFrame,
|
|
456
|
+
group_col: str,
|
|
457
|
+
pre_cols: Sequence[str],
|
|
458
|
+
control: str,
|
|
459
|
+
treatment: str,
|
|
460
|
+
alpha: float = 0.05,
|
|
461
|
+
) -> pd.DataFrame:
|
|
462
|
+
"""Check the arms looked the same BEFORE the treatment ran.
|
|
463
|
+
|
|
464
|
+
Randomisation is supposed to make the arms exchangeable. Testing the
|
|
465
|
+
pre-period metrics is how that gets checked rather than assumed: a
|
|
466
|
+
significant difference here means the split is suspect and the headline
|
|
467
|
+
result should not be read at face value.
|
|
468
|
+
|
|
469
|
+
A pre-period column that is constant or all zero carries no information,
|
|
470
|
+
which happens legitimately when the units are new and have no history. It
|
|
471
|
+
is reported as ``uninformative`` rather than tested, because a degenerate
|
|
472
|
+
t-test returns a confident-looking answer built on nothing.
|
|
473
|
+
"""
|
|
474
|
+
rows = []
|
|
475
|
+
informative = []
|
|
476
|
+
for column in pre_cols:
|
|
477
|
+
if column not in data.columns:
|
|
478
|
+
raise KeyError(f"{column!r} is not a column of the data")
|
|
479
|
+
series = data[column].astype(float)
|
|
480
|
+
if float(series.std(ddof=0)) < 1e-12:
|
|
481
|
+
rows.append(
|
|
482
|
+
{
|
|
483
|
+
"metric": column,
|
|
484
|
+
"treatment_group": treatment,
|
|
485
|
+
"control_group": control,
|
|
486
|
+
"uninformative": True,
|
|
487
|
+
}
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
informative.append(column)
|
|
491
|
+
|
|
492
|
+
if informative:
|
|
493
|
+
tested = compare_groups(
|
|
494
|
+
data,
|
|
495
|
+
group_col=group_col,
|
|
496
|
+
metric_cols=informative,
|
|
497
|
+
control=control,
|
|
498
|
+
treatment=treatment,
|
|
499
|
+
alpha=alpha,
|
|
500
|
+
)
|
|
501
|
+
tested["uninformative"] = False
|
|
502
|
+
rows.extend(tested.to_dict("records"))
|
|
503
|
+
|
|
504
|
+
result = pd.DataFrame(rows)
|
|
505
|
+
order = {name: i for i, name in enumerate(pre_cols)}
|
|
506
|
+
return result.sort_values("metric", key=lambda s: s.map(order)).reset_index(
|
|
507
|
+
drop=True
|
|
508
|
+
)
|