Three defects made the external baseline numbers and labels wrong. - bench/systems.py: _sentence_model rebuilt the model on every query, so dense arm latency was mostly model loading and the latency-vs-size curve was flat because a constant dominated it. Models are now cached per process. - bench/counterpart.py: visible() and search() walked 404k Python objects per query. Per-item holder/source/time columns are built once and the query path is numpy; 12.5 queries/s instead of 0.7. - bench/metrics.py: the aggregate key was named wrong_holder_confidence_unanswerable while it counts top hits that ARE from the asking holder. BASELINE.md read that backwards. Renamed to top_hit_from_holder_unanswerable and the sentence rewritten. - bench/run_external.py: the by-modality table read row['answerable'] but aggregate emits n_answerable, so the writer died after scoring finished. Each store is now scored twice, [holder scope] and [whole corpus], so the gain from the holder boundary is separable from the gain from ranking; progress lines every 500 queries. Verification: pytest bench/tests -q -> 61 passed; ruff check bench/ -> all checks passed; mypy (fresh cache) -> no issues in 25 source files.
151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
"""Retrieval metrics with no model and no dependency: recall@k, MRR, nDCG@10, Wilson bounds.
|
|
|
|
Rankings are lists of source ids; gold is the set of source ids a third party marked
|
|
correct. Unanswerable queries carry an empty gold set, so they are scored by abstention
|
|
behaviour instead of recall, and never mixed into the recall denominator.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
|
|
Z_TWO_SIDED_95 = 1.959963984540054
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QueryScore:
|
|
query_id: str
|
|
answerable: bool
|
|
gold: int
|
|
recall_at_1: float
|
|
recall_at_5: float
|
|
recall_at_10: float
|
|
ndcg_at_10: float
|
|
mrr: float
|
|
first_hit_rank: int | None
|
|
abstained: bool
|
|
top_hit_from_holder: bool | None
|
|
|
|
|
|
def recall_at_k(ranked: Sequence[str], gold: set[str], k: int) -> float:
|
|
"""Fraction of gold sources inside the first k results; 0.0 when gold is empty."""
|
|
if not gold:
|
|
return 0.0
|
|
return len(gold & set(ranked[:k])) / len(gold)
|
|
|
|
|
|
def reciprocal_rank(ranked: Sequence[str], gold: set[str]) -> float:
|
|
for position, source_id in enumerate(ranked, start=1):
|
|
if source_id in gold:
|
|
return 1.0 / position
|
|
return 0.0
|
|
|
|
|
|
def first_hit_rank(ranked: Sequence[str], gold: set[str]) -> int | None:
|
|
for position, source_id in enumerate(ranked, start=1):
|
|
if source_id in gold:
|
|
return position
|
|
return None
|
|
|
|
|
|
def ndcg_at_10(ranked: Sequence[str], gold: set[str]) -> float:
|
|
"""Binary-relevance nDCG@10; 0.0 when gold is empty."""
|
|
if not gold:
|
|
return 0.0
|
|
dcg = sum(
|
|
1.0 / math.log2(position + 1)
|
|
for position, source_id in enumerate(ranked[:10], start=1)
|
|
if source_id in gold
|
|
)
|
|
ideal = sum(1.0 / math.log2(position + 1) for position in range(1, min(len(gold), 10) + 1))
|
|
return dcg / ideal
|
|
|
|
|
|
def wilson_lower_bound(successes: int, trials: int, z: float = Z_TWO_SIDED_95) -> float:
|
|
"""One number: the lower end of the Wilson score interval for a binomial proportion."""
|
|
if trials <= 0:
|
|
raise SystemExit(f"wilson needs a positive trial count, got {trials}")
|
|
if not 0 <= successes <= trials:
|
|
raise SystemExit(f"wilson needs 0 <= {successes} <= {trials}")
|
|
rate = successes / trials
|
|
denominator = 1.0 + z * z / trials
|
|
centre = rate + z * z / (2 * trials)
|
|
spread = z * math.sqrt((rate * (1.0 - rate) + z * z / (4 * trials)) / trials)
|
|
return max(0.0, (centre - spread) / denominator)
|
|
|
|
|
|
def chance_recall_at_k(gold: int, corpus_size: int, k: int) -> float:
|
|
"""Expected recall@k for a uniformly random ranking: k/N, whatever the gold count.
|
|
|
|
A random top-k holds a k/N share of every subset, so the share of gold found does not
|
|
depend on how many sources are gold; gold only decides whether the question is answerable.
|
|
"""
|
|
if corpus_size <= 0:
|
|
raise SystemExit(f"chance needs a positive corpus size, got {corpus_size}")
|
|
if gold <= 0:
|
|
return 0.0
|
|
return min(1.0, k / corpus_size)
|
|
|
|
|
|
def score_query(
|
|
query_id: str,
|
|
ranked: Sequence[str],
|
|
gold: set[str],
|
|
holder: str | None = None,
|
|
holder_of: dict[str, str] | None = None,
|
|
) -> QueryScore:
|
|
"""Score one query; abstention is only defined when the system can signal no_match."""
|
|
abstained = not ranked
|
|
top_holder = None
|
|
if holder_of is not None and ranked:
|
|
top_holder = holder_of.get(ranked[0])
|
|
return QueryScore(
|
|
query_id=query_id,
|
|
answerable=bool(gold),
|
|
gold=len(gold),
|
|
recall_at_1=recall_at_k(ranked, gold, 1),
|
|
recall_at_5=recall_at_k(ranked, gold, 5),
|
|
recall_at_10=recall_at_k(ranked, gold, 10),
|
|
ndcg_at_10=ndcg_at_10(ranked, gold),
|
|
mrr=reciprocal_rank(ranked, gold),
|
|
first_hit_rank=first_hit_rank(ranked, gold),
|
|
abstained=abstained,
|
|
top_hit_from_holder=None if holder is None else top_holder == holder,
|
|
)
|
|
|
|
|
|
def aggregate(scores: Sequence[QueryScore]) -> dict[str, float | int]:
|
|
"""Mean metrics over answerable queries plus abstention over unanswerable ones."""
|
|
answerable = [score for score in scores if score.answerable]
|
|
unanswerable = [score for score in scores if not score.answerable]
|
|
if not answerable:
|
|
raise SystemExit("aggregate needs at least one answerable query")
|
|
|
|
def mean(field: str) -> float:
|
|
return sum(getattr(score, field) for score in answerable) / len(answerable)
|
|
|
|
hits_10 = sum(1 for score in answerable if score.recall_at_10 > 0.0)
|
|
holder_known = [score for score in unanswerable if score.top_hit_from_holder is not None]
|
|
return {
|
|
"n_answerable": len(answerable),
|
|
"n_unanswerable": len(unanswerable),
|
|
"recall_at_1": mean("recall_at_1"),
|
|
"recall_at_5": mean("recall_at_5"),
|
|
"recall_at_10": mean("recall_at_10"),
|
|
"ndcg_at_10": mean("ndcg_at_10"),
|
|
"mrr": mean("mrr"),
|
|
"hit_at_10": hits_10 / len(answerable),
|
|
"hit_at_10_wilson_lower": wilson_lower_bound(hits_10, len(answerable)),
|
|
"abstention_rate_answerable": sum(1 for s in answerable if s.abstained) / len(answerable),
|
|
"abstention_rate_unanswerable": (
|
|
sum(1 for s in unanswerable if s.abstained) / len(unanswerable) if unanswerable else 0.0
|
|
),
|
|
"top_hit_from_holder_unanswerable": (
|
|
sum(1 for s in holder_known if s.top_hit_from_holder) / len(holder_known)
|
|
if holder_known
|
|
else 0.0
|
|
),
|
|
}
|