Spaces:
Running on Zero
Running on Zero
File size: 6,151 Bytes
86e797b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | """Retrieval metrics using benchmark qrels; no answer quality evaluation."""
import json
import math
import platform
from datetime import UTC, datetime
from importlib.metadata import version
from pathlib import Path
from statistics import mean, median
from time import perf_counter
import torch
from visual_doc_rag.data import inspect_tables, load_tables, write_json
from visual_doc_rag.retrieve import rrf
from visual_doc_rag.text_index import TextIndex
from visual_doc_rag.visual_index import Encoder, VisualIndex
def metrics_at(ranking: list[int], qrels: dict[int, int], k: int) -> dict[str, float]:
"""Match trec_eval ndcg_cut: linear graded gains, log2(rank+1) discount."""
if k < 1 or len(set(ranking)) != len(ranking):
raise ValueError("Cutoff must be positive and ranking IDs must be unique.")
relevant = {page for page, score in qrels.items() if score > 0}
gains = [max(qrels.get(page, 0), 0) for page in ranking[:k]]
ideal = sorted((score for score in qrels.values() if score > 0), reverse=True)[:k]
dcg = sum(score / math.log2(rank + 2) for rank, score in enumerate(gains))
idcg = sum(score / math.log2(rank + 2) for rank, score in enumerate(ideal))
return {
f"NDCG@{k}": dcg / idcg if idcg else 0.0,
f"recall@{k}": len(set(ranking[:k]) & relevant) / len(relevant) if relevant else 0.0,
}
def latency_summary(values: list[float]) -> dict:
ordered = sorted(values)
return {
"mean_seconds": mean(values),
"median_seconds": median(values),
"p95_seconds": ordered[math.ceil(0.95 * len(ordered)) - 1],
}
def evaluate(subset: str, data_dir: Path, runs_dir: Path) -> tuple[Path, dict]:
source, tables = load_tables(subset, data_dir)
if not all(inspect_tables(tables)["integrity_checks"].values()):
raise ValueError("Dataset integrity failed. Run `vdr inspect` before evaluating.")
root = data_dir / subset / "indexes"
text = TextIndex.load(root, source)
visual = VisualIndex(root, source)
expected_ids = set(tables["corpus"]["corpus_id"])
if set(text.page_ids) != expected_ids or set(visual.page_ids) != expected_ids:
raise ValueError("Indexes must cover the full candidate corpus.")
encoder = Encoder(data_dir, visual.manifest["signature"]["model"], "cpu")
queries = [
row for row in tables["queries"] if row["language"].strip().lower() in {"english", "en"}
]
qrels: dict[int, dict[int, int]] = {}
for row in tables["qrels"]:
qrels.setdefault(row["query_id"], {})[row["corpus_id"]] = row["score"]
# Warm model kernels and search; exclude initialization/warmup from measured latency.
visual.search(encoder.encode(query=queries[0]["query"]))
text.search(queries[0]["query"])
timings = {name: [] for name in ("text", "visual", "hybrid")}
per_query = []
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
output = runs_dir / stamp
output.mkdir(parents=True)
for index, row in enumerate(queries):
started = perf_counter()
text_ranking = text.search(row["query"])
text_seconds = perf_counter() - started
started = perf_counter()
visual_ranking = visual.search(encoder.encode(query=row["query"]))
visual_seconds = perf_counter() - started
started = perf_counter()
hybrid_ranking = rrf([text_ranking, visual_ranking], k=60)
fusion_seconds = perf_counter() - started
timings["text"].append(text_seconds)
timings["visual"].append(visual_seconds)
timings["hybrid"].append(text_seconds + visual_seconds + fusion_seconds)
results = {}
for name, ranking in zip(
timings, (text_ranking, visual_ranking, hybrid_ranking), strict=True
):
results[name] = {
**metrics_at(ranking, qrels[row["query_id"]], 5),
**metrics_at(ranking, qrels[row["query_id"]], 10),
}
per_query.append({"query_id": row["query_id"], "metrics": results})
if (index + 1) % 10 == 0 or index + 1 == len(queries):
print(f"Evaluated: {index + 1}/{len(queries)} English queries", flush=True)
keys = ("NDCG@5", "NDCG@10", "recall@5", "recall@10")
report = {
"dataset": source,
"n_queries": len(queries),
"n_pages": len(expected_ids),
"language": "english",
"split": "test",
"tuning": "none",
"metrics": {
name: {key: mean(row["metrics"][name][key] for row in per_query) for key in keys}
for name in timings
},
"cpu_latency": {name: latency_summary(values) for name, values in timings.items()},
"latency_protocol": "4 torch threads; CPU float32 query encoding + full-corpus search; "
"warm indexes/model; no query cache; hybrid sequential sum plus RRF",
"metric_definition": "macro mean over English queries; trec_eval linear graded NDCG; "
"recall uses score>0; unjudged gain=0; RRF k=60 over full rankings",
"index_build": {
"text": json.loads((root / "text-build.json").read_text(encoding="utf-8")),
"visual": {k: v for k, v in visual.manifest.items() if k not in {"pages", "dataset"}},
},
"environment": {
"python": platform.python_version(),
"platform": platform.platform(),
"torch": torch.__version__,
"torch_threads": torch.get_num_threads(),
"processor": platform.processor(),
"library_versions": {
name: version(name)
for name in ("colpali-engine", "transformers", "rank-bm25", "datasets")
},
},
}
write_json(output / "per-query.json", {"queries": per_query})
write_json(output / "metrics.json", report)
return output, report
def format_table(report: dict) -> str:
header = "method NDCG@5 NDCG@10 recall@5 recall@10"
lines = [header]
for name, values in report["metrics"].items():
lines.append(f"{name:<10} " + " ".join(f"{value:.4f}" for value in values.values()))
return "\n".join(lines)
|