| """ |
| 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 |
|
|
|
|
| |
|
|
| 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 |
|
|
| |
| 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) |
|
|
|
|
| |
|
|
| 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() |
|
|
| |
| survey_groups: dict[str, list[int]] = defaultdict(list) |
| for i, sid in enumerate(survey_ids): |
| survey_groups[sid].append(i) |
|
|
| |
| 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] |
|
|
| |
| if np.sum(query_labels > 0) == 0: |
| continue |
|
|
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| 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 |
|
|
|
|
| |
|
|
| 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): |
| |
| return np.array(labels, dtype=np.float64) + np.random.default_rng(42).random(len(labels)) * 0.01 |
|
|
|
|
| |
|
|
| 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()) |
| |
| display_metrics = [ |
| "num_queries", |
| "ndcg@10", "ndcg@20", |
| "recall@10", "recall@20", |
| "mrr", "map", |
| "hard_neg_auc", |
| "essential_ndcg@10", "relevant_ndcg@10", |
| ] |
|
|
| |
| 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))) |
|
|
| |
| 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?") |
|
|
| |
| 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!") |
|
|
|
|
| |
|
|
| 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() |
|
|
| |
| 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 = {} |
|
|
| |
| 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: |
| |
| |
| |
| 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_report(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: |
| |
| 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() |
|
|