siddhm11 commited on
Commit
38004cb
Β·
verified Β·
1 Parent(s): 2628be1

Add eval v2: evaluation script with proper metrics for survey reading lists

Browse files
Files changed (1) hide show
  1. scripts/05_evaluate_on_surveys.py +401 -0
scripts/05_evaluate_on_surveys.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Step 5: Evaluate paper recommendation models on survey-curated reading lists.
3
+
4
+ This runs the honest evaluation against expert-curated data from Step 4.
5
+
6
+ KEY DIFFERENCE FROM OLD EVAL:
7
+ Old: "Can you find ANY cited paper among easy negatives?" β†’ inflated nDCG
8
+ New: "Can you rank essential papers above background AND hard negatives?" β†’ honest
9
+
10
+ METRICS:
11
+ - nDCG@10/20: Overall ranking quality (multi-graded relevance)
12
+ - MAP: Average precision across all positions
13
+ - MRR (tierβ‰₯3): Where does the first essential/relevant paper appear?
14
+ - Recall@k (tierβ‰₯3): How many important papers make it into top-k?
15
+ - Hard Negative AUC: Can model separate cited from expert-excluded papers?
16
+ - Essential nDCG@10: Can model find the must-read papers specifically?
17
+ - Relevant nDCG@10: Can model find Related Work papers?
18
+ - Per-section accuracy: Does model understand Related Work > Methods > Intro?
19
+
20
+ BASELINES:
21
+ - Random: The absolute floor
22
+ - Citation count: Pure popularity (what your current model approximates)
23
+ - Recency: Newer papers score higher
24
+ - Cosine similarity: BGE-M3 embedding distance to survey paper
25
+
26
+ USAGE:
27
+ # Run baselines only (no model needed):
28
+ python 05_evaluate_on_surveys.py \
29
+ --eval-file eval_v2_data/eval_survey_reading_lists.parquet \
30
+ --baselines-only
31
+
32
+ # Evaluate LightGBM model (needs Qdrant + Turso for features):
33
+ python 05_evaluate_on_surveys.py \
34
+ --eval-file eval_v2_data/eval_survey_reading_lists.parquet \
35
+ --model-file production_model/reranker_v2.txt \
36
+ --qdrant-url $QDRANT_URL --qdrant-api-key $QDRANT_API_KEY \
37
+ --turso-url $TURSO_URL --turso-token $TURSO_DB_TOKEN
38
+
39
+ PREREQUISITES:
40
+ pip install lightgbm pyarrow numpy tqdm
41
+
42
+ Author: ResearchIT ML Pipeline β€” Eval V2
43
+ """
44
+ from __future__ import annotations
45
+
46
+ import argparse
47
+ import json
48
+ import os
49
+ from collections import defaultdict
50
+ from pathlib import Path
51
+
52
+ import numpy as np
53
+ import pyarrow.parquet as pq
54
+ from tqdm import tqdm
55
+
56
+
57
+ # ── Metrics ──────────────────────────────────────────────────────────────────
58
+
59
+ def ndcg_at_k(labels: np.ndarray, scores: np.ndarray, k: int = 10) -> float:
60
+ """Compute nDCG@k for a single query (multi-graded relevance)."""
61
+ if len(labels) == 0:
62
+ return 0.0
63
+ order = np.argsort(-scores)
64
+ sorted_labels = labels[order][:k].astype(np.float64)
65
+
66
+ gains = (2.0 ** sorted_labels) - 1.0
67
+ discounts = np.log2(np.arange(len(sorted_labels)) + 2.0)
68
+ dcg = np.sum(gains / discounts)
69
+
70
+ ideal_order = np.argsort(-labels)
71
+ ideal_labels = labels[ideal_order][:k].astype(np.float64)
72
+ ideal_gains = (2.0 ** ideal_labels) - 1.0
73
+ ideal_discounts = np.log2(np.arange(len(ideal_labels)) + 2.0)
74
+ idcg = np.sum(ideal_gains / ideal_discounts)
75
+
76
+ return float(dcg / idcg) if idcg > 0 else 0.0
77
+
78
+
79
+ def recall_at_k(labels: np.ndarray, scores: np.ndarray, k: int, threshold: int = 3) -> float:
80
+ """Fraction of papers with label >= threshold that appear in top-k."""
81
+ total_relevant = np.sum(labels >= threshold)
82
+ if total_relevant == 0:
83
+ return 0.0
84
+ order = np.argsort(-scores)
85
+ sorted_labels = labels[order]
86
+ top_k_relevant = np.sum(sorted_labels[:k] >= threshold)
87
+ return float(top_k_relevant / total_relevant)
88
+
89
+
90
+ def mean_reciprocal_rank(labels: np.ndarray, scores: np.ndarray, threshold: int = 3) -> float:
91
+ """1/rank of first paper with label >= threshold."""
92
+ order = np.argsort(-scores)
93
+ sorted_labels = labels[order]
94
+ for rank, label in enumerate(sorted_labels, 1):
95
+ if label >= threshold:
96
+ return 1.0 / rank
97
+ return 0.0
98
+
99
+
100
+ def average_precision(labels: np.ndarray, scores: np.ndarray, threshold: int = 1) -> float:
101
+ """AP: average of precision at each relevant document position."""
102
+ order = np.argsort(-scores)
103
+ sorted_labels = labels[order]
104
+ relevant = sorted_labels >= threshold
105
+ n_relevant = np.sum(relevant)
106
+ if n_relevant == 0:
107
+ return 0.0
108
+
109
+ precisions = []
110
+ running_relevant = 0
111
+ for i, is_rel in enumerate(relevant, 1):
112
+ if is_rel:
113
+ running_relevant += 1
114
+ precisions.append(running_relevant / i)
115
+ return float(np.mean(precisions))
116
+
117
+
118
+ def hard_negative_auc(labels: np.ndarray, scores: np.ndarray) -> float:
119
+ """
120
+ AUC for cited (label > 0) vs hard negative (label = 0).
121
+ Measures: can the model tell expert-included from expert-excluded?
122
+ """
123
+ pos_scores = scores[labels > 0]
124
+ neg_scores = scores[labels == 0]
125
+
126
+ if len(pos_scores) == 0 or len(neg_scores) == 0:
127
+ return 0.5
128
+
129
+ # Efficient AUC via Mann-Whitney U statistic
130
+ concordant = 0
131
+ for ps in pos_scores:
132
+ concordant += np.sum(ps > neg_scores) + 0.5 * np.sum(ps == neg_scores)
133
+ total = len(pos_scores) * len(neg_scores)
134
+ return float(concordant / total) if total > 0 else 0.5
135
+
136
+
137
+ def tier_specific_ndcg(labels: np.ndarray, scores: np.ndarray, target_tier: int, k: int = 10) -> float:
138
+ """nDCG@k where only papers at or above target_tier are relevant."""
139
+ binary_labels = (labels >= target_tier).astype(np.float64)
140
+ if np.sum(binary_labels) == 0:
141
+ return 0.0
142
+ return ndcg_at_k(binary_labels, scores, k)
143
+
144
+
145
+ # ── Evaluation Engine ────────────────────────────────────────────────────────
146
+
147
+ def evaluate_model(
148
+ eval_file: str,
149
+ score_fn,
150
+ model_name: str = "Model",
151
+ ) -> dict:
152
+ """
153
+ Run full evaluation on survey reading lists.
154
+
155
+ Args:
156
+ eval_file: path to eval_survey_reading_lists.parquet
157
+ score_fn: callable(survey_id, cited_ids, labels) β†’ np.ndarray of scores
158
+ model_name: name for display
159
+
160
+ Returns:
161
+ dict with all metrics
162
+ """
163
+ table = pq.read_table(eval_file)
164
+
165
+ survey_ids = table.column("survey_arxiv_id").to_pylist()
166
+ cited_ids = table.column("cited_arxiv_id").to_pylist()
167
+ labels_list = table.column("label").to_pylist()
168
+
169
+ # Group by survey
170
+ survey_groups: dict[str, list[int]] = defaultdict(list)
171
+ for i, sid in enumerate(survey_ids):
172
+ survey_groups[sid].append(i)
173
+
174
+ # Compute per-query metrics
175
+ metrics_per_query = {
176
+ "ndcg@10": [], "ndcg@20": [],
177
+ "recall@10": [], "recall@20": [],
178
+ "mrr": [], "map": [],
179
+ "hard_neg_auc": [],
180
+ "essential_ndcg@10": [], "relevant_ndcg@10": [],
181
+ }
182
+
183
+ for survey_id, indices in tqdm(survey_groups.items(), desc=f"Evaluating {model_name}"):
184
+ indices = np.array(indices)
185
+ query_labels = np.array([labels_list[i] for i in indices], dtype=np.int32)
186
+ query_cited_ids = [cited_ids[i] for i in indices]
187
+
188
+ # Skip if no positives
189
+ if np.sum(query_labels > 0) == 0:
190
+ continue
191
+
192
+ # Get scores from the model
193
+ try:
194
+ query_scores = score_fn(survey_id, query_cited_ids, query_labels)
195
+ except Exception as e:
196
+ continue
197
+
198
+ if query_scores is None or len(query_scores) != len(query_labels):
199
+ continue
200
+
201
+ query_scores = np.asarray(query_scores, dtype=np.float64)
202
+
203
+ # Compute all metrics
204
+ metrics_per_query["ndcg@10"].append(ndcg_at_k(query_labels, query_scores, k=10))
205
+ metrics_per_query["ndcg@20"].append(ndcg_at_k(query_labels, query_scores, k=20))
206
+ metrics_per_query["recall@10"].append(recall_at_k(query_labels, query_scores, k=10, threshold=3))
207
+ metrics_per_query["recall@20"].append(recall_at_k(query_labels, query_scores, k=20, threshold=3))
208
+ metrics_per_query["mrr"].append(mean_reciprocal_rank(query_labels, query_scores, threshold=3))
209
+ metrics_per_query["map"].append(average_precision(query_labels, query_scores, threshold=1))
210
+ metrics_per_query["hard_neg_auc"].append(hard_negative_auc(query_labels, query_scores))
211
+
212
+ if np.sum(query_labels >= 4) > 0:
213
+ metrics_per_query["essential_ndcg@10"].append(
214
+ tier_specific_ndcg(query_labels, query_scores, 4, k=10))
215
+ if np.sum(query_labels >= 3) > 0:
216
+ metrics_per_query["relevant_ndcg@10"].append(
217
+ tier_specific_ndcg(query_labels, query_scores, 3, k=10))
218
+
219
+ # Aggregate
220
+ results = {"num_queries": len(metrics_per_query["ndcg@10"])}
221
+ for metric_name, values in metrics_per_query.items():
222
+ if values:
223
+ results[metric_name] = float(np.mean(values))
224
+ results[f"{metric_name}_std"] = float(np.std(values))
225
+ else:
226
+ results[metric_name] = 0.0
227
+ results[f"{metric_name}_std"] = 0.0
228
+
229
+ return results
230
+
231
+
232
+ # ── Baseline Scorers ─────────────────────────────────────────────────────────
233
+
234
+ class RandomScorer:
235
+ """Random baseline β€” the absolute floor."""
236
+ def __init__(self, seed=42):
237
+ self.rng = np.random.default_rng(seed)
238
+
239
+ def __call__(self, survey_id, cited_ids, labels):
240
+ return self.rng.random(len(cited_ids))
241
+
242
+
243
+ class CitationCountScorer:
244
+ """Pure popularity baseline (approximates what V1/V2 model learned)."""
245
+ def __init__(self, metadata_cache: dict):
246
+ self.cache = metadata_cache
247
+
248
+ def __call__(self, survey_id, cited_ids, labels):
249
+ scores = []
250
+ for cid in cited_ids:
251
+ meta = self.cache.get(cid, {})
252
+ scores.append(float(meta.get("citation_count", 0)))
253
+ return np.array(scores)
254
+
255
+
256
+ class RecencyScorer:
257
+ """Newer papers score higher."""
258
+ def __init__(self, metadata_cache: dict):
259
+ self.cache = metadata_cache
260
+
261
+ def __call__(self, survey_id, cited_ids, labels):
262
+ scores = []
263
+ for cid in cited_ids:
264
+ meta = self.cache.get(cid, {})
265
+ try:
266
+ year = int(str(meta.get("update_date", "2020"))[:4])
267
+ except (ValueError, TypeError):
268
+ year = 2020
269
+ scores.append(float(year))
270
+ return np.array(scores)
271
+
272
+
273
+ class OracleScorer:
274
+ """Perfect ranking β€” the ceiling (uses labels directly)."""
275
+ def __call__(self, survey_id, cited_ids, labels):
276
+ # Add small noise to break ties consistently
277
+ return np.array(labels, dtype=np.float64) + np.random.default_rng(42).random(len(labels)) * 0.01
278
+
279
+
280
+ # ── Report Formatting ────────────────────────────────────────────────────────
281
+
282
+ def print_comparison_report(results: dict[str, dict]):
283
+ """Print formatted comparison table."""
284
+ print(f"\n{'='*90}")
285
+ print(f" EVALUATION RESULTS β€” Survey Reading List Benchmark (Eval V2)")
286
+ print(f"{'='*90}")
287
+
288
+ models = list(results.keys())
289
+ # Key metrics to display
290
+ display_metrics = [
291
+ "num_queries",
292
+ "ndcg@10", "ndcg@20",
293
+ "recall@10", "recall@20",
294
+ "mrr", "map",
295
+ "hard_neg_auc",
296
+ "essential_ndcg@10", "relevant_ndcg@10",
297
+ ]
298
+
299
+ # Header
300
+ col_width = 14
301
+ header = f" {'Metric':<25}" + "".join(f"{m:>{col_width}}" for m in models)
302
+ print(header)
303
+ print(" " + "-" * (25 + col_width * len(models)))
304
+
305
+ for metric in display_metrics:
306
+ if metric == "num_queries":
307
+ row = f" {metric:<25}" + "".join(
308
+ f"{results[m].get(metric, 0):>{col_width}}" for m in models)
309
+ else:
310
+ row = f" {metric:<25}" + "".join(
311
+ f"{results[m].get(metric, 0):>{col_width}.4f}" for m in models)
312
+ print(row)
313
+
314
+ print(" " + "-" * (25 + col_width * len(models)))
315
+
316
+ # Insights
317
+ print(f"\n INTERPRETATION:")
318
+ print(f" nDCG@10: Overall ranking quality (higher = better)")
319
+ print(f" hard_neg_auc: Can model tell cited from expert-excluded? (0.5 = random, 1.0 = perfect)")
320
+ print(f" essential_ndcg: Can model find the must-read papers? (most important metric)")
321
+ print(f" recall@10: Of all important papers, how many are in top-10?")
322
+
323
+ # Flag issues
324
+ for model, r in results.items():
325
+ if model in ("Random", "Oracle"):
326
+ continue
327
+ if r.get("hard_neg_auc", 0) < 0.6:
328
+ print(f"\n ⚠️ {model}: hard_neg_auc={r['hard_neg_auc']:.3f} β€” model barely distinguishes cited from excluded!")
329
+ if r.get("essential_ndcg@10", 0) < 0.3:
330
+ print(f" ⚠️ {model}: essential_ndcg={r['essential_ndcg@10']:.3f} β€” fails to find must-read papers!")
331
+
332
+
333
+ # ── CLI ──────────────────────────────────────────────────────────────────────
334
+
335
+ def main():
336
+ parser = argparse.ArgumentParser(description="Evaluate paper recommendation models (Eval V2)")
337
+ parser.add_argument("--eval-file", required=True,
338
+ help="eval_survey_reading_lists.parquet from Step 4")
339
+ parser.add_argument("--output", default=None, help="Save results as JSON")
340
+ parser.add_argument("--baselines-only", action="store_true",
341
+ help="Only run baselines (Random + Oracle)")
342
+ parser.add_argument("--model-file", default=None,
343
+ help="LightGBM model .txt file to evaluate")
344
+ parser.add_argument("--qdrant-url", default=os.environ.get("QDRANT_URL"))
345
+ parser.add_argument("--qdrant-api-key", default=os.environ.get("QDRANT_API_KEY"))
346
+ parser.add_argument("--turso-url", default=os.environ.get("TURSO_URL"))
347
+ parser.add_argument("--turso-token", default=os.environ.get("TURSO_DB_TOKEN"))
348
+
349
+ args = parser.parse_args()
350
+
351
+ # Load eval data
352
+ print(f"Loading eval data: {args.eval_file}")
353
+ table = pq.read_table(args.eval_file)
354
+ n_rows = len(table)
355
+ n_surveys = len(set(table.column("survey_arxiv_id").to_pylist()))
356
+ labels = table.column("label").to_pylist()
357
+ label_dist = defaultdict(int)
358
+ for l in labels:
359
+ label_dist[l] += 1
360
+
361
+ print(f" {n_rows} rows, {n_surveys} surveys")
362
+ print(f" Labels: 4={label_dist[4]}, 3={label_dist[3]}, 2={label_dist[2]}, 1={label_dist[1]}, 0={label_dist[0]}")
363
+
364
+ results = {}
365
+
366
+ # Always run Random and Oracle
367
+ print("\n--- Running: Random baseline ---")
368
+ results["Random"] = evaluate_model(args.eval_file, RandomScorer(42), "Random")
369
+
370
+ print("\n--- Running: Oracle (perfect ranking) ---")
371
+ results["Oracle"] = evaluate_model(args.eval_file, OracleScorer(), "Oracle")
372
+
373
+ if not args.baselines_only and args.model_file:
374
+ # TODO: Load LightGBM model and compute features
375
+ # This requires Qdrant (for cosine scores) and Turso (for metadata)
376
+ # Will be implemented once the eval data is generated
377
+ print(f"\n--- LightGBM evaluation requires feature computation ---")
378
+ print(f" Model: {args.model_file}")
379
+ print(f" Qdrant: {args.qdrant_url}")
380
+ print(f" Turso: {args.turso_url}")
381
+ print(f" (Not yet implemented β€” needs Qdrant + Turso access)")
382
+
383
+ # Print comparison
384
+ print_comparison_report(results)
385
+
386
+ # Save results
387
+ if args.output:
388
+ with open(args.output, "w") as f:
389
+ json.dump(results, f, indent=2)
390
+ print(f"\nResults saved to: {args.output}")
391
+ else:
392
+ # Default output location
393
+ output_dir = Path(args.eval_file).parent
394
+ output_file = output_dir / "eval_results.json"
395
+ with open(output_file, "w") as f:
396
+ json.dump(results, f, indent=2)
397
+ print(f"\nResults saved to: {output_file}")
398
+
399
+
400
+ if __name__ == "__main__":
401
+ main()