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/pairwise.py ADDED
@@ -0,0 +1,682 @@
1
+ """Which models on your leaderboard are actually in a different position?
2
+
3
+ A blind pairwise comparison produces a ranking, and the ranking gets
4
+ published. Almost nobody publishes what the ranking rests on. Six models make
5
+ fifteen pairs, and on the volumes people usually run, two or three of those
6
+ pairs are distinguishable and the rest are a coin flip dressed as an order.
7
+
8
+ Bradley-Terry is the model underneath every Elo leaderboard. Each model gets
9
+ a rating, and the chance one beats another is the logistic of the gap between
10
+ their ratings. Fit by maximum likelihood, with one rating pinned at zero
11
+ because only differences are identified.
12
+
13
+ Two things get in the way of a fit, and both show up in real leaderboard
14
+ data. Models that split into groups which never played each other have no
15
+ common scale at all. And a model that never lost has no finite rating, which
16
+ is what a clean sweep against a weak baseline produces. Neither returns
17
+ numbers here.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Optional
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+ from ._types import _MIN_USABLE_SHARE, BTResult
29
+
30
+ __all__ = ["bradley_terry", "to_elo"]
31
+
32
+ _COLUMNS = ("item_id", "model_a", "model_b", "winner")
33
+
34
+ # The string a tie is recorded as, matched without regard to case. A missing
35
+ # winner counts too, because that is how a spreadsheet records one.
36
+ _TIE = "tie"
37
+
38
+ # Tie policies this builds. "davidson" is recognised and refused on purpose,
39
+ # so asking for it gets an explanation rather than a list of valid options
40
+ # that quietly omits the treatment a reader came looking for.
41
+ _TIE_POLICIES = ("split", "drop")
42
+ _TIE_HOOK = "davidson"
43
+
44
+ # Newton on a concave log-likelihood from a sensible start converges in a few
45
+ # steps. The cap is here so a resample that is nearly separable cannot spin.
46
+ _MAX_NEWTON = 100
47
+ _NEWTON_TOL = 1e-11
48
+
49
+ # Ceiling on the working array the bootstrap allocates, in floats. Resamples
50
+ # run in blocks sized to fit under it. A block is not a resample: every
51
+ # resample inside a block is still fitted in the same pass as the others.
52
+ _BLOCK_BUDGET = 4_000_000
53
+
54
+
55
+ def bradley_terry(
56
+ comparisons: pd.DataFrame,
57
+ ties: str = "split",
58
+ reference: Optional[str] = None,
59
+ n_boot: int = 1000,
60
+ confidence: float = 0.95,
61
+ seed: Optional[int] = None,
62
+ ) -> BTResult:
63
+ """Fit Bradley-Terry ratings and report which pairs they actually order.
64
+
65
+ Parameters
66
+ ----------
67
+ comparisons
68
+ A frame with ``item_id``, ``model_a``, ``model_b`` and ``winner``.
69
+ One row per judgement. ``winner`` names one of the two models, or is
70
+ the string ``"tie"``, or is left empty to mean the same thing.
71
+ ``item_id`` labels the prompt the pair was judged on. Nothing in the
72
+ fit reads it and it is required anyway, so the frame says what was
73
+ compared as well as who won.
74
+ ties
75
+ ``"split"`` gives half a win to each side. ``"drop"`` discards the
76
+ tied comparisons. The choice is not cosmetic. Splitting pulls every
77
+ rating toward level, and on data where most comparisons end tied the
78
+ two policies produce ratings that are nowhere near each other, so
79
+ pick the one that matches what a tie meant to your judges.
80
+
81
+ Davidson's model is the principled third option. It fits an explicit
82
+ tie parameter instead of assuming a tie is half a win, which is the
83
+ assumption split makes and cannot check. It is not built here.
84
+ Passing ``"davidson"`` raises rather than silently falling back.
85
+ reference
86
+ The model pinned at zero. Defaults to the one with the most
87
+ comparisons, with ties broken alphabetically. The choice changes
88
+ which model reads zero and nothing else. Ratings, the win matrix and
89
+ the separable pairs are all unchanged by it.
90
+ n_boot
91
+ Bootstrap resamples for the intervals. Comparisons are resampled with
92
+ replacement and the model is refitted on each draw. Zero skips the
93
+ interval, and then no pair can be called separable, which is the
94
+ finding this function exists to produce.
95
+ confidence
96
+ Nominal coverage of the percentile interval, default 0.95.
97
+ seed
98
+ Seeds the bootstrap. Set it in anything you publish.
99
+
100
+ Returns
101
+ -------
102
+ BTResult
103
+
104
+ Notes
105
+ -----
106
+ Ratings are differences. One is pinned at zero to make the fit
107
+ identifiable and the pick is arbitrary, so the level of a single rating
108
+ says nothing and only gaps between models do.
109
+
110
+ The intervals are on each rating measured against the average of the
111
+ field, then shifted onto the reference. That keeps them, and so the
112
+ count of separable pairs, from turning on which model was pinned.
113
+
114
+ The bootstrap resamples comparisons, not items. Rows that share an
115
+ ``item_id`` are correlated, since they are judgements of the same prompt,
116
+ so on a design where one prompt carries many comparisons the interval
117
+ will run slightly narrow.
118
+
119
+ A resample can be undefined the same two ways the full data can, by
120
+ splitting the models into groups or by leaving one unbeaten. Those
121
+ resamples are dropped, and if too many of them are the interval is
122
+ refused rather than taken from the survivors.
123
+ """
124
+ if ties == _TIE_HOOK:
125
+ raise NotImplementedError(
126
+ "ties='davidson' is not built. Davidson's model treats a tie as "
127
+ "its own outcome with its own parameter, rather than assuming a "
128
+ "tie is worth half a win, and it is the more principled "
129
+ "treatment when ties are common. Use ties='split' or "
130
+ "ties='drop' and say in your write-up which you picked."
131
+ )
132
+ if ties not in _TIE_POLICIES:
133
+ raise ValueError(
134
+ f"ties must be one of {sorted(_TIE_POLICIES)}, got {ties!r}"
135
+ )
136
+ if not 0 < confidence < 1:
137
+ raise ValueError(f"confidence must be in (0, 1), got {confidence}")
138
+ if n_boot < 0:
139
+ raise ValueError(f"n_boot must not be negative, got {n_boot}")
140
+
141
+ data = _clean(comparisons)
142
+ models = sorted(set(data["model_a"]) | set(data["model_b"]))
143
+ index = {m: k for k, m in enumerate(models)}
144
+
145
+ used = _apply_tie_policy(data, ties)
146
+ if used.empty:
147
+ raise ValueError(
148
+ "every comparison was a tie and ties='drop' discarded all of "
149
+ "them, so there is nothing left to fit. Use ties='split' to give "
150
+ "each side half a win."
151
+ )
152
+
153
+ rows = _Rows.build(used, index)
154
+ credit = rows.credit_matrix()
155
+ counts = credit + credit.T
156
+
157
+ reference = _pick_reference(models, counts, reference)
158
+ ref = index[reference]
159
+
160
+ groups = _components(counts > 0, models)
161
+ connected = len(groups) == 1
162
+ undefeated, winless = ((), ())
163
+ if connected:
164
+ undefeated, winless = _separated_sets(credit > 0, models)
165
+
166
+ totals = counts.sum(axis=1)
167
+ shared = dict(
168
+ reference=reference,
169
+ ties=ties,
170
+ n_models=len(models),
171
+ n_comparisons=int(len(used)),
172
+ n_ties=int(_is_tie(data["winner"]).sum()),
173
+ n_dropped=int(len(data) - len(used)),
174
+ n_items=int(used["item_id"].nunique()),
175
+ n_pairs=len(models) * (len(models) - 1) // 2,
176
+ connected=connected,
177
+ comparable_groups=groups,
178
+ undefeated=undefeated,
179
+ winless=winless,
180
+ confidence=confidence,
181
+ n_boot=n_boot,
182
+ )
183
+
184
+ if not connected or undefeated:
185
+ return _refused(models, totals, shared)
186
+
187
+ centred = _fit(credit[None])[0]
188
+ rating = centred - centred[ref]
189
+
190
+ lo, hi, usable = _bootstrap(
191
+ rows, len(models), centred[ref], n_boot, confidence, seed
192
+ )
193
+ shared["n_boot_usable"] = usable
194
+
195
+ order = np.argsort(-rating, kind="stable")
196
+ names = [models[k] for k in order]
197
+ ratings = pd.DataFrame({
198
+ "model": names,
199
+ "rating": rating[order],
200
+ "ci_low": np.full(len(models), np.nan) if lo is None else lo[order],
201
+ "ci_high": np.full(len(models), np.nan) if hi is None else hi[order],
202
+ "n_comparisons": totals[order].round().astype(int),
203
+ })
204
+
205
+ return BTResult(
206
+ ratings=ratings,
207
+ win_matrix=_win_matrix(rating[order], names),
208
+ separable_pairs=_separable(rating, lo, hi, counts, models, index),
209
+ **shared,
210
+ )
211
+
212
+
213
+ def to_elo(
214
+ bt_result: BTResult, scale: float = 400, base: float = 1500
215
+ ) -> BTResult:
216
+ """Put Bradley-Terry ratings on the Elo scale, intervals included.
217
+
218
+ Elo is what a leaderboard audience expects to read, and it is the same
219
+ numbers stretched. A gap of ``scale`` points means the better model takes
220
+ ten decisive comparisons out of eleven, which is where 400 comes from.
221
+
222
+ Leaderboards publish Elo without intervals. Publishing them with
223
+ intervals is the reason this function exists, so the interval on every
224
+ rating is carried across the same affine change of units the rating is.
225
+ That leaves the win matrix and the separable pairs exactly as they were,
226
+ since stretching a scale cannot make two intervals stop overlapping.
227
+
228
+ ``base`` lands on the reference model. It is a display choice with no
229
+ content, the same way the reference is.
230
+ """
231
+ if not isinstance(bt_result, BTResult):
232
+ raise ValueError("to_elo takes a BTResult from bradley_terry")
233
+ if bt_result.is_elo:
234
+ raise ValueError(
235
+ "this result is already on the Elo scale. Convert the "
236
+ "Bradley-Terry result instead of converting twice."
237
+ )
238
+ if scale <= 0:
239
+ raise ValueError(f"scale must be positive, got {scale}")
240
+
241
+ factor = scale / math.log(10.0)
242
+
243
+ ratings = bt_result.ratings.copy()
244
+ for column in ("rating", "ci_low", "ci_high"):
245
+ ratings[column] = base + ratings[column] * factor
246
+
247
+ pairs = bt_result.separable_pairs.copy()
248
+ pairs["difference"] = pairs["difference"] * factor
249
+
250
+ return BTResult(
251
+ ratings=ratings,
252
+ win_matrix=bt_result.win_matrix.copy(),
253
+ separable_pairs=pairs,
254
+ reference=bt_result.reference,
255
+ ties=bt_result.ties,
256
+ n_models=bt_result.n_models,
257
+ n_comparisons=bt_result.n_comparisons,
258
+ n_ties=bt_result.n_ties,
259
+ n_dropped=bt_result.n_dropped,
260
+ n_items=bt_result.n_items,
261
+ n_pairs=bt_result.n_pairs,
262
+ connected=bt_result.connected,
263
+ comparable_groups=bt_result.comparable_groups,
264
+ undefeated=bt_result.undefeated,
265
+ winless=bt_result.winless,
266
+ confidence=bt_result.confidence,
267
+ n_boot=bt_result.n_boot,
268
+ n_boot_usable=bt_result.n_boot_usable,
269
+ elo_scale=float(scale),
270
+ elo_base=float(base),
271
+ )
272
+
273
+
274
+ # --------------------------------------------------------------------------
275
+ # Input handling
276
+ # --------------------------------------------------------------------------
277
+
278
+ def _is_tie(winner: pd.Series) -> pd.Series:
279
+ """True where the row records a tie.
280
+
281
+ A missing winner and the literal word both count. Case is ignored, since
282
+ the column is typed by hand as often as it is generated.
283
+ """
284
+ text = winner.astype("object")
285
+ missing = text.isna()
286
+ words = text.where(~missing, "").astype(str).str.strip().str.lower()
287
+ return missing | (words == _TIE)
288
+
289
+
290
+ def _clean(comparisons: pd.DataFrame) -> pd.DataFrame:
291
+ """Validate and copy. Every check here is a data-entry mistake."""
292
+ if not isinstance(comparisons, pd.DataFrame):
293
+ raise ValueError("comparisons must be a pandas DataFrame")
294
+
295
+ missing = [c for c in _COLUMNS if c not in comparisons.columns]
296
+ if missing:
297
+ raise ValueError(
298
+ f"comparisons is missing required column(s): {', '.join(missing)}. "
299
+ f"Expected {', '.join(_COLUMNS)}, where winner names one of the "
300
+ f"two models or is {_TIE!r}."
301
+ )
302
+
303
+ data = comparisons.loc[:, list(_COLUMNS)].copy()
304
+ if data.empty:
305
+ raise ValueError("comparisons must not be empty")
306
+
307
+ same = data["model_a"] == data["model_b"]
308
+ if same.any():
309
+ first = data[same].iloc[0]
310
+ raise ValueError(
311
+ f"comparisons pits a model against itself on item "
312
+ f"{first['item_id']!r}, where both sides are "
313
+ f"{first['model_a']!r}. There is no rating to learn from that."
314
+ )
315
+
316
+ models = set(data["model_a"]) | set(data["model_b"])
317
+ reserved = sorted(
318
+ m for m in models if str(m).strip().lower() == _TIE
319
+ )
320
+ if reserved:
321
+ raise ValueError(
322
+ f"a model is called {reserved[0]!r}, and {_TIE!r} is how this "
323
+ f"reads a tie in the winner column. Rename the model."
324
+ )
325
+
326
+ tie = _is_tie(data["winner"])
327
+ on_offer = (data["winner"] == data["model_a"]) | (
328
+ data["winner"] == data["model_b"]
329
+ )
330
+ bad = ~tie & ~on_offer
331
+ if bad.any():
332
+ first = data[bad].iloc[0]
333
+ raise ValueError(
334
+ f"comparisons has a winner that was not on offer: item "
335
+ f"{first['item_id']!r} names {first['winner']!r}, which is "
336
+ f"neither {first['model_a']!r} nor {first['model_b']!r}. Use "
337
+ f"{_TIE!r} or leave it empty to record a tie."
338
+ )
339
+ return data
340
+
341
+
342
+ def _apply_tie_policy(data: pd.DataFrame, ties: str) -> pd.DataFrame:
343
+ if ties == "drop":
344
+ return data[~_is_tie(data["winner"])]
345
+ return data
346
+
347
+
348
+ def _pick_reference(models, counts, reference) -> str:
349
+ """The model pinned at zero.
350
+
351
+ Defaults to the most compared one, which is the best measured, with ties
352
+ broken alphabetically so the same frame always gives the same answer.
353
+ The choice moves nothing except which model reads zero.
354
+ """
355
+ if reference is not None:
356
+ if reference not in models:
357
+ raise ValueError(
358
+ f"reference {reference!r} is not one of the models compared: "
359
+ f"{', '.join(map(str, models))}"
360
+ )
361
+ return reference
362
+ totals = counts.sum(axis=1)
363
+ return models[int(np.lexsort((models, -totals))[0])]
364
+
365
+
366
+ class _Rows:
367
+ """The comparisons as index arrays, ready to be resampled.
368
+
369
+ One entry per row of the frame, carrying which models met and how much
370
+ credit each took. A split tie is half a win each way, which is why the
371
+ credits are floats rather than a winner index.
372
+ """
373
+
374
+ def __init__(self, a, b, credit_a, credit_b, n_models):
375
+ self.a = a
376
+ self.b = b
377
+ self.credit_a = credit_a
378
+ self.credit_b = credit_b
379
+ self.n_models = n_models
380
+
381
+ @classmethod
382
+ def build(cls, used: pd.DataFrame, index) -> "_Rows":
383
+ a = used["model_a"].map(index).to_numpy(dtype=np.intp)
384
+ b = used["model_b"].map(index).to_numpy(dtype=np.intp)
385
+ tie = _is_tie(used["winner"]).to_numpy()
386
+ a_won = (used["winner"] == used["model_a"]).to_numpy()
387
+
388
+ credit_a = np.where(tie, 0.5, a_won.astype(float))
389
+ credit_b = np.where(tie, 0.5, 1.0 - a_won.astype(float))
390
+ return cls(a, b, credit_a, credit_b, len(index))
391
+
392
+ def __len__(self) -> int:
393
+ return len(self.a)
394
+
395
+ def credit_matrix(self) -> np.ndarray:
396
+ """The full-data credits, as a (models, models) array."""
397
+ return self.credits(np.arange(len(self))[None])[0]
398
+
399
+ def credits(self, draws: np.ndarray) -> np.ndarray:
400
+ """Credits for a block of resamples, in one pass.
401
+
402
+ ``draws`` is (blocks, rows) of row indices. Every resample in the
403
+ block is tallied by a single bincount over a flattened index, so the
404
+ cost of a block does not depend on how many resamples are in it.
405
+ """
406
+ blocks, _ = draws.shape
407
+ m = self.n_models
408
+ cell = m * m
409
+ offset = (np.arange(blocks) * cell)[:, None]
410
+
411
+ forward = offset + self.a[draws] * m + self.b[draws]
412
+ backward = offset + self.b[draws] * m + self.a[draws]
413
+
414
+ flat = np.bincount(
415
+ forward.ravel(),
416
+ weights=self.credit_a[draws].ravel(),
417
+ minlength=blocks * cell,
418
+ )
419
+ flat += np.bincount(
420
+ backward.ravel(),
421
+ weights=self.credit_b[draws].ravel(),
422
+ minlength=blocks * cell,
423
+ )
424
+ return flat.reshape(blocks, m, m)
425
+
426
+
427
+ # --------------------------------------------------------------------------
428
+ # The fit
429
+ # --------------------------------------------------------------------------
430
+
431
+ def _fit(credit: np.ndarray) -> np.ndarray:
432
+ """Maximum likelihood ratings for a stack of credit matrices.
433
+
434
+ Newton's method on the mean-centred parameterisation. The likelihood is
435
+ concave and flat along the direction that adds a constant to every
436
+ rating, so the Hessian is singular by exactly that one direction. Adding
437
+ 1/m to every entry fills it in without touching the rest.
438
+
439
+ The two centring lines are defensive and nothing turns on them. The
440
+ gradient already sums to zero at every point, because a pair is counted
441
+ once in each direction and the two probabilities add to one, so the
442
+ projection and the recentring only stop rounding error accumulating
443
+ across steps. Deleting either one changes no answer this package
444
+ produces, which is to say no test can tell the difference and none
445
+ should try.
446
+
447
+ The loop is over Newton steps, not over resamples. Every resample in
448
+ ``credit`` takes each step at the same time.
449
+ """
450
+ blocks, m, _ = credit.shape
451
+ n = credit + np.transpose(credit, (0, 2, 1))
452
+ earned = credit.sum(axis=2)
453
+ r = np.zeros((blocks, m))
454
+
455
+ for _ in range(_MAX_NEWTON):
456
+ p = _sigmoid(r[:, :, None] - r[:, None, :])
457
+ _fill_diagonal(p, 0.0)
458
+
459
+ gradient = (n * p).sum(axis=2) - earned
460
+ gradient -= gradient.mean(axis=1, keepdims=True)
461
+ if np.max(np.abs(gradient)) < _NEWTON_TOL:
462
+ break
463
+
464
+ weight = n * p * (1.0 - p)
465
+ _fill_diagonal(weight, 0.0)
466
+ hessian = -weight
467
+ _fill_diagonal(hessian, weight.sum(axis=2))
468
+ hessian += 1.0 / m
469
+
470
+ r -= np.linalg.solve(hessian, gradient[:, :, None])[:, :, 0]
471
+ r -= r.mean(axis=1, keepdims=True)
472
+
473
+ return r
474
+
475
+
476
+ def _sigmoid(x: np.ndarray) -> np.ndarray:
477
+ return 1.0 / (1.0 + np.exp(-x))
478
+
479
+
480
+ def _fill_diagonal(stack: np.ndarray, value) -> None:
481
+ """Set the diagonal of every matrix in a (blocks, m, m) stack."""
482
+ np.einsum("bii->bi", stack)[...] = value
483
+
484
+
485
+ # --------------------------------------------------------------------------
486
+ # Whether a fit exists at all
487
+ # --------------------------------------------------------------------------
488
+
489
+ def _reachable(adjacency: np.ndarray) -> np.ndarray:
490
+ """Transitive closure of a stack of boolean adjacency matrices.
491
+
492
+ Repeated squaring, so the number of passes is the log of the model count
493
+ rather than the model count. Every matrix in the stack closes at once.
494
+ """
495
+ m = adjacency.shape[-1]
496
+ reach = adjacency | np.eye(m, dtype=bool)
497
+ for _ in range(max(1, int(np.ceil(np.log2(max(m, 2)))))):
498
+ reach = reach | (reach.astype(np.uint8) @ reach.astype(np.uint8) > 0)
499
+ return reach
500
+
501
+
502
+ def _components(played: np.ndarray, models) -> tuple:
503
+ """The groups of models joined by chains of comparisons.
504
+
505
+ One group means every model can be reached from every other, which is
506
+ what putting them on a single scale requires.
507
+ """
508
+ reach = _reachable(played[None])[0]
509
+ groups, seen = [], set()
510
+ for i, name in enumerate(models):
511
+ if name in seen:
512
+ continue
513
+ members = tuple(models[j] for j in np.flatnonzero(reach[i]))
514
+ seen.update(members)
515
+ groups.append(members)
516
+ return tuple(groups)
517
+
518
+
519
+ def _separated_sets(beat: np.ndarray, models):
520
+ """A set that never lost outside itself, and one that never won.
521
+
522
+ Ford's condition: the maximum exists and is unique when every model is
523
+ reachable from every other through a chain of wins. When it is not, some
524
+ group of models never lost to anything outside it, and its ratings run
525
+ off to infinity. Both ends are returned because they read as different
526
+ findings. One model that never lost and one that never won are the two
527
+ shapes this takes in practice, and they call for different sentences.
528
+ """
529
+ reach = _reachable(beat[None])[0]
530
+ strong = reach & reach.T
531
+ if strong.all():
532
+ return (), ()
533
+
534
+ def pick(mask_out):
535
+ """The first group with no arrow coming in, or going out."""
536
+ for i in range(len(models)):
537
+ group = np.flatnonzero(strong[i])
538
+ outside = np.setdiff1d(np.arange(len(models)), group)
539
+ if not mask_out[np.ix_(outside, group)].any():
540
+ return tuple(models[j] for j in group)
541
+ return ()
542
+
543
+ return pick(beat), pick(beat.T)
544
+
545
+
546
+ # --------------------------------------------------------------------------
547
+ # The bootstrap
548
+ # --------------------------------------------------------------------------
549
+
550
+ def _bootstrap(rows, n_models, ref_centred, n_boot, confidence, seed):
551
+ """Percentile intervals from refitting on resampled comparisons.
552
+
553
+ Every draw is fitted on the mean-centred scale, so the percentiles are
554
+ on each rating against the average of the field. Shifting by the
555
+ reference's own centred rating at the end puts them on the reported
556
+ scale without letting the choice of reference change their widths.
557
+ """
558
+ if n_boot == 0:
559
+ return None, None, 0
560
+
561
+ rng = np.random.default_rng(seed)
562
+ n_rows = len(rows)
563
+ block = max(1, int(_BLOCK_BUDGET // max(n_models * n_models, 1)))
564
+
565
+ kept = []
566
+ drawn = 0
567
+ while drawn < n_boot:
568
+ size = min(block, n_boot - drawn)
569
+ draws = rng.integers(0, n_rows, size=(size, n_rows))
570
+ credit = rows.credits(draws)
571
+
572
+ usable = _usable(credit)
573
+ if usable.any():
574
+ kept.append(_fit(credit[usable]))
575
+ drawn += size
576
+
577
+ n_usable = sum(len(part) for part in kept)
578
+ if n_usable < _MIN_USABLE_SHARE * n_boot or n_usable < 2:
579
+ return None, None, n_usable
580
+
581
+ draws = np.concatenate(kept, axis=0)
582
+ tail = (1 - confidence) / 2
583
+ lo = np.percentile(draws, 100 * tail, axis=0) - ref_centred
584
+ hi = np.percentile(draws, 100 * (1 - tail), axis=0) - ref_centred
585
+ return lo, hi, n_usable
586
+
587
+
588
+ def _usable(credit: np.ndarray) -> np.ndarray:
589
+ """Which resamples in a block have a maximum at all.
590
+
591
+ The same two conditions the full data has to meet. Every model reachable
592
+ from every other by having played, and every model reachable from every
593
+ other by having won.
594
+ """
595
+ beat = credit > 0
596
+ played = beat | np.transpose(beat, (0, 2, 1))
597
+ connected = _reachable(played).all(axis=(1, 2))
598
+ reach = _reachable(beat)
599
+ strong = (reach & np.transpose(reach, (0, 2, 1))).all(axis=(1, 2))
600
+ return connected & strong
601
+
602
+
603
+ # --------------------------------------------------------------------------
604
+ # Output tables
605
+ # --------------------------------------------------------------------------
606
+
607
+ def _win_matrix(rating: np.ndarray, names) -> pd.DataFrame:
608
+ """Modelled P(row beats column), in rating order.
609
+
610
+ The diagonal is NaN. A model's chance against itself is a half by
611
+ construction and means nothing, and printing 0.5 down the diagonal
612
+ invites a reader to treat it as a measurement.
613
+ """
614
+ matrix = _sigmoid(rating[:, None] - rating[None, :])
615
+ np.fill_diagonal(matrix, np.nan)
616
+ return pd.DataFrame(matrix, index=list(names), columns=list(names))
617
+
618
+
619
+ _PAIR_COLUMNS = [
620
+ "model_a", "model_b", "difference", "p_a_beats_b", "n_head_to_head"
621
+ ]
622
+
623
+
624
+ def _separable(rating, lo, hi, counts, models, index) -> pd.DataFrame:
625
+ """The pairs whose intervals do not overlap.
626
+
627
+ Conservative on purpose. Two intervals that overlap a little can still
628
+ describe a difference the data supports, so this undercounts rather than
629
+ overcounts what the leaderboard orders.
630
+ """
631
+ if lo is None:
632
+ return pd.DataFrame(columns=_PAIR_COLUMNS)
633
+
634
+ rows = []
635
+ for i in range(len(models)):
636
+ for j in range(i + 1, len(models)):
637
+ if not (hi[i] < lo[j] or hi[j] < lo[i]):
638
+ continue
639
+ top, bottom = (i, j) if rating[i] > rating[j] else (j, i)
640
+ gap = rating[top] - rating[bottom]
641
+ rows.append({
642
+ "model_a": models[top],
643
+ "model_b": models[bottom],
644
+ "difference": gap,
645
+ "p_a_beats_b": float(_sigmoid(gap)),
646
+ "n_head_to_head": int(round(counts[i, j])),
647
+ })
648
+
649
+ table = pd.DataFrame(rows, columns=_PAIR_COLUMNS)
650
+ return (
651
+ table.sort_values("difference", ascending=False, kind="mergesort")
652
+ .reset_index(drop=True)
653
+ )
654
+
655
+
656
+ def _refused(models, totals, shared) -> BTResult:
657
+ """A result with no ratings in it.
658
+
659
+ The models, their comparison counts and the reason are all still here.
660
+ What is missing is the ranking, because the data does not carry one.
661
+ """
662
+ order = np.argsort(-totals, kind="stable")
663
+ names = [models[k] for k in order]
664
+ nan = np.full(len(models), np.nan)
665
+ ratings = pd.DataFrame({
666
+ "model": names,
667
+ "rating": nan,
668
+ "ci_low": nan.copy(),
669
+ "ci_high": nan.copy(),
670
+ "n_comparisons": totals[order].round().astype(int),
671
+ })
672
+ empty = pd.DataFrame(
673
+ np.full((len(models), len(models)), np.nan),
674
+ index=names, columns=names,
675
+ )
676
+ return BTResult(
677
+ ratings=ratings,
678
+ win_matrix=empty,
679
+ separable_pairs=pd.DataFrame(columns=_PAIR_COLUMNS),
680
+ n_boot_usable=0,
681
+ **shared,
682
+ )