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
+ """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,6 @@
1
+ """Metric analysis functions."""
2
+
3
+ from experimentation_toolkit.analysis.continuous import analyze_continuous
4
+ from experimentation_toolkit.analysis.proportion import analyze_proportion
5
+
6
+ __all__ = ["analyze_continuous", "analyze_proportion"]
@@ -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,5 @@
1
+ """Experiment diagnostics."""
2
+
3
+ from experimentation_toolkit.diagnostics.srm import check_sample_ratio_mismatch
4
+
5
+ __all__ = ["check_sample_ratio_mismatch"]
@@ -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
+ ]