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