Is the Winner Real?
Two configs score 0.69 and 0.44 on recall@5. Config A wins — but on 8 questions, is that
a real difference, or would the ranking flip if you’d asked 8 slightly different questions?
This page is about answering that question honestly instead of reading the leaderboard order
as if it settles it.
Get paired per-question scores first
Every function on this page compares two configurations question by question, so you need
scores in the shape per_query() already gives you: {question_id: score}
for each config, over the same questions.
import contextgrid as cg
config_a = {"q1": 1.0, "q2": 1.0, "q3": 0.0, "q4": 1.0, "q5": 0.5, "q6": 1.0, "q7": 0.0, "q8": 1.0}config_b = {"q1": 0.0, "q2": 1.0, "q3": 0.0, "q4": 1.0, "q5": 0.0, "q6": 1.0, "q7": 0.0, "q8": 0.5}In a real pipeline these come from cg.per_query(qrels, run_a, "recall", 5) and
cg.per_query(qrels, run_b, "recall", 5) — same qrels, two different runs.
Interval and bootstrap_interval — a confidence interval for one score
Interval(estimate: float, low: float, high: float, confidence: float = 0.95).width, .excludes_zero (low > 0 or high < 0), and str(interval) renders as
"{estimate:.3f} [{low:.3f}, {high:.3f}]".
def bootstrap_interval( scores: Sequence[float], *, confidence: float = 0.95, resamples: int = 2000, seed: int = 0,) -> Intervalscores = [1.0, 0.5, 0.0, 1.0, 1.0, 0.5]interval = cg.bootstrap_interval(scores, seed=0)print(interval, interval.width, interval.excludes_zero)# 0.667 [0.333, 0.917] 0.5833333333333333 TrueIt works by resampling the questions with replacement resamples times and taking the mean of
each resample — no assumption that per-question scores are normally distributed, which they
usually aren’t (recall@5 on one question is often just 0 or 1). A single score returns a
zero-width interval at that value, not an error or NaN.
seed: what it actually controls
seed seeds the resampling RNG (numpy.random.default_rng(seed)). It does not change what your
scores mean — it only makes the resampling reproducible: the same scores, resamples, and
seed always give you back the exact same interval, so a number you report today won’t quietly
shift if someone reruns your script tomorrow.
i1 = cg.bootstrap_interval(scores, seed=0, resamples=50)i2 = cg.bootstrap_interval(scores, seed=1, resamples=50)print(i1)# 0.667 [0.417, 0.917]print(i2)# 0.667 [0.417, 0.898]paired_bootstrap — a confidence interval for the difference
def paired_bootstrap( left: Sequence[float], right: Sequence[float], *, confidence: float = 0.95, resamples: int = 2000, seed: int = 0,) -> Intervaldiff = cg.paired_bootstrap([1.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.5], seed=0)print(diff)# 0.375 [0.000, 0.750]This is not two independent calls to bootstrap_interval — it resamples matched question pairs
together, so the pairing (config A and config B answering the same question) contributes to
the estimate. That pairing is what gives this test real power on a head-to-head comparison,
compared to treating the two configs as unrelated samples.
randomisation_test — a p-value for the difference
def randomisation_test( left: Sequence[float], right: Sequence[float], *, permutations: int = 2000, seed: int = 0,) -> floatA two-sided paired sign-flip permutation test. Returns 1.0 immediately, without running any
permutations, if every paired difference is already ~0 (np.allclose) — two configs that always
tie have nothing for a permutation test to say. Otherwise it uses a “+1 both sides” correction
((count + 1) / (permutations + 1)), so it can never report p=0 no matter how consistent the
difference looks.
p = cg.randomisation_test([1.0, 1.0, 0.0, 1.0], [0.0, 1.0, 0.0, 0.5], seed=0)print(p)compare() — the function you actually want
compare is not promoted to cg.compare — import it from its module directly:
from contextgrid.score.significance import comparedef compare( left_scores: Mapping[str, float], right_scores: Mapping[str, float], *, left: str = "left", right: str = "right", metric: str = "recall@5", alpha: float = 0.05, confidence: float = 0.95, resamples: int = 2000, seed: int = 0,) -> ComparisonIt takes the two per_query() dicts, matches them up by question id, and runs both
paired_bootstrap and randomisation_test for you. metric is just a label used in the
printed verdict — it isn’t looked up or validated against cg.METRICS, so nothing stops you
passing a name that doesn’t match what you actually scored; keep it honest yourself.
Only questions present in both dicts are used — a question scored on one side only is
silently excluded, not counted as a miss. If there’s no overlap at all, compare raises
contextgrid.score.significance.SignificanceError — not a cg.X name, same as compare itself.
result = compare(config_a, config_b, left="bm25", right="dense", metric="recall@5")print(result)# Comparison(left='bm25', right='dense', metric='recall@5', n=8, left_mean=0.6875,# right_mean=0.4375, difference=Interval(estimate=0.25, low=0.0625, high=0.5,# confidence=0.95), p_value=0.245..., alpha=0.05, wins=3, losses=0, ties=5)Comparison — read .distinguishable, not the raw means
Comparison( left: str, right: str, metric: str, n: int, left_mean: float, right_mean: float, difference: Interval, p_value: float, alpha: float = 0.05, wins: int = 0, losses: int = 0, ties: int = 0,)bm25 scored 0.6875 and dense scored 0.4375 above — a 25-point gap. But:
print(result.distinguishable)# Falseprint(result.winner)# None.distinguishable is deliberately conservative: it requires both
p_value < alpha and difference.excludes_zero — not either alone. Two weak signals
agreeing is a better basis for a decision than trusting one. .winner only returns a name when
.distinguishable is True; otherwise it’s None, on purpose, so you can’t accidentally read
a winner out of noise.
wins / losses / ties are plain counts of left > right, left < right, left == right
on the raw per-question scores — descriptive, not a statistical test. Here bm25 won 3
questions and tied 5, which is consistent with distinguishable=False: winning some individual
questions is not the same as the aggregate gap being real.
.verdict() — the sentence you can paste into a PR description
print(result.verdict())# bm25 and dense are not distinguishable on this eval set (n=8). The gap of +0.250 on recall@5# has a confidence interval of +0.062 to +0.500, which does stay clear of zero, but the# p-value of 0.245 is not below alpha 0.05. Both have to hold before this package will name a# winner, so on this evidence the ranking could still come down to which questions were asked.# Settling a gap this size would take at least roughly 63 questions -- on a two-sided test at# alpha 0.05 with 80% power. That estimate assumes an unpaired test while this one is paired,# so it is a lower bound: the more the two configurations disagree question by question, the# more you need. It is an order of magnitude, not a count.Note which half of the rule failed here, because the sentence says so. The interval
+0.062 to +0.500 never touches zero, so excludes_zero is True; it’s the p-value that
doesn’t clear alpha. When the interval is the half that fails, the wording changes to match:
print(compare({"q1": 1.0, "q2": 0.0}, {"q1": 0.0, "q2": 1.0}, left="bm25", right="dense").verdict())# bm25 and dense are not distinguishable on this eval set (n=2). The gap of +0.000 on recall@5# sits inside the confidence interval -1.000 to +1.000, so it is consistent with no difference# at all. They average exactly the same score while disagreeing on individual questions, so no# number of questions like these would separate them.The negative case gets the longer explanation on purpose — a reader who’s told “not distinguishable” needs to know what it would take to actually settle the question, or they’ll just fall back to reading the raw means as if they were an answer. The “roughly 63 questions” estimate is a magnitude, not a precise target: it assumes worst-case variance for a 0/1-style score, so treat it as “you need an order of magnitude more data,” not a number to hit exactly.
When the gap is real — a bigger effect, more questions — .verdict() reads differently:
config_c = {f"q{i}": 1.0 for i in range(20)}config_d = {f"q{i}": (1.0 if i % 5 == 0 else 0.0) for i in range(20)}
result2 = compare(config_c, config_d, left="bm25", right="dense")print(result2.distinguishable, result2.winner)# True bm25print(result2.verdict())# bm25 beats dense by 0.800 on recall@5 (95% CI +0.600 to +0.950, p=0.000, n=20). It wins on# 16 questions, loses on 0 and ties on 4.Compare that against the first example: same kind of question, but a bigger effect (0.8 vs.
0.25) over more questions (20 vs. 8) is what actually clears the bar. A gap has to earn
distinguishable=True on both fronts — size and sample — not just look big in a bar chart.
When a leaderboard gap means nothing
- Small
n. 8 or 20 questions is not enough to distinguish anything but a huge effect..verdict()’s sample-size note exists specifically to put a number on “how much data would it actually take.” - Ties dominate. If
winsandlossesare both small next toties, the two configs are mostly agreeing question by question — the aggregate gap is being driven by a handful of questions, which is exactly what a paired bootstrap is built to catch and a plain mean isn’t. - You compared on different questions.
compare()only uses the intersection of both score dicts. If your two runs didn’t answer the same questions (a filtered eval set, a crashed run), the comparison is quietly narrower than you think — checkresult.nagainst the eval set size. - The metric doesn’t match the story.
metric=is a label, not a lookup — aComparisonbuilt by accidentally passingprecision@5scores while labeling it"recall@5"will print a perfectly confident, perfectly wrong sentence.