exactpower 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.
- exactpower-0.1.0/LICENSE +21 -0
- exactpower-0.1.0/PKG-INFO +46 -0
- exactpower-0.1.0/README.md +27 -0
- exactpower-0.1.0/pyproject.toml +26 -0
- exactpower-0.1.0/setup.cfg +4 -0
- exactpower-0.1.0/src/exactpower/__init__.py +7 -0
- exactpower-0.1.0/src/exactpower/_fisher.py +65 -0
- exactpower-0.1.0/src/exactpower/_power.py +133 -0
- exactpower-0.1.0/src/exactpower/_validate.py +78 -0
- exactpower-0.1.0/src/exactpower.egg-info/PKG-INFO +46 -0
- exactpower-0.1.0/src/exactpower.egg-info/SOURCES.txt +13 -0
- exactpower-0.1.0/src/exactpower.egg-info/dependency_links.txt +1 -0
- exactpower-0.1.0/src/exactpower.egg-info/requires.txt +2 -0
- exactpower-0.1.0/src/exactpower.egg-info/top_level.txt +1 -0
- exactpower-0.1.0/tests/test_free.py +131 -0
exactpower-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Groundplane Labs
|
|
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,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exactpower
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Exact power of Fisher's exact test for a planned 2x2 study (free core)
|
|
5
|
+
Author: Groundplane Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://groundplane0.gumroad.com/l/tyevqe
|
|
8
|
+
Keywords: power analysis,sample size,fisher exact test,exact test,statistics,biostatistics
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# exactpower
|
|
21
|
+
|
|
22
|
+
Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
|
|
23
|
+
|
|
24
|
+
**The problem**
|
|
25
|
+
You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
|
|
26
|
+
|
|
27
|
+
**What it does**
|
|
28
|
+
`exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
|
|
29
|
+
|
|
30
|
+
**Limits**
|
|
31
|
+
Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
|
|
32
|
+
|
|
33
|
+
**Install**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install exactpower
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Needs Python 3.9 or newer, NumPy, and SciPy.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from exactpower import exact_power_fisher
|
|
43
|
+
print(exact_power_fisher(60, 60, 0.55, 0.30))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# exactpower
|
|
2
|
+
|
|
3
|
+
Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
|
|
4
|
+
|
|
5
|
+
**The problem**
|
|
6
|
+
You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
|
|
7
|
+
|
|
8
|
+
**What it does**
|
|
9
|
+
`exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
|
|
10
|
+
|
|
11
|
+
**Limits**
|
|
12
|
+
Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
|
|
13
|
+
|
|
14
|
+
**Install**
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install exactpower
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Needs Python 3.9 or newer, NumPy, and SciPy.
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from exactpower import exact_power_fisher
|
|
24
|
+
print(exact_power_fisher(60, 60, 0.55, 0.30))
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "exactpower"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Exact power of Fisher's exact test for a planned 2x2 study (free core)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [{name = "Groundplane Labs"}]
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = ["numpy", "scipy"]
|
|
14
|
+
keywords = ["power analysis", "sample size", "fisher exact test", "exact test", "statistics", "biostatistics"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"Topic :: Scientific/Engineering :: Mathematics",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://groundplane0.gumroad.com/l/tyevqe"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["src"]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Vectorized floating-point kernels for the free exactpower core.
|
|
2
|
+
|
|
3
|
+
:func:`hypergeom_pmf` builds the hypergeometric null pmf in log-space
|
|
4
|
+
(via ``scipy.special.gammaln``) with the mode subtracted before
|
|
5
|
+
exponentiating, so the result is finite and correctly normalized for
|
|
6
|
+
large N. :func:`minlike_pvalues` turns a pmf into two-sided
|
|
7
|
+
(minimum-likelihood) p-values, grouping probabilities that agree to
|
|
8
|
+
1e-9 relative as ties (the hypergeometric pmf has exact rational ties
|
|
9
|
+
that floating point renders as differing by ~1e-17).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.special import gammaln
|
|
14
|
+
|
|
15
|
+
__all__ = ["hypergeom_pmf", "minlike_pvalues"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hypergeom_pmf(N, K, n):
|
|
19
|
+
"""Pmf of X ~ Hypergeometric(N, K, n) over its full support.
|
|
20
|
+
|
|
21
|
+
Returns ``(xs, pmf)`` with ``xs`` the support points (int array) and
|
|
22
|
+
``pmf`` the probabilities (float array, sums to 1).
|
|
23
|
+
"""
|
|
24
|
+
lo = max(0, n + K - N)
|
|
25
|
+
hi = min(n, K)
|
|
26
|
+
xs = np.arange(lo, hi + 1)
|
|
27
|
+
if xs.size == 1:
|
|
28
|
+
return xs, np.ones(1)
|
|
29
|
+
x = xs.astype(np.float64)
|
|
30
|
+
# log P(X = x) = log C(K, x) + log C(N-K, n-x) - log C(N, n).
|
|
31
|
+
# All gammaln arguments are >= 1 on the support, so this is safe.
|
|
32
|
+
logp = (
|
|
33
|
+
gammaln(K + 1) - gammaln(x + 1) - gammaln(K - x + 1)
|
|
34
|
+
+ gammaln(N - K + 1) - gammaln(n - x + 1) - gammaln(N - K - n + x + 1)
|
|
35
|
+
- (gammaln(N + 1) - gammaln(n + 1) - gammaln(N - n + 1))
|
|
36
|
+
)
|
|
37
|
+
logp -= logp.max()
|
|
38
|
+
pmf = np.exp(logp)
|
|
39
|
+
total = pmf.sum()
|
|
40
|
+
if not np.isfinite(total) or total <= 0.0: # pragma: no cover
|
|
41
|
+
raise ArithmeticError("hypergeom_pmf failed to normalize")
|
|
42
|
+
pmf /= total
|
|
43
|
+
return xs, pmf
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def minlike_pvalues(pmf):
|
|
47
|
+
"""Two-sided (minimum-likelihood) p-value for each support point.
|
|
48
|
+
|
|
49
|
+
For support point i, returns the total probability of all support
|
|
50
|
+
points whose probability is <= pmf[i]. Probabilities that agree to
|
|
51
|
+
1e-9 relative are treated as tied.
|
|
52
|
+
"""
|
|
53
|
+
pmf = np.asarray(pmf, dtype=float)
|
|
54
|
+
order = np.argsort(pmf)
|
|
55
|
+
s = pmf[order]
|
|
56
|
+
prev = s[:-1]
|
|
57
|
+
cur = s[1:]
|
|
58
|
+
same = cur <= prev * (1.0 + 1e-9) + 1e-300
|
|
59
|
+
gid = np.concatenate(([0], np.cumsum(~same)))
|
|
60
|
+
gsum = np.bincount(gid, weights=s)
|
|
61
|
+
gc = np.cumsum(gsum)
|
|
62
|
+
p_sorted = gc[gid]
|
|
63
|
+
inv = np.empty_like(order)
|
|
64
|
+
inv[order] = np.arange(len(s))
|
|
65
|
+
return p_sorted[inv]
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Exact power of Fisher's exact test for a planned 2x2 study (free core).
|
|
2
|
+
|
|
3
|
+
The power law
|
|
4
|
+
-------------
|
|
5
|
+
Group sizes ``n1`` and ``n2`` are fixed by the study design; the event
|
|
6
|
+
counts are random. The alternative is the **binomial-product law**
|
|
7
|
+
|
|
8
|
+
A ~ Binomial(n1, p1), B ~ Binomial(n2, p2), independent,
|
|
9
|
+
|
|
10
|
+
i.e. the exact law of the data in a two-group comparative study. The
|
|
11
|
+
test itself conditions on the total number of events, as Fisher's test
|
|
12
|
+
always does; the power does not, because the total is not fixed by the
|
|
13
|
+
design.
|
|
14
|
+
|
|
15
|
+
Exact power is then the expected rejection probability under the
|
|
16
|
+
alternative:
|
|
17
|
+
|
|
18
|
+
power = sum_{a=0..n1} sum_{b=0..n2}
|
|
19
|
+
Bin(n1,p1)(a) * Bin(n2,p2)(b) * 1{p_F(a, b) <= alpha},
|
|
20
|
+
|
|
21
|
+
where ``p_F(a, b)`` is the two-sided Fisher p-value of the 2x2 table
|
|
22
|
+
``[[a, n1-a], [b, n2-b]]``.
|
|
23
|
+
|
|
24
|
+
Computation groups outcome pairs by the total event count m1 = a + b;
|
|
25
|
+
for each m1 the hypergeometric null pmf is built once and the two-sided
|
|
26
|
+
p-values come from a sort/cumulative-sum pass.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from scipy.stats import binom
|
|
31
|
+
|
|
32
|
+
from ._fisher import hypergeom_pmf, minlike_pvalues
|
|
33
|
+
from ._validate import (
|
|
34
|
+
check_alpha,
|
|
35
|
+
check_alternative,
|
|
36
|
+
check_group_size,
|
|
37
|
+
check_prob,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = ["exact_power_fisher", "FREE_MAX_TOTAL_N"]
|
|
41
|
+
|
|
42
|
+
#: Hard cap on n1 + n2 in the free version. Beyond this the function
|
|
43
|
+
#: raises ValueError immediately instead of attempting the calculation.
|
|
44
|
+
FREE_MAX_TOTAL_N = 1000
|
|
45
|
+
|
|
46
|
+
_PAID_URL = "https://groundplane0.gumroad.com/l/tyevqe"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_probs(p1, p2, odds_ratio):
|
|
50
|
+
"""Turn (p1, p2) or (odds_ratio, p2) into a validated (p1, p2) pair."""
|
|
51
|
+
if odds_ratio is not None:
|
|
52
|
+
if p1 is not None:
|
|
53
|
+
raise ValueError("specify either p1 or odds_ratio, not both")
|
|
54
|
+
if p2 is None:
|
|
55
|
+
raise ValueError("odds_ratio requires p2 (the reference-group probability)")
|
|
56
|
+
import math
|
|
57
|
+
|
|
58
|
+
if not isinstance(odds_ratio, (int, float)) or isinstance(odds_ratio, bool):
|
|
59
|
+
raise ValueError(f"odds_ratio must be a positive number, got {odds_ratio!r}")
|
|
60
|
+
odds_ratio = float(odds_ratio)
|
|
61
|
+
if math.isnan(odds_ratio) or not (odds_ratio > 0.0) or math.isinf(odds_ratio):
|
|
62
|
+
raise ValueError(f"odds_ratio must be a positive finite number, got {odds_ratio!r}")
|
|
63
|
+
p2 = check_prob(p2, "p2")
|
|
64
|
+
# OR = [p1/(1-p1)] / [p2/(1-p2)] => p1 = OR*p2 / (1-p2+OR*p2)
|
|
65
|
+
p1 = odds_ratio * p2 / (1.0 - p2 + odds_ratio * p2)
|
|
66
|
+
return p1, p2
|
|
67
|
+
if p1 is None or p2 is None:
|
|
68
|
+
raise ValueError("specify p1 and p2, or odds_ratio together with p2")
|
|
69
|
+
return check_prob(p1, "p1"), check_prob(p2, "p2")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def exact_power_fisher(n1, n2, p1=None, p2=None, alpha=0.05,
|
|
73
|
+
alternative="two-sided", *, odds_ratio=None):
|
|
74
|
+
"""Exact power of Fisher's exact test for comparing two proportions.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
n1, n2 : int
|
|
79
|
+
Group sizes (number of subjects per group), each >= 1. The
|
|
80
|
+
free version requires ``n1 + n2 <= 1000``.
|
|
81
|
+
p1, p2 : float
|
|
82
|
+
Assumed true event probabilities in group 1 and group 2, each
|
|
83
|
+
in [0, 1]. Alternatively pass ``odds_ratio`` with ``p2`` and
|
|
84
|
+
leave ``p1`` unset.
|
|
85
|
+
alpha : float, default 0.05
|
|
86
|
+
Significance level, strictly between 0 and 1.
|
|
87
|
+
alternative : {"two-sided"}, default "two-sided"
|
|
88
|
+
Only the two-sided test is included in the free version.
|
|
89
|
+
One-sided power is part of the full version.
|
|
90
|
+
odds_ratio : float, optional
|
|
91
|
+
Assumed true odds ratio ``[p1/(1-p1)] / [p2/(1-p2)]``, a
|
|
92
|
+
positive finite number. Requires ``p2``; ``p1`` is derived
|
|
93
|
+
from it.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
float
|
|
98
|
+
The exact rejection probability under the binomial-product
|
|
99
|
+
alternative described in the module docstring.
|
|
100
|
+
|
|
101
|
+
Raises
|
|
102
|
+
------
|
|
103
|
+
ValueError
|
|
104
|
+
On any invalid input, when ``n1 + n2`` exceeds 1000, or when a
|
|
105
|
+
one-sided alternative is requested.
|
|
106
|
+
"""
|
|
107
|
+
n1 = check_group_size(n1, "n1")
|
|
108
|
+
n2 = check_group_size(n2, "n2")
|
|
109
|
+
if n1 + n2 > FREE_MAX_TOTAL_N:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"Free version is capped at n1 + n2 <= {FREE_MAX_TOTAL_N}. "
|
|
112
|
+
f"The full version ($30, one-time) goes to n1 + n2 = 5000: {_PAID_URL}"
|
|
113
|
+
)
|
|
114
|
+
alternative = check_alternative(alternative)
|
|
115
|
+
if alternative != "two-sided":
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Free version includes two-sided power only (got {alternative!r}). "
|
|
118
|
+
f"The full version ($30, one-time) adds one-sided power: {_PAID_URL}"
|
|
119
|
+
)
|
|
120
|
+
p1, p2 = _resolve_probs(p1, p2, odds_ratio)
|
|
121
|
+
alpha = check_alpha(alpha)
|
|
122
|
+
|
|
123
|
+
b1 = binom.pmf(np.arange(n1 + 1), n1, p1)
|
|
124
|
+
b2 = binom.pmf(np.arange(n2 + 1), n2, p2)
|
|
125
|
+
N = n1 + n2
|
|
126
|
+
power = 0.0
|
|
127
|
+
for m1 in range(N + 1):
|
|
128
|
+
xs, pmf = hypergeom_pmf(N, m1, n1)
|
|
129
|
+
pvals = minlike_pvalues(pmf)
|
|
130
|
+
# outcome pairs (a, b) with a + b = m1: a ranges over the support
|
|
131
|
+
w = b1[xs] * b2[m1 - xs]
|
|
132
|
+
power += w[pvals <= alpha].sum()
|
|
133
|
+
return float(power)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Input validation helpers for the free exactpower core.
|
|
2
|
+
|
|
3
|
+
Every public function validates loudly and refuses to compute on bad
|
|
4
|
+
input: a ValueError is always better than a silent wrong number.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import numbers
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"check_prob",
|
|
14
|
+
"check_alpha",
|
|
15
|
+
"check_alternative",
|
|
16
|
+
"check_group_size",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_integral(x):
|
|
21
|
+
if isinstance(x, bool):
|
|
22
|
+
return False
|
|
23
|
+
if isinstance(x, numbers.Integral):
|
|
24
|
+
return True
|
|
25
|
+
if isinstance(x, numbers.Real):
|
|
26
|
+
return float(x).is_integer()
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_count(n, name):
|
|
31
|
+
"""Validate a sample-size / count argument.
|
|
32
|
+
|
|
33
|
+
Accepts ints, numpy integers, and integral floats (10.0); anything
|
|
34
|
+
else (10.5, -3, "10", NaN) raises ValueError.
|
|
35
|
+
"""
|
|
36
|
+
if isinstance(n, np.ndarray):
|
|
37
|
+
raise ValueError(f"{name} must be a single integer, got an array")
|
|
38
|
+
if not _is_integral(n):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"{name} must be an integer count, got {n!r}"
|
|
41
|
+
)
|
|
42
|
+
n = int(n) if isinstance(n, numbers.Integral) else int(n)
|
|
43
|
+
return n
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_group_size(n, name):
|
|
47
|
+
n = check_count(n, name)
|
|
48
|
+
if n < 1:
|
|
49
|
+
raise ValueError(f"{name} must be >= 1, got {n}")
|
|
50
|
+
return n
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_prob(p, name):
|
|
54
|
+
"""Validate a probability in [0, 1]."""
|
|
55
|
+
if isinstance(p, bool) or not isinstance(p, numbers.Real):
|
|
56
|
+
raise ValueError(f"{name} must be a number in [0, 1], got {p!r}")
|
|
57
|
+
p = float(p)
|
|
58
|
+
if math.isnan(p) or not (0.0 <= p <= 1.0):
|
|
59
|
+
raise ValueError(f"{name} must be in [0, 1], got {p!r}")
|
|
60
|
+
return p
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_alpha(alpha):
|
|
64
|
+
if isinstance(alpha, bool) or not isinstance(alpha, numbers.Real):
|
|
65
|
+
raise ValueError(f"alpha must be a number in (0, 1), got {alpha!r}")
|
|
66
|
+
alpha = float(alpha)
|
|
67
|
+
if math.isnan(alpha) or not (0.0 < alpha < 1.0):
|
|
68
|
+
raise ValueError(f"alpha must be strictly between 0 and 1, got {alpha!r}")
|
|
69
|
+
return alpha
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_alternative(alternative):
|
|
73
|
+
if alternative not in ("two-sided", "less", "greater"):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"alternative must be 'two-sided', 'less' or 'greater', "
|
|
76
|
+
f"got {alternative!r}"
|
|
77
|
+
)
|
|
78
|
+
return alternative
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: exactpower
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Exact power of Fisher's exact test for a planned 2x2 study (free core)
|
|
5
|
+
Author: Groundplane Labs
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://groundplane0.gumroad.com/l/tyevqe
|
|
8
|
+
Keywords: power analysis,sample size,fisher exact test,exact test,statistics,biostatistics
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Requires-Dist: scipy
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# exactpower
|
|
21
|
+
|
|
22
|
+
Free core of ExactPower: exact power of Fisher's exact test for a planned 2x2 study.
|
|
23
|
+
|
|
24
|
+
**The problem**
|
|
25
|
+
You are planning a study with two groups and a yes/no outcome, and you need to pick a sample size before you collect data. The usual power calculators use approximations that drift exactly where small samples matter most. Search terms: exact power Fisher test Python, sample size Fisher exact test, power analysis 2x2 Python.
|
|
26
|
+
|
|
27
|
+
**What it does**
|
|
28
|
+
`exact_power_fisher(n1, n2, p1, p2)` computes the exact rejection probability of the two-sided Fisher exact test under your planned design. Give it the group sizes and the event rates you expect, and it enumerates every possible outcome. No approximation, no simulation. The free version handles n1 + n2 up to 1000.
|
|
29
|
+
|
|
30
|
+
**Limits**
|
|
31
|
+
Free version: two-sided power only, n1 + n2 <= 1000. Larger studies are refused outright instead of hanging. No sample-size inversion, no Monte Carlo power estimator, no plots, no CLI.
|
|
32
|
+
|
|
33
|
+
**Install**
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install exactpower
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Needs Python 3.9 or newer, NumPy, and SciPy.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from exactpower import exact_power_fisher
|
|
43
|
+
print(exact_power_fisher(60, 60, 0.55, 0.30))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This is the free core: two-sided exact power for a planned 2x2 design, capped at n1 + n2 <= 1000. The full version ($30, one-time) goes to n1 + n2 = 5000 and adds sample-size inversion, Monte Carlo power for r-by-c tables, and one-sided power: https://groundplane0.gumroad.com/l/tyevqe
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/exactpower/__init__.py
|
|
5
|
+
src/exactpower/_fisher.py
|
|
6
|
+
src/exactpower/_power.py
|
|
7
|
+
src/exactpower/_validate.py
|
|
8
|
+
src/exactpower.egg-info/PKG-INFO
|
|
9
|
+
src/exactpower.egg-info/SOURCES.txt
|
|
10
|
+
src/exactpower.egg-info/dependency_links.txt
|
|
11
|
+
src/exactpower.egg-info/requires.txt
|
|
12
|
+
src/exactpower.egg-info/top_level.txt
|
|
13
|
+
tests/test_free.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exactpower
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Tests for the free exactpower core.
|
|
2
|
+
|
|
3
|
+
Correctness is checked against the paid package (imported from its own
|
|
4
|
+
source tree under an alias, never copied). Cap behavior and input
|
|
5
|
+
validation are checked directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.util
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
import exactpower
|
|
15
|
+
|
|
16
|
+
HERE = Path(__file__).resolve().parent
|
|
17
|
+
PAID_SRC = HERE.parent.parent.parent / "exact-power" / "src" / "exactpower"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_paid():
|
|
21
|
+
spec = importlib.util.spec_from_file_location(
|
|
22
|
+
"paid_exactpower",
|
|
23
|
+
PAID_SRC / "__init__.py",
|
|
24
|
+
submodule_search_locations=[str(PAID_SRC)],
|
|
25
|
+
)
|
|
26
|
+
mod = importlib.util.module_from_spec(spec)
|
|
27
|
+
sys.modules["paid_exactpower"] = mod
|
|
28
|
+
spec.loader.exec_module(mod)
|
|
29
|
+
return mod
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
paid = _load_paid()
|
|
33
|
+
|
|
34
|
+
PAID_URL = "https://groundplane0.gumroad.com/l/tyevqe"
|
|
35
|
+
|
|
36
|
+
CASES = [
|
|
37
|
+
(60, 60, 0.55, 0.30, 0.05),
|
|
38
|
+
(30, 30, 0.8, 0.2, 0.05),
|
|
39
|
+
(100, 50, 0.4, 0.35, 0.01),
|
|
40
|
+
(10, 10, 0.9, 0.1, 0.05),
|
|
41
|
+
(200, 200, 0.52, 0.48, 0.05),
|
|
42
|
+
(45, 60, 0.7, 0.5, 0.10),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_matches_paid_exactly():
|
|
47
|
+
for n1, n2, p1, p2, alpha in CASES:
|
|
48
|
+
free = exactpower.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
|
|
49
|
+
ref = paid.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
|
|
50
|
+
assert free == pytest.approx(ref, rel=1e-12), (n1, n2, p1, p2, alpha)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_odds_ratio_matches_paid():
|
|
54
|
+
free = exactpower.exact_power_fisher(60, 60, None, 0.30, odds_ratio=2.85)
|
|
55
|
+
ref = paid.exact_power_fisher(60, 60, None, 0.30, odds_ratio=2.85)
|
|
56
|
+
assert free == pytest.approx(ref, rel=1e-12)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_over_cap_refuses_loudly():
|
|
60
|
+
with pytest.raises(ValueError, match="n1 \\+ n2 <= 1000"):
|
|
61
|
+
exactpower.exact_power_fisher(600, 600, 0.55, 0.45)
|
|
62
|
+
with pytest.raises(ValueError, match="n1 \\+ n2 <= 1000"):
|
|
63
|
+
exactpower.exact_power_fisher(1, 1000, 0.55, 0.45)
|
|
64
|
+
try:
|
|
65
|
+
exactpower.exact_power_fisher(600, 600, 0.55, 0.45)
|
|
66
|
+
except ValueError as e:
|
|
67
|
+
assert PAID_URL in str(e)
|
|
68
|
+
assert "30" in str(e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_at_cap_still_works():
|
|
72
|
+
p = exactpower.exact_power_fisher(500, 500, 0.55, 0.45)
|
|
73
|
+
assert 0.0 <= p <= 1.0
|
|
74
|
+
assert p == pytest.approx(paid.exact_power_fisher(500, 500, 0.55, 0.45), rel=1e-12)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_one_sided_refused_with_paid_pointer():
|
|
78
|
+
with pytest.raises(ValueError, match="two-sided"):
|
|
79
|
+
exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="greater")
|
|
80
|
+
try:
|
|
81
|
+
exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="less")
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
assert PAID_URL in str(e)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_bad_alternative_string_raises():
|
|
87
|
+
with pytest.raises(ValueError):
|
|
88
|
+
exactpower.exact_power_fisher(60, 60, 0.55, 0.30, alternative="bogus")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.mark.parametrize(
|
|
92
|
+
"args",
|
|
93
|
+
[
|
|
94
|
+
(0, 60, 0.55, 0.30), # n1 < 1
|
|
95
|
+
(60, -5, 0.55, 0.30), # negative n2
|
|
96
|
+
(60.5, 60, 0.55, 0.30), # fractional n1
|
|
97
|
+
(60, 60, 1.5, 0.30), # p1 > 1
|
|
98
|
+
(60, 60, 0.55, -0.1), # p2 < 0
|
|
99
|
+
(60, 60, float("nan"), 0.30),
|
|
100
|
+
(60, 60, 0.55, 0.30, 0.0), # alpha = 0 (positional alpha)
|
|
101
|
+
(60, 60, 0.55, 0.30, 1.0), # alpha = 1
|
|
102
|
+
("60", 60, 0.55, 0.30), # string n1
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
def test_garbage_raises(args):
|
|
106
|
+
n1, n2, p1, p2 = args[0], args[1], args[2], args[3]
|
|
107
|
+
alpha = args[4] if len(args) > 4 else 0.05
|
|
108
|
+
with pytest.raises(ValueError):
|
|
109
|
+
exactpower.exact_power_fisher(n1, n2, p1, p2, alpha=alpha)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_paid_only_symbols_absent():
|
|
113
|
+
for name in (
|
|
114
|
+
"sample_size_fisher",
|
|
115
|
+
"SampleSizeNotAchieved",
|
|
116
|
+
"power_mc",
|
|
117
|
+
"MCPowerResult",
|
|
118
|
+
"fisher_exact_2x2",
|
|
119
|
+
"fisher_exact_rc",
|
|
120
|
+
"count_tables",
|
|
121
|
+
):
|
|
122
|
+
assert not hasattr(exactpower, name), name
|
|
123
|
+
assert set(exactpower.__all__) == {
|
|
124
|
+
"exact_power_fisher",
|
|
125
|
+
"FREE_MAX_TOTAL_N",
|
|
126
|
+
"__version__",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_free_cap_constant():
|
|
131
|
+
assert exactpower.FREE_MAX_TOTAL_N == 1000
|