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/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))