| """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 ( |
| average_precision, ndcg_at_k, precision_at_k, reciprocal_rank, |
| ) |
| from app.indexing.bm25 import BM25Index |
| from app.indexing.indobert import IndoBERTIndex |
| from app.indexing.tfidf import TFIDFIndex |
| from app.preprocessing import PreprocessingPipeline |
|
|
|
|
| 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 |
| |
| 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] |
|
|
| |
| 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]) |
|
|
| |
| 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]) |
|
|
| |
| 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"] |
|
|
| |
| 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]) |
|
|
| |
| 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("\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()) |
|
|