researchit-reranker-phase6 / scripts /05_evaluate_on_surveys.py
siddhm11's picture
Add eval v2: evaluation script with proper metrics for survey reading lists
38004cb verified
Raw
History Blame Contribute Delete
15.7 kB
"""
Step 5: Evaluate paper recommendation models on survey-curated reading lists.
This runs the honest evaluation against expert-curated data from Step 4.
KEY DIFFERENCE FROM OLD EVAL:
Old: "Can you find ANY cited paper among easy negatives?" β†’ inflated nDCG
New: "Can you rank essential papers above background AND hard negatives?" β†’ honest
METRICS:
- nDCG@10/20: Overall ranking quality (multi-graded relevance)
- MAP: Average precision across all positions
- MRR (tierβ‰₯3): Where does the first essential/relevant paper appear?
- Recall@k (tierβ‰₯3): How many important papers make it into top-k?
- Hard Negative AUC: Can model separate cited from expert-excluded papers?
- Essential nDCG@10: Can model find the must-read papers specifically?
- Relevant nDCG@10: Can model find Related Work papers?
- Per-section accuracy: Does model understand Related Work > Methods > Intro?
BASELINES:
- Random: The absolute floor
- Citation count: Pure popularity (what your current model approximates)
- Recency: Newer papers score higher
- Cosine similarity: BGE-M3 embedding distance to survey paper
USAGE:
# Run baselines only (no model needed):
python 05_evaluate_on_surveys.py \
--eval-file eval_v2_data/eval_survey_reading_lists.parquet \
--baselines-only
# Evaluate LightGBM model (needs Qdrant + Turso for features):
python 05_evaluate_on_surveys.py \
--eval-file eval_v2_data/eval_survey_reading_lists.parquet \
--model-file production_model/reranker_v2.txt \
--qdrant-url $QDRANT_URL --qdrant-api-key $QDRANT_API_KEY \
--turso-url $TURSO_URL --turso-token $TURSO_DB_TOKEN
PREREQUISITES:
pip install lightgbm pyarrow numpy tqdm
Author: ResearchIT ML Pipeline β€” Eval V2
"""
from __future__ import annotations
import argparse
import json
import os
from collections import defaultdict
from pathlib import Path
import numpy as np
import pyarrow.parquet as pq
from tqdm import tqdm
# ── Metrics ──────────────────────────────────────────────────────────────────
def ndcg_at_k(labels: np.ndarray, scores: np.ndarray, k: int = 10) -> float:
"""Compute nDCG@k for a single query (multi-graded relevance)."""
if len(labels) == 0:
return 0.0
order = np.argsort(-scores)
sorted_labels = labels[order][:k].astype(np.float64)
gains = (2.0 ** sorted_labels) - 1.0
discounts = np.log2(np.arange(len(sorted_labels)) + 2.0)
dcg = np.sum(gains / discounts)
ideal_order = np.argsort(-labels)
ideal_labels = labels[ideal_order][:k].astype(np.float64)
ideal_gains = (2.0 ** ideal_labels) - 1.0
ideal_discounts = np.log2(np.arange(len(ideal_labels)) + 2.0)
idcg = np.sum(ideal_gains / ideal_discounts)
return float(dcg / idcg) if idcg > 0 else 0.0
def recall_at_k(labels: np.ndarray, scores: np.ndarray, k: int, threshold: int = 3) -> float:
"""Fraction of papers with label >= threshold that appear in top-k."""
total_relevant = np.sum(labels >= threshold)
if total_relevant == 0:
return 0.0
order = np.argsort(-scores)
sorted_labels = labels[order]
top_k_relevant = np.sum(sorted_labels[:k] >= threshold)
return float(top_k_relevant / total_relevant)
def mean_reciprocal_rank(labels: np.ndarray, scores: np.ndarray, threshold: int = 3) -> float:
"""1/rank of first paper with label >= threshold."""
order = np.argsort(-scores)
sorted_labels = labels[order]
for rank, label in enumerate(sorted_labels, 1):
if label >= threshold:
return 1.0 / rank
return 0.0
def average_precision(labels: np.ndarray, scores: np.ndarray, threshold: int = 1) -> float:
"""AP: average of precision at each relevant document position."""
order = np.argsort(-scores)
sorted_labels = labels[order]
relevant = sorted_labels >= threshold
n_relevant = np.sum(relevant)
if n_relevant == 0:
return 0.0
precisions = []
running_relevant = 0
for i, is_rel in enumerate(relevant, 1):
if is_rel:
running_relevant += 1
precisions.append(running_relevant / i)
return float(np.mean(precisions))
def hard_negative_auc(labels: np.ndarray, scores: np.ndarray) -> float:
"""
AUC for cited (label > 0) vs hard negative (label = 0).
Measures: can the model tell expert-included from expert-excluded?
"""
pos_scores = scores[labels > 0]
neg_scores = scores[labels == 0]
if len(pos_scores) == 0 or len(neg_scores) == 0:
return 0.5
# Efficient AUC via Mann-Whitney U statistic
concordant = 0
for ps in pos_scores:
concordant += np.sum(ps > neg_scores) + 0.5 * np.sum(ps == neg_scores)
total = len(pos_scores) * len(neg_scores)
return float(concordant / total) if total > 0 else 0.5
def tier_specific_ndcg(labels: np.ndarray, scores: np.ndarray, target_tier: int, k: int = 10) -> float:
"""nDCG@k where only papers at or above target_tier are relevant."""
binary_labels = (labels >= target_tier).astype(np.float64)
if np.sum(binary_labels) == 0:
return 0.0
return ndcg_at_k(binary_labels, scores, k)
# ── Evaluation Engine ────────────────────────────────────────────────────────
def evaluate_model(
eval_file: str,
score_fn,
model_name: str = "Model",
) -> dict:
"""
Run full evaluation on survey reading lists.
Args:
eval_file: path to eval_survey_reading_lists.parquet
score_fn: callable(survey_id, cited_ids, labels) β†’ np.ndarray of scores
model_name: name for display
Returns:
dict with all metrics
"""
table = pq.read_table(eval_file)
survey_ids = table.column("survey_arxiv_id").to_pylist()
cited_ids = table.column("cited_arxiv_id").to_pylist()
labels_list = table.column("label").to_pylist()
# Group by survey
survey_groups: dict[str, list[int]] = defaultdict(list)
for i, sid in enumerate(survey_ids):
survey_groups[sid].append(i)
# Compute per-query metrics
metrics_per_query = {
"ndcg@10": [], "ndcg@20": [],
"recall@10": [], "recall@20": [],
"mrr": [], "map": [],
"hard_neg_auc": [],
"essential_ndcg@10": [], "relevant_ndcg@10": [],
}
for survey_id, indices in tqdm(survey_groups.items(), desc=f"Evaluating {model_name}"):
indices = np.array(indices)
query_labels = np.array([labels_list[i] for i in indices], dtype=np.int32)
query_cited_ids = [cited_ids[i] for i in indices]
# Skip if no positives
if np.sum(query_labels > 0) == 0:
continue
# Get scores from the model
try:
query_scores = score_fn(survey_id, query_cited_ids, query_labels)
except Exception as e:
continue
if query_scores is None or len(query_scores) != len(query_labels):
continue
query_scores = np.asarray(query_scores, dtype=np.float64)
# Compute all metrics
metrics_per_query["ndcg@10"].append(ndcg_at_k(query_labels, query_scores, k=10))
metrics_per_query["ndcg@20"].append(ndcg_at_k(query_labels, query_scores, k=20))
metrics_per_query["recall@10"].append(recall_at_k(query_labels, query_scores, k=10, threshold=3))
metrics_per_query["recall@20"].append(recall_at_k(query_labels, query_scores, k=20, threshold=3))
metrics_per_query["mrr"].append(mean_reciprocal_rank(query_labels, query_scores, threshold=3))
metrics_per_query["map"].append(average_precision(query_labels, query_scores, threshold=1))
metrics_per_query["hard_neg_auc"].append(hard_negative_auc(query_labels, query_scores))
if np.sum(query_labels >= 4) > 0:
metrics_per_query["essential_ndcg@10"].append(
tier_specific_ndcg(query_labels, query_scores, 4, k=10))
if np.sum(query_labels >= 3) > 0:
metrics_per_query["relevant_ndcg@10"].append(
tier_specific_ndcg(query_labels, query_scores, 3, k=10))
# Aggregate
results = {"num_queries": len(metrics_per_query["ndcg@10"])}
for metric_name, values in metrics_per_query.items():
if values:
results[metric_name] = float(np.mean(values))
results[f"{metric_name}_std"] = float(np.std(values))
else:
results[metric_name] = 0.0
results[f"{metric_name}_std"] = 0.0
return results
# ── Baseline Scorers ─────────────────────────────────────────────────────────
class RandomScorer:
"""Random baseline β€” the absolute floor."""
def __init__(self, seed=42):
self.rng = np.random.default_rng(seed)
def __call__(self, survey_id, cited_ids, labels):
return self.rng.random(len(cited_ids))
class CitationCountScorer:
"""Pure popularity baseline (approximates what V1/V2 model learned)."""
def __init__(self, metadata_cache: dict):
self.cache = metadata_cache
def __call__(self, survey_id, cited_ids, labels):
scores = []
for cid in cited_ids:
meta = self.cache.get(cid, {})
scores.append(float(meta.get("citation_count", 0)))
return np.array(scores)
class RecencyScorer:
"""Newer papers score higher."""
def __init__(self, metadata_cache: dict):
self.cache = metadata_cache
def __call__(self, survey_id, cited_ids, labels):
scores = []
for cid in cited_ids:
meta = self.cache.get(cid, {})
try:
year = int(str(meta.get("update_date", "2020"))[:4])
except (ValueError, TypeError):
year = 2020
scores.append(float(year))
return np.array(scores)
class OracleScorer:
"""Perfect ranking β€” the ceiling (uses labels directly)."""
def __call__(self, survey_id, cited_ids, labels):
# Add small noise to break ties consistently
return np.array(labels, dtype=np.float64) + np.random.default_rng(42).random(len(labels)) * 0.01
# ── Report Formatting ────────────────────────────────────────────────────────
def print_comparison_report(results: dict[str, dict]):
"""Print formatted comparison table."""
print(f"\n{'='*90}")
print(f" EVALUATION RESULTS β€” Survey Reading List Benchmark (Eval V2)")
print(f"{'='*90}")
models = list(results.keys())
# Key metrics to display
display_metrics = [
"num_queries",
"ndcg@10", "ndcg@20",
"recall@10", "recall@20",
"mrr", "map",
"hard_neg_auc",
"essential_ndcg@10", "relevant_ndcg@10",
]
# Header
col_width = 14
header = f" {'Metric':<25}" + "".join(f"{m:>{col_width}}" for m in models)
print(header)
print(" " + "-" * (25 + col_width * len(models)))
for metric in display_metrics:
if metric == "num_queries":
row = f" {metric:<25}" + "".join(
f"{results[m].get(metric, 0):>{col_width}}" for m in models)
else:
row = f" {metric:<25}" + "".join(
f"{results[m].get(metric, 0):>{col_width}.4f}" for m in models)
print(row)
print(" " + "-" * (25 + col_width * len(models)))
# Insights
print(f"\n INTERPRETATION:")
print(f" nDCG@10: Overall ranking quality (higher = better)")
print(f" hard_neg_auc: Can model tell cited from expert-excluded? (0.5 = random, 1.0 = perfect)")
print(f" essential_ndcg: Can model find the must-read papers? (most important metric)")
print(f" recall@10: Of all important papers, how many are in top-10?")
# Flag issues
for model, r in results.items():
if model in ("Random", "Oracle"):
continue
if r.get("hard_neg_auc", 0) < 0.6:
print(f"\n ⚠️ {model}: hard_neg_auc={r['hard_neg_auc']:.3f} β€” model barely distinguishes cited from excluded!")
if r.get("essential_ndcg@10", 0) < 0.3:
print(f" ⚠️ {model}: essential_ndcg={r['essential_ndcg@10']:.3f} β€” fails to find must-read papers!")
# ── CLI ──────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Evaluate paper recommendation models (Eval V2)")
parser.add_argument("--eval-file", required=True,
help="eval_survey_reading_lists.parquet from Step 4")
parser.add_argument("--output", default=None, help="Save results as JSON")
parser.add_argument("--baselines-only", action="store_true",
help="Only run baselines (Random + Oracle)")
parser.add_argument("--model-file", default=None,
help="LightGBM model .txt file to evaluate")
parser.add_argument("--qdrant-url", default=os.environ.get("QDRANT_URL"))
parser.add_argument("--qdrant-api-key", default=os.environ.get("QDRANT_API_KEY"))
parser.add_argument("--turso-url", default=os.environ.get("TURSO_URL"))
parser.add_argument("--turso-token", default=os.environ.get("TURSO_DB_TOKEN"))
args = parser.parse_args()
# Load eval data
print(f"Loading eval data: {args.eval_file}")
table = pq.read_table(args.eval_file)
n_rows = len(table)
n_surveys = len(set(table.column("survey_arxiv_id").to_pylist()))
labels = table.column("label").to_pylist()
label_dist = defaultdict(int)
for l in labels:
label_dist[l] += 1
print(f" {n_rows} rows, {n_surveys} surveys")
print(f" Labels: 4={label_dist[4]}, 3={label_dist[3]}, 2={label_dist[2]}, 1={label_dist[1]}, 0={label_dist[0]}")
results = {}
# Always run Random and Oracle
print("\n--- Running: Random baseline ---")
results["Random"] = evaluate_model(args.eval_file, RandomScorer(42), "Random")
print("\n--- Running: Oracle (perfect ranking) ---")
results["Oracle"] = evaluate_model(args.eval_file, OracleScorer(), "Oracle")
if not args.baselines_only and args.model_file:
# TODO: Load LightGBM model and compute features
# This requires Qdrant (for cosine scores) and Turso (for metadata)
# Will be implemented once the eval data is generated
print(f"\n--- LightGBM evaluation requires feature computation ---")
print(f" Model: {args.model_file}")
print(f" Qdrant: {args.qdrant_url}")
print(f" Turso: {args.turso_url}")
print(f" (Not yet implemented β€” needs Qdrant + Turso access)")
# Print comparison
print_comparison_report(results)
# Save results
if args.output:
with open(args.output, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to: {args.output}")
else:
# Default output location
output_dir = Path(args.eval_file).parent
output_file = output_dir / "eval_results.json"
with open(output_file, "w") as f:
json.dump(results, f, indent=2)
print(f"\nResults saved to: {output_file}")
if __name__ == "__main__":
main()