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)