Fewer Human Evals, Better Model Rankings
How active arena evals find reliable model winners without grading every possible matchup.
Active arena evals rank model versions with far fewer human reviews by choosing the head-to-head comparison most likely to improve the leaderboard.
That matters because exhaustive arena grading gets expensive quickly. If you have n models and m benchmark tasks, the full grid asks a human to compare every model pair on every task:
1def brute_force_comparisons(num_models: int, num_tasks: int) -> int:2 return num_models * (num_models - 1) // 2 * num_tasks3 4 5brute_force_comparisons(10, 20) # 900Nine hundred human judgments might be useful for a final benchmark paper. It is a lot for day-to-day model iteration, where the real question is simpler: which model is clearly better, which one is worse, and which models are too close to separate without more evidence?
The goal is not to observe the whole comparison graph. The goal is to spend human attention where it buys the most information.
The product win is not grading everything. It is knowing which next comparison would change the leaderboard the most.
The Basic Arena Unit
Each arena judgment compares two model outputs on one task:
1comparison = {2 "model_a": "agent_042",3 "model_b": "agent_039",4 "task_id": "task_yc_founders",5 "grade": 3,6}The grade is on a -5 to +5 scale:
-5means model B is much better.0means the outputs are tied or too close to call.+5means model A is much better.
The nice thing about this scale is that it stores both direction and margin. A tiny preference should move ratings less than a clear win.
1def grade_to_target(grade: int) -> float:2 eps = 0.023 target = (grade + 5) / 104 return min(1 - eps, max(eps, target))This turns the human grade into a soft preference target. A blowout gets close to 0 or 1. A tie lands near 0.5.
What We Can Safely Assume
The useful assumptions are modest:
- Transitivity is helpful, but not perfect.
- We need approximate ordering, not fake precision.
- Some tasks separate models better than others.
- Big wins usually contain more signal than tiny wins.
- Weak models should stop consuming comparisons once the evidence is clear.
- Models that are too close should be grouped together instead of forced into arbitrary ranks.
Approximate transitivity is the main savings:
1# Evidence, not law:2# A beats B3# B beats C4# therefore A probably beats CThis should reduce work, but it should not erase uncertainty. A model can be strong on extraction, weak on code, and tied on writing. The system should remember that tasks are not interchangeable.
Score The Models
A simple Bradley-Terry model is enough for the first serious version. Each model has a latent strength score, and a pairwise comparison estimates the probability that one model beats another.
1from math import exp2 3 4def sigmoid(x: float) -> float:5 return 1 / (1 + exp(-x))6 7 8def expected_preference(beta_a: float, beta_b: float, task_discrimination: float) -> float:9 return sigmoid(task_discrimination * (beta_a - beta_b))Each model stores:
1model_state = {2 "beta": 0.0,3 "sigma": 1.0,4 "eval_count": 0,5}Each task stores:
1task_state = {2 "discrimination": 1.0,3 "noise": 1.0,4 "eval_count": 0,5}beta is the model's strength. sigma is uncertainty. Task discrimination measures whether a task actually separates models. A task where every model fails equally may be hard, but it is not useful for ranking.
The Math Behind The Scores
The friendly version is: every model gets a hidden strength, every task gets a discrimination weight, and every human grade is a noisy measurement of the gap between two models.
Let model have latent strength:
For model against model on task , define the latent gap:
Task has discrimination . The predicted preference for over is:
The human grade is mapped to:
The assumption is deliberately gentle:
So we are not assuming humans are perfect. We are assuming the grade is a noisy signal that points in the right direction on average:
We fit the model with weighted Bradley-Terry loss:
The comparison weight is higher when the task is discriminative and lower when the task is noisy:
where is the estimated noise for task .
Because only differences in matter, we center the scores after fitting:
That normalization does not change any predicted matchup. It just keeps the coordinate system tidy.
Why The Active Rule Is Good
The active arena picks comparisons by information gain. Here is the core reason close comparisons are valuable.
For one comparison, let be a vector with at model , at model , and everywhere else. Then:
The gradient of the log-likelihood is proportional to:
The Fisher information from this comparison is:
The scalar part is:
The term is maximized at . So the system learns most when it compares models whose outcome is uncertain.
Proof.
so is the maximum. That is the whole reason the algorithm prefers close, uncertain comparisons over obvious blowouts.
Update Scores Online
For interactive grading, update scores after every human judgment. This makes the leaderboard feel live without waiting for a full refit.
1from math import sqrt2 3 4def update_from_grade(5 beta: dict[str, float],6 sigma: dict[str, float],7 task_discrimination: dict[str, float],8 task_noise: dict[str, float],9 model_a: str,10 model_b: str,11 task_id: str,12 grade: int,13 learning_rate: float = 0.05,14) -> None:15 y = grade_to_target(grade)16 17 disc = task_discrimination[task_id]18 noise = max(task_noise[task_id], 0.25)19 20 p = expected_preference(beta[model_a], beta[model_b], disc)21 error = y - p22 23 task_weight = disc / noise24 step = learning_rate * task_weight * disc * error25 26 beta[model_a] += step27 beta[model_b] -= step28 29 info = task_weight * (disc ** 2) * p * (1 - p)30 sigma[model_a] = 1 / sqrt((1 / sigma[model_a] ** 2) + info)31 sigma[model_b] = 1 / sqrt((1 / sigma[model_b] ** 2) + info)The online update is fast. Periodically, refit from the full match history with a weighted Bradley-Terry loss so the live updates do not drift over time.
1def refit_global_scores(comparisons: list[dict]) -> None:2 """3 Fit beta by minimizing weighted soft-label Bradley-Terry loss.4 Use grade_to_target(grade) as the target.5 Weight each comparison by task discrimination and task noise.6 """7 raise NotImplementedErrorPick The Next Best Comparison
The active arena should not ask a random match. It should ask the match that is most likely to change what we believe.
A candidate is a triple:
1candidate = ("model_a", "model_b", "task_id")The value of that candidate is high when:
- the expected outcome is uncertain,
- both models still have high rating uncertainty,
- the task is reliable,
- the model pair has not already been over-sampled,
- the task has not already received enough judgments.
1def candidate_value(2 beta: dict[str, float],3 sigma: dict[str, float],4 task_discrimination: dict[str, float],5 task_noise: dict[str, float],6 eval_count: dict[str, int],7 task_eval_count: dict[str, int],8 seen_pair_task: dict[tuple[str, str, str], int],9 model_a: str,10 model_b: str,11 task_id: str,12) -> float:13 disc = task_discrimination[task_id]14 noise = max(task_noise[task_id], 0.25)15 16 p = expected_preference(beta[model_a], beta[model_b], disc)17 outcome_uncertainty = p * (1 - p)18 rating_uncertainty = sigma[model_a] ** 2 + sigma[model_b] ** 219 task_quality = disc / noise20 21 pair_key = tuple(sorted((model_a, model_b)) + [task_id])22 repeat_penalty = 1 / sqrt(1 + seen_pair_task.get(pair_key, 0))23 24 model_coverage = (25 1 / sqrt(1 + eval_count[model_a])26 + 1 / sqrt(1 + eval_count[model_b])27 )28 task_coverage = 1 / sqrt(1 + task_eval_count[task_id])29 30 return (31 outcome_uncertainty32 * rating_uncertainty33 * task_quality34 * repeat_penalty35 * (1 + 0.15 * model_coverage)36 * (1 + 0.15 * task_coverage)37 )The key term is:
1p * (1 - p)It is largest when p = 0.5. Close comparisons are informative. Obvious blowouts are usually not.
One-Step Optimality
The heuristic above is the version I would ship first. The more mathematical version keeps a covariance matrix over the model scores. Large covariance means we are still uncertain.
For a candidate comparison with vector , the information matrix update is:
where:
Using the Sherman-Morrison identity:
we get:
Now pick any model gap we care about:
For example, might be for model A and for model B. Its current variance is:
After adding candidate comparison , the variance reduction is:
So if we care about a set of tier-boundary gaps , the one-step best comparison is:
That is the proof-backed version of "ask the next most useful question." It is not claiming the greedy policy is globally perfect across every future batch. It is claiming something narrower and more useful: under the current uncertainty estimate, this comparison gives the largest immediate reduction in the uncertainty we care about.
In Python-shaped pseudocode:
1def variance_reduction(V, x, c, info):2 numerator = info * float(c.T @ V @ x) ** 23 denominator = 1 + info * float(x.T @ V @ x)4 return numerator / denominator5 6 7def information_gain(V, x, info, important_contrasts):8 return sum(9 variance_reduction(V, x, c, info)10 for c in important_contrasts11 )Rank By Tiers, Not Fake Precision
Exact rankings waste effort near ties. If two models are within a practical margin, group them together until the evidence says otherwise.
1PRACTICAL_MARGIN_ELO = 50Convert Bradley-Terry scores into an Elo-like display score:
1from math import log2 3 4def beta_to_elo(beta_value: float, base: float = 1000) -> float:5 scale = 400 / log(10)6 return base + scale * beta_valueThen build tiers from confidence intervals:
1def should_separate(elo_a: float, sigma_a: float, elo_b: float, sigma_b: float) -> bool:2 lower_a = elo_a - 1.96 * sigma_a3 upper_b = elo_b + 1.96 * sigma_b4 return lower_a > upper_b + PRACTICAL_MARGIN_ELOThis gives the leaderboard a more honest shape:
1tiers = [2 ["agent_042", "agent_041"],3 ["agent_039", "agent_038", "agent_036"],4 ["agent_030"],5]If two models are indistinguishable in practice, the product should say that. It is better than pretending rank 3 and rank 4 are meaningfully different because one has two more Elo points.
A Conservative Tier Guarantee
Here is the guarantee I would want the product to stand behind.
Let be the fitted score for model , and let be its confidence radius. Assume the intervals are simultaneous, meaning:
Define:
Only put model in a strictly higher tier than model when:
where is the practical margin.
Theorem. With probability at least , every separated tier boundary is correct up to margin .
Proof. On the event that all confidence intervals contain their true values, and .
If the algorithm separates above , then:
Combining the inequalities:
Therefore:
So the separated tier boundary is real up to the practical margin. Since the simultaneous intervals hold with probability at least , the guarantee holds with probability at least .
This is conservative in the right way. It may keep two truly different models in the same tier if there is not enough evidence. But it will not confidently invent a separation that the intervals do not support.
Update Task Quality
Tasks should also be evaluated. After each comparison, update task noise and discrimination with conservative moving averages:
1def ema(old: float, new: float, alpha: float = 0.05) -> float:2 return (1 - alpha) * old + alpha * new3 4 5def update_task_stats(6 task_discrimination: dict[str, float],7 task_noise: dict[str, float],8 task_residual_ema: dict[str, float],9 task_variance_ema: dict[str, float],10 task_id: str,11 observed_target: float,12 predicted_target: float,13) -> None:14 residual = abs(observed_target - predicted_target)15 16 task_residual_ema[task_id] = ema(task_residual_ema[task_id], residual)17 task_variance_ema[task_id] = ema(task_variance_ema[task_id], residual ** 2)18 task_noise[task_id] = sqrt(task_variance_ema[task_id] + 1e-6)19 20 observed_signal = abs(observed_target - 0.5) * 221 reliability = 1 / (1 + task_noise[task_id])22 23 task_discrimination[task_id] = min(24 3.0,25 max(0.25, ema(task_discrimination[task_id], observed_signal * reliability)),26 )This prevents one dramatic comparison from making a task look permanently magical.
Batch Without Tunnel Vision
When asking for a batch of human grades, do not let one uncertain pair consume the entire batch. Sort candidates by value, then enforce simple diversity limits across models and tasks.
1from collections import Counter2from itertools import combinations3 4 5def suggest_batch(models: list[str], tasks: list[str], batch_size: int, state: dict):6 candidates = []7 8 for model_a, model_b in combinations(models, 2):9 for task_id in tasks:10 value = candidate_value(11 beta=state["beta"],12 sigma=state["sigma"],13 task_discrimination=state["task_discrimination"],14 task_noise=state["task_noise"],15 eval_count=state["eval_count"],16 task_eval_count=state["task_eval_count"],17 seen_pair_task=state["seen_pair_task"],18 model_a=model_a,19 model_b=model_b,20 task_id=task_id,21 )22 candidates.append((value, model_a, model_b, task_id))23 24 candidates.sort(reverse=True)25 26 batch = []27 used_models = Counter()28 used_tasks = Counter()29 30 for value, model_a, model_b, task_id in candidates:31 if len(batch) == batch_size:32 break33 if used_models[model_a] >= 4 or used_models[model_b] >= 4:34 continue35 if used_tasks[task_id] >= 4:36 continue37 38 batch.append((model_a, model_b, task_id, value))39 used_models[model_a] += 140 used_models[model_b] += 141 used_tasks[task_id] += 142 43 return batchThis keeps the queue useful and varied.
When To Stop
Stop based on decision quality, not a fixed comparison count.
1def should_stop(remaining_budget: int, best_candidate_value: float, min_value: float) -> bool:2 if remaining_budget <= 0:3 return True4 if best_candidate_value < min_value:5 return True6 return FalseAnother practical rule is tier stability:
1def stop_when_stable(recent_tiers: list[list[list[str]]]) -> bool:2 if len(recent_tiers) < 3:3 return False4 return recent_tiers[-1] == recent_tiers[-2] == recent_tiers[-3]If the same tiers survive several batches, more comparisons may not change the product decision.
How Many Comparisons Can This Save?
For 10 models and 20 tasks, brute force is 900 comparisons. An active arena should often land closer to 100 to 300, depending on how close the models are.
The back-of-the-envelope reason is that distinguishing a gap gets quadratically harder as the gap gets smaller.
Suppose two models differ by Bradley-Terry gap:
For a task with discrimination , the observable logit gap is:
Near , each comparison has Fisher information on the order of:
After effective comparisons, the uncertainty of the gap shrinks like:
To distinguish a practical gap , we want:
Solving for :
If we want confidence across many possible model boundaries, add a log factor:
The shape matters more than the constants:
That is why exact rankings are so expensive. Separating models that are 10 Elo apart can require roughly more signal than separating models that are 50 Elo apart:
The biggest savings come from:
- skipping inferred relationships when confidence is already high,
- using every comparison to update global model strength,
- spending more judgments near uncertain tier boundaries,
- downweighting noisy tasks,
- using margin grades instead of binary wins,
- stopping once rankings are stable enough for the decision.
If the top models are almost tied, the system should either spend more comparisons or honestly keep them in the same tier.
The First Version To Ship
The first useful version does not need a full Bayesian optimizer. It needs a tight loop:
- Store pairwise comparison records with
-5..+5grades. - Update Elo-like scores after each human grade.
- Track per-model uncertainty.
- Track per-task noise and discrimination.
- Suggest high-value model-task pairs.
- Group close models into practical tiers.
- Periodically refit scores from the full match history.
The exhaustive arena asks, "Have we graded every possible comparison?"
The active arena asks, "Would the next comparison change what we believe?"
Active evals turn human review from a giant checklist into a focused measurement system.
That is the product difference.