experimentpy 0.1.0__tar.gz

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
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-07
4
+
5
+ - Binary-metric sample-size planning for independent two-arm A/B tests.
6
+ - Equal and unequal allocation; absolute positive or negative MDE.
7
+ - Pearson chi-square SRM checks for two or more arms.
8
+ - Typed result dataclasses, input validation and executable examples.
9
+ - Reference, edge-case and independent simulation tests.
@@ -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,3 @@
1
+ include LICENSE README.md CHANGELOG.md SUBIR_A_PYPI.md
2
+ recursive-include tests *.py
3
+ recursive-include examples *.py
@@ -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,115 @@
1
+ # ExperimentPy
2
+
3
+ Statistical tools for online experimentation.
4
+
5
+ **Version 0.1.0** provides two working tools: sample-size planning for binary
6
+ A/B metrics and sample ratio mismatch (SRM) checks. This library concerns
7
+ statistical experiments; it is independent of the `pytest` software-testing framework.
8
+
9
+ ## Install
10
+
11
+ Python 3.10 or newer is required. SciPy is the only direct runtime dependency.
12
+
13
+ ```bash
14
+ pip install experimentpy
15
+ ```
16
+
17
+ That command becomes available after publication on PyPI. To install the
18
+ included wheel before publication, run this from the extracted project folder:
19
+
20
+ ```bash
21
+ python -m pip install dist/experimentpy-0.1.0-py3-none-any.whl
22
+ ```
23
+
24
+ ## Sample size for a binary A/B metric
25
+
26
+ ```python
27
+ from experimentpy import sample_size_proportions
28
+
29
+ plan = sample_size_proportions(
30
+ baseline=0.05, # 5% control conversion rate
31
+ mde_absolute=0.005, # +0.5 percentage points: 5% -> 5.5%
32
+ alpha=0.05,
33
+ power=0.80,
34
+ ratio=1.0, # variant units / control units
35
+ )
36
+ print(plan.n_control, plan.n_variant, plan.total)
37
+ ```
38
+
39
+ An MDE of `0.005` means **0.5 percentage points**, or a **10% relative lift**
40
+ on a 5% baseline. For a relative MDE, calculate `baseline * relative_lift`
41
+ before calling the function. Negative absolute MDEs plan for decreases.
42
+
43
+ The design uses a two-sided normal approximation for two independent
44
+ proportions, pooled variance under the null and unpooled variance under the
45
+ alternative. With `p0=baseline`, `p1=p0+mde_absolute`, and
46
+ `r=n_variant/n_control`, the unrounded control sample size is:
47
+
48
+ ```text
49
+ p_bar = (p0 + r*p1) / (1+r)
50
+ n_control = (
51
+ (z_(1-alpha/2)*sqrt(p_bar*(1-p_bar)*(1+1/r))
52
+ + z_power*sqrt(p0*(1-p0) + p1*(1-p1)/r)) / abs(p1-p0)
53
+ )**2
54
+ ```
55
+
56
+ Arm sizes are rounded up. Power is approximate; there is no continuity
57
+ correction. Designs with fewer than five expected events or non-events in
58
+ either arm are rejected. This is a single-comparison, fixed-horizon design:
59
+ repeated peeking, multiple comparisons and cluster randomization require
60
+ different procedures. Users, sessions or other units must match the unit
61
+ of independent randomization.
62
+
63
+ ## Sample ratio mismatch
64
+
65
+ ```python
66
+ from experimentpy import check_srm
67
+
68
+ quality = check_srm(
69
+ observed={"control": 5100, "variant": 4900},
70
+ allocation={"control": 0.5, "variant": 0.5},
71
+ alpha=0.001,
72
+ )
73
+ print(f"SRM p-value: {quality.pvalue:.4f}")
74
+ print(f"SRM detected: {quality.has_srm}")
75
+ ```
76
+
77
+ Pearson's goodness-of-fit test compares observed counts with the prespecified
78
+ allocation, with `number_of_arms - 1` degrees of freedom. Omit `allocation`
79
+ for equal allocation. Two or more arms are supported. Allocation proportions
80
+ must be positive and sum to one, and expected counts must be at least five
81
+ per arm. Counts must be nonnegative integers, not event totals or weights.
82
+
83
+ Use each randomized unit once and avoid post-treatment filters. SRM signals
84
+ an allocation discrepancy; it does not identify its cause. Absence of SRM
85
+ does not establish overall experiment validity. The default SRM alpha is
86
+ 0.001 and is separate from the outcome-analysis alpha.
87
+
88
+ Results are dataclasses and can be exported with `dataclasses.asdict(result)`.
89
+
90
+ ## Scope and next steps
91
+
92
+ This release implements sample-size planning and SRM only. Outcome p-values,
93
+ effect intervals, practical significance, variance reduction and
94
+ quasi-experimental methods are planned extensions, not implemented APIs.
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ python -m pip install -e ".[dev]"
100
+ python -m unittest discover -s tests -v
101
+ python -m build
102
+ python -m twine check dist/*
103
+ ```
104
+
105
+ Tests use Python's standard `unittest` framework; no `pytest` dependency is
106
+ needed. See `examples/quickstart.py` for an executable example.
107
+
108
+ ## References
109
+
110
+ - [SciPy Pearson chi-square documentation](https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.chisquare.html)
111
+ - [statsmodels two-proportion power documentation](https://www.statsmodels.org/stable/generated/statsmodels.stats.proportion.power_proportions_2indep.html)
112
+
113
+ ## License
114
+
115
+ MIT. Copyright (c) 2026 Ubaldo Hervas.
@@ -0,0 +1,9 @@
1
+ # Publicación de ExperimentPy 0.1.0
2
+
3
+ Esta carpeta contiene el código y los archivos de distribución en `dist/`.
4
+ El nombre queda registrado al aceptar PyPI la primera subida; disponer de
5
+ los archivos localmente no lo reserva.
6
+
7
+ La distribución se publica mediante Twine usando la cuenta del propietario.
8
+ La autenticación de subida usa un token de PyPI. No añadas ese token al código,
9
+ al repositorio ni a los archivos de distribución.
@@ -0,0 +1,9 @@
1
+ """Run after installing experimentpy: python examples/quickstart.py."""
2
+
3
+ from experimentpy import check_srm, sample_size_proportions
4
+
5
+ plan = sample_size_proportions(baseline=0.05, mde_absolute=0.005)
6
+ print(f"Control: {plan.n_control:,}; variant: {plan.n_variant:,}; total: {plan.total:,}")
7
+
8
+ quality = check_srm({"control": 5100, "variant": 4900})
9
+ print(f"SRM p-value: {quality.pvalue:.4f}; detected: {quality.has_srm}")
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "experimentpy"
7
+ version = "0.1.0"
8
+ description = "Statistical tools for online experimentation: sample size and sample ratio mismatch."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Ubaldo Hervas"}]
14
+ keywords = ["ab-testing", "experimentation", "sample-size", "srm", "statistics"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Scientific/Engineering :: Mathematics",
21
+ ]
22
+ dependencies = ["scipy>=1.10"]
23
+
24
+ [project.optional-dependencies]
25
+ dev = ["build>=1.2", "twine>=5"]
26
+
27
+ [tool.setuptools.packages.find]
28
+ where = ["src"]
29
+
30
+ [tool.setuptools.package-data]
31
+ experimentpy = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)
@@ -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
+ )
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,18 @@
1
+ CHANGELOG.md
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ SUBIR_A_PYPI.md
6
+ pyproject.toml
7
+ examples/quickstart.py
8
+ src/experimentpy/__init__.py
9
+ src/experimentpy/_validation.py
10
+ src/experimentpy/design.py
11
+ src/experimentpy/py.typed
12
+ src/experimentpy/quality.py
13
+ src/experimentpy.egg-info/PKG-INFO
14
+ src/experimentpy.egg-info/SOURCES.txt
15
+ src/experimentpy.egg-info/dependency_links.txt
16
+ src/experimentpy.egg-info/requires.txt
17
+ src/experimentpy.egg-info/top_level.txt
18
+ tests/test_statistics.py
@@ -0,0 +1,5 @@
1
+ scipy>=1.10
2
+
3
+ [dev]
4
+ build>=1.2
5
+ twine>=5
@@ -0,0 +1 @@
1
+ experimentpy
@@ -0,0 +1,126 @@
1
+ """Reference checks, invalid inputs, and independent Monte Carlo checks."""
2
+
3
+ import math
4
+ import unittest
5
+
6
+ import numpy as np
7
+ from scipy.stats import chisquare, norm
8
+
9
+ from experimentpy import __version__, check_srm, sample_size_proportions
10
+
11
+
12
+ class SampleSizeTests(unittest.TestCase):
13
+ def test_balanced_reference_design(self):
14
+ # Standard 5% -> 6% normal-approximation design: ~8150 per arm.
15
+ result = sample_size_proportions(0.05, 0.01)
16
+ self.assertTrue(8140 <= result.n_control <= 8160)
17
+ self.assertEqual(result.n_control, result.n_variant)
18
+ self.assertEqual(result.total, result.n_control * 2)
19
+
20
+ def test_mde_and_power_behaviour(self):
21
+ base = sample_size_proportions(0.05, 0.005)
22
+ self.assertGreater(base.total, sample_size_proportions(0.05, 0.01).total)
23
+ self.assertGreater(sample_size_proportions(0.05, 0.005, power=0.9).total, base.total)
24
+ self.assertGreater(sample_size_proportions(0.05, 0.005, alpha=0.01).total, base.total)
25
+
26
+ def test_unequal_allocation(self):
27
+ result = sample_size_proportions(0.10, 0.02, ratio=2)
28
+ self.assertEqual(result.n_variant, 2 * result.n_control)
29
+
30
+ def test_decrease_and_swapped_arms(self):
31
+ increase = sample_size_proportions(0.05, 0.01)
32
+ decrease = sample_size_proportions(0.06, -0.01)
33
+ self.assertEqual(increase.total, decrease.total)
34
+ self.assertLess(decrease.variant_rate, decrease.baseline)
35
+
36
+ def test_invalid_inputs(self):
37
+ cases = [
38
+ dict(baseline=0), dict(baseline=1), dict(baseline=True),
39
+ dict(baseline=float('nan')), dict(mde_absolute=0),
40
+ dict(mde_absolute=1), dict(mde_absolute=-0.1),
41
+ dict(mde_absolute='0.01'), dict(alpha=0), dict(alpha=float('inf')),
42
+ dict(power=1), dict(power=0.01), dict(power=0.5), dict(ratio=0), dict(ratio=-1),
43
+ ]
44
+ for case in cases:
45
+ args = dict(baseline=0.05, mde_absolute=0.005)
46
+ args.update(case)
47
+ with self.subTest(case=case), self.assertRaises(ValueError):
48
+ sample_size_proportions(**args)
49
+
50
+ def test_sparse_design_rejected(self):
51
+ with self.assertRaises(ValueError):
52
+ sample_size_proportions(0.000001, 0.1)
53
+
54
+ def test_power_with_independent_binomial_simulation(self):
55
+ # Independent generator and rejection rule check the complete design.
56
+ rng = np.random.default_rng(20261107)
57
+ for p0, delta, ratio in [(0.05, 0.01, 1), (0.1, 0.02, 2), (0.1, -0.02, 0.5)]:
58
+ with self.subTest(p0=p0, delta=delta, ratio=ratio):
59
+ plan = sample_size_proportions(p0, delta, ratio=ratio)
60
+ x0 = rng.binomial(plan.n_control, p0, size=30000)
61
+ x1 = rng.binomial(plan.n_variant, p0 + delta, size=30000)
62
+ pooled = (x0 + x1) / plan.total
63
+ se = np.sqrt(pooled * (1 - pooled) *
64
+ (1 / plan.n_control + 1 / plan.n_variant))
65
+ z = (x1 / plan.n_variant - x0 / plan.n_control) / se
66
+ empirical = np.mean(np.abs(z) > norm.isf(0.025))
67
+ self.assertLess(abs(empirical - 0.80), 0.02)
68
+
69
+
70
+ class SRMTests(unittest.TestCase):
71
+ def test_balanced_allocation(self):
72
+ result = check_srm({'control': 5000, 'variant': 5000})
73
+ self.assertEqual(result.statistic, 0)
74
+ self.assertEqual(result.pvalue, 1)
75
+ self.assertFalse(result.has_srm)
76
+
77
+ def test_hand_calculated_reference(self):
78
+ result = check_srm({'control': 600, 'variant': 400})
79
+ self.assertAlmostEqual(result.statistic, 40)
80
+ self.assertAlmostEqual(result.pvalue, math.erfc(math.sqrt(20)), places=20)
81
+ self.assertTrue(result.has_srm)
82
+
83
+ def test_unequal_allocation_and_mapping_order(self):
84
+ result = check_srm({'a': 200, 'b': 800}, {'b': 0.8, 'a': 0.2})
85
+ self.assertEqual(result.expected, (200., 800.))
86
+ self.assertEqual(result.pvalue, 1)
87
+
88
+ def test_multiple_arms_against_scipy(self):
89
+ result = check_srm({'a': 110, 'b': 190, 'c': 300},
90
+ {'a': 1/6, 'b': 1/3, 'c': 1/2})
91
+ reference = chisquare([110, 190, 300], [100, 200, 300])
92
+ self.assertEqual(result.degrees_of_freedom, 2)
93
+ self.assertAlmostEqual(result.statistic, reference.statistic)
94
+ self.assertAlmostEqual(result.pvalue, reference.pvalue)
95
+
96
+ def test_zero_observed_arm_is_supported(self):
97
+ self.assertTrue(check_srm({'a': 0, 'b': 100}).has_srm)
98
+
99
+ def test_numpy_integer_counts(self):
100
+ self.assertEqual(check_srm({'a': np.int64(50), 'b': np.int64(50)}).pvalue, 1)
101
+
102
+ def test_invalid_counts(self):
103
+ cases = [{}, {'a': 10}, {'a': 0, 'b': 0}, {'a': 1, 'b': 1},
104
+ {'a': -1, 'b': 100}, {'a': 1.5, 'b': 100},
105
+ {'a': True, 'b': 100}, {'a': float('nan'), 'b': 100},
106
+ {'': 100, 'b': 100}]
107
+ for case in cases:
108
+ with self.subTest(case=case), self.assertRaises(ValueError):
109
+ check_srm(case)
110
+
111
+ def test_invalid_allocations(self):
112
+ cases = [{'a': 50, 'b': 50}, {'a': 0, 'b': 1},
113
+ {'a': -0.1, 'b': 1.1}, {'a': 0.5, 'c': 0.5},
114
+ {'a': float('nan'), 'b': 0.5}]
115
+ for case in cases:
116
+ with self.subTest(case=case), self.assertRaises(ValueError):
117
+ check_srm({'a': 100, 'b': 100}, case)
118
+
119
+
120
+ class PackageTests(unittest.TestCase):
121
+ def test_version(self):
122
+ self.assertEqual(__version__, '0.1.0')
123
+
124
+
125
+ if __name__ == '__main__':
126
+ unittest.main()