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/power.py ADDED
@@ -0,0 +1,528 @@
1
+ """What an eval could have found, and how big the next one has to be.
2
+
3
+ The question this module exists to answer comes after the result, not
4
+ before it. Someone hands you a finished eval, two systems, a difference
5
+ that did not reach significance, and asks whether that means the systems
6
+ are the same. Usually it does not. It means the eval was never large enough
7
+ to tell, and that was fixed before the first item was graded.
8
+
9
+ For paired binary data the size that matters is not the number of items.
10
+ McNemar reads only the pairs where the two systems disagree, and the rest
11
+ carry no information about which one is better. So the calculation runs on
12
+ the discordance rate. An eval with 500 items and 12 discordant pairs is a
13
+ 12 item study, and 488 of those items did nothing but cost money.
14
+
15
+ That rate is a property of the two systems and the item set, so it cannot
16
+ be guessed from the pass rates. When it is not supplied these functions
17
+ stand in a conservative 0.3 and say so in the output. They never assume it
18
+ quietly.
19
+
20
+ The baseline pass rate has no part in any of this, which is why
21
+ :func:`min_sample_size` takes the difference first and leaves the baseline
22
+ as a keyword. A paired call reads ``min_sample_size(0.05)`` and passes
23
+ nothing it does not use.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Optional
29
+
30
+ import numpy as np
31
+ from scipy import optimize as _optimize
32
+ from scipy import stats as _stats
33
+
34
+ from ._types import PowerResult
35
+
36
+ __all__ = ["detectable_effect", "min_sample_size"]
37
+
38
+ # Stands in when no discordance rate is supplied. Conservative in both
39
+ # directions, since a higher rate means a larger detectable difference and a
40
+ # larger required sample. Real evals usually run well under it, so the
41
+ # answers come out pessimistic rather than flattering. That is the safe way
42
+ # to be wrong when the question is what an eval could have found.
43
+ _DEFAULT_DISCORDANCE = 0.3
44
+
45
+ # The difference the summary prices out, so the reader has a familiar
46
+ # anchor beside the answer. Five points is the size people mean when they
47
+ # say a system improved.
48
+ _REFERENCE_DIFFERENCE = 0.05
49
+
50
+ # Room left at the top of the bracket when solving for an independent
51
+ # difference. The largest difference a baseline allows is 1 - baseline, and
52
+ # brentq needs the bracket to be a genuine interval.
53
+ _BRACKET_FLOOR = 1e-12
54
+
55
+ # brentq's tolerance on the difference. The sample size it feeds back into
56
+ # moves about 2n / difference per unit, so at eval sizes this leaves the
57
+ # roundtrip accurate to about 1e-8 of an item.
58
+ _SOLVER_XTOL = 1e-14
59
+
60
+
61
+ def detectable_effect(
62
+ n: int,
63
+ baseline: float = 0.5,
64
+ power: float = 0.8,
65
+ alpha: float = 0.05,
66
+ paired: bool = True,
67
+ discordance_rate: Optional[float] = None,
68
+ ) -> PowerResult:
69
+ """The smallest difference an eval of this size could have found.
70
+
71
+ The audit direction. You are handed a completed evaluation and you want
72
+ to know what it could ever have shown, whatever it happened to show.
73
+
74
+ Parameters
75
+ ----------
76
+ n
77
+ Items the eval ran. Paired comparisons when ``paired`` is True, and
78
+ the total across both groups when it is False, which is the same
79
+ convention :func:`evalaudit.compare_independent` uses for its ``n``.
80
+ baseline
81
+ Pass rate of the system being compared against. Used only when
82
+ ``paired`` is False. A paired binary calculation does not depend on
83
+ it, so it is carried into the result and otherwise ignored. See
84
+ Notes.
85
+ power
86
+ The chance of finding a difference that is really there. 0.8 is the
87
+ convention.
88
+ alpha
89
+ Two sided significance level.
90
+ paired
91
+ True when both systems were run on the same items, which is how
92
+ most evals are built.
93
+ discordance_rate
94
+ Share of items the two systems disagree on. Paired designs only.
95
+ When it is not supplied, 0.3 stands in and ``discordance_assumed``
96
+ comes back True. See Notes.
97
+
98
+ Returns
99
+ -------
100
+ PowerResult
101
+ With ``solved_for="difference"``. Check ``attainable`` before
102
+ reading ``difference``.
103
+
104
+ Notes
105
+ -----
106
+ Paired binary power runs on the discordance rate, not on the number of
107
+ items and not on the baseline. McNemar's test looks only at the pairs
108
+ where one system passed and the other failed. Items both systems passed
109
+ and items both failed drop out of the statistic entirely, so an eval
110
+ with 500 items and 12 discordant pairs carries the information of a 12
111
+ item study.
112
+
113
+ The direction surprises people. A higher discordance rate makes the
114
+ detectable difference larger, not smaller. Disagreement between the two
115
+ systems is noise in the per item comparison, so systems that agree on
116
+ most items are the ones McNemar can separate sharply.
117
+
118
+ The arithmetic is Connor's normal approximation, which is the standard
119
+ published formula. evalaudit's own McNemar runs the continuity
120
+ corrected chi-square above 25 discordant pairs and the exact binomial
121
+ below it, and both are a little conservative. Measured over 200,000
122
+ simulated evals the shipped test delivers about 0.784 where this
123
+ formula promises 0.800. So the difference reported here is a floor. An
124
+ eval could not have found anything smaller, and in practice it needs a
125
+ little more.
126
+
127
+ Examples
128
+ --------
129
+ >>> detectable_effect(220, discordance_rate=0.3).difference
130
+ 0.10289...
131
+ """
132
+ _check_power_and_alpha(power, alpha)
133
+ _check_baseline(baseline)
134
+ n = _check_n(n)
135
+ rate = _resolve_discordance(paired, discordance_rate)
136
+
137
+ if paired:
138
+ difference = _paired_difference(n, rate, alpha, power)
139
+ # <= rather than <, matching min_sample_size and _reference_size. A
140
+ # difference equal to the discordance rate is the table where every
141
+ # discordant pair fell the same way, c is zero and b is the whole
142
+ # rate. That is a real point in the parameter space and the
143
+ # arithmetic goes through it, so all three places treat it as
144
+ # reachable.
145
+ #
146
+ # The two comparisons cannot be told apart by a test that goes
147
+ # through this function. Landing on the tie needs the solved
148
+ # difference to equal the rate in float, and a scan of eight rates
149
+ # against every size from 2 to 4000 found no such n. The convention
150
+ # is pinned where it is observable instead, on the min_sample_size
151
+ # and _reference_size side, by
152
+ # test_the_boundary_convention_is_the_same_in_all_three_places.
153
+ attainable = bool(np.isfinite(difference) and difference <= rate)
154
+ n_discordant = int(round(n * rate))
155
+ n_per_group = None
156
+ else:
157
+ n_per_group = n / 2.0
158
+ difference = _independent_difference(n_per_group, baseline, alpha, power)
159
+ attainable = bool(np.isfinite(difference))
160
+ n_discordant = None
161
+
162
+ return PowerResult(
163
+ difference=float(difference),
164
+ n=n,
165
+ baseline=baseline,
166
+ power=power,
167
+ alpha=alpha,
168
+ paired=paired,
169
+ solved_for="difference",
170
+ attainable=attainable,
171
+ discordance_rate=rate,
172
+ discordance_assumed=paired and discordance_rate is None,
173
+ n_discordant=n_discordant,
174
+ n_per_group=n_per_group,
175
+ reference_difference=_REFERENCE_DIFFERENCE,
176
+ n_for_reference=_reference_size(baseline, power, alpha, paired, rate),
177
+ )
178
+
179
+
180
+ def min_sample_size(
181
+ mde: float,
182
+ baseline: float = 0.5,
183
+ power: float = 0.8,
184
+ alpha: float = 0.05,
185
+ paired: bool = True,
186
+ discordance_rate: Optional[float] = None,
187
+ ) -> PowerResult:
188
+ """How many items it takes to find a difference of a given size.
189
+
190
+ The design direction, for a team planning a run. It is the same
191
+ relation :func:`detectable_effect` solves, turned around, so composing
192
+ the two returns where it started.
193
+
194
+ Parameters
195
+ ----------
196
+ mde
197
+ Minimum difference worth detecting, on the 0 to 1 scale. 0.05 is
198
+ five points of pass rate. First because it is the only argument a
199
+ paired design needs, and a paired design is the default.
200
+ baseline
201
+ Pass rate of the system being compared against. Used only when
202
+ ``paired`` is False, for the same reason given there. A paired call
203
+ should leave it alone rather than pass a number that does nothing.
204
+ power
205
+ The chance of finding that difference when it is really there.
206
+ alpha
207
+ Two sided significance level.
208
+ paired
209
+ True when both systems will be run on the same items. Nearly always
210
+ worth doing, and it cuts the number of items sharply.
211
+ discordance_rate
212
+ Share of items the two systems will disagree on. Paired designs
213
+ only. When it is not supplied, 0.3 stands in and the result says so.
214
+
215
+ Returns
216
+ -------
217
+ PowerResult
218
+ With ``solved_for="n"``. ``n`` is paired comparisons for a paired
219
+ design and the total across both groups for an independent one,
220
+ with ``n_per_group`` carrying the split.
221
+
222
+ Raises
223
+ ------
224
+ ValueError
225
+ When the difference asked for cannot occur. A paired difference
226
+ cannot exceed the discordance rate, since both come from the same
227
+ discordant pairs. A difference equal to the rate is allowed, since
228
+ that is the table where every discordant pair fell the same way.
229
+ An independent difference cannot push the second pass rate past 1.
230
+
231
+ Notes
232
+ -----
233
+ Everything in the Notes on :func:`detectable_effect` applies here. The
234
+ number this returns is a floor on the paired side for the same reason,
235
+ so treat it as the least a run could get away with.
236
+
237
+ Examples
238
+ --------
239
+ >>> min_sample_size(0.05, discordance_rate=0.3).n
240
+ 940
241
+ """
242
+ _check_power_and_alpha(power, alpha)
243
+ _check_baseline(baseline)
244
+ _check_mde(mde)
245
+ rate = _resolve_discordance(paired, discordance_rate)
246
+
247
+ if paired:
248
+ if mde > rate:
249
+ raise ValueError(
250
+ f"a difference of {mde} cannot occur at a discordance rate "
251
+ f"of {rate}. Both come from the same discordant pairs, so "
252
+ f"the difference is at most the discordance rate. Either "
253
+ f"the rate is higher than the one given or the difference "
254
+ f"is not one these two systems can produce"
255
+ )
256
+ exact = _paired_size(rate, mde, alpha, power)
257
+ n = int(np.ceil(exact))
258
+ n_discordant = int(round(n * rate))
259
+ n_per_group = None
260
+ else:
261
+ if baseline + mde > 1.0:
262
+ raise ValueError(
263
+ f"a difference of {mde} on a baseline of {baseline} puts the "
264
+ f"second pass rate at {baseline + mde}, above 1. Lower the "
265
+ f"baseline or the difference"
266
+ )
267
+ per_group = float(np.ceil(_independent_size(baseline, mde, alpha, power)))
268
+ n = int(2 * per_group)
269
+ n_discordant = None
270
+ n_per_group = per_group
271
+
272
+ return PowerResult(
273
+ difference=mde,
274
+ n=n,
275
+ baseline=baseline,
276
+ power=power,
277
+ alpha=alpha,
278
+ paired=paired,
279
+ solved_for="n",
280
+ attainable=True,
281
+ discordance_rate=rate,
282
+ discordance_assumed=paired and discordance_rate is None,
283
+ n_discordant=n_discordant,
284
+ n_per_group=n_per_group,
285
+ )
286
+
287
+
288
+ # --------------------------------------------------------------------------
289
+ # Paired binary, McNemar
290
+ # --------------------------------------------------------------------------
291
+
292
+ def _power_paired(
293
+ n: int, discordance_rate: float, difference: float, alpha: float
294
+ ) -> float:
295
+ """Power of McNemar's test, normal approximation.
296
+
297
+ Under the hypothesis of no difference the count b - c has variance
298
+ n * pi, and under the alternative it has n * (pi - delta^2). Writing the
299
+ rejection rule in the first and the distribution in the second gives
300
+
301
+ power = Phi((sqrt(n) delta - z sqrt(pi)) / sqrt(pi - delta^2))
302
+
303
+ The chance of rejecting on the wrong side is dropped. It runs under
304
+ 1e-5 at any size a real eval reaches, and dropping it is what lets the
305
+ two entry points be exact inverses of each other rather than
306
+ approximate ones.
307
+ """
308
+ z = _stats.norm.ppf(1 - alpha / 2)
309
+ spread = discordance_rate - difference**2
310
+ if spread <= 0:
311
+ # Every discordant pair falls the same way. Nothing is left to vary.
312
+ return 1.0
313
+ reach = np.sqrt(n) * difference - z * np.sqrt(discordance_rate)
314
+ return float(_stats.norm.cdf(reach / np.sqrt(spread)))
315
+
316
+
317
+ def _paired_size(
318
+ discordance_rate: float, difference: float, alpha: float, power: float
319
+ ) -> float:
320
+ """Pairs needed, Connor (1987).
321
+
322
+ n = [z sqrt(pi) + z_beta sqrt(pi - delta^2)]^2 / delta^2
323
+
324
+ Connor writes this with the discordant odds ratio in place of the
325
+ difference. The two are the same formula after substituting
326
+ delta = pi (psi - 1) / (psi + 1), and the test suite checks both routes
327
+ land on the same integer.
328
+ """
329
+ z, z_beta = _critical_values(alpha, power)
330
+ numerator = z * np.sqrt(discordance_rate) + z_beta * np.sqrt(
331
+ discordance_rate - difference**2
332
+ )
333
+ return float(numerator**2 / difference**2)
334
+
335
+
336
+ def _paired_difference(
337
+ n: int, discordance_rate: float, alpha: float, power: float
338
+ ) -> float:
339
+ """Smallest difference reaching the requested power, in closed form.
340
+
341
+ Solving _power_paired for delta squares once and gives a quadratic,
342
+
343
+ (n + z_beta^2) delta^2 - 2 sqrt(n) z sqrt(pi) delta
344
+ + pi (z^2 - z_beta^2) = 0
345
+
346
+ whose upper root tidies to the expression below. The discriminant is
347
+ pi z_beta^2 (n - z^2 + z_beta^2), so it goes negative on samples too
348
+ small to separate the two critical values at all. There is no
349
+ detectable difference there and the answer is NaN rather than a number.
350
+ """
351
+ z, z_beta = _critical_values(alpha, power)
352
+ inner = n - z**2 + z_beta**2
353
+ if inner < 0:
354
+ return float("nan")
355
+ reach = z * np.sqrt(n) + z_beta * np.sqrt(inner)
356
+ return float(np.sqrt(discordance_rate) * reach / (n + z_beta**2))
357
+
358
+
359
+ # --------------------------------------------------------------------------
360
+ # Independent binary, two proportions
361
+ # --------------------------------------------------------------------------
362
+
363
+ def _power_independent(
364
+ n_per_group: float, baseline: float, difference: float, alpha: float
365
+ ) -> float:
366
+ """Power of the two proportion score test, equal groups.
367
+
368
+ The null variance is pooled, which is the maximum likelihood estimate
369
+ when the two rates are equal and the variance the test in
370
+ :func:`evalaudit.compare_independent` actually uses. The alternative
371
+ variance is not pooled, since under the alternative the rates differ.
372
+ Same convention as the far tail in :func:`_power_paired`, dropped for
373
+ the same reason.
374
+ """
375
+ z = _stats.norm.ppf(1 - alpha / 2)
376
+ second = baseline + difference
377
+ pooled = (baseline + second) / 2
378
+ null_var = 2 * pooled * (1 - pooled)
379
+ alt_var = baseline * (1 - baseline) + second * (1 - second)
380
+ reach = abs(difference) - z * np.sqrt(null_var / n_per_group)
381
+ if alt_var <= 0:
382
+ # Both rates pinned at an edge, so the estimate cannot vary.
383
+ return 1.0 if reach > 0 else 0.0
384
+ return float(_stats.norm.cdf(reach / np.sqrt(alt_var / n_per_group)))
385
+
386
+
387
+ def _independent_size(
388
+ baseline: float, difference: float, alpha: float, power: float
389
+ ) -> float:
390
+ """Items per group needed, the pooled normal approximation.
391
+
392
+ n = [z sqrt(2 p q) + z_beta sqrt(p1 q1 + p2 q2)]^2 / delta^2
393
+
394
+ The formula behind every published two proportion sample size table,
395
+ and the one statsmodels solves in
396
+ ``samplesize_proportions_2indep_onetail``.
397
+ """
398
+ z, z_beta = _critical_values(alpha, power)
399
+ second = baseline + difference
400
+ pooled = (baseline + second) / 2
401
+ numerator = z * np.sqrt(2 * pooled * (1 - pooled)) + z_beta * np.sqrt(
402
+ baseline * (1 - baseline) + second * (1 - second)
403
+ )
404
+ return float(numerator**2 / difference**2)
405
+
406
+
407
+ def _independent_difference(
408
+ n_per_group: float, baseline: float, alpha: float, power: float
409
+ ) -> float:
410
+ """Smallest difference reaching the requested power.
411
+
412
+ Both variances move with the difference, so there is no closed form and
413
+ this is a root of :func:`_power_independent`. Power rises with the
414
+ difference everywhere it is below 1, so the root is unique.
415
+
416
+ The ceiling is 1 - baseline, since a pass rate cannot pass 1. When even
417
+ that difference falls short of the requested power the answer is NaN.
418
+ An eval this size against this baseline could not have found anything.
419
+ """
420
+ ceiling = 1.0 - baseline
421
+ if ceiling <= _BRACKET_FLOOR:
422
+ return float("nan")
423
+ if _power_independent(n_per_group, baseline, ceiling, alpha) < power:
424
+ return float("nan")
425
+
426
+ def shortfall(difference: float) -> float:
427
+ return _power_independent(n_per_group, baseline, difference, alpha) - power
428
+
429
+ if shortfall(_BRACKET_FLOOR) >= 0:
430
+ return _BRACKET_FLOOR
431
+ return float(
432
+ _optimize.brentq(shortfall, _BRACKET_FLOOR, ceiling, xtol=_SOLVER_XTOL)
433
+ )
434
+
435
+
436
+ # --------------------------------------------------------------------------
437
+ # Shared helpers
438
+ # --------------------------------------------------------------------------
439
+
440
+ def _critical_values(alpha: float, power: float) -> tuple[float, float]:
441
+ return _stats.norm.ppf(1 - alpha / 2), _stats.norm.ppf(power)
442
+
443
+
444
+ def _reference_size(
445
+ baseline: float,
446
+ power: float,
447
+ alpha: float,
448
+ paired: bool,
449
+ discordance_rate: Optional[float],
450
+ ) -> Optional[int]:
451
+ """Items needed for the reference difference, for the summary to quote.
452
+
453
+ None when that difference is not one the design could produce, drawn at
454
+ exactly the bar :func:`min_sample_size` refuses at. The summary must
455
+ never quote a size that function would decline to return.
456
+ """
457
+ if paired:
458
+ if discordance_rate is None or _REFERENCE_DIFFERENCE > discordance_rate:
459
+ return None
460
+ return int(
461
+ np.ceil(_paired_size(discordance_rate, _REFERENCE_DIFFERENCE, alpha, power))
462
+ )
463
+ if baseline + _REFERENCE_DIFFERENCE > 1.0:
464
+ return None
465
+ per_group = np.ceil(
466
+ _independent_size(baseline, _REFERENCE_DIFFERENCE, alpha, power)
467
+ )
468
+ return int(2 * per_group)
469
+
470
+
471
+ def _resolve_discordance(
472
+ paired: bool, discordance_rate: Optional[float]
473
+ ) -> Optional[float]:
474
+ if not paired:
475
+ if discordance_rate is not None:
476
+ raise ValueError(
477
+ "discordance_rate applies to paired designs only. An "
478
+ "independent comparison has no discordant pairs, since the "
479
+ "two systems were never run on the same items. Drop the "
480
+ "argument or set paired=True"
481
+ )
482
+ return None
483
+ if discordance_rate is None:
484
+ return _DEFAULT_DISCORDANCE
485
+ if not 0 < discordance_rate <= 1:
486
+ raise ValueError(
487
+ f"discordance_rate must be above 0 and at most 1, got "
488
+ f"{discordance_rate}. It is the share of items the two systems "
489
+ f"disagree on, and at 0 there is nothing for McNemar to read"
490
+ )
491
+ return float(discordance_rate)
492
+
493
+
494
+ def _check_power_and_alpha(power: float, alpha: float) -> None:
495
+ if not 0 < alpha < 1:
496
+ raise ValueError(f"alpha must be between 0 and 1, got {alpha}")
497
+ if not 0 < power < 1:
498
+ raise ValueError(f"power must be between 0 and 1, got {power}")
499
+ if power <= alpha:
500
+ raise ValueError(
501
+ f"power must exceed alpha, got power={power} and alpha={alpha}. "
502
+ f"A two sided test rejects at the alpha rate when there is "
503
+ f"nothing to find, so no sample size delivers less than that"
504
+ )
505
+
506
+
507
+ def _check_baseline(baseline: float) -> None:
508
+ if not 0 <= baseline <= 1:
509
+ raise ValueError(
510
+ f"baseline must be a pass rate between 0 and 1, got {baseline}"
511
+ )
512
+
513
+
514
+ def _check_mde(mde: float) -> None:
515
+ if not 0 < mde < 1:
516
+ raise ValueError(
517
+ f"mde must be a difference above 0 and below 1, got {mde}. It is "
518
+ f"points of pass rate on the 0 to 1 scale, so five points is 0.05"
519
+ )
520
+
521
+
522
+ def _check_n(n: int) -> int:
523
+ if n != int(n):
524
+ raise ValueError(f"n must be a whole number of items, got {n}")
525
+ n = int(n)
526
+ if n < 2:
527
+ raise ValueError(f"n must be at least 2 items, got {n}")
528
+ return n
evalaudit/scores.py ADDED
@@ -0,0 +1,136 @@
1
+ """Confidence intervals on a single eval score.
2
+
3
+ The most common missing piece in published eval results: a pass rate with no
4
+ interval around it. At n=50 the interval is roughly +/- 10 points, which is
5
+ usually wider than the difference being claimed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional, Sequence
11
+
12
+ import numpy as np
13
+ from scipy import stats as _stats
14
+
15
+ from ._types import ScoreCI
16
+
17
+ __all__ = ["score_ci"]
18
+
19
+ _VALID_METHODS = {"auto", "wilson", "bootstrap", "t"}
20
+
21
+
22
+ def score_ci(
23
+ scores: Sequence[float],
24
+ method: str = "auto",
25
+ confidence: float = 0.95,
26
+ n_boot: int = 10000,
27
+ seed: Optional[int] = None,
28
+ ) -> ScoreCI:
29
+ """Confidence interval on an eval score.
30
+
31
+ Parameters
32
+ ----------
33
+ scores
34
+ Binary (0/1) or continuous values, one per evaluated item.
35
+ method
36
+ ``"auto"`` picks Wilson for binary data and bootstrap for continuous.
37
+ ``"wilson"`` is the score interval for a proportion. Use it rather than
38
+ the normal approximation, which is visibly wrong at small n and near
39
+ the boundaries, and can return bounds outside [0, 1].
40
+ ``"bootstrap"`` is the percentile bootstrap of the mean.
41
+ ``"t"`` is the Student-t interval on the mean.
42
+ confidence
43
+ Nominal coverage, default 0.95.
44
+ n_boot
45
+ Bootstrap resamples. 1000 is enough for a consulting report;
46
+ the default is generous.
47
+ seed
48
+ Seeds the bootstrap so results are reproducible. Always set this in
49
+ anything you publish.
50
+
51
+ Returns
52
+ -------
53
+ ScoreCI
54
+
55
+ Examples
56
+ --------
57
+ >>> score_ci([1] * 42 + [0] * 8).summary()
58
+ '84.0% pass rate (95% CI: 71.5%-91.7%, n=50). ...'
59
+ """
60
+ x = np.asarray(scores, dtype=float)
61
+ n = len(x)
62
+ if n == 0:
63
+ raise ValueError("scores must not be empty")
64
+ if method not in _VALID_METHODS:
65
+ raise ValueError(f"method must be one of {_VALID_METHODS}, got {method!r}")
66
+
67
+ binary = _is_binary(x)
68
+
69
+ if method == "auto":
70
+ method = "wilson" if binary else "bootstrap"
71
+
72
+ estimate = float(np.mean(x))
73
+
74
+ if method == "wilson":
75
+ k = int(np.sum(x))
76
+ ci_low, ci_high = _wilson(k, n, confidence)
77
+ elif method == "bootstrap":
78
+ ci_low, ci_high = _bootstrap_mean(x, confidence, n_boot, seed)
79
+ else: # "t"
80
+ alpha = 1 - confidence
81
+ sem = float(np.std(x, ddof=1) / np.sqrt(n))
82
+ t_crit = _stats.t.ppf(1 - alpha / 2, df=n - 1)
83
+ ci_low = estimate - t_crit * sem
84
+ ci_high = estimate + t_crit * sem
85
+
86
+ return ScoreCI(
87
+ estimate=estimate,
88
+ ci_low=ci_low,
89
+ ci_high=ci_high,
90
+ n=n,
91
+ method=method,
92
+ binary=binary,
93
+ confidence=confidence,
94
+ )
95
+
96
+
97
+ def _is_binary(x: np.ndarray) -> bool:
98
+ """True when every value is 0 or 1."""
99
+ return bool(np.all((x == 0) | (x == 1)))
100
+
101
+
102
+ def _wilson(k: int, n: int, confidence: float) -> tuple[float, float]:
103
+ """Wilson score interval for a proportion.
104
+
105
+ (p + z^2/2n +/- z * sqrt( p(1-p)/n + z^2/4n^2 )) / (1 + z^2/n)
106
+
107
+ Clip to [0, 1] at the end.
108
+ """
109
+ p = k / n
110
+ z = _stats.norm.ppf(1 - (1 - confidence) / 2)
111
+ z2 = z * z
112
+ denom = 1 + z2 / n
113
+ centre = (p + z2 / (2 * n)) / denom
114
+ margin = (z / denom) * np.sqrt(p * (1 - p) / n + z2 / (4 * n * n))
115
+ lo = float(np.clip(centre - margin, 0.0, 1.0))
116
+ hi = float(np.clip(centre + margin, 0.0, 1.0))
117
+ return lo, hi
118
+
119
+
120
+ def _bootstrap_mean(
121
+ x: np.ndarray, confidence: float, n_boot: int, seed: Optional[int]
122
+ ) -> tuple[float, float]:
123
+ """Percentile bootstrap of the mean.
124
+
125
+ Vectorise this. Draw an (n_boot, n) index matrix with
126
+ ``rng.integers(0, n, size=(n_boot, n))`` and take row means in one pass.
127
+ A Python loop here is the difference between instant and slow on a laptop.
128
+ """
129
+ rng = np.random.default_rng(seed)
130
+ n = len(x)
131
+ idx = rng.integers(0, n, size=(n_boot, n))
132
+ means = x[idx].mean(axis=1)
133
+ alpha = 1 - confidence
134
+ lo = float(np.percentile(means, 100 * alpha / 2))
135
+ hi = float(np.percentile(means, 100 * (1 - alpha / 2)))
136
+ return lo, hi