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
|
+
"""Experiment metadata models."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
4
|
+
|
|
5
|
+
from experimentation_toolkit.models.enums import MetricType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolkitModel(BaseModel):
|
|
9
|
+
"""Strict, immutable base model with standards-compliant JSON output."""
|
|
10
|
+
|
|
11
|
+
model_config = ConfigDict(extra="forbid", frozen=True, allow_inf_nan=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Variant(ToolkitModel):
|
|
15
|
+
"""A named experiment arm."""
|
|
16
|
+
|
|
17
|
+
name: str = Field(min_length=1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Metric(ToolkitModel):
|
|
21
|
+
"""Metadata for one experiment outcome."""
|
|
22
|
+
|
|
23
|
+
name: str = Field(min_length=1)
|
|
24
|
+
metric_type: MetricType
|
|
25
|
+
primary: bool = False
|
|
26
|
+
description: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Experiment(ToolkitModel):
|
|
30
|
+
"""Metadata for a two-arm controlled experiment."""
|
|
31
|
+
|
|
32
|
+
name: str = "A/B experiment"
|
|
33
|
+
control: Variant | str
|
|
34
|
+
treatment: Variant | str
|
|
35
|
+
metrics: tuple[Metric, ...] = ()
|
|
36
|
+
|
|
37
|
+
@model_validator(mode="after")
|
|
38
|
+
def validate_distinct_variants(self) -> "Experiment":
|
|
39
|
+
"""Require distinct non-empty arm names and unique metric names."""
|
|
40
|
+
control_name = self.control.name if isinstance(self.control, Variant) else self.control
|
|
41
|
+
treatment_name = (
|
|
42
|
+
self.treatment.name if isinstance(self.treatment, Variant) else self.treatment
|
|
43
|
+
)
|
|
44
|
+
if not control_name.strip() or not treatment_name.strip():
|
|
45
|
+
raise ValueError("variant names must be non-empty")
|
|
46
|
+
if control_name == treatment_name:
|
|
47
|
+
raise ValueError("control and treatment must have different names")
|
|
48
|
+
metric_names = [metric.name for metric in self.metrics]
|
|
49
|
+
if len(metric_names) != len(set(metric_names)):
|
|
50
|
+
raise ValueError("metric names must be unique within an experiment")
|
|
51
|
+
return self
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Enumerations shared by the public API."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AlternativeHypothesis(StrEnum):
|
|
7
|
+
"""Direction of the treatment-minus-control hypothesis test."""
|
|
8
|
+
|
|
9
|
+
TWO_SIDED = "two-sided"
|
|
10
|
+
GREATER = "greater"
|
|
11
|
+
LESS = "less"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MetricType(StrEnum):
|
|
15
|
+
"""Supported metric families."""
|
|
16
|
+
|
|
17
|
+
PROPORTION = "proportion"
|
|
18
|
+
CONTINUOUS = "continuous"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DiagnosticStatus(StrEnum):
|
|
22
|
+
"""Status of a factual diagnostic check."""
|
|
23
|
+
|
|
24
|
+
PASS = "PASS"
|
|
25
|
+
WARN = "WARN"
|
|
26
|
+
FAIL = "FAIL"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CorrectionMethod(StrEnum):
|
|
30
|
+
"""Supported multiple-testing procedures."""
|
|
31
|
+
|
|
32
|
+
BONFERRONI = "bonferroni"
|
|
33
|
+
HOLM = "holm"
|
|
34
|
+
BENJAMINI_HOCHBERG = "benjamini-hochberg"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MDEType(StrEnum):
|
|
38
|
+
"""Scale on which a minimum detectable effect is supplied."""
|
|
39
|
+
|
|
40
|
+
ABSOLUTE = "absolute"
|
|
41
|
+
RELATIVE = "relative"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Serializable analysis, diagnostic, power, and report models."""
|
|
2
|
+
|
|
3
|
+
from math import isclose
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from experimentation_toolkit.models.core import Experiment, ToolkitModel
|
|
9
|
+
from experimentation_toolkit.models.enums import (
|
|
10
|
+
AlternativeHypothesis,
|
|
11
|
+
CorrectionMethod,
|
|
12
|
+
DiagnosticStatus,
|
|
13
|
+
MDEType,
|
|
14
|
+
MetricType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfidenceInterval(ToolkitModel):
|
|
19
|
+
"""An interval whose ``None`` endpoint represents an unbounded side."""
|
|
20
|
+
|
|
21
|
+
lower: float | None
|
|
22
|
+
upper: float | None
|
|
23
|
+
confidence_level: float = Field(gt=0.0, lt=1.0)
|
|
24
|
+
|
|
25
|
+
@field_validator("upper")
|
|
26
|
+
@classmethod
|
|
27
|
+
def validate_order(cls, upper: float | None, info: Any) -> float | None:
|
|
28
|
+
lower = info.data.get("lower")
|
|
29
|
+
if lower is not None and upper is not None and lower > upper:
|
|
30
|
+
raise ValueError("confidence interval lower bound must not exceed upper bound")
|
|
31
|
+
return upper
|
|
32
|
+
|
|
33
|
+
@model_validator(mode="after")
|
|
34
|
+
def validate_bounded_side(self) -> "ConfidenceInterval":
|
|
35
|
+
if self.lower is None and self.upper is None:
|
|
36
|
+
raise ValueError("at least one confidence interval endpoint must be bounded")
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DiagnosticResult(ToolkitModel):
|
|
41
|
+
"""A factual diagnostic emitted by an analysis."""
|
|
42
|
+
|
|
43
|
+
code: str
|
|
44
|
+
status: DiagnosticStatus
|
|
45
|
+
message: str
|
|
46
|
+
details: dict[str, int | float | str | bool | None] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AnalysisResult(ToolkitModel):
|
|
50
|
+
"""Common result schema for treatment-minus-control analyses."""
|
|
51
|
+
|
|
52
|
+
metric_name: str | None = None
|
|
53
|
+
metric_type: MetricType
|
|
54
|
+
control_estimate: float
|
|
55
|
+
treatment_estimate: float
|
|
56
|
+
absolute_effect: float
|
|
57
|
+
relative_effect: float | None
|
|
58
|
+
relative_effect_definition: str | None
|
|
59
|
+
confidence_interval: ConfidenceInterval
|
|
60
|
+
test_statistic: float | None
|
|
61
|
+
p_value: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
62
|
+
confidence_level: float = Field(gt=0.0, lt=1.0)
|
|
63
|
+
alternative: AlternativeHypothesis
|
|
64
|
+
control_sample_size: int = Field(gt=0)
|
|
65
|
+
treatment_sample_size: int = Field(gt=0)
|
|
66
|
+
control_standard_deviation: float | None = Field(default=None, ge=0.0)
|
|
67
|
+
treatment_standard_deviation: float | None = Field(default=None, ge=0.0)
|
|
68
|
+
degrees_of_freedom: float | None = Field(default=None, gt=0.0)
|
|
69
|
+
method: str
|
|
70
|
+
null_hypothesis: str
|
|
71
|
+
alternative_hypothesis: str
|
|
72
|
+
diagnostics: tuple[DiagnosticResult, ...] = ()
|
|
73
|
+
|
|
74
|
+
@model_validator(mode="after")
|
|
75
|
+
def validate_result_coherence(self) -> "AnalysisResult":
|
|
76
|
+
if self.confidence_interval.confidence_level != self.confidence_level:
|
|
77
|
+
raise ValueError("analysis and interval confidence levels must match")
|
|
78
|
+
expected_effect = self.treatment_estimate - self.control_estimate
|
|
79
|
+
if not isclose(self.absolute_effect, expected_effect, rel_tol=1e-12, abs_tol=0.0):
|
|
80
|
+
raise ValueError("absolute effect must equal treatment estimate minus control estimate")
|
|
81
|
+
if (self.relative_effect is None) != (self.relative_effect_definition is None):
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"relative effect and its definition must either both be set or both be null"
|
|
84
|
+
)
|
|
85
|
+
if self.alternative is AlternativeHypothesis.TWO_SIDED and (
|
|
86
|
+
self.confidence_interval.lower is None or self.confidence_interval.upper is None
|
|
87
|
+
):
|
|
88
|
+
raise ValueError("a two-sided analysis requires two bounded interval endpoints")
|
|
89
|
+
if self.alternative is AlternativeHypothesis.GREATER and (
|
|
90
|
+
self.confidence_interval.lower is None or self.confidence_interval.upper is not None
|
|
91
|
+
):
|
|
92
|
+
raise ValueError("a greater alternative requires a lower-bounded interval")
|
|
93
|
+
if self.alternative is AlternativeHypothesis.LESS and (
|
|
94
|
+
self.confidence_interval.lower is not None or self.confidence_interval.upper is None
|
|
95
|
+
):
|
|
96
|
+
raise ValueError("a less alternative requires an upper-bounded interval")
|
|
97
|
+
return self
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class SRMResult(ToolkitModel):
|
|
101
|
+
"""Chi-square goodness-of-fit sample-ratio-mismatch result."""
|
|
102
|
+
|
|
103
|
+
observed_counts: dict[str, int]
|
|
104
|
+
expected_counts: dict[str, float]
|
|
105
|
+
chi_square_statistic: float = Field(ge=0.0)
|
|
106
|
+
p_value: float = Field(ge=0.0, le=1.0)
|
|
107
|
+
significance_level: float = Field(gt=0.0, lt=1.0)
|
|
108
|
+
degrees_of_freedom: int = Field(ge=1)
|
|
109
|
+
status: DiagnosticStatus
|
|
110
|
+
method: str = "Pearson chi-square goodness-of-fit test"
|
|
111
|
+
message: str
|
|
112
|
+
diagnostics: tuple[DiagnosticResult, ...] = ()
|
|
113
|
+
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def validate_srm_structure(self) -> "SRMResult":
|
|
116
|
+
if set(self.observed_counts) != set(self.expected_counts):
|
|
117
|
+
raise ValueError("observed and expected SRM counts must have identical keys")
|
|
118
|
+
if any(value < 0 for value in self.observed_counts.values()):
|
|
119
|
+
raise ValueError("observed SRM counts must be non-negative")
|
|
120
|
+
if any(value <= 0.0 for value in self.expected_counts.values()):
|
|
121
|
+
raise ValueError("expected SRM counts must be positive")
|
|
122
|
+
if self.degrees_of_freedom != len(self.observed_counts) - 1:
|
|
123
|
+
raise ValueError("SRM degrees of freedom must equal the number of groups minus one")
|
|
124
|
+
if self.status not in (DiagnosticStatus.PASS, DiagnosticStatus.FAIL):
|
|
125
|
+
raise ValueError("SRM status must be PASS or FAIL")
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MultipleTestingResult(ToolkitModel):
|
|
130
|
+
"""Adjusted p-values in the same order as the input hypotheses."""
|
|
131
|
+
|
|
132
|
+
original_p_values: tuple[float, ...]
|
|
133
|
+
adjusted_p_values: tuple[float, ...]
|
|
134
|
+
rejected: tuple[bool, ...]
|
|
135
|
+
alpha: float = Field(gt=0.0, lt=1.0)
|
|
136
|
+
method: CorrectionMethod
|
|
137
|
+
hypothesis_labels: tuple[str, ...] | None = None
|
|
138
|
+
error_rate_control: str
|
|
139
|
+
|
|
140
|
+
@model_validator(mode="after")
|
|
141
|
+
def validate_parallel_results(self) -> "MultipleTestingResult":
|
|
142
|
+
size = len(self.original_p_values)
|
|
143
|
+
if size == 0:
|
|
144
|
+
raise ValueError("multiple-testing results must contain at least one hypothesis")
|
|
145
|
+
if len(self.adjusted_p_values) != size or len(self.rejected) != size:
|
|
146
|
+
raise ValueError("multiple-testing result arrays must have equal lengths")
|
|
147
|
+
if self.hypothesis_labels is not None and len(self.hypothesis_labels) != size:
|
|
148
|
+
raise ValueError("hypothesis labels must match the number of p-values")
|
|
149
|
+
if any(value < 0.0 or value > 1.0 for value in self.original_p_values):
|
|
150
|
+
raise ValueError("original p-values must be in [0, 1]")
|
|
151
|
+
if any(value < 0.0 or value > 1.0 for value in self.adjusted_p_values):
|
|
152
|
+
raise ValueError("adjusted p-values must be in [0, 1]")
|
|
153
|
+
if self.rejected != tuple(value <= self.alpha for value in self.adjusted_p_values):
|
|
154
|
+
raise ValueError("rejection decisions must correspond to adjusted p-values and alpha")
|
|
155
|
+
expected_error_rate = (
|
|
156
|
+
"false discovery rate"
|
|
157
|
+
if self.method is CorrectionMethod.BENJAMINI_HOCHBERG
|
|
158
|
+
else "family-wise error rate"
|
|
159
|
+
)
|
|
160
|
+
if self.error_rate_control != expected_error_rate:
|
|
161
|
+
raise ValueError("error-rate description must correspond to the correction method")
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ProportionSampleSizeResult(ToolkitModel):
|
|
166
|
+
"""Normal-approximation sample-size planning result."""
|
|
167
|
+
|
|
168
|
+
baseline_rate: float = Field(gt=0.0, lt=1.0)
|
|
169
|
+
treatment_rate: float = Field(gt=0.0, lt=1.0)
|
|
170
|
+
minimum_detectable_effect: float = Field(gt=0.0)
|
|
171
|
+
mde_type: MDEType
|
|
172
|
+
alpha: float = Field(gt=0.0, lt=1.0)
|
|
173
|
+
target_power: float = Field(gt=0.0, lt=1.0)
|
|
174
|
+
achieved_power: float = Field(ge=0.0, le=1.0)
|
|
175
|
+
allocation_ratio: float = Field(gt=0.0)
|
|
176
|
+
alternative: AlternativeHypothesis
|
|
177
|
+
control_sample_size: int = Field(gt=0)
|
|
178
|
+
treatment_sample_size: int = Field(gt=0)
|
|
179
|
+
total_sample_size: int = Field(gt=0)
|
|
180
|
+
method: str
|
|
181
|
+
|
|
182
|
+
@model_validator(mode="after")
|
|
183
|
+
def validate_total_sample_size(self) -> "ProportionSampleSizeResult":
|
|
184
|
+
if self.total_sample_size != self.control_sample_size + self.treatment_sample_size:
|
|
185
|
+
raise ValueError("total sample size must equal control plus treatment sample sizes")
|
|
186
|
+
if self.achieved_power < self.target_power:
|
|
187
|
+
raise ValueError("achieved power must meet or exceed target power")
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class AchievedPowerResult(ToolkitModel):
|
|
192
|
+
"""Approximate achieved power for fixed group sizes and rates."""
|
|
193
|
+
|
|
194
|
+
baseline_rate: float = Field(gt=0.0, lt=1.0)
|
|
195
|
+
treatment_rate: float = Field(gt=0.0, lt=1.0)
|
|
196
|
+
control_sample_size: int = Field(gt=0)
|
|
197
|
+
treatment_sample_size: int = Field(gt=0)
|
|
198
|
+
alpha: float = Field(gt=0.0, lt=1.0)
|
|
199
|
+
power: float = Field(ge=0.0, le=1.0)
|
|
200
|
+
alternative: AlternativeHypothesis
|
|
201
|
+
method: str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ExperimentReport(ToolkitModel):
|
|
205
|
+
"""Serializable collection of experiment analyses and diagnostics."""
|
|
206
|
+
|
|
207
|
+
experiment: Experiment
|
|
208
|
+
analyses: tuple[AnalysisResult, ...]
|
|
209
|
+
diagnostics: tuple[SRMResult | DiagnosticResult, ...] = ()
|
|
210
|
+
multiple_testing: MultipleTestingResult | None = None
|
|
211
|
+
metadata: dict[str, int | float | str | bool | None] = Field(default_factory=dict)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Multiple-testing corrections with stable input-order mapping."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from experimentation_toolkit.models.enums import CorrectionMethod
|
|
8
|
+
from experimentation_toolkit.models.results import MultipleTestingResult
|
|
9
|
+
from experimentation_toolkit.validation.inputs import validate_p_values, validate_probability
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def adjust_p_values(
|
|
13
|
+
p_values: Iterable[float],
|
|
14
|
+
*,
|
|
15
|
+
method: CorrectionMethod | str,
|
|
16
|
+
alpha: float = 0.05,
|
|
17
|
+
hypothesis_labels: Sequence[str] | None = None,
|
|
18
|
+
) -> MultipleTestingResult:
|
|
19
|
+
"""Adjust p-values with Bonferroni, Holm, or Benjamini-Hochberg.
|
|
20
|
+
|
|
21
|
+
Bonferroni and Holm control family-wise error rate. Benjamini-Hochberg
|
|
22
|
+
controls false discovery rate under independence or positive dependence;
|
|
23
|
+
it does not generally control family-wise error rate.
|
|
24
|
+
"""
|
|
25
|
+
p_values = validate_p_values(p_values)
|
|
26
|
+
alpha = validate_probability(alpha, "alpha", open_interval=True)
|
|
27
|
+
try:
|
|
28
|
+
method = CorrectionMethod(method)
|
|
29
|
+
except ValueError as error:
|
|
30
|
+
allowed = ", ".join(item.value for item in CorrectionMethod)
|
|
31
|
+
raise ValueError(f"method must be one of: {allowed}") from error
|
|
32
|
+
labels = tuple(hypothesis_labels) if hypothesis_labels is not None else None
|
|
33
|
+
if labels is not None and len(labels) != len(p_values):
|
|
34
|
+
raise ValueError("hypothesis_labels must have the same length as p_values")
|
|
35
|
+
if labels is not None and any(not label for label in labels):
|
|
36
|
+
raise ValueError("hypothesis labels must be non-empty")
|
|
37
|
+
|
|
38
|
+
values = np.asarray(p_values, dtype=np.float64)
|
|
39
|
+
count = values.size
|
|
40
|
+
if method is CorrectionMethod.BONFERRONI:
|
|
41
|
+
adjusted = np.minimum(values * count, 1.0)
|
|
42
|
+
error_rate_control = "family-wise error rate"
|
|
43
|
+
else:
|
|
44
|
+
order = np.argsort(values, kind="stable")
|
|
45
|
+
sorted_values = values[order]
|
|
46
|
+
if method is CorrectionMethod.HOLM:
|
|
47
|
+
scaled = (count - np.arange(count)) * sorted_values
|
|
48
|
+
sorted_adjusted = np.minimum(np.maximum.accumulate(scaled), 1.0)
|
|
49
|
+
error_rate_control = "family-wise error rate"
|
|
50
|
+
else:
|
|
51
|
+
scaled = count * sorted_values / np.arange(1, count + 1)
|
|
52
|
+
sorted_adjusted = np.minimum(np.minimum.accumulate(scaled[::-1])[::-1], 1.0)
|
|
53
|
+
error_rate_control = "false discovery rate"
|
|
54
|
+
adjusted = np.empty(count, dtype=np.float64)
|
|
55
|
+
adjusted[order] = sorted_adjusted
|
|
56
|
+
rejected = adjusted <= alpha
|
|
57
|
+
return MultipleTestingResult(
|
|
58
|
+
original_p_values=p_values,
|
|
59
|
+
adjusted_p_values=tuple(float(value) for value in adjusted),
|
|
60
|
+
rejected=tuple(bool(value) for value in rejected),
|
|
61
|
+
alpha=alpha,
|
|
62
|
+
method=method,
|
|
63
|
+
hypothesis_labels=labels,
|
|
64
|
+
error_rate_control=error_rate_control,
|
|
65
|
+
)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Normal-approximation power and sample size for independent proportions."""
|
|
2
|
+
|
|
3
|
+
from math import ceil, sqrt
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
|
|
8
|
+
from experimentation_toolkit.models.enums import AlternativeHypothesis, MDEType
|
|
9
|
+
from experimentation_toolkit.models.results import AchievedPowerResult, ProportionSampleSizeResult
|
|
10
|
+
from experimentation_toolkit.validation.inputs import (
|
|
11
|
+
normalize_alternative,
|
|
12
|
+
validate_positive_integer,
|
|
13
|
+
validate_probability,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_METHOD = "Normal approximation for a pooled-null two-sample proportion score test"
|
|
17
|
+
_MAX_EXACT_SAMPLE_SIZE = 2**53
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normal_approximation_power(
|
|
21
|
+
baseline_rate: float,
|
|
22
|
+
treatment_rate: float,
|
|
23
|
+
control_size: int,
|
|
24
|
+
treatment_size: int,
|
|
25
|
+
alpha: float,
|
|
26
|
+
alternative: AlternativeHypothesis,
|
|
27
|
+
) -> float:
|
|
28
|
+
control_weight = control_size / (control_size + treatment_size)
|
|
29
|
+
pooled_rate = control_weight * baseline_rate + (1.0 - control_weight) * treatment_rate
|
|
30
|
+
inverse_root_control = 1.0 / sqrt(control_size)
|
|
31
|
+
inverse_root_treatment = 1.0 / sqrt(treatment_size)
|
|
32
|
+
null_standard_error = sqrt(pooled_rate * (1.0 - pooled_rate)) * np.hypot(
|
|
33
|
+
inverse_root_control, inverse_root_treatment
|
|
34
|
+
)
|
|
35
|
+
alternative_standard_error = np.hypot(
|
|
36
|
+
sqrt(baseline_rate * (1.0 - baseline_rate)) * inverse_root_control,
|
|
37
|
+
sqrt(treatment_rate * (1.0 - treatment_rate)) * inverse_root_treatment,
|
|
38
|
+
)
|
|
39
|
+
if null_standard_error == 0.0 or alternative_standard_error == 0.0:
|
|
40
|
+
raise ValueError("power approximation is undefined at degenerate boundary rates")
|
|
41
|
+
mean = (treatment_rate - baseline_rate) / null_standard_error
|
|
42
|
+
scale = alternative_standard_error / null_standard_error
|
|
43
|
+
if alternative is AlternativeHypothesis.TWO_SIDED:
|
|
44
|
+
critical = float(stats.norm.ppf(1.0 - alpha / 2.0))
|
|
45
|
+
power = stats.norm.sf((critical - mean) / scale) + stats.norm.cdf(
|
|
46
|
+
(-critical - mean) / scale
|
|
47
|
+
)
|
|
48
|
+
elif alternative is AlternativeHypothesis.GREATER:
|
|
49
|
+
critical = float(stats.norm.ppf(1.0 - alpha))
|
|
50
|
+
power = stats.norm.sf((critical - mean) / scale)
|
|
51
|
+
else:
|
|
52
|
+
critical = float(stats.norm.ppf(alpha))
|
|
53
|
+
power = stats.norm.cdf((critical - mean) / scale)
|
|
54
|
+
return float(np.clip(power, 0.0, 1.0))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def proportion_achieved_power(
|
|
58
|
+
*,
|
|
59
|
+
baseline_rate: float,
|
|
60
|
+
treatment_rate: float,
|
|
61
|
+
control_sample_size: int,
|
|
62
|
+
treatment_sample_size: int,
|
|
63
|
+
alpha: float = 0.05,
|
|
64
|
+
alternative: AlternativeHypothesis | str = AlternativeHypothesis.TWO_SIDED,
|
|
65
|
+
) -> AchievedPowerResult:
|
|
66
|
+
"""Estimate achieved power for a fixed two-proportion design."""
|
|
67
|
+
baseline_rate = validate_probability(baseline_rate, "baseline_rate", open_interval=True)
|
|
68
|
+
treatment_rate = validate_probability(treatment_rate, "treatment_rate", open_interval=True)
|
|
69
|
+
control_sample_size = validate_positive_integer(control_sample_size, "control_sample_size")
|
|
70
|
+
treatment_sample_size = validate_positive_integer(
|
|
71
|
+
treatment_sample_size, "treatment_sample_size"
|
|
72
|
+
)
|
|
73
|
+
alpha = validate_probability(alpha, "alpha", open_interval=True)
|
|
74
|
+
alternative = normalize_alternative(alternative)
|
|
75
|
+
power = _normal_approximation_power(
|
|
76
|
+
baseline_rate,
|
|
77
|
+
treatment_rate,
|
|
78
|
+
control_sample_size,
|
|
79
|
+
treatment_sample_size,
|
|
80
|
+
alpha,
|
|
81
|
+
alternative,
|
|
82
|
+
)
|
|
83
|
+
return AchievedPowerResult(
|
|
84
|
+
baseline_rate=baseline_rate,
|
|
85
|
+
treatment_rate=treatment_rate,
|
|
86
|
+
control_sample_size=control_sample_size,
|
|
87
|
+
treatment_sample_size=treatment_sample_size,
|
|
88
|
+
alpha=alpha,
|
|
89
|
+
power=power,
|
|
90
|
+
alternative=alternative,
|
|
91
|
+
method=_METHOD,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def proportion_sample_size(
|
|
96
|
+
*,
|
|
97
|
+
baseline_rate: float,
|
|
98
|
+
minimum_detectable_effect: float,
|
|
99
|
+
mde_type: MDEType | str,
|
|
100
|
+
alpha: float = 0.05,
|
|
101
|
+
power: float = 0.8,
|
|
102
|
+
allocation_ratio: float = 1.0,
|
|
103
|
+
alternative: AlternativeHypothesis | str = AlternativeHypothesis.TWO_SIDED,
|
|
104
|
+
) -> ProportionSampleSizeResult:
|
|
105
|
+
"""Plan group sizes for a two-proportion experiment.
|
|
106
|
+
|
|
107
|
+
``allocation_ratio`` is treatment/control. The MDE is a positive magnitude:
|
|
108
|
+
it is added to the baseline for two-sided/greater alternatives and subtracted
|
|
109
|
+
for a less alternative. A relative MDE is a fraction of the baseline (``0.1``
|
|
110
|
+
means a 10% relative change), never percentage points.
|
|
111
|
+
"""
|
|
112
|
+
baseline_rate = validate_probability(baseline_rate, "baseline_rate", open_interval=True)
|
|
113
|
+
effect = float(minimum_detectable_effect)
|
|
114
|
+
if not np.isfinite(effect) or effect <= 0.0:
|
|
115
|
+
raise ValueError("minimum_detectable_effect must be finite and greater than zero")
|
|
116
|
+
try:
|
|
117
|
+
mde_type = MDEType(mde_type)
|
|
118
|
+
except ValueError as error:
|
|
119
|
+
raise ValueError("mde_type must be 'absolute' or 'relative'") from error
|
|
120
|
+
alpha = validate_probability(alpha, "alpha", open_interval=True)
|
|
121
|
+
power = validate_probability(power, "power", open_interval=True)
|
|
122
|
+
allocation_ratio = float(allocation_ratio)
|
|
123
|
+
if not np.isfinite(allocation_ratio) or allocation_ratio <= 0.0:
|
|
124
|
+
raise ValueError("allocation_ratio must be finite and greater than zero")
|
|
125
|
+
alternative = normalize_alternative(alternative)
|
|
126
|
+
|
|
127
|
+
absolute_effect = effect if mde_type is MDEType.ABSOLUTE else baseline_rate * effect
|
|
128
|
+
sign = -1.0 if alternative is AlternativeHypothesis.LESS else 1.0
|
|
129
|
+
treatment_rate = baseline_rate + sign * absolute_effect
|
|
130
|
+
if treatment_rate == baseline_rate:
|
|
131
|
+
raise ValueError("the MDE is too small to resolve at float64 precision")
|
|
132
|
+
if not 0.0 < treatment_rate < 1.0:
|
|
133
|
+
raise ValueError("the MDE implies a treatment rate outside the open interval (0, 1)")
|
|
134
|
+
|
|
135
|
+
pooled_rate = (baseline_rate + allocation_ratio * treatment_rate) / (1.0 + allocation_ratio)
|
|
136
|
+
null_variance_factor = pooled_rate * (1.0 - pooled_rate) * (1.0 + 1.0 / allocation_ratio)
|
|
137
|
+
alternative_variance_factor = baseline_rate * (1.0 - baseline_rate) + (
|
|
138
|
+
treatment_rate * (1.0 - treatment_rate) / allocation_ratio
|
|
139
|
+
)
|
|
140
|
+
alpha_quantile = float(
|
|
141
|
+
stats.norm.ppf(
|
|
142
|
+
1.0 - alpha / 2.0 if alternative is AlternativeHypothesis.TWO_SIDED else 1.0 - alpha
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
power_quantile = float(stats.norm.ppf(power))
|
|
146
|
+
standardized_distance = (
|
|
147
|
+
alpha_quantile * sqrt(null_variance_factor)
|
|
148
|
+
+ power_quantile * sqrt(alternative_variance_factor)
|
|
149
|
+
) / absolute_effect
|
|
150
|
+
if not np.isfinite(standardized_distance) or abs(standardized_distance) > sqrt(
|
|
151
|
+
_MAX_EXACT_SAMPLE_SIZE
|
|
152
|
+
):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
"the requested design exceeds the sample-size range supported by float64 precision"
|
|
155
|
+
)
|
|
156
|
+
raw_control_size = standardized_distance**2
|
|
157
|
+
if not np.isfinite(raw_control_size) or raw_control_size > _MAX_EXACT_SAMPLE_SIZE:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
"the requested design exceeds the sample-size range supported by float64 precision"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def treatment_size_for(control_size: int) -> int:
|
|
163
|
+
treatment_size = max(2, ceil(allocation_ratio * control_size))
|
|
164
|
+
if treatment_size > _MAX_EXACT_SAMPLE_SIZE:
|
|
165
|
+
raise ValueError(
|
|
166
|
+
"the requested design exceeds the sample-size range supported by float64 precision"
|
|
167
|
+
)
|
|
168
|
+
return treatment_size
|
|
169
|
+
|
|
170
|
+
upper_control_size = max(2, ceil(raw_control_size))
|
|
171
|
+
upper_treatment_size = treatment_size_for(upper_control_size)
|
|
172
|
+
upper_power = _normal_approximation_power(
|
|
173
|
+
baseline_rate,
|
|
174
|
+
treatment_rate,
|
|
175
|
+
upper_control_size,
|
|
176
|
+
upper_treatment_size,
|
|
177
|
+
alpha,
|
|
178
|
+
alternative,
|
|
179
|
+
)
|
|
180
|
+
while upper_power < power:
|
|
181
|
+
upper_control_size *= 2
|
|
182
|
+
if upper_control_size > _MAX_EXACT_SAMPLE_SIZE:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
"the requested design exceeds the sample-size range supported by float64 precision"
|
|
185
|
+
)
|
|
186
|
+
upper_treatment_size = treatment_size_for(upper_control_size)
|
|
187
|
+
upper_power = _normal_approximation_power(
|
|
188
|
+
baseline_rate,
|
|
189
|
+
treatment_rate,
|
|
190
|
+
upper_control_size,
|
|
191
|
+
upper_treatment_size,
|
|
192
|
+
alpha,
|
|
193
|
+
alternative,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
lower_control_size = 2
|
|
197
|
+
while lower_control_size < upper_control_size:
|
|
198
|
+
candidate_control_size = (lower_control_size + upper_control_size) // 2
|
|
199
|
+
candidate_treatment_size = treatment_size_for(candidate_control_size)
|
|
200
|
+
candidate_power = _normal_approximation_power(
|
|
201
|
+
baseline_rate,
|
|
202
|
+
treatment_rate,
|
|
203
|
+
candidate_control_size,
|
|
204
|
+
candidate_treatment_size,
|
|
205
|
+
alpha,
|
|
206
|
+
alternative,
|
|
207
|
+
)
|
|
208
|
+
if candidate_power >= power:
|
|
209
|
+
upper_control_size = candidate_control_size
|
|
210
|
+
else:
|
|
211
|
+
lower_control_size = candidate_control_size + 1
|
|
212
|
+
|
|
213
|
+
control_size = lower_control_size
|
|
214
|
+
treatment_size = treatment_size_for(control_size)
|
|
215
|
+
achieved = _normal_approximation_power(
|
|
216
|
+
baseline_rate,
|
|
217
|
+
treatment_rate,
|
|
218
|
+
control_size,
|
|
219
|
+
treatment_size,
|
|
220
|
+
alpha,
|
|
221
|
+
alternative,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return ProportionSampleSizeResult(
|
|
225
|
+
baseline_rate=baseline_rate,
|
|
226
|
+
treatment_rate=treatment_rate,
|
|
227
|
+
minimum_detectable_effect=effect,
|
|
228
|
+
mde_type=mde_type,
|
|
229
|
+
alpha=alpha,
|
|
230
|
+
target_power=power,
|
|
231
|
+
achieved_power=achieved,
|
|
232
|
+
allocation_ratio=allocation_ratio,
|
|
233
|
+
alternative=alternative,
|
|
234
|
+
control_sample_size=control_size,
|
|
235
|
+
treatment_sample_size=treatment_size,
|
|
236
|
+
total_sample_size=control_size + treatment_size,
|
|
237
|
+
method=_METHOD,
|
|
238
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Input validation utilities."""
|