File size: 3,416 Bytes
39ff835
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Run offline evaluation (Precision@K / Recall / F1 / latency) across methods.

Uses the prebuilt index at ``artifacts/index.pkl`` if present, otherwise builds
one from the committed sample. Relevance is judged with pseudo-qrels derived from
the dataset's category labels (see ``news_search.evaluate``).

Usage
-----
    python scripts/evaluate.py
    python scripts/evaluate.py --index artifacts/index.pkl --top-k 10
"""
from __future__ import annotations

import argparse
import pickle
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

from news_search import SearchEngine, build_index, load_corpus  # noqa: E402
from news_search.index import InvertedIndex  # noqa: E402
from news_search.evaluate import evaluate  # noqa: E402

ALL_METHODS = ["bm25", "tfidf", "prf", "wordnet", "bert", "hybrid"]
BASE_METHODS = ["bm25", "tfidf", "prf", "wordnet"]


def _load_dense(index_path: Path):
    """Load the dense (BERT) retriever sitting next to the index, if present."""
    dense_path = Path(index_path).with_name("dense.pkl")
    if not dense_path.exists():
        return None
    try:
        with dense_path.open("rb") as fh:
            return pickle.load(fh)
    except Exception as exc:  # pragma: no cover
        print(f"[warn] could not load {dense_path}: {exc}")
        return None

DEFAULT_QUERIES = [
    "covid vaccine health",
    "election president vote",
    "movie film",
    "stock market money",
    "game sport",
    "travel food recipe",
]


def main() -> int:
    ap = argparse.ArgumentParser(description="Offline IR evaluation across methods.")
    ap.add_argument("--index", default=str(ROOT / "artifacts" / "index.pkl"),
                    help="Prebuilt index to evaluate (falls back to the sample).")
    ap.add_argument("--data", default=str(ROOT / "data" / "sample_news.jsonl"),
                    help="Dataset used when no prebuilt index exists.")
    ap.add_argument("--top-k", type=int, default=10)
    ap.add_argument("--methods", nargs="*", default=None,
                    help="Subset of methods to evaluate (default: bm25 tfidf prf wordnet).")
    args = ap.parse_args()

    if Path(args.index).exists():
        print(f"Loading index from {args.index}")
        engine = SearchEngine(InvertedIndex.load(args.index), dense=_load_dense(Path(args.index)))
    else:
        print(f"No index at {args.index}; building from {args.data}")
        engine = SearchEngine(build_index(load_corpus(args.data), verbose=False))

    # Evaluate BERT methods too when embeddings are available.
    methods = args.methods or (ALL_METHODS if engine.dense is not None else BASE_METHODS)
    print(f"Index: {engine.index.num_docs:,} docs | "
          f"BERT {'enabled' if engine.dense is not None else 'disabled'} | methods: {methods}")

    # Warm up the BERT model so its one-time load isn't charged to the first
    # timed query (gives a fair steady-state latency).
    if engine.dense is not None:
        engine.search("warmup query", method="bert", top_k=1)

    rows = evaluate(engine, DEFAULT_QUERIES, methods=methods, top_k=args.top_k)

    print(f"\n{'method':<12}{'P@K':>8}{'Recall':>9}{'F1':>8}{'avg ms':>9}")
    print("-" * 46)
    for r in rows:
        print(f"{r.method:<12}{r.precision:>8.3f}{r.recall:>9.3f}{r.f1:>8.3f}{r.avg_ms:>9.1f}")
    return 0


if __name__ == "__main__":
    sys.exit(main())