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