| """ |
| kfold_cv.py β Group-stratified k-fold cross-validation for the gadget ranker. |
| |
| Each fold holds out a disjoint set of binary groups (so no binary appears in |
| both train and val). Reports NDCG@1/3/5/10 per fold and averaged. |
| |
| Usage |
| ----- |
| python -m lcsajdump.ml.kfold_cv \\ |
| --csv /tmp/gadget_dataset_v3.csv \\ |
| --k 5 \\ |
| --out /tmp/kfold_results.json |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import sys |
| from collections import defaultdict |
|
|
| import numpy as np |
|
|
| try: |
| import lightgbm as lgb |
| import pandas as pd |
| from sklearn.metrics import ndcg_score as _ndcg |
| except ImportError as e: |
| print(f"Missing dependency: {e}", file=sys.stderr) |
| sys.exit(1) |
|
|
| from lcsajdump.ml.features import FEATURE_NAMES |
| from lcsajdump.ml_study.train_model import ( |
| DEFAULT_PARAMS, |
| _fix_degenerate_groups, |
| _cap_group_sizes, |
| ) |
|
|
| |
|
|
|
|
| def run_kfold(df: pd.DataFrame, k: int = 5, params: dict = None) -> dict: |
| """ |
| Group k-fold cross-validation. |
| |
| Returns a dict with per-fold and aggregated NDCG scores. |
| """ |
| params = {**DEFAULT_PARAMS, **(params or {})} |
|
|
| binary_ids = df["binary_id"].unique().tolist() |
| n = len(binary_ids) |
|
|
| if k > n: |
| print(f"[kfold] k={k} > n_groups={n}; reducing k to {n}", file=sys.stderr) |
| k = n |
|
|
| |
| rng = np.random.default_rng(42) |
| shuffled = rng.permutation(binary_ids).tolist() |
| folds = [shuffled[i::k] for i in range(k)] |
|
|
| fold_results = [] |
| print(f"[kfold] {k}-fold CV on {n} groups ({len(df)} samples)") |
| print(f"[kfold] Fold sizes: {[len(f) for f in folds]}") |
| print() |
|
|
| for fold_idx, val_ids in enumerate(folds): |
| train_ids = [bid for bid in binary_ids if bid not in set(val_ids)] |
|
|
| df_train = df[df["binary_id"].isin(train_ids)].copy() |
| df_val = df[df["binary_id"].isin(val_ids)].copy() |
|
|
| X_train = df_train[FEATURE_NAMES].values |
| y_train = df_train["label"].values.astype(np.float32) |
| g_train = [ |
| int(df_train[df_train["binary_id"] == bid].shape[0]) for bid in train_ids |
| ] |
|
|
| X_val = df_val[FEATURE_NAMES].values |
| y_val = df_val["label"].values.astype(np.float32) |
| g_val = [int(df_val[df_val["binary_id"] == bid].shape[0]) for bid in val_ids] |
|
|
| |
| X_train_df = pd.DataFrame(X_train, columns=FEATURE_NAMES) |
| y_train, g_train, X_train_df = _cap_group_sizes(y_train, g_train, X_train_df) |
| X_train = X_train_df.values |
| y_train_c, g_train_c, _ = _fix_degenerate_groups(y_train, g_train, X_train_df) |
|
|
| model = lgb.LGBMRanker(**params) |
| model.fit(X_train, y_train_c, group=g_train_c) |
|
|
| |
| |
| |
| |
| scores = model.predict(X_val) |
| ndcg_at = {1: [], 3: [], 5: [], 10: []} |
| per_group = {} |
|
|
| val_bid_col = df_val["binary_id"].values |
| for bid in val_ids: |
| mask = val_bid_col == bid |
| tc = y_val[mask] |
| sc = scores[mask] |
| if tc.sum() == 0: |
| continue |
| group_ndcg = {} |
| for kk in ndcg_at: |
| try: |
| if len(tc) < 2: |
| v = 1.0 if (tc[0] == 1) else 0.0 |
| else: |
| v = _ndcg([tc], [sc], k=kk) |
| ndcg_at[kk].append(v) |
| group_ndcg[kk] = round(v, 4) |
| except Exception: |
| n_pos = int(tc.sum()) |
| if n_pos > 0: |
| top_k_idx = np.argsort(sc)[-kk:][::-1] |
| n_pos_in_top_k = int(tc[top_k_idx].sum()) |
| v = n_pos_in_top_k / min(n_pos, kk) |
| ndcg_at[kk].append(v) |
| group_ndcg[kk] = round(v, 4) |
| per_group[bid] = group_ndcg |
|
|
| fold_mean = { |
| f"ndcg_{kk}": float(np.mean(v)) if v else 0.0 for kk, v in ndcg_at.items() |
| } |
| fold_mean["per_group"] = per_group |
| fold_mean["val_groups"] = val_ids |
| fold_results.append(fold_mean) |
|
|
| print(f" Fold {fold_idx + 1}/{k} β val groups: {val_ids}") |
| print( |
| f" NDCG@1={fold_mean.get('ndcg_1', 0):.4f} " |
| f"@3={fold_mean.get('ndcg_3', 0):.4f} " |
| f"@5={fold_mean.get('ndcg_5', 0):.4f} " |
| f"@10={fold_mean.get('ndcg_10', 0):.4f}" |
| ) |
| for bid, gn in per_group.items(): |
| print( |
| f" {bid}: @1={gn.get(1, 0):.3f} @3={gn.get(3, 0):.3f} @5={gn.get(5, 0):.3f}" |
| ) |
| print() |
|
|
| |
| agg = {} |
| for metric in ("ndcg_1", "ndcg_3", "ndcg_5", "ndcg_10"): |
| vals = [f[metric] for f in fold_results if metric in f] |
| if vals: |
| agg[metric] = { |
| "mean": float(np.mean(vals)), |
| "std": float(np.std(vals)), |
| "min": float(np.min(vals)), |
| "max": float(np.max(vals)), |
| } |
|
|
| print("=== K-FOLD SUMMARY ===") |
| for metric, stats in agg.items(): |
| print( |
| f" {metric}: mean={stats['mean']:.4f} Β± {stats['std']:.4f} " |
| f"[{stats['min']:.4f}, {stats['max']:.4f}]" |
| ) |
|
|
| return {"k": k, "folds": fold_results, "aggregate": agg} |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| parser = argparse.ArgumentParser(description="K-fold CV for gadget ranker") |
| parser.add_argument("--csv", required=True, help="Dataset CSV path") |
| parser.add_argument("--k", type=int, default=5, help="Number of folds") |
| parser.add_argument("--out", default="/tmp/kfold_results.json", help="Output JSON") |
| args = parser.parse_args() |
|
|
| df = pd.read_csv(args.csv) |
| for col in FEATURE_NAMES: |
| if col not in df.columns: |
| print(f"[Warning] Manca la feature {col} nel CSV! Faccio padding con 0, ma dovresti ricreare il dataset.", file=sys.stderr) |
| df[col] = 0 |
| results = run_kfold(df, k=args.k) |
|
|
| with open(args.out, "w") as f: |
| json.dump(results, f, indent=2) |
| print(f"\n[kfold] Results saved to {args.out}") |
|
|