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/audit.py ADDED
@@ -0,0 +1,821 @@
1
+ """One call over whatever data the client actually has.
2
+
3
+ This is the function the consulting work runs and the reason the other five
4
+ modules exist. You are handed a finished evaluation, some mixture of scores,
5
+ grader ratings, judge labels and pairwise judgements, and a conclusion
6
+ somebody has already written into a slide. The job is to say which parts of
7
+ that conclusion the data supports.
8
+
9
+ Seven checks run, each one on the data that supports it and never on data
10
+ that does not. A check with nothing behind it is reported as a check that
11
+ could not run, with the reason, because silence about a check reads as a
12
+ pass.
13
+
14
+ Two things here matter more than the arithmetic, which lives in the modules
15
+ and is tested there.
16
+
17
+ The ranking. A report that lists seven statistics in the order the modules
18
+ happen to run is worth nothing. Findings sort by severity first, so the
19
+ report opens with the thing that changes the decision, and by CHECK_ORDER
20
+ inside a severity.
21
+
22
+ The prose. Every finding carries the underlying result's own ``summary()``
23
+ word for word, framed by a line on what it means for the claim and a line on
24
+ what to do about it. The audit never restates a number in its own words,
25
+ because a second set of sentences about the same numbers drifts from the
26
+ first one the moment either changes.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import fields as _fields
32
+ from typing import Mapping, Optional
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+
37
+ from ._types import (
38
+ _SEVERITIES,
39
+ AuditConfig,
40
+ AuditReport,
41
+ Finding,
42
+ SkippedCheck,
43
+ )
44
+ from .agreement import _LEVELS, rater_agreement
45
+ from .compare import compare_independent, compare_paired
46
+ from .judge import judge_validation, length_bias, position_bias
47
+ from .power import detectable_effect
48
+ from .scores import _is_binary, score_ci
49
+
50
+ __all__ = ["audit"]
51
+
52
+ SEVERITY_ORDER = _SEVERITIES
53
+
54
+ # How findings sort inside one severity, and it is not module order.
55
+ #
56
+ # The margin comes first because the margin is the number on the slide. If
57
+ # it does not survive a proper fit, nothing further down changes what the
58
+ # client has to be told. Power sits behind it because it says whether that
59
+ # margin could ever have been found. Then the two checks on whether the
60
+ # labels underneath mean anything, then the two judge biases, then the
61
+ # score intervals, which are context by construction.
62
+ #
63
+ # agreement and judge sitting below compare is deliberate and is not a
64
+ # statement about their importance. They are what makes this package worth
65
+ # building. But severity does the real work in this ordering: a critical
66
+ # agreement finding already outranks a compare warning, and this tuple only
67
+ # breaks ties inside one severity. Reordering it on the instinct that the
68
+ # best module should be first makes the report worse, because it moves the
69
+ # slide deck number down the page for reasons the reader does not share.
70
+ CHECK_ORDER = (
71
+ "compare",
72
+ "power",
73
+ "agreement",
74
+ "judge",
75
+ "position",
76
+ "length",
77
+ "scores",
78
+ )
79
+
80
+ # What each check is called when it could not run. Findings carry their own
81
+ # titles, which say what was found rather than what was looked at.
82
+ CHECK_TITLES = {
83
+ "compare": "Headline margin",
84
+ "power": "What this eval could have detected",
85
+ "agreement": "Rater agreement",
86
+ "judge": "Judge against humans",
87
+ "position": "Position bias",
88
+ "length": "Length bias",
89
+ "scores": "Score intervals",
90
+ }
91
+
92
+ _JUDGE_KEYS = (
93
+ "human",
94
+ "judge",
95
+ "slices",
96
+ "preferences",
97
+ "lengths",
98
+ "human_preferences",
99
+ )
100
+
101
+
102
+ def audit(
103
+ scores=None,
104
+ comparisons=None,
105
+ ratings=None,
106
+ judge=None,
107
+ config=None,
108
+ ) -> AuditReport:
109
+ """Run every check the supplied data supports and rank what comes back.
110
+
111
+ Parameters
112
+ ----------
113
+ scores
114
+ Per-item scores, either a mapping of system name to scores or a bare
115
+ sequence for a single system. Binary 0/1 or continuous. Two systems
116
+ get a comparison and a power check on top of their intervals. Three
117
+ or more get intervals only, since which pair to compare is not the
118
+ audit's guess to make.
119
+ comparisons
120
+ A frame with ``pair_id``, ``option_a``, ``option_b`` and ``winner``,
121
+ as :func:`evalaudit.position_bias` takes it. One row per pairwise
122
+ judgement.
123
+ ratings
124
+ A long frame with ``item_id``, ``rater_id`` and ``rating``, as
125
+ :func:`evalaudit.rater_agreement` takes it. One row per rating given.
126
+ judge
127
+ A mapping. ``human`` and ``judge`` are aligned label sequences and
128
+ run the validation check, and ``slices`` alongside them is the
129
+ argument worth supplying, since a judge that fails on the close
130
+ calls posts a fine headline number. ``preferences`` and ``lengths``
131
+ run the length check, with ``human_preferences`` sharpening it.
132
+ config
133
+ An :class:`evalaudit.AuditConfig` or a mapping of its fields. The
134
+ ones worth setting are ``claim``, ``effect_of_interest`` and
135
+ ``claims_direction``.
136
+
137
+ Returns
138
+ -------
139
+ AuditReport
140
+
141
+ Notes
142
+ -----
143
+ Nothing here is inferred from data that was not supplied. Every check
144
+ either produces a finding or lands in ``not_run`` with the reason, so an
145
+ audit of scores alone says out loud that it never looked at the graders.
146
+
147
+ The paired versus independent question is read off the data when
148
+ ``config.paired`` is None. Two systems with the same number of items are
149
+ taken as paired, which is how most evals are built and which the finding
150
+ says out loud so it can be corrected.
151
+
152
+ Examples
153
+ --------
154
+ >>> report = audit(scores={"new": new, "old": old}) # doctest: +SKIP
155
+ >>> print(report.summary()) # doctest: +SKIP
156
+ >>> print(report.to_markdown()) # doctest: +SKIP
157
+ """
158
+ cfg = _config(config)
159
+ systems = _systems(scores)
160
+ judge_data = _judge_data(judge)
161
+
162
+ if not systems and comparisons is None and ratings is None and not judge_data:
163
+ raise ValueError(
164
+ "audit needs at least one of scores, comparisons, ratings or judge"
165
+ )
166
+
167
+ findings: list = []
168
+ skipped: list = []
169
+
170
+ def record(check, outcome):
171
+ """Every check returns a Finding, a list of them, or a reason."""
172
+ if isinstance(outcome, str):
173
+ skipped.append(SkippedCheck(check, CHECK_TITLES[check], outcome))
174
+ elif isinstance(outcome, list):
175
+ findings.extend(outcome)
176
+ else:
177
+ findings.append(outcome)
178
+
179
+ record("scores", _score_findings(systems, cfg))
180
+ comparison = _comparison(systems, cfg)
181
+ record("compare", _compare_finding(comparison, systems, cfg))
182
+ record("power", _power_finding(comparison, systems, cfg))
183
+ record("agreement", _agreement_finding(ratings, cfg))
184
+ record("judge", _judge_finding(judge_data, cfg))
185
+ record("position", _position_finding(comparisons, cfg))
186
+ record("length", _length_finding(judge_data, cfg))
187
+
188
+ skipped.sort(key=lambda s: CHECK_ORDER.index(s.check))
189
+ return AuditReport(
190
+ findings=_ranked(findings), not_run=tuple(skipped), config=cfg
191
+ )
192
+
193
+
194
+ # --------------------------------------------------------------------------
195
+ # Ranking
196
+ # --------------------------------------------------------------------------
197
+
198
+ def _ranked(findings) -> tuple:
199
+ """Sort by severity, then by CHECK_ORDER, keeping ties as they came.
200
+
201
+ The sort is stable, so two findings from one check stay in the order the
202
+ check produced them. That is what keeps the score intervals in the order
203
+ the systems were supplied in.
204
+ """
205
+ return tuple(
206
+ sorted(
207
+ findings,
208
+ key=lambda f: (
209
+ SEVERITY_ORDER.index(f.severity),
210
+ CHECK_ORDER.index(f.check),
211
+ ),
212
+ )
213
+ )
214
+
215
+
216
+ # --------------------------------------------------------------------------
217
+ # Input handling
218
+ # --------------------------------------------------------------------------
219
+
220
+ def _config(config) -> AuditConfig:
221
+ if config is None:
222
+ return _validated(AuditConfig())
223
+ if isinstance(config, AuditConfig):
224
+ return _validated(config)
225
+ if not isinstance(config, Mapping):
226
+ raise ValueError(
227
+ "config must be an AuditConfig or a mapping of its fields, got "
228
+ f"{type(config).__name__}"
229
+ )
230
+ valid = [f.name for f in _fields(AuditConfig)]
231
+ unknown = sorted(set(config) - set(valid))
232
+ if unknown:
233
+ raise ValueError(
234
+ f"unknown config keys {unknown}. Valid keys are {valid}"
235
+ )
236
+ return _validated(AuditConfig(**config))
237
+
238
+
239
+ def _validated(cfg: AuditConfig) -> AuditConfig:
240
+ if not 0 < cfg.confidence < 1:
241
+ raise ValueError(f"confidence must be in (0, 1), got {cfg.confidence}")
242
+ if not 0 < cfg.power < 1:
243
+ raise ValueError(f"power must be in (0, 1), got {cfg.power}")
244
+ if not 0 < cfg.alpha < 1:
245
+ raise ValueError(f"alpha must be in (0, 1), got {cfg.alpha}")
246
+ if cfg.level not in _LEVELS:
247
+ raise ValueError(
248
+ f"level must be one of {sorted(_LEVELS)}, got {cfg.level!r}"
249
+ )
250
+ if cfg.effect_of_interest is not None and not cfg.effect_of_interest > 0:
251
+ raise ValueError(
252
+ "effect_of_interest must be greater than zero, got "
253
+ f"{cfg.effect_of_interest}. It is the size of the difference the "
254
+ "decision turns on, so zero names no difference at all"
255
+ )
256
+ if cfg.n_boot < 0:
257
+ raise ValueError(f"n_boot must not be negative, got {cfg.n_boot}")
258
+ return cfg
259
+
260
+
261
+ def _systems(scores) -> dict:
262
+ """Score sequences by system name, in the order they were supplied."""
263
+ if scores is None:
264
+ return {}
265
+ if isinstance(scores, pd.DataFrame):
266
+ raise ValueError(
267
+ "scores must be a sequence or a mapping of system name to "
268
+ "scores. For a frame, pass a mapping of the columns you want "
269
+ "compared, such as {name: frame[name] for name in (...)}"
270
+ )
271
+ if isinstance(scores, Mapping):
272
+ return {str(name): np.asarray(list(v)) for name, v in scores.items()}
273
+ return {"system": np.asarray(list(scores))}
274
+
275
+
276
+ def _judge_data(judge) -> dict:
277
+ if judge is None:
278
+ return {}
279
+ if not isinstance(judge, Mapping):
280
+ raise ValueError(
281
+ f"judge must be a mapping with keys from {list(_JUDGE_KEYS)}, "
282
+ f"got {type(judge).__name__}"
283
+ )
284
+ unknown = sorted(set(judge) - set(_JUDGE_KEYS))
285
+ if unknown:
286
+ raise ValueError(
287
+ f"unknown judge keys {unknown}. Valid keys are {list(_JUDGE_KEYS)}"
288
+ )
289
+ return dict(judge)
290
+
291
+
292
+ # --------------------------------------------------------------------------
293
+ # Score intervals. Context, and the foundation the margin sits on.
294
+ # --------------------------------------------------------------------------
295
+
296
+ def _score_findings(systems: dict, cfg: AuditConfig):
297
+ if not systems:
298
+ return (
299
+ "No scores supplied. Pass scores as a sequence, or as a mapping "
300
+ "of system name to scores."
301
+ )
302
+ return [
303
+ Finding(
304
+ check="scores",
305
+ severity="info",
306
+ title=f"Score interval for {name}",
307
+ detail=_detail(
308
+ f"The headline number for {name}, with the interval around "
309
+ f"it.",
310
+ result,
311
+ "Quote the interval wherever the number appears. A point "
312
+ "estimate on its own invites a reader to compare it with "
313
+ "something it cannot be compared with.",
314
+ ),
315
+ result=result,
316
+ )
317
+ for name, result in (
318
+ (
319
+ name,
320
+ score_ci(values, confidence=cfg.confidence, seed=cfg.seed),
321
+ )
322
+ for name, values in systems.items()
323
+ )
324
+ ]
325
+
326
+
327
+ # --------------------------------------------------------------------------
328
+ # Check 2. The headline margin
329
+ # --------------------------------------------------------------------------
330
+
331
+ def _comparison(systems: dict, cfg: AuditConfig):
332
+ """The comparison itself, or None when there is not exactly one to make."""
333
+ names = list(systems)
334
+ if len(names) != 2:
335
+ return None
336
+ a, b = systems[names[0]], systems[names[1]]
337
+ paired = (a.size == b.size) if cfg.paired is None else cfg.paired
338
+ if paired and a.size != b.size:
339
+ raise ValueError(
340
+ f"a paired comparison needs the same number of items from both "
341
+ f"systems, got {a.size} and {b.size}. Set paired=False in the "
342
+ f"config if the two were run on different items"
343
+ )
344
+ if paired:
345
+ return compare_paired(a, b, confidence=cfg.confidence, seed=cfg.seed)
346
+ return compare_independent(a, b, confidence=cfg.confidence, seed=cfg.seed)
347
+
348
+
349
+ def _compare_finding(comparison, systems: dict, cfg: AuditConfig):
350
+ if comparison is None:
351
+ return _no_comparison_reason(systems)
352
+
353
+ names = list(systems)
354
+ if comparison.crosses_zero:
355
+ severity = "critical" if cfg.claims_direction else "warning"
356
+ lead = (
357
+ f"This is the margin between {names[0]} and {names[1]}, which is "
358
+ f"the number the claim rests on."
359
+ )
360
+ action = (
361
+ "Do not report a direction from this data. Either run enough "
362
+ "more items to close the interval, or state the result as "
363
+ "unresolved."
364
+ )
365
+ if not cfg.claims_direction:
366
+ action += (
367
+ " Nothing is claiming a direction here, so this weakens the "
368
+ "result rather than undoing it."
369
+ )
370
+ else:
371
+ severity = "info"
372
+ lead = (
373
+ f"This is the margin between {names[0]} and {names[1]}, and it "
374
+ f"survives the fit."
375
+ )
376
+ action = (
377
+ "Report the interval alongside the difference. The lower bound "
378
+ "is the size of the win the data actually supports."
379
+ )
380
+
381
+ if cfg.paired is None and comparison.paired:
382
+ action += (
383
+ " Both systems scored the same number of items, so this was read "
384
+ "as a paired comparison. Set paired=False in the config if they "
385
+ "were run on different items."
386
+ )
387
+
388
+ return Finding(
389
+ check="compare",
390
+ severity=severity,
391
+ title=(
392
+ "The headline margin does not exclude zero"
393
+ if comparison.crosses_zero
394
+ else "The headline margin clears zero"
395
+ ),
396
+ detail=_detail(lead, comparison, action),
397
+ result=comparison,
398
+ )
399
+
400
+
401
+ def _no_comparison_reason(systems: dict) -> str:
402
+ if not systems:
403
+ return (
404
+ "No scores supplied, so there is no margin to test. Pass scores "
405
+ "for the two systems being compared."
406
+ )
407
+ if len(systems) == 1:
408
+ return (
409
+ "Only one system was scored, so there is no margin to test. Pass "
410
+ "scores for two systems."
411
+ )
412
+ return (
413
+ f"Scores for {len(systems)} systems were supplied and this check "
414
+ f"compares two. Pass the two the claim is about."
415
+ )
416
+
417
+
418
+ # --------------------------------------------------------------------------
419
+ # Check 3. What the eval could have detected
420
+ # --------------------------------------------------------------------------
421
+
422
+ def _power_finding(comparison, systems: dict, cfg: AuditConfig):
423
+ effect, source = _effect_at_issue(comparison, cfg)
424
+ if effect is None:
425
+ return source
426
+
427
+ design = _power_design(comparison, systems, cfg)
428
+ if isinstance(design, str):
429
+ return design
430
+ n, paired, binary = design
431
+
432
+ if not binary:
433
+ return (
434
+ "The power arithmetic here is for pass rates and these scores "
435
+ "are not 0/1. Nothing to report rather than a figure that does "
436
+ "not apply."
437
+ )
438
+
439
+ result = detectable_effect(
440
+ n,
441
+ baseline=_baseline(systems),
442
+ power=cfg.power,
443
+ alpha=cfg.alpha,
444
+ paired=paired,
445
+ discordance_rate=_discordance(systems, paired, cfg),
446
+ )
447
+ reached = result.attainable and result.difference <= effect
448
+
449
+ lead = f"The effect at issue is {_points(effect)}, {source}."
450
+ if reached:
451
+ return Finding(
452
+ check="power",
453
+ severity="info",
454
+ title="This eval was large enough for the effect at issue",
455
+ detail=_detail(
456
+ lead,
457
+ result,
458
+ "Nothing to do here. A null result from this eval would be "
459
+ "worth something, because the eval could have found the "
460
+ "effect if it were there.",
461
+ ),
462
+ result=result,
463
+ )
464
+ return Finding(
465
+ check="power",
466
+ severity="critical",
467
+ title="This eval could not have detected the effect at issue",
468
+ detail=_detail(
469
+ lead,
470
+ result,
471
+ "No conclusion about an effect this size can be drawn from this "
472
+ "data, in either direction. A null result here says the eval was "
473
+ "too small and says nothing about the systems. Size the next run "
474
+ "off the figure above before grading anything.",
475
+ ),
476
+ result=result,
477
+ )
478
+
479
+
480
+ def _effect_at_issue(comparison, cfg: AuditConfig):
481
+ """The difference to size against, and where it came from.
482
+
483
+ A stated effect wins, because it is the one the decision turns on. With
484
+ nothing stated the observed margin stands in, since that is the number
485
+ being claimed. A margin of exactly zero names no effect at all, and
486
+ inventing one to check against would be the audit making up the question.
487
+ """
488
+ if cfg.effect_of_interest is not None:
489
+ return cfg.effect_of_interest, "as supplied in the config"
490
+ if comparison is not None and abs(comparison.difference) > 0:
491
+ return abs(comparison.difference), "the margin this eval reports"
492
+ return None, (
493
+ "Nothing to size the eval against. Pass effect_of_interest in the "
494
+ "config, or scores for two systems so the observed margin can stand "
495
+ "in for it."
496
+ )
497
+
498
+
499
+ def _power_design(comparison, systems: dict, cfg: AuditConfig):
500
+ """Items, pairing and whether the data is binary, or why not."""
501
+ if comparison is not None:
502
+ return comparison.n, comparison.paired, comparison.binary
503
+ if len(systems) == 1:
504
+ values = next(iter(systems.values()))
505
+ paired = True if cfg.paired is None else cfg.paired
506
+ return int(values.size), paired, _is_binary(values)
507
+ if not systems:
508
+ return (
509
+ "No scores supplied, so there is no sample size to check. Pass "
510
+ "scores for the system or systems the eval ran."
511
+ )
512
+ return (
513
+ f"Scores for {len(systems)} systems were supplied, so which "
514
+ f"comparison to size is ambiguous. Pass the two the claim is about."
515
+ )
516
+
517
+
518
+ def _discordance(systems: dict, paired: bool, cfg: AuditConfig):
519
+ """The measured rate when the data carries one, else whatever was set.
520
+
521
+ Paired binary power runs on the share of items the two systems disagree
522
+ on, and that share is sitting in the data. Measuring it beats both a
523
+ supplied figure and the conservative default the power module stands in.
524
+ """
525
+ if not paired or len(systems) != 2:
526
+ return cfg.discordance_rate
527
+ a, b = list(systems.values())
528
+ if a.size != b.size or not (_is_binary(a) and _is_binary(b)):
529
+ return cfg.discordance_rate
530
+ return float(np.mean(a != b))
531
+
532
+
533
+ def _baseline(systems: dict) -> float:
534
+ """The rate being compared against, for the independent arithmetic.
535
+
536
+ Paired binary power does not use it at all. It still has to be a rate,
537
+ so anything that is not one falls back on a half.
538
+ """
539
+ if not systems:
540
+ return 0.5
541
+ reference = list(systems.values())[-1]
542
+ mean = float(np.mean(reference))
543
+ return mean if 0 < mean < 1 else 0.5
544
+
545
+
546
+ # --------------------------------------------------------------------------
547
+ # Check 1. Rater agreement
548
+ # --------------------------------------------------------------------------
549
+
550
+ def _agreement_finding(ratings, cfg: AuditConfig):
551
+ if ratings is None:
552
+ return (
553
+ "No ratings supplied. Pass ratings as a long frame with "
554
+ "item_id, rater_id and rating columns, one row per rating given."
555
+ )
556
+
557
+ result = rater_agreement(
558
+ ratings,
559
+ level=cfg.level,
560
+ confidence=cfg.confidence,
561
+ bootstrap_ci=cfg.n_boot > 0,
562
+ n_boot=cfg.n_boot,
563
+ seed=cfg.seed,
564
+ )
565
+
566
+ if result.n_overlapping_items == 0:
567
+ return Finding(
568
+ check="agreement",
569
+ severity="critical",
570
+ title="Agreement was never measurable",
571
+ detail=_detail(
572
+ "Every conclusion in the eval rests on these grades, and "
573
+ "this asks whether the people giving them agreed.",
574
+ result,
575
+ "Nothing in the eval can be defended as reproducible until "
576
+ "some items are graded twice. Take a sample, have a second "
577
+ "person grade it blind, and run this again before anything "
578
+ "else in the report is acted on.",
579
+ ),
580
+ result=result,
581
+ )
582
+
583
+ alpha = result.alpha
584
+ if alpha != alpha or alpha < cfg.agreement_threshold:
585
+ return Finding(
586
+ check="agreement",
587
+ severity="warning",
588
+ title="Rater agreement is below the working threshold",
589
+ detail=_detail(
590
+ f"The grades under this eval reproduce less well than the "
591
+ f"{cfg.agreement_threshold:.3f} the report is holding them "
592
+ f"to.",
593
+ result,
594
+ "The eval is measuring the rubric as much as the systems. "
595
+ "Read the item table for the cases the graders split on, "
596
+ "since ambiguous wording is the usual cause and it is "
597
+ "cheaper to fix than more grading.",
598
+ ),
599
+ result=result,
600
+ )
601
+
602
+ return Finding(
603
+ check="agreement",
604
+ severity="info",
605
+ title="Rater agreement holds up",
606
+ detail=_detail(
607
+ f"The grades reproduce at or above the "
608
+ f"{cfg.agreement_threshold:.3f} the report is holding them to.",
609
+ result,
610
+ "Nothing to do here. Keep the double graded sample in the next "
611
+ "round so this stays measurable.",
612
+ ),
613
+ result=result,
614
+ )
615
+
616
+
617
+ # --------------------------------------------------------------------------
618
+ # Check 4. The judge against the humans
619
+ # --------------------------------------------------------------------------
620
+
621
+ def _judge_finding(judge_data: dict, cfg: AuditConfig):
622
+ if "human" not in judge_data or "judge" not in judge_data:
623
+ return (
624
+ "No judge labels supplied. Pass judge with human and judge "
625
+ "label sequences for the same items, and slices alongside them "
626
+ "if the eval is cut by difficulty or task type."
627
+ )
628
+
629
+ result = judge_validation(
630
+ judge_data["human"],
631
+ judge_data["judge"],
632
+ slices=judge_data.get("slices"),
633
+ level=cfg.level,
634
+ confidence=cfg.confidence,
635
+ n_boot=cfg.n_boot,
636
+ seed=cfg.seed,
637
+ )
638
+
639
+ if result.slice_is_distinguishable:
640
+ return Finding(
641
+ check="judge",
642
+ severity="critical",
643
+ title="The judge comes apart on one slice of the data",
644
+ detail=_detail(
645
+ "The judge is standing in for the humans across the whole "
646
+ "eval, so it has to track them everywhere the eval draws a "
647
+ "conclusion.",
648
+ result,
649
+ "Any ranking that leans on this slice is unsupported, "
650
+ "whatever the headline agreement says. Have humans label "
651
+ "that slice, or exclude it and say so, before the "
652
+ "comparison is used to pick anything.",
653
+ ),
654
+ result=result,
655
+ )
656
+
657
+ agreement = result.agreement
658
+ if agreement != agreement or agreement < cfg.judge_threshold:
659
+ return Finding(
660
+ check="judge",
661
+ severity="warning",
662
+ title="Judge-human agreement is below the working threshold",
663
+ detail=_detail(
664
+ f"The judge is standing in for the humans, and it tracks "
665
+ f"them less closely than the {cfg.judge_threshold:.3f} this "
666
+ f"report is holding it to.",
667
+ result,
668
+ "That threshold is a convention rather than a validated "
669
+ "line, so the question is whether this level of agreement "
670
+ "is good enough for the decision being made. Say which "
671
+ "decision, and set judge_threshold from it.",
672
+ ),
673
+ result=result,
674
+ )
675
+
676
+ return Finding(
677
+ check="judge",
678
+ severity="info",
679
+ title="The judge tracks the humans",
680
+ detail=_detail(
681
+ f"The judge agrees with the humans at or above the "
682
+ f"{cfg.judge_threshold:.3f} this report is holding it to.",
683
+ result,
684
+ "Nothing to do here. Keep labelling a slice of items by hand "
685
+ "each round, since a judge that drifts shows up nowhere else.",
686
+ ),
687
+ result=result,
688
+ )
689
+
690
+
691
+ # --------------------------------------------------------------------------
692
+ # Check 5. Position and length bias
693
+ # --------------------------------------------------------------------------
694
+
695
+ def _position_finding(comparisons, cfg: AuditConfig):
696
+ if comparisons is None:
697
+ return (
698
+ "No pairwise judgements supplied. Pass comparisons as a frame "
699
+ "with pair_id, option_a, option_b and winner columns, one row "
700
+ "per judgement."
701
+ )
702
+
703
+ result = position_bias(comparisons, seed=cfg.seed)
704
+
705
+ if result.has_position_effect:
706
+ return Finding(
707
+ check="position",
708
+ severity="warning",
709
+ title="The judge favours whichever answer it reads first",
710
+ detail=_detail(
711
+ "A judge that reads position rather than quality can rank "
712
+ "two systems wrongly while agreeing with humans often "
713
+ "enough to pass a validation.",
714
+ result,
715
+ "Run every pair in both orders and keep only the pairs the "
716
+ "judge called the same way twice. Until then the margin "
717
+ "from these judgements carries an effect that has nothing "
718
+ "to do with the systems.",
719
+ ),
720
+ result=result,
721
+ )
722
+
723
+ return Finding(
724
+ check="position",
725
+ severity="info",
726
+ title="No position effect in the pairwise judgements",
727
+ detail=_detail(
728
+ "This asks whether the judge is reading position rather than "
729
+ "quality.",
730
+ result,
731
+ "Nothing to do here. Keep the order shuffled in the next round, "
732
+ "since this is a property of the setup rather than of the "
733
+ "model.",
734
+ ),
735
+ result=result,
736
+ )
737
+
738
+
739
+ def _length_finding(judge_data: dict, cfg: AuditConfig):
740
+ if "preferences" not in judge_data or "lengths" not in judge_data:
741
+ return (
742
+ "No judge preferences and lengths supplied. Pass judge with "
743
+ "preferences and lengths for the same pairs, and "
744
+ "human_preferences alongside them to sharpen the fit."
745
+ )
746
+
747
+ result = length_bias(
748
+ judge_data["preferences"],
749
+ judge_data["lengths"],
750
+ human_preferences=judge_data.get("human_preferences"),
751
+ )
752
+
753
+ if _length_flagged(result):
754
+ return Finding(
755
+ check="length",
756
+ severity="warning",
757
+ title="The judge is pulled by how long the answer is",
758
+ detail=_detail(
759
+ "A judge that rewards length ranks the wordier system "
760
+ "higher whatever it says, and it can do that while "
761
+ "agreeing with humans on most pairs.",
762
+ result,
763
+ "Check whether the winning system is simply the longer one. "
764
+ "Comparing answers trimmed to a common length, or scoring "
765
+ "with length in the rubric rather than in the judge, will "
766
+ "say whether the margin survives.",
767
+ ),
768
+ result=result,
769
+ )
770
+
771
+ return Finding(
772
+ check="length",
773
+ severity="info",
774
+ title="No length effect in the judge's choices",
775
+ detail=_detail(
776
+ "This asks whether the judge is rewarding length rather than "
777
+ "quality.",
778
+ result,
779
+ "Nothing to do here.",
780
+ ),
781
+ result=result,
782
+ )
783
+
784
+
785
+ def _length_flagged(result) -> bool:
786
+ """Whether the length fit is a finding, and which fit decides it.
787
+
788
+ With human labels the disagreement fit decides, because it is the
789
+ sharper of the two. It asks whether the judge breaks with the humans on
790
+ the pairs where the humans went short, and a plain preference for longer
791
+ answers is not bias on its own, since longer answers may be better.
792
+
793
+ Without human labels, or when the judge never disagreed and there was
794
+ nothing to fit, the preference model decides and the summary carries
795
+ that caveat.
796
+ """
797
+ low, high = result.ci_low, result.ci_high
798
+ if result.has_human and np.isfinite(result.disagreement_ci_low):
799
+ low, high = result.disagreement_ci_low, result.disagreement_ci_high
800
+ if not (np.isfinite(low) and np.isfinite(high)):
801
+ return False
802
+ return not low <= 0.0 <= high
803
+
804
+
805
+ # --------------------------------------------------------------------------
806
+ # Prose
807
+ # --------------------------------------------------------------------------
808
+
809
+ def _detail(lead: str, result, action: str) -> str:
810
+ """What was measured, what the module said, and what to do about it.
811
+
812
+ The middle sentence is the module's own ``summary()``, unedited. Audit
813
+ frames it and never paraphrases it, so there is one set of sentences
814
+ about any given number and it lives next to the arithmetic.
815
+ """
816
+ return f"{lead} {result.summary()} {action}"
817
+
818
+
819
+ def _points(effect: float) -> str:
820
+ """A difference on the scale people say it out loud in."""
821
+ return f"{effect * 100:.1f} points"