""" KRONECTOR - LightGBM training entry point. Training flow: - load race parquet data - prepare features and fit categorical LabelEncoders - evaluate all TimeSeriesSplit folds and log averaged CV metrics - retrain a final LightGBM model on the full dataset - log the model, fitted encoders, and SHAP summary to MLflow """ from __future__ import annotations import argparse import os import tempfile from pathlib import Path import numpy as np import pandas as pd from sklearn.metrics import log_loss, roc_auc_score from ml.feature_engineering import ( create_time_series_splits, prepare_model_data, save_encoders, ) REGISTERED_MODEL_NAME = "kronector-f1-lgbm" LIGHTGBM_PARAMS = { "objective": "binary", "n_estimators": 200, "learning_rate": 0.05, "num_leaves": 31, "random_state": 42, "class_weight": "balanced", "verbosity": -1, } def _positive_class_shap_values(shap_values): """Normalize SHAP binary-class outputs to one 2D array.""" if isinstance(shap_values, list): return shap_values[1] if len(shap_values) > 1 else shap_values[0] if isinstance(shap_values, np.ndarray) and shap_values.ndim == 3: return shap_values[:, :, 1] return shap_values def _build_model(): import lightgbm as lgb return lgb.LGBMClassifier(**LIGHTGBM_PARAMS) def _cross_validate(bundle, n_splits: int) -> dict[str, float]: """Evaluate all time-series folds and return averaged metrics.""" fold_metrics = [] for fold, (train_idx, valid_idx) in enumerate( create_time_series_splits(bundle.X, n_splits=n_splits), start=1 ): model = _build_model() X_train = bundle.X.iloc[train_idx] y_train = bundle.y.iloc[train_idx] X_valid = bundle.X.iloc[valid_idx] y_valid = bundle.y.iloc[valid_idx] model.fit(X_train, y_train) valid_prob = model.predict_proba(X_valid)[:, 1] metrics = { "fold": fold, "log_loss": log_loss(y_valid, valid_prob, labels=[0, 1]), } if y_valid.nunique() > 1: metrics["roc_auc"] = roc_auc_score(y_valid, valid_prob) fold_metrics.append(metrics) metric_names = sorted( metric for metrics in fold_metrics for metric in metrics if metric != "fold" ) averaged = {} for metric in metric_names: values = [fold[metric] for fold in fold_metrics if metric in fold] averaged[f"cv_mean_{metric}"] = float(np.mean(values)) return averaged def _save_shap_summary(model, X: pd.DataFrame, path: Path) -> None: """Create a SHAP summary plot for the final fitted model.""" import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import shap explainer = shap.TreeExplainer(model) shap_values = _positive_class_shap_values(explainer.shap_values(X)) shap.summary_plot(shap_values, X, show=False) plt.tight_layout() plt.savefig(path, dpi=160, bbox_inches="tight") plt.close() def train_model( data_path: str = "data_output/fastf1_races.parquet", experiment_name: str = "kronector-week3", n_splits: int = 5, ) -> str: """ Train a LightGBM model and log model artifacts to MLflow. Returns: MLflow run id. """ import mlflow import mlflow.lightgbm df = pd.read_parquet(data_path) bundle, encoders = prepare_model_data(df) cv_metrics = _cross_validate(bundle, n_splits=n_splits) final_model = _build_model() final_model.fit(bundle.X, bundle.y) tracking_uri = os.getenv("MLFLOW_TRACKING_URI") if not tracking_uri: mlflow.set_tracking_uri("file:./mlruns") mlflow.set_experiment(experiment_name) with mlflow.start_run() as run: mlflow.log_params(final_model.get_params()) mlflow.log_params( { "n_splits": n_splits, "n_features": len(bundle.feature_columns), "n_rows": len(bundle.X), "model_type": "LightGBM", } ) mlflow.log_metrics(cv_metrics) mlflow.lightgbm.log_model( final_model, artifact_path="model", registered_model_name=REGISTERED_MODEL_NAME, ) with tempfile.TemporaryDirectory() as tmpdir: tmp_path = Path(tmpdir) encoder_path = tmp_path / "label_encoders.pkl" save_encoders(encoders, str(encoder_path)) mlflow.log_artifact(str(encoder_path), artifact_path="encoders") shap_path = tmp_path / "shap_summary.png" _save_shap_summary(final_model, bundle.X, shap_path) mlflow.log_artifact(str(shap_path), artifact_path="explainability") return run.info.run_id def main() -> None: parser = argparse.ArgumentParser(description="Train KRONECTOR LightGBM model") parser.add_argument("--data-path", default="data_output/fastf1_races.parquet") parser.add_argument("--experiment-name", default="kronector-week3") parser.add_argument("--n-splits", type=int, default=5) args = parser.parse_args() run_id = train_model( data_path=args.data_path, experiment_name=args.experiment_name, n_splits=args.n_splits, ) print(f"MLflow run_id: {run_id}") if __name__ == "__main__": main()