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/judge.py
ADDED
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
"""Is the LLM judge standing in for your humans, or only where it is easy?
|
|
2
|
+
|
|
3
|
+
Three checks, and the first one is the reason the module exists. A judge
|
|
4
|
+
scored against human labels almost always posts a respectable headline
|
|
5
|
+
number, because most items in most evals are not close calls. The judge
|
|
6
|
+
tracks humans on those and comes apart on the ones that decide the result.
|
|
7
|
+
So the per-slice table is the finding and the headline is the setup.
|
|
8
|
+
|
|
9
|
+
The other two are the failure modes that do not show up as disagreement at
|
|
10
|
+
all. A judge that prefers whichever answer it reads first, and a judge that
|
|
11
|
+
prefers whichever answer is longer, can both agree with humans often enough
|
|
12
|
+
to pass a validation and still rank two systems wrongly.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Optional, Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pandas as pd
|
|
21
|
+
from scipy import special as _special
|
|
22
|
+
from scipy import stats as _stats
|
|
23
|
+
|
|
24
|
+
from ._types import (
|
|
25
|
+
_NOTE_SLICE_NO_VARIANCE,
|
|
26
|
+
_NOTE_SLICE_ONE_ITEM,
|
|
27
|
+
JudgeValidation,
|
|
28
|
+
LengthBias,
|
|
29
|
+
PositionBias,
|
|
30
|
+
)
|
|
31
|
+
from .agreement import _LEVELS, _Units, _bootstrap
|
|
32
|
+
from .scores import _wilson
|
|
33
|
+
|
|
34
|
+
__all__ = ["judge_validation", "position_bias", "length_bias"]
|
|
35
|
+
|
|
36
|
+
_COMPARISON_COLUMNS = ("pair_id", "option_a", "option_b", "winner")
|
|
37
|
+
|
|
38
|
+
# A pair counts as run in both orders when two of its rows are exact
|
|
39
|
+
# reverses. The design is read as both-orders when at least this share of
|
|
40
|
+
# pairs qualify, so a handful of stray duplicates cannot flip the analysis.
|
|
41
|
+
_BOTH_ORDERS_SHARE = 0.5
|
|
42
|
+
|
|
43
|
+
# Why a logistic fit returns no coefficient. Each is a different thing to go
|
|
44
|
+
# and fix, so each gets its own sentence rather than a shared failure.
|
|
45
|
+
_NOTE_ONE_OUTCOME = "the outcome never varied, so there is nothing to model"
|
|
46
|
+
_NOTE_NO_LENGTH_VARIATION = (
|
|
47
|
+
"every pair had the same length difference, so the predictor is constant"
|
|
48
|
+
)
|
|
49
|
+
_NOTE_SEPARATED = (
|
|
50
|
+
"length difference splits the outcome perfectly, so the coefficient is unbounded"
|
|
51
|
+
)
|
|
52
|
+
_NOTE_NO_CONVERGENCE = "the fit did not converge"
|
|
53
|
+
|
|
54
|
+
_MAX_IRLS_STEPS = 100
|
|
55
|
+
_IRLS_TOLERANCE = 1e-10
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --------------------------------------------------------------------------
|
|
59
|
+
# judge_validation
|
|
60
|
+
# --------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def judge_validation(
|
|
63
|
+
human: Sequence,
|
|
64
|
+
judge: Sequence,
|
|
65
|
+
slices: Optional[Sequence] = None,
|
|
66
|
+
level: str = "nominal",
|
|
67
|
+
confidence: float = 0.95,
|
|
68
|
+
n_boot: int = 1000,
|
|
69
|
+
seed: Optional[int] = None,
|
|
70
|
+
) -> JudgeValidation:
|
|
71
|
+
"""Agreement between an LLM judge's labels and human labels.
|
|
72
|
+
|
|
73
|
+
Parameters
|
|
74
|
+
----------
|
|
75
|
+
human, judge
|
|
76
|
+
Labels for the same items, aligned position by position. Items where
|
|
77
|
+
either side has no label are set aside and counted, since an item the
|
|
78
|
+
judge could not label is not a disagreement.
|
|
79
|
+
slices
|
|
80
|
+
One label per item saying which group it belongs to. Difficulty band,
|
|
81
|
+
task type, language, whatever the eval is cut by. This is the
|
|
82
|
+
argument worth supplying. A judge that agrees with humans at 0.85
|
|
83
|
+
overall and at 0.20 on the close calls is not a judge you can rank
|
|
84
|
+
two systems with, and only the breakdown shows it.
|
|
85
|
+
level
|
|
86
|
+
``"nominal"``, ``"ordinal"`` or ``"interval"``, read exactly as
|
|
87
|
+
``rater_agreement`` reads them. A 1-5 quality rubric is ordinal, and
|
|
88
|
+
calling it nominal throws away the fact that 4 and 5 are nearly the
|
|
89
|
+
same judgement.
|
|
90
|
+
confidence
|
|
91
|
+
Nominal coverage of the bootstrap interval, default 0.95.
|
|
92
|
+
n_boot
|
|
93
|
+
Bootstrap resamples over items. Set 0 to skip the interval. Then
|
|
94
|
+
nothing in the result will name a slice, because there is no
|
|
95
|
+
sampling error to judge one against.
|
|
96
|
+
seed
|
|
97
|
+
Seeds the bootstrap. Set it in anything you publish.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
JudgeValidation
|
|
102
|
+
|
|
103
|
+
Notes
|
|
104
|
+
-----
|
|
105
|
+
The overall agreement is Krippendorff's alpha between the two label
|
|
106
|
+
series, which is the same statistic and the same scale as
|
|
107
|
+
``rater_agreement``. A judge is a rater.
|
|
108
|
+
|
|
109
|
+
Per-slice alpha is computed on that slice alone, so the value domain is
|
|
110
|
+
the one the slice used. That is the right definition for how well the
|
|
111
|
+
judge did there, and it means two slices can carry alphas that are not on
|
|
112
|
+
quite the same footing when they used different label sets.
|
|
113
|
+
"""
|
|
114
|
+
if level not in _LEVELS:
|
|
115
|
+
raise ValueError(f"level must be one of {sorted(_LEVELS)}, got {level!r}")
|
|
116
|
+
if not 0 < confidence < 1:
|
|
117
|
+
raise ValueError(f"confidence must be in (0, 1), got {confidence}")
|
|
118
|
+
|
|
119
|
+
human_raw = np.asarray(list(human), dtype=object)
|
|
120
|
+
judge_raw = np.asarray(list(judge), dtype=object)
|
|
121
|
+
if human_raw.shape != judge_raw.shape:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"human and judge must be the same length, got "
|
|
124
|
+
f"{human_raw.size} and {judge_raw.size}"
|
|
125
|
+
)
|
|
126
|
+
if human_raw.size == 0:
|
|
127
|
+
raise ValueError("human and judge must not be empty")
|
|
128
|
+
|
|
129
|
+
if slices is None:
|
|
130
|
+
slice_raw = None
|
|
131
|
+
else:
|
|
132
|
+
slice_raw = np.asarray(list(slices), dtype=object)
|
|
133
|
+
if slice_raw.shape != human_raw.shape:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"slices must be the same length as human and judge, got "
|
|
136
|
+
f"{slice_raw.size} and {human_raw.size}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
keep = np.array(
|
|
140
|
+
[_present(a) and _present(b) for a, b in zip(human_raw, judge_raw)]
|
|
141
|
+
)
|
|
142
|
+
n_dropped = int((~keep).sum())
|
|
143
|
+
human_kept = human_raw[keep]
|
|
144
|
+
judge_kept = judge_raw[keep]
|
|
145
|
+
slice_kept = None if slice_raw is None else slice_raw[keep]
|
|
146
|
+
|
|
147
|
+
if human_kept.size == 0:
|
|
148
|
+
raise ValueError("no item carries a label from both sides")
|
|
149
|
+
|
|
150
|
+
units = _Units.build(_long_frame(human_kept, judge_kept), level)
|
|
151
|
+
agreement = units.alpha()
|
|
152
|
+
ci_low, ci_high, drawn, usable = _bootstrap(
|
|
153
|
+
units, confidence, n_boot > 0, n_boot, seed
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return JudgeValidation(
|
|
157
|
+
agreement=agreement,
|
|
158
|
+
ci_low=ci_low,
|
|
159
|
+
ci_high=ci_high,
|
|
160
|
+
accuracy=float(np.mean(human_kept == judge_kept)),
|
|
161
|
+
n_items=int(human_kept.size),
|
|
162
|
+
n_dropped=n_dropped,
|
|
163
|
+
level=level,
|
|
164
|
+
by_slice=_slice_table(
|
|
165
|
+
human_kept, judge_kept, slice_kept, level, confidence, n_boot, seed
|
|
166
|
+
),
|
|
167
|
+
confidence=confidence,
|
|
168
|
+
n_boot=drawn,
|
|
169
|
+
n_boot_usable=usable,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _present(value) -> bool:
|
|
174
|
+
"""True unless the label is missing.
|
|
175
|
+
|
|
176
|
+
Written out because None, NaN and pd.NA all turn up in label columns and
|
|
177
|
+
a self-comparison alone does not cover the first two.
|
|
178
|
+
"""
|
|
179
|
+
if value is None or value is pd.NA:
|
|
180
|
+
return False
|
|
181
|
+
return not (isinstance(value, float) and value != value)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _long_frame(human: np.ndarray, judge: np.ndarray) -> pd.DataFrame:
|
|
185
|
+
"""The two label series as the long ratings frame agreement.py expects.
|
|
186
|
+
|
|
187
|
+
Item ids are positions, so the same item is the same row in both series
|
|
188
|
+
and every item ends up with exactly two ratings.
|
|
189
|
+
"""
|
|
190
|
+
n = human.size
|
|
191
|
+
positions = np.arange(n)
|
|
192
|
+
return pd.DataFrame({
|
|
193
|
+
"item_id": np.concatenate([positions, positions]),
|
|
194
|
+
"rater_id": np.array(["human"] * n + ["judge"] * n, dtype=object),
|
|
195
|
+
"rating": np.concatenate([human, judge]),
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _slice_table(human, judge, slices, level, confidence, n_boot, seed):
|
|
200
|
+
"""Agreement and accuracy within each slice, worst first, with intervals.
|
|
201
|
+
|
|
202
|
+
Sorted ascending because the reader looks at the top row, and undefined
|
|
203
|
+
slices sorted last because a slice where both sides used one label is not
|
|
204
|
+
a failure and must not be presented as the worst one in the eval.
|
|
205
|
+
|
|
206
|
+
Each slice gets its own bootstrap interval, and the naming rule needs it.
|
|
207
|
+
A slice is a fraction of the data, so its sampling error is wider than
|
|
208
|
+
the one on the overall figure, often several times wider. Comparing a
|
|
209
|
+
slice's point estimate to the overall interval ignores that and names a
|
|
210
|
+
slice on data that is nothing but noise. Cut three hundred homogeneous
|
|
211
|
+
items into five slices of sixty and the lowest one lands below the
|
|
212
|
+
overall lower bound most of the time.
|
|
213
|
+
"""
|
|
214
|
+
columns = ["slice", "n_items", "agreement", "ci_low", "ci_high",
|
|
215
|
+
"accuracy", "note"]
|
|
216
|
+
if slices is None:
|
|
217
|
+
return pd.DataFrame(columns=columns)
|
|
218
|
+
|
|
219
|
+
rng = np.random.default_rng(seed)
|
|
220
|
+
rows = []
|
|
221
|
+
for name in dict.fromkeys(slices):
|
|
222
|
+
mask = slices == name
|
|
223
|
+
h, j = human[mask], judge[mask]
|
|
224
|
+
units = _Units.build(_long_frame(h, j), level)
|
|
225
|
+
|
|
226
|
+
if units.n_units < 2:
|
|
227
|
+
# The item count is checked before the variance, unlike the
|
|
228
|
+
# dropout table in agreement.py. There, a rater whose removal
|
|
229
|
+
# leaves everyone agreeing is the finding. Here one item is too
|
|
230
|
+
# small to measure whatever else happens to be true of it.
|
|
231
|
+
note = _NOTE_SLICE_ONE_ITEM
|
|
232
|
+
elif units.is_degenerate:
|
|
233
|
+
note = _NOTE_SLICE_NO_VARIANCE
|
|
234
|
+
else:
|
|
235
|
+
note = ""
|
|
236
|
+
|
|
237
|
+
lo, hi, _, _ = _bootstrap(
|
|
238
|
+
units, confidence, n_boot > 0, n_boot,
|
|
239
|
+
int(rng.integers(2 ** 32)) if n_boot > 0 else None,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
rows.append({
|
|
243
|
+
"slice": name,
|
|
244
|
+
"n_items": int(h.size),
|
|
245
|
+
"agreement": units.alpha(),
|
|
246
|
+
"ci_low": lo,
|
|
247
|
+
"ci_high": hi,
|
|
248
|
+
"accuracy": float(np.mean(h == j)),
|
|
249
|
+
"note": note,
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
table = pd.DataFrame(rows, columns=columns)
|
|
253
|
+
return (
|
|
254
|
+
table.sort_values(
|
|
255
|
+
"agreement", ascending=True, na_position="last", kind="mergesort"
|
|
256
|
+
)
|
|
257
|
+
.reset_index(drop=True)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --------------------------------------------------------------------------
|
|
262
|
+
# position_bias
|
|
263
|
+
# --------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def position_bias(
|
|
266
|
+
comparisons: pd.DataFrame, seed: Optional[int] = None
|
|
267
|
+
) -> PositionBias:
|
|
268
|
+
"""Does the judge favour whichever output it sees first.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
comparisons
|
|
273
|
+
A frame with ``pair_id``, ``option_a``, ``option_b`` and ``winner``.
|
|
274
|
+
One row per judgement. ``option_a`` is the output that was shown
|
|
275
|
+
first and ``option_b`` the one shown second, so the two columns carry
|
|
276
|
+
the presentation order rather than a fixed system label. ``winner``
|
|
277
|
+
names the output the judge picked, so it has to match one of the two.
|
|
278
|
+
Leave it missing to record a tie.
|
|
279
|
+
seed
|
|
280
|
+
Accepted so the signature matches the rest of the package. Nothing
|
|
281
|
+
here is resampled. Both intervals are Wilson score intervals, which
|
|
282
|
+
beat the bootstrap near the boundaries, and consistency rates live
|
|
283
|
+
near one.
|
|
284
|
+
|
|
285
|
+
Returns
|
|
286
|
+
-------
|
|
287
|
+
PositionBias
|
|
288
|
+
|
|
289
|
+
Notes
|
|
290
|
+
-----
|
|
291
|
+
The design is read off the data rather than asked for. A pair counts as
|
|
292
|
+
run in both orders when two of its rows are exact reverses of each other,
|
|
293
|
+
and the whole frame is analysed as a both-orders study when at least half
|
|
294
|
+
the pairs qualify. Otherwise it is analysed as a randomised study.
|
|
295
|
+
|
|
296
|
+
A mixed frame is analysed entirely under whichever branch wins that
|
|
297
|
+
threshold, not split between the two. Rows the winning branch cannot use
|
|
298
|
+
are set aside rather than analysed the other way, so a both-orders frame
|
|
299
|
+
with a tail of singly judged pairs reports the consistency rate on the
|
|
300
|
+
pairs that have both orderings and says how many pairs that was. Mixing
|
|
301
|
+
the two rates into one number would average quantities with different
|
|
302
|
+
denominators and different meanings.
|
|
303
|
+
|
|
304
|
+
The randomised branch cannot tell whether the order really was
|
|
305
|
+
randomised. If the same system sat in position A every time, a position-A
|
|
306
|
+
win rate above a half is exactly what a better system produces, and the
|
|
307
|
+
summary says so.
|
|
308
|
+
"""
|
|
309
|
+
data = _clean_comparisons(comparisons)
|
|
310
|
+
design, n_both_orders = _detect_design(data)
|
|
311
|
+
|
|
312
|
+
if design == "both_orders":
|
|
313
|
+
return _both_orders_result(data, n_both_orders)
|
|
314
|
+
return _randomised_result(data, n_both_orders)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _clean_comparisons(comparisons: pd.DataFrame) -> pd.DataFrame:
|
|
318
|
+
"""Validate and copy. Every check here is a data-entry mistake."""
|
|
319
|
+
if not isinstance(comparisons, pd.DataFrame):
|
|
320
|
+
raise ValueError("comparisons must be a pandas DataFrame")
|
|
321
|
+
|
|
322
|
+
missing = [c for c in _COMPARISON_COLUMNS if c not in comparisons.columns]
|
|
323
|
+
if missing:
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"comparisons is missing required column(s): {', '.join(missing)}. "
|
|
326
|
+
f"Expected {', '.join(_COMPARISON_COLUMNS)}, where option_a is the "
|
|
327
|
+
f"output shown first and winner names the one the judge picked."
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
data = comparisons.loc[:, list(_COMPARISON_COLUMNS)].copy()
|
|
331
|
+
if data.empty:
|
|
332
|
+
raise ValueError("comparisons must not be empty")
|
|
333
|
+
|
|
334
|
+
same = data["option_a"] == data["option_b"]
|
|
335
|
+
if same.any():
|
|
336
|
+
first = data[same].iloc[0]
|
|
337
|
+
raise ValueError(
|
|
338
|
+
f"comparisons pits an output against itself on pair "
|
|
339
|
+
f"{first['pair_id']!r}, where both sides are the same output. "
|
|
340
|
+
f"There is no position to measure on a pair like that."
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
decided = data["winner"].notna()
|
|
344
|
+
valid = (data["winner"] == data["option_a"]) | (
|
|
345
|
+
data["winner"] == data["option_b"]
|
|
346
|
+
)
|
|
347
|
+
bad = decided & ~valid
|
|
348
|
+
if bad.any():
|
|
349
|
+
first = data[bad].iloc[0]
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"comparisons has a winner that was not on offer: pair "
|
|
352
|
+
f"{first['pair_id']!r} names {first['winner']!r}, which is "
|
|
353
|
+
f"neither {first['option_a']!r} nor {first['option_b']!r}. Leave "
|
|
354
|
+
f"winner empty to record a tie."
|
|
355
|
+
)
|
|
356
|
+
return data
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _detect_design(data: pd.DataFrame):
|
|
360
|
+
"""Which study this is, and how many pairs were run both ways.
|
|
361
|
+
|
|
362
|
+
Returns the design name and the count, because the count belongs in the
|
|
363
|
+
summary either way. Under the randomised branch it says how many stray
|
|
364
|
+
reversals were ignored, and under the both-orders branch it says how much
|
|
365
|
+
of the frame the rate rests on.
|
|
366
|
+
"""
|
|
367
|
+
n_both = 0
|
|
368
|
+
for _, rows in data.groupby("pair_id", sort=False):
|
|
369
|
+
if _has_both_orders(rows):
|
|
370
|
+
n_both += 1
|
|
371
|
+
|
|
372
|
+
n_pairs = int(data["pair_id"].nunique())
|
|
373
|
+
if n_both and n_both >= _BOTH_ORDERS_SHARE * n_pairs:
|
|
374
|
+
return "both_orders", n_both
|
|
375
|
+
return "randomised", n_both
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _has_both_orders(rows: pd.DataFrame) -> bool:
|
|
379
|
+
"""True when two of these rows are exact reverses of each other.
|
|
380
|
+
|
|
381
|
+
The same pair judged twice with the same output in front is a repeat
|
|
382
|
+
measurement and says nothing about position, so it does not count.
|
|
383
|
+
"""
|
|
384
|
+
seen = set()
|
|
385
|
+
for a, b in zip(rows["option_a"], rows["option_b"]):
|
|
386
|
+
if (b, a) in seen:
|
|
387
|
+
return True
|
|
388
|
+
seen.add((a, b))
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _reversed_couple(rows: pd.DataFrame):
|
|
393
|
+
"""The first two rows of a pair that are reverses, or None."""
|
|
394
|
+
seen = {}
|
|
395
|
+
for position, (a, b) in enumerate(zip(rows["option_a"], rows["option_b"])):
|
|
396
|
+
if (b, a) in seen:
|
|
397
|
+
return rows.iloc[[seen[(b, a)], position]]
|
|
398
|
+
seen.setdefault((a, b), position)
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _pair_is_consistent(rows: pd.DataFrame) -> bool:
|
|
403
|
+
"""True when the judge named the same output under both orderings.
|
|
404
|
+
|
|
405
|
+
The same output, not the same position. On a pair the judge flipped, the
|
|
406
|
+
two are opposite answers, and counting positions here would score a judge
|
|
407
|
+
that always grabs whatever is in front of it as perfectly consistent.
|
|
408
|
+
"""
|
|
409
|
+
return bool(rows["winner"].nunique() == 1)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _randomised_result(data: pd.DataFrame, n_both_orders: int) -> PositionBias:
|
|
413
|
+
decided = data[data["winner"].notna()]
|
|
414
|
+
n_decisive = int(len(decided))
|
|
415
|
+
n_a_wins = int((decided["winner"] == decided["option_a"]).sum())
|
|
416
|
+
rate, lo, hi, p = _proportion(n_a_wins, n_decisive)
|
|
417
|
+
|
|
418
|
+
return PositionBias(
|
|
419
|
+
design="randomised",
|
|
420
|
+
estimate=rate,
|
|
421
|
+
ci_low=lo,
|
|
422
|
+
ci_high=hi,
|
|
423
|
+
p_value=p,
|
|
424
|
+
position_a_rate=rate,
|
|
425
|
+
n_a_wins=n_a_wins,
|
|
426
|
+
n_decisive=n_decisive,
|
|
427
|
+
consistency_rate=float("nan"),
|
|
428
|
+
n_pairs=int(data["pair_id"].nunique()),
|
|
429
|
+
n_pairs_scored=n_decisive,
|
|
430
|
+
n_judgements=int(len(data)),
|
|
431
|
+
n_both_orders=n_both_orders,
|
|
432
|
+
n_ties=int(len(data) - n_decisive),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _both_orders_result(data: pd.DataFrame, n_both_orders: int) -> PositionBias:
|
|
437
|
+
"""Consistency over the pairs run both ways, and where the flips went.
|
|
438
|
+
|
|
439
|
+
A flip has a direction. If the judge picked whatever was in front of it
|
|
440
|
+
in both presentations, that is position. If the flips split evenly across
|
|
441
|
+
the two positions, the judge is unsteady and that is a different fault.
|
|
442
|
+
"""
|
|
443
|
+
n_consistent = 0
|
|
444
|
+
n_scored = 0
|
|
445
|
+
n_flipped = 0
|
|
446
|
+
n_first = 0
|
|
447
|
+
|
|
448
|
+
for _, rows in data.groupby("pair_id", sort=False):
|
|
449
|
+
couple = _reversed_couple(rows)
|
|
450
|
+
if couple is None or couple["winner"].isna().any():
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
n_scored += 1
|
|
454
|
+
if _pair_is_consistent(couple):
|
|
455
|
+
n_consistent += 1
|
|
456
|
+
continue
|
|
457
|
+
|
|
458
|
+
n_flipped += 1
|
|
459
|
+
# `all` and `any` return the same thing here, and no test can tell
|
|
460
|
+
# them apart, so leave this as it is rather than reaching for the
|
|
461
|
+
# shorter one. The couple is two reversed presentations of one pair
|
|
462
|
+
# and the branch only runs when the winners differ, which leaves two
|
|
463
|
+
# cases. The judge named the first-shown output both times, and both
|
|
464
|
+
# rows have winner == option_a. Or it named the second-shown output
|
|
465
|
+
# both times, and neither does. There is no couple where the rows
|
|
466
|
+
# disagree, so the two functions cannot diverge on reachable input.
|
|
467
|
+
# `all` is kept because it states the intent, which is that the
|
|
468
|
+
# position has to hold across both presentations.
|
|
469
|
+
if bool((couple["winner"] == couple["option_a"]).all()):
|
|
470
|
+
n_first += 1
|
|
471
|
+
|
|
472
|
+
consistency, c_lo, c_hi, _ = _proportion(n_consistent, n_scored)
|
|
473
|
+
rate, _, _, p = _proportion(n_first, n_flipped)
|
|
474
|
+
|
|
475
|
+
return PositionBias(
|
|
476
|
+
design="both_orders",
|
|
477
|
+
estimate=consistency,
|
|
478
|
+
ci_low=c_lo,
|
|
479
|
+
ci_high=c_hi,
|
|
480
|
+
p_value=p,
|
|
481
|
+
position_a_rate=rate,
|
|
482
|
+
n_a_wins=n_first,
|
|
483
|
+
n_decisive=n_flipped,
|
|
484
|
+
consistency_rate=consistency,
|
|
485
|
+
n_pairs=int(data["pair_id"].nunique()),
|
|
486
|
+
n_pairs_scored=n_scored,
|
|
487
|
+
n_judgements=int(len(data)),
|
|
488
|
+
n_both_orders=n_both_orders,
|
|
489
|
+
n_ties=int(data["winner"].isna().sum()),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _proportion(k: int, n: int):
|
|
494
|
+
"""Rate, Wilson interval, and the exact binomial p-value against a half."""
|
|
495
|
+
if n == 0:
|
|
496
|
+
nan = float("nan")
|
|
497
|
+
return nan, nan, nan, nan
|
|
498
|
+
lo, hi = _wilson(k, n, 0.95)
|
|
499
|
+
p = float(_stats.binomtest(k, n, 0.5).pvalue)
|
|
500
|
+
return k / n, lo, hi, p
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# --------------------------------------------------------------------------
|
|
504
|
+
# length_bias
|
|
505
|
+
# --------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def length_bias(
|
|
508
|
+
preferences: Sequence,
|
|
509
|
+
lengths,
|
|
510
|
+
human_preferences: Optional[Sequence] = None,
|
|
511
|
+
) -> LengthBias:
|
|
512
|
+
"""Does the judge go for the longer answer.
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
preferences
|
|
517
|
+
Which option the judge picked, one per pair. Either 0 and 1, or the
|
|
518
|
+
strings ``"A"`` and ``"B"``, where 1 and ``"A"`` mean the first
|
|
519
|
+
option.
|
|
520
|
+
lengths
|
|
521
|
+
An (n, 2) array or two-column frame of the two options' lengths, in
|
|
522
|
+
whatever unit you measure them in. Characters and tokens both work,
|
|
523
|
+
and the summary reports the effect over one standard deviation of the
|
|
524
|
+
observed differences so the unit does not have to be guessed at.
|
|
525
|
+
human_preferences
|
|
526
|
+
The humans' choices on the same pairs, same encoding. Supplying these
|
|
527
|
+
adds the second model, which is the sharper of the two.
|
|
528
|
+
|
|
529
|
+
Returns
|
|
530
|
+
-------
|
|
531
|
+
LengthBias
|
|
532
|
+
|
|
533
|
+
Notes
|
|
534
|
+
-----
|
|
535
|
+
Two fits on the same length difference, and they answer different
|
|
536
|
+
questions.
|
|
537
|
+
|
|
538
|
+
The first regresses judge preference on the signed length difference. A
|
|
539
|
+
positive coefficient says the judge picks the longer answer. That is not
|
|
540
|
+
bias on its own. Longer answers may be better, and on most corpora they
|
|
541
|
+
somewhat are.
|
|
542
|
+
|
|
543
|
+
The second regresses judge-human disagreement on the same difference,
|
|
544
|
+
oriented by what the humans picked, so the predictor is the length they
|
|
545
|
+
passed over minus the length they chose. A positive coefficient says the
|
|
546
|
+
judge breaks with the humans on the pairs where the humans went short,
|
|
547
|
+
which is what a length-biased judge does.
|
|
548
|
+
|
|
549
|
+
Holding the human verdict fixed removes the part of the length-quality
|
|
550
|
+
link the human labels capture. It does not remove the rest. A binary
|
|
551
|
+
label is a coarse measure of quality and the length difference still
|
|
552
|
+
carries quality information the label missed, so on a corpus where length
|
|
553
|
+
tracks quality closely the second coefficient stays positive for a judge
|
|
554
|
+
with no length preference at all. It is smaller than the first, it is the
|
|
555
|
+
better of the two numbers, and it is not a clean separation. The summary
|
|
556
|
+
says so rather than selling it as one.
|
|
557
|
+
|
|
558
|
+
Orienting the second model by the judge's own choice looks equivalent and
|
|
559
|
+
is not. Every disagreement is by definition a pair where the judge took
|
|
560
|
+
what the humans rejected, so the predictor becomes a consequence of the
|
|
561
|
+
outcome and the coefficient comes out negative whether or not the judge
|
|
562
|
+
is biased.
|
|
563
|
+
"""
|
|
564
|
+
chose_a = _as_choice(preferences, "preferences")
|
|
565
|
+
len_a, len_b = _as_lengths(lengths, chose_a.size)
|
|
566
|
+
|
|
567
|
+
if human_preferences is None:
|
|
568
|
+
human_chose_a = None
|
|
569
|
+
else:
|
|
570
|
+
human_chose_a = _as_choice(human_preferences, "human_preferences")
|
|
571
|
+
if human_chose_a.size != chose_a.size:
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"human_preferences must be the same length as preferences, "
|
|
574
|
+
f"got {human_chose_a.size} and {chose_a.size}"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
difference = _length_difference(len_a, len_b)
|
|
578
|
+
coefficient, ci_low, ci_high, p_value, note = _fit_logistic(
|
|
579
|
+
difference, chose_a.astype(float)
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
picked = np.where(chose_a, len_a, len_b)
|
|
583
|
+
passed = np.where(chose_a, len_b, len_a)
|
|
584
|
+
longer_rate = float(np.mean(picked > passed))
|
|
585
|
+
|
|
586
|
+
if human_chose_a is None:
|
|
587
|
+
oriented = np.zeros(0)
|
|
588
|
+
n_disagreements = 0
|
|
589
|
+
d_coef = d_lo = d_hi = d_p = float("nan")
|
|
590
|
+
d_note = ""
|
|
591
|
+
else:
|
|
592
|
+
oriented = _oriented_length_difference(human_chose_a, len_a, len_b)
|
|
593
|
+
disagrees = (chose_a != human_chose_a).astype(float)
|
|
594
|
+
n_disagreements = int(disagrees.sum())
|
|
595
|
+
d_coef, d_lo, d_hi, d_p, d_note = _fit_logistic(oriented, disagrees)
|
|
596
|
+
|
|
597
|
+
return LengthBias(
|
|
598
|
+
coefficient=coefficient,
|
|
599
|
+
ci_low=ci_low,
|
|
600
|
+
ci_high=ci_high,
|
|
601
|
+
p_value=p_value,
|
|
602
|
+
sd_difference=_sd(difference),
|
|
603
|
+
note=note,
|
|
604
|
+
disagreement_coefficient=d_coef,
|
|
605
|
+
disagreement_ci_low=d_lo,
|
|
606
|
+
disagreement_ci_high=d_hi,
|
|
607
|
+
disagreement_p_value=d_p,
|
|
608
|
+
disagreement_sd_difference=_sd(oriented),
|
|
609
|
+
disagreement_note=d_note,
|
|
610
|
+
n=int(chose_a.size),
|
|
611
|
+
n_disagreements=n_disagreements,
|
|
612
|
+
longer_rate=longer_rate,
|
|
613
|
+
has_human=human_chose_a is not None,
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _length_difference(len_a: np.ndarray, len_b: np.ndarray) -> np.ndarray:
|
|
618
|
+
"""The predictor in the preference model, signed.
|
|
619
|
+
|
|
620
|
+
Signed rather than absolute, because a judge that favours long answers
|
|
621
|
+
and one that favours short answers are opposite findings and an unsigned
|
|
622
|
+
predictor cannot tell them apart.
|
|
623
|
+
"""
|
|
624
|
+
return len_a - len_b
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _oriented_length_difference(
|
|
628
|
+
human_chose_a: np.ndarray, len_a: np.ndarray, len_b: np.ndarray
|
|
629
|
+
) -> np.ndarray:
|
|
630
|
+
"""The predictor in the disagreement model.
|
|
631
|
+
|
|
632
|
+
Length the humans passed over, minus the length they picked, so positive
|
|
633
|
+
means the humans went for the shorter answer on that pair.
|
|
634
|
+
"""
|
|
635
|
+
picked = np.where(human_chose_a, len_a, len_b)
|
|
636
|
+
passed = np.where(human_chose_a, len_b, len_a)
|
|
637
|
+
return passed - picked
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _as_choice(values: Sequence, name: str) -> np.ndarray:
|
|
641
|
+
"""Preferences as a boolean, True where the first option was picked."""
|
|
642
|
+
array = np.asarray(list(values), dtype=object)
|
|
643
|
+
if array.size == 0:
|
|
644
|
+
raise ValueError(f"{name} must not be empty")
|
|
645
|
+
|
|
646
|
+
distinct = set(array.tolist())
|
|
647
|
+
letters = {str(v).strip().upper() for v in distinct}
|
|
648
|
+
if letters <= {"A", "B"}:
|
|
649
|
+
return np.array([str(v).strip().upper() == "A" for v in array])
|
|
650
|
+
|
|
651
|
+
numeric = {float(v) for v in distinct if isinstance(v, (int, float, np.number))}
|
|
652
|
+
if len(numeric) == len(distinct) and numeric <= {0.0, 1.0}:
|
|
653
|
+
return np.array([float(v) == 1.0 for v in array])
|
|
654
|
+
|
|
655
|
+
raise ValueError(
|
|
656
|
+
f"{name} must take exactly two values, either 0 and 1 or 'A' and 'B', "
|
|
657
|
+
f"where 1 and 'A' mean the first option. Got {sorted(map(str, distinct))}."
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _as_lengths(lengths, n: int):
|
|
662
|
+
"""The two length columns, validated."""
|
|
663
|
+
if isinstance(lengths, pd.DataFrame):
|
|
664
|
+
array = lengths.to_numpy(dtype=float)
|
|
665
|
+
else:
|
|
666
|
+
array = np.asarray(lengths, dtype=float)
|
|
667
|
+
|
|
668
|
+
if array.ndim != 2 or array.shape[1] != 2:
|
|
669
|
+
raise ValueError(
|
|
670
|
+
f"lengths must have two columns, one per option, got shape "
|
|
671
|
+
f"{array.shape}"
|
|
672
|
+
)
|
|
673
|
+
if array.shape[0] != n:
|
|
674
|
+
raise ValueError(
|
|
675
|
+
f"preferences and lengths must be the same length, got {n} and "
|
|
676
|
+
f"{array.shape[0]}"
|
|
677
|
+
)
|
|
678
|
+
return array[:, 0], array[:, 1]
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _sd(x: np.ndarray) -> float:
|
|
682
|
+
"""Standard deviation of the predictor, for reading the coefficient."""
|
|
683
|
+
if x.size < 2:
|
|
684
|
+
return float("nan")
|
|
685
|
+
return float(np.std(x, ddof=1))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _fit_logistic(x: np.ndarray, y: np.ndarray):
|
|
689
|
+
"""Logistic regression of y on x with an intercept, by Newton steps.
|
|
690
|
+
|
|
691
|
+
Returns the slope, its Wald interval, its two-sided p-value and a note.
|
|
692
|
+
The note carries the reason when there is no coefficient, because the
|
|
693
|
+
three ways this fails are three different things to go and fix.
|
|
694
|
+
"""
|
|
695
|
+
nan = float("nan")
|
|
696
|
+
if y.size == 0 or np.unique(y).size < 2:
|
|
697
|
+
return nan, nan, nan, nan, _NOTE_ONE_OUTCOME
|
|
698
|
+
if np.ptp(x) == 0:
|
|
699
|
+
return nan, nan, nan, nan, _NOTE_NO_LENGTH_VARIATION
|
|
700
|
+
if _is_separated(x, y):
|
|
701
|
+
# The maximum likelihood slope is infinite here. Newton will happily
|
|
702
|
+
# return whatever it reached when the step size fell under tolerance,
|
|
703
|
+
# and printing that number would report the stopping rule as a
|
|
704
|
+
# finding.
|
|
705
|
+
return nan, nan, nan, nan, _NOTE_SEPARATED
|
|
706
|
+
|
|
707
|
+
design = np.column_stack([np.ones_like(x), x])
|
|
708
|
+
beta = np.zeros(2)
|
|
709
|
+
information = None
|
|
710
|
+
|
|
711
|
+
for _ in range(_MAX_IRLS_STEPS):
|
|
712
|
+
mu = _special.expit(design @ beta)
|
|
713
|
+
weights = np.clip(mu * (1 - mu), 1e-12, None)
|
|
714
|
+
weighted = design.T * weights
|
|
715
|
+
information = weighted @ design
|
|
716
|
+
try:
|
|
717
|
+
step = np.linalg.solve(information, design.T @ (y - mu))
|
|
718
|
+
except np.linalg.LinAlgError:
|
|
719
|
+
return nan, nan, nan, nan, _NOTE_NO_CONVERGENCE
|
|
720
|
+
beta = beta + step
|
|
721
|
+
if np.max(np.abs(step)) < _IRLS_TOLERANCE:
|
|
722
|
+
break
|
|
723
|
+
else:
|
|
724
|
+
return nan, nan, nan, nan, _NOTE_NO_CONVERGENCE
|
|
725
|
+
|
|
726
|
+
mu = _special.expit(design @ beta)
|
|
727
|
+
weights = np.clip(mu * (1 - mu), 1e-12, None)
|
|
728
|
+
information = (design.T * weights) @ design
|
|
729
|
+
covariance = np.linalg.inv(information)
|
|
730
|
+
se = float(np.sqrt(covariance[1, 1]))
|
|
731
|
+
|
|
732
|
+
slope = float(beta[1])
|
|
733
|
+
critical = _stats.norm.ppf(0.975)
|
|
734
|
+
p_value = float(2 * _stats.norm.sf(abs(slope / se)))
|
|
735
|
+
return slope, slope - critical * se, slope + critical * se, p_value, ""
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _is_separated(x: np.ndarray, y: np.ndarray) -> bool:
|
|
739
|
+
"""True when the predictor splits the two outcomes with no overlap."""
|
|
740
|
+
low = x[y == np.min(y)]
|
|
741
|
+
high = x[y != np.min(y)]
|
|
742
|
+
if low.size == 0 or high.size == 0:
|
|
743
|
+
return True
|
|
744
|
+
return bool(low.max() < high.min() or high.max() < low.min())
|