experimentpy 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,9 @@
1
+ """Statistical tools for randomized online experiments."""
2
+
3
+ from .design import SampleSizeResult, sample_size_proportions
4
+ from .quality import SRMResult, check_srm
5
+
6
+ __version__ = "0.1.0"
7
+ __all__ = [
8
+ "SampleSizeResult", "SRMResult", "sample_size_proportions", "check_srm",
9
+ ]
@@ -0,0 +1,26 @@
1
+ """Shared input validation."""
2
+
3
+ from math import isfinite
4
+ from numbers import Integral, Real
5
+
6
+
7
+ def finite_real(value: Real, name: str) -> float:
8
+ if isinstance(value, bool) or not isinstance(value, Real):
9
+ raise ValueError(f"{name} must be a finite real number.")
10
+ result = float(value)
11
+ if not isfinite(result):
12
+ raise ValueError(f"{name} must be a finite real number.")
13
+ return result
14
+
15
+
16
+ def probability(value: Real, name: str) -> float:
17
+ result = finite_real(value, name)
18
+ if not 0 < result < 1:
19
+ raise ValueError(f"{name} must be strictly between 0 and 1.")
20
+ return result
21
+
22
+
23
+ def count(value: Integral, name: str) -> int:
24
+ if isinstance(value, bool) or not isinstance(value, Integral) or value < 0:
25
+ raise ValueError(f"{name} must be a nonnegative integer count.")
26
+ return int(value)
experimentpy/design.py ADDED
@@ -0,0 +1,85 @@
1
+ """Fixed-horizon planning for two independent binary outcomes."""
2
+
3
+ from dataclasses import dataclass
4
+ from math import ceil, isfinite, sqrt
5
+
6
+ from scipy.stats import norm
7
+
8
+ from ._validation import finite_real, probability
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class SampleSizeResult:
13
+ """Required independent experimental units, rounded up by arm."""
14
+
15
+ n_control: int
16
+ n_variant: int
17
+ baseline: float
18
+ variant_rate: float
19
+ mde_absolute: float
20
+ alpha: float
21
+ power: float
22
+ ratio: float
23
+
24
+ @property
25
+ def total(self) -> int:
26
+ return self.n_control + self.n_variant
27
+
28
+
29
+ def sample_size_proportions(
30
+ baseline: float,
31
+ mde_absolute: float,
32
+ *,
33
+ alpha: float = 0.05,
34
+ power: float = 0.80,
35
+ ratio: float = 1.0,
36
+ ) -> SampleSizeResult:
37
+ """Plan a two-sided, fixed-horizon, two-proportion z-test.
38
+
39
+ ``baseline`` and ``mde_absolute`` use proportions, not percentages:
40
+ baseline=0.05 and mde_absolute=0.005 plan for 5% versus 5.5%.
41
+ A negative MDE plans for a decrease. ``ratio`` is n_variant/n_control.
42
+
43
+ Uses a normal approximation with pooled null variance and unpooled
44
+ alternative variance, without continuity correction. Power is approximate.
45
+ Assumes independent Bernoulli units and a single planned comparison.
46
+ """
47
+ baseline = probability(baseline, "baseline")
48
+ alpha = probability(alpha, "alpha")
49
+ power = probability(power, "power")
50
+ if power <= max(0.5, alpha):
51
+ raise ValueError("power must exceed both 0.5 and alpha.")
52
+ mde_absolute = finite_real(mde_absolute, "mde_absolute")
53
+ if mde_absolute == 0:
54
+ raise ValueError("mde_absolute must be nonzero.")
55
+ variant = probability(baseline + mde_absolute, "baseline + mde_absolute")
56
+ ratio = finite_real(ratio, "ratio")
57
+ if ratio <= 0:
58
+ raise ValueError("ratio must be positive.")
59
+
60
+ # Weighted event rate across the two planned allocation sizes.
61
+ weight = ratio / (1.0 + ratio)
62
+ pooled = baseline * (1.0 - weight) + variant * weight
63
+ variance_null = pooled * (1.0 - pooled) * (1.0 + 1.0 / ratio)
64
+ variance_alternative = (
65
+ baseline * (1.0 - baseline) + variant * (1.0 - variant) / ratio
66
+ )
67
+ # isf avoids loss of precision from computing 1 - alpha/2.
68
+ critical = norm.isf(alpha / 2.0)
69
+ z_power = norm.ppf(power)
70
+ required = ((critical * sqrt(variance_null)
71
+ + z_power * sqrt(variance_alternative)) / abs(mde_absolute)) ** 2
72
+ if not isfinite(required) or required <= 0:
73
+ raise ValueError("Inputs exceed the supported numerical range.")
74
+ n_control = max(1, ceil(required))
75
+ n_variant = max(1, ceil(ratio * n_control))
76
+ # Explicitly reject sparse designs instead of silently promising accuracy.
77
+ if min(n_control * baseline, n_control * (1 - baseline),
78
+ n_variant * variant, n_variant * (1 - variant)) < 5:
79
+ raise ValueError(
80
+ "The normal approximation requires at least 5 expected events "
81
+ "and non-events per arm; use an exact or simulation-based design."
82
+ )
83
+ return SampleSizeResult(
84
+ n_control, n_variant, baseline, variant, mde_absolute, alpha, power, ratio
85
+ )
experimentpy/py.typed ADDED
File without changes
@@ -0,0 +1,74 @@
1
+ """Sample ratio mismatch checks on assignment counts."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from math import fsum, isclose
6
+
7
+ from scipy.stats import chi2
8
+
9
+ from ._validation import count, finite_real, probability
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SRMResult:
14
+ """Pearson goodness-of-fit result for planned allocation proportions."""
15
+
16
+ labels: tuple[str, ...]
17
+ observed: tuple[int, ...]
18
+ expected: tuple[float, ...]
19
+ statistic: float
20
+ pvalue: float
21
+ degrees_of_freedom: int
22
+ alpha: float
23
+
24
+ @property
25
+ def has_srm(self) -> bool:
26
+ return self.pvalue < self.alpha
27
+
28
+
29
+ def check_srm(
30
+ observed: Mapping[str, int],
31
+ allocation: Mapping[str, float] | None = None,
32
+ *,
33
+ alpha: float = 0.001,
34
+ ) -> SRMResult:
35
+ """Check assignment counts against a prespecified allocation.
36
+
37
+ Defaults to equal allocation. Allocation values must be positive
38
+ proportions summing to 1; keys must match observed exactly. Supports
39
+ two or more arms. Uses Pearson's chi-square with k-1 degrees of freedom.
40
+
41
+ Count each independent randomized unit once, before post-treatment
42
+ filters. A nonsignificant result does not establish experiment validity.
43
+ Raises ValueError if any expected arm count is below 5.
44
+ """
45
+ alpha = probability(alpha, "alpha")
46
+ if not isinstance(observed, Mapping) or len(observed) < 2:
47
+ raise ValueError("observed must map at least two arm labels to counts.")
48
+ labels = tuple(observed)
49
+ if any(not isinstance(label, str) or not label for label in labels):
50
+ raise ValueError("Arm labels must be nonempty strings.")
51
+ counts = tuple(count(observed[label], label) for label in labels)
52
+ total = sum(counts)
53
+ if total == 0:
54
+ raise ValueError("The total observed count must be positive.")
55
+ if allocation is None:
56
+ weights = (1.0 / len(labels),) * len(labels)
57
+ else:
58
+ if not isinstance(allocation, Mapping) or set(allocation) != set(labels):
59
+ raise ValueError("allocation and observed must have identical arm labels.")
60
+ weights = tuple(finite_real(allocation[label], label) for label in labels)
61
+ if any(weight <= 0 for weight in weights):
62
+ raise ValueError("Allocation proportions must be positive.")
63
+ if not isclose(fsum(weights), 1.0, rel_tol=0, abs_tol=1e-12):
64
+ raise ValueError("Allocation proportions must sum to 1.")
65
+ # Remove harmless floating-point summation error only.
66
+ weights = tuple(weight / fsum(weights) for weight in weights)
67
+ expected = tuple(total * weight for weight in weights)
68
+ if min(expected) < 5:
69
+ raise ValueError("Chi-square approximation requires expected counts >= 5 per arm.")
70
+ statistic = fsum((actual - target) ** 2 / target
71
+ for actual, target in zip(counts, expected))
72
+ df = len(labels) - 1
73
+ pvalue = float(chi2.sf(statistic, df))
74
+ return SRMResult(labels, counts, expected, statistic, pvalue, df, alpha)
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: experimentpy
3
+ Version: 0.1.0
4
+ Summary: Statistical tools for online experimentation: sample size and sample ratio mismatch.
5
+ Author: Ubaldo Hervas
6
+ License-Expression: MIT
7
+ Keywords: ab-testing,experimentation,sample-size,srm,statistics
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: scipy>=1.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: build>=1.2; extra == "dev"
19
+ Requires-Dist: twine>=5; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # ExperimentPy
23
+
24
+ Statistical tools for online experimentation.
25
+
26
+ **Version 0.1.0** provides two working tools: sample-size planning for binary
27
+ A/B metrics and sample ratio mismatch (SRM) checks. This library concerns
28
+ statistical experiments; it is independent of the `pytest` software-testing framework.
29
+
30
+ ## Install
31
+
32
+ Python 3.10 or newer is required. SciPy is the only direct runtime dependency.
33
+
34
+ ```bash
35
+ pip install experimentpy
36
+ ```
37
+
38
+ That command becomes available after publication on PyPI. To install the
39
+ included wheel before publication, run this from the extracted project folder:
40
+
41
+ ```bash
42
+ python -m pip install dist/experimentpy-0.1.0-py3-none-any.whl
43
+ ```
44
+
45
+ ## Sample size for a binary A/B metric
46
+
47
+ ```python
48
+ from experimentpy import sample_size_proportions
49
+
50
+ plan = sample_size_proportions(
51
+ baseline=0.05, # 5% control conversion rate
52
+ mde_absolute=0.005, # +0.5 percentage points: 5% -> 5.5%
53
+ alpha=0.05,
54
+ power=0.80,
55
+ ratio=1.0, # variant units / control units
56
+ )
57
+ print(plan.n_control, plan.n_variant, plan.total)
58
+ ```
59
+
60
+ An MDE of `0.005` means **0.5 percentage points**, or a **10% relative lift**
61
+ on a 5% baseline. For a relative MDE, calculate `baseline * relative_lift`
62
+ before calling the function. Negative absolute MDEs plan for decreases.
63
+
64
+ The design uses a two-sided normal approximation for two independent
65
+ proportions, pooled variance under the null and unpooled variance under the
66
+ alternative. With `p0=baseline`, `p1=p0+mde_absolute`, and
67
+ `r=n_variant/n_control`, the unrounded control sample size is:
68
+
69
+ ```text
70
+ p_bar = (p0 + r*p1) / (1+r)
71
+ n_control = (
72
+ (z_(1-alpha/2)*sqrt(p_bar*(1-p_bar)*(1+1/r))
73
+ + z_power*sqrt(p0*(1-p0) + p1*(1-p1)/r)) / abs(p1-p0)
74
+ )**2
75
+ ```
76
+
77
+ Arm sizes are rounded up. Power is approximate; there is no continuity
78
+ correction. Designs with fewer than five expected events or non-events in
79
+ either arm are rejected. This is a single-comparison, fixed-horizon design:
80
+ repeated peeking, multiple comparisons and cluster randomization require
81
+ different procedures. Users, sessions or other units must match the unit
82
+ of independent randomization.
83
+
84
+ ## Sample ratio mismatch
85
+
86
+ ```python
87
+ from experimentpy import check_srm
88
+
89
+ quality = check_srm(
90
+ observed={"control": 5100, "variant": 4900},
91
+ allocation={"control": 0.5, "variant": 0.5},
92
+ alpha=0.001,
93
+ )
94
+ print(f"SRM p-value: {quality.pvalue:.4f}")
95
+ print(f"SRM detected: {quality.has_srm}")
96
+ ```
97
+
98
+ Pearson's goodness-of-fit test compares observed counts with the prespecified
99
+ allocation, with `number_of_arms - 1` degrees of freedom. Omit `allocation`
100
+ for equal allocation. Two or more arms are supported. Allocation proportions
101
+ must be positive and sum to one, and expected counts must be at least five
102
+ per arm. Counts must be nonnegative integers, not event totals or weights.
103
+
104
+ Use each randomized unit once and avoid post-treatment filters. SRM signals
105
+ an allocation discrepancy; it does not identify its cause. Absence of SRM
106
+ does not establish overall experiment validity. The default SRM alpha is
107
+ 0.001 and is separate from the outcome-analysis alpha.
108
+
109
+ Results are dataclasses and can be exported with `dataclasses.asdict(result)`.
110
+
111
+ ## Scope and next steps
112
+
113
+ This release implements sample-size planning and SRM only. Outcome p-values,
114
+ effect intervals, practical significance, variance reduction and
115
+ quasi-experimental methods are planned extensions, not implemented APIs.
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ python -m pip install -e ".[dev]"
121
+ python -m unittest discover -s tests -v
122
+ python -m build
123
+ python -m twine check dist/*
124
+ ```
125
+
126
+ Tests use Python's standard `unittest` framework; no `pytest` dependency is
127
+ needed. See `examples/quickstart.py` for an executable example.
128
+
129
+ ## References
130
+
131
+ - [SciPy Pearson chi-square documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.chisquare.html)
132
+ - [statsmodels two-proportion power documentation](https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.power_proportions_2indep.html)
133
+
134
+ ## License
135
+
136
+ MIT. Copyright (c) 2026 Ubaldo Hervas.
@@ -0,0 +1,10 @@
1
+ experimentpy/__init__.py,sha256=uiDkkRA3mDaDUi9kUgha_TvcAlb4-j-pHazOxZxJVQg,278
2
+ experimentpy/_validation.py,sha256=GYQiD3nQQ-M7X9woxMy8rKlORFSaIZjP-tYehdYiMVQ,841
3
+ experimentpy/design.py,sha256=5efQ1lSPU4HZugHMHrUeU9geXHdtJ1K4CmNC9Hml4jU,3156
4
+ experimentpy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ experimentpy/quality.py,sha256=7zYykXBIxgmQslFxLfAeLtAX9T_B7g_WNrOZFteWb9o,2997
6
+ experimentpy-0.1.0.dist-info/licenses/LICENSE,sha256=aebUfBenioyYLiBCcFPNF4RcQ1hScWz2HeTb37An0cw,1070
7
+ experimentpy-0.1.0.dist-info/METADATA,sha256=zJS_bQEvogFo2zfNvIZcS6tu664hJWHQ7D3Vx8nSz2g,4791
8
+ experimentpy-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ experimentpy-0.1.0.dist-info/top_level.txt,sha256=p5lQGMpMVbm8_G6zf3s2aZ7qMJazweFA6Wa7Ku9F5gM,13
10
+ experimentpy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ubaldo Hervas
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ experimentpy