Skip to content

Commit a942a83

Browse files
committed
Initial v0.1.0: sample size, CUPED, ratio metrics (delta method), mSPRT, CLI
0 parents  commit a942a83

12 files changed

Lines changed: 582 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.10", "3.11", "3.12"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
cache: pip
21+
- name: Install
22+
run: |
23+
python -m pip install --upgrade pip
24+
pip install -e ".[dev]"
25+
- name: Lint
26+
run: ruff check .
27+
- name: Test
28+
run: pytest

.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
__pycache__/
2+
*.py[cod]
3+
.Python
4+
.venv/
5+
venv/
6+
.env
7+
*.egg-info/
8+
build/
9+
dist/
10+
.pytest_cache/
11+
.ruff_cache/
12+
.mypy_cache/
13+
.coverage
14+
.DS_Store
15+
.idea/
16+
.vscode/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Tejas Wavde
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.

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# experiment-toolkit
2+
3+
> Small, well-tested utilities for online controlled experiments.
4+
5+
![CI](https://github.com/wavde/experiment-toolkit/actions/workflows/ci.yml/badge.svg)
6+
![Python](https://img.shields.io/badge/python-3.10+-blue.svg)
7+
![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)
8+
9+
## What's inside
10+
11+
| Module | Purpose |
12+
|--------|---------|
13+
| `sample_size` | Required per-arm sample size for a given MDE, and the inverse |
14+
| `cuped` | Deng et al. (2013) CUPED variance reduction |
15+
| `ratio` | Delta-method variance for ratio metrics (revenue/session, etc.) |
16+
| `sequential` | mSPRT always-valid p-values for peeking-robust experiments |
17+
18+
Every function is tested, typed, and has a reference to the paper it implements.
19+
20+
## Install
21+
22+
```bash
23+
# from source (PyPI release TBD)
24+
pip install git+https://github.com/wavde/experiment-toolkit.git
25+
```
26+
27+
## Quick start
28+
29+
```python
30+
from experiment_toolkit import sample_size_for_mde, apply_cuped, msprt_pvalue
31+
32+
# How many users do I need per arm to detect a 2% lift (sd=1.0)?
33+
n = sample_size_for_mde(mde=0.02, std_dev=1.0, alpha=0.05, power=0.80)
34+
# ~39,000 per arm
35+
36+
# Apply CUPED with a pre-experiment covariate
37+
y_adj = apply_cuped(y, pre_period_y)
38+
39+
# Always-valid p-value — safe to peek
40+
p = msprt_pvalue(delta_hat=0.015, sigma=1.0, n_per_arm=5000, tau=0.05)
41+
```
42+
43+
## CLI
44+
45+
```bash
46+
experiment-toolkit sample-size --mde 0.02 --sd 1.0
47+
# Required per-arm sample size: 39,242
48+
49+
experiment-toolkit mde --n 10000 --sd 1.0
50+
# Detectable effect (MDE): 0.0396
51+
```
52+
53+
## Development
54+
55+
```bash
56+
pip install -e ".[dev]"
57+
pytest
58+
ruff check .
59+
```
60+
61+
## References
62+
63+
- Deng, Xu, Kohavi, Walker (2013) — CUPED
64+
- Deng, Knoblich, Lu (2018) — Delta Method in Metric Analytics
65+
- Johari, Pekelis, Walsh (2015) — Always Valid Inference
66+
- Kohavi, Tang, Xu (2020) — *Trustworthy Online Controlled Experiments*
67+
68+
## License
69+
70+
MIT — see [LICENSE](LICENSE).

pyproject.toml

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "experiment-toolkit"
7+
version = "0.1.0"
8+
description = "Small, well-tested utilities for online controlled experiments: sample size, CUPED, sequential testing, delta-method variance."
9+
readme = "README.md"
10+
license = { file = "LICENSE" }
11+
requires-python = ">=3.10"
12+
authors = [{ name = "Tejas Wavde" }]
13+
keywords = ["experimentation", "ab-testing", "causal-inference", "statistics"]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Intended Audience :: Science/Research",
17+
"License :: OSI Approved :: MIT License",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.10",
20+
"Programming Language :: Python :: 3.11",
21+
"Programming Language :: Python :: 3.12",
22+
"Topic :: Scientific/Engineering :: Mathematics",
23+
]
24+
dependencies = [
25+
"numpy>=1.24",
26+
"scipy>=1.10",
27+
]
28+
29+
[project.optional-dependencies]
30+
dev = ["pytest>=7", "ruff>=0.3", "mypy>=1.8"]
31+
32+
[project.urls]
33+
Homepage = "https://github.com/wavde/experiment-toolkit"
34+
Issues = "https://github.com/wavde/experiment-toolkit/issues"
35+
36+
[project.scripts]
37+
experiment-toolkit = "experiment_toolkit.cli:main"
38+
39+
[tool.hatch.build.targets.wheel]
40+
packages = ["src/experiment_toolkit"]
41+
42+
[tool.ruff]
43+
line-length = 100
44+
target-version = "py310"
45+
46+
[tool.ruff.lint]
47+
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM"]
48+
49+
[tool.pytest.ini_options]
50+
testpaths = ["tests"]
51+
addopts = "-q"

src/experiment_toolkit/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""experiment-toolkit: small utilities for online controlled experiments."""
2+
3+
from experiment_toolkit.cuped import apply_cuped, compute_theta
4+
from experiment_toolkit.ratio import ratio_metric_variance
5+
from experiment_toolkit.sample_size import mde_for_n, sample_size_for_mde
6+
from experiment_toolkit.sequential import msprt_pvalue
7+
8+
__version__ = "0.1.0"
9+
10+
__all__ = [
11+
"apply_cuped",
12+
"compute_theta",
13+
"mde_for_n",
14+
"msprt_pvalue",
15+
"ratio_metric_variance",
16+
"sample_size_for_mde",
17+
]

src/experiment_toolkit/cli.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""Minimal CLI for experiment-toolkit.
2+
3+
Example usage:
4+
experiment-toolkit sample-size --mde 0.02 --sd 1.0
5+
experiment-toolkit mde --n 10000 --sd 1.0
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import argparse
11+
import sys
12+
13+
from experiment_toolkit.sample_size import mde_for_n, sample_size_for_mde
14+
15+
16+
def main(argv: list[str] | None = None) -> int:
17+
parser = argparse.ArgumentParser(prog="experiment-toolkit")
18+
sub = parser.add_subparsers(dest="cmd", required=True)
19+
20+
p_ss = sub.add_parser("sample-size", help="compute required per-arm sample size")
21+
p_ss.add_argument("--mde", type=float, required=True, help="minimum detectable effect")
22+
p_ss.add_argument("--sd", type=float, required=True, help="outcome standard deviation")
23+
p_ss.add_argument("--alpha", type=float, default=0.05)
24+
p_ss.add_argument("--power", type=float, default=0.80)
25+
26+
p_mde = sub.add_parser("mde", help="compute detectable effect given sample size")
27+
p_mde.add_argument("--n", type=int, required=True, help="per-arm sample size")
28+
p_mde.add_argument("--sd", type=float, required=True)
29+
p_mde.add_argument("--alpha", type=float, default=0.05)
30+
p_mde.add_argument("--power", type=float, default=0.80)
31+
32+
args = parser.parse_args(argv)
33+
34+
if args.cmd == "sample-size":
35+
n = sample_size_for_mde(args.mde, args.sd, args.alpha, args.power)
36+
print(f"Required per-arm sample size: {n:,}")
37+
elif args.cmd == "mde":
38+
mde = mde_for_n(args.n, args.sd, args.alpha, args.power)
39+
print(f"Detectable effect (MDE): {mde:.4f}")
40+
return 0
41+
42+
43+
if __name__ == "__main__":
44+
sys.exit(main())

src/experiment_toolkit/cuped.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""CUPED variance reduction — see Deng et al. (2013)."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
from numpy.typing import ArrayLike
7+
8+
9+
def compute_theta(y: ArrayLike, x: ArrayLike) -> float:
10+
"""Optimal CUPED coefficient theta = Cov(Y, X) / Var(X)."""
11+
y_arr = np.asarray(y, dtype=float)
12+
x_arr = np.asarray(x, dtype=float)
13+
var_x = np.var(x_arr, ddof=1)
14+
if var_x == 0:
15+
return 0.0
16+
return float(np.cov(y_arr, x_arr, ddof=1)[0, 1] / var_x)
17+
18+
19+
def apply_cuped(
20+
y: ArrayLike,
21+
x: ArrayLike,
22+
theta: float | None = None,
23+
) -> np.ndarray:
24+
"""Return CUPED-adjusted outcome: Y - theta * (X - mean(X))."""
25+
y_arr = np.asarray(y, dtype=float)
26+
x_arr = np.asarray(x, dtype=float)
27+
if theta is None:
28+
theta = compute_theta(y_arr, x_arr)
29+
return y_arr - theta * (x_arr - x_arr.mean())

src/experiment_toolkit/ratio.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Variance of ratio metrics via the delta method.
3+
4+
Real product metrics are often ratios (revenue / session, clicks / impression,
5+
minutes / DAU). A naive two-sample t-test on per-user ratios is usually wrong
6+
because the numerator and denominator are correlated.
7+
8+
Delta-method approximation for R = E[N] / E[D] with per-user (n_i, d_i):
9+
10+
Var(R) ~= (1/mean(d))^2 * ( Var(N) - 2*R*Cov(N,D) + R^2 * Var(D) )
11+
12+
Reference: Deng, Knoblich, Lu (2018), "Applying the Delta Method in Metric
13+
Analytics: A Practical Guide with Novel Ideas."
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import numpy as np
19+
from numpy.typing import ArrayLike
20+
21+
22+
def ratio_metric_variance(numerator: ArrayLike, denominator: ArrayLike) -> tuple[float, float]:
23+
"""
24+
Return (ratio_estimate, standard_error) for the per-unit ratio metric.
25+
26+
Parameters
27+
----------
28+
numerator : per-unit numerator values (e.g., minutes watched per user)
29+
denominator : per-unit denominator values (e.g., sessions per user)
30+
31+
Returns
32+
-------
33+
(ratio, standard_error)
34+
"""
35+
n = np.asarray(numerator, dtype=float)
36+
d = np.asarray(denominator, dtype=float)
37+
if n.shape != d.shape:
38+
raise ValueError("numerator and denominator must have the same shape")
39+
if len(n) < 2:
40+
raise ValueError("need at least 2 observations")
41+
42+
mean_n = n.mean()
43+
mean_d = d.mean()
44+
if mean_d == 0:
45+
raise ValueError("mean of denominator is zero; ratio undefined")
46+
47+
ratio = mean_n / mean_d
48+
49+
# Delta-method variance of the sample ratio.
50+
var_n = np.var(n, ddof=1)
51+
var_d = np.var(d, ddof=1)
52+
cov_nd = np.cov(n, d, ddof=1)[0, 1]
53+
54+
var_ratio = (var_n - 2 * ratio * cov_nd + ratio**2 * var_d) / (len(n) * mean_d**2)
55+
se = float(np.sqrt(max(var_ratio, 0.0)))
56+
57+
return float(ratio), se

0 commit comments

Comments
 (0)