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