Eval Set Quality
A leaderboard is only as good as the questions behind it. This page is about the tools that
catch a bad eval set before it costs you a wrong conclusion: assess() to measure it,
default_filters()/FilterChain to clean an auto-generated draft, Classifier to label
question types, and ReviewQueue to put a human in the loop.
What a bad eval set does to a leaderboard
Two failure modes matter most, and both are silent unless you check for them:
- Too few questions. A small eval set can’t tell noise from a real difference. Two
configs that score 0.62 and 0.68 on 8 questions might just as well be identical — you’d
need a much bigger set to say otherwise.
EvalSetQuality.detectable_differenceputs a number on exactly how small a gap the set can’t see. - Questions that don’t test retrieval. A question answerable from general knowledge (“What is the capital of France?”) scores well for every configuration, because the generator LLM already knew the answer without reading your corpus. It doesn’t measure retrieval at all — it just makes every arm of a sweep look equally good, or bad.
assess() — measuring a set
assess(evalset: EvalSet, *, baseline_scores: dict[str, float] | None = None, reviewed_key: str = "reviewed") -> EvalSetQualityimport contextgrid as cg
def with_anchor(id, question, quote="Employees accrue 1.5 days of leave per month."): return cg.EvalItem( id=id, question=question, anchors=(cg.GoldAnchor(source_id="handbook.pdf", quote=quote),), qtype=cg.QuestionType.FACTOID, )
items = [ with_anchor("q1", "How much leave do employees accrue per month?"), with_anchor("q2", "How much leave do employees accrue per month?"), # near-duplicate with_anchor("q3", "it"), # too short, dangling pronoun]evalset = cg.EvalSet(id="handbook-quiz", items=tuple(items))
quality = cg.assess(evalset)print(quality)print("reviewed_fraction:", quality.reviewed_fraction)print("detectable_difference:", quality.detectable_difference)print(quality.summary())for w in quality.warnings().entries: print(str(w))EvalSetQuality(size=3, answerable=3, reviewed=0, portable=3, types={'factoid': 3}, mean_discriminating_power=None, non_discriminating=0)reviewed_fraction: 0.0detectable_difference: 1.03 questions (3 with evidence, unchecked against a corpus), 0% reviewed, differences below about 1.00 are noiseCAUTION [evalset]: 3 questions carry evidence, unchecked against a corpus, so anything below about 1.00 is noise on this set -- it cannot reliably detect a gap that small. A gap above that is worth testing rather than assuming; `is_the_winner_real()` settles itCAUTION [evalset]: only 0% of this set is marked as checked by a human. Ground truth nobody has read is the weakest link in any retrieval comparison. If you wrote these questions yourself, say so with `"meta": {"reviewed": true}` on each one; otherwise the review queue is the cheapest place to fix itdetectable_difference: 1.0 on 3 questions is the point made concrete: with this few
questions, the set literally cannot distinguish two configs unless one scores 100 percentage
points above the other. reviewed is read from item.meta.get(reviewed_key) — a
hand-written set only counts as reviewed if you set "meta": {"reviewed": true} yourself
(or run it through ReviewQueue, below).
EvalSetQuality
EvalSetQuality( size: int, answerable: int, reviewed: int, portable: int, types: dict[str, int] = {}, mean_discriminating_power: float | None = None, non_discriminating: int = 0,)| Property | Means |
|---|---|
.reviewed_fraction | reviewed / size |
.unanswerable | size - answerable |
.detectable_difference | minimum_detectable_difference(self.answerable) — the exact floor |
.reported_detectable_difference | the same floor rounded up to 2 decimals; this is the number .summary() and .warnings() print |
.is_portable | portable == answerable |
.can_support(difference) -> bool | is this gap above the noise floor — tested against the exact .detectable_difference. False is reliable; True is a necessary condition, not a verdict (see the warning below) |
.summary() -> str | the one-line summary shown above |
.warnings() -> WarningLog | CAUTION/INFO messages: small size, low review coverage, non-discriminating questions, non-portable gold, one question type dominating (>70% of a set of 10+) |
minimum_detectable_difference(n, *, power=0.8, alpha=0.05) computes
(z_alpha + z_power) * sqrt(2 * 0.25 / n) — the standard error for the difference between two
independent proportions at p = 0.5. It uses two-value lookup tables for the significance
constants, not a real inverse-normal computation, so passing e.g. alpha=0.10 doesn’t smoothly
move the answer; it just switches to the other bucket’s constant. n < 2 returns 1.0 rather
than erroring. It isn’t cg.X; import it as
contextgrid.evalset.minimum_detectable_difference if you want to call it directly.
Filtering an auto-generated draft
A draft from Generating Questions is explicitly unreviewed. Filters throw out the questions that shouldn’t be on a leaderboard at all.
default_filters()
default_filters(*, baseline_scores: dict[str, float] | None = None, answerer: Callable[[str], str] | None = None) -> FilterChainBuilds a FilterChain, in this order: ShortQuestionFilter() →
DanglingReferenceFilter() → UnresolvedEvidenceFilter() → DuplicateFilter() →
NonDiscriminatingFilter(baseline_scores=baseline_scores or {}) →
GeneralKnowledgeFilter(answerer=answerer). Cheap regex filters run first, expensive
ones (an LLM call per question) run last, on whatever survived.
chain = cg.default_filters()result = chain.run(evalset)print("kept:", result.kept_count, "rejected:", result.rejected_count)print(result.by_filter())print(result.summary())for r in result.rejected: print(r)for w in result.warnings.entries: print(str(w))kept: 1 rejected: 2{'too-short': 1, 'near-duplicate': 1}kept 1 of 3 questions (near-duplicate 1, too-short 1)[too-short] q3: only 1 words; too vague to retrieve on[near-duplicate] q2: nearly the same question as 'q1': 'How much leave do employees accrue per month?'CAUTION [evalset]: the general-knowledge filter had no model to ask, so it did not run. Questions answerable without reading the corpus will still be in this set, and they score well for every configurationCAUTION [evalset]: filtering removed 2 of 3 questions. That much rejection usually means the generator prompt needs work rather than the filtersCAUTION [evalset]: 1 questions is a small eval set. Differences below about 0.1 will not be distinguishable from noise on itGet answerer from contextgrid.evalset.llm.answerer_from(llm). Here’s the same set with a
general-knowledge question, caught with a RecordingLLM standing in for a real model — no
API key needed:
from contextgrid.evalset.llm import answerer_from
gk_items = [cg.EvalItem( id="q1", question="What is the capital of France?", answer="Paris", anchors=(cg.GoldAnchor(source_id="h.pdf", quote="Paris is the capital of France."),),)]gk_evalset = cg.EvalSet(id="e", items=tuple(gk_items))
llm = cg.RecordingLLM(replies=["Paris"])chain = cg.default_filters(answerer=answerer_from(llm))result = chain.run(gk_evalset)print(result.kept_count, result.rejected_count, result.by_filter())for r in result.rejected: print(r)0 1 {'general-knowledge': 1}[general-knowledge] q1: answerable from general knowledge, so it measures the model rather than the retrieverThat’s the second failure mode from the top of this page, caught: the LLM answered “Paris”
without reading the corpus, matched item.answer, and GeneralKnowledgeFilter threw the
question out before it could quietly inflate every config’s score.
FilterChain and FilterResult
FilterChain(filters: list[Filter] = []).run(evalset: EvalSet) -> FilterResult
FilterResult(kept: tuple[EvalItem, ...] = (), rejected: tuple[Rejection, ...] = (), warnings: WarningLog = WarningLog())Filters run in list order, each one’s survivors feeding the next — that’s why
default_filters() puts the cheap regex filters before the LLM-backed one. .run() also
logs CAUTION when more than half the questions were rejected in total, and CAUTION when the
kept count is below 30.
FilterResult has .kept_count, .rejected_count, .by_filter() -> dict[str, int],
.summary() -> str, and .as_evalset(original: EvalSet) -> EvalSet — which bumps
version by 1 relative to original:
new_evalset = result.as_evalset(evalset)print(evalset.version, new_evalset.version)1 2The individual filters
Each one is full-path-only (e.g. contextgrid.evalset.filters.ShortQuestionFilter) — none
are cg.X.
| Filter | Signature | Rejects | Worth knowing |
|---|---|---|---|
ShortQuestionFilter | (min_words=4, name="too-short") | fewer than min_words words | a coarse first pass, not a writing-quality judge |
DanglingReferenceFilter | (name="dangling-reference") | a pronoun (it/its/they/this/that/he/she/…) with no earlier antecedent in the question | crude regex — “the/a/an <noun>” or a capitalized non-first word counts as an antecedent |
DuplicateFilter | (threshold=0.8, name="near-duplicate") | Jaccard word-overlap ≥ threshold with an already-kept question | order-dependent — compares against survivors seen so far, not the whole set |
UnresolvedEvidenceFilter | (name="unresolved-evidence") | items whose anchors failed to resolve to a span | stands down (no-op) if nothing in the batch is resolved yet — only meaningful after AnchorResolver.resolve() has run |
NonDiscriminatingFilter | (baseline_scores={}, threshold=1.0, name="non-discriminating") | items where baseline_scores[item.id] >= threshold | no-op if baseline_scores is empty |
GeneralKnowledgeFilter | (answerer=None, name="general-knowledge") | items whose closed-book answer Jaccard-matches (≥0.6) item.answer | no-op if answerer is None; also a no-op per-item when item.answer is None |
Classifier — labeling question types
Classifier(model: Callable[[str], str] | None = None, overwrite: bool = False).label(item: EvalItem) -> EvalItem.label_all(items: Sequence[EvalItem]) -> list[EvalItem].label_set(evalset: EvalSet) -> EvalSetWithout a model, Classifier falls back to a regex heuristic
(contextgrid.evalset.classify_question, most-specific-first: summarisation → comparative →
tabular → numeric → multi-hop → else factoid):
def with_anchor(id, q): return cg.EvalItem(id=id, question=q, anchors=(cg.GoldAnchor(source_id="handbook.pdf", quote="Employees accrue 1.5 days of leave per month."),))
items = [ with_anchor("q1", "How much leave do employees accrue per month?"), with_anchor("q2", "Compare the leave policy in the US office and the UK office."), with_anchor("q3", "How many paid sick days does an employee get per year?"),]labeled = cg.Classifier().label_all(items)for it in labeled: print(it.id, it.qtype)q1 tabularq2 comparativeq3 numericWith a model, Classifier calls model(item.question), lowercases and strips the reply,
and only accepts it if it’s in QuestionType.ALL — an out-of-vocabulary reply is
discarded and the item falls through to the same heuristic:
item = cg.EvalItem(id="q1", question="How much leave do employees accrue per month?", anchors=(cg.GoldAnchor(source_id="h.pdf", quote="Employees accrue 1.5 days of leave per month."),))clf = cg.Classifier(model=lambda q: "not-a-real-type")print(clf.label(item).qtype) # falls back to the heuristictabularcontextgrid.evalset.type_distribution(evalset) -> dict[str, int] counts items per qtype,
with items where qtype is None counted under the literal key "unlabelled". It isn’t
cg.X either.
ReviewQueue — putting a human in the loop
Filters catch mechanical problems. A human still has to read the good-looking questions that are left.
ReviewQueue(items: list[EvalItem], position: int = 0, decisions: dict = {}, history: list = [])ReviewQueue.from_evalset(evalset: EvalSet, *, skip_reviewed: bool = True) -> ReviewQueueBy default, from_evalset excludes items already marked meta["reviewed"], so re-running
review only shows you what’s new.
items = [ cg.EvalItem(id="q1", question="How much leave do employees accrue per month?", anchors=(cg.GoldAnchor(source_id="handbook.pdf", quote="Employees accrue 1.5 days of leave per month."),)), cg.EvalItem(id="q2", question="What is the capital of France?", anchors=(cg.GoldAnchor(source_id="handbook.pdf", quote="Paris is the capital of France."),)), cg.EvalItem(id="q3", question="it", anchors=(cg.GoldAnchor(source_id="handbook.pdf", quote="Employees accrue 1.5 days of leave per month."),)),]evalset = cg.EvalSet(id="handbook-quiz", items=tuple(items))
queue = cg.ReviewQueue.from_evalset(evalset)print(queue.progress())
print(queue.current.id)queue.accept(note="good, on-topic")
print(queue.current.id)queue.reject(note="general knowledge, not about the handbook")
print(queue.current.id)edited = queue.edit(question="What does the pronoun refer to in section 3?", note="fixed dangling reference")print("edited question:", edited.question)
print("is_done:", queue.is_done)print("counts:", queue.counts())
final = queue.result(evalset)print("kept ids:", [it.id for it in final])print("version:", evalset.version, "->", final.version)for it in final: print(it.id, it.meta.get("reviewed"), it.meta.get("verdict"))0 of 3 · 3 leftq1q2q3edited question: What does the pronoun refer to in section 3?is_done: Truecounts: {'accepted': 1, 'rejected': 1, 'edited': 1, 'skipped': 0}kept ids: ['q1', 'q3']version: 1 -> 2q1 True acceptedq3 True editedOne-keystroke methods, each needing .current is not None (otherwise EvalSetError: “the
review queue is finished; there is nothing to decide on”): .accept(note=""),
.reject(note=""), .skip(note=""), .edit(*, question=None, quote=None, qtype=None, grade=None, note="") -> EvalItem, .mark(qtype, note="") (shorthand for
edit(qtype=...)), .undo() -> EvalItem | None.
.result(original: EvalSet) -> EvalSet drops rejected and skipped items, passes undecided
items through unchanged, and stamps meta["reviewed"]=True plus meta["verdict"] on
accepted/edited ones. It bumps version by 1, same as FilterResult.as_evalset.
.rejections() -> list[Decision] gives you just the REJECTED ones — worth reading before
regenerating a batch, since it’s the fastest way to see what your generator keeps getting
wrong.
Verdict is a str Enum: PENDING = "pending", ACCEPTED = "accepted",
REJECTED = "rejected", EDITED = "edited", SKIPPED = "skipped".
contextgrid.evalset.review.pending(evalset) -> Sequence[EvalItem] is a simpler,
queue-unaware filter for not item.meta.get("reviewed"); neither it nor
contextgrid.evalset.review.review_summary(queue, elapsed_seconds=None) -> str is cg.X.