barnard-exact 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,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,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: barnard-exact
3
+ Version: 0.1.0
4
+ Summary: Barnard's and Boschloo's exact tests for 2x2 tables (free core)
5
+ Author: Groundplane Labs
6
+ License: MIT
7
+ Project-URL: Homepage, https://groundplane0.gumroad.com/l/conxfk
8
+ Keywords: barnard test,boschloo test,exact test,fisher 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
+ # barnard-exact
21
+
22
+ Free core of Barnard: Barnard's and Boschloo's exact tests for 2x2 tables.
23
+
24
+ **The problem**
25
+ You ran a small 2x2 experiment and Fisher's exact test came back just above your threshold. Fisher's test conditions on the total number of successes, which your experiment never fixed in advance, and that conditioning throws away power. scipy does not implement the unconditional alternatives. Search terms: Barnard test Python pip, Boschloo test Python, Barnard exact test 2x2.
26
+
27
+ **What it does**
28
+ `barnard_test(table)` and `boschloo_test(table)` compute the exact unconditional two-sided p-values, maximized over the nuisance parameter, with ties classified in exact integer arithmetic. Boschloo's never returns a larger p-value than Fisher's exact test. Both are validated against published reference values. The free version handles total N up to 200.
29
+
30
+ **Limits**
31
+ Free version: total N <= 200. Larger tables are refused outright instead of hanging. No CLI, no plots, no batch mode.
32
+
33
+ **Install**
34
+
35
+ ```bash
36
+ pip install barnard-exact
37
+ ```
38
+
39
+ Needs Python 3.9 or newer, NumPy, and SciPy.
40
+
41
+ ```python
42
+ from barnard_exact import barnard_test, boschloo_test
43
+ print(barnard_test([[7, 12], [3, 18]]))
44
+ print(boschloo_test([[7, 12], [3, 18]]))
45
+ ```
46
+
47
+ This is the free core: Barnard's and Boschloo's exact tests for 2x2 tables, capped at total N <= 200. The full version ($30, one-time) is uncapped: https://groundplane0.gumroad.com/l/conxfk
@@ -0,0 +1,28 @@
1
+ # barnard-exact
2
+
3
+ Free core of Barnard: Barnard's and Boschloo's exact tests for 2x2 tables.
4
+
5
+ **The problem**
6
+ You ran a small 2x2 experiment and Fisher's exact test came back just above your threshold. Fisher's test conditions on the total number of successes, which your experiment never fixed in advance, and that conditioning throws away power. scipy does not implement the unconditional alternatives. Search terms: Barnard test Python pip, Boschloo test Python, Barnard exact test 2x2.
7
+
8
+ **What it does**
9
+ `barnard_test(table)` and `boschloo_test(table)` compute the exact unconditional two-sided p-values, maximized over the nuisance parameter, with ties classified in exact integer arithmetic. Boschloo's never returns a larger p-value than Fisher's exact test. Both are validated against published reference values. The free version handles total N up to 200.
10
+
11
+ **Limits**
12
+ Free version: total N <= 200. Larger tables are refused outright instead of hanging. No CLI, no plots, no batch mode.
13
+
14
+ **Install**
15
+
16
+ ```bash
17
+ pip install barnard-exact
18
+ ```
19
+
20
+ Needs Python 3.9 or newer, NumPy, and SciPy.
21
+
22
+ ```python
23
+ from barnard_exact import barnard_test, boschloo_test
24
+ print(barnard_test([[7, 12], [3, 18]]))
25
+ print(boschloo_test([[7, 12], [3, 18]]))
26
+ ```
27
+
28
+ This is the free core: Barnard's and Boschloo's exact tests for 2x2 tables, capped at total N <= 200. The full version ($30, one-time) is uncapped: https://groundplane0.gumroad.com/l/conxfk
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "barnard-exact"
7
+ version = "0.1.0"
8
+ description = "Barnard's and Boschloo's exact tests for 2x2 tables (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 = ["barnard test", "boschloo test", "exact test", "fisher 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/conxfk"
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """barnard_exact: Barnard's and Boschloo's exact tests for 2x2 tables (free core)."""
2
+
3
+ from ._exact import FREE_MAX_N, barnard_test, boschloo_test
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["barnard_test", "boschloo_test", "FREE_MAX_N", "__version__"]
@@ -0,0 +1,284 @@
1
+ """Core machinery for unconditional exact tests of 2x2 tables (free core).
2
+
3
+ Implements the classical mathematics behind the tests:
4
+
5
+ * Barnard's exact test: order all tables with the observed row totals by
6
+ the pooled Wald statistic
7
+
8
+ T = (x1/n1 - x2/n2) / sqrt(p_pool (1-p_pool) (1/n1 + 1/n2)),
9
+
10
+ with p_pool = (x1 + x2) / (n1 + n2). The two-sided p-value is
11
+
12
+ max_{pi in [0,1]} P(|T| >= |t_obs| | pi),
13
+
14
+ where under the null each row is Binomial(n_i, pi) independently.
15
+
16
+ * Boschloo's exact test: use Fisher's exact two-sided p-value of each
17
+ table as the test statistic B. The p-value is
18
+
19
+ max_{pi in [0,1]} P(B <= b_obs | pi)
20
+
21
+ with the same binomial-product probabilities.
22
+
23
+ Because the rejection region is fixed (the test statistic does not depend
24
+ on the nuisance parameter pi), the tail probability being maximized is a
25
+ *polynomial* in pi (degree n1+n2). It is maximized by a fine grid plus
26
+ local Brent refinement of the best candidates.
27
+ """
28
+
29
+ import math
30
+
31
+ import numpy as np
32
+ from scipy.optimize import minimize_scalar
33
+
34
+ __all__ = [
35
+ "_validate_table",
36
+ "_barnard_region_twosided_exact",
37
+ "_fisher_twosided_grid",
38
+ "_exact_fisher_le",
39
+ "_TailProb",
40
+ "_maximize_over_pi",
41
+ ]
42
+
43
+
44
+ def _validate_table(table):
45
+ """Validate a 2x2 table of counts; return (a, b, c, d, n1, n2) as ints.
46
+
47
+ Anything that is not a 2x2 array of nonnegative integers (fractional
48
+ entries, negatives, NaN/inf, wrong shape, non-numeric input) raises
49
+ ValueError loudly. Nothing is silently coerced or truncated.
50
+ """
51
+ try:
52
+ T = np.asarray(table)
53
+ except Exception as exc:
54
+ raise ValueError(f"table could not be interpreted as an array: {exc}")
55
+ if T.dtype == object:
56
+ # e.g. ragged input like [[1, 2], [3]]; asarray gives object dtype
57
+ raise ValueError("table must be a 2x2 array of counts")
58
+ if T.dtype.kind == "b":
59
+ raise ValueError("table entries must be integer counts, not booleans")
60
+ if T.dtype.kind == "c":
61
+ raise ValueError("table entries must be real counts, not complex")
62
+ if T.dtype.kind not in "iuf":
63
+ raise ValueError("table entries must be numeric counts")
64
+ if T.ndim != 2 or T.shape != (2, 2):
65
+ raise ValueError(f"table must be 2x2, got shape {T.shape}")
66
+ if not np.all(np.isfinite(T)):
67
+ raise ValueError("table entries must be finite (no NaN or inf)")
68
+ if np.any(T < 0):
69
+ raise ValueError("table entries must be nonnegative")
70
+ if not np.all(np.equal(np.mod(T, 1), 0)):
71
+ raise ValueError("table entries must be integers (no fractional counts)")
72
+ a, b, c, d = (int(v) for v in T.flat)
73
+ return a, b, c, d, a + b, c + d
74
+
75
+
76
+ def _log_binom_coefs(n):
77
+ """log C(n, k) for k = 0..n (float array)."""
78
+ ks = np.arange(n + 1, dtype=float)
79
+ return (
80
+ math.lgamma(n + 1)
81
+ - np.array([math.lgamma(k + 1) for k in ks])
82
+ - np.array([math.lgamma(n - k + 1) for k in ks])
83
+ )
84
+
85
+
86
+ def _fisher_twosided_grid(n1, n2):
87
+ """Two-sided Fisher exact p-value for every table with row totals n1, n2.
88
+
89
+ Each table (x1, x2) is scored under its own column margin s = x1 + x2:
90
+ with the hypergeometric law Hypergeometric(N, n1, s), the p-value is the
91
+ total probability of tables whose probability is <= the observed table's
92
+ (Fisher's "minimum likelihood" ordering). Tie classification uses exact
93
+ integer arithmetic on the weights C(n1,x1)*C(n2,s-x1), so no
94
+ floating-point tolerance decides extremeness.
95
+ """
96
+ N = n1 + n2
97
+ lg1 = _log_binom_coefs(n1)
98
+ lg2 = _log_binom_coefs(n2)
99
+ lgN = math.lgamma(N + 1)
100
+ F = np.empty((n1 + 1, n2 + 1))
101
+ for s in range(N + 1):
102
+ lo = max(0, s - n2)
103
+ hi = min(n1, s)
104
+ xs = np.arange(lo, hi + 1)
105
+ x2s = s - xs
106
+ lp = lg1[xs] + lg2[x2s] - (lgN - math.lgamma(s + 1) - math.lgamma(N - s + 1))
107
+ # Exact tie weights (integers): P(x) <= P(y) <=> w(x) >= w(y).
108
+ w = [math.comb(n1, int(x)) * math.comb(n2, int(s - x)) for x in xs]
109
+ order = sorted(range(len(xs)), key=lambda i: w[i])
110
+ pmf = np.exp(lp - lp.max())
111
+ pmf /= pmf.sum()
112
+ spo = pmf[order]
113
+ cso = np.cumsum(spo)
114
+ fp = np.empty(len(xs))
115
+ j = 0
116
+ while j < len(xs):
117
+ k = j
118
+ while k + 1 < len(xs) and w[order[k + 1]] == w[order[j]]:
119
+ k += 1
120
+ # tied tables share the largest cumulative probability
121
+ fp[[order[t] for t in range(j, k + 1)]] = cso[k]
122
+ j = k + 1
123
+ F[xs, x2s] = fp
124
+ return F
125
+
126
+
127
+ def _exact_fisher_margin(n1, n2, s, direction):
128
+ """Exact rational Fisher p-values for every x1 on column margin s.
129
+
130
+ Returns ``(A, D)`` with ``p(x1) == A[x1] / D`` exactly, as Python
131
+ integers. ``direction`` is ``"minlike"`` (two-sided
132
+ minimum-likelihood ordering). Used to resolve cross-margin ties that
133
+ floating point cannot decide.
134
+ """
135
+ N = n1 + n2
136
+ lo, hi = max(0, s - n2), min(n1, s)
137
+ ys = list(range(lo, hi + 1))
138
+ ws = [math.comb(n1, y) * math.comb(n2, s - y) for y in ys]
139
+ D = math.comb(N, s)
140
+ A = {}
141
+ if direction == "minlike":
142
+ # group tied weights: A(w) = sum of weights <= w (minimum-
143
+ # likelihood ordering sums the probabilities of tables no more
144
+ # likely than the observed one)
145
+ pairs = sorted(zip(ws, ys), key=lambda t: t[0])
146
+ run = 0
147
+ i = 0
148
+ while i < len(pairs):
149
+ j = i
150
+ while j < len(pairs) and pairs[j][0] == pairs[i][0]:
151
+ j += 1
152
+ run += sum(w for w, _ in pairs[i:j])
153
+ for _, y in pairs[i:j]:
154
+ A[y] = run
155
+ i = j
156
+ else:
157
+ raise ValueError("direction must be 'minlike'")
158
+ return A, D
159
+
160
+
161
+ def _exact_fisher_le(n1, n2, mask, a_obs, s_obs, direction):
162
+ """Decide ``F(t) <= F(obs)`` exactly for cells where ``mask`` is True.
163
+
164
+ Compares exact rational Fisher p-values by integer
165
+ cross-multiplication (``A_t * D_obs <= A_obs * D_s``), so
166
+ cross-margin ties are never misclassified by floating-point noise.
167
+ Cells where ``mask`` is False are False in the output.
168
+ """
169
+ A_obs_d, D_obs = _exact_fisher_margin(n1, n2, s_obs, direction)
170
+ A_obs = A_obs_d[a_obs]
171
+ out = np.zeros_like(mask)
172
+ cache = {}
173
+ for x1, x2 in np.argwhere(mask):
174
+ x1, x2 = int(x1), int(x2)
175
+ s = x1 + x2
176
+ if s not in cache:
177
+ cache[s] = _exact_fisher_margin(n1, n2, s, direction)
178
+ A_d, D_s = cache[s]
179
+ if A_d[x1] * D_obs <= A_obs * D_s:
180
+ out[x1, x2] = True
181
+ return out
182
+
183
+
184
+ def _wald_t2_num_den(x1, x2, n1, n2):
185
+ """Exact integer numerator/denominator of the squared pooled Wald statistic.
186
+
187
+ T(x1, x2)^2 = num / den, where with s = x1 + x2 and N = n1 + n2::
188
+
189
+ num = (x1*n2 - x2*n1)^2
190
+ den = s * (N - s)
191
+
192
+ (the factors N, n1, n2 cancel in any ratio comparison). A zero
193
+ denominator means T = 0 (the numerator is zero too in that case).
194
+ All values are Python integers: comparisons of squared statistics are
195
+ exact, so tied tables are never misclassified by floating-point noise.
196
+ """
197
+ s = x1 + x2
198
+ return (x1 * n2 - x2 * n1) ** 2, s * (n1 + n2 - s)
199
+
200
+
201
+ def _barnard_region_twosided_exact(a, b, c, d, n1, n2):
202
+ """|T| >= |t_obs| region via exact integer arithmetic (no float ties)."""
203
+ num0, den0 = _wald_t2_num_den(a, c, n1, n2)
204
+ x1 = np.arange(n1 + 1, dtype=object)[:, None]
205
+ x2 = np.arange(n2 + 1, dtype=object)[None, :]
206
+ num1, den1 = _wald_t2_num_den(x1, x2, n1, n2)
207
+ if num0 == 0 or den0 == 0:
208
+ # t_obs == 0: every table is at least as extreme
209
+ return np.ones((n1 + 1, n2 + 1), dtype=bool)
210
+ with np.errstate(all="ignore"):
211
+ # den1 == 0 -> T = 0 < |t_obs| -> excluded
212
+ region = np.logical_and(den1 != 0, num1 * den0 >= num0 * den1)
213
+ return np.asarray(region, dtype=bool)
214
+
215
+
216
+ class _TailProb:
217
+ """Tail probability P(region | pi) as a callable of pi in [0, 1].
218
+
219
+ Precomputes the log binomial coefficients and the sufficient statistic
220
+ S = x1 + x2 over the rejection region, so each evaluation is a single
221
+ vectorized log-sum-exp: O(|region|) work. pi = 0 and pi = 1 are handled
222
+ exactly (only the all-failure / all-success table has mass there).
223
+ """
224
+
225
+ def __init__(self, n1, n2, region):
226
+ self._n = n1 + n2
227
+ x1 = np.arange(n1 + 1)[:, None]
228
+ x2 = np.arange(n2 + 1)[None, :]
229
+ lg1 = _log_binom_coefs(n1)[:, None]
230
+ lg2 = _log_binom_coefs(n2)[None, :]
231
+ self._K = (lg1 + lg2)[region].astype(float)
232
+ self._S = (x1 + x2)[region].astype(float)
233
+ self._p0 = 1.0 if bool((self._S == 0).any()) else 0.0
234
+ self._p1 = 1.0 if bool((self._S == self._n).any()) else 0.0
235
+
236
+ def __call__(self, pi):
237
+ if self._K.size == 0:
238
+ return 0.0
239
+ if pi <= 0.0:
240
+ return self._p0
241
+ if pi >= 1.0:
242
+ return self._p1
243
+ lp = self._K + self._S * math.log(pi) + (self._n - self._S) * math.log1p(-pi)
244
+ m = float(lp.max())
245
+ return float(np.exp(lp - m).sum() * math.exp(m))
246
+
247
+
248
+ def _maximize_over_pi(tail, n_grid=2001, n_refine=32):
249
+ """max_{pi in [0,1]} tail(pi): dense grid plus Brent local refinement.
250
+
251
+ The tail probability is a polynomial in pi (the rejection region is
252
+ fixed), so it is smooth; still, narrow peaks are possible, hence the
253
+ fine grid plus bounded Brent refinement around the best candidates.
254
+ Deterministic: same input always gives the same output.
255
+ """
256
+ pis = np.linspace(0.0, 1.0, n_grid)
257
+ vals = np.empty(n_grid)
258
+ for i, pi in enumerate(pis):
259
+ vals[i] = tail(pi)
260
+ best = float(vals.max())
261
+ h = 1.0 / (n_grid - 1)
262
+ order = np.argsort(vals, kind="stable")
263
+ blocked = np.zeros(n_grid, dtype=bool)
264
+ done = 0
265
+ for i in order[::-1]:
266
+ if done >= n_refine:
267
+ break
268
+ if blocked[i]:
269
+ continue
270
+ blocked[max(0, i - 2) : i + 3] = True
271
+ done += 1
272
+ lo = max(0.0, pis[i] - 3 * h)
273
+ hi = min(1.0, pis[i] + 3 * h)
274
+ if hi <= lo:
275
+ continue
276
+ res = minimize_scalar(
277
+ lambda p: -tail(p),
278
+ bounds=(lo, hi),
279
+ method="bounded",
280
+ options={"xatol": 1e-13, "maxiter": 300},
281
+ )
282
+ if math.isfinite(res.fun) and -res.fun > best:
283
+ best = -res.fun
284
+ return min(1.0, max(0.0, best))
@@ -0,0 +1,138 @@
1
+ """Barnard's and Boschloo's exact unconditional tests for 2x2 tables (free core).
2
+
3
+ Both tests condition only on the row totals (the two sample sizes) and
4
+ treat the column totals as random. Under the null hypothesis of equal
5
+ success probabilities, each row is Binomial(n_i, pi) independently, with
6
+ the common success probability pi an unknown *nuisance parameter*. The
7
+ p-value is the worst case over pi in [0, 1] of the tail probability.
8
+
9
+ :func:`barnard_test` orders tables by the pooled Wald statistic and is
10
+ two-sided by test-statistic ordering: the p-value is
11
+ max_pi P(|T| >= |t_obs| | pi).
12
+
13
+ :func:`boschloo_test` uses Fisher's exact two-sided p-value of each table
14
+ as the test statistic B; the p-value is max_pi P(B <= b_obs | pi).
15
+ Boschloo's test is uniformly at least as powerful as Fisher's exact test:
16
+ on every table its p-value is <= Fisher's.
17
+ """
18
+
19
+ import numpy as np
20
+
21
+ from ._core import (
22
+ _barnard_region_twosided_exact,
23
+ _exact_fisher_le,
24
+ _fisher_twosided_grid,
25
+ _maximize_over_pi,
26
+ _TailProb,
27
+ _validate_table,
28
+ )
29
+
30
+ __all__ = ["barnard_test", "boschloo_test", "FREE_MAX_N"]
31
+
32
+ #: Hard cap on the total sample size (n1 + n2) in the free version.
33
+ #: Beyond this the functions raise ValueError immediately instead of
34
+ #: attempting the calculation.
35
+ FREE_MAX_N = 200
36
+
37
+ _PAID_URL = "https://groundplane0.gumroad.com/l/conxfk"
38
+
39
+
40
+ def _check_free_cap(n1, n2):
41
+ if n1 + n2 > FREE_MAX_N:
42
+ raise ValueError(
43
+ f"Free version is capped at total N <= {FREE_MAX_N}. "
44
+ f"The full version ($30, one-time) is uncapped: {_PAID_URL}"
45
+ )
46
+
47
+
48
+ def _unconditional_pvalue(table, region_fn):
49
+ """Shared engine: build the rejection region, maximize over pi."""
50
+ a, b, c, d, n1, n2 = _validate_table(table)
51
+ _check_free_cap(n1, n2)
52
+ if n1 == 0 or n2 == 0:
53
+ # A group with no observations carries no information about its
54
+ # success probability; there is nothing to test.
55
+ return 1.0
56
+ region = region_fn(a, b, c, d, n1, n2)
57
+ tail = _TailProb(n1, n2, region)
58
+ return _maximize_over_pi(tail)
59
+
60
+
61
+ def _barnard_region_twosided(a, b, c, d, n1, n2):
62
+ # Exact integer tie classification: tables tied with the observed
63
+ # table are always counted as "as extreme".
64
+ return _barnard_region_twosided_exact(a, b, c, d, n1, n2)
65
+
66
+
67
+ def _band_region(F, f_obs, a, b, c, n1, n2, direction):
68
+ # Float comparison is reliable outside a 1e-9 relative band (the float
69
+ # error in these normalized sums is ~1e-14). Cells inside the band,
70
+ # true cross-margin ties plus float dust, are decided by exact
71
+ # integer arithmetic.
72
+ lo = f_obs * (1.0 - 1e-9)
73
+ hi = f_obs * (1.0 + 1e-9)
74
+ region = F <= lo
75
+ band = (F > lo) & (F <= hi)
76
+ if band.any():
77
+ region = region | _exact_fisher_le(n1, n2, band, a, a + c, direction)
78
+ return region
79
+
80
+
81
+ def _boschloo_region_twosided(a, b, c, d, n1, n2):
82
+ F = _fisher_twosided_grid(n1, n2)
83
+ return _band_region(F, F[a, c], a, b, c, n1, n2, "minlike")
84
+
85
+
86
+ def barnard_test(table):
87
+ """Barnard's exact unconditional test for a 2x2 table (two-sided).
88
+
89
+ Parameters
90
+ ----------
91
+ table : array_like, shape (2, 2)
92
+ Observed counts ``[[a, b], [c, d]]`` (nonnegative integers).
93
+ Rows are the two groups; only the row totals are conditioned on.
94
+ The free version requires total N = n1 + n2 <= 200.
95
+
96
+ Returns
97
+ -------
98
+ float
99
+ Two-sided exact p-value: max over the nuisance parameter pi in
100
+ [0, 1] of the null probability (independent Binomial(n1, pi) x
101
+ Binomial(n2, pi)) of tables whose absolute pooled Wald statistic
102
+ is at least as large as the observed table's.
103
+
104
+ Notes
105
+ -----
106
+ Two-sidedness is by test-statistic ordering (|T| >= |t_obs|), not by
107
+ doubling a one-sided p-value. Tables with a zero row total return 1.0
108
+ (no information).
109
+ """
110
+ return _unconditional_pvalue(table, _barnard_region_twosided)
111
+
112
+
113
+ def boschloo_test(table):
114
+ """Boschloo's exact unconditional test for a 2x2 table (two-sided).
115
+
116
+ Parameters
117
+ ----------
118
+ table : array_like, shape (2, 2)
119
+ Observed counts ``[[a, b], [c, d]]`` (nonnegative integers).
120
+ Rows are the two groups; only the row totals are conditioned on.
121
+ The free version requires total N = n1 + n2 <= 200.
122
+
123
+ Returns
124
+ -------
125
+ float
126
+ Two-sided exact p-value: max over pi in [0, 1] of the null
127
+ probability of tables whose Fisher exact two-sided p-value is no
128
+ larger than the observed table's. Always <= the Fisher exact
129
+ two-sided p-value of the same table.
130
+
131
+ Notes
132
+ -----
133
+ Fisher's two-sided p-value here uses the minimum-likelihood ordering
134
+ (sum of hypergeometric probabilities <= the observed table's), with
135
+ ties classified in exact integer arithmetic. Tables with a zero row
136
+ total return 1.0.
137
+ """
138
+ return _unconditional_pvalue(table, _boschloo_region_twosided)
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: barnard-exact
3
+ Version: 0.1.0
4
+ Summary: Barnard's and Boschloo's exact tests for 2x2 tables (free core)
5
+ Author: Groundplane Labs
6
+ License: MIT
7
+ Project-URL: Homepage, https://groundplane0.gumroad.com/l/conxfk
8
+ Keywords: barnard test,boschloo test,exact test,fisher 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
+ # barnard-exact
21
+
22
+ Free core of Barnard: Barnard's and Boschloo's exact tests for 2x2 tables.
23
+
24
+ **The problem**
25
+ You ran a small 2x2 experiment and Fisher's exact test came back just above your threshold. Fisher's test conditions on the total number of successes, which your experiment never fixed in advance, and that conditioning throws away power. scipy does not implement the unconditional alternatives. Search terms: Barnard test Python pip, Boschloo test Python, Barnard exact test 2x2.
26
+
27
+ **What it does**
28
+ `barnard_test(table)` and `boschloo_test(table)` compute the exact unconditional two-sided p-values, maximized over the nuisance parameter, with ties classified in exact integer arithmetic. Boschloo's never returns a larger p-value than Fisher's exact test. Both are validated against published reference values. The free version handles total N up to 200.
29
+
30
+ **Limits**
31
+ Free version: total N <= 200. Larger tables are refused outright instead of hanging. No CLI, no plots, no batch mode.
32
+
33
+ **Install**
34
+
35
+ ```bash
36
+ pip install barnard-exact
37
+ ```
38
+
39
+ Needs Python 3.9 or newer, NumPy, and SciPy.
40
+
41
+ ```python
42
+ from barnard_exact import barnard_test, boschloo_test
43
+ print(barnard_test([[7, 12], [3, 18]]))
44
+ print(boschloo_test([[7, 12], [3, 18]]))
45
+ ```
46
+
47
+ This is the free core: Barnard's and Boschloo's exact tests for 2x2 tables, capped at total N <= 200. The full version ($30, one-time) is uncapped: https://groundplane0.gumroad.com/l/conxfk
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/barnard_exact/__init__.py
5
+ src/barnard_exact/_core.py
6
+ src/barnard_exact/_exact.py
7
+ src/barnard_exact.egg-info/PKG-INFO
8
+ src/barnard_exact.egg-info/SOURCES.txt
9
+ src/barnard_exact.egg-info/dependency_links.txt
10
+ src/barnard_exact.egg-info/requires.txt
11
+ src/barnard_exact.egg-info/top_level.txt
12
+ tests/test_free.py
@@ -0,0 +1,2 @@
1
+ numpy
2
+ scipy
@@ -0,0 +1 @@
1
+ barnard_exact
@@ -0,0 +1,133 @@
1
+ """Tests for the free barnard_exact core.
2
+
3
+ Correctness is checked against the paid package (imported from its own
4
+ source tree, never copied). Cap behavior and input validation are
5
+ checked directly.
6
+ """
7
+
8
+ import importlib.util
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import pytest
14
+
15
+ import barnard_exact
16
+
17
+ HERE = Path(__file__).resolve().parent
18
+ PAID_SRC = HERE.parent.parent.parent / "barnard" / "src" / "barnard"
19
+
20
+
21
+ def _load_paid():
22
+ spec = importlib.util.spec_from_file_location(
23
+ "paid_barnard",
24
+ PAID_SRC / "__init__.py",
25
+ submodule_search_locations=[str(PAID_SRC)],
26
+ )
27
+ mod = importlib.util.module_from_spec(spec)
28
+ sys.modules["paid_barnard"] = mod
29
+ spec.loader.exec_module(mod)
30
+ return mod
31
+
32
+
33
+ paid = _load_paid()
34
+
35
+ PAID_URL = "https://groundplane0.gumroad.com/l/conxfk"
36
+
37
+ TABLES = [
38
+ [[2, 3], [3, 2]],
39
+ [[7, 12], [3, 18]],
40
+ [[1, 9], [11, 3]],
41
+ [[8, 2], [1, 5]],
42
+ [[0, 5], [5, 0]],
43
+ [[10, 0], [0, 10]],
44
+ [[40, 10], [30, 20]],
45
+ [[0, 0], [5, 5]], # zero row total -> 1.0
46
+ [[25, 25], [25, 25]],
47
+ ]
48
+
49
+
50
+ def test_matches_paid_exactly():
51
+ for t in TABLES:
52
+ for free_fn, paid_fn in (
53
+ (barnard_exact.barnard_test, paid.barnard_test),
54
+ (barnard_exact.boschloo_test, paid.boschloo_test),
55
+ ):
56
+ assert free_fn(t) == pytest.approx(paid_fn(t), rel=1e-9), t
57
+
58
+
59
+ def test_matches_paid_on_random_tables():
60
+ rng = np.random.default_rng(3)
61
+ for _ in range(15):
62
+ t = rng.integers(0, 40, size=(2, 2)).tolist()
63
+ assert barnard_exact.barnard_test(t) == pytest.approx(
64
+ paid.barnard_test(t), rel=1e-9
65
+ )
66
+ assert barnard_exact.boschloo_test(t) == pytest.approx(
67
+ paid.boschloo_test(t), rel=1e-9
68
+ )
69
+
70
+
71
+ def test_over_cap_refuses_loudly():
72
+ big = [[61, 40], [55, 45]] # N = 201
73
+ for fn in (barnard_exact.barnard_test, barnard_exact.boschloo_test):
74
+ with pytest.raises(ValueError, match="total N <= 200"):
75
+ fn(big)
76
+ try:
77
+ fn(big)
78
+ except ValueError as e:
79
+ assert PAID_URL in str(e)
80
+ assert "30" in str(e)
81
+
82
+
83
+ def test_at_cap_still_works():
84
+ t = [[60, 40], [55, 45]] # N = 200
85
+ assert barnard_exact.barnard_test(t) == pytest.approx(
86
+ paid.barnard_test(t), rel=1e-9
87
+ )
88
+ assert barnard_exact.boschloo_test(t) == pytest.approx(
89
+ paid.boschloo_test(t), rel=1e-9
90
+ )
91
+
92
+
93
+ @pytest.mark.parametrize(
94
+ "bad",
95
+ [
96
+ [[1.5, 2], [3, 4]], # fractional
97
+ [[-1, 2], [3, 4]], # negative
98
+ [[1, 2, 3], [4, 5, 6]], # wrong shape
99
+ [[1, 2]], # not 2x2
100
+ [[float("nan"), 2], [3, 4]],
101
+ [[float("inf"), 2], [3, 4]],
102
+ [[True, False], [True, True]], # booleans
103
+ "not a table",
104
+ ],
105
+ )
106
+ def test_garbage_raises(bad):
107
+ for fn in (barnard_exact.barnard_test, barnard_exact.boschloo_test):
108
+ with pytest.raises(ValueError):
109
+ fn(bad)
110
+
111
+
112
+ def test_paid_only_symbols_absent():
113
+ assert set(barnard_exact.__all__) == {
114
+ "barnard_test",
115
+ "boschloo_test",
116
+ "FREE_MAX_N",
117
+ "__version__",
118
+ }
119
+ for name in ("_barnard_onesided", "_boschloo_onesided", "_fisher_twosided"):
120
+ assert not hasattr(barnard_exact, name), name
121
+
122
+
123
+ def test_free_cap_constant():
124
+ assert barnard_exact.FREE_MAX_N == 200
125
+
126
+
127
+ def test_pvalues_in_unit_interval():
128
+ rng = np.random.default_rng(9)
129
+ for _ in range(15):
130
+ t = rng.integers(0, 30, size=(2, 2)).tolist()
131
+ for fn in (barnard_exact.barnard_test, barnard_exact.boschloo_test):
132
+ p = fn(t)
133
+ assert 0.0 <= p <= 1.0