evalaudit 0.2.0__py3-none-any.whl
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.
- evalaudit/__init__.py +56 -0
- evalaudit/_types.py +1572 -0
- evalaudit/agreement.py +600 -0
- evalaudit/audit.py +821 -0
- evalaudit/compare.py +708 -0
- evalaudit/judge.py +744 -0
- evalaudit/pairwise.py +682 -0
- evalaudit/power.py +528 -0
- evalaudit/scores.py +136 -0
- evalaudit-0.2.0.dist-info/METADATA +313 -0
- evalaudit-0.2.0.dist-info/RECORD +13 -0
- evalaudit-0.2.0.dist-info/WHEEL +4 -0
- evalaudit-0.2.0.dist-info/licenses/LICENSE +21 -0
evalaudit/compare.py
ADDED
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Comparing two systems.
|
|
2
|
+
|
|
3
|
+
The mistake this module exists to stop: running an unpaired test on paired
|
|
4
|
+
data. Most evals score both systems on the same items, and that pairing
|
|
5
|
+
removes the item-to-item difficulty that otherwise swamps the difference.
|
|
6
|
+
Ignore it and the interval comes out several times too wide.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy import optimize as _optimize
|
|
15
|
+
from scipy import stats as _stats
|
|
16
|
+
|
|
17
|
+
from ._types import ComparisonResult
|
|
18
|
+
from .scores import _is_binary
|
|
19
|
+
|
|
20
|
+
__all__ = ["compare_paired", "compare_independent"]
|
|
21
|
+
|
|
22
|
+
_PAIRED_METHODS = {"auto", "mcnemar", "bootstrap", "wilcoxon"}
|
|
23
|
+
_INDEPENDENT_METHODS = {"auto", "bootstrap", "t", "score"}
|
|
24
|
+
|
|
25
|
+
# Below this many discordant pairs the chi-square approximation is not
|
|
26
|
+
# trustworthy and McNemar falls back to the exact binomial test.
|
|
27
|
+
_EXACT_BELOW = 25
|
|
28
|
+
|
|
29
|
+
# Stands in for an infinite score at the edges of the range, where the
|
|
30
|
+
# variance under the hypothesis goes to zero. A finite value keeps the root
|
|
31
|
+
# finder happy and the sign is all that matters there.
|
|
32
|
+
_EDGE_SCORE = 1e6
|
|
33
|
+
|
|
34
|
+
# Above this many items the signed-rank test drops the exact distribution
|
|
35
|
+
# for the normal approximation. This is scipy's own cutoff, and the interval
|
|
36
|
+
# follows the test across it so the two always agree.
|
|
37
|
+
_SIGNED_RANK_EXACT_MAX = 50
|
|
38
|
+
|
|
39
|
+
# The signed-rank interval reports a midpoint of two differences. Below this
|
|
40
|
+
# many distinct non-zero differences those midpoints number three or fewer,
|
|
41
|
+
# and an interval picked off three values cannot line up with the p-value
|
|
42
|
+
# beside it. Binary scores are the common way to get here, though any pair
|
|
43
|
+
# of systems that only ever differ by one amount does it too.
|
|
44
|
+
_MIN_DISTINCT_SHIFTS = 3
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def compare_paired(
|
|
48
|
+
a: Sequence[float],
|
|
49
|
+
b: Sequence[float],
|
|
50
|
+
method: str = "auto",
|
|
51
|
+
confidence: float = 0.95,
|
|
52
|
+
n_boot: int = 10000,
|
|
53
|
+
seed: Optional[int] = None,
|
|
54
|
+
) -> ComparisonResult:
|
|
55
|
+
"""Compare two systems scored on the same items.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
a, b
|
|
60
|
+
Scores for system A and system B, aligned item by item. Same length.
|
|
61
|
+
method
|
|
62
|
+
``"auto"`` picks McNemar for binary data and the paired bootstrap for
|
|
63
|
+
continuous data.
|
|
64
|
+
``"mcnemar"`` is the test for paired binary outcomes. It looks only at
|
|
65
|
+
items where the two systems disagree, since items they both pass or
|
|
66
|
+
both fail say nothing about which system is better.
|
|
67
|
+
``"bootstrap"`` resamples the per-item differences.
|
|
68
|
+
``"wilcoxon"`` is the signed-rank test, reported with the
|
|
69
|
+
Hodges-Lehmann shift and its distribution-free interval. It needs
|
|
70
|
+
the two systems to differ by at least three distinct amounts, since
|
|
71
|
+
below that the interval it would return cannot agree with its own
|
|
72
|
+
p-value. Binary scores never clear that bar. Use McNemar for those.
|
|
73
|
+
confidence
|
|
74
|
+
Nominal coverage, default 0.95.
|
|
75
|
+
n_boot
|
|
76
|
+
Bootstrap resamples, used by ``"bootstrap"`` only.
|
|
77
|
+
seed
|
|
78
|
+
Seeds the bootstrap so results are reproducible.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
ComparisonResult
|
|
83
|
+
|
|
84
|
+
Examples
|
|
85
|
+
--------
|
|
86
|
+
>>> compare_paired([1] * 40 + [0] * 10, [1] * 35 + [0] * 15).summary()
|
|
87
|
+
'Difference 10.0% ...'
|
|
88
|
+
"""
|
|
89
|
+
if method not in _PAIRED_METHODS:
|
|
90
|
+
raise ValueError(f"method must be one of {_PAIRED_METHODS}, got {method!r}")
|
|
91
|
+
|
|
92
|
+
x = np.asarray(a, dtype=float)
|
|
93
|
+
y = np.asarray(b, dtype=float)
|
|
94
|
+
if x.shape != y.shape:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"a and b must be the same length for paired data, "
|
|
97
|
+
f"got {x.size} and {y.size}"
|
|
98
|
+
)
|
|
99
|
+
if x.size == 0:
|
|
100
|
+
raise ValueError("a and b must not be empty")
|
|
101
|
+
|
|
102
|
+
n = int(x.size)
|
|
103
|
+
binary = _is_binary(x) and _is_binary(y)
|
|
104
|
+
|
|
105
|
+
if method == "auto":
|
|
106
|
+
method = "mcnemar" if binary else "bootstrap"
|
|
107
|
+
|
|
108
|
+
if method == "mcnemar":
|
|
109
|
+
if not binary:
|
|
110
|
+
raise ValueError("mcnemar needs binary 0/1 scores")
|
|
111
|
+
return _mcnemar(x, y, n, confidence)
|
|
112
|
+
if method == "bootstrap":
|
|
113
|
+
return _paired_bootstrap(x, y, n, binary, confidence, n_boot, seed)
|
|
114
|
+
shifts = x - y
|
|
115
|
+
n_distinct = int(np.unique(shifts[shifts != 0]).size)
|
|
116
|
+
if 0 < n_distinct < _MIN_DISTINCT_SHIFTS:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"wilcoxon needs at least {_MIN_DISTINCT_SHIFTS} distinct "
|
|
119
|
+
f"non-zero differences between the two systems and got "
|
|
120
|
+
f"{n_distinct}. The interval is built from midpoints of those "
|
|
121
|
+
f"differences, so this few leaves it on a three-value lattice, "
|
|
122
|
+
f"where it contradicts its own p-value on roughly 41% of "
|
|
123
|
+
f"samples. Binary 0/1 scores always land here, and so does any "
|
|
124
|
+
f"scale the two systems only use two levels of. Use "
|
|
125
|
+
f"method='mcnemar' for 0/1 data, or method='bootstrap' otherwise"
|
|
126
|
+
)
|
|
127
|
+
return _wilcoxon(x, y, n, binary, confidence)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def compare_independent(
|
|
131
|
+
a: Sequence[float],
|
|
132
|
+
b: Sequence[float],
|
|
133
|
+
method: str = "auto",
|
|
134
|
+
confidence: float = 0.95,
|
|
135
|
+
n_boot: int = 10000,
|
|
136
|
+
seed: Optional[int] = None,
|
|
137
|
+
) -> ComparisonResult:
|
|
138
|
+
"""Compare two systems scored on separate samples of items.
|
|
139
|
+
|
|
140
|
+
Use this only when the two systems really were run on different items.
|
|
141
|
+
If they were run on the same items, use :func:`compare_paired`. That is
|
|
142
|
+
the same numbers analysed properly, and it gives a much tighter interval.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
a, b
|
|
147
|
+
Scores for system A and system B. Lengths may differ.
|
|
148
|
+
method
|
|
149
|
+
``"auto"`` picks the score method for binary data and the bootstrap
|
|
150
|
+
for continuous data.
|
|
151
|
+
``"score"`` is the score interval and score test for a difference of
|
|
152
|
+
two independent proportions. The interval is the test inverted, so
|
|
153
|
+
the two never disagree about zero.
|
|
154
|
+
``"bootstrap"`` resamples each group separately.
|
|
155
|
+
``"t"`` is Welch's t interval, which does not assume equal variance.
|
|
156
|
+
confidence
|
|
157
|
+
Nominal coverage, default 0.95.
|
|
158
|
+
n_boot
|
|
159
|
+
Bootstrap resamples, used by ``"bootstrap"`` only.
|
|
160
|
+
seed
|
|
161
|
+
Seeds the bootstrap so results are reproducible.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
ComparisonResult
|
|
166
|
+
With ``paired=False``. ``n`` is the total number of items across both
|
|
167
|
+
groups.
|
|
168
|
+
"""
|
|
169
|
+
if method not in _INDEPENDENT_METHODS:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"method must be one of {_INDEPENDENT_METHODS}, got {method!r}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
x = np.asarray(a, dtype=float)
|
|
175
|
+
y = np.asarray(b, dtype=float)
|
|
176
|
+
if x.size == 0 or y.size == 0:
|
|
177
|
+
raise ValueError("a and b must not be empty")
|
|
178
|
+
|
|
179
|
+
binary = _is_binary(x) and _is_binary(y)
|
|
180
|
+
if method == "auto":
|
|
181
|
+
method = "score" if binary else "bootstrap"
|
|
182
|
+
|
|
183
|
+
n_total = int(x.size + y.size)
|
|
184
|
+
difference = float(np.mean(x) - np.mean(y))
|
|
185
|
+
|
|
186
|
+
if method == "score":
|
|
187
|
+
if not binary:
|
|
188
|
+
raise ValueError("score needs binary 0/1 scores")
|
|
189
|
+
ci_low, ci_high = _score_diff_ci(x, y, confidence)
|
|
190
|
+
p_value = _two_proportion_p(x, y)
|
|
191
|
+
elif method == "bootstrap":
|
|
192
|
+
rng = np.random.default_rng(seed)
|
|
193
|
+
idx_a = rng.integers(0, x.size, size=(n_boot, x.size))
|
|
194
|
+
idx_b = rng.integers(0, y.size, size=(n_boot, y.size))
|
|
195
|
+
boot = x[idx_a].mean(axis=1) - y[idx_b].mean(axis=1)
|
|
196
|
+
ci_low, ci_high = _percentile_ci(boot, confidence)
|
|
197
|
+
p_value = _bootstrap_p(boot)
|
|
198
|
+
else: # "t"
|
|
199
|
+
ci_low, ci_high, p_value = _welch(x, y, confidence)
|
|
200
|
+
|
|
201
|
+
return ComparisonResult(
|
|
202
|
+
difference=difference,
|
|
203
|
+
ci_low=ci_low,
|
|
204
|
+
ci_high=ci_high,
|
|
205
|
+
p_value=p_value,
|
|
206
|
+
n=n_total,
|
|
207
|
+
method=method,
|
|
208
|
+
binary=binary,
|
|
209
|
+
paired=False,
|
|
210
|
+
confidence=confidence,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# --------------------------------------------------------------------------
|
|
215
|
+
# Paired methods
|
|
216
|
+
# --------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
def _mcnemar(
|
|
219
|
+
x: np.ndarray, y: np.ndarray, n: int, confidence: float
|
|
220
|
+
) -> ComparisonResult:
|
|
221
|
+
"""McNemar's test on the discordant pairs.
|
|
222
|
+
|
|
223
|
+
Exact binomial below 25 discordant pairs, chi-square with the continuity
|
|
224
|
+
correction at 25 and above. The threshold is a convention and the two
|
|
225
|
+
answers sit close together near it, so the choice changes little. It is
|
|
226
|
+
pinned by a test because an off-by-one here is invisible in the output.
|
|
227
|
+
"""
|
|
228
|
+
a_hit = x.astype(bool)
|
|
229
|
+
b_hit = y.astype(bool)
|
|
230
|
+
n_a_only = int(np.sum(a_hit & ~b_hit))
|
|
231
|
+
n_b_only = int(np.sum(~a_hit & b_hit))
|
|
232
|
+
n_disc = n_a_only + n_b_only
|
|
233
|
+
|
|
234
|
+
if n_disc == 0:
|
|
235
|
+
# Nothing to test. The p-value is 1 by convention, and summary()
|
|
236
|
+
# says plainly that the table was empty.
|
|
237
|
+
p_value = 1.0
|
|
238
|
+
elif n_disc < _EXACT_BELOW:
|
|
239
|
+
smaller = min(n_a_only, n_b_only)
|
|
240
|
+
p_value = float(min(2 * _stats.binom.cdf(smaller, n_disc, 0.5), 1.0))
|
|
241
|
+
else:
|
|
242
|
+
stat = (abs(n_a_only - n_b_only) - 1.0) ** 2 / n_disc
|
|
243
|
+
p_value = float(_stats.chi2.sf(stat, 1))
|
|
244
|
+
|
|
245
|
+
difference = (n_a_only - n_b_only) / n
|
|
246
|
+
ci_low, ci_high = _paired_proportion_ci(n_a_only, n_b_only, n, confidence)
|
|
247
|
+
|
|
248
|
+
return ComparisonResult(
|
|
249
|
+
difference=difference,
|
|
250
|
+
ci_low=ci_low,
|
|
251
|
+
ci_high=ci_high,
|
|
252
|
+
p_value=p_value,
|
|
253
|
+
n=n,
|
|
254
|
+
method="mcnemar",
|
|
255
|
+
binary=True,
|
|
256
|
+
paired=True,
|
|
257
|
+
confidence=confidence,
|
|
258
|
+
n_discordant=n_disc,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _tango_score(n_a_only: int, n_b_only: int, n: int, delta: float) -> float:
|
|
263
|
+
"""Tango's score statistic for the hypothesis that the difference is delta.
|
|
264
|
+
|
|
265
|
+
The nuisance parameter is the rate of B-only items, and it is replaced by
|
|
266
|
+
its maximum likelihood estimate under the hypothesis, which is the root
|
|
267
|
+
of a quadratic. At delta=0 the whole thing collapses to
|
|
268
|
+
|
|
269
|
+
(b - c) / sqrt(b + c)
|
|
270
|
+
|
|
271
|
+
which is McNemar's statistic without the continuity correction. That is
|
|
272
|
+
the property that keeps the interval and the test telling the same story.
|
|
273
|
+
"""
|
|
274
|
+
b, c = n_a_only, n_b_only
|
|
275
|
+
quad_a = 2.0 * n
|
|
276
|
+
quad_b = -b - c + (2.0 * n - b + c) * delta
|
|
277
|
+
quad_c = -c * delta * (1.0 - delta)
|
|
278
|
+
disc = max(quad_b * quad_b - 4.0 * quad_a * quad_c, 0.0)
|
|
279
|
+
q = (-quad_b + np.sqrt(disc)) / (2.0 * quad_a)
|
|
280
|
+
|
|
281
|
+
num = b - c - n * delta
|
|
282
|
+
den = n * (2.0 * q + delta * (1.0 - delta))
|
|
283
|
+
if den <= 0:
|
|
284
|
+
# Only at the edges of the range, where delta is barely possible.
|
|
285
|
+
if num == 0:
|
|
286
|
+
return 0.0
|
|
287
|
+
return _EDGE_SCORE if num > 0 else -_EDGE_SCORE
|
|
288
|
+
return num / np.sqrt(den)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _paired_proportion_ci(
|
|
292
|
+
n_a_only: int, n_b_only: int, n: int, confidence: float
|
|
293
|
+
) -> tuple[float, float]:
|
|
294
|
+
"""Tango's score interval on a paired difference in proportions.
|
|
295
|
+
|
|
296
|
+
The bounds are the two values of the difference the data would just
|
|
297
|
+
barely reject, found by solving the score statistic for +/- z. Tango
|
|
298
|
+
(1998), "Equivalence test and confidence interval for the difference in
|
|
299
|
+
proportions for the paired-sample design".
|
|
300
|
+
|
|
301
|
+
The Wald interval is the obvious alternative and it is worse in two
|
|
302
|
+
ways. It under-covers when the pass rates run near 1, which is where
|
|
303
|
+
eval numbers usually sit. And with no discordant pairs its standard
|
|
304
|
+
error is zero, so it returns a single point and claims two systems are
|
|
305
|
+
identical on the strength of an empty table. The score interval stays
|
|
306
|
+
wide there, which is the honest answer.
|
|
307
|
+
"""
|
|
308
|
+
z = _stats.norm.ppf(1 - (1 - confidence) / 2)
|
|
309
|
+
d_hat = (n_a_only - n_b_only) / n
|
|
310
|
+
edge = 1.0 - 1e-9
|
|
311
|
+
|
|
312
|
+
if d_hat <= -edge:
|
|
313
|
+
lo = -1.0
|
|
314
|
+
else:
|
|
315
|
+
def below(delta: float) -> float:
|
|
316
|
+
return _tango_score(n_a_only, n_b_only, n, delta) - z
|
|
317
|
+
|
|
318
|
+
lo = (
|
|
319
|
+
float(_optimize.brentq(below, -edge, d_hat))
|
|
320
|
+
if below(-edge) > 0
|
|
321
|
+
else -1.0
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
if d_hat >= edge:
|
|
325
|
+
hi = 1.0
|
|
326
|
+
else:
|
|
327
|
+
def above(delta: float) -> float:
|
|
328
|
+
return _tango_score(n_a_only, n_b_only, n, delta) + z
|
|
329
|
+
|
|
330
|
+
hi = (
|
|
331
|
+
float(_optimize.brentq(above, d_hat, edge))
|
|
332
|
+
if above(edge) < 0
|
|
333
|
+
else 1.0
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
return lo, hi
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _paired_bootstrap(
|
|
340
|
+
x: np.ndarray,
|
|
341
|
+
y: np.ndarray,
|
|
342
|
+
n: int,
|
|
343
|
+
binary: bool,
|
|
344
|
+
confidence: float,
|
|
345
|
+
n_boot: int,
|
|
346
|
+
seed: Optional[int],
|
|
347
|
+
) -> ComparisonResult:
|
|
348
|
+
"""Percentile bootstrap of the mean per-item difference.
|
|
349
|
+
|
|
350
|
+
Resample items, not systems. Each resample takes the pair (a_i, b_i)
|
|
351
|
+
together, which is what keeps the shared item difficulty out of the
|
|
352
|
+
interval.
|
|
353
|
+
"""
|
|
354
|
+
d = x - y
|
|
355
|
+
rng = np.random.default_rng(seed)
|
|
356
|
+
idx = rng.integers(0, n, size=(n_boot, n))
|
|
357
|
+
boot = d[idx].mean(axis=1)
|
|
358
|
+
ci_low, ci_high = _percentile_ci(boot, confidence)
|
|
359
|
+
|
|
360
|
+
return ComparisonResult(
|
|
361
|
+
difference=float(np.mean(d)),
|
|
362
|
+
ci_low=ci_low,
|
|
363
|
+
ci_high=ci_high,
|
|
364
|
+
p_value=_bootstrap_p(boot),
|
|
365
|
+
n=n,
|
|
366
|
+
method="bootstrap",
|
|
367
|
+
binary=binary,
|
|
368
|
+
paired=True,
|
|
369
|
+
confidence=confidence,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _wilcoxon(
|
|
374
|
+
x: np.ndarray, y: np.ndarray, n: int, binary: bool, confidence: float
|
|
375
|
+
) -> ComparisonResult:
|
|
376
|
+
"""Wilcoxon signed-rank test with the Hodges-Lehmann shift.
|
|
377
|
+
|
|
378
|
+
The effect size is the median of the Walsh averages of the per-item
|
|
379
|
+
differences, and the interval comes from the signed-rank distribution.
|
|
380
|
+
Building the Walsh averages costs n^2 memory. That is fine for eval sets
|
|
381
|
+
and would not be for millions of rows.
|
|
382
|
+
|
|
383
|
+
The test and the interval always come from the same distribution. Both
|
|
384
|
+
are exact for 50 items or fewer with no zero differences and no two
|
|
385
|
+
differences the same size, and both fall back to the normal
|
|
386
|
+
approximation otherwise, tie correction included. Letting scipy pick the
|
|
387
|
+
test on its own while the interval used the approximation would put a
|
|
388
|
+
p-value and an interval from two different distributions on the same
|
|
389
|
+
line.
|
|
390
|
+
|
|
391
|
+
Data whose non-zero differences take fewer than three distinct values
|
|
392
|
+
never reaches here. compare_paired turns it away, and this is why. Two
|
|
393
|
+
distinct differences, say plus and minus one, give Walsh averages of
|
|
394
|
+
only minus one, zero and one, so the interval can land nowhere else.
|
|
395
|
+
Measured over 600 binary samples it reported a bound of exactly zero
|
|
396
|
+
while the p-value rejected on 41% of them. No choice of variance fixes
|
|
397
|
+
it, since the limit is the resolution of the estimate rather than its
|
|
398
|
+
spread. Binary scores are the common case and McNemar answers the same
|
|
399
|
+
question on them properly.
|
|
400
|
+
"""
|
|
401
|
+
d = x - y
|
|
402
|
+
if np.all(d == 0):
|
|
403
|
+
# Every item tied, so nothing is left to rank and nothing was
|
|
404
|
+
# analysed.
|
|
405
|
+
return ComparisonResult(
|
|
406
|
+
difference=0.0,
|
|
407
|
+
ci_low=0.0,
|
|
408
|
+
ci_high=0.0,
|
|
409
|
+
p_value=1.0,
|
|
410
|
+
n=0,
|
|
411
|
+
method="wilcoxon",
|
|
412
|
+
binary=binary,
|
|
413
|
+
paired=True,
|
|
414
|
+
confidence=confidence,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Items the two systems tied on carry no sign, and the signed-rank test
|
|
418
|
+
# drops them. The estimate and the interval drop them too, so all three
|
|
419
|
+
# describe the same set of items.
|
|
420
|
+
d_signed = d[d != 0]
|
|
421
|
+
exact = _signed_rank_is_exact(d_signed)
|
|
422
|
+
p_value = float(
|
|
423
|
+
_stats.wilcoxon(d_signed, method="exact" if exact else "approx").pvalue
|
|
424
|
+
)
|
|
425
|
+
walsh = _walsh_averages(d_signed)
|
|
426
|
+
difference = float(np.median(walsh))
|
|
427
|
+
ci_low, ci_high = _signed_rank_ci(
|
|
428
|
+
walsh,
|
|
429
|
+
d_signed.size,
|
|
430
|
+
confidence,
|
|
431
|
+
exact,
|
|
432
|
+
0.0 if exact else _tie_correction(d_signed),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return ComparisonResult(
|
|
436
|
+
difference=difference,
|
|
437
|
+
ci_low=ci_low,
|
|
438
|
+
ci_high=ci_high,
|
|
439
|
+
p_value=p_value,
|
|
440
|
+
# The items the two systems tied on are not in any of these numbers,
|
|
441
|
+
# so they are not in the count either.
|
|
442
|
+
n=int(d_signed.size),
|
|
443
|
+
method="wilcoxon",
|
|
444
|
+
binary=binary,
|
|
445
|
+
paired=True,
|
|
446
|
+
confidence=confidence,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _walsh_averages(d: np.ndarray) -> np.ndarray:
|
|
451
|
+
"""Sorted pairwise averages (d_i + d_j) / 2 for i <= j."""
|
|
452
|
+
i, j = np.triu_indices(len(d))
|
|
453
|
+
return np.sort((d[i] + d[j]) / 2.0)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _signed_rank_is_exact(d_signed: np.ndarray) -> bool:
|
|
457
|
+
"""Whether the exact signed-rank distribution applies to these numbers.
|
|
458
|
+
|
|
459
|
+
It needs every difference distinct in size, since ties break the ranking
|
|
460
|
+
the distribution is built on. Zeros are already gone by this point. The
|
|
461
|
+
size cap is scipy's own cutoff for the exact test.
|
|
462
|
+
"""
|
|
463
|
+
if d_signed.size > _SIGNED_RANK_EXACT_MAX:
|
|
464
|
+
return False
|
|
465
|
+
magnitudes = np.abs(d_signed)
|
|
466
|
+
return bool(np.unique(magnitudes).size == magnitudes.size)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _signed_rank_counts(n: int) -> np.ndarray:
|
|
470
|
+
"""How many sign patterns give each possible rank sum.
|
|
471
|
+
|
|
472
|
+
Under the null every one of the 2^n sign patterns is equally likely, so
|
|
473
|
+
counting subsets of {1, ..., n} by their sum gives the whole null
|
|
474
|
+
distribution. Counts stay whole numbers below 2^53 for n up to 50, so
|
|
475
|
+
float64 holds them exactly.
|
|
476
|
+
"""
|
|
477
|
+
counts = np.zeros(n * (n + 1) // 2 + 1)
|
|
478
|
+
counts[0] = 1.0
|
|
479
|
+
for rank in range(1, n + 1):
|
|
480
|
+
counts[rank:] += counts[:-rank].copy()
|
|
481
|
+
return counts
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _tie_correction(d_signed: np.ndarray) -> float:
|
|
485
|
+
"""The sum of t^3 - t over groups of differences with the same size.
|
|
486
|
+
|
|
487
|
+
Tied magnitudes share an average rank, which cuts the spread of the
|
|
488
|
+
signed-rank statistic. This is the quantity scipy subtracts from the
|
|
489
|
+
variance to account for it, and groups of one contribute nothing.
|
|
490
|
+
"""
|
|
491
|
+
_, sizes = np.unique(np.abs(d_signed), return_counts=True)
|
|
492
|
+
return float(np.sum(sizes.astype(float) ** 3 - sizes))
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _signed_rank_trim(
|
|
496
|
+
n: int, confidence: float, exact: bool, tie_correction: float = 0.0
|
|
497
|
+
) -> int:
|
|
498
|
+
"""How many Walsh averages to drop from each end of the sorted list.
|
|
499
|
+
|
|
500
|
+
The interval is the set of shifts the signed-rank test would not reject,
|
|
501
|
+
so the number trimmed is the test's critical value. Taking it from the
|
|
502
|
+
exact distribution keeps the interval and the p-value on the same
|
|
503
|
+
footing, which is the whole point of doing it this way.
|
|
504
|
+
"""
|
|
505
|
+
if not exact:
|
|
506
|
+
# Same variance scipy uses, tie correction included. Without the
|
|
507
|
+
# correction the interval would be drawn from a wider distribution
|
|
508
|
+
# than the p-value beside it, which on binary data, where every
|
|
509
|
+
# difference is the same size, is most of the distribution.
|
|
510
|
+
# scipy's approximate p-value carries no continuity correction, so
|
|
511
|
+
# neither does this.
|
|
512
|
+
z = _stats.norm.ppf(1 - (1 - confidence) / 2)
|
|
513
|
+
spread = n * (n + 1) * (2 * n + 1) - tie_correction / 2.0
|
|
514
|
+
sd = np.sqrt(max(spread, 0.0) / 24.0)
|
|
515
|
+
return int(np.floor(n * (n + 1) / 4.0 - z * sd))
|
|
516
|
+
|
|
517
|
+
counts = _signed_rank_counts(n)
|
|
518
|
+
cdf = np.cumsum(counts) / counts.sum()
|
|
519
|
+
eligible = np.nonzero(cdf <= (1 - confidence) / 2)[0]
|
|
520
|
+
if eligible.size == 0:
|
|
521
|
+
# Below about six items the distribution is too coarse for this
|
|
522
|
+
# confidence level to rule anything out. Trim nothing and return the
|
|
523
|
+
# widest interval the data offer, which is what R does in the same
|
|
524
|
+
# corner. The interval can then sit clear of zero while the test
|
|
525
|
+
# declines to reject, the same discreteness that shows up in McNemar
|
|
526
|
+
# at tiny discordant counts.
|
|
527
|
+
return 0
|
|
528
|
+
return int(eligible[-1])
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _signed_rank_ci(
|
|
532
|
+
walsh: np.ndarray,
|
|
533
|
+
n: int,
|
|
534
|
+
confidence: float,
|
|
535
|
+
exact: bool,
|
|
536
|
+
tie_correction: float = 0.0,
|
|
537
|
+
) -> tuple[float, float]:
|
|
538
|
+
"""Distribution-free interval on the Hodges-Lehmann shift."""
|
|
539
|
+
m = len(walsh)
|
|
540
|
+
trim = _signed_rank_trim(n, confidence, exact, tie_correction)
|
|
541
|
+
k = int(np.clip(trim, 0, (m - 1) // 2))
|
|
542
|
+
return float(walsh[k]), float(walsh[m - 1 - k])
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# --------------------------------------------------------------------------
|
|
546
|
+
# Independent methods
|
|
547
|
+
# --------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def _constrained_rates(
|
|
550
|
+
p1: float, n1: int, p2: float, n2: int, delta: float
|
|
551
|
+
) -> tuple[float, float]:
|
|
552
|
+
"""Maximum likelihood pass rates given that the difference is delta.
|
|
553
|
+
|
|
554
|
+
The two rates are tied together by the hypothesis, so only one is free,
|
|
555
|
+
and it is the root of a cubic. Farrington and Manning (1990) give the
|
|
556
|
+
closed form used here. At delta=0 the root is the pooled pass rate,
|
|
557
|
+
which is what makes the interval and the z test the same procedure.
|
|
558
|
+
"""
|
|
559
|
+
t = n2 / n1
|
|
560
|
+
a = 1.0 + t
|
|
561
|
+
b = -(1.0 + t + p1 + t * p2 + delta * (t + 2.0))
|
|
562
|
+
c = delta * delta + delta * (2.0 * p1 + t + 1.0) + p1 + t * p2
|
|
563
|
+
d = -p1 * delta * (1.0 + delta)
|
|
564
|
+
|
|
565
|
+
v = (b / a / 3.0) ** 3 - b * c / (6.0 * a * a) + d / a / 2.0
|
|
566
|
+
s = np.sqrt(max((b / a / 3.0) ** 2 - c / a / 3.0, 0.0))
|
|
567
|
+
if s == 0:
|
|
568
|
+
q1 = -b / (3.0 * a)
|
|
569
|
+
else:
|
|
570
|
+
u = s if v > 0 else -s
|
|
571
|
+
w = (np.pi + np.arccos(np.clip(v / u**3, -1.0, 1.0))) / 3.0
|
|
572
|
+
q1 = 2.0 * u * np.cos(w) - b / a / 3.0
|
|
573
|
+
return q1, q1 - delta
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _score_diff_stat(
|
|
577
|
+
p1: float, n1: int, p2: float, n2: int, delta: float
|
|
578
|
+
) -> float:
|
|
579
|
+
"""Score statistic for the hypothesis that the difference is delta."""
|
|
580
|
+
num = p1 - p2 - delta
|
|
581
|
+
if num == 0:
|
|
582
|
+
return 0.0
|
|
583
|
+
q1, q2 = _constrained_rates(p1, n1, p2, n2, delta)
|
|
584
|
+
var = q1 * (1.0 - q1) / n1 + q2 * (1.0 - q2) / n2
|
|
585
|
+
if var <= 0:
|
|
586
|
+
return _EDGE_SCORE if num > 0 else -_EDGE_SCORE
|
|
587
|
+
return num / np.sqrt(var)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _score_diff_ci(
|
|
591
|
+
x: np.ndarray, y: np.ndarray, confidence: float
|
|
592
|
+
) -> tuple[float, float]:
|
|
593
|
+
"""Score interval for a difference of independent proportions.
|
|
594
|
+
|
|
595
|
+
Mee (1984), with the constrained estimates solved as in Farrington and
|
|
596
|
+
Manning (1990). Same construction as the paired case: the bounds are the
|
|
597
|
+
two differences the data would just barely reject, so the interval and
|
|
598
|
+
the p-value are one procedure rather than two.
|
|
599
|
+
|
|
600
|
+
Miettinen and Nurminen (1985) give the same interval with the variance
|
|
601
|
+
multiplied by N / (N - 1). That factor is left out here on purpose. The
|
|
602
|
+
p-value reported beside this interval is the pooled two-proportion z
|
|
603
|
+
test, whose variance carries no such factor, so adding it would pull the
|
|
604
|
+
bounds slightly wider than the test and break the agreement that is the
|
|
605
|
+
reason for using a score interval at all. Measured over 9213 tables the
|
|
606
|
+
correction produced 4 disagreements where this version produces none,
|
|
607
|
+
and it bought no coverage in exchange: both sit within 0.0005 of each
|
|
608
|
+
other across five simulated settings, with Miettinen-Nurminen intervals
|
|
609
|
+
about 0.3 to 1 percent wider. Anyone wanting that variant should use the
|
|
610
|
+
factor in both places, not one.
|
|
611
|
+
|
|
612
|
+
Newcombe's square-and-add interval is the usual alternative and it is a
|
|
613
|
+
fine interval. It came out of this package because it is built from two
|
|
614
|
+
one-sample Wilson intervals, so nothing ties it to the two-sample test
|
|
615
|
+
reported beside it, and the two can disagree about zero.
|
|
616
|
+
"""
|
|
617
|
+
z = _stats.norm.ppf(1 - (1 - confidence) / 2)
|
|
618
|
+
n1, n2 = int(x.size), int(y.size)
|
|
619
|
+
p1, p2 = float(np.mean(x)), float(np.mean(y))
|
|
620
|
+
d_hat = p1 - p2
|
|
621
|
+
edge = 1.0 - 1e-9
|
|
622
|
+
|
|
623
|
+
if d_hat <= -edge:
|
|
624
|
+
lo = -1.0
|
|
625
|
+
else:
|
|
626
|
+
def below(delta: float) -> float:
|
|
627
|
+
return _score_diff_stat(p1, n1, p2, n2, delta) - z
|
|
628
|
+
|
|
629
|
+
lo = (
|
|
630
|
+
float(_optimize.brentq(below, -edge, d_hat))
|
|
631
|
+
if below(-edge) > 0
|
|
632
|
+
else -1.0
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
if d_hat >= edge:
|
|
636
|
+
hi = 1.0
|
|
637
|
+
else:
|
|
638
|
+
def above(delta: float) -> float:
|
|
639
|
+
return _score_diff_stat(p1, n1, p2, n2, delta) + z
|
|
640
|
+
|
|
641
|
+
hi = (
|
|
642
|
+
float(_optimize.brentq(above, d_hat, edge))
|
|
643
|
+
if above(edge) < 0
|
|
644
|
+
else 1.0
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
return lo, hi
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _two_proportion_p(x: np.ndarray, y: np.ndarray) -> float:
|
|
651
|
+
"""Two-sided score test on two independent proportions.
|
|
652
|
+
|
|
653
|
+
The pooled variance is the maximum likelihood estimate under the null,
|
|
654
|
+
which makes this the score test at a difference of zero and the exact
|
|
655
|
+
test that :func:`_score_diff_ci` inverts.
|
|
656
|
+
"""
|
|
657
|
+
n1, n2 = int(x.size), int(y.size)
|
|
658
|
+
p1, p2 = float(np.mean(x)), float(np.mean(y))
|
|
659
|
+
pooled = (np.sum(x) + np.sum(y)) / (n1 + n2)
|
|
660
|
+
se = np.sqrt(pooled * (1 - pooled) * (1 / n1 + 1 / n2))
|
|
661
|
+
if se == 0:
|
|
662
|
+
return 1.0
|
|
663
|
+
z = (p1 - p2) / se
|
|
664
|
+
return float(2 * _stats.norm.sf(abs(z)))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _welch(
|
|
668
|
+
x: np.ndarray, y: np.ndarray, confidence: float
|
|
669
|
+
) -> tuple[float, float, float]:
|
|
670
|
+
"""Welch's t interval and p-value for two independent means."""
|
|
671
|
+
n1, n2 = int(x.size), int(y.size)
|
|
672
|
+
if n1 < 2 or n2 < 2:
|
|
673
|
+
raise ValueError(
|
|
674
|
+
f"the t method needs at least 2 items per group to estimate a "
|
|
675
|
+
f"variance, got {n1} and {n2}. Use method='bootstrap' if one "
|
|
676
|
+
f"group really has a single item"
|
|
677
|
+
)
|
|
678
|
+
res = _stats.ttest_ind(x, y, equal_var=False)
|
|
679
|
+
v1 = float(np.var(x, ddof=1)) / n1
|
|
680
|
+
v2 = float(np.var(y, ddof=1)) / n2
|
|
681
|
+
se = np.sqrt(v1 + v2)
|
|
682
|
+
df = (v1 + v2) ** 2 / (v1**2 / (n1 - 1) + v2**2 / (n2 - 1))
|
|
683
|
+
t_crit = _stats.t.ppf(1 - (1 - confidence) / 2, df=df)
|
|
684
|
+
diff = float(np.mean(x) - np.mean(y))
|
|
685
|
+
return diff - t_crit * se, diff + t_crit * se, float(res.pvalue)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# --------------------------------------------------------------------------
|
|
689
|
+
# Shared helpers
|
|
690
|
+
# --------------------------------------------------------------------------
|
|
691
|
+
|
|
692
|
+
def _percentile_ci(boot: np.ndarray, confidence: float) -> tuple[float, float]:
|
|
693
|
+
alpha = 1 - confidence
|
|
694
|
+
lo = float(np.percentile(boot, 100 * alpha / 2))
|
|
695
|
+
hi = float(np.percentile(boot, 100 * (1 - alpha / 2)))
|
|
696
|
+
return lo, hi
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _bootstrap_p(boot: np.ndarray) -> float:
|
|
700
|
+
"""Two-sided bootstrap p-value.
|
|
701
|
+
|
|
702
|
+
The share of resamples falling on the far side of zero, doubled. It
|
|
703
|
+
cannot resolve past 1 / n_boot, so it is floored there rather than
|
|
704
|
+
reported as zero.
|
|
705
|
+
"""
|
|
706
|
+
n_boot = len(boot)
|
|
707
|
+
tail = min(float(np.mean(boot <= 0)), float(np.mean(boot >= 0)))
|
|
708
|
+
return float(min(max(2 * tail, 1.0 / n_boot), 1.0))
|