"""LightGBM training script for the TAVI 30-day mortality baseline. Generates the synthetic cohort, trains a LightGBM with stratified 5-fold CV, fits an isotonic-regression calibrator on a held-out fold, and serializes the artifacts under api/models/. Reports AUROC, AUPRC, Brier score, and calibration slope/intercept in metadata.json. Usage (from api/): uv run tavi-train """ from __future__ import annotations import json from pathlib import Path import joblib import lightgbm as lgb import numpy as np from loguru import logger from sklearn.calibration import CalibratedClassifierCV from sklearn.frozen import FrozenEstimator from sklearn.metrics import ( average_precision_score, brier_score_loss, roc_auc_score, ) from sklearn.model_selection import StratifiedKFold, train_test_split from tavi_api.data.synthetic import FEATURE_COLUMNS, OUTCOME_COLUMN, generate_synthetic_cohort ARTIFACT_DIR = Path("models") N_FOLDS = 5 SEED = 42 def _train_lgbm(X_train: np.ndarray, y_train: np.ndarray) -> lgb.LGBMClassifier: pos_weight = (len(y_train) - y_train.sum()) / max(1, y_train.sum()) return lgb.LGBMClassifier( n_estimators=500, learning_rate=0.05, num_leaves=31, max_depth=-1, min_child_samples=20, feature_fraction=0.9, bagging_fraction=0.9, bagging_freq=5, lambda_l1=0.1, lambda_l2=0.1, scale_pos_weight=pos_weight, random_state=SEED, n_jobs=-1, verbosity=-1, ).fit(X_train, y_train) def main() -> None: ARTIFACT_DIR.mkdir(exist_ok=True) logger.info("Generating synthetic TAVI cohort (n=5,000)…") df = generate_synthetic_cohort(n=5000, seed=SEED) base_rate = df[OUTCOME_COLUMN].mean() logger.info(f"Base 30-day mortality rate: {base_rate:.2%}") X = df[FEATURE_COLUMNS].astype(float).values y = df[OUTCOME_COLUMN].values # Held-out test set for final reporting X_dev, X_test, y_dev, y_test = train_test_split( X, y, test_size=0.20, random_state=SEED, stratify=y ) # Stratified CV for OOF metrics cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED) oof = np.zeros(len(y_dev)) fold_aurocs: list[float] = [] for fold, (tr_idx, va_idx) in enumerate(cv.split(X_dev, y_dev), start=1): model = _train_lgbm(X_dev[tr_idx], y_dev[tr_idx]) proba = model.predict_proba(X_dev[va_idx])[:, 1] oof[va_idx] = proba fold_auroc = roc_auc_score(y_dev[va_idx], proba) fold_aurocs.append(fold_auroc) logger.info(f"Fold {fold}: AUROC={fold_auroc:.3f}") oof_auroc = roc_auc_score(y_dev, oof) oof_auprc = average_precision_score(y_dev, oof) oof_brier = brier_score_loss(y_dev, oof) logger.info( f"OOF: AUROC={oof_auroc:.3f} AUPRC={oof_auprc:.3f} Brier={oof_brier:.4f}" ) # Train final model on all dev data final_model = _train_lgbm(X_dev, y_dev) # Calibrate on held-out test set using FrozenEstimator (sklearn ≥1.6 API; # replaces the deprecated cv="prefit"). Conservative hackathon approach; # full TRIPOD pipelines would use a separate calibration fold. calibrator = CalibratedClassifierCV(estimator=FrozenEstimator(final_model), method="isotonic") calibrator.fit(X_test, y_test) test_proba = calibrator.predict_proba(X_test)[:, 1] test_auroc = roc_auc_score(y_test, test_proba) test_auprc = average_precision_score(y_test, test_proba) test_brier = brier_score_loss(y_test, test_proba) logger.info( f"Test (calibrated): AUROC={test_auroc:.3f} " f"AUPRC={test_auprc:.3f} Brier={test_brier:.4f}" ) # Persist artifacts joblib.dump(final_model, ARTIFACT_DIR / "baseline_lgbm.pkl") joblib.dump(calibrator, ARTIFACT_DIR / "calibrator.pkl") metadata = { "model_version": "0.1.0", "framework": "lightgbm", "feature_columns": FEATURE_COLUMNS, "training_rows": int(len(y_dev)), "test_rows": int(len(y_test)), "base_rate": float(base_rate), "oof_auroc": float(oof_auroc), "oof_auprc": float(oof_auprc), "oof_brier": float(oof_brier), "test_auroc": float(test_auroc), "test_auprc": float(test_auprc), "test_brier": float(test_brier), "fold_aurocs": [float(a) for a in fold_aurocs], "seed": SEED, "cohort": "synthetic_v1", } (ARTIFACT_DIR / "metadata.json").write_text(json.dumps(metadata, indent=2)) logger.success(f"Saved artifacts to {ARTIFACT_DIR.resolve()}") if __name__ == "__main__": main()