Train and serve pairwise LLM judges (A/B/tie) with budget-aware multi-turn packing and position-bias correction
Project description
pairjudge
Train and serve pairwise LLM judges (A wins / B wins / tie) — with budget-aware multi-turn packing, position-bias correction, and pseudo-label distillation.
pairjudge is the generalized core of the 4th-place (gold medal) solution to Kaggle's LMSYS — Chatbot Arena Human Preference Predictions (1,849 teams), extracted into a small, tested library you can run on your own preference data with any Hugging Face backbone. The exact competition artifacts are preserved untouched in competition/, and a golden test pins the library's default behavior to the medal-winning code byte for byte.
Use it when you need a model that answers: given a prompt and two candidate responses, which one would a human prefer — or is it a tie? That model is the engine behind response reranking, A/B evaluation of fine-tunes, RLHF/RLAIF reward signals, and arena-style leaderboards.
Why not just an off-the-shelf reward model?
Three problems show up the moment you train a pairwise judge on real conversations, and they are exactly what this library packages:
1. Truncation silently destroys the comparison.
A judge input holds a multi-turn conversation plus two responses per turn. With naive left- or right-truncation, long inputs routinely lose response B (or the prompt) entirely — the judge then learns position artifacts instead of preferences. PairPacker packs rounds greedily and, when the budget runs out, truncates the final round proportionally (default 20% prompt / 40% response A / 40% response B), marks every cut with an explicit ellipsis, and drops rounds that can't be shown honestly. Guarantee: never exceeds max_length, and every retained round shows all three fields.
2. Pairwise judges have position bias.
Swap A and B and a naive judge changes its verdict on a measurable fraction of pairs. PairwiseJudge.predict_proba(swap_debias=True) scores each pair in both orders and averages in the original frame — order-invariant by construction. position_flip_rate() measures how biased your judge is before you decide to pay the 2x compute.
3. Human preference labels are scarce and noisy.
The medal recipe is a two-phase semi-supervised loop: train on human labels → pseudo-label a large unlabeled pool with full probability distributions → retrain with soft-label KL distillation (label_mode: soft). Ties are a first-class third category throughout — real human preference data is full of them, and scalar Bradley–Terry reward models (e.g. TRL's RewardTrainer, num_labels=1) cannot represent them.
Install
pip install -e . # core: packing + data loaders (no torch needed)
pip install -e .[judge] # + inference (torch, transformers)
pip install -e .[train] # + LoRA fine-tuning (peft, datasets, accelerate)
60 seconds
from pairjudge import PairPacker, PackerConfig, from_pairs
# 1. Pack pairwise conversations into a token budget — any HF tokenizer.
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
packer = PairPacker(tok, PackerConfig(max_length=2048))
packed = packer.pack(
prompts=["Explain quantum entanglement to a 10-year-old."],
responses_a=["Imagine two magic coins..."],
responses_b=["Quantum entanglement is a physical phenomenon..."],
)
packed.input_ids # <= 2048 tokens, prompt + BOTH responses guaranteed visible
packed.truncated # False — everything fit
# 2. Judge a pair with a trained model, position-bias-free.
from pairjudge import PairwiseJudge
judge = PairwiseJudge.from_pretrained("path/to/your/judge")
df = from_pairs(
prompts=["Explain quantum entanglement to a 10-year-old."],
responses_a=["Imagine two magic coins..."],
responses_b=["Quantum entanglement is a physical phenomenon..."],
)
judge.predict_proba(df, swap_debias=True) # [[p_a_wins, p_b_wins, p_tie]]
judge.position_flip_rate(df) # how order-sensitive is my judge?
Train your own judge
# Small judge on one consumer GPU (Qwen2.5-0.5B, ungated):
python -m pairjudge.training --cfg examples/configs/quickstart.yaml
# The competition setup (gemma-2-9b-it, 4x A100):
python -m pairjudge.training --cfg examples/configs/reproduce_competition.yaml
Input is either an Arena-format CSV (the Kaggle competition schema) or a parquet with canonical columns — prompt / response_a / response_b as per-round string lists plus one-hot (or soft) winner_* columns. pairjudge.data ships loaders for Arena CSVs and UltraFeedback-style chosen/rejected data, plus from_pairs() for plain Python lists.
The full two-phase distillation loop:
# Phase 1: train on human labels
python -m pairjudge.training --cfg phase1.yaml # label_mode: hard
# Pseudo-label an unlabeled pool with the phase-1 judge (soft labels)
python -m pairjudge.pseudo_label \
--model ./output/judge/merged \
--data pool.parquet --out pool_pl.parquet --swap-debias
# Phase 2: retrain from scratch on human + soft labels with KL loss
python -m pairjudge.training --cfg phase2.yaml # label_mode: soft
In the competition, this loop (88k human-labeled + 30k pseudo-labeled UltraFeedback conversations) was a decisive part of the gap between a good model and a gold-medal one.
Inference guardrails
Two degenerate cases are worth handling outside the model — on competition data this was worth a measurable amount of log-loss:
from pairjudge import empty_and_identical_masks
a_empty, b_empty, identical = empty_and_identical_masks(raw_df)
proba[a_empty] = [0.04, 0.88, 0.08] # empty response loses — but never bet 1.0
proba[b_empty] = [0.88, 0.04, 0.08] # labels are noisy; log-loss punishes overconfidence
proba[identical] = [0.06, 0.06, 0.88] # identical responses are a tie
How it relates to TRL's RewardTrainer
TRL RewardTrainer |
pairjudge |
|
|---|---|---|
| Output | scalar reward (num_labels=1) |
3-class distribution (A / B / tie) |
| Loss | Bradley–Terry (logsigmoid of reward gap) | CE on human labels, KL on soft pseudo-labels |
| Ties | not representable | first-class |
| Multi-turn pair truncation | generic | proportional, all-fields-guaranteed |
| Position bias | n/a at inference (scores singletons) | swap-debias averaging + flip-rate diagnostic |
If you need a scalar reward for PPO-style RLHF, use TRL. If you need a judge that compares two concrete responses — for evaluation, reranking, data labeling, or arena prediction — and your data has ties, this is the recipe that placed 4th of 1,849 on exactly that task.
Provenance & validation
- The competition scripts, configs, inference notebook and certificate are preserved verbatim in
competition/, including the full original write-up. tests/test_packing.py::TestCompetitionEquivalencefuzzes 1,500 conversations against a verbatim copy of the competition tokenizer (tests/reference_impl.py) and asserts byte-identical output with default settings — the library is the medal-winning code, not a reimplementation of it.- Final leaderboard: 4th / 1,849 (gold medal, $20,000 prize).
Citation
@misc{li2024pairjudge,
author = {Daoyuan Li},
title = {pairjudge: pairwise LLM judges with budget-aware packing and position-bias correction},
year = {2024},
url = {https://github.com/DaoyuanLi2816/pairjudge},
note = {Generalized from the 4th-place solution, Kaggle LMSYS Chatbot Arena Human Preference Predictions}
}
License
MIT — see LICENSE.
Author
Daoyuan Li — Kaggle (distiller) · lidaoyuan2816@gmail.com
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pairjudge-0.1.0.tar.gz.
File metadata
- Download URL: pairjudge-0.1.0.tar.gz
- Upload date:
- Size: 27.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d53cbb00bca1e72c88ba6c667122b279c9a3defbd390f8bb9d789ff88b87036d
|
|
| MD5 |
7bf0584011d57fcd0d21d8ad163acc4b
|
|
| BLAKE2b-256 |
762ea50622c5bfbbb7b242da3392c7a53caf69fe53d3cc865e31b8710105760a
|
File details
Details for the file pairjudge-0.1.0-py3-none-any.whl.
File metadata
- Download URL: pairjudge-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.11
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
124c964cf0faa0168f33dc6884430f2b359a7c986510c76c77c22054bd8c22b1
|
|
| MD5 |
0e2b44d195df885391a6518b2091fa79
|
|
| BLAKE2b-256 |
c751771becc225f5345568b0aaf7921a0748387150e75eaa50823445a3631333
|