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/_types.py
ADDED
|
@@ -0,0 +1,1572 @@
|
|
|
1
|
+
"""Return types for evalaudit.
|
|
2
|
+
|
|
3
|
+
Every public function returns one of these. Each carries the numbers and a
|
|
4
|
+
``summary()`` that states, in plain English, what the numbers do and do not
|
|
5
|
+
support. The summary is the point of the library: scipy already computes
|
|
6
|
+
McNemar, but nothing tells a product manager their result does not hold.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import html as _html
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _pct(x: float) -> str:
|
|
20
|
+
return f"{x * 100:.1f}%"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _plural(word: str, n: int) -> str:
|
|
24
|
+
return word if n == 1 else word + "s"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _escape(text: str) -> str:
|
|
28
|
+
"""HTML escape for a text node.
|
|
29
|
+
|
|
30
|
+
System names, slice labels and rater ids come out of the client's
|
|
31
|
+
spreadsheet and land in the report. They are data, not markup.
|
|
32
|
+
"""
|
|
33
|
+
return _html.escape(str(text), quote=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ScoreCI:
|
|
38
|
+
"""Confidence interval on a single eval score."""
|
|
39
|
+
|
|
40
|
+
estimate: float
|
|
41
|
+
ci_low: float
|
|
42
|
+
ci_high: float
|
|
43
|
+
n: int
|
|
44
|
+
method: str
|
|
45
|
+
binary: bool
|
|
46
|
+
confidence: float = 0.95
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def margin(self) -> float:
|
|
50
|
+
"""Half-width of the interval."""
|
|
51
|
+
return (self.ci_high - self.ci_low) / 2
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def width(self) -> float:
|
|
55
|
+
return self.ci_high - self.ci_low
|
|
56
|
+
|
|
57
|
+
def summary(self) -> str:
|
|
58
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
59
|
+
if self.binary:
|
|
60
|
+
head = (
|
|
61
|
+
f"{_pct(self.estimate)} pass rate "
|
|
62
|
+
f"({conf} CI: {_pct(self.ci_low)}-{_pct(self.ci_high)}, n={self.n})."
|
|
63
|
+
)
|
|
64
|
+
span = f"{self.width * 100:.0f} points"
|
|
65
|
+
else:
|
|
66
|
+
head = (
|
|
67
|
+
f"Mean score {self.estimate:.3f} "
|
|
68
|
+
f"({conf} CI: {self.ci_low:.3f}-{self.ci_high:.3f}, n={self.n})."
|
|
69
|
+
)
|
|
70
|
+
span = f"{self.width:.3f}"
|
|
71
|
+
|
|
72
|
+
tail = (
|
|
73
|
+
f" The interval spans {span}; treat differences smaller than that "
|
|
74
|
+
f"as unresolved."
|
|
75
|
+
)
|
|
76
|
+
if self.n < 30:
|
|
77
|
+
tail += (
|
|
78
|
+
f" With only {self.n} observations this estimate is weak "
|
|
79
|
+
f"regardless of the point value."
|
|
80
|
+
)
|
|
81
|
+
return head + tail
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str: # pragma: no cover
|
|
84
|
+
return self.summary()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class ComparisonResult:
|
|
89
|
+
"""Result of comparing two systems.
|
|
90
|
+
|
|
91
|
+
Returned by both entry points. ``paired`` says which one produced it,
|
|
92
|
+
since that is the difference that matters for reading the interval.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
difference: float
|
|
96
|
+
ci_low: float
|
|
97
|
+
ci_high: float
|
|
98
|
+
p_value: float
|
|
99
|
+
n: int
|
|
100
|
+
method: str
|
|
101
|
+
binary: bool
|
|
102
|
+
paired: bool
|
|
103
|
+
confidence: float = 0.95
|
|
104
|
+
n_discordant: Optional[int] = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def width(self) -> float:
|
|
108
|
+
return self.ci_high - self.ci_low
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def crosses_zero(self) -> bool:
|
|
112
|
+
return self.ci_low <= 0.0 <= self.ci_high
|
|
113
|
+
|
|
114
|
+
def summary(self) -> str:
|
|
115
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
116
|
+
pairing = "paired" if self.paired else "independent"
|
|
117
|
+
|
|
118
|
+
if self.binary:
|
|
119
|
+
head = (
|
|
120
|
+
f"Difference {_pct(self.difference)} "
|
|
121
|
+
f"({conf} CI: {_pct(self.ci_low)} to {_pct(self.ci_high)}, "
|
|
122
|
+
f"{pairing}, n={self.n})."
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
head = (
|
|
126
|
+
f"Difference {self.difference:.3f} "
|
|
127
|
+
f"({conf} CI: {self.ci_low:.3f} to {self.ci_high:.3f}, "
|
|
128
|
+
f"{pairing}, n={self.n})."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if self.crosses_zero:
|
|
132
|
+
verdict = (
|
|
133
|
+
" The interval crosses zero, so the data cannot confirm "
|
|
134
|
+
"that either system is better."
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
direction = "A" if self.difference > 0 else "B"
|
|
138
|
+
verdict = f" System {direction} is higher by this measure."
|
|
139
|
+
|
|
140
|
+
if self.n_discordant == 0:
|
|
141
|
+
# An empty discordant table. The p-value is a convention here,
|
|
142
|
+
# not a result, and the summary has to say so.
|
|
143
|
+
verdict = (
|
|
144
|
+
" No items changed between systems. The test has an empty "
|
|
145
|
+
"table, so the p-value cannot support any claim in either "
|
|
146
|
+
"direction."
|
|
147
|
+
)
|
|
148
|
+
elif self.n_discordant is not None:
|
|
149
|
+
verdict += (
|
|
150
|
+
f" {self.n_discordant} of {self.n} items changed between systems."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return head + verdict
|
|
154
|
+
|
|
155
|
+
def __str__(self) -> str: # pragma: no cover
|
|
156
|
+
return self.summary()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class KappaResult:
|
|
161
|
+
"""Cohen's or Fleiss' kappa.
|
|
162
|
+
|
|
163
|
+
Here because people ask for kappa by name. Krippendorff's alpha handles
|
|
164
|
+
everything these two do and several things they do not, so
|
|
165
|
+
``rater_agreement`` is the one to reach for.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
kappa: float
|
|
169
|
+
p_observed: float
|
|
170
|
+
p_expected: float
|
|
171
|
+
n_items: int
|
|
172
|
+
n_raters: int
|
|
173
|
+
n_categories: int
|
|
174
|
+
method: str
|
|
175
|
+
|
|
176
|
+
def summary(self) -> str:
|
|
177
|
+
name = "Cohen's" if self.method == "cohen" else "Fleiss'"
|
|
178
|
+
if not (self.kappa == self.kappa): # NaN
|
|
179
|
+
return (
|
|
180
|
+
f"{name} kappa is undefined. Every rating fell in one "
|
|
181
|
+
f"category, so agreement by chance is already total and "
|
|
182
|
+
f"there is nothing left for kappa to measure."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
head = (
|
|
186
|
+
f"{name} kappa {self.kappa:.3f} "
|
|
187
|
+
f"({self.n_items} items, {self.n_raters} raters, "
|
|
188
|
+
f"{self.n_categories} categories). "
|
|
189
|
+
f"Raters agreed on {_pct(self.p_observed)} of ratings, "
|
|
190
|
+
f"and {_pct(self.p_expected)} was expected by chance."
|
|
191
|
+
)
|
|
192
|
+
tail = (
|
|
193
|
+
" Kappa moves with how often each category is used, so the same "
|
|
194
|
+
"raters score lower on a lopsided scale than a balanced one. "
|
|
195
|
+
"Krippendorff's alpha does not have that problem and handles "
|
|
196
|
+
"missing data, so prefer rater_agreement for anything you report."
|
|
197
|
+
)
|
|
198
|
+
return head + tail
|
|
199
|
+
|
|
200
|
+
def __str__(self) -> str: # pragma: no cover
|
|
201
|
+
return self.summary()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Below this share of usable bootstrap resamples the interval is refused
|
|
205
|
+
# rather than computed from the survivors. Undefined resamples are the
|
|
206
|
+
# unanimous ones, so dropping them shaves the top off the distribution and
|
|
207
|
+
# pulls the upper bound down, hardest exactly when agreement is high.
|
|
208
|
+
_MIN_USABLE_SHARE = 0.90
|
|
209
|
+
|
|
210
|
+
# Krippendorff's own thresholds. Above 0.800 is reliable, 0.667 to 0.800
|
|
211
|
+
# supports tentative conclusions, below 0.667 supports none.
|
|
212
|
+
_ALPHA_RELIABLE = 0.800
|
|
213
|
+
_ALPHA_FLOOR = 0.667
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class AgreementResult:
|
|
218
|
+
"""Krippendorff's alpha, plus the two tables that say where it came from.
|
|
219
|
+
|
|
220
|
+
``item_disagreement`` ranks items by how much the graders differed on
|
|
221
|
+
them, normalised so an item graded ten times is not punished against one
|
|
222
|
+
graded twice. ``rater_dropout`` recomputes alpha with each rater removed.
|
|
223
|
+
|
|
224
|
+
Both tables invite a reader to act on the top row, so ``summary()``
|
|
225
|
+
refuses to name a rater unless dropping them shifts alpha by more than
|
|
226
|
+
the sampling error on alpha.
|
|
227
|
+
|
|
228
|
+
``alpha`` is NaN when the data cannot carry an estimate: when fewer than
|
|
229
|
+
two items were graded twice, or when every rating that could be compared
|
|
230
|
+
was identical. Neither is perfect agreement and neither gets a number.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
alpha: float
|
|
234
|
+
ci_low: float
|
|
235
|
+
ci_high: float
|
|
236
|
+
n_items: int
|
|
237
|
+
n_raters: int
|
|
238
|
+
n_overlapping_items: int
|
|
239
|
+
level: str
|
|
240
|
+
item_disagreement: pd.DataFrame
|
|
241
|
+
rater_dropout: pd.DataFrame
|
|
242
|
+
confidence: float = 0.95
|
|
243
|
+
n_boot: int = 0
|
|
244
|
+
n_boot_usable: int = 0
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def has_interval(self) -> bool:
|
|
248
|
+
return self.ci_low == self.ci_low and self.ci_high == self.ci_high
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def width(self) -> float:
|
|
252
|
+
return self.ci_high - self.ci_low
|
|
253
|
+
|
|
254
|
+
@property
|
|
255
|
+
def dropout_is_distinguishable(self) -> bool:
|
|
256
|
+
"""True when some rater's removal shifts alpha by more than noise.
|
|
257
|
+
|
|
258
|
+
The comparison is the leave-one-out shift against the sampling error
|
|
259
|
+
on alpha, taken as half the interval width. Comparing the shifted
|
|
260
|
+
alpha to ``ci_high`` instead looks equivalent and is not: when alpha
|
|
261
|
+
sits near zero the interval runs far below it and barely above, so
|
|
262
|
+
``ci_high`` is a low bar and pure noise clears it.
|
|
263
|
+
|
|
264
|
+
Without an interval there is nothing to measure the differences
|
|
265
|
+
against, so the answer is False rather than a guess.
|
|
266
|
+
"""
|
|
267
|
+
if not self.has_interval or self.rater_dropout.empty:
|
|
268
|
+
return False
|
|
269
|
+
best = self.rater_dropout["delta"].max()
|
|
270
|
+
return bool(best == best and best > self.width / 2)
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def top_dropout_rater(self) -> Optional[str]:
|
|
274
|
+
"""The rater worth investigating, or None when the data cannot say.
|
|
275
|
+
|
|
276
|
+
The dropout table always has a first row. This returns None unless
|
|
277
|
+
that first row is doing more than sorting noise.
|
|
278
|
+
"""
|
|
279
|
+
if not self.dropout_is_distinguishable:
|
|
280
|
+
return None
|
|
281
|
+
return self.rater_dropout.iloc[0]["rater_id"]
|
|
282
|
+
|
|
283
|
+
def summary(self) -> str:
|
|
284
|
+
if self.n_overlapping_items == 0:
|
|
285
|
+
return (
|
|
286
|
+
"Agreement was never measurable. No item was graded by more "
|
|
287
|
+
"than one rater, so there is nothing to compare and "
|
|
288
|
+
"Krippendorff's alpha does not exist for this data. Grade a "
|
|
289
|
+
"sample of items twice and run this again."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
carried = (
|
|
293
|
+
f"{self.n_overlapping_items} of {self.n_items} items "
|
|
294
|
+
f"graded more than once"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if self.alpha != self.alpha: # NaN
|
|
298
|
+
return self._undefined(carried) + self._dropout_sentence()
|
|
299
|
+
|
|
300
|
+
if self.has_interval:
|
|
301
|
+
head = (
|
|
302
|
+
f"Krippendorff's alpha {self.alpha:.3f} "
|
|
303
|
+
f"({self.confidence * 100:.0f}% CI: {self.ci_low:.3f} to "
|
|
304
|
+
f"{self.ci_high:.3f}, {self.level}, {carried})."
|
|
305
|
+
)
|
|
306
|
+
else:
|
|
307
|
+
head = (
|
|
308
|
+
f"Krippendorff's alpha {self.alpha:.3f} "
|
|
309
|
+
f"({self.level}, {carried})." + self._refusal_sentence()
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return head + self._verdict() + self._dropout_sentence()
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def _no_variance(self) -> bool:
|
|
316
|
+
"""Every pairable rating was the same value.
|
|
317
|
+
|
|
318
|
+
vs_chance divides by expected disagreement, so it is NaN across the
|
|
319
|
+
board exactly when there was none.
|
|
320
|
+
"""
|
|
321
|
+
table = self.item_disagreement
|
|
322
|
+
return bool(not table.empty and table["vs_chance"].isna().all())
|
|
323
|
+
|
|
324
|
+
def _undefined(self, carried: str) -> str:
|
|
325
|
+
"""Why there is no alpha. Two causes, and they are opposite news.
|
|
326
|
+
|
|
327
|
+
Checked in the same order as the dropout notes: agreement first,
|
|
328
|
+
because a rubric everyone applied identically is a finding, and a
|
|
329
|
+
thin overlap is a scheduling problem.
|
|
330
|
+
"""
|
|
331
|
+
if self._no_variance:
|
|
332
|
+
return (
|
|
333
|
+
f"Krippendorff's alpha is undefined ({self.level}, "
|
|
334
|
+
f"{carried}). Every rating that could be compared was "
|
|
335
|
+
f"identical, so there is no disagreement to divide by. That "
|
|
336
|
+
f"is not perfect agreement, it is a scale nobody varied."
|
|
337
|
+
)
|
|
338
|
+
return (
|
|
339
|
+
f"Krippendorff's alpha is undefined ({self.level}, {carried}). "
|
|
340
|
+
f"One item cannot carry a reliability estimate, so there is no "
|
|
341
|
+
f"number to report and no interval around it. The arithmetic "
|
|
342
|
+
f"does return a value at this size, which is why it is withheld "
|
|
343
|
+
f"rather than shown. Grade a larger sample twice."
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _refusal_sentence(self) -> str:
|
|
347
|
+
"""Why there is no interval, when there is still an alpha."""
|
|
348
|
+
if self.n_boot == 0:
|
|
349
|
+
return " An interval was not requested."
|
|
350
|
+
dropped = self.n_boot - self.n_boot_usable
|
|
351
|
+
return (
|
|
352
|
+
f" No interval: {dropped} of {self.n_boot} resamples came back "
|
|
353
|
+
f"undefined, above the {(1 - _MIN_USABLE_SHARE) * 100:.0f}% this "
|
|
354
|
+
f"reports through. Those resamples are the unanimous ones, so "
|
|
355
|
+
f"percentiles of the rest would understate the upper bound."
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _verdict(self) -> str:
|
|
359
|
+
if self.alpha >= _ALPHA_RELIABLE:
|
|
360
|
+
return (
|
|
361
|
+
f" That is at or above {_ALPHA_RELIABLE:.3f}, the "
|
|
362
|
+
f"conventional bar for treating coded data as reliable."
|
|
363
|
+
)
|
|
364
|
+
if self.alpha >= _ALPHA_FLOOR:
|
|
365
|
+
return (
|
|
366
|
+
f" That sits between {_ALPHA_FLOOR:.3f} and "
|
|
367
|
+
f"{_ALPHA_RELIABLE:.3f}, which supports tentative "
|
|
368
|
+
f"conclusions and no firm ones."
|
|
369
|
+
)
|
|
370
|
+
return (
|
|
371
|
+
f" That is below {_ALPHA_FLOOR:.3f}, the conventional floor for "
|
|
372
|
+
f"drawing any conclusion from coded data. Fix the rubric before "
|
|
373
|
+
f"reading anything into the scores it produced."
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _dropout_sentence(self) -> str:
|
|
377
|
+
if self.rater_dropout.empty:
|
|
378
|
+
return ""
|
|
379
|
+
|
|
380
|
+
if self.dropout_is_distinguishable:
|
|
381
|
+
row = self.rater_dropout.iloc[0]
|
|
382
|
+
return (
|
|
383
|
+
f" Dropping {row['rater_id']} raises alpha to "
|
|
384
|
+
f"{row['alpha_without']:.3f}, a shift of {row['delta']:.3f} "
|
|
385
|
+
f"against a sampling error of {self.width / 2:.3f}. They "
|
|
386
|
+
f"graded {int(row['n_ratings'])} items, so weigh that shift "
|
|
387
|
+
f"against how much of the grading it rests on."
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if self.rater_dropout["alpha_without"].isna().all():
|
|
391
|
+
return (
|
|
392
|
+
" Every leave-one-out alpha is undefined, so the data cannot "
|
|
393
|
+
"distinguish the raters."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if not self.has_interval:
|
|
397
|
+
return (
|
|
398
|
+
" Without an interval on alpha there is nothing to measure "
|
|
399
|
+
"the leave-one-out differences against, so the data cannot "
|
|
400
|
+
"distinguish the raters."
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
f" The largest leave-one-out shift is "
|
|
405
|
+
f"{self.rater_dropout['delta'].max():.3f}, inside the sampling "
|
|
406
|
+
f"error of {self.width / 2:.3f} on alpha, so the data cannot "
|
|
407
|
+
f"distinguish the raters. Do not read the top of the dropout "
|
|
408
|
+
f"table as an outlier."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def __str__(self) -> str: # pragma: no cover
|
|
412
|
+
return self.summary()
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# --------------------------------------------------------------------------
|
|
416
|
+
# pairwise
|
|
417
|
+
# --------------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
# How the two tie policies read in a summary. Davidson's model is the third
|
|
420
|
+
# entry a reader might expect and it is deliberately absent. See
|
|
421
|
+
# evalaudit.pairwise for why there is a hook and no implementation.
|
|
422
|
+
_TIE_WORDS = {
|
|
423
|
+
"split": "ties split",
|
|
424
|
+
"drop": "ties dropped",
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _join(names) -> str:
|
|
429
|
+
"""Names in a sentence, with the Oxford comma."""
|
|
430
|
+
names = list(names)
|
|
431
|
+
if len(names) == 1:
|
|
432
|
+
return str(names[0])
|
|
433
|
+
if len(names) == 2:
|
|
434
|
+
return f"{names[0]} and {names[1]}"
|
|
435
|
+
return ", ".join(str(n) for n in names[:-1]) + f", and {names[-1]}"
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@dataclass(frozen=True)
|
|
439
|
+
class BTResult:
|
|
440
|
+
"""Bradley-Terry ratings, and how few of the models they actually order.
|
|
441
|
+
|
|
442
|
+
``ratings`` carries one row per model with its rating, an interval and
|
|
443
|
+
the number of comparisons behind it. ``win_matrix`` is the modelled
|
|
444
|
+
probability that the row model beats the column model. ``separable_pairs``
|
|
445
|
+
lists the pairs whose intervals do not overlap.
|
|
446
|
+
|
|
447
|
+
``n_separable`` against ``n_pairs`` is the number worth reporting. A six
|
|
448
|
+
model leaderboard has fifteen pairs, and published boards routinely rank
|
|
449
|
+
all six off data that orders two or three of them.
|
|
450
|
+
|
|
451
|
+
The ratings are differences and nothing else. One model is pinned at zero
|
|
452
|
+
to make the fit identifiable, and which one is a free choice, so the level
|
|
453
|
+
of a single rating carries no information. Only gaps do. The intervals are
|
|
454
|
+
on each rating measured against the average of the field, then shifted
|
|
455
|
+
onto the anchor named in ``reference``. Anchoring the interval on the
|
|
456
|
+
reference instead would hand the reference an interval of width zero and
|
|
457
|
+
make ``n_separable`` depend on an arbitrary pick.
|
|
458
|
+
|
|
459
|
+
Non-overlapping intervals are a conservative test of a difference. Some
|
|
460
|
+
pair called inseparable here would separate under a direct test of the
|
|
461
|
+
gap, so read ``n_separable`` as a floor on what the data orders.
|
|
462
|
+
|
|
463
|
+
``rating`` is NaN for every model when the fit does not exist. That is
|
|
464
|
+
two situations and they are not the same. ``connected`` is False when the
|
|
465
|
+
models fall into groups that never met, so there is no common scale.
|
|
466
|
+
``undefeated`` or ``winless`` is non-empty when they all met and some set
|
|
467
|
+
of models never lost outside itself, which sends its rating to infinity.
|
|
468
|
+
"""
|
|
469
|
+
|
|
470
|
+
ratings: pd.DataFrame
|
|
471
|
+
win_matrix: pd.DataFrame
|
|
472
|
+
separable_pairs: pd.DataFrame
|
|
473
|
+
reference: str
|
|
474
|
+
ties: str
|
|
475
|
+
n_models: int
|
|
476
|
+
n_comparisons: int
|
|
477
|
+
n_ties: int
|
|
478
|
+
n_dropped: int
|
|
479
|
+
n_items: int
|
|
480
|
+
n_pairs: int
|
|
481
|
+
connected: bool
|
|
482
|
+
comparable_groups: tuple
|
|
483
|
+
undefeated: tuple
|
|
484
|
+
winless: tuple
|
|
485
|
+
confidence: float = 0.95
|
|
486
|
+
n_boot: int = 0
|
|
487
|
+
n_boot_usable: int = 0
|
|
488
|
+
elo_scale: Optional[float] = None
|
|
489
|
+
elo_base: Optional[float] = None
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def has_fit(self) -> bool:
|
|
493
|
+
"""True when the maximum exists and is unique.
|
|
494
|
+
|
|
495
|
+
Ford's condition. Every model has to be reachable from every other
|
|
496
|
+
through a chain of wins, which is stronger than everyone having
|
|
497
|
+
played. A model that never lost has no finite rating.
|
|
498
|
+
"""
|
|
499
|
+
return self.connected and not self.undefeated
|
|
500
|
+
|
|
501
|
+
@property
|
|
502
|
+
def has_interval(self) -> bool:
|
|
503
|
+
if self.ratings.empty:
|
|
504
|
+
return False
|
|
505
|
+
return bool(self.ratings["ci_low"].notna().all())
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def is_elo(self) -> bool:
|
|
509
|
+
return self.elo_scale is not None
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def n_separable(self) -> int:
|
|
513
|
+
return int(len(self.separable_pairs))
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def units(self) -> str:
|
|
517
|
+
return "Elo" if self.is_elo else "log-odds"
|
|
518
|
+
|
|
519
|
+
def summary(self) -> str:
|
|
520
|
+
if not self.connected:
|
|
521
|
+
return self._disconnected()
|
|
522
|
+
if not self.has_fit:
|
|
523
|
+
return self._no_maximum()
|
|
524
|
+
return self._head() + self._separability() + self._reference_sentence()
|
|
525
|
+
|
|
526
|
+
# -- the two refusals ---------------------------------------------------
|
|
527
|
+
|
|
528
|
+
def _disconnected(self) -> str:
|
|
529
|
+
groups = "; ".join(_join(g) for g in self.comparable_groups)
|
|
530
|
+
return (
|
|
531
|
+
f"No ratings. The {self.n_models} models fall into "
|
|
532
|
+
f"{len(self.comparable_groups)} groups that never met: {groups}. "
|
|
533
|
+
f"Bradley-Terry puts models on one scale by chaining comparisons "
|
|
534
|
+
f"between them, and there is no chain from one group to the "
|
|
535
|
+
f"next, so nothing here can rank them against each other. Run "
|
|
536
|
+
f"comparisons across the groups, or rate each group on its own "
|
|
537
|
+
f"and report them as separate boards."
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _no_maximum(self) -> str:
|
|
541
|
+
head = (
|
|
542
|
+
f"No ratings. Every model was compared, and the fit still does "
|
|
543
|
+
f"not exist. "
|
|
544
|
+
)
|
|
545
|
+
if len(self.undefeated) == 1:
|
|
546
|
+
body = (
|
|
547
|
+
f"{self.undefeated[0]} never lost a comparison, so the "
|
|
548
|
+
f"likelihood keeps rising as its rating goes up and there is "
|
|
549
|
+
f"no maximum to report."
|
|
550
|
+
)
|
|
551
|
+
elif len(self.winless) == 1:
|
|
552
|
+
body = (
|
|
553
|
+
f"{self.winless[0]} never won a comparison, so the "
|
|
554
|
+
f"likelihood keeps rising as its rating goes down and there "
|
|
555
|
+
f"is no maximum to report."
|
|
556
|
+
)
|
|
557
|
+
else:
|
|
558
|
+
body = (
|
|
559
|
+
f"No model outside {_join(self.undefeated)} ever beat a "
|
|
560
|
+
f"model inside it, so that group's ratings run away from the "
|
|
561
|
+
f"rest and there is no maximum to report."
|
|
562
|
+
)
|
|
563
|
+
return head + body + (
|
|
564
|
+
" A rating needs every model reachable from every other through "
|
|
565
|
+
"a chain of wins, which a clean sweep breaks. Report the record "
|
|
566
|
+
"directly, or add comparisons that close the chain."
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# -- the ordinary report ------------------------------------------------
|
|
570
|
+
|
|
571
|
+
def _head(self) -> str:
|
|
572
|
+
scale = (
|
|
573
|
+
f"Elo ratings (scale {self.elo_scale:g}, base {self.elo_base:g})"
|
|
574
|
+
if self.is_elo
|
|
575
|
+
else "Bradley-Terry ratings"
|
|
576
|
+
)
|
|
577
|
+
ties = _TIE_WORDS.get(self.ties, self.ties)
|
|
578
|
+
if self.ties == "drop" and self.n_dropped:
|
|
579
|
+
ties = f"{ties}, {self.n_dropped} of them"
|
|
580
|
+
elif self.n_ties:
|
|
581
|
+
ties = f"{ties}, {self.n_ties} of them"
|
|
582
|
+
return (
|
|
583
|
+
f"{scale} for {self.n_models} models from "
|
|
584
|
+
f"{self.n_comparisons} {_plural('comparison', self.n_comparisons)} "
|
|
585
|
+
f"({ties})."
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def _separability(self) -> str:
|
|
589
|
+
if not self.has_interval:
|
|
590
|
+
return self._no_interval_sentence()
|
|
591
|
+
|
|
592
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
593
|
+
head = (
|
|
594
|
+
f" {self.n_separable} of {self.n_pairs} pairs "
|
|
595
|
+
f"{'is' if self.n_pairs == 1 else 'are'} separable at {conf}, "
|
|
596
|
+
f"meaning their intervals do not overlap."
|
|
597
|
+
)
|
|
598
|
+
if self.n_separable == 0:
|
|
599
|
+
return head + (
|
|
600
|
+
" Not one pair can be ordered from this data. Any ranking "
|
|
601
|
+
"built on it is a ranking of noise."
|
|
602
|
+
)
|
|
603
|
+
rest = self.n_pairs - self.n_separable
|
|
604
|
+
if rest == 0:
|
|
605
|
+
return head + " Every pair is ordered by this data."
|
|
606
|
+
return head + (
|
|
607
|
+
f" The other {rest} {_plural('pair', rest)} cannot be ordered, "
|
|
608
|
+
f"so a leaderboard that puts those models in a line is reporting "
|
|
609
|
+
f"an order the data does not carry."
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def _no_interval_sentence(self) -> str:
|
|
613
|
+
if self.n_boot == 0:
|
|
614
|
+
return (
|
|
615
|
+
" No interval was requested, so no pair can be called "
|
|
616
|
+
"separable. Separability is the finding here, so run this "
|
|
617
|
+
"again with resamples."
|
|
618
|
+
)
|
|
619
|
+
dropped = self.n_boot - self.n_boot_usable
|
|
620
|
+
return (
|
|
621
|
+
f" No interval: {dropped} of {self.n_boot} resamples came back "
|
|
622
|
+
f"undefined, above the {(1 - _MIN_USABLE_SHARE) * 100:.0f}% this "
|
|
623
|
+
f"reports through. A resample that misses a comparison can leave "
|
|
624
|
+
f"a model unbeaten inside it, and those resamples are the ones "
|
|
625
|
+
f"with the widest ratings, so percentiles of the rest would "
|
|
626
|
+
f"understate the spread. Without an interval no pair can be "
|
|
627
|
+
f"called separable."
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def _reference_sentence(self) -> str:
|
|
631
|
+
anchor = f"{self.elo_base:g}" if self.is_elo else "0"
|
|
632
|
+
return (
|
|
633
|
+
f" Ratings are anchored on {self.reference} at {anchor}. Only "
|
|
634
|
+
f"differences between models mean anything, and adding the same "
|
|
635
|
+
f"amount to every rating would change nothing about the fit or "
|
|
636
|
+
f"about which pairs separate."
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
def __str__(self) -> str: # pragma: no cover
|
|
640
|
+
return self.summary()
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# Slices thinner than this get a hedge in the summary even when they clear
|
|
644
|
+
# the interval. Same count ScoreCI uses to call an estimate weak.
|
|
645
|
+
_THIN_SLICE = 30
|
|
646
|
+
|
|
647
|
+
# Why a slice has no alpha. Checked in this order, which is the opposite of
|
|
648
|
+
# the order rater_dropout uses. There, a rater whose removal leaves everyone
|
|
649
|
+
# agreeing is a finding worth stating over the item count. Here a slice with
|
|
650
|
+
# one item is too small to measure whatever else is true of it.
|
|
651
|
+
_NOTE_SLICE_ONE_ITEM = "one item, too few to measure agreement"
|
|
652
|
+
_NOTE_SLICE_NO_VARIANCE = (
|
|
653
|
+
"human and judge used one label throughout, so alpha has no denominator"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@dataclass(frozen=True)
|
|
658
|
+
class JudgeValidation:
|
|
659
|
+
"""How well an LLM judge's labels line up with human labels.
|
|
660
|
+
|
|
661
|
+
``agreement`` is Krippendorff's alpha between the two, so it is the same
|
|
662
|
+
statistic ``rater_agreement`` reports and reads the same way. A judge is
|
|
663
|
+
a rater. ``accuracy`` is the plain share of items where the two labels
|
|
664
|
+
matched, reported beside it because people ask for it and because the
|
|
665
|
+
gap between the two numbers is informative on its own. On a lopsided
|
|
666
|
+
label set a judge that always guesses the common label scores high
|
|
667
|
+
accuracy and no agreement at all.
|
|
668
|
+
|
|
669
|
+
``by_slice`` is the part worth reading. Judges track humans on the easy
|
|
670
|
+
cases and come apart where the decision is close, so a good headline
|
|
671
|
+
number routinely hides the failure. The table is sorted ascending, worst
|
|
672
|
+
first, and ``summary()`` refuses to name the top row unless its
|
|
673
|
+
agreement falls below the interval on the overall figure.
|
|
674
|
+
"""
|
|
675
|
+
|
|
676
|
+
agreement: float
|
|
677
|
+
ci_low: float
|
|
678
|
+
ci_high: float
|
|
679
|
+
accuracy: float
|
|
680
|
+
n_items: int
|
|
681
|
+
n_dropped: int
|
|
682
|
+
level: str
|
|
683
|
+
by_slice: pd.DataFrame
|
|
684
|
+
confidence: float = 0.95
|
|
685
|
+
n_boot: int = 0
|
|
686
|
+
n_boot_usable: int = 0
|
|
687
|
+
|
|
688
|
+
@property
|
|
689
|
+
def has_interval(self) -> bool:
|
|
690
|
+
return self.ci_low == self.ci_low and self.ci_high == self.ci_high
|
|
691
|
+
|
|
692
|
+
@property
|
|
693
|
+
def width(self) -> float:
|
|
694
|
+
return self.ci_high - self.ci_low
|
|
695
|
+
|
|
696
|
+
@property
|
|
697
|
+
def slice_is_distinguishable(self) -> bool:
|
|
698
|
+
"""True when the worst slice clears the overall interval outright.
|
|
699
|
+
|
|
700
|
+
The noise guard rater_dropout uses, in the form this statistic
|
|
701
|
+
takes. Cut a homogeneous population into five slices and one of them
|
|
702
|
+
is last by sampling noise alone, so a table that always ranks
|
|
703
|
+
somebody first needs a rule about when the first row means anything.
|
|
704
|
+
|
|
705
|
+
The rule is that the slice's own interval has to sit entirely below
|
|
706
|
+
the interval on the overall figure. The slice's agreement being
|
|
707
|
+
below the overall lower bound is necessary and is not enough on its
|
|
708
|
+
own, because a slice holds a fraction of the items and its sampling
|
|
709
|
+
error is correspondingly wider. Cut three hundred homogeneous items
|
|
710
|
+
into five slices of sixty and the lowest one drops under the overall
|
|
711
|
+
lower bound most of the time, so that test alone names a slice on
|
|
712
|
+
noise, which is the thing it exists to prevent.
|
|
713
|
+
|
|
714
|
+
One slice is the whole dataset under another name, so it never
|
|
715
|
+
qualifies. Without intervals there is nothing to place the slices
|
|
716
|
+
against and the answer is False rather than a guess.
|
|
717
|
+
"""
|
|
718
|
+
if not self.has_interval or len(self.by_slice) < 2:
|
|
719
|
+
return False
|
|
720
|
+
worst = self.by_slice.iloc[0]
|
|
721
|
+
return bool(
|
|
722
|
+
np.isfinite(worst["agreement"])
|
|
723
|
+
and np.isfinite(worst["ci_high"])
|
|
724
|
+
and worst["ci_high"] < self.ci_low
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
@property
|
|
728
|
+
def worst_slice(self):
|
|
729
|
+
"""The slice worth investigating, or None when the data cannot say."""
|
|
730
|
+
if not self.slice_is_distinguishable:
|
|
731
|
+
return None
|
|
732
|
+
return self.by_slice.iloc[0]["slice"]
|
|
733
|
+
|
|
734
|
+
def summary(self) -> str:
|
|
735
|
+
return self._head() + self._verdict() + self._slice_sentence()
|
|
736
|
+
|
|
737
|
+
def _head(self) -> str:
|
|
738
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
739
|
+
accuracy = f" Plain accuracy is {_pct(self.accuracy)}."
|
|
740
|
+
dropped = ""
|
|
741
|
+
if self.n_dropped:
|
|
742
|
+
dropped = (
|
|
743
|
+
f" {self.n_dropped} items were set aside because one side "
|
|
744
|
+
f"had no label."
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
if self.agreement != self.agreement: # NaN
|
|
748
|
+
return (
|
|
749
|
+
f"Judge and human agreement is undefined ({self.level}, "
|
|
750
|
+
f"{self.n_items} items). Every label that could be compared "
|
|
751
|
+
f"was identical, so there is no disagreement to divide by. "
|
|
752
|
+
f"That is not perfect agreement, it is a rubric with one "
|
|
753
|
+
f"label in it." + accuracy + dropped
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
if self.has_interval:
|
|
757
|
+
head = (
|
|
758
|
+
f"Judge and human agree at alpha {self.agreement:.3f} "
|
|
759
|
+
f"({conf} CI: {self.ci_low:.3f} to {self.ci_high:.3f}, "
|
|
760
|
+
f"{self.level}, {self.n_items} items)."
|
|
761
|
+
)
|
|
762
|
+
else:
|
|
763
|
+
head = (
|
|
764
|
+
f"Judge and human agree at alpha {self.agreement:.3f} "
|
|
765
|
+
f"({self.level}, {self.n_items} items). No interval was "
|
|
766
|
+
f"computed, so nothing here is placed against sampling error."
|
|
767
|
+
)
|
|
768
|
+
return head + accuracy + dropped
|
|
769
|
+
|
|
770
|
+
def _verdict(self) -> str:
|
|
771
|
+
if self.agreement != self.agreement:
|
|
772
|
+
return ""
|
|
773
|
+
if self.agreement >= _ALPHA_RELIABLE:
|
|
774
|
+
return (
|
|
775
|
+
f" That is at or above {_ALPHA_RELIABLE:.3f}, the "
|
|
776
|
+
f"conventional bar for treating coded data as reliable."
|
|
777
|
+
)
|
|
778
|
+
if self.agreement >= _ALPHA_FLOOR:
|
|
779
|
+
return (
|
|
780
|
+
f" That sits between {_ALPHA_FLOOR:.3f} and "
|
|
781
|
+
f"{_ALPHA_RELIABLE:.3f}, which supports tentative "
|
|
782
|
+
f"conclusions and no firm ones."
|
|
783
|
+
)
|
|
784
|
+
return (
|
|
785
|
+
f" That is below {_ALPHA_FLOOR:.3f}, the conventional floor for "
|
|
786
|
+
f"drawing any conclusion from coded data. The judge is not a "
|
|
787
|
+
f"stand-in for the humans at this level."
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
def _slice_sentence(self) -> str:
|
|
791
|
+
if self.by_slice.empty:
|
|
792
|
+
return ""
|
|
793
|
+
|
|
794
|
+
if len(self.by_slice) < 2:
|
|
795
|
+
return (
|
|
796
|
+
" One slice is the whole dataset under another name, so "
|
|
797
|
+
"there is nothing to compare it against."
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
if self.slice_is_distinguishable:
|
|
801
|
+
row = self.by_slice.iloc[0]
|
|
802
|
+
count = int(row["n_items"])
|
|
803
|
+
sentence = (
|
|
804
|
+
f" Worst slice {str(row['slice'])!r} agrees at "
|
|
805
|
+
f"{row['agreement']:.3f} on {count} items, and its interval "
|
|
806
|
+
f"tops out at {row['ci_high']:.3f}, clear of the "
|
|
807
|
+
f"{self.ci_low:.3f} lower bound on the overall figure. The "
|
|
808
|
+
f"headline number is carried by the rest of the data."
|
|
809
|
+
)
|
|
810
|
+
if count < _THIN_SLICE:
|
|
811
|
+
sentence += (
|
|
812
|
+
f" That slice holds only {count} items, so treat the gap "
|
|
813
|
+
f"as provisional and grade more of them before acting."
|
|
814
|
+
)
|
|
815
|
+
return sentence
|
|
816
|
+
|
|
817
|
+
if not self.has_interval:
|
|
818
|
+
return (
|
|
819
|
+
" Without an interval on the overall figure there is nothing "
|
|
820
|
+
"to place the slices against, so the data cannot single out "
|
|
821
|
+
"a slice. Do not read the top of the table as a failure."
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
row = self.by_slice.iloc[0]
|
|
825
|
+
if not np.isfinite(row["agreement"]) or not np.isfinite(row["ci_high"]):
|
|
826
|
+
return (
|
|
827
|
+
" No slice carries both an agreement figure and an interval, "
|
|
828
|
+
"so the data cannot single out a slice. Do not read the top "
|
|
829
|
+
"of the table as a failure."
|
|
830
|
+
)
|
|
831
|
+
return (
|
|
832
|
+
f" The lowest slice {str(row['slice'])!r} runs up to "
|
|
833
|
+
f"{row['ci_high']:.3f} against a lower bound of "
|
|
834
|
+
f"{self.ci_low:.3f} on the overall figure, so the two overlap "
|
|
835
|
+
f"and the data cannot single out a slice. Something is always "
|
|
836
|
+
f"last. Do not read the top of the table as a failure."
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
def __str__(self) -> str: # pragma: no cover
|
|
840
|
+
return self.summary()
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@dataclass(frozen=True)
|
|
844
|
+
class PositionBias:
|
|
845
|
+
"""Whether the judge favours whichever output it saw first.
|
|
846
|
+
|
|
847
|
+
Two designs answer this question and they answer it with different
|
|
848
|
+
arithmetic, so ``design`` says which one the data turned out to be.
|
|
849
|
+
|
|
850
|
+
``"randomised"`` means each pair was judged once with the order shuffled
|
|
851
|
+
beforehand. Then the position-A win rate is tested against a half.
|
|
852
|
+
|
|
853
|
+
``"both_orders"`` means the same pair was judged twice, once each way.
|
|
854
|
+
Then the headline is the consistency rate, the share of pairs where the
|
|
855
|
+
judge named the same output both times, and the direction of the pairs
|
|
856
|
+
it flipped on says whether the flipping was position or noise.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
design: str
|
|
860
|
+
estimate: float
|
|
861
|
+
ci_low: float
|
|
862
|
+
ci_high: float
|
|
863
|
+
p_value: float
|
|
864
|
+
position_a_rate: float
|
|
865
|
+
n_a_wins: int
|
|
866
|
+
n_decisive: int
|
|
867
|
+
consistency_rate: float
|
|
868
|
+
n_pairs: int
|
|
869
|
+
n_pairs_scored: int
|
|
870
|
+
n_judgements: int
|
|
871
|
+
n_both_orders: int
|
|
872
|
+
n_ties: int
|
|
873
|
+
confidence: float = 0.95
|
|
874
|
+
|
|
875
|
+
@property
|
|
876
|
+
def has_position_effect(self) -> bool:
|
|
877
|
+
"""True when the interval on the position-A rate clears a half.
|
|
878
|
+
|
|
879
|
+
In the both-orders design this reads the direction of the flips, not
|
|
880
|
+
the consistency rate, because an inconsistent judge and a
|
|
881
|
+
position-biased one are different problems.
|
|
882
|
+
"""
|
|
883
|
+
if self.design == "both_orders":
|
|
884
|
+
if self.n_decisive == 0 or self.p_value != self.p_value:
|
|
885
|
+
return False
|
|
886
|
+
return bool(self.p_value < 1 - self.confidence)
|
|
887
|
+
if not (self.ci_low == self.ci_low):
|
|
888
|
+
return False
|
|
889
|
+
return not (self.ci_low <= 0.5 <= self.ci_high)
|
|
890
|
+
|
|
891
|
+
def summary(self) -> str:
|
|
892
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
893
|
+
if self.design == "both_orders":
|
|
894
|
+
return self._both_orders(conf) + self._ties()
|
|
895
|
+
return self._randomised(conf) + self._ties()
|
|
896
|
+
|
|
897
|
+
def _randomised(self, conf: str) -> str:
|
|
898
|
+
head = (
|
|
899
|
+
f"Each pair was judged once, so this reports the position-A win "
|
|
900
|
+
f"rate. Position A won {_pct(self.position_a_rate)} of "
|
|
901
|
+
f"{self.n_decisive} judgements ({conf} CI: {_pct(self.ci_low)} "
|
|
902
|
+
f"to {_pct(self.ci_high)}, exact binomial p={self.p_value:.4f})."
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
if self.has_position_effect:
|
|
906
|
+
side = "first" if self.position_a_rate > 0.5 else "second"
|
|
907
|
+
verdict = (
|
|
908
|
+
f" The interval clears 50%, so the judge favours whichever "
|
|
909
|
+
f"output it sees {side}."
|
|
910
|
+
)
|
|
911
|
+
else:
|
|
912
|
+
verdict = (
|
|
913
|
+
" The interval covers 50%, so the data cannot show that "
|
|
914
|
+
"position moved the judge."
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
caveat = (
|
|
918
|
+
" This assumes presentation order was randomised, which the data "
|
|
919
|
+
"cannot confirm. If the same system sat in position A each time, "
|
|
920
|
+
"the same number appears when that system is simply better."
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
stray = ""
|
|
924
|
+
if self.n_both_orders:
|
|
925
|
+
stray = (
|
|
926
|
+
f" {self.n_both_orders} pairs were also run in the reverse "
|
|
927
|
+
f"order, too few to change which analysis applies."
|
|
928
|
+
)
|
|
929
|
+
return head + verdict + caveat + stray
|
|
930
|
+
|
|
931
|
+
def _both_orders(self, conf: str) -> str:
|
|
932
|
+
head = (
|
|
933
|
+
f"{self.n_both_orders} of {self.n_pairs} pairs were run in both "
|
|
934
|
+
f"orders, so this reports the consistency rate. The judge named "
|
|
935
|
+
f"the same output under both orderings on "
|
|
936
|
+
f"{_pct(self.consistency_rate)} of {self.n_pairs_scored} pairs "
|
|
937
|
+
f"({conf} CI: {_pct(self.ci_low)} to {_pct(self.ci_high)})."
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
if self.n_decisive == 0:
|
|
941
|
+
return head + (
|
|
942
|
+
" It never flipped, so there is no direction to test and "
|
|
943
|
+
"nothing here points at position."
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
direction = (
|
|
947
|
+
f" Of the {self.n_decisive} pairs it flipped on, {self.n_a_wins} "
|
|
948
|
+
f"went to whichever output was shown first "
|
|
949
|
+
f"({_pct(self.position_a_rate)}, exact binomial "
|
|
950
|
+
f"p={self.p_value:.4f})."
|
|
951
|
+
)
|
|
952
|
+
|
|
953
|
+
if self.has_position_effect:
|
|
954
|
+
side = "first" if self.position_a_rate > 0.5 else "second"
|
|
955
|
+
verdict = (
|
|
956
|
+
f" The flips have a direction, so this is position bias "
|
|
957
|
+
f"rather than an unsteady judge. It reaches for whatever it "
|
|
958
|
+
f"sees {side}."
|
|
959
|
+
)
|
|
960
|
+
else:
|
|
961
|
+
verdict = (
|
|
962
|
+
" The flips split evenly across the two positions, so this "
|
|
963
|
+
"is an unsteady judge rather than a position-biased one. "
|
|
964
|
+
"Inconsistency is its own problem and does not become "
|
|
965
|
+
"position bias without a direction."
|
|
966
|
+
)
|
|
967
|
+
return head + direction + verdict
|
|
968
|
+
|
|
969
|
+
def _ties(self) -> str:
|
|
970
|
+
if not self.n_ties:
|
|
971
|
+
return ""
|
|
972
|
+
return (
|
|
973
|
+
f" {self.n_ties} judgements were ties and are left out of the "
|
|
974
|
+
f"rates above."
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
def __str__(self) -> str: # pragma: no cover
|
|
978
|
+
return self.summary()
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@dataclass(frozen=True)
|
|
982
|
+
class LengthBias:
|
|
983
|
+
"""Two logistic fits on the same length difference, and they answer
|
|
984
|
+
different questions.
|
|
985
|
+
|
|
986
|
+
The first regresses judge preference on the length difference between
|
|
987
|
+
the two answers. A positive coefficient says the judge goes for the
|
|
988
|
+
longer answer, and on its own that is not bias, because longer answers
|
|
989
|
+
may be better.
|
|
990
|
+
|
|
991
|
+
The second regresses judge-human disagreement on the same length
|
|
992
|
+
difference, oriented by what the humans picked, so a positive
|
|
993
|
+
coefficient says the judge breaks with the humans on the pairs where the
|
|
994
|
+
humans went short. Holding the human verdict fixed removes the part of
|
|
995
|
+
the length-quality link the human labels capture. It does not remove the
|
|
996
|
+
rest. A binary human label is a coarse measure of quality and the length
|
|
997
|
+
difference still carries quality information the label missed, so on a
|
|
998
|
+
corpus where length tracks quality closely this coefficient stays
|
|
999
|
+
positive for a judge with no length preference at all. It is the sharper
|
|
1000
|
+
of the two and it is not a clean separation.
|
|
1001
|
+
"""
|
|
1002
|
+
|
|
1003
|
+
coefficient: float
|
|
1004
|
+
ci_low: float
|
|
1005
|
+
ci_high: float
|
|
1006
|
+
p_value: float
|
|
1007
|
+
sd_difference: float
|
|
1008
|
+
note: str
|
|
1009
|
+
disagreement_coefficient: float
|
|
1010
|
+
disagreement_ci_low: float
|
|
1011
|
+
disagreement_ci_high: float
|
|
1012
|
+
disagreement_p_value: float
|
|
1013
|
+
disagreement_sd_difference: float
|
|
1014
|
+
disagreement_note: str
|
|
1015
|
+
n: int
|
|
1016
|
+
n_disagreements: int
|
|
1017
|
+
longer_rate: float
|
|
1018
|
+
has_human: bool
|
|
1019
|
+
confidence: float = 0.95
|
|
1020
|
+
|
|
1021
|
+
@property
|
|
1022
|
+
def odds_ratio_per_sd(self) -> float:
|
|
1023
|
+
"""What one standard deviation of extra length does to the odds.
|
|
1024
|
+
|
|
1025
|
+
The raw coefficient is a log-odds per character, which nobody can
|
|
1026
|
+
read. This puts it on a step the data actually contains.
|
|
1027
|
+
"""
|
|
1028
|
+
return float(np.exp(self.coefficient * self.sd_difference))
|
|
1029
|
+
|
|
1030
|
+
@property
|
|
1031
|
+
def disagreement_odds_ratio_per_sd(self) -> float:
|
|
1032
|
+
return float(
|
|
1033
|
+
np.exp(self.disagreement_coefficient * self.disagreement_sd_difference)
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def summary(self) -> str:
|
|
1037
|
+
return self._preference() + self._disagreement()
|
|
1038
|
+
|
|
1039
|
+
def _preference(self) -> str:
|
|
1040
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
1041
|
+
if self.coefficient != self.coefficient: # NaN
|
|
1042
|
+
return (
|
|
1043
|
+
f"No judge preference model: {self.note} ({self.n} pairs). "
|
|
1044
|
+
f"The judge picked the longer answer on "
|
|
1045
|
+
f"{_pct(self.longer_rate)} of pairs."
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
head = (
|
|
1049
|
+
f"Judge preference against length: {self.sd_difference:.0f} "
|
|
1050
|
+
f"characters of extra length multiplies the odds the judge picks "
|
|
1051
|
+
f"that answer by {self.odds_ratio_per_sd:.2f} ({conf} CI: "
|
|
1052
|
+
f"{np.exp(self.ci_low * self.sd_difference):.2f} to "
|
|
1053
|
+
f"{np.exp(self.ci_high * self.sd_difference):.2f}, "
|
|
1054
|
+
f"p={self.p_value:.4f}, {self.n} pairs). It picked the longer "
|
|
1055
|
+
f"answer on {_pct(self.longer_rate)} of pairs."
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
if self.ci_low <= 0 <= self.ci_high:
|
|
1059
|
+
return head + (
|
|
1060
|
+
" The interval covers no effect, so the data cannot show "
|
|
1061
|
+
"that length moved the judge."
|
|
1062
|
+
)
|
|
1063
|
+
return head + (
|
|
1064
|
+
" A positive coefficient here is not bias on its own, because "
|
|
1065
|
+
"longer answers may simply be better."
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
def _disagreement(self) -> str:
|
|
1069
|
+
conf = f"{self.confidence * 100:.0f}%"
|
|
1070
|
+
if not self.has_human:
|
|
1071
|
+
return (
|
|
1072
|
+
" Without human labels there is no second model, so this "
|
|
1073
|
+
"cannot separate a length preference from longer answers "
|
|
1074
|
+
"being better. Supply human_preferences to get the sharper "
|
|
1075
|
+
"of the two."
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
if self.disagreement_coefficient != self.disagreement_coefficient:
|
|
1079
|
+
return (
|
|
1080
|
+
f" No disagreement model: {self.disagreement_note} "
|
|
1081
|
+
f"({self.n_disagreements} disagreements)."
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
head = (
|
|
1085
|
+
f" Judge-human disagreement against the same length difference, "
|
|
1086
|
+
f"oriented by what the humans picked: "
|
|
1087
|
+
f"{self.disagreement_sd_difference:.0f} characters multiplies "
|
|
1088
|
+
f"the odds the judge breaks with them by "
|
|
1089
|
+
f"{self.disagreement_odds_ratio_per_sd:.2f} ({conf} CI: "
|
|
1090
|
+
f"{np.exp(self.disagreement_ci_low * self.disagreement_sd_difference):.2f}"
|
|
1091
|
+
f" to "
|
|
1092
|
+
f"{np.exp(self.disagreement_ci_high * self.disagreement_sd_difference):.2f}"
|
|
1093
|
+
f", p={self.disagreement_p_value:.4f}, "
|
|
1094
|
+
f"{self.n_disagreements} disagreements)."
|
|
1095
|
+
)
|
|
1096
|
+
|
|
1097
|
+
if self.disagreement_ci_low <= 0 <= self.disagreement_ci_high:
|
|
1098
|
+
verdict = (
|
|
1099
|
+
" That interval covers no effect, so the judge does not "
|
|
1100
|
+
"depart from the humans in the direction of length."
|
|
1101
|
+
)
|
|
1102
|
+
else:
|
|
1103
|
+
verdict = (
|
|
1104
|
+
" The judge departs from the humans in the direction of "
|
|
1105
|
+
"length."
|
|
1106
|
+
)
|
|
1107
|
+
|
|
1108
|
+
caveat = (
|
|
1109
|
+
" Holding the human verdict fixed strips the part of the "
|
|
1110
|
+
"length-quality link the human labels capture, and it does not "
|
|
1111
|
+
"remove the rest, since a binary label is a coarse measure of "
|
|
1112
|
+
"quality. Read this as the sharper of the two numbers rather "
|
|
1113
|
+
"than as proof."
|
|
1114
|
+
)
|
|
1115
|
+
return head + verdict + caveat
|
|
1116
|
+
|
|
1117
|
+
def __str__(self) -> str: # pragma: no cover
|
|
1118
|
+
return self.summary()
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _points(x: float) -> str:
|
|
1122
|
+
"""A difference as points of pass rate, trimmed of trailing zeros."""
|
|
1123
|
+
return f"{round(x * 100, 1):g}"
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _rate(x: float) -> str:
|
|
1127
|
+
"""A rate as a percentage, trimmed of trailing zeros."""
|
|
1128
|
+
return f"{round(x * 100, 1):g}%"
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
@dataclass(frozen=True)
|
|
1132
|
+
class PowerResult:
|
|
1133
|
+
"""What an eval of a given size could find, or the size a target needs.
|
|
1134
|
+
|
|
1135
|
+
One relation solved in two directions. ``solved_for`` says which, either
|
|
1136
|
+
``"difference"`` from :func:`evalaudit.detectable_effect` or ``"n"``
|
|
1137
|
+
from :func:`evalaudit.min_sample_size`.
|
|
1138
|
+
|
|
1139
|
+
``n`` is the number of paired comparisons in a paired design and the
|
|
1140
|
+
total number of items across both groups in an independent one, which
|
|
1141
|
+
is the convention ``ComparisonResult`` already uses.
|
|
1142
|
+
|
|
1143
|
+
``attainable`` is False when no difference reaches the requested power
|
|
1144
|
+
at this size. For a paired design ``difference`` is then the figure the
|
|
1145
|
+
arithmetic returns even though it sits above the discordance rate,
|
|
1146
|
+
which is the ceiling a paired difference cannot pass. For an
|
|
1147
|
+
independent design there is no such figure and ``difference`` is NaN.
|
|
1148
|
+
|
|
1149
|
+
``discordance_assumed`` is True when no discordance rate was supplied
|
|
1150
|
+
and the conservative default stood in. The summary says so every time.
|
|
1151
|
+
Nothing here assumes that rate quietly.
|
|
1152
|
+
"""
|
|
1153
|
+
|
|
1154
|
+
difference: float
|
|
1155
|
+
n: int
|
|
1156
|
+
baseline: float
|
|
1157
|
+
power: float
|
|
1158
|
+
alpha: float
|
|
1159
|
+
paired: bool
|
|
1160
|
+
solved_for: str
|
|
1161
|
+
attainable: bool
|
|
1162
|
+
discordance_rate: Optional[float] = None
|
|
1163
|
+
discordance_assumed: bool = False
|
|
1164
|
+
n_discordant: Optional[int] = None
|
|
1165
|
+
n_per_group: Optional[float] = None
|
|
1166
|
+
reference_difference: Optional[float] = None
|
|
1167
|
+
n_for_reference: Optional[int] = None
|
|
1168
|
+
|
|
1169
|
+
@property
|
|
1170
|
+
def has_difference(self) -> bool:
|
|
1171
|
+
return bool(np.isfinite(self.difference))
|
|
1172
|
+
|
|
1173
|
+
def summary(self) -> str:
|
|
1174
|
+
if self.solved_for == "n":
|
|
1175
|
+
return self._required_size()
|
|
1176
|
+
return self._reach()
|
|
1177
|
+
|
|
1178
|
+
# ------------------------------------------------------------------
|
|
1179
|
+
# min_sample_size
|
|
1180
|
+
# ------------------------------------------------------------------
|
|
1181
|
+
|
|
1182
|
+
def _required_size(self) -> str:
|
|
1183
|
+
target = (
|
|
1184
|
+
f"To detect a {_points(self.difference)} point difference at "
|
|
1185
|
+
f"{_rate(self.power)} power and a {_rate(self.alpha)} "
|
|
1186
|
+
f"significance level"
|
|
1187
|
+
)
|
|
1188
|
+
if self.paired:
|
|
1189
|
+
head = f"{target} you need roughly {self.n:,} paired comparisons."
|
|
1190
|
+
return (
|
|
1191
|
+
head
|
|
1192
|
+
+ self._discordant_sentence()
|
|
1193
|
+
+ self._assumed_sentence()
|
|
1194
|
+
+ (
|
|
1195
|
+
f" The baseline pass rate does not enter a paired binary "
|
|
1196
|
+
f"calculation. Two systems can both pass "
|
|
1197
|
+
f"{_rate(self.baseline)} of items and disagree on none of "
|
|
1198
|
+
f"them or on all of them, and it is the disagreement that "
|
|
1199
|
+
f"sets the number above."
|
|
1200
|
+
)
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
head = (
|
|
1204
|
+
f"{target} from a {_rate(self.baseline)} baseline you need "
|
|
1205
|
+
f"roughly {self.n:,} items, {self.n_per_group:,g} in each group."
|
|
1206
|
+
)
|
|
1207
|
+
return head + self._pairing_sentence()
|
|
1208
|
+
|
|
1209
|
+
# ------------------------------------------------------------------
|
|
1210
|
+
# detectable_effect
|
|
1211
|
+
# ------------------------------------------------------------------
|
|
1212
|
+
|
|
1213
|
+
def _reach(self) -> str:
|
|
1214
|
+
if self.paired:
|
|
1215
|
+
head = f"This eval ran {self.n:,} paired comparisons."
|
|
1216
|
+
else:
|
|
1217
|
+
head = (
|
|
1218
|
+
f"This eval ran {self.n:,} items, {self.n_per_group:,g} in "
|
|
1219
|
+
f"each group."
|
|
1220
|
+
)
|
|
1221
|
+
if not self.attainable:
|
|
1222
|
+
return head + self._out_of_reach() + self._discordant_sentence()
|
|
1223
|
+
return (
|
|
1224
|
+
head
|
|
1225
|
+
+ self._smallest_finding()
|
|
1226
|
+
+ self._discordant_sentence()
|
|
1227
|
+
+ self._reference_sentence()
|
|
1228
|
+
+ self._assumed_sentence()
|
|
1229
|
+
+ (self._floor_sentence() if self.paired else self._pairing_sentence())
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
def _smallest_finding(self) -> str:
|
|
1233
|
+
against = "" if self.paired else f" against a {_rate(self.baseline)} baseline"
|
|
1234
|
+
return (
|
|
1235
|
+
f" At {_rate(self.power)} power and a {_rate(self.alpha)} "
|
|
1236
|
+
f"significance level{against} the smallest difference it could "
|
|
1237
|
+
f"have found is {_points(self.difference)} points, and anything "
|
|
1238
|
+
f"smaller was out of reach before the first item was graded."
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
def _out_of_reach(self) -> str:
|
|
1242
|
+
if self.paired:
|
|
1243
|
+
if self.has_difference:
|
|
1244
|
+
needed = (
|
|
1245
|
+
f" Reaching {_rate(self.power)} power here would take a "
|
|
1246
|
+
f"difference of {_points(self.difference)} points."
|
|
1247
|
+
)
|
|
1248
|
+
else:
|
|
1249
|
+
needed = (
|
|
1250
|
+
f" No difference reaches {_rate(self.power)} power at "
|
|
1251
|
+
f"this size."
|
|
1252
|
+
)
|
|
1253
|
+
return needed + (
|
|
1254
|
+
f" A difference can never exceed the discordance rate, which "
|
|
1255
|
+
f"here is {_rate(self.discordance_rate)}, so no difference at "
|
|
1256
|
+
f"all was detectable. This eval could not have found "
|
|
1257
|
+
f"anything, whatever the two systems really do."
|
|
1258
|
+
)
|
|
1259
|
+
return (
|
|
1260
|
+
f" A {_rate(self.baseline)} baseline leaves at most a "
|
|
1261
|
+
f"{_points(1.0 - self.baseline)} point difference before the pass "
|
|
1262
|
+
f"rate hits 100%, and this many items cannot find even that at "
|
|
1263
|
+
f"{_rate(self.power)} power. So no difference at all was "
|
|
1264
|
+
f"detectable, and this eval could not have found anything."
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
# ------------------------------------------------------------------
|
|
1268
|
+
# Shared clauses
|
|
1269
|
+
# ------------------------------------------------------------------
|
|
1270
|
+
|
|
1271
|
+
def _discordant_sentence(self) -> str:
|
|
1272
|
+
if not self.paired or self.n_discordant is None:
|
|
1273
|
+
return ""
|
|
1274
|
+
return (
|
|
1275
|
+
f" At a {_rate(self.discordance_rate)} discordance rate that is "
|
|
1276
|
+
f"about {self.n_discordant:,} discordant pairs, and McNemar reads "
|
|
1277
|
+
f"only those. The rest of the items agree across both systems and "
|
|
1278
|
+
f"carry no information about which one is better."
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
def _reference_sentence(self) -> str:
|
|
1282
|
+
if self.n_for_reference is None or self.reference_difference is None:
|
|
1283
|
+
return ""
|
|
1284
|
+
unit = "comparisons" if self.paired else "items"
|
|
1285
|
+
return (
|
|
1286
|
+
f" To find a {_points(self.reference_difference)} point "
|
|
1287
|
+
f"difference you would have needed roughly "
|
|
1288
|
+
f"{self.n_for_reference:,} {unit}, and this eval ran {self.n:,}."
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
def _assumed_sentence(self) -> str:
|
|
1292
|
+
if not self.discordance_assumed:
|
|
1293
|
+
return ""
|
|
1294
|
+
return (
|
|
1295
|
+
f" The discordance rate was not supplied, so "
|
|
1296
|
+
f"{_rate(self.discordance_rate)} was assumed. That is a "
|
|
1297
|
+
f"conservative stand-in and it is not a measurement. Supply the "
|
|
1298
|
+
f"rate your own eval produced, since a lower rate needs fewer "
|
|
1299
|
+
f"pairs and a higher one needs more."
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
def _floor_sentence(self) -> str:
|
|
1303
|
+
return (
|
|
1304
|
+
" This is the standard normal approximation for McNemar, and it "
|
|
1305
|
+
"runs a little ahead of the continuity corrected and exact forms "
|
|
1306
|
+
"evalaudit actually uses. Read the number as a floor on what the "
|
|
1307
|
+
"eval could have found. The true reach is slightly worse."
|
|
1308
|
+
)
|
|
1309
|
+
|
|
1310
|
+
def _pairing_sentence(self) -> str:
|
|
1311
|
+
return (
|
|
1312
|
+
" Running both systems on the same items would cut this sharply. "
|
|
1313
|
+
"Pairing removes the item to item difficulty that an independent "
|
|
1314
|
+
"comparison has to absorb, and most evals can pair."
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
def __str__(self) -> str: # pragma: no cover
|
|
1318
|
+
return self.summary()
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# --------------------------------------------------------------------------
|
|
1322
|
+
# audit
|
|
1323
|
+
# --------------------------------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
# The three words a finding can carry, in the order a report is read. They
|
|
1326
|
+
# are not degrees of one thing. Critical means the conclusion does not hold,
|
|
1327
|
+
# warning means it holds and something weakens it, info is context.
|
|
1328
|
+
_SEVERITIES = ("critical", "warning", "info")
|
|
1329
|
+
|
|
1330
|
+
# The line for judge-human agreement. It is the same number as _ALPHA_FLOOR
|
|
1331
|
+
# and it is not the same kind of number. See AuditConfig.
|
|
1332
|
+
_JUDGE_THRESHOLD = _ALPHA_FLOOR
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
@dataclass(frozen=True)
|
|
1336
|
+
class AuditConfig:
|
|
1337
|
+
"""What the audit is being asked to check, and what it may assume.
|
|
1338
|
+
|
|
1339
|
+
Everything here has a default that suits a two system pass rate eval, so
|
|
1340
|
+
a first run needs none of it. The three worth setting are ``claim``,
|
|
1341
|
+
which puts the sentence being audited at the top of the report,
|
|
1342
|
+
``effect_of_interest``, which is the difference the decision actually
|
|
1343
|
+
turns on, and ``claims_direction``, which says whether anyone is
|
|
1344
|
+
asserting that one system beat the other. A margin whose interval
|
|
1345
|
+
crosses zero is critical when a direction is being claimed and a warning
|
|
1346
|
+
when it is not, because in the second case nobody is leaning on it.
|
|
1347
|
+
|
|
1348
|
+
``paired`` is left as None so the design is read off the data. Two
|
|
1349
|
+
systems scored on the same number of items are taken as paired, which is
|
|
1350
|
+
how most evals are built. Set it when that guess would be wrong.
|
|
1351
|
+
|
|
1352
|
+
``discordance_rate`` is for paired binary designs only. When both score
|
|
1353
|
+
sets are 0/1 the rate is measured from the data and this is ignored,
|
|
1354
|
+
since a measured rate always beats a supplied one.
|
|
1355
|
+
|
|
1356
|
+
``n_boot`` sizes the agreement and judge bootstraps, the two that
|
|
1357
|
+
resample. The score and comparison intervals keep their own module
|
|
1358
|
+
defaults, which are higher because those resamples are cheap.
|
|
1359
|
+
|
|
1360
|
+
Thresholds
|
|
1361
|
+
----------
|
|
1362
|
+
The two default to the same number and they earned it differently, so
|
|
1363
|
+
they are separate settings and should be set separately.
|
|
1364
|
+
|
|
1365
|
+
``agreement_threshold`` is 0.667, which is Krippendorff's own published
|
|
1366
|
+
line: above 0.800 is reliable, 0.667 to 0.800 supports tentative
|
|
1367
|
+
conclusions, below 0.667 supports none. ``rater_agreement`` already
|
|
1368
|
+
reads its verdict off those same cutoffs, so the audit is repeating the
|
|
1369
|
+
module rather than inventing a rule.
|
|
1370
|
+
|
|
1371
|
+
``judge_threshold`` is 0.667 because it was borrowed from there. It is a
|
|
1372
|
+
convention imported from content analysis, where it describes when two
|
|
1373
|
+
humans coding text agree well enough to pool their work. Nobody has
|
|
1374
|
+
validated it as the point where an LLM judge becomes safe to rank
|
|
1375
|
+
systems with, and this library is not claiming they have. Set it from
|
|
1376
|
+
what the decision can tolerate. A judge picking which of two models
|
|
1377
|
+
ships needs to track humans far more closely than one triaging a queue
|
|
1378
|
+
for human review, and neither number is 0.667 for any reason beyond
|
|
1379
|
+
habit.
|
|
1380
|
+
"""
|
|
1381
|
+
|
|
1382
|
+
claim: Optional[str] = None
|
|
1383
|
+
claims_direction: bool = True
|
|
1384
|
+
effect_of_interest: Optional[float] = None
|
|
1385
|
+
confidence: float = 0.95
|
|
1386
|
+
level: str = "nominal"
|
|
1387
|
+
agreement_threshold: float = _ALPHA_FLOOR
|
|
1388
|
+
judge_threshold: float = _JUDGE_THRESHOLD
|
|
1389
|
+
power: float = 0.8
|
|
1390
|
+
alpha: float = 0.05
|
|
1391
|
+
paired: Optional[bool] = None
|
|
1392
|
+
discordance_rate: Optional[float] = None
|
|
1393
|
+
n_boot: int = 1000
|
|
1394
|
+
seed: Optional[int] = None
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
@dataclass(frozen=True)
|
|
1398
|
+
class Finding:
|
|
1399
|
+
"""One thing the audit found, and what it means for the claim.
|
|
1400
|
+
|
|
1401
|
+
``severity`` is one of the three words in ``_SEVERITIES``. ``check``
|
|
1402
|
+
names the check that produced this, which is what the report sorts on
|
|
1403
|
+
inside a severity. ``result`` is the object the underlying module
|
|
1404
|
+
returned, kept so a reader can go back to the numbers, and ``detail``
|
|
1405
|
+
carries that object's own ``summary()`` word for word, framed by a line
|
|
1406
|
+
on what it means for the claim and a line on what to do about it.
|
|
1407
|
+
"""
|
|
1408
|
+
|
|
1409
|
+
check: str
|
|
1410
|
+
severity: str
|
|
1411
|
+
title: str
|
|
1412
|
+
detail: str
|
|
1413
|
+
result: object = None
|
|
1414
|
+
|
|
1415
|
+
def summary(self) -> str:
|
|
1416
|
+
return f"[{self.severity}] {self.title}. {self.detail}"
|
|
1417
|
+
|
|
1418
|
+
def __str__(self) -> str: # pragma: no cover
|
|
1419
|
+
return self.summary()
|
|
1420
|
+
|
|
1421
|
+
|
|
1422
|
+
@dataclass(frozen=True)
|
|
1423
|
+
class SkippedCheck:
|
|
1424
|
+
"""A check that could not run, and the data that would have let it.
|
|
1425
|
+
|
|
1426
|
+
Silence about a check reads as a pass. Every check the audit knows how
|
|
1427
|
+
to run either produces a finding or turns up here saying what was
|
|
1428
|
+
missing, so a thin report is visibly thin.
|
|
1429
|
+
"""
|
|
1430
|
+
|
|
1431
|
+
check: str
|
|
1432
|
+
title: str
|
|
1433
|
+
reason: str
|
|
1434
|
+
|
|
1435
|
+
def summary(self) -> str:
|
|
1436
|
+
return f"{self.title}: {self.reason}"
|
|
1437
|
+
|
|
1438
|
+
def __str__(self) -> str: # pragma: no cover
|
|
1439
|
+
return self.summary()
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
@dataclass(frozen=True)
|
|
1443
|
+
class AuditReport:
|
|
1444
|
+
"""Everything the audit found, ranked by what changes the decision.
|
|
1445
|
+
|
|
1446
|
+
``findings`` is sorted by severity first, so the report opens with what
|
|
1447
|
+
undoes the conclusion, and by check order inside a severity. A reader
|
|
1448
|
+
who stops after the first entry has read the most important one.
|
|
1449
|
+
|
|
1450
|
+
``not_run`` is the other half of the report. A check with no data behind
|
|
1451
|
+
it is listed there with the reason, never left out.
|
|
1452
|
+
|
|
1453
|
+
A report of nothing but info is a result, not an empty file. It says the
|
|
1454
|
+
eval holds up on everything that could be tested, and it still carries
|
|
1455
|
+
every number, because those numbers are what makes it worth sending.
|
|
1456
|
+
"""
|
|
1457
|
+
|
|
1458
|
+
findings: tuple
|
|
1459
|
+
not_run: tuple
|
|
1460
|
+
config: AuditConfig
|
|
1461
|
+
|
|
1462
|
+
@property
|
|
1463
|
+
def severity_counts(self) -> dict:
|
|
1464
|
+
"""How many findings of each severity, zeros included.
|
|
1465
|
+
|
|
1466
|
+
The zeros are the point. A report with no criticals says so rather
|
|
1467
|
+
than leaving the reader to count.
|
|
1468
|
+
"""
|
|
1469
|
+
counts = {severity: 0 for severity in _SEVERITIES}
|
|
1470
|
+
for finding in self.findings:
|
|
1471
|
+
counts[finding.severity] += 1
|
|
1472
|
+
return counts
|
|
1473
|
+
|
|
1474
|
+
@property
|
|
1475
|
+
def worst_severity(self) -> Optional[str]:
|
|
1476
|
+
"""The severity of the top finding, or None on an empty report."""
|
|
1477
|
+
if not self.findings:
|
|
1478
|
+
return None
|
|
1479
|
+
return self.findings[0].severity
|
|
1480
|
+
|
|
1481
|
+
def summary(self) -> str:
|
|
1482
|
+
counts = self.severity_counts
|
|
1483
|
+
parts = []
|
|
1484
|
+
if self.config.claim:
|
|
1485
|
+
parts.append(f"Claim under audit: {self.config.claim}.")
|
|
1486
|
+
parts.append(self._verdict(counts))
|
|
1487
|
+
parts.append(self._coverage())
|
|
1488
|
+
return " ".join(parts)
|
|
1489
|
+
|
|
1490
|
+
def _verdict(self, counts: dict) -> str:
|
|
1491
|
+
criticals = counts["critical"]
|
|
1492
|
+
warnings = counts["warning"]
|
|
1493
|
+
if criticals:
|
|
1494
|
+
return (
|
|
1495
|
+
f"{criticals} critical {_plural('finding', criticals)}, "
|
|
1496
|
+
f"{warnings} {_plural('warning', warnings)} and "
|
|
1497
|
+
f"{counts['info']} for context. On the critical "
|
|
1498
|
+
f"{_plural('finding', criticals)} the conclusion this data "
|
|
1499
|
+
f"is being asked to support does not hold as stated."
|
|
1500
|
+
)
|
|
1501
|
+
if warnings:
|
|
1502
|
+
return (
|
|
1503
|
+
f"No critical findings. {warnings} "
|
|
1504
|
+
f"{_plural('warning', warnings)} and {counts['info']} for "
|
|
1505
|
+
f"context. The result stands and the design weakens it, so "
|
|
1506
|
+
f"report it with what the {_plural('warning', warnings)} "
|
|
1507
|
+
f"say attached."
|
|
1508
|
+
)
|
|
1509
|
+
ran = len(self.findings)
|
|
1510
|
+
return (
|
|
1511
|
+
f"No critical findings and no warnings. All {ran} "
|
|
1512
|
+
f"{_plural('check', ran)} that ran came back clean, so the eval "
|
|
1513
|
+
f"holds up on everything this report could test."
|
|
1514
|
+
)
|
|
1515
|
+
|
|
1516
|
+
def _coverage(self) -> str:
|
|
1517
|
+
if not self.not_run:
|
|
1518
|
+
return "Every check ran."
|
|
1519
|
+
missing = len(self.not_run)
|
|
1520
|
+
tail = "It is" if missing == 1 else "They are"
|
|
1521
|
+
return (
|
|
1522
|
+
f"{missing} {_plural('check', missing)} could not run for lack "
|
|
1523
|
+
f"of data. {tail} listed at the end."
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
def to_markdown(self) -> str:
|
|
1527
|
+
lines = ["# Eval audit", "", self.summary(), "", "## Findings", ""]
|
|
1528
|
+
for position, finding in enumerate(self.findings, start=1):
|
|
1529
|
+
lines += [
|
|
1530
|
+
f"### {position}. [{finding.severity}] {finding.title}",
|
|
1531
|
+
"",
|
|
1532
|
+
finding.detail,
|
|
1533
|
+
"",
|
|
1534
|
+
]
|
|
1535
|
+
if self.not_run:
|
|
1536
|
+
lines += ["## Checks that could not run", ""]
|
|
1537
|
+
lines += [f"- **{s.title}**: {s.reason}" for s in self.not_run]
|
|
1538
|
+
lines.append("")
|
|
1539
|
+
return "\n".join(lines)
|
|
1540
|
+
|
|
1541
|
+
def to_html(self) -> str:
|
|
1542
|
+
parts = [
|
|
1543
|
+
'<section class="evalaudit-report">',
|
|
1544
|
+
"<h1>Eval audit</h1>",
|
|
1545
|
+
f'<p class="audit-summary">{_escape(self.summary())}</p>',
|
|
1546
|
+
'<ol class="audit-findings">',
|
|
1547
|
+
]
|
|
1548
|
+
for finding in self.findings:
|
|
1549
|
+
parts += [
|
|
1550
|
+
f'<li class="audit-finding audit-{finding.severity}">',
|
|
1551
|
+
f"<h2>{_escape(finding.title)}</h2>",
|
|
1552
|
+
f'<p class="audit-severity">{finding.severity}</p>',
|
|
1553
|
+
f"<p>{_escape(finding.detail)}</p>",
|
|
1554
|
+
"</li>",
|
|
1555
|
+
]
|
|
1556
|
+
parts.append("</ol>")
|
|
1557
|
+
if self.not_run:
|
|
1558
|
+
parts += [
|
|
1559
|
+
"<h2>Checks that could not run</h2>",
|
|
1560
|
+
'<ul class="audit-not-run">',
|
|
1561
|
+
]
|
|
1562
|
+
parts += [
|
|
1563
|
+
f"<li><strong>{_escape(s.title)}</strong>: "
|
|
1564
|
+
f"{_escape(s.reason)}</li>"
|
|
1565
|
+
for s in self.not_run
|
|
1566
|
+
]
|
|
1567
|
+
parts.append("</ul>")
|
|
1568
|
+
parts.append("</section>")
|
|
1569
|
+
return "\n".join(parts)
|
|
1570
|
+
|
|
1571
|
+
def __str__(self) -> str: # pragma: no cover
|
|
1572
|
+
return self.summary()
|