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()
|