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