配套教程:LLM 评测与 Benchmark · EN
LLM Evaluation & Benchmarking - minimal runnable implementation
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656
"""
LLM Evaluation & Benchmarking - minimal runnable implementation
================================================================
Pure-stdlib + numpy/scipy (Python 3.9+), runs in a few seconds on CPU.
Pairs with: docs/tutorials/llm_evaluation_benchmarking_tutorial.md (concept reference).
[D01] pass@k: high-variance direct estimate vs unbiased combinatorial
estimate (Chen et al.) vs biased plug-in estimate
[D02] bootstrap CI: paired per-item difference, degenerate constant array,
brute-force enumeration check, Wilson interval vs naive percentile
bootstrap at a boundary proportion, and the multi-judge-vote /
prompt-cluster independence trap
[D03] Bradley-Terry MLE: closed-form 2-player case, standard-error scaling
under count multiplication, cyclic (intransitive) data, complete
separation detection, disconnected-graph detection
[D04] position bias: equal-quality responses under a positional logit
shift, order-balancing recovers the marginal win rate, and
swap-inconsistency exposes what a deterministic "always pick first"
judge hides behind that balanced marginal
Run:
python3 llm_eval_metrics.py
"""
import itertools
import math
import sys
from math import comb, log
import numpy as np
from scipy import optimize
# ==========================================================================
# [D01] pass@k: three estimators, not two
# ==========================================================================
# Teaching note: "run exactly k samples, check if >=1 passes" is UNBIASED
# (its expectation is exactly the true pass@k) but high-variance (a single
# Bernoulli draw per problem). The common BIASED estimator is the plug-in
# form 1-(1-c/n)^k, which substitutes empirical accuracy into the population
# formula. The Chen et al. combinatorial estimator is unbiased AND lower
# variance because it averages over all C(n,k) size-k subsets of n>=k
# independently drawn samples instead of using only one subset.
def _validate_pass_at_k_inputs(n, c, k):
"""Shared validation for the n/c/k-based pass@k estimators below. n is
the number of Monte Carlo samples actually generated, c is how many of
them passed, and k is the estimand's allowed-attempts budget -- silently
accepting out-of-range values would otherwise produce nonsense statistics
(a negative pass@k for c<0, a silently-wrong 1.0 for c>n, or a
ZeroDivisionError deep inside pass_at_k_plugin for n=0)."""
for name, val in (("n", n), ("c", c), ("k", k)):
if isinstance(val, bool) or not isinstance(val, (int, np.integer)):
raise ValueError(f"{name} must be an integer, got {val!r}")
if n < 1:
raise ValueError(f"n must be >= 1, got n={n}")
if k < 1:
raise ValueError(f"k must be >= 1, got k={k}")
if not (0 <= c <= n):
raise ValueError(f"c must satisfy 0 <= c <= n, got c={c}, n={n}")
def pass_at_k_true(p, k):
"""True pass@k for a FIXED per-problem success probability p under a
fixed sampling policy (temperature/top-p/max tokens all fixed)."""
if not (isinstance(k, (int, np.integer)) and not isinstance(k, bool) and k >= 1):
raise ValueError(f"k must be an integer >= 1, got {k!r}")
if not (0.0 <= p <= 1.0):
raise ValueError(f"p must satisfy 0 <= p <= 1, got p={p}")
return 1 - (1 - p) ** k
def pass_at_k_unbiased(n, c, k):
"""Chen et al. unbiased, lower-variance estimator from n>=k samples with
c correct. Convention: if fewer than k samples are wrong (n-c < k), the
estimate is 1 (every size-k subset necessarily contains a correct one)."""
_validate_pass_at_k_inputs(n, c, k)
if k > n:
raise ValueError(f"k={k} must be <= n={n}")
if n - c < k:
return 1.0
return 1 - comb(n - c, k) / comb(n, k)
def pass_at_k_plugin(n, c, k):
"""Common BIASED plug-in estimator: substitute p_hat=c/n into the
population formula. Biased DOWNWARD for k>=2 because 1-(1-p)^k is
concave in p (Jensen's inequality applied to the random variable c/n)."""
_validate_pass_at_k_inputs(n, c, k)
return 1 - (1 - c / n) ** k
def binomial_pmf(n, p):
return {c: comb(n, c) * p ** c * (1 - p) ** (n - c) for c in range(n + 1)}
# ==========================================================================
# [D02] bootstrap CI: paired design, degeneracy, and cluster dependence
# ==========================================================================
def percentile_ci(values, alpha=0.05):
lo = float(np.percentile(values, 100 * alpha / 2))
hi = float(np.percentile(values, 100 * (1 - alpha / 2)))
return lo, hi
def wilson_ci(x, n, z=1.96):
"""Wilson score interval for a binomial proportion x/n -- does not
degenerate to a zero-width interval at x=n like a naive percentile
bootstrap of an all-successes sample does."""
phat = x / n
denom = 1 + z ** 2 / n
center = (phat + z ** 2 / (2 * n)) / denom
margin = (z / denom) * math.sqrt(phat * (1 - phat) / n + z ** 2 / (4 * n ** 2))
return center - margin, center + margin
# ==========================================================================
# [D03] Bradley-Terry MLE: identifiability, separation, non-transitivity
# ==========================================================================
# comparisons: dict {(i, j): (wins_i_over_j, wins_j_over_i)} for i < j.
# Player 0's theta is fixed at 0 during optimization (removes the additive
# non-identifiability); the caller may re-center to sum-zero afterward.
def _validate_comparisons(comparisons, n_players):
"""Validate a comparisons dict before any BT computation. Keys must be
(i, j) integer pairs with i < j and both indices in range(n_players)
(rejects self-comparisons and out-of-range player ids); counts must be
finite, non-negative, and sum to a positive total (a (0, 0) entry
carries no information and is almost certainly a bug, so it is
rejected rather than silently treated as "not an edge")."""
if not (isinstance(n_players, (int, np.integer)) and n_players >= 2):
raise ValueError(f"n_players must be an integer >= 2, got {n_players!r}")
for (i, j), (wij, wji) in comparisons.items():
if isinstance(i, bool) or isinstance(j, bool) or not (
isinstance(i, (int, np.integer)) and isinstance(j, (int, np.integer))):
raise ValueError(f"player indices must be integers, got key {(i, j)!r}")
if not (0 <= i < n_players and 0 <= j < n_players):
raise ValueError(
f"player indices {(i, j)} out of range for n_players={n_players}")
if i == j:
raise ValueError(f"self-comparison is not allowed: key {(i, j)}")
if i >= j:
raise ValueError(f"comparisons keys must satisfy i < j, got {(i, j)}")
if not (np.isfinite(wij) and np.isfinite(wji)):
raise ValueError(f"non-finite comparison count at {(i, j)}: {(wij, wji)}")
if wij < 0 or wji < 0:
raise ValueError(f"negative comparison count at {(i, j)}: {(wij, wji)}")
if wij + wji <= 0:
raise ValueError(
f"comparison {(i, j)} has zero total games (0, 0), which "
"carries no information; remove it from the input")
def _is_connected(comparisons, n_players):
"""Undirected weak-connectivity check. Assumes comparisons has already
been validated (see _validate_comparisons), so every entry has
wij + wji > 0 and is a genuine edge."""
adj = {i: set() for i in range(n_players)}
for (i, j), (wij, wji) in comparisons.items():
if wij + wji > 0:
adj[i].add(j)
adj[j].add(i)
seen, stack = {0}, [0]
while stack:
u = stack.pop()
for v in adj[u]:
if v not in seen:
seen.add(v)
stack.append(v)
return len(seen) == n_players
def _reachable(start, adj, n_players):
seen, stack = {start}, [start]
while stack:
u = stack.pop()
for v in adj.get(u, ()):
if v not in seen:
seen.add(v)
stack.append(v)
return seen
def detect_complete_separation(comparisons, n_players):
"""Full strong-connectivity check on the directed winner-to-loser graph
(edge i -> j iff player i has at least one recorded win over player j).
Under the standard unregularized Bradley-Terry model, a finite MLE
exists (unique up to the usual additive shift) if and only if this
directed graph is STRONGLY CONNECTED: every non-trivial partition of
the players must have at least one win crossing in each direction. When
it is not strongly connected, the negative log-likelihood's infimum is
not attained at any finite parameter vector -- the optimal parameters
diverge to +/-infinity along the direction separating the strongly
connected components (the supremum of the likelihood is finite, but no
finite parameter vector attains it).
For n_players == 2 this collapses to the familiar "one player never
lost a single comparison" rule, but that pairwise rule does NOT
generalize to n >= 3: e.g. A beats B 10-0 while A:C and B:C are both
5-5 is NOT complete separation (C's split record against both A and B
makes the whole graph strongly connected, hence a finite MLE exists),
even though the isolated A-vs-B pair looks exactly like the classic
separation pattern.
Returns a list of the strongly connected components (each a sorted
list of player indices), only when there is more than one component
(i.e. separation is present); returns an empty list when the graph is
strongly connected (no separation). Assumes comparisons has already
been validated (see _validate_comparisons).
"""
out_adj = {i: set() for i in range(n_players)}
for (i, j), (wij, wji) in comparisons.items():
if wij > 0:
out_adj[i].add(j)
if wji > 0:
out_adj[j].add(i)
in_adj = {i: set() for i in range(n_players)}
for u, vs in out_adj.items():
for v in vs:
in_adj[v].add(u)
components, assigned = [], set()
for u in range(n_players):
if u in assigned:
continue
scc = _reachable(u, out_adj, n_players) & _reachable(u, in_adj, n_players)
components.append(sorted(scc))
assigned |= scc
return components if len(components) > 1 else []
def bt_negloglik(theta_free, comparisons, n_players, ridge=0.0):
theta = np.concatenate(([0.0], theta_free))
nll = 0.0
for (i, j), (wij, wji) in comparisons.items():
d = theta[i] - theta[j]
# log sigma(d) = -log(1+exp(-d)) = -logaddexp(0, -d)
# log sigma(-d) = -log(1+exp(d)) = -logaddexp(0, d)
nll -= wij * (-np.logaddexp(0, -d))
nll -= wji * (-np.logaddexp(0, d))
if ridge > 0:
# Penalize the SUM-ZERO-CENTERED theta, not theta_free directly.
# theta_free implicitly pins player 0's parameter at 0 purely as an
# optimization device to remove the additive non-identifiability;
# penalizing theta_free alone (as if player 0 were a fixed
# reference point) makes the ridge ESTIMATE depend on which player
# happens to be numbered 0. Centering first makes the penalty (and
# the resulting estimate) invariant to that arbitrary choice.
theta_centered = theta - theta.mean()
nll += ridge * np.sum(theta_centered ** 2)
return nll
def bt_mle(comparisons, n_players, ridge=0.0, allow_separation=False, x0=None):
"""Batch Bradley-Terry MLE with theta_0 fixed at 0 during optimization,
then re-centered to sum-zero. Raises on invalid input (see
_validate_comparisons), on a disconnected comparison graph (relative
strength across components is not identifiable), and, unless ridge>0
or the caller explicitly opts in, on detected complete separation (no
finite unregularized MLE exists). Also raises if the optimizer fails to
converge to a finite solution, rather than silently returning whatever
scipy last tried.
x0: optional custom starting point for theta_free (shape
(n_players-1,)); defaults to all-zeros. Mainly useful for tests where
starting exactly at the true solution would make the optimizer's
correctness untestable.
"""
_validate_comparisons(comparisons, n_players)
if not (np.isfinite(ridge) and ridge >= 0):
raise ValueError(f"ridge must be a finite non-negative real number, got {ridge!r}")
if not _is_connected(comparisons, n_players):
raise ValueError(
"comparison graph is disconnected: relative strength across "
"components is not identifiable from this data alone")
flagged = detect_complete_separation(comparisons, n_players)
if flagged and not (ridge > 0) and not allow_separation:
raise ValueError(
f"complete separation detected: the winner-to-loser graph "
f"splits into strongly connected components {flagged} with no "
"win flowing back into an earlier component from a later one; "
"no finite unregularized MLE exists; pass ridge>0 or "
"allow_separation=True")
if x0 is None:
x0 = np.zeros(n_players - 1)
else:
x0 = np.asarray(x0, dtype=float)
if x0.shape != (n_players - 1,):
raise ValueError(
f"x0 must have shape ({n_players - 1},), got {x0.shape}")
res = optimize.minimize(
bt_negloglik, x0, args=(comparisons, n_players, ridge), method="BFGS")
theta = np.concatenate(([0.0], res.x))
theta = theta - theta.mean()
if not res.success or not np.isfinite(res.fun) or not np.all(np.isfinite(theta)):
raise RuntimeError(
"BT MLE optimization did not converge to a finite solution: "
f"success={res.success}, fun={res.fun}, message={res.message!r}")
return theta, res
def bt_pairwise_se(n_ij, p_ij):
"""Standard error of theta_i - theta_j for a pair observed in ISOLATION
(no other comparisons touching i or j): the log-likelihood restricted to
d=theta_i-theta_j is exactly a binomial-logistic model with n_ij trials,
Fisher information n_ij * p * (1-p)."""
info = n_ij * p_ij * (1 - p_ij)
return 1.0 / math.sqrt(info)
# ==========================================================================
# [D04] position bias: positional logit shift + swap consistency
# ==========================================================================
def sigmoid(x):
return 1.0 / (1.0 + np.exp(-x))
POSITION_BIAS_B = math.log(7 / 3) # calibrated so P(A wins | shown first) = 0.7
def p_a_wins(delta, position_a):
"""position_a: +1 if A is shown first, -1 if A is shown second.
delta is the TRUE quality gap (0 for equal-quality responses)."""
return sigmoid(delta + POSITION_BIAS_B * position_a)
def order_balanced_marginal_win_rate(judge_fn, x, y):
"""Directly compute, for the ACTUAL judge_fn (not a separate smooth
logistic model of it), the marginal win rate for x when the pair (x, y)
is order-balanced: judged once with x shown first and once with x
shown second, each counted with weight 0.5. This is what "order
balancing" means operationally, and it is what hides a purely
positional judge like always_pick_first behind an innocuous-looking
marginal of 0.5, even though the judge never once looked at content."""
win_when_first = judge_fn(x, y) == x # x shown first
win_when_second = judge_fn(y, x) == x # x shown second
return 0.5 * win_when_first + 0.5 * win_when_second
def swap_inconsistency_rate(judge_fn, pairs):
"""For each (X, Y) pair, ask the judge in original order (X first, Y
second) and swapped order (Y first, X second); report the fraction of
pairs where the WINNER IDENTITY flips between the two orders. A judge
that only tracks true quality gives rate 0; a position-driven judge can
give rate 1 even though its balanced marginal win rate looks like 0.5."""
flips = 0
for x, y in pairs:
winner_original = judge_fn(x, y) # x shown first
winner_swapped = judge_fn(y, x) # y shown first
if winner_original != winner_swapped:
flips += 1
return flips / len(pairs)
def main():
if not __debug__:
raise SystemExit("run without -O: the checks below are assert-based")
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
# ---------------------------------------------------------------- D01
p, k, n = 0.2, 2, 5
true_pk = pass_at_k_true(p, k)
assert abs(true_pk - 0.36) < 1e-12 # 1 - 0.8^2
pmf = binomial_pmf(n, p)
e_unbiased = sum(pass_at_k_unbiased(n, c, k) * w for c, w in pmf.items())
e_plugin = sum(pass_at_k_plugin(n, c, k) * w for c, w in pmf.items())
assert abs(e_unbiased - 0.36) < 1e-9 # exactly unbiased
assert abs(e_plugin - 0.328) < 1e-9 # biased downward
bias_pred = -p * (1 - p) / n # closed form for k=2
assert abs((e_plugin - true_pk) - bias_pred) < 1e-9
# "run exactly k times, record >=1 success": unbiased single Bernoulli obs
var_naive = true_pk * (1 - true_pk)
assert abs(var_naive - 0.2304) < 1e-9
# Chen et al.'s claim is not just "unbiased" but "unbiased AND lower
# variance than the naive k-sample estimator, for the SAME n". Actually
# compute Var[unbiased estimator] over repeated draws of n samples
# (c ~ Binomial(n, p)) via the same pmf used for e_unbiased above, and
# check it against var_naive rather than only asserting the mean matches.
e_unbiased_sq = sum(pass_at_k_unbiased(n, c, k) ** 2 * w for c, w in pmf.items())
var_unbiased = e_unbiased_sq - e_unbiased ** 2
assert var_unbiased < var_naive # Rao-Blackwell: strictly lower here
assert abs(var_unbiased - 0.08448) < 1e-4
# hand example: n=10, c=3, k=5 -> 1 - C(7,5)/C(10,5) = 11/12
assert abs(pass_at_k_unbiased(10, 3, 5) - 11 / 12) < 1e-12
# edge cases
assert abs(pass_at_k_unbiased(6, 4, 1) - 4 / 6) < 1e-12 # k=1 -> c/n
assert pass_at_k_unbiased(6, 0, 3) == 0.0 # c=0 -> 0
assert pass_at_k_unbiased(6, 5, 3) == 1.0 # n-c=1<k -> 1
try:
pass_at_k_unbiased(3, 1, 5)
raise AssertionError("expected ValueError for k>n")
except ValueError:
pass
# illegal n/c/k must raise, not silently produce a negative pass@k, a
# falsely-perfect 1.0 for c>n, or (pre-fix) a ZeroDivisionError for n=0
for bad_call in (
lambda: pass_at_k_unbiased(5, -1, 2), # negative c
lambda: pass_at_k_unbiased(5, 6, 2), # c > n
lambda: pass_at_k_unbiased(0, 0, 1), # n < 1
lambda: pass_at_k_unbiased(5, 2, 0), # k < 1
lambda: pass_at_k_unbiased(5.5, 2, 2), # non-integer n
lambda: pass_at_k_plugin(0, 0, 2), # n=0 -> used to ZeroDivisionError
lambda: pass_at_k_plugin(5, -1, 2), # negative c
lambda: pass_at_k_plugin(5, 6, 2), # c > n
lambda: pass_at_k_true(0.2, 0), # k < 1
lambda: pass_at_k_true(1.5, 2), # p out of [0, 1]
):
try:
bad_call()
raise AssertionError(f"expected ValueError for invalid pass@k input {bad_call}")
except ValueError:
pass
# multi-problem averaging: per-problem first, THEN average -- not pooled
per_problem_avg = (pass_at_k_unbiased(5, 1, 2) + pass_at_k_unbiased(5, 4, 2)) / 2
pooled_wrong = pass_at_k_unbiased(10, 5, 2) # c and n summed first
assert abs(per_problem_avg - 0.7) < 1e-12
assert abs(pooled_wrong - 0.7778) < 1e-3
assert abs(per_problem_avg - pooled_wrong) > 0.05 # nonlinear transform bites
print(f"[D01] pass@k(p=0.2,k=2)=0.360 true; E[unbiased]={e_unbiased:.3f} "
f"(exact) vs E[plugin]={e_plugin:.3f} (biased {e_plugin - true_pk:+.3f}); "
f"Var[unbiased]={var_unbiased:.5f} < Var[naive k-sample]={var_naive:.4f}; "
f"n=10,c=3,k=5 -> {pass_at_k_unbiased(10, 3, 5):.4f}=11/12; "
f"per-problem avg={per_problem_avg:.3f} != pooled={pooled_wrong:.4f} PASS")
# ---------------------------------------------------------------- D02
rng = np.random.default_rng(0)
n_items = 30
A = rng.normal(0.70, 0.10, n_items)
B = rng.normal(0.65, 0.10, n_items)
d = A - B
n_boot = 4000
idx_matrix = rng.integers(0, n_items, size=(n_boot, n_items))
boot_paired = np.array([A[idx].mean() - B[idx].mean() for idx in idx_matrix])
boot_direct = np.array([d[idx].mean() for idx in idx_matrix])
# algebraic identity (same bootstrap indices -> identical means): check
# against an absolute tolerance ONLY (rtol=0), since the default rtol
# would otherwise dominate atol here and mask a real discrepancy
assert np.allclose(boot_paired, boot_direct, rtol=0, atol=1e-9)
# degenerate constant array
const_data = np.full(20, 0.07)
idx_matrix2 = rng.integers(0, 20, size=(2000, 20))
const_means = np.array([const_data[idx].mean() for idx in idx_matrix2])
lo_c, hi_c = percentile_ci(const_means)
assert lo_c == hi_c
assert abs(lo_c - 0.07) < 1e-9
# brute-force enumeration: mean of ALL 3^3=27 bootstrap-sample means
# equals the original sample mean exactly (bootstrap-mean identity)
d_small = np.array([0.2, -0.1, 0.4])
all_means = [d_small[list(combo)].mean()
for combo in itertools.product(range(3), repeat=3)]
assert len(all_means) == 27
assert abs(sum(all_means) / len(all_means) - d_small.mean()) < 1e-12
assert abs(d_small.mean() - 1 / 6) < 1e-12
# all-correct edge case: naive percentile bootstrap collapses to a point,
# Wilson score interval does not
all_correct = np.ones(20)
idx_matrix3 = rng.integers(0, 20, size=(2000, 20))
ac_means = np.array([all_correct[idx].mean() for idx in idx_matrix3])
lo_ac, hi_ac = percentile_ci(ac_means)
assert lo_ac == hi_ac == 1.0
lo_w, hi_w = wilson_ci(20, 20)
assert abs(lo_w - 0.839) < 0.005
# multi-judge-vote / prompt-cluster independence trap: 4 prompts, each
# duplicated by 5 (perfectly agreeing) judge votes. Treating all 20 votes
# as independent draws understates uncertainty; resampling by PROMPT
# cluster (then keeping each prompt's 5 duplicate votes together) is honest.
prompt_values = np.array([0.9, 0.5, 0.5, 0.1])
votes_flat = np.repeat(prompt_values, 5) # 20 "votes"
idx_naive = rng.integers(0, 20, size=(5000, 20))
naive_means = np.array([votes_flat[idx].mean() for idx in idx_naive])
lo_n, hi_n = percentile_ci(naive_means)
idx_cluster = rng.integers(0, 4, size=(5000, 4)) # resample PROMPTS
cluster_means = np.array([prompt_values[idx].mean() for idx in idx_cluster])
lo_cl, hi_cl = percentile_ci(cluster_means)
width_naive, width_cluster = hi_n - lo_n, hi_cl - lo_cl
assert width_cluster > 1.5 * width_naive # cluster CI much wider
print(f"[D02] paired==direct bootstrap: "
f"{np.allclose(boot_paired, boot_direct, rtol=0, atol=1e-9)}; "
f"constant-array CI=[{lo_c:.4f},{hi_c:.4f}]; 27-sample enum mean="
f"{sum(all_means)/len(all_means):.4f}=1/6; all-correct naive=[1.0,1.0] vs "
f"Wilson=[{lo_w:.3f},{hi_w:.3f}]; cluster CI width {width_cluster:.3f} > "
f"naive width {width_naive:.3f} PASS")
# ---------------------------------------------------------------- D03
# closed-form 2-player case: A beats B 75/25 -> theta_A-theta_B = log(3)
comparisons_2p = {(0, 1): (75, 25)}
theta_2p, res_2p = bt_mle(comparisons_2p, n_players=2)
assert res_2p.success
assert abs((theta_2p[0] - theta_2p[1]) - log(3)) < 1e-6
assert abs(theta_2p[0] - log(3) / 2) < 1e-6
assert abs(theta_2p[1] + log(3) / 2) < 1e-6
pred_win = sigmoid(theta_2p[0] - theta_2p[1])
assert abs(pred_win - 0.75) < 1e-6
# scaling: multiply win/loss counts by 10 -> same point estimate, smaller SE
se_orig = bt_pairwise_se(100, 0.75)
se_scaled = bt_pairwise_se(1000, 0.75)
comparisons_2p_scaled = {(0, 1): (750, 250)}
theta_2p_scaled, res_2p_scaled = bt_mle(comparisons_2p_scaled, n_players=2)
assert res_2p_scaled.success
assert abs((theta_2p_scaled[0] - theta_2p_scaled[1]) - log(3)) < 1e-6
assert se_scaled < se_orig
assert abs(se_scaled / se_orig - 1 / math.sqrt(10)) < 1e-6
# cyclic (intransitive) data: A>B>C>A each 2:1 (key (0,2)=(1,2) encodes
# "C beats A 2:1" under the i<j key convention) -> scalar model forces
# equal strengths, predicting ~0.5 for every pair despite the 2:1 cycles.
# Start the optimizer AWAY from the all-zeros initial guess: zeros
# happen to be exactly the symmetric solution here, so starting there
# would let a broken objective/gradient pass by sheer luck of never
# having to move. res_cyc.success is also checked explicitly rather
# than only checking theta_cyc for finiteness.
comparisons_cyc = {(0, 1): (2, 1), (1, 2): (2, 1), (0, 2): (1, 2)}
theta_cyc, res_cyc = bt_mle(comparisons_cyc, n_players=3, x0=np.array([1.0, -1.0]))
assert res_cyc.success
assert max(theta_cyc) - min(theta_cyc) < 1e-4
for (i, j) in comparisons_cyc:
assert abs(sigmoid(theta_cyc[i] - theta_cyc[j]) - 0.5) < 1e-3
# complete separation (2-player): A beats B 10-0, no losses -> flagged,
# no finite unregularized MLE; with ridge>0 the estimate is finite AND
# the optimizer must actually report success (not just finite numbers).
comparisons_sep = {(0, 1): (10, 0)}
assert detect_complete_separation(comparisons_sep, n_players=2) == [[0], [1]]
try:
bt_mle(comparisons_sep, n_players=2)
raise AssertionError("expected ValueError for complete separation")
except ValueError:
pass
theta_reg, res_reg = bt_mle(comparisons_sep, n_players=2, ridge=1.0)
assert res_reg.success
assert np.all(np.isfinite(theta_reg))
# generalization check (the actual P1 bug): A:B=10:0 in isolation looks
# exactly like the separation case above, but with A:C=5:5 and B:C=5:5
# added, the winner-to-loser graph IS strongly connected (C's split
# record against both A and B provides a path back), so a finite
# unregularized MLE exists and must NOT be flagged or rejected.
comparisons_not_sep = {(0, 1): (10, 0), (0, 2): (5, 5), (1, 2): (5, 5)}
assert detect_complete_separation(comparisons_not_sep, n_players=3) == []
theta_not_sep, res_not_sep = bt_mle(comparisons_not_sep, n_players=3)
assert res_not_sep.success
assert np.all(np.isfinite(theta_not_sep))
# a genuine multi-player separation that the naive "one pair only-wins"
# rule and the graph-based rule both catch: a strict win chain 0->1->2
# with no reverse wins anywhere splits into 3 singleton components.
comparisons_chain_sep = {(0, 1): (10, 0), (1, 2): (10, 0)}
assert detect_complete_separation(comparisons_chain_sep, n_players=3) == [[0], [1], [2]]
try:
bt_mle(comparisons_chain_sep, n_players=3)
raise AssertionError("expected ValueError for chain complete separation")
except ValueError:
pass
# disconnected graph: {0,1} and {2,3} never compared -> must raise
comparisons_disc = {(0, 1): (5, 5), (2, 3): (5, 5)}
try:
bt_mle(comparisons_disc, n_players=4)
raise AssertionError("expected ValueError for disconnected graph")
except ValueError:
pass
# invalid comparisons input must raise, not silently corrupt the graph
# (out-of-range id, self-comparison, negative count, zero-total count,
# i>=j key ordering, non-integer id) or bypass separation via bad ridge
for bad_call in (
lambda: bt_mle({(0, 5): (3, 2)}, n_players=3), # id out of range
lambda: bt_mle({(1, 1): (3, 2)}, n_players=3), # self-comparison
lambda: bt_mle({(0, 1): (-1, 2)}, n_players=2), # negative count
lambda: bt_mle({(0, 1): (0, 0)}, n_players=2), # zero-total count
lambda: bt_mle({(1, 0): (3, 2)}, n_players=2), # i >= j
lambda: bt_mle({(0.5, 1): (3, 2)}, n_players=2), # non-integer id
lambda: bt_mle(comparisons_2p, n_players=2, ridge=-1.0), # negative ridge
lambda: bt_mle(comparisons_2p, n_players=2, ridge=float("nan")), # NaN ridge
):
try:
bad_call()
raise AssertionError(f"expected ValueError for invalid BT input {bad_call}")
except ValueError:
pass
print(f"[D03] 2-player MLE: theta_A-theta_B={theta_2p[0]-theta_2p[1]:.6f}=log3, "
f"pred_win={pred_win:.3f}; SE shrinks {se_orig:.4f}->{se_scaled:.4f} "
f"(x10 counts, ratio {se_scaled/se_orig:.4f}=1/sqrt(10)); cyclic strengths "
f"span={max(theta_cyc)-min(theta_cyc):.2e} (all ~0.5 pairwise); "
f"2-player separation detected+regularized finite; 3-player A:B=10:0/"
f"A:C=5:5/B:C=5:5 correctly NOT flagged (strongly connected); chain "
f"separation 0->1->2 flagged; disconnected graph -> raises PASS")
# ---------------------------------------------------------------- D04
p_first = p_a_wins(delta=0.0, position_a=1)
p_second = p_a_wins(delta=0.0, position_a=-1)
assert abs(p_first - 0.7) < 1e-9
assert abs(p_second - 0.3) < 1e-9
marginal = 0.5 * p_first + 0.5 * p_second
assert abs(marginal - 0.5) < 1e-12
def always_pick_first(x, y):
return x # x is shown first
pairs = [("item1", "item2"), ("item3", "item4"), ("item5", "item6"),
("item7", "item8"), ("item9", "item10")]
rate = swap_inconsistency_rate(always_pick_first, pairs)
assert rate == 1.0
# the claim "always-pick-first's order-balanced marginal is 0.5" is
# about the deterministic judge itself, not about the separate smooth
# logistic model (p_a_wins) used above -- compute it directly for the
# actual judge_fn on a concrete pair.
marginal_first_direct = order_balanced_marginal_win_rate(
always_pick_first, "item1", "item2")
assert marginal_first_direct == 0.5
def quality_only_judge(x, y, quality):
return x if quality[x] >= quality[y] else y
# distinct qualities per item (never tied) so the judge below is a clean
# position-INVARIANT baseline: it always favors the higher-quality item
# regardless of which slot it is shown in
quality = {name: q for q, name in enumerate(
item for pair in pairs for item in pair)}
consistent_rate = swap_inconsistency_rate(
lambda x, y: quality_only_judge(x, y, quality), pairs)
assert consistent_rate == 0.0
print(f"[D04] position bias b=log(7/3)={POSITION_BIAS_B:.6f}: "
f"P(A wins|first)={p_first:.1f}, P(A wins|second)={p_second:.1f}, "
f"balanced marginal={marginal:.1f}; always-pick-first swap "
f"inconsistency={rate:.1f} vs quality-only judge={consistent_rate:.1f} PASS")
print("\nall llm_eval sanity checks passed ✓")
if __name__ == "__main__":
main()