experimentation-toolkit 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.
- experimentation_toolkit/__init__.py +51 -0
- experimentation_toolkit/analysis/__init__.py +6 -0
- experimentation_toolkit/analysis/continuous.py +255 -0
- experimentation_toolkit/analysis/proportion.py +204 -0
- experimentation_toolkit/diagnostics/__init__.py +5 -0
- experimentation_toolkit/diagnostics/srm.py +89 -0
- experimentation_toolkit/models/__init__.py +40 -0
- experimentation_toolkit/models/core.py +51 -0
- experimentation_toolkit/models/enums.py +41 -0
- experimentation_toolkit/models/results.py +211 -0
- experimentation_toolkit/multiple_testing/__init__.py +5 -0
- experimentation_toolkit/multiple_testing/corrections.py +65 -0
- experimentation_toolkit/power/__init__.py +8 -0
- experimentation_toolkit/power/proportion.py +238 -0
- experimentation_toolkit/py.typed +1 -0
- experimentation_toolkit/validation/__init__.py +1 -0
- experimentation_toolkit/validation/inputs.py +89 -0
- experimentation_toolkit-0.1.0.dist-info/METADATA +213 -0
- experimentation_toolkit-0.1.0.dist-info/RECORD +21 -0
- experimentation_toolkit-0.1.0.dist-info/WHEEL +4 -0
- experimentation_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Statistically explicit tools for classical controlled A/B experiments."""
|
|
2
|
+
|
|
3
|
+
from experimentation_toolkit.analysis import analyze_continuous, analyze_proportion
|
|
4
|
+
from experimentation_toolkit.diagnostics import check_sample_ratio_mismatch
|
|
5
|
+
from experimentation_toolkit.models import (
|
|
6
|
+
AchievedPowerResult,
|
|
7
|
+
AlternativeHypothesis,
|
|
8
|
+
AnalysisResult,
|
|
9
|
+
ConfidenceInterval,
|
|
10
|
+
CorrectionMethod,
|
|
11
|
+
DiagnosticResult,
|
|
12
|
+
DiagnosticStatus,
|
|
13
|
+
Experiment,
|
|
14
|
+
ExperimentReport,
|
|
15
|
+
MDEType,
|
|
16
|
+
Metric,
|
|
17
|
+
MetricType,
|
|
18
|
+
MultipleTestingResult,
|
|
19
|
+
ProportionSampleSizeResult,
|
|
20
|
+
SRMResult,
|
|
21
|
+
Variant,
|
|
22
|
+
)
|
|
23
|
+
from experimentation_toolkit.multiple_testing import adjust_p_values
|
|
24
|
+
from experimentation_toolkit.power import proportion_achieved_power, proportion_sample_size
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AchievedPowerResult",
|
|
28
|
+
"AlternativeHypothesis",
|
|
29
|
+
"AnalysisResult",
|
|
30
|
+
"ConfidenceInterval",
|
|
31
|
+
"CorrectionMethod",
|
|
32
|
+
"DiagnosticResult",
|
|
33
|
+
"DiagnosticStatus",
|
|
34
|
+
"Experiment",
|
|
35
|
+
"ExperimentReport",
|
|
36
|
+
"MDEType",
|
|
37
|
+
"Metric",
|
|
38
|
+
"MetricType",
|
|
39
|
+
"MultipleTestingResult",
|
|
40
|
+
"ProportionSampleSizeResult",
|
|
41
|
+
"SRMResult",
|
|
42
|
+
"Variant",
|
|
43
|
+
"adjust_p_values",
|
|
44
|
+
"analyze_continuous",
|
|
45
|
+
"analyze_proportion",
|
|
46
|
+
"check_sample_ratio_mismatch",
|
|
47
|
+
"proportion_achieved_power",
|
|
48
|
+
"proportion_sample_size",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Welch analysis for independent continuous outcomes."""
|
|
2
|
+
|
|
3
|
+
from math import hypot, sqrt
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
from scipy import stats
|
|
9
|
+
|
|
10
|
+
from experimentation_toolkit.models.enums import (
|
|
11
|
+
AlternativeHypothesis,
|
|
12
|
+
DiagnosticStatus,
|
|
13
|
+
MetricType,
|
|
14
|
+
)
|
|
15
|
+
from experimentation_toolkit.models.results import (
|
|
16
|
+
AnalysisResult,
|
|
17
|
+
ConfidenceInterval,
|
|
18
|
+
DiagnosticResult,
|
|
19
|
+
)
|
|
20
|
+
from experimentation_toolkit.validation.inputs import (
|
|
21
|
+
normalize_alternative,
|
|
22
|
+
normalize_numeric_sample,
|
|
23
|
+
validate_probability,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _stable_mean_and_standard_deviation(
|
|
28
|
+
values: npt.NDArray[np.float64],
|
|
29
|
+
) -> tuple[float, float]:
|
|
30
|
+
"""Compute float64 moments after scaling to avoid avoidable overflow/underflow."""
|
|
31
|
+
scale = float(np.max(np.abs(values)))
|
|
32
|
+
if scale == 0.0:
|
|
33
|
+
return 0.0, 0.0
|
|
34
|
+
scaled = values / scale
|
|
35
|
+
scaled_mean = float(np.mean(scaled, dtype=np.float64))
|
|
36
|
+
centered = scaled - scaled_mean
|
|
37
|
+
scaled_sum_of_squares = float(np.dot(centered, centered))
|
|
38
|
+
mean = scaled_mean * scale
|
|
39
|
+
standard_deviation = scale * sqrt(scaled_sum_of_squares / (values.size - 1))
|
|
40
|
+
if not np.isfinite(mean) or not np.isfinite(standard_deviation):
|
|
41
|
+
raise ValueError("sample moments are non-finite; rescale extreme observations")
|
|
42
|
+
return mean, standard_deviation
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _welch_degrees_of_freedom(
|
|
46
|
+
control_standard_error: float,
|
|
47
|
+
control_size: int,
|
|
48
|
+
treatment_standard_error: float,
|
|
49
|
+
treatment_size: int,
|
|
50
|
+
) -> float:
|
|
51
|
+
scale = max(control_standard_error, treatment_standard_error)
|
|
52
|
+
control_term = (control_standard_error / scale) ** 2
|
|
53
|
+
treatment_term = (treatment_standard_error / scale) ** 2
|
|
54
|
+
return (control_term + treatment_term) ** 2 / (
|
|
55
|
+
control_term**2 / (control_size - 1) + treatment_term**2 / (treatment_size - 1)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _welch_interval(
|
|
60
|
+
effect: float,
|
|
61
|
+
standard_error: float,
|
|
62
|
+
degrees_of_freedom: float,
|
|
63
|
+
confidence_level: float,
|
|
64
|
+
alternative: AlternativeHypothesis,
|
|
65
|
+
) -> ConfidenceInterval:
|
|
66
|
+
alpha = 1.0 - confidence_level
|
|
67
|
+
tail_probability = alpha / 2.0 if alternative is AlternativeHypothesis.TWO_SIDED else alpha
|
|
68
|
+
critical_value = float(stats.t.ppf(1.0 - tail_probability, degrees_of_freedom))
|
|
69
|
+
margin = critical_value * standard_error
|
|
70
|
+
if alternative is AlternativeHypothesis.GREATER:
|
|
71
|
+
return ConfidenceInterval(
|
|
72
|
+
lower=effect - margin, upper=None, confidence_level=confidence_level
|
|
73
|
+
)
|
|
74
|
+
if alternative is AlternativeHypothesis.LESS:
|
|
75
|
+
return ConfidenceInterval(
|
|
76
|
+
lower=None, upper=effect + margin, confidence_level=confidence_level
|
|
77
|
+
)
|
|
78
|
+
return ConfidenceInterval(
|
|
79
|
+
lower=effect - margin,
|
|
80
|
+
upper=effect + margin,
|
|
81
|
+
confidence_level=confidence_level,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def analyze_continuous(
|
|
86
|
+
*,
|
|
87
|
+
control: npt.ArrayLike,
|
|
88
|
+
treatment: npt.ArrayLike,
|
|
89
|
+
confidence_level: float = 0.95,
|
|
90
|
+
alternative: AlternativeHypothesis | str = AlternativeHypothesis.TWO_SIDED,
|
|
91
|
+
nonfinite_policy: Literal["raise", "omit"] = "raise",
|
|
92
|
+
metric_name: str | None = None,
|
|
93
|
+
) -> AnalysisResult:
|
|
94
|
+
"""Analyze independent means using Welch's unequal-variance t procedure.
|
|
95
|
+
|
|
96
|
+
Inputs must be one-dimensional and contain at least two finite values per arm.
|
|
97
|
+
Non-finite observations raise by default; callers may explicitly request omission.
|
|
98
|
+
A relative effect is emitted only when both group means are non-negative and the
|
|
99
|
+
control mean is numerically resolved relative to the observed data scale.
|
|
100
|
+
"""
|
|
101
|
+
control_values, control_omitted = normalize_numeric_sample(
|
|
102
|
+
control, "control", nonfinite_policy=nonfinite_policy
|
|
103
|
+
)
|
|
104
|
+
treatment_values, treatment_omitted = normalize_numeric_sample(
|
|
105
|
+
treatment, "treatment", nonfinite_policy=nonfinite_policy
|
|
106
|
+
)
|
|
107
|
+
confidence_level = validate_probability(
|
|
108
|
+
confidence_level, "confidence_level", open_interval=True
|
|
109
|
+
)
|
|
110
|
+
alternative = normalize_alternative(alternative)
|
|
111
|
+
control_size = int(control_values.size)
|
|
112
|
+
treatment_size = int(treatment_values.size)
|
|
113
|
+
control_mean, control_sd = _stable_mean_and_standard_deviation(control_values)
|
|
114
|
+
treatment_mean, treatment_sd = _stable_mean_and_standard_deviation(treatment_values)
|
|
115
|
+
effect = treatment_mean - control_mean
|
|
116
|
+
if not np.isfinite(effect):
|
|
117
|
+
raise ValueError("the mean difference is non-finite; rescale extreme observations")
|
|
118
|
+
diagnostics: list[DiagnosticResult] = []
|
|
119
|
+
|
|
120
|
+
omitted = control_omitted + treatment_omitted
|
|
121
|
+
if omitted:
|
|
122
|
+
diagnostics.append(
|
|
123
|
+
DiagnosticResult(
|
|
124
|
+
code="NONFINITE_OBSERVATIONS_OMITTED",
|
|
125
|
+
status=DiagnosticStatus.WARN,
|
|
126
|
+
message="Non-finite observations were omitted by explicit caller policy.",
|
|
127
|
+
details={
|
|
128
|
+
"control_omitted": control_omitted,
|
|
129
|
+
"treatment_omitted": treatment_omitted,
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
if control_size < 30 or treatment_size < 30:
|
|
134
|
+
diagnostics.append(
|
|
135
|
+
DiagnosticResult(
|
|
136
|
+
code="SMALL_SAMPLE",
|
|
137
|
+
status=DiagnosticStatus.WARN,
|
|
138
|
+
message=(
|
|
139
|
+
"At least one arm has fewer than 30 observations; Welch inference relies "
|
|
140
|
+
"more strongly on approximate normality of that arm's sample mean."
|
|
141
|
+
),
|
|
142
|
+
details={"control_size": control_size, "treatment_size": treatment_size},
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
control_standard_error = control_sd / sqrt(control_size)
|
|
147
|
+
treatment_standard_error = treatment_sd / sqrt(treatment_size)
|
|
148
|
+
standard_error = hypot(control_standard_error, treatment_standard_error)
|
|
149
|
+
if standard_error == 0.0:
|
|
150
|
+
diagnostics.append(
|
|
151
|
+
DiagnosticResult(
|
|
152
|
+
code="DEGENERATE_ZERO_VARIANCE",
|
|
153
|
+
status=DiagnosticStatus.WARN,
|
|
154
|
+
message=(
|
|
155
|
+
"Both samples have zero variance; Welch's reference distribution is "
|
|
156
|
+
"undefined, so no test statistic or p-value is reported."
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
interval = ConfidenceInterval(
|
|
161
|
+
lower=effect if alternative is not AlternativeHypothesis.LESS else None,
|
|
162
|
+
upper=effect if alternative is not AlternativeHypothesis.GREATER else None,
|
|
163
|
+
confidence_level=confidence_level,
|
|
164
|
+
)
|
|
165
|
+
statistic = None
|
|
166
|
+
p_value = None
|
|
167
|
+
degrees_of_freedom = None
|
|
168
|
+
else:
|
|
169
|
+
degrees_of_freedom = _welch_degrees_of_freedom(
|
|
170
|
+
control_standard_error,
|
|
171
|
+
control_size,
|
|
172
|
+
treatment_standard_error,
|
|
173
|
+
treatment_size,
|
|
174
|
+
)
|
|
175
|
+
statistic = effect / standard_error
|
|
176
|
+
if alternative is AlternativeHypothesis.GREATER:
|
|
177
|
+
p_value = float(stats.t.sf(statistic, degrees_of_freedom))
|
|
178
|
+
elif alternative is AlternativeHypothesis.LESS:
|
|
179
|
+
p_value = float(stats.t.cdf(statistic, degrees_of_freedom))
|
|
180
|
+
else:
|
|
181
|
+
p_value = float(2.0 * stats.t.sf(abs(statistic), degrees_of_freedom))
|
|
182
|
+
interval = _welch_interval(
|
|
183
|
+
effect,
|
|
184
|
+
standard_error,
|
|
185
|
+
degrees_of_freedom,
|
|
186
|
+
confidence_level,
|
|
187
|
+
alternative,
|
|
188
|
+
)
|
|
189
|
+
if control_sd == 0.0 or treatment_sd == 0.0:
|
|
190
|
+
diagnostics.append(
|
|
191
|
+
DiagnosticResult(
|
|
192
|
+
code="ONE_ZERO_VARIANCE_SAMPLE",
|
|
193
|
+
status=DiagnosticStatus.WARN,
|
|
194
|
+
message=(
|
|
195
|
+
"One sample has zero variance; Welch inference is computable but the "
|
|
196
|
+
"data-generating assumptions deserve review."
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
observed_scale = max(
|
|
202
|
+
float(np.max(np.abs(control_values))),
|
|
203
|
+
float(np.max(np.abs(treatment_values))),
|
|
204
|
+
np.finfo(np.float64).tiny,
|
|
205
|
+
)
|
|
206
|
+
denominator_resolution = sqrt(np.finfo(np.float64).eps) * observed_scale
|
|
207
|
+
relative_effect = (
|
|
208
|
+
effect / control_mean
|
|
209
|
+
if control_mean > denominator_resolution and treatment_mean >= 0.0
|
|
210
|
+
else None
|
|
211
|
+
)
|
|
212
|
+
if relative_effect is None:
|
|
213
|
+
diagnostics.append(
|
|
214
|
+
DiagnosticResult(
|
|
215
|
+
code="RELATIVE_EFFECT_NOT_REPORTED",
|
|
216
|
+
status=DiagnosticStatus.WARN,
|
|
217
|
+
message=(
|
|
218
|
+
"Relative mean difference is not reported because the observed means do "
|
|
219
|
+
"not support a non-negative, numerically resolved ratio-scale interpretation."
|
|
220
|
+
),
|
|
221
|
+
details={"minimum_resolved_control_mean": float(denominator_resolution)},
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
direction = {
|
|
225
|
+
AlternativeHypothesis.TWO_SIDED: "mu_treatment - mu_control != 0",
|
|
226
|
+
AlternativeHypothesis.GREATER: "mu_treatment - mu_control > 0",
|
|
227
|
+
AlternativeHypothesis.LESS: "mu_treatment - mu_control < 0",
|
|
228
|
+
}[alternative]
|
|
229
|
+
return AnalysisResult(
|
|
230
|
+
metric_name=metric_name,
|
|
231
|
+
metric_type=MetricType.CONTINUOUS,
|
|
232
|
+
control_estimate=control_mean,
|
|
233
|
+
treatment_estimate=treatment_mean,
|
|
234
|
+
absolute_effect=effect,
|
|
235
|
+
relative_effect=relative_effect,
|
|
236
|
+
relative_effect_definition=(
|
|
237
|
+
"(treatment mean - control mean) / control mean"
|
|
238
|
+
if relative_effect is not None
|
|
239
|
+
else None
|
|
240
|
+
),
|
|
241
|
+
confidence_interval=interval,
|
|
242
|
+
test_statistic=statistic,
|
|
243
|
+
p_value=p_value,
|
|
244
|
+
confidence_level=confidence_level,
|
|
245
|
+
alternative=alternative,
|
|
246
|
+
control_sample_size=control_size,
|
|
247
|
+
treatment_sample_size=treatment_size,
|
|
248
|
+
control_standard_deviation=control_sd,
|
|
249
|
+
treatment_standard_deviation=treatment_sd,
|
|
250
|
+
degrees_of_freedom=degrees_of_freedom,
|
|
251
|
+
method="Welch's two-sample t-test and Welch-Satterthwaite confidence interval",
|
|
252
|
+
null_hypothesis="mu_treatment - mu_control = 0",
|
|
253
|
+
alternative_hypothesis=direction,
|
|
254
|
+
diagnostics=tuple(diagnostics),
|
|
255
|
+
)
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Two-sample analysis for binary outcomes."""
|
|
2
|
+
|
|
3
|
+
from math import sqrt
|
|
4
|
+
|
|
5
|
+
from scipy import stats
|
|
6
|
+
|
|
7
|
+
from experimentation_toolkit.models.enums import (
|
|
8
|
+
AlternativeHypothesis,
|
|
9
|
+
DiagnosticStatus,
|
|
10
|
+
MetricType,
|
|
11
|
+
)
|
|
12
|
+
from experimentation_toolkit.models.results import (
|
|
13
|
+
AnalysisResult,
|
|
14
|
+
ConfidenceInterval,
|
|
15
|
+
DiagnosticResult,
|
|
16
|
+
)
|
|
17
|
+
from experimentation_toolkit.validation.inputs import (
|
|
18
|
+
normalize_alternative,
|
|
19
|
+
validate_nonnegative_integer,
|
|
20
|
+
validate_positive_integer,
|
|
21
|
+
validate_probability,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _wilson_bounds(successes: int, total: int, z_value: float) -> tuple[float, float]:
|
|
26
|
+
proportion = successes / total
|
|
27
|
+
z_squared = z_value**2
|
|
28
|
+
denominator = 1.0 + z_squared / total
|
|
29
|
+
center = (proportion + z_squared / (2.0 * total)) / denominator
|
|
30
|
+
half_width = (
|
|
31
|
+
z_value
|
|
32
|
+
* sqrt(proportion * (1.0 - proportion) / total + z_squared / (4.0 * total) / total)
|
|
33
|
+
/ denominator
|
|
34
|
+
)
|
|
35
|
+
return max(0.0, center - half_width), min(1.0, center + half_width)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _newcombe_difference_interval(
|
|
39
|
+
control_successes: int,
|
|
40
|
+
control_total: int,
|
|
41
|
+
treatment_successes: int,
|
|
42
|
+
treatment_total: int,
|
|
43
|
+
confidence_level: float,
|
|
44
|
+
alternative: AlternativeHypothesis,
|
|
45
|
+
) -> ConfidenceInterval:
|
|
46
|
+
alpha = 1.0 - confidence_level
|
|
47
|
+
tail_probability = alpha / 2.0 if alternative is AlternativeHypothesis.TWO_SIDED else alpha
|
|
48
|
+
z_value = float(stats.norm.ppf(1.0 - tail_probability))
|
|
49
|
+
control_rate = control_successes / control_total
|
|
50
|
+
treatment_rate = treatment_successes / treatment_total
|
|
51
|
+
effect = treatment_rate - control_rate
|
|
52
|
+
control_lower, control_upper = _wilson_bounds(control_successes, control_total, z_value)
|
|
53
|
+
treatment_lower, treatment_upper = _wilson_bounds(treatment_successes, treatment_total, z_value)
|
|
54
|
+
lower = effect - sqrt(
|
|
55
|
+
(treatment_rate - treatment_lower) ** 2 + (control_upper - control_rate) ** 2
|
|
56
|
+
)
|
|
57
|
+
upper = effect + sqrt(
|
|
58
|
+
(treatment_upper - treatment_rate) ** 2 + (control_rate - control_lower) ** 2
|
|
59
|
+
)
|
|
60
|
+
if alternative is AlternativeHypothesis.GREATER:
|
|
61
|
+
return ConfidenceInterval(lower=float(lower), upper=None, confidence_level=confidence_level)
|
|
62
|
+
if alternative is AlternativeHypothesis.LESS:
|
|
63
|
+
return ConfidenceInterval(lower=None, upper=float(upper), confidence_level=confidence_level)
|
|
64
|
+
return ConfidenceInterval(
|
|
65
|
+
lower=float(lower), upper=float(upper), confidence_level=confidence_level
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _score_test(
|
|
70
|
+
control_successes: int,
|
|
71
|
+
control_total: int,
|
|
72
|
+
treatment_successes: int,
|
|
73
|
+
treatment_total: int,
|
|
74
|
+
alternative: AlternativeHypothesis,
|
|
75
|
+
) -> tuple[float, float]:
|
|
76
|
+
control_rate = control_successes / control_total
|
|
77
|
+
treatment_rate = treatment_successes / treatment_total
|
|
78
|
+
pooled_rate = (control_successes + treatment_successes) / (control_total + treatment_total)
|
|
79
|
+
standard_error = sqrt(
|
|
80
|
+
pooled_rate * (1.0 - pooled_rate) * (1.0 / control_total + 1.0 / treatment_total)
|
|
81
|
+
)
|
|
82
|
+
if standard_error == 0.0:
|
|
83
|
+
return 0.0, 1.0
|
|
84
|
+
statistic = (treatment_rate - control_rate) / standard_error
|
|
85
|
+
if alternative is AlternativeHypothesis.GREATER:
|
|
86
|
+
p_value = stats.norm.sf(statistic)
|
|
87
|
+
elif alternative is AlternativeHypothesis.LESS:
|
|
88
|
+
p_value = stats.norm.cdf(statistic)
|
|
89
|
+
else:
|
|
90
|
+
p_value = 2.0 * stats.norm.sf(abs(statistic))
|
|
91
|
+
return float(statistic), float(p_value)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def analyze_proportion(
|
|
95
|
+
*,
|
|
96
|
+
control_successes: int,
|
|
97
|
+
control_total: int,
|
|
98
|
+
treatment_successes: int,
|
|
99
|
+
treatment_total: int,
|
|
100
|
+
confidence_level: float = 0.95,
|
|
101
|
+
alternative: AlternativeHypothesis | str = AlternativeHypothesis.TWO_SIDED,
|
|
102
|
+
metric_name: str | None = None,
|
|
103
|
+
) -> AnalysisResult:
|
|
104
|
+
"""Analyze a treatment-minus-control difference in independent proportions.
|
|
105
|
+
|
|
106
|
+
The test is the pooled two-sample score z-test for ``H0: p_t - p_c = 0``.
|
|
107
|
+
The confidence interval is Newcombe's hybrid score interval based on Wilson
|
|
108
|
+
intervals, without continuity correction. One-sided alternatives return the
|
|
109
|
+
corresponding one-sided interval with ``None`` for its unbounded endpoint.
|
|
110
|
+
"""
|
|
111
|
+
control_total = validate_positive_integer(control_total, "control_total")
|
|
112
|
+
treatment_total = validate_positive_integer(treatment_total, "treatment_total")
|
|
113
|
+
control_successes = validate_nonnegative_integer(control_successes, "control_successes")
|
|
114
|
+
treatment_successes = validate_nonnegative_integer(treatment_successes, "treatment_successes")
|
|
115
|
+
if control_successes > control_total:
|
|
116
|
+
raise ValueError("control_successes must not exceed control_total")
|
|
117
|
+
if treatment_successes > treatment_total:
|
|
118
|
+
raise ValueError("treatment_successes must not exceed treatment_total")
|
|
119
|
+
confidence_level = validate_probability(
|
|
120
|
+
confidence_level, "confidence_level", open_interval=True
|
|
121
|
+
)
|
|
122
|
+
alternative = normalize_alternative(alternative)
|
|
123
|
+
|
|
124
|
+
control_rate = control_successes / control_total
|
|
125
|
+
treatment_rate = treatment_successes / treatment_total
|
|
126
|
+
effect = treatment_rate - control_rate
|
|
127
|
+
relative_effect = effect / control_rate if control_rate > 0.0 else None
|
|
128
|
+
diagnostics: list[DiagnosticResult] = []
|
|
129
|
+
if relative_effect is None:
|
|
130
|
+
diagnostics.append(
|
|
131
|
+
DiagnosticResult(
|
|
132
|
+
code="RELATIVE_EFFECT_UNDEFINED",
|
|
133
|
+
status=DiagnosticStatus.WARN,
|
|
134
|
+
message="Relative lift is undefined because the control rate is zero.",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
pooled_rate = (control_successes + treatment_successes) / (control_total + treatment_total)
|
|
139
|
+
expected_cells = (
|
|
140
|
+
control_total * pooled_rate,
|
|
141
|
+
control_total * (1.0 - pooled_rate),
|
|
142
|
+
treatment_total * pooled_rate,
|
|
143
|
+
treatment_total * (1.0 - pooled_rate),
|
|
144
|
+
)
|
|
145
|
+
if min(expected_cells) < 5.0:
|
|
146
|
+
diagnostics.append(
|
|
147
|
+
DiagnosticResult(
|
|
148
|
+
code="ASYMPTOTIC_APPROXIMATION_WARNING",
|
|
149
|
+
status=DiagnosticStatus.WARN,
|
|
150
|
+
message=(
|
|
151
|
+
"At least one expected success/failure count under the null is below 5; "
|
|
152
|
+
"the score-test normal approximation may be inaccurate."
|
|
153
|
+
),
|
|
154
|
+
details={"minimum_expected_cell_count": float(min(expected_cells))},
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
statistic, p_value = _score_test(
|
|
159
|
+
control_successes,
|
|
160
|
+
control_total,
|
|
161
|
+
treatment_successes,
|
|
162
|
+
treatment_total,
|
|
163
|
+
alternative,
|
|
164
|
+
)
|
|
165
|
+
interval = _newcombe_difference_interval(
|
|
166
|
+
control_successes,
|
|
167
|
+
control_total,
|
|
168
|
+
treatment_successes,
|
|
169
|
+
treatment_total,
|
|
170
|
+
confidence_level,
|
|
171
|
+
alternative,
|
|
172
|
+
)
|
|
173
|
+
direction = {
|
|
174
|
+
AlternativeHypothesis.TWO_SIDED: "p_treatment - p_control != 0",
|
|
175
|
+
AlternativeHypothesis.GREATER: "p_treatment - p_control > 0",
|
|
176
|
+
AlternativeHypothesis.LESS: "p_treatment - p_control < 0",
|
|
177
|
+
}[alternative]
|
|
178
|
+
return AnalysisResult(
|
|
179
|
+
metric_name=metric_name,
|
|
180
|
+
metric_type=MetricType.PROPORTION,
|
|
181
|
+
control_estimate=float(control_rate),
|
|
182
|
+
treatment_estimate=float(treatment_rate),
|
|
183
|
+
absolute_effect=float(effect),
|
|
184
|
+
relative_effect=float(relative_effect) if relative_effect is not None else None,
|
|
185
|
+
relative_effect_definition=(
|
|
186
|
+
"(treatment rate - control rate) / control rate"
|
|
187
|
+
if relative_effect is not None
|
|
188
|
+
else None
|
|
189
|
+
),
|
|
190
|
+
confidence_interval=interval,
|
|
191
|
+
test_statistic=statistic,
|
|
192
|
+
p_value=p_value,
|
|
193
|
+
confidence_level=confidence_level,
|
|
194
|
+
alternative=alternative,
|
|
195
|
+
control_sample_size=control_total,
|
|
196
|
+
treatment_sample_size=treatment_total,
|
|
197
|
+
method=(
|
|
198
|
+
"Pooled two-sample score z-test; Newcombe hybrid Wilson confidence interval "
|
|
199
|
+
"for the absolute risk difference (no continuity correction)"
|
|
200
|
+
),
|
|
201
|
+
null_hypothesis="p_treatment - p_control = 0",
|
|
202
|
+
alternative_hypothesis=direction,
|
|
203
|
+
diagnostics=tuple(diagnostics),
|
|
204
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Sample-ratio-mismatch diagnostics."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
|
|
8
|
+
from experimentation_toolkit.models.enums import DiagnosticStatus
|
|
9
|
+
from experimentation_toolkit.models.results import DiagnosticResult, SRMResult
|
|
10
|
+
from experimentation_toolkit.validation.inputs import (
|
|
11
|
+
validate_nonnegative_integer,
|
|
12
|
+
validate_probability,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check_sample_ratio_mismatch(
|
|
17
|
+
*,
|
|
18
|
+
observed_counts: Mapping[str, int],
|
|
19
|
+
expected_proportions: Mapping[str, float],
|
|
20
|
+
significance_level: float = 0.01,
|
|
21
|
+
proportion_tolerance: float = 1e-12,
|
|
22
|
+
) -> SRMResult:
|
|
23
|
+
"""Run Pearson's chi-square goodness-of-fit test for assignment counts.
|
|
24
|
+
|
|
25
|
+
A failing result flags a potential randomization or instrumentation problem. It
|
|
26
|
+
does not identify the cause. The chi-square approximation assumes independent
|
|
27
|
+
assignments and sufficiently large positive expected counts.
|
|
28
|
+
"""
|
|
29
|
+
significance_level = validate_probability(
|
|
30
|
+
significance_level, "significance_level", open_interval=True
|
|
31
|
+
)
|
|
32
|
+
if not np.isfinite(proportion_tolerance) or proportion_tolerance < 0.0:
|
|
33
|
+
raise ValueError("proportion_tolerance must be finite and non-negative")
|
|
34
|
+
if len(observed_counts) < 2:
|
|
35
|
+
raise ValueError("at least two variants are required for an SRM check")
|
|
36
|
+
if set(observed_counts) != set(expected_proportions):
|
|
37
|
+
raise ValueError("observed_counts and expected_proportions must have identical keys")
|
|
38
|
+
|
|
39
|
+
observed = {
|
|
40
|
+
name: validate_nonnegative_integer(count, f"observed_counts[{name!r}]")
|
|
41
|
+
for name, count in observed_counts.items()
|
|
42
|
+
}
|
|
43
|
+
proportions = {name: float(expected_proportions[name]) for name in observed}
|
|
44
|
+
if any(not np.isfinite(value) or value <= 0.0 for value in proportions.values()):
|
|
45
|
+
raise ValueError("all expected allocation proportions must be finite and positive")
|
|
46
|
+
if not np.isclose(sum(proportions.values()), 1.0, rtol=0.0, atol=proportion_tolerance):
|
|
47
|
+
raise ValueError("expected allocation proportions must sum to 1")
|
|
48
|
+
total = sum(observed.values())
|
|
49
|
+
if total <= 0:
|
|
50
|
+
raise ValueError("the total observed assignment count must be greater than zero")
|
|
51
|
+
expected = {name: total * proportion for name, proportion in proportions.items()}
|
|
52
|
+
diagnostics: tuple[DiagnosticResult, ...] = ()
|
|
53
|
+
minimum_expected_count = min(expected.values())
|
|
54
|
+
if minimum_expected_count < 5.0:
|
|
55
|
+
diagnostics = (
|
|
56
|
+
DiagnosticResult(
|
|
57
|
+
code="SMALL_EXPECTED_ASSIGNMENT_COUNT",
|
|
58
|
+
status=DiagnosticStatus.WARN,
|
|
59
|
+
message=(
|
|
60
|
+
"At least one expected assignment count is below 5; the chi-square "
|
|
61
|
+
"reference approximation may be inaccurate."
|
|
62
|
+
),
|
|
63
|
+
details={"minimum_expected_count": float(minimum_expected_count)},
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
result = stats.chisquare(
|
|
68
|
+
f_obs=np.asarray(tuple(observed.values()), dtype=np.float64),
|
|
69
|
+
f_exp=np.asarray(tuple(expected.values()), dtype=np.float64),
|
|
70
|
+
)
|
|
71
|
+
statistic = float(result.statistic)
|
|
72
|
+
p_value = float(result.pvalue)
|
|
73
|
+
failed = p_value < significance_level
|
|
74
|
+
return SRMResult(
|
|
75
|
+
observed_counts=observed,
|
|
76
|
+
expected_counts={name: float(value) for name, value in expected.items()},
|
|
77
|
+
chi_square_statistic=statistic,
|
|
78
|
+
p_value=p_value,
|
|
79
|
+
significance_level=significance_level,
|
|
80
|
+
degrees_of_freedom=len(observed) - 1,
|
|
81
|
+
status=DiagnosticStatus.FAIL if failed else DiagnosticStatus.PASS,
|
|
82
|
+
message=(
|
|
83
|
+
"Assignment counts are inconsistent with the configured allocation at the "
|
|
84
|
+
"selected threshold; investigate randomization or instrumentation."
|
|
85
|
+
if failed
|
|
86
|
+
else "No sample ratio mismatch was detected at the selected threshold."
|
|
87
|
+
),
|
|
88
|
+
diagnostics=diagnostics,
|
|
89
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Public domain and result models."""
|
|
2
|
+
|
|
3
|
+
from experimentation_toolkit.models.core import Experiment, Metric, ToolkitModel, Variant
|
|
4
|
+
from experimentation_toolkit.models.enums import (
|
|
5
|
+
AlternativeHypothesis,
|
|
6
|
+
CorrectionMethod,
|
|
7
|
+
DiagnosticStatus,
|
|
8
|
+
MDEType,
|
|
9
|
+
MetricType,
|
|
10
|
+
)
|
|
11
|
+
from experimentation_toolkit.models.results import (
|
|
12
|
+
AchievedPowerResult,
|
|
13
|
+
AnalysisResult,
|
|
14
|
+
ConfidenceInterval,
|
|
15
|
+
DiagnosticResult,
|
|
16
|
+
ExperimentReport,
|
|
17
|
+
MultipleTestingResult,
|
|
18
|
+
ProportionSampleSizeResult,
|
|
19
|
+
SRMResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AchievedPowerResult",
|
|
24
|
+
"AlternativeHypothesis",
|
|
25
|
+
"AnalysisResult",
|
|
26
|
+
"ConfidenceInterval",
|
|
27
|
+
"CorrectionMethod",
|
|
28
|
+
"DiagnosticResult",
|
|
29
|
+
"DiagnosticStatus",
|
|
30
|
+
"Experiment",
|
|
31
|
+
"ExperimentReport",
|
|
32
|
+
"MDEType",
|
|
33
|
+
"Metric",
|
|
34
|
+
"MetricType",
|
|
35
|
+
"MultipleTestingResult",
|
|
36
|
+
"ProportionSampleSizeResult",
|
|
37
|
+
"SRMResult",
|
|
38
|
+
"ToolkitModel",
|
|
39
|
+
"Variant",
|
|
40
|
+
]
|