from __future__ import annotations import argparse import json from pathlib import Path import joblib import mlflow import pandas as pd from sklearn.metrics import average_precision_score, confusion_matrix, roc_auc_score from sklearn.model_selection import train_test_split from sklearn.pipeline import Pipeline from credexp.config import ARTIFACTS_DIR, DATA_DIR, settings from credexp.modeling.pipelines import make_numeric_steps from credexp.modeling.threshold import business_cost, find_best_threshold from credexp.utils.logging import get_logger log = get_logger(__name__) try: import lightgbm as lgb except Exception as e: raise RuntimeError("LightGBM must be installed to run train_final.py") from e def main() -> None: parser = argparse.ArgumentParser() parser.add_argument( "--features", type=str, default=str(DATA_DIR / "processed" / "features.parquet") ) parser.add_argument("--holdout-size", type=float, default=0.10) parser.add_argument("--val-size", type=float, default=0.20) parser.add_argument("--cost-fn", type=float, default=10.0) parser.add_argument("--cost-fp", type=float, default=1.0) parser.add_argument( "--undersample", action="store_true", help="(optional) not used in this final script for simplicity", ) parser.add_argument("--run-name", type=str, default="lgbm_final") args = parser.parse_args() features_path = Path(args.features) df = pd.read_parquet(features_path) df_train = df[df["TARGET"].notna()].copy() y = df_train["TARGET"].astype(int) X = df_train.drop(columns=["TARGET"]) # 1) Holdout split (never seen by the model) X_dev, X_holdout, y_dev, y_holdout = train_test_split( X, y, test_size=args.holdout_size, random_state=settings.random_state, stratify=y, ) holdout_path = DATA_DIR / "processed" / "api_holdout.parquet" holdout = X_holdout.copy() holdout["TARGET"] = y_holdout.values holdout.to_parquet(holdout_path, index=False) log.info(f"Saved API holdout to {holdout_path} shape={holdout.shape}") # 2) Dev split to pick threshold (train/val) X_tr, X_va, y_tr, y_va = train_test_split( X_dev, y_dev, test_size=args.val_size, random_state=settings.random_state, stratify=y_dev, ) # Baseline LGBM params (you can tune later via Optuna) # class_weight = "balanced" # model = lgb.LGBMClassifier( # n_estimators=2000, # learning_rate=0.03, # num_leaves=64, # subsample=0.8, # colsample_bytree=0.8, # reg_lambda=1.0, # objective="binary", # class_weight=class_weight, # random_state=settings.random_state, # n_jobs=-1, # ) # Tuned LGBM params (from Optuna best trial) class_weight = "balanced" model = lgb.LGBMClassifier( n_estimators=781, learning_rate=0.022855, num_leaves=59, max_depth=6, min_child_samples=117, subsample=0.609379, colsample_bytree=0.900507, reg_alpha=1.233276, reg_lambda=4.120487, objective="binary", class_weight=class_weight, random_state=settings.random_state, n_jobs=-1, verbosity=-1, ) pipe = Pipeline( [ *make_numeric_steps(scale=False), ("model", model), ] ) # MLflow setup mlflow.set_tracking_uri(settings.mlflow_tracking_uri) mlflow.set_registry_uri(settings.mlflow_registry_uri) mlflow.set_experiment(settings.mlflow_experiment_name) with mlflow.start_run(run_name=args.run_name): mlflow.log_param("model_family", "lightgbm") mlflow.log_param("holdout_size", args.holdout_size) mlflow.log_param("val_size", args.val_size) mlflow.log_param("cost_fn", args.cost_fn) mlflow.log_param("cost_fp", args.cost_fp) mlflow.log_param("class_weight", class_weight) mlflow.log_param("features_file", str(features_path)) # Log tuned hyperparams mlflow.log_params( { "lgbm_n_estimators": 781, "lgbm_learning_rate": 0.022855, "lgbm_num_leaves": 59, "lgbm_max_depth": 6, "lgbm_min_child_samples": 117, "lgbm_subsample": 0.609379, "lgbm_colsample_bytree": 0.900507, "lgbm_reg_alpha": 1.233276, "lgbm_reg_lambda": 4.120487, } ) # 3) Fit on train split, choose threshold on val split pipe.fit(X_tr, y_tr) proba_va = pipe.predict_proba(X_va)[:, 1] best_thr, best_cost = find_best_threshold( y_true=y_va.to_numpy(), y_proba=proba_va, cost_fn=args.cost_fn, cost_fp=args.cost_fp, ) # Val metrics val_auc = roc_auc_score(y_va, proba_va) val_prauc = average_precision_score(y_va, proba_va) mlflow.log_metric("val_roc_auc", float(val_auc)) mlflow.log_metric("val_pr_auc", float(val_prauc)) mlflow.log_metric("val_best_threshold", float(best_thr)) mlflow.log_metric("val_business_cost", float(best_cost)) log.info( f"Val: AUC={val_auc:.4f} PR-AUC={val_prauc:.4f} best_thr={best_thr:.3f} cost={best_cost:.1f}" ) # 4) Refit final model on ALL dev data (train+val) pipe.fit(X_dev, y_dev) # 5) Evaluate on holdout (never seen) proba_hold = pipe.predict_proba(X_holdout)[:, 1] hold_auc = roc_auc_score(y_holdout, proba_hold) hold_prauc = average_precision_score(y_holdout, proba_hold) hold_cost = business_cost( y_holdout.to_numpy(), proba_hold, best_thr, args.cost_fn, args.cost_fp ) y_pred_hold = (proba_hold >= best_thr).astype(int) cm = confusion_matrix(y_holdout, y_pred_hold).tolist() mlflow.log_metric("holdout_roc_auc", float(hold_auc)) mlflow.log_metric("holdout_pr_auc", float(hold_prauc)) mlflow.log_metric("holdout_business_cost", float(hold_cost)) # Log confusion matrix elements (handy for dashboards) tn, fp = cm[0] fn, tp = cm[1] mlflow.log_metric("holdout_tn", tn) mlflow.log_metric("holdout_fp", fp) mlflow.log_metric("holdout_fn", fn) mlflow.log_metric("holdout_tp", tp) log.info( f"Holdout: AUC={hold_auc:.4f} PR-AUC={hold_prauc:.4f} cost={hold_cost:.1f} cm={cm}" ) # 6) Save local artifacts (joblib + threshold json) out_dir = ARTIFACTS_DIR / "models" out_dir.mkdir(parents=True, exist_ok=True) model_path = out_dir / "pipeline.joblib" joblib.dump(pipe, model_path) threshold_path = out_dir / "threshold.json" threshold_payload = { "threshold": float(best_thr), "cost_fn": float(args.cost_fn), "cost_fp": float(args.cost_fp), "selection": "val_optimized_then_refit_on_dev", } threshold_path.write_text(json.dumps(threshold_payload, indent=2), encoding="utf-8") # 7) Log artifacts to MLflow mlflow.log_artifact(str(model_path), artifact_path="export") mlflow.log_artifact(str(threshold_path), artifact_path="export") mlflow.log_artifact(str(holdout_path), artifact_path="export") # Also log as MLflow model (appears under run -> Artifacts/model) mlflow.sklearn.log_model( pipe, artifact_path="model", registered_model_name="credit_scoring_model", ) log.info("Final model exported and logged to MLflow.") if __name__ == "__main__": main()