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.
@@ -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,5 @@
1
+ """Multiple-testing procedures."""
2
+
3
+ from experimentation_toolkit.multiple_testing.corrections import adjust_p_values
4
+
5
+ __all__ = ["adjust_p_values"]
@@ -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,8 @@
1
+ """Power and sample-size planning."""
2
+
3
+ from experimentation_toolkit.power.proportion import (
4
+ proportion_achieved_power,
5
+ proportion_sample_size,
6
+ )
7
+
8
+ __all__ = ["proportion_achieved_power", "proportion_sample_size"]
@@ -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."""