roptimizer / kfold_cv.py
chris1sflaggin's picture
Upload 4 files
aa03608 verified
Raw
History Blame Contribute Delete
6.83 kB
"""
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,
)
# ── Core CV logic ─────────────────────────────────────────────────────────────
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
# Assign groups to folds round-robin (deterministic)
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]
# Cap oversized groups then fix degenerate ones
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)
# Per-group NDCG for this fold
# NOTE: use boolean masks instead of offset arithmetic β€” df_val rows are in
# CSV order, which may differ from val_ids (shuffled) order. Offset-based
# slicing would assign labels/scores to the wrong binary.
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()
# Aggregate
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}
# ── CLI ───────────────────────────────────────────────────────────────────────
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}")