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

Add 03_train_lightgbm.py

Browse files
Files changed (1) hide show
  1. scripts/03_train_lightgbm.py +568 -0
scripts/03_train_lightgbm.py ADDED
@@ -0,0 +1,568 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Step 3: Train LightGBM lambdarank reranker + compare against heuristic baseline.
3
+
4
+ Produces:
5
+ - reranker_v1.txt β€” trained LightGBM model (~100KB)
6
+ - eval_metrics.json β€” nDCG@10, Recall@50, label distribution, feature importance
7
+ - feature_importance.csv β€” ranked feature importance
8
+ - baseline_comparison.json β€” LightGBM vs heuristic scorer on same eval set
9
+
10
+ Usage:
11
+ python 03_train_lightgbm.py \
12
+ --train-file ltr_dataset/train.parquet \
13
+ --eval-file ltr_dataset/eval.parquet \
14
+ --output-dir ./model_output \
15
+ --num-boost-round 500 \
16
+ --learning-rate 0.05
17
+
18
+ Prerequisites:
19
+ - train.parquet + eval.parquet from Step 2
20
+ - pip install lightgbm pyarrow numpy
21
+
22
+ The heuristic baseline replicates the EXACT scoring logic from
23
+ app/recommend/reranker.py β†’ heuristic_score():
24
+ score = 0.40 Γ— lt_sim + 0.25 Γ— st_sim + 0.15 Γ— recency
25
+ + 0.10 Γ— rrf_conf - 0.15 Γ— neg_penalty
26
+
27
+ Since pseudo-label training has no user profiles (features 20-30 = 0),
28
+ the heuristic baseline for pseudo-labels simplifies to:
29
+ score = 0.15 Γ— recency + 0.10 Γ— (1 - position/max_position)
30
+
31
+ This is the fair baseline: both models see the same zero-filled user features.
32
+
33
+ Author: ResearchIT ML Pipeline β€” Phase 6, Step 3
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import time
41
+ from collections import defaultdict
42
+ from pathlib import Path
43
+
44
+ import lightgbm as lgb
45
+ import numpy as np
46
+ import pyarrow.parquet as pq
47
+
48
+
49
+ # ── Feature schema (must match Step 2) ───────────────────────────────────────
50
+
51
+ FEATURE_SCHEMA = [
52
+ "qdrant_cosine_score", "candidate_position", "candidate_citation_count",
53
+ "candidate_log_citations", "candidate_influential_citations",
54
+ "candidate_age_days", "candidate_recency_score", "query_citation_count",
55
+ "query_age_days", "year_diff", "same_primary_category", "co_citation_count",
56
+ "shared_author_count", "candidate_is_newer", "query_log_citations",
57
+ "citation_count_ratio", "age_ratio", "candidate_citations_per_year",
58
+ "query_num_references", "candidate_num_cited_by",
59
+ "ewma_longterm_similarity", "ewma_shortterm_similarity",
60
+ "ewma_negative_similarity", "cluster_importance",
61
+ "cluster_distance_to_medoid", "is_suppressed_category",
62
+ "onboarding_category_match", "user_total_saves", "user_total_dismissals",
63
+ "user_days_since_last_save", "user_session_save_count",
64
+ "cosine_x_recency", "cosine_x_citations", "category_x_recency",
65
+ "cosine_x_cocitation", "position_inverse", "citations_x_recency",
66
+ ]
67
+
68
+ NUM_FEATURES = 37
69
+
70
+
71
+ # ── Data Loading ─────────────────────────────────────────────────────────────
72
+
73
+ def load_ltr_data(parquet_path: str) -> tuple[np.ndarray, np.ndarray, list[int], list[str]]:
74
+ """
75
+ Load a parquet file into LightGBM-ready format.
76
+
77
+ Returns:
78
+ features: (N, 37) float32 matrix
79
+ labels: (N,) int32 array (0, 1, or 2)
80
+ groups: list of group sizes (candidates per query)
81
+ query_ids: list of query arXiv IDs (one per row, for analysis)
82
+ """
83
+ table = pq.read_table(parquet_path)
84
+
85
+ query_ids = table.column("query_arxiv_id").to_pylist()
86
+ labels = np.array(table.column("label").to_pylist(), dtype=np.int32)
87
+
88
+ # Extract feature columns
89
+ feature_arrays = []
90
+ for fname in FEATURE_SCHEMA:
91
+ col = table.column(fname).to_pylist()
92
+ feature_arrays.append(col)
93
+ features = np.column_stack(feature_arrays).astype(np.float32)
94
+
95
+ # Compute group sizes (number of candidates per query)
96
+ groups = []
97
+ current_qid = None
98
+ current_count = 0
99
+ for qid in query_ids:
100
+ if qid != current_qid:
101
+ if current_qid is not None:
102
+ groups.append(current_count)
103
+ current_qid = qid
104
+ current_count = 1
105
+ else:
106
+ current_count += 1
107
+ if current_count > 0:
108
+ groups.append(current_count)
109
+
110
+ # Verify consistency
111
+ assert sum(groups) == len(labels), f"Group sum {sum(groups)} != {len(labels)} rows"
112
+ assert features.shape == (len(labels), NUM_FEATURES), f"Feature shape mismatch"
113
+
114
+ return features, labels, groups, query_ids
115
+
116
+
117
+ # ── Heuristic Baseline ──────────────────────────────────────────────────────
118
+
119
+ def heuristic_baseline_score(features: np.ndarray) -> np.ndarray:
120
+ """
121
+ Replicate the EXACT scoring logic from app/recommend/reranker.py.
122
+
123
+ heuristic_score():
124
+ lt_sim = features[:, 0] β†’ here: ewma_longterm_similarity (col 20) = 0
125
+ st_sim = features[:, 1] β†’ here: ewma_shortterm_similarity (col 21) = 0
126
+ age_days = features[:, 2] β†’ here: candidate_age_days (col 5)
127
+ rrf_pos = features[:, 3] β†’ here: candidate_position (col 1)
128
+ neg_sim = features[:, 4] β†’ here: ewma_negative_similarity (col 22) = 0
129
+
130
+ For pseudo-label data, EWMA features are 0, so score simplifies to:
131
+ score = 0.15 Γ— exp(-0.002 Γ— age_days) + 0.10 Γ— (1 - pos/max_pos)
132
+
133
+ But we also include the cosine score (col 0) since that's what the
134
+ reranker would actually see in production (it's feature 0 = lt_sim proxy).
135
+ In the real pipeline, lt_sim IS the cosine similarity to the long-term
136
+ profile β€” for pseudo-labels, the closest proxy is qdrant_cosine_score.
137
+
138
+ So the fair pseudo-label heuristic baseline is:
139
+ score = 0.40 Γ— qdrant_cosine_score (proxy for lt_sim)
140
+ + 0.15 Γ— recency_decay
141
+ + 0.10 Γ— rrf_confidence
142
+ """
143
+ qdrant_cosine = features[:, 0] # qdrant_cosine_score
144
+ position = features[:, 1] # candidate_position
145
+ age_days = features[:, 5] # candidate_age_days
146
+
147
+ # Recency: exp(-0.002 * age_days) β€” matches reranker.py exactly
148
+ recency = np.exp(-0.002 * age_days)
149
+
150
+ # RRF confidence: inverse of position (normalised)
151
+ max_pos = position.max() + 1
152
+ rrf_conf = 1.0 - (position / max_pos)
153
+
154
+ scores = (
155
+ 0.40 * qdrant_cosine
156
+ + 0.15 * recency
157
+ + 0.10 * rrf_conf
158
+ )
159
+ return scores
160
+
161
+
162
+ # ── Evaluation Metrics ───────────────────────────────────────────────────────
163
+
164
+ def ndcg_at_k(labels: np.ndarray, scores: np.ndarray, groups: list[int], k: int = 10) -> float:
165
+ """Compute mean nDCG@k across all queries."""
166
+ ndcg_scores = []
167
+ offset = 0
168
+ for group_size in groups:
169
+ group_labels = labels[offset:offset + group_size]
170
+ group_scores = scores[offset:offset + group_size]
171
+
172
+ # Sort by predicted score descending
173
+ order = np.argsort(-group_scores)
174
+ sorted_labels = group_labels[order]
175
+
176
+ # DCG@k
177
+ top_k = sorted_labels[:k]
178
+ gains = (2.0 ** top_k) - 1.0
179
+ discounts = np.log2(np.arange(len(top_k)) + 2.0)
180
+ dcg = np.sum(gains / discounts)
181
+
182
+ # Ideal DCG@k
183
+ ideal_order = np.argsort(-group_labels)
184
+ ideal_labels = group_labels[ideal_order][:k]
185
+ ideal_gains = (2.0 ** ideal_labels) - 1.0
186
+ ideal_discounts = np.log2(np.arange(len(ideal_labels)) + 2.0)
187
+ idcg = np.sum(ideal_gains / ideal_discounts)
188
+
189
+ if idcg > 0:
190
+ ndcg_scores.append(dcg / idcg)
191
+ # Skip queries with all-zero labels (no positives)
192
+
193
+ offset += group_size
194
+
195
+ return float(np.mean(ndcg_scores)) if ndcg_scores else 0.0
196
+
197
+
198
+ def recall_at_k(labels: np.ndarray, scores: np.ndarray, groups: list[int], k: int = 50) -> float:
199
+ """Compute mean Recall@k (fraction of positives in top-k) across all queries."""
200
+ recalls = []
201
+ offset = 0
202
+ for group_size in groups:
203
+ group_labels = labels[offset:offset + group_size]
204
+ group_scores = scores[offset:offset + group_size]
205
+
206
+ total_positives = np.sum(group_labels > 0)
207
+ if total_positives == 0:
208
+ offset += group_size
209
+ continue
210
+
211
+ order = np.argsort(-group_scores)
212
+ sorted_labels = group_labels[order]
213
+ top_k_positives = np.sum(sorted_labels[:k] > 0)
214
+ recalls.append(top_k_positives / total_positives)
215
+
216
+ offset += group_size
217
+
218
+ return float(np.mean(recalls)) if recalls else 0.0
219
+
220
+
221
+ def hit_rate_at_k(labels: np.ndarray, scores: np.ndarray, groups: list[int], k: int = 10) -> float:
222
+ """Compute HR@k: fraction of queries where at least one positive is in top-k."""
223
+ hits = 0
224
+ total = 0
225
+ offset = 0
226
+ for group_size in groups:
227
+ group_labels = labels[offset:offset + group_size]
228
+ group_scores = scores[offset:offset + group_size]
229
+
230
+ if np.sum(group_labels > 0) == 0:
231
+ offset += group_size
232
+ continue
233
+
234
+ order = np.argsort(-group_scores)
235
+ sorted_labels = group_labels[order]
236
+ if np.any(sorted_labels[:k] > 0):
237
+ hits += 1
238
+ total += 1
239
+ offset += group_size
240
+
241
+ return hits / total if total > 0 else 0.0
242
+
243
+
244
+ def mean_reciprocal_rank(labels: np.ndarray, scores: np.ndarray, groups: list[int]) -> float:
245
+ """Compute MRR: average of 1/rank of the first positive result."""
246
+ rr_scores = []
247
+ offset = 0
248
+ for group_size in groups:
249
+ group_labels = labels[offset:offset + group_size]
250
+ group_scores = scores[offset:offset + group_size]
251
+
252
+ if np.sum(group_labels > 0) == 0:
253
+ offset += group_size
254
+ continue
255
+
256
+ order = np.argsort(-group_scores)
257
+ sorted_labels = group_labels[order]
258
+ for rank, l in enumerate(sorted_labels, 1):
259
+ if l > 0:
260
+ rr_scores.append(1.0 / rank)
261
+ break
262
+
263
+ offset += group_size
264
+
265
+ return float(np.mean(rr_scores)) if rr_scores else 0.0
266
+
267
+
268
+ def evaluate_model(
269
+ name: str,
270
+ labels: np.ndarray,
271
+ scores: np.ndarray,
272
+ groups: list[int],
273
+ ) -> dict:
274
+ """Run all eval metrics and return as dict."""
275
+ metrics = {
276
+ "model": name,
277
+ "ndcg@5": ndcg_at_k(labels, scores, groups, k=5),
278
+ "ndcg@10": ndcg_at_k(labels, scores, groups, k=10),
279
+ "ndcg@20": ndcg_at_k(labels, scores, groups, k=20),
280
+ "recall@10": recall_at_k(labels, scores, groups, k=10),
281
+ "recall@50": recall_at_k(labels, scores, groups, k=50),
282
+ "hr@10": hit_rate_at_k(labels, scores, groups, k=10),
283
+ "mrr": mean_reciprocal_rank(labels, scores, groups),
284
+ }
285
+ return metrics
286
+
287
+
288
+ # ── Main Training Pipeline ───────────────────────────────────────────────────
289
+
290
+ def main():
291
+ parser = argparse.ArgumentParser(
292
+ description="Train LightGBM lambdarank reranker for ResearchIT"
293
+ )
294
+ parser.add_argument("--train-file", required=True, help="train.parquet from Step 2")
295
+ parser.add_argument("--eval-file", required=True, help="eval.parquet from Step 2")
296
+ parser.add_argument("--output-dir", default="./model_output")
297
+ parser.add_argument("--num-boost-round", type=int, default=500)
298
+ parser.add_argument("--learning-rate", type=float, default=0.05)
299
+ parser.add_argument("--num-leaves", type=int, default=63)
300
+ parser.add_argument("--min-data-in-leaf", type=int, default=50)
301
+ parser.add_argument("--feature-fraction", type=float, default=0.8)
302
+ parser.add_argument("--early-stopping-rounds", type=int, default=50)
303
+
304
+ args = parser.parse_args()
305
+
306
+ output_dir = Path(args.output_dir)
307
+ output_dir.mkdir(parents=True, exist_ok=True)
308
+
309
+ # ── Load data ────────────────────────────────────────────────────────
310
+ print("=" * 60)
311
+ print("Loading training data...")
312
+ train_features, train_labels, train_groups, train_qids = load_ltr_data(args.train_file)
313
+ print(f" Train: {len(train_labels)} rows, {len(train_groups)} queries")
314
+ print(f" Label distribution: 0={np.sum(train_labels==0)}, 1={np.sum(train_labels==1)}, 2={np.sum(train_labels==2)}")
315
+
316
+ print("\nLoading eval data...")
317
+ eval_features, eval_labels, eval_groups, eval_qids = load_ltr_data(args.eval_file)
318
+ print(f" Eval: {len(eval_labels)} rows, {len(eval_groups)} queries")
319
+ print(f" Label distribution: 0={np.sum(eval_labels==0)}, 1={np.sum(eval_labels==1)}, 2={np.sum(eval_labels==2)}")
320
+
321
+ # Verify time split: no overlap between train and eval query IDs
322
+ train_query_set = set(train_qids)
323
+ eval_query_set = set(eval_qids)
324
+ overlap = train_query_set & eval_query_set
325
+ if overlap:
326
+ print(f" WARNING: {len(overlap)} query IDs appear in both splits!")
327
+ else:
328
+ print(f" βœ… No query overlap between train/eval splits")
329
+
330
+ # ── Baseline: heuristic scorer ───────────────────────────────────────
331
+ print("\n" + "=" * 60)
332
+ print("Evaluating heuristic baseline...")
333
+
334
+ baseline_scores = heuristic_baseline_score(eval_features)
335
+ baseline_metrics = evaluate_model("heuristic_baseline", eval_labels, baseline_scores, eval_groups)
336
+
337
+ print(f"\n Heuristic Baseline Results:")
338
+ for k, v in baseline_metrics.items():
339
+ if k != "model":
340
+ print(f" {k}: {v:.4f}")
341
+
342
+ # ── Train LightGBM ───────────────────────────────────────────────────
343
+ print("\n" + "=" * 60)
344
+ print("Training LightGBM lambdarank...")
345
+
346
+ train_dataset = lgb.Dataset(
347
+ train_features,
348
+ label=train_labels,
349
+ group=train_groups,
350
+ feature_name=FEATURE_SCHEMA,
351
+ free_raw_data=False,
352
+ )
353
+
354
+ eval_dataset = lgb.Dataset(
355
+ eval_features,
356
+ label=eval_labels,
357
+ group=eval_groups,
358
+ feature_name=FEATURE_SCHEMA,
359
+ reference=train_dataset,
360
+ free_raw_data=False,
361
+ )
362
+
363
+ params = {
364
+ "objective": "lambdarank",
365
+ "metric": "ndcg",
366
+ "eval_at": [5, 10, 20],
367
+ "num_leaves": args.num_leaves,
368
+ "learning_rate": args.learning_rate,
369
+ "min_data_in_leaf": args.min_data_in_leaf,
370
+ "feature_fraction": args.feature_fraction,
371
+ "bagging_fraction": 0.8,
372
+ "bagging_freq": 5,
373
+ "lambdarank_truncation_level": 20,
374
+ "verbose": 1,
375
+ "seed": 42,
376
+ "num_threads": os.cpu_count() or 4,
377
+ }
378
+
379
+ print(f"\n Parameters:")
380
+ for k, v in params.items():
381
+ print(f" {k}: {v}")
382
+
383
+ callbacks = [
384
+ lgb.log_evaluation(period=50),
385
+ lgb.early_stopping(stopping_rounds=args.early_stopping_rounds),
386
+ ]
387
+
388
+ t0 = time.time()
389
+ model = lgb.train(
390
+ params,
391
+ train_dataset,
392
+ num_boost_round=args.num_boost_round,
393
+ valid_sets=[eval_dataset],
394
+ valid_names=["eval"],
395
+ callbacks=callbacks,
396
+ )
397
+ train_time = time.time() - t0
398
+
399
+ print(f"\n Training completed in {train_time:.1f}s")
400
+ print(f" Best iteration: {model.best_iteration}")
401
+ print(f" Best nDCG@10: {model.best_score.get('eval', {}).get('ndcg@10', 'N/A')}")
402
+
403
+ # ── Evaluate LightGBM ────────────────────────────────────────────────
404
+ print("\n" + "=" * 60)
405
+ print("Evaluating LightGBM on eval set...")
406
+
407
+ lgb_scores = model.predict(eval_features)
408
+ lgb_metrics = evaluate_model("lightgbm_lambdarank", eval_labels, lgb_scores, eval_groups)
409
+
410
+ print(f"\n LightGBM Results:")
411
+ for k, v in lgb_metrics.items():
412
+ if k != "model":
413
+ print(f" {k}: {v:.4f}")
414
+
415
+ # ── Comparison ───────────────────────────────────────────────────────
416
+ print("\n" + "=" * 60)
417
+ print("COMPARISON: LightGBM vs Heuristic Baseline")
418
+ print("-" * 50)
419
+ print(f" {'Metric':<15} {'Heuristic':>12} {'LightGBM':>12} {'Ξ”':>10} {'%Ξ”':>8}")
420
+ print("-" * 50)
421
+
422
+ comparison = {}
423
+ for metric_key in ["ndcg@5", "ndcg@10", "ndcg@20", "recall@10", "recall@50", "hr@10", "mrr"]:
424
+ b = baseline_metrics[metric_key]
425
+ l = lgb_metrics[metric_key]
426
+ delta = l - b
427
+ pct = (delta / b * 100) if b > 0 else float('inf')
428
+ comparison[metric_key] = {
429
+ "heuristic": round(b, 4),
430
+ "lightgbm": round(l, 4),
431
+ "delta": round(delta, 4),
432
+ "pct_improvement": round(pct, 2),
433
+ }
434
+ marker = "βœ…" if delta > 0 else "⚠️" if delta == 0 else "❌"
435
+ print(f" {metric_key:<15} {b:>12.4f} {l:>12.4f} {delta:>+10.4f} {pct:>+7.1f}% {marker}")
436
+
437
+ print("-" * 50)
438
+
439
+ # ── Feature Importance ───────────────────────────────────────────────
440
+ print("\n" + "=" * 60)
441
+ print("Feature Importance (top 20):")
442
+
443
+ importance = model.feature_importance(importance_type="gain")
444
+ importance_pairs = sorted(
445
+ zip(FEATURE_SCHEMA, importance),
446
+ key=lambda x: x[1],
447
+ reverse=True,
448
+ )
449
+
450
+ print(f" {'Rank':<6} {'Feature':<35} {'Importance':>12}")
451
+ print("-" * 55)
452
+ for rank, (fname, imp) in enumerate(importance_pairs[:20], 1):
453
+ bar = "β–ˆ" * int(imp / max(importance) * 30) if max(importance) > 0 else ""
454
+ print(f" {rank:<6} {fname:<35} {imp:>12.1f} {bar}")
455
+
456
+ # Zero-importance features (expected: user behavior features 20-30)
457
+ zero_features = [fname for fname, imp in importance_pairs if imp == 0]
458
+ if zero_features:
459
+ print(f"\n Zero-importance features ({len(zero_features)}):")
460
+ for fname in zero_features:
461
+ print(f" - {fname}")
462
+
463
+ # ── Inference latency benchmark ──────────────────────────────────────
464
+ print("\n" + "=" * 60)
465
+ print("Inference Latency Benchmark:")
466
+
467
+ # Simulate production: 100 candidates per query
468
+ test_batch = eval_features[:100] if len(eval_features) >= 100 else eval_features
469
+
470
+ # Warm up
471
+ for _ in range(10):
472
+ model.predict(test_batch)
473
+
474
+ # Benchmark
475
+ n_iters = 1000
476
+ t0 = time.time()
477
+ for _ in range(n_iters):
478
+ model.predict(test_batch)
479
+ total_ms = (time.time() - t0) * 1000
480
+ per_call_ms = total_ms / n_iters
481
+
482
+ print(f" {len(test_batch)} candidates Γ— {n_iters} iterations")
483
+ print(f" Total: {total_ms:.1f}ms")
484
+ print(f" Per call: {per_call_ms:.3f}ms")
485
+ print(f" Target: <1ms for 100 candidates β†’ {'βœ… PASS' if per_call_ms < 1.0 else '⚠️ SLOW'}")
486
+
487
+ # ── Save outputs ─────────────────────────────────────────────────────
488
+ print("\n" + "=" * 60)
489
+ print("Saving outputs...")
490
+
491
+ # Model
492
+ model_path = output_dir / "reranker_v1.txt"
493
+ model.save_model(str(model_path))
494
+ model_size_kb = os.path.getsize(model_path) / 1024
495
+ print(f" Model: {model_path} ({model_size_kb:.1f} KB)")
496
+
497
+ # Eval metrics
498
+ metrics_path = output_dir / "eval_metrics.json"
499
+ with open(metrics_path, "w") as f:
500
+ json.dump({
501
+ "baseline": baseline_metrics,
502
+ "lightgbm": lgb_metrics,
503
+ "comparison": comparison,
504
+ "training": {
505
+ "num_boost_round": args.num_boost_round,
506
+ "best_iteration": model.best_iteration,
507
+ "training_time_seconds": round(train_time, 1),
508
+ "train_rows": len(train_labels),
509
+ "train_queries": len(train_groups),
510
+ "eval_rows": len(eval_labels),
511
+ "eval_queries": len(eval_groups),
512
+ "params": params,
513
+ },
514
+ "latency": {
515
+ "candidates": len(test_batch),
516
+ "per_call_ms": round(per_call_ms, 3),
517
+ "target_ms": 1.0,
518
+ "pass": per_call_ms < 1.0,
519
+ },
520
+ "feature_importance": [
521
+ {"feature": fname, "importance": float(imp)}
522
+ for fname, imp in importance_pairs
523
+ ],
524
+ }, f, indent=2)
525
+ print(f" Metrics: {metrics_path}")
526
+
527
+ # Feature importance CSV
528
+ fi_path = output_dir / "feature_importance.csv"
529
+ with open(fi_path, "w") as f:
530
+ f.write("rank,feature,importance\n")
531
+ for rank, (fname, imp) in enumerate(importance_pairs, 1):
532
+ f.write(f"{rank},{fname},{imp}\n")
533
+ print(f" Feature importance: {fi_path}")
534
+
535
+ # Baseline comparison
536
+ comp_path = output_dir / "baseline_comparison.json"
537
+ with open(comp_path, "w") as f:
538
+ json.dump(comparison, f, indent=2)
539
+ print(f" Comparison: {comp_path}")
540
+
541
+ # ── Summary ──────────────────────────────────────────────────────────
542
+ print("\n" + "=" * 60)
543
+ primary_metric = "ndcg@10"
544
+ b = baseline_metrics[primary_metric]
545
+ l = lgb_metrics[primary_metric]
546
+ delta = l - b
547
+ pct = (delta / b * 100) if b > 0 else 0
548
+
549
+ if delta > 0.03:
550
+ verdict = "βœ… STRONG IMPROVEMENT β€” deploy LightGBM"
551
+ elif delta > 0:
552
+ verdict = "⚠️ MARGINAL IMPROVEMENT β€” consider if complexity is worth it"
553
+ else:
554
+ verdict = "❌ NO IMPROVEMENT β€” keep heuristic, investigate features"
555
+
556
+ print(f"PRIMARY METRIC: nDCG@10")
557
+ print(f" Heuristic: {b:.4f}")
558
+ print(f" LightGBM: {l:.4f} ({delta:+.4f}, {pct:+.1f}%)")
559
+ print(f" Verdict: {verdict}")
560
+ print(f"\nModel file: {model_path}")
561
+ print(f"Model size: {model_size_kb:.1f} KB")
562
+ print(f"Latency: {per_call_ms:.3f}ms per 100 candidates")
563
+
564
+ print("\nβœ… Done!")
565
+
566
+
567
+ if __name__ == "__main__":
568
+ main()