Files
ely-eye/bench/metrics.py
T
ZacharyZhang-NY fc0698c242 Add baseline harness: ingest, metrics, ablation arms, external counterpart, audit
- bench/ingest_baseline.py drives the unmodified product IngestionService over
  all 50,789 corpus files in sharded SQLite DBs, then merges them. Thread and
  memory ceilings are set explicitly: ONNX Runtime defaulted to every core
  (95 threads/worker, load 490) and its CPU arena kept freed pages mapped,
  which OOM-killed workers.
- bench/metrics.py: recall@k, MRR, nDCG@10, Wilson lower bound, chance
  baseline. No model, no product import, so the scorer cannot grade itself.
- bench/systems.py + bench/run.py: the five ablation arms over the merged DB,
  latency percentiles, scale curve, and a top-10 equivalence check against the
  live RetrievalService.
- bench/counterpart.py + bench/run_external.py: in-repo Mem0-style flat store
  and Zep-style temporal store as the external reference point.
- bench/audit.py: enforces single-attempt scoring, frozen n, a chance row
  beside every score, and producer != verifier. It already flags the product's
  retry-until-target loop at backend/src/ely_eye/evals.py:2351.
- backend/pyproject.toml: rapidocr-onnxruntime>=1.4.4 was unsatisfiable
  (PyPI maxes at 1.2.3); the successor is the renamed rapidocr package.
2026-10-06 14:36:02 -04:00

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
),
"wrong_holder_confidence_unanswerable": (
sum(1 for s in holder_known if s.top_hit_from_holder) / len(holder_known)
if holder_known
else 0.0
),
}