siddhm11 commited on
Commit
616cfe6
Β·
verified Β·
1 Parent(s): c82215c

Add 02_generate_training_triples.py

Browse files
scripts/02_generate_training_triples.py ADDED
@@ -0,0 +1,748 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Step 2: Generate LightGBM training triples from citation edges.
3
+
4
+ Produces: train.parquet + eval.parquet
5
+ Each row = (query_arxiv_id, candidate_arxiv_id, label, feature_1, ..., feature_N)
6
+
7
+ Labels:
8
+ 2 = directly cited by query paper (strong positive)
9
+ 1 = co-cited with query paper (weak positive)
10
+ 0 = retrieved but not cited (negative)
11
+
12
+ Time-split:
13
+ train: query papers published before 2023-01-01
14
+ eval: query papers published on or after 2023-01-01
15
+
16
+ Usage:
17
+ python 02_generate_training_triples.py \
18
+ --citations citations.parquet \
19
+ --corpus-file arxiv_ids.txt \
20
+ --qdrant-url https://YOUR_QDRANT_URL \
21
+ --qdrant-api-key YOUR_KEY \
22
+ --qdrant-collection arxiv_bgem3_dense \
23
+ --turso-url https://YOUR_TURSO_URL \
24
+ --turso-token YOUR_TOKEN \
25
+ --output-dir ./ltr_dataset \
26
+ --num-queries 100000 \
27
+ --candidates-per-query 50
28
+
29
+ Prerequisites:
30
+ - citations.parquet from Step 1
31
+ - Qdrant Cloud access (ANN search + embedding retrieval)
32
+ - Turso access (paper metadata)
33
+ - pip install httpx pyarrow qdrant-client tqdm numpy
34
+
35
+ Feature Schema (37 features):
36
+ See FEATURE_SCHEMA below for the full list.
37
+ Features 1-20 are populated from citation graph + metadata.
38
+ Features 21-27 are zero-filled (EWMA/cluster/suppression β€” need real users).
39
+ All 37 feature columns are present so the model schema is stable.
40
+
41
+ Author: ResearchIT ML Pipeline β€” Phase 6, Step 2
42
+ """
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import asyncio
47
+ import json
48
+ import os
49
+ import random
50
+ import time
51
+ from collections import defaultdict
52
+ from datetime import datetime, timezone
53
+ from pathlib import Path
54
+
55
+ import httpx
56
+ import numpy as np
57
+ import pyarrow as pa
58
+ import pyarrow.parquet as pq
59
+ from tqdm import tqdm
60
+
61
+ try:
62
+ from qdrant_client import QdrantClient
63
+ from qdrant_client.models import Filter, FieldCondition, MatchValue
64
+ except ImportError:
65
+ print("ERROR: pip install qdrant-client")
66
+ raise
67
+
68
+
69
+ # ── Feature Schema ───────────────────────────────────────────────────────────
70
+ # This defines ALL 37 features. Features 21-27 are zero-filled for pseudo-label
71
+ # training but will be populated when real user data is available.
72
+ #
73
+ # The schema is designed so that the LightGBM model trained on pseudo-labels
74
+ # can be retrained on real data without changing the feature layout.
75
+
76
+ FEATURE_SCHEMA = [
77
+ # === Content/Retrieval features (populated during pseudo-label training) ===
78
+ "qdrant_cosine_score", # 0: ANN cosine similarity
79
+ "candidate_position", # 1: rank position in ANN results (0-indexed)
80
+ "candidate_citation_count", # 2: citation count of candidate paper
81
+ "candidate_log_citations", # 3: log(citation_count + 1)
82
+ "candidate_influential_citations", # 4: influential citation count
83
+ "candidate_age_days", # 5: days since candidate was published
84
+ "candidate_recency_score", # 6: exp(-0.002 * age_days) β€” matches heuristic
85
+ "query_citation_count", # 7: citation count of query/user paper
86
+ "query_age_days", # 8: days since query paper was published
87
+ "year_diff", # 9: |query_year - candidate_year|
88
+ "same_primary_category", # 10: 1 if same primary arXiv category, else 0
89
+ "co_citation_count", # 11: papers that cite BOTH query and candidate
90
+ "shared_author_count", # 12: number of shared authors
91
+ "candidate_is_newer", # 13: 1 if candidate published after query, else 0
92
+ "query_log_citations", # 14: log(query_citation_count + 1)
93
+ "citation_count_ratio", # 15: candidate_citations / (query_citations + 1)
94
+ "age_ratio", # 16: candidate_age / (query_age + 1)
95
+ "candidate_citations_per_year", # 17: citation_count / max(age_years, 0.5)
96
+ "query_num_references", # 18: how many papers the query paper cites (in-corpus)
97
+ "candidate_num_cited_by", # 19: how many corpus papers cite the candidate
98
+
99
+ # === User behavior features (zero-filled for pseudo-labels, active for real users) ===
100
+ "ewma_longterm_similarity", # 20: cos(candidate, user long-term EWMA profile)
101
+ "ewma_shortterm_similarity", # 21: cos(candidate, user short-term EWMA profile)
102
+ "ewma_negative_similarity", # 22: cos(candidate, user negative EWMA profile)
103
+ "cluster_importance", # 23: importance weight of serving cluster
104
+ "cluster_distance_to_medoid", # 24: cos(candidate, cluster medoid)
105
+ "is_suppressed_category", # 25: 1 if candidate's category is suppressed
106
+ "onboarding_category_match", # 26: 1 if candidate matches user's onboarding categories
107
+
108
+ # === Interaction features (zero-filled for pseudo-labels) ===
109
+ "user_total_saves", # 27: total papers user has saved
110
+ "user_total_dismissals", # 28: total papers user has dismissed
111
+ "user_days_since_last_save", # 29: days since user's last save
112
+ "user_session_save_count", # 30: saves in current session
113
+
114
+ # === Cross features (computed from combinations) ===
115
+ "cosine_x_recency", # 31: qdrant_cosine_score Γ— candidate_recency_score
116
+ "cosine_x_citations", # 32: qdrant_cosine_score Γ— candidate_log_citations
117
+ "category_x_recency", # 33: same_primary_category Γ— candidate_recency_score
118
+ "cosine_x_cocitation", # 34: qdrant_cosine_score Γ— log(co_citation_count + 1)
119
+ "position_inverse", # 35: 1 / (candidate_position + 1)
120
+ "citations_x_recency", # 36: candidate_log_citations Γ— candidate_recency_score
121
+ ]
122
+
123
+ NUM_FEATURES = len(FEATURE_SCHEMA) # 37
124
+ assert NUM_FEATURES == 37, f"Expected 37 features, got {NUM_FEATURES}"
125
+
126
+ # Time split cutoff
127
+ EVAL_CUTOFF = "2023-01-01"
128
+ EVAL_CUTOFF_DATE = datetime(2023, 1, 1, tzinfo=timezone.utc)
129
+
130
+
131
+ # ── Citation Graph Loading ───────────────────────────────────────────────────
132
+
133
+ def load_citation_graph(citations_path: str) -> tuple[dict, dict, dict]:
134
+ """
135
+ Load citation edges and build lookup structures.
136
+
137
+ Returns:
138
+ references: {citing_id: set(cited_ids)} β€” outgoing references
139
+ cited_by: {cited_id: set(citing_ids)} β€” incoming citations
140
+ co_citation_counts: precomputed co-citation matrix (lazily computed per query)
141
+ """
142
+ table = pq.read_table(citations_path)
143
+ citing_col = table.column("citing_arxiv_id").to_pylist()
144
+ cited_col = table.column("cited_arxiv_id").to_pylist()
145
+
146
+ references: dict[str, set[str]] = defaultdict(set)
147
+ cited_by: dict[str, set[str]] = defaultdict(set)
148
+
149
+ for citing, cited in zip(citing_col, cited_col):
150
+ references[citing].add(cited)
151
+ cited_by[cited].add(citing)
152
+
153
+ print(f"Loaded citation graph:")
154
+ print(f" {len(references)} papers with outgoing references")
155
+ print(f" {len(cited_by)} papers with incoming citations")
156
+ print(f" {sum(len(v) for v in references.values())} total edges")
157
+
158
+ return dict(references), dict(cited_by), {}
159
+
160
+
161
+ def compute_co_citation_count(
162
+ query_id: str,
163
+ candidate_id: str,
164
+ cited_by: dict[str, set[str]],
165
+ ) -> int:
166
+ """Count papers that cite BOTH query and candidate."""
167
+ citing_query = cited_by.get(query_id, set())
168
+ citing_candidate = cited_by.get(candidate_id, set())
169
+ return len(citing_query & citing_candidate)
170
+
171
+
172
+ # ── Turso Metadata Fetching ─────────────────────────────────────────────────
173
+
174
+ async def fetch_turso_metadata_batch(
175
+ arxiv_ids: list[str],
176
+ turso_url: str,
177
+ turso_token: str,
178
+ ) -> dict[str, dict]:
179
+ """Fetch paper metadata from Turso DB."""
180
+ if not arxiv_ids:
181
+ return {}
182
+
183
+ pipeline_url = turso_url.rstrip("/")
184
+ if pipeline_url.startswith("libsql://"):
185
+ pipeline_url = "https://" + pipeline_url[len("libsql://"):]
186
+ elif not pipeline_url.startswith("https://"):
187
+ pipeline_url = "https://" + pipeline_url
188
+
189
+ placeholders = ", ".join(["?" for _ in arxiv_ids])
190
+ sql = f"""SELECT arxiv_id, title, authors, primary_topic, update_date,
191
+ citation_count, influential_citations
192
+ FROM papers WHERE arxiv_id IN ({placeholders})"""
193
+
194
+ args = [{"type": "text", "value": aid} for aid in arxiv_ids]
195
+
196
+ payload = {
197
+ "requests": [
198
+ {"type": "execute", "stmt": {"sql": sql, "args": args}},
199
+ {"type": "close"},
200
+ ]
201
+ }
202
+
203
+ headers = {
204
+ "Authorization": f"Bearer {turso_token}",
205
+ "Content-Type": "application/json",
206
+ }
207
+
208
+ async with httpx.AsyncClient(timeout=15) as client:
209
+ resp = await client.post(f"{pipeline_url}/v2/pipeline", json=payload, headers=headers)
210
+ resp.raise_for_status()
211
+
212
+ data = resp.json()
213
+ results = data.get("results", [])
214
+ if not results:
215
+ return {}
216
+
217
+ execute_result = results[0]
218
+ if execute_result.get("type") == "error":
219
+ print(f"[turso] Query error: {execute_result.get('error')}")
220
+ return {}
221
+
222
+ response = execute_result.get("response", {})
223
+ result_data = response.get("result", {})
224
+ cols = [c["name"] for c in result_data.get("cols", [])]
225
+ rows = result_data.get("rows", [])
226
+
227
+ output = {}
228
+ for row in rows:
229
+ values = {}
230
+ for i, col in enumerate(cols):
231
+ cell = row[i]
232
+ values[col] = None if cell.get("type") == "null" else cell.get("value", "")
233
+
234
+ arxiv_id = values.get("arxiv_id")
235
+ if not arxiv_id:
236
+ continue
237
+
238
+ # Parse citation counts
239
+ try:
240
+ citation_count = int(values.get("citation_count") or 0)
241
+ except (ValueError, TypeError):
242
+ citation_count = 0
243
+ try:
244
+ influential = int(values.get("influential_citations") or 0)
245
+ except (ValueError, TypeError):
246
+ influential = 0
247
+
248
+ # Parse authors
249
+ authors_raw = values.get("authors") or ""
250
+ if authors_raw.startswith("["):
251
+ try:
252
+ author_list = json.loads(authors_raw)
253
+ except json.JSONDecodeError:
254
+ author_list = [a.strip() for a in authors_raw.split(",") if a.strip()]
255
+ else:
256
+ author_list = [a.strip() for a in authors_raw.split(",") if a.strip()]
257
+
258
+ output[arxiv_id] = {
259
+ "arxiv_id": arxiv_id,
260
+ "primary_topic": values.get("primary_topic") or "",
261
+ "update_date": values.get("update_date") or "",
262
+ "citation_count": citation_count,
263
+ "influential_citations": influential,
264
+ "authors": author_list,
265
+ }
266
+
267
+ return output
268
+
269
+
270
+ # ── Feature Computation ──────────────────────────────────────────────────────
271
+
272
+ def compute_paper_age_days(published_str: str) -> int:
273
+ """Compute age in days from a YYYY-MM-DD date string."""
274
+ now = datetime.now(timezone.utc)
275
+ try:
276
+ pub_date = datetime.strptime(published_str[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
277
+ return max(0, (now - pub_date).days)
278
+ except (ValueError, TypeError):
279
+ return 365 # default 1 year
280
+
281
+
282
+ def parse_year(published_str: str) -> int:
283
+ """Extract year from YYYY-MM-DD string."""
284
+ try:
285
+ return int(published_str[:4])
286
+ except (ValueError, TypeError, IndexError):
287
+ return 2020 # default
288
+
289
+
290
+ def compute_shared_authors(authors_a: list[str], authors_b: list[str]) -> int:
291
+ """Count shared authors between two papers (case-insensitive)."""
292
+ set_a = {a.lower().strip() for a in authors_a if a.strip()}
293
+ set_b = {b.lower().strip() for b in authors_b if b.strip()}
294
+ return len(set_a & set_b)
295
+
296
+
297
+ def compute_features_for_pair(
298
+ query_meta: dict,
299
+ candidate_meta: dict,
300
+ qdrant_score: float,
301
+ candidate_position: int,
302
+ co_citation_count: int,
303
+ query_num_references: int,
304
+ candidate_num_cited_by: int,
305
+ ) -> np.ndarray:
306
+ """
307
+ Compute the full 37-feature vector for a (query, candidate) pair.
308
+
309
+ Features 20-30 (user behavior) are zero-filled for pseudo-label training.
310
+ """
311
+ features = np.zeros(NUM_FEATURES, dtype=np.float32)
312
+
313
+ # --- Content/Retrieval features (0-19) ---
314
+
315
+ # 0: qdrant_cosine_score
316
+ features[0] = qdrant_score
317
+
318
+ # 1: candidate_position
319
+ features[1] = float(candidate_position)
320
+
321
+ # 2: candidate_citation_count
322
+ cand_citations = candidate_meta.get("citation_count", 0)
323
+ features[2] = float(cand_citations)
324
+
325
+ # 3: candidate_log_citations
326
+ features[3] = np.log(cand_citations + 1)
327
+
328
+ # 4: candidate_influential_citations
329
+ features[4] = float(candidate_meta.get("influential_citations", 0))
330
+
331
+ # 5: candidate_age_days
332
+ cand_age = compute_paper_age_days(candidate_meta.get("update_date", ""))
333
+ features[5] = float(cand_age)
334
+
335
+ # 6: candidate_recency_score (matches heuristic in reranker.py)
336
+ features[6] = np.exp(-0.002 * cand_age)
337
+
338
+ # 7: query_citation_count
339
+ query_citations = query_meta.get("citation_count", 0)
340
+ features[7] = float(query_citations)
341
+
342
+ # 8: query_age_days
343
+ query_age = compute_paper_age_days(query_meta.get("update_date", ""))
344
+ features[8] = float(query_age)
345
+
346
+ # 9: year_diff
347
+ query_year = parse_year(query_meta.get("update_date", ""))
348
+ cand_year = parse_year(candidate_meta.get("update_date", ""))
349
+ features[9] = abs(query_year - cand_year)
350
+
351
+ # 10: same_primary_category
352
+ query_cat = query_meta.get("primary_topic", "")
353
+ cand_cat = candidate_meta.get("primary_topic", "")
354
+ features[10] = 1.0 if (query_cat and cand_cat and query_cat == cand_cat) else 0.0
355
+
356
+ # 11: co_citation_count
357
+ features[11] = float(co_citation_count)
358
+
359
+ # 12: shared_author_count
360
+ features[12] = float(compute_shared_authors(
361
+ query_meta.get("authors", []),
362
+ candidate_meta.get("authors", []),
363
+ ))
364
+
365
+ # 13: candidate_is_newer
366
+ features[13] = 1.0 if cand_year > query_year else 0.0
367
+
368
+ # 14: query_log_citations
369
+ features[14] = np.log(query_citations + 1)
370
+
371
+ # 15: citation_count_ratio
372
+ features[15] = cand_citations / (query_citations + 1)
373
+
374
+ # 16: age_ratio
375
+ features[16] = cand_age / (query_age + 1)
376
+
377
+ # 17: candidate_citations_per_year
378
+ cand_age_years = max(cand_age / 365.0, 0.5)
379
+ features[17] = cand_citations / cand_age_years
380
+
381
+ # 18: query_num_references
382
+ features[18] = float(query_num_references)
383
+
384
+ # 19: candidate_num_cited_by
385
+ features[19] = float(candidate_num_cited_by)
386
+
387
+ # --- User behavior features (20-30): zero-filled for pseudo-labels ---
388
+ # features[20] = ewma_longterm_similarity β†’ 0.0
389
+ # features[21] = ewma_shortterm_similarity β†’ 0.0
390
+ # features[22] = ewma_negative_similarity β†’ 0.0
391
+ # features[23] = cluster_importance β†’ 0.0
392
+ # features[24] = cluster_distance_to_medoid β†’ 0.0
393
+ # features[25] = is_suppressed_category β†’ 0.0
394
+ # features[26] = onboarding_category_match β†’ 0.0
395
+ # features[27] = user_total_saves β†’ 0.0
396
+ # features[28] = user_total_dismissals β†’ 0.0
397
+ # features[29] = user_days_since_last_save β†’ 0.0
398
+ # features[30] = user_session_save_count β†’ 0.0
399
+
400
+ # --- Cross features (31-36) ---
401
+
402
+ # 31: cosine_x_recency
403
+ features[31] = features[0] * features[6]
404
+
405
+ # 32: cosine_x_citations
406
+ features[32] = features[0] * features[3]
407
+
408
+ # 33: category_x_recency
409
+ features[33] = features[10] * features[6]
410
+
411
+ # 34: cosine_x_cocitation
412
+ features[34] = features[0] * np.log(co_citation_count + 1)
413
+
414
+ # 35: position_inverse
415
+ features[35] = 1.0 / (candidate_position + 1)
416
+
417
+ # 36: citations_x_recency
418
+ features[36] = features[3] * features[6]
419
+
420
+ return features
421
+
422
+
423
+ # ── Main Pipeline ────────────────────────────────────────────────────────────
424
+
425
+ async def generate_triples(
426
+ citations_path: str,
427
+ corpus_ids: list[str],
428
+ qdrant_url: str,
429
+ qdrant_api_key: str,
430
+ qdrant_collection: str,
431
+ turso_url: str,
432
+ turso_token: str,
433
+ output_dir: str,
434
+ num_queries: int,
435
+ candidates_per_query: int,
436
+ seed: int = 42,
437
+ ):
438
+ """Main pipeline: load graph β†’ sample queries β†’ ANN search β†’ compute features."""
439
+
440
+ output_path = Path(output_dir)
441
+ output_path.mkdir(parents=True, exist_ok=True)
442
+
443
+ # ── Step 1: Load citation graph ──────────────────────────────────────
444
+ print("=" * 60)
445
+ print("STEP 1: Loading citation graph...")
446
+ references, cited_by, _ = load_citation_graph(citations_path)
447
+
448
+ corpus_set = set(corpus_ids)
449
+ print(f"Corpus size: {len(corpus_set)}")
450
+
451
+ # Pre-compute per-paper stats
452
+ num_references = {pid: len(refs) for pid, refs in references.items()}
453
+ num_cited_by = {pid: len(citers) for pid, citers in cited_by.items()}
454
+
455
+ # ── Step 2: Connect to Qdrant ────────────────────────────────────────
456
+ print("\nSTEP 2: Connecting to Qdrant...")
457
+ qdrant = QdrantClient(url=qdrant_url, api_key=qdrant_api_key, timeout=30)
458
+ collection_info = qdrant.get_collection(qdrant_collection)
459
+ print(f" Collection: {qdrant_collection}")
460
+ print(f" Points: {collection_info.points_count}")
461
+
462
+ # ── Step 3: Sample query papers ──────────────────────────────────────
463
+ print("\nSTEP 3: Sampling query papers...")
464
+
465
+ # Only sample papers that have references (otherwise no positive labels)
466
+ papers_with_refs = [pid for pid in corpus_ids if pid in references and len(references[pid]) >= 3]
467
+ print(f" Papers with β‰₯3 in-corpus references: {len(papers_with_refs)}")
468
+
469
+ rng = random.Random(seed)
470
+ if len(papers_with_refs) > num_queries:
471
+ sampled_queries = rng.sample(papers_with_refs, num_queries)
472
+ else:
473
+ sampled_queries = papers_with_refs
474
+ print(f" Warning: only {len(sampled_queries)} papers have enough references")
475
+
476
+ print(f" Sampled {len(sampled_queries)} query papers")
477
+
478
+ # ── Step 4: Fetch metadata for all relevant papers ───────────────────
479
+ print("\nSTEP 4: Fetching metadata from Turso...")
480
+
481
+ # Collect all paper IDs we'll need metadata for
482
+ all_needed_ids = set(sampled_queries)
483
+ for qid in sampled_queries:
484
+ all_needed_ids.update(references.get(qid, set()))
485
+ # We'll also need metadata for ANN candidates, but we fetch those per-batch
486
+
487
+ # Fetch in batches of 500 (Turso limit)
488
+ metadata_cache: dict[str, dict] = {}
489
+ needed_list = list(all_needed_ids & corpus_set)
490
+ batch_size = 500
491
+ for i in tqdm(range(0, len(needed_list), batch_size), desc="Fetching metadata"):
492
+ batch = needed_list[i:i + batch_size]
493
+ try:
494
+ meta = await fetch_turso_metadata_batch(batch, turso_url, turso_token)
495
+ metadata_cache.update(meta)
496
+ except Exception as e:
497
+ print(f" Warning: metadata batch failed: {e}")
498
+ print(f" Cached metadata for {len(metadata_cache)} papers")
499
+
500
+ # ── Step 5: Time-split the queries ───────────────────────────────────
501
+ print(f"\nSTEP 5: Applying time-split (eval cutoff: {EVAL_CUTOFF})...")
502
+
503
+ train_queries = []
504
+ eval_queries = []
505
+ skipped = 0
506
+
507
+ for qid in sampled_queries:
508
+ meta = metadata_cache.get(qid)
509
+ if not meta:
510
+ skipped += 1
511
+ continue
512
+ pub_date = meta.get("update_date", "")
513
+ year = parse_year(pub_date)
514
+ if year < 2023:
515
+ train_queries.append(qid)
516
+ else:
517
+ eval_queries.append(qid)
518
+
519
+ print(f" Train queries (pre-2023): {len(train_queries)}")
520
+ print(f" Eval queries (2023+): {len(eval_queries)}")
521
+ print(f" Skipped (no metadata): {skipped}")
522
+
523
+ # Verify no temporal leakage
524
+ if train_queries and eval_queries:
525
+ max_train_year = max(parse_year(metadata_cache[q].get("update_date", "")) for q in train_queries if q in metadata_cache)
526
+ min_eval_year = min(parse_year(metadata_cache[q].get("update_date", "")) for q in eval_queries if q in metadata_cache)
527
+ print(f" Max train year: {max_train_year}")
528
+ print(f" Min eval year: {min_eval_year}")
529
+ assert max_train_year < min_eval_year, "TEMPORAL LEAKAGE DETECTED!"
530
+ print(f" βœ… No temporal leakage")
531
+
532
+ # ── Step 6: Generate triples ─────────────────────────────────────────
533
+ print("\nSTEP 6: Generating training triples...")
534
+
535
+ for split_name, query_ids in [("train", train_queries), ("eval", eval_queries)]:
536
+ if not query_ids:
537
+ print(f" Skipping {split_name} β€” no queries")
538
+ continue
539
+
540
+ print(f"\n Processing {split_name} split ({len(query_ids)} queries)...")
541
+
542
+ all_query_ids = []
543
+ all_candidate_ids = []
544
+ all_labels = []
545
+ all_features = []
546
+
547
+ for qi, qid in enumerate(tqdm(query_ids, desc=f" {split_name}")):
548
+ query_meta = metadata_cache.get(qid, {})
549
+ query_refs = references.get(qid, set())
550
+
551
+ # Build co-cited set: papers that share references with query
552
+ co_cited = set()
553
+ for ref_id in query_refs:
554
+ co_cited.update(references.get(ref_id, set()))
555
+ co_cited -= query_refs # exclude direct citations
556
+ co_cited.discard(qid) # exclude self
557
+
558
+ # ANN search from Qdrant
559
+ try:
560
+ # Look up query paper by arxiv_id payload field
561
+ # retrieve() takes point IDs (integers), not payload values.
562
+ # Use scroll() with a FieldCondition filter to find by arxiv_id.
563
+ scroll_results, _ = qdrant.scroll(
564
+ collection_name=qdrant_collection,
565
+ scroll_filter=Filter(
566
+ must=[FieldCondition(key="arxiv_id", match=MatchValue(value=qid))]
567
+ ),
568
+ limit=1,
569
+ with_vectors=True,
570
+ with_payload=True,
571
+ )
572
+ if not scroll_results:
573
+ continue
574
+
575
+ query_vector = scroll_results[0].vector
576
+ if query_vector is None:
577
+ continue
578
+
579
+ # ANN search using the query paper's embedding
580
+ results = qdrant.query_points(
581
+ collection_name=qdrant_collection,
582
+ query=query_vector,
583
+ limit=candidates_per_query,
584
+ with_payload=True,
585
+ )
586
+
587
+ candidates = []
588
+ for hit in results.points:
589
+ cand_id = hit.payload.get("arxiv_id") if hit.payload else None
590
+ if cand_id and cand_id != qid and cand_id in corpus_set:
591
+ candidates.append((cand_id, hit.score))
592
+
593
+ except Exception as e:
594
+ if qi < 3: # Only print first few errors
595
+ print(f" Warning: Qdrant query failed for {qid}: {e}")
596
+ continue
597
+
598
+ if not candidates:
599
+ continue
600
+
601
+ # Fetch metadata for candidates not yet cached
602
+ uncached = [cid for cid, _ in candidates if cid not in metadata_cache]
603
+ if uncached:
604
+ try:
605
+ meta_batch = await fetch_turso_metadata_batch(
606
+ uncached[:500], turso_url, turso_token
607
+ )
608
+ metadata_cache.update(meta_batch)
609
+ except Exception:
610
+ pass
611
+
612
+ # Compute features and labels for each candidate
613
+ for pos, (cand_id, qdrant_score) in enumerate(candidates):
614
+ cand_meta = metadata_cache.get(cand_id, {})
615
+
616
+ # Label assignment
617
+ if cand_id in query_refs:
618
+ label = 2 # direct citation
619
+ elif cand_id in co_cited:
620
+ label = 1 # co-cited
621
+ else:
622
+ label = 0 # not cited
623
+
624
+ # Co-citation count
625
+ cocite_count = compute_co_citation_count(qid, cand_id, cited_by)
626
+
627
+ # Feature vector
628
+ feat = compute_features_for_pair(
629
+ query_meta=query_meta,
630
+ candidate_meta=cand_meta,
631
+ qdrant_score=qdrant_score,
632
+ candidate_position=pos,
633
+ co_citation_count=cocite_count,
634
+ query_num_references=num_references.get(qid, 0),
635
+ candidate_num_cited_by=num_cited_by.get(cand_id, 0),
636
+ )
637
+
638
+ all_query_ids.append(qid)
639
+ all_candidate_ids.append(cand_id)
640
+ all_labels.append(label)
641
+ all_features.append(feat)
642
+
643
+ # ── Save to parquet ──────────────────────────────────────────────
644
+ if not all_features:
645
+ print(f" No data for {split_name} split!")
646
+ continue
647
+
648
+ feature_matrix = np.array(all_features, dtype=np.float32)
649
+
650
+ # Build parquet table
651
+ columns = {
652
+ "query_arxiv_id": pa.array(all_query_ids, type=pa.string()),
653
+ "candidate_arxiv_id": pa.array(all_candidate_ids, type=pa.string()),
654
+ "label": pa.array(all_labels, type=pa.int32()),
655
+ }
656
+
657
+ # Add each feature as a named column
658
+ for fi, fname in enumerate(FEATURE_SCHEMA):
659
+ columns[fname] = pa.array(feature_matrix[:, fi].tolist(), type=pa.float32())
660
+
661
+ # Add group_size info (candidates per query, needed for LightGBM)
662
+ # We track this separately
663
+ table = pa.table(columns)
664
+
665
+ out_file = output_path / f"{split_name}.parquet"
666
+ pq.write_table(table, str(out_file), compression="snappy")
667
+
668
+ # Print stats
669
+ label_counts = {0: 0, 1: 0, 2: 0}
670
+ for l in all_labels:
671
+ label_counts[l] = label_counts.get(l, 0) + 1
672
+
673
+ num_queries_actual = len(set(all_query_ids))
674
+ print(f"\n {split_name} split saved to {out_file}")
675
+ print(f" Rows: {len(all_labels)}")
676
+ print(f" Queries: {num_queries_actual}")
677
+ print(f" Avg candidates/query: {len(all_labels) / max(num_queries_actual, 1):.1f}")
678
+ print(f" Labels: 0={label_counts[0]}, 1={label_counts[1]}, 2={label_counts[2]}")
679
+ print(f" Label 2 rate: {100*label_counts[2]/max(len(all_labels),1):.2f}%")
680
+ print(f" Label 1 rate: {100*label_counts[1]/max(len(all_labels),1):.2f}%")
681
+ print(f" Features: {NUM_FEATURES}")
682
+
683
+ # ── Save feature schema ──────────────────────────────────────────────
684
+ schema_file = output_path / "feature_schema.json"
685
+ with open(schema_file, "w") as f:
686
+ json.dump({
687
+ "features": FEATURE_SCHEMA,
688
+ "num_features": NUM_FEATURES,
689
+ "pseudo_label_features": list(range(0, 20)) + list(range(31, 37)),
690
+ "user_features_zero_filled": list(range(20, 31)),
691
+ "eval_cutoff": EVAL_CUTOFF,
692
+ "description": "37-feature schema for ResearchIT LightGBM reranker. "
693
+ "Features 20-30 are zero-filled during pseudo-label training "
694
+ "and will be populated when real user data is available.",
695
+ }, f, indent=2)
696
+ print(f"\nFeature schema saved to {schema_file}")
697
+
698
+
699
+ # ── CLI ──────────────────────────────────────────────────────────────────────
700
+
701
+ def main():
702
+ parser = argparse.ArgumentParser(
703
+ description="Generate LightGBM training triples from citation graph"
704
+ )
705
+ parser.add_argument("--citations", required=True, help="citations.parquet from Step 1")
706
+ parser.add_argument("--corpus-file", required=True, help="Text file with arXiv IDs")
707
+ parser.add_argument("--qdrant-url", required=True)
708
+ parser.add_argument("--qdrant-api-key", required=True)
709
+ parser.add_argument("--qdrant-collection", default="arxiv_bgem3_dense")
710
+ parser.add_argument("--turso-url", required=True)
711
+ parser.add_argument("--turso-token", required=True)
712
+ parser.add_argument("--output-dir", default="./ltr_dataset")
713
+ parser.add_argument("--num-queries", type=int, default=100000)
714
+ parser.add_argument("--candidates-per-query", type=int, default=50)
715
+ parser.add_argument("--seed", type=int, default=42)
716
+
717
+ args = parser.parse_args()
718
+
719
+ # Load corpus IDs
720
+ corpus_ids = []
721
+ with open(args.corpus_file) as f:
722
+ for line in f:
723
+ line = line.strip()
724
+ if line and not line.startswith("#"):
725
+ if line.startswith("arXiv:"):
726
+ line = line[6:]
727
+ corpus_ids.append(line)
728
+ print(f"Loaded {len(corpus_ids)} corpus IDs")
729
+
730
+ asyncio.run(generate_triples(
731
+ citations_path=args.citations,
732
+ corpus_ids=corpus_ids,
733
+ qdrant_url=args.qdrant_url,
734
+ qdrant_api_key=args.qdrant_api_key,
735
+ qdrant_collection=args.qdrant_collection,
736
+ turso_url=args.turso_url,
737
+ turso_token=args.turso_token,
738
+ output_dir=args.output_dir,
739
+ num_queries=args.num_queries,
740
+ candidates_per_query=args.candidates_per_query,
741
+ seed=args.seed,
742
+ ))
743
+
744
+ print("\nβœ… Done! Training triples generated.")
745
+
746
+
747
+ if __name__ == "__main__":
748
+ main()