File size: 6,651 Bytes
03b34b2 | 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 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | """Pool-restricted evaluation: kurangi bias pooling untuk IndoBERT/Hybrid.
Standard top-K evaluation menyalahkan IndoBERT/Hybrid karena pool annotation
dibangun dari BM25/TF-IDF candidates — IndoBERT-specific results (yang belum
di-annotate) otomatis di-treat rel=0 walau secara semantik mungkin relevan.
Pool-restricted eval:
1. Untuk tiap query, ambil semua doc_ids yang DI-ANNOTATE di ground_truth.
2. Score subset itu pakai tiap model.
3. Sort by score, compute metrics on the ranking within pool.
Hasil: fair comparison — semua model dinilai berdasar kemampuan ranking pool
yang sama. Cocok dilaporkan side-by-side dengan standard top-K.
Usage:
cd backend
python -m scripts.eval_pool_restricted
"""
from __future__ import annotations
import csv
import json
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from app.evaluation.metrics import ( # noqa: E402
average_precision, ndcg_at_k, precision_at_k, reciprocal_rank,
)
from app.indexing.bm25 import BM25Index # noqa: E402
from app.indexing.indobert import IndoBERTIndex # noqa: E402
from app.indexing.tfidf import TFIDFIndex # noqa: E402
from app.preprocessing import PreprocessingPipeline # noqa: E402
def rank_pool_with_bm25(bm25: BM25Index, q_processed: str, pool: list[str]) -> list[str]:
import numpy as np
q_tokens = q_processed.split()
scores = bm25.bm25.get_scores(q_tokens)
id_to_idx = {d: i for i, d in enumerate(bm25.doc_ids)}
pool_scored = [(d, scores[id_to_idx[d]]) for d in pool if d in id_to_idx]
pool_scored.sort(key=lambda x: -x[1])
return [d for d, _ in pool_scored]
def rank_pool_with_tfidf(tfidf: TFIDFIndex, q_processed: str, pool: list[str]) -> list[str]:
from sklearn.metrics.pairwise import cosine_similarity
q_vec = tfidf.vectorizer.transform([q_processed])
scores = cosine_similarity(q_vec, tfidf.doc_matrix).flatten()
id_to_idx = {d: i for i, d in enumerate(tfidf.doc_ids)}
pool_scored = [(d, scores[id_to_idx[d]]) for d in pool if d in id_to_idx]
pool_scored.sort(key=lambda x: -x[1])
return [d for d, _ in pool_scored]
def rank_pool_with_indobert(indobert: IndoBERTIndex, q_raw: str, pool: list[str]) -> list[str]:
q_emb = indobert.encode_query(q_raw)
pairs = indobert.score_docs(q_emb, pool)
pairs.sort(key=lambda x: -x[1])
return [d for d, _ in pairs]
def rank_pool_with_hybrid(
bm25: BM25Index, indobert: IndoBERTIndex,
q_raw: str, q_processed: str, pool: list[str], alpha: float = 0.3,
) -> list[str]:
import numpy as np
# Filter pool to docs present in both indexes so both score arrays stay aligned
bm25_id_to_idx = {d: i for i, d in enumerate(bm25.doc_ids)}
pool_filtered = [d for d in pool if d in bm25_id_to_idx]
# BM25 score filtered subset
q_tokens = q_processed.split()
bm25_scores_full = bm25.bm25.get_scores(q_tokens)
bm25_pool_scores = np.array([bm25_scores_full[bm25_id_to_idx[d]] for d in pool_filtered])
# IndoBERT score filtered subset
q_emb = indobert.encode_query(q_raw)
ib_pairs = dict(indobert.score_docs(q_emb, pool_filtered))
ib_pool_scores = np.array([ib_pairs.get(d, 0.0) for d in pool_filtered])
# Min-max normalize
def norm(x):
if x.size == 0 or x.max() - x.min() < 1e-9:
return np.zeros_like(x)
return (x - x.min()) / (x.max() - x.min())
combined = alpha * norm(bm25_pool_scores) + (1 - alpha) * norm(ib_pool_scores)
order = np.argsort(-combined)
return [pool_filtered[i] for i in order]
def main() -> int:
ROOT = Path(__file__).resolve().parents[2]
indexes_dir = ROOT / "data" / "indexes"
print("[load] indexes + pipeline...")
tfidf = TFIDFIndex.load(indexes_dir / "tfidf.pkl")
bm25 = BM25Index.load(indexes_dir / "bm25.pkl")
indobert = IndoBERTIndex.load(indexes_dir / "indobert")
pipeline = PreprocessingPipeline()
queries = json.loads((ROOT / "eval" / "queries.json").read_text(encoding="utf-8"))["queries"]
# Load ground truth
gt: dict[str, dict[str, int]] = {}
with open(ROOT / "eval" / "ground_truth.csv", encoding="utf-8") as f:
for row in csv.DictReader(f):
gt.setdefault(row["query_id"], {})[row["doc_id"]] = int(row["relevance"])
print(f"[loaded] {len(queries)} queries")
out_rows = []
aggregates: dict[str, dict[str, list[float]]] = {
m: {"p5": [], "p10": [], "ap": [], "ndcg": [], "rr": []}
for m in ["tfidf", "bm25", "indobert", "hybrid"]
}
for q in queries:
qid = q["id"]
q_raw = q["query"]
q_processed = pipeline.process(q_raw).processed
pool = list(gt.get(qid, {}).keys())
rel_dict = gt.get(qid, {})
rel_set = {d for d, r in rel_dict.items() if r >= 1}
if not pool:
continue
rankings = {
"tfidf": rank_pool_with_tfidf(tfidf, q_processed, pool),
"bm25": rank_pool_with_bm25(bm25, q_processed, pool),
"indobert": rank_pool_with_indobert(indobert, q_raw, pool),
"hybrid": rank_pool_with_hybrid(bm25, indobert, q_raw, q_processed, pool, alpha=0.9),
}
for model, ranking in rankings.items():
p5 = precision_at_k(ranking, rel_set, 5)
p10 = precision_at_k(ranking, rel_set, 10)
ap = average_precision(ranking, rel_set)
ndcg = ndcg_at_k(ranking, rel_dict, 10)
rr = reciprocal_rank(ranking, rel_set)
aggregates[model]["p5"].append(p5)
aggregates[model]["p10"].append(p10)
aggregates[model]["ap"].append(ap)
aggregates[model]["ndcg"].append(ndcg)
aggregates[model]["rr"].append(rr)
out_rows.append([model, qid, q_raw, p5, p10, ap, ndcg, rr])
# Write per-query CSV
out = ROOT / "eval" / "results_pool_restricted.csv"
with out.open("w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["model", "query_id", "query", "p_at_5", "p_at_10", "ap", "ndcg_at_10", "rr"])
w.writerows(out_rows)
# Print aggregate
print("\n=== POOL-RESTRICTED METRICS (fair across models) ===")
print(f"{'model':10s} {'P@5':>8s} {'P@10':>8s} {'MAP':>8s} {'NDCG@10':>8s} {'MRR':>8s}")
for m, agg in aggregates.items():
n = len(agg["p5"]) or 1
print(f"{m:10s} {sum(agg['p5'])/n:>8.4f} {sum(agg['p10'])/n:>8.4f} "
f"{sum(agg['ap'])/n:>8.4f} {sum(agg['ndcg'])/n:>8.4f} {sum(agg['rr'])/n:>8.4f}")
print(f"\n[saved] {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
|