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.
- experimentpy-0.1.0/CHANGELOG.md +9 -0
- experimentpy-0.1.0/LICENSE +21 -0
- experimentpy-0.1.0/MANIFEST.in +3 -0
- experimentpy-0.1.0/PKG-INFO +136 -0
- experimentpy-0.1.0/README.md +115 -0
- experimentpy-0.1.0/SUBIR_A_PYPI.md +9 -0
- experimentpy-0.1.0/examples/quickstart.py +9 -0
- experimentpy-0.1.0/pyproject.toml +31 -0
- experimentpy-0.1.0/setup.cfg +4 -0
- experimentpy-0.1.0/src/experimentpy/__init__.py +9 -0
- experimentpy-0.1.0/src/experimentpy/_validation.py +26 -0
- experimentpy-0.1.0/src/experimentpy/design.py +85 -0
- experimentpy-0.1.0/src/experimentpy/py.typed +0 -0
- experimentpy-0.1.0/src/experimentpy/quality.py +74 -0
- experimentpy-0.1.0/src/experimentpy.egg-info/PKG-INFO +136 -0
- experimentpy-0.1.0/src/experimentpy.egg-info/SOURCES.txt +18 -0
- experimentpy-0.1.0/src/experimentpy.egg-info/dependency_links.txt +1 -0
- experimentpy-0.1.0/src/experimentpy.egg-info/requires.txt +5 -0
- experimentpy-0.1.0/src/experimentpy.egg-info/top_level.txt +1 -0
- experimentpy-0.1.0/tests/test_statistics.py +126 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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()
|