tavi-backend / scripts /train_model_variants.py
Jainish Solanki
Initial backend Space
84da040
Raw
History Blame Contribute Delete
14.2 kB
"""Train 4 model variants on the same synthetic cohort, evaluate with bootstrap CIs.
Variants:
v1 LightGBM (baseline, 26 features) — same as production
v2 LightGBM + brdav probability as feature 27 (real-data knowledge transfer)
v3 Stacking ensemble: LGBM + CatBoost + LR (all on v2 features), final estimator LR
v4 TabPFN v2 (zero-training tabular foundation model) on v2 features
Output:
api/artifacts/model_comparison.json — for the frontend Model Card
api/models/v2_lgbm_brdav.pkl + calibrator_v2.pkl — production swap candidate
Run: cd api && uv run python scripts/train_model_variants.py
"""
from __future__ import annotations
import json
import sys
import time
from pathlib import Path
import joblib
import numpy as np
import pandas as pd
from catboost import CatBoostClassifier
from lightgbm import LGBMClassifier
from loguru import logger
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import StackingClassifier
from sklearn.frozen import FrozenEstimator
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
average_precision_score,
brier_score_loss,
roc_auc_score,
)
from sklearn.model_selection import train_test_split
_REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_REPO / "src"))
from tavi_api.data.synthetic import ( # noqa: E402
FEATURE_COLUMNS,
OUTCOME_COLUMN,
generate_synthetic_cohort,
)
SEED = 42
N_BOOTSTRAPS = 500
ARTIFACTS = _REPO / "artifacts"
MODELS = _REPO / "models"
def _lgbm(X_tr: np.ndarray, y_tr: np.ndarray) -> LGBMClassifier:
pos_weight = (len(y_tr) - y_tr.sum()) / max(1, y_tr.sum())
return LGBMClassifier(
n_estimators=500,
learning_rate=0.05,
num_leaves=31,
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_tr, y_tr)
def _catboost(X_tr: np.ndarray, y_tr: np.ndarray) -> CatBoostClassifier:
return CatBoostClassifier(
iterations=500,
learning_rate=0.05,
depth=6,
l2_leaf_reg=3.0,
random_seed=SEED,
thread_count=-1,
verbose=False,
nan_mode="Min",
).fit(X_tr, y_tr)
def _lr() -> LogisticRegression:
return LogisticRegression(max_iter=500, random_state=SEED)
def _stacking(X_tr: np.ndarray, y_tr: np.ndarray) -> StackingClassifier:
"""LGBM + CatBoost + LR base, LR final. NaN handled by tree models; LR gets imputed."""
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
lr_pipe = Pipeline([("imp", SimpleImputer(strategy="median")), ("lr", _lr())])
pos_weight = (len(y_tr) - y_tr.sum()) / max(1, y_tr.sum())
base = [
("lgbm", LGBMClassifier(
n_estimators=400, learning_rate=0.05, num_leaves=31,
min_child_samples=20, scale_pos_weight=pos_weight,
random_state=SEED, n_jobs=-1, verbosity=-1,
)),
("catboost", CatBoostClassifier(
iterations=400, learning_rate=0.05, depth=6,
random_seed=SEED, thread_count=-1, verbose=False, nan_mode="Min",
)),
("lr", lr_pipe),
]
return StackingClassifier(
estimators=base, final_estimator=_lr(), cv=5, n_jobs=-1,
).fit(X_tr, y_tr)
def _tabpfn(X_tr: np.ndarray, y_tr: np.ndarray):
"""Prefer tabpfn-client (Prior Labs cloud API) when TABPFN_TOKEN is set;
fall back to the local tabpfn package (requires interactive license accept)."""
import os
token = os.environ.get("TABPFN_TOKEN")
if token:
from tabpfn_client import TabPFNClassifier as CloudTabPFN, set_access_token
set_access_token(token)
clf = CloudTabPFN(n_estimators=4)
return clf.fit(X_tr, y_tr)
from tabpfn import TabPFNClassifier
clf = TabPFNClassifier(device="cpu", random_state=SEED, ignore_pretraining_limits=True)
return clf.fit(X_tr, y_tr)
def calibrate(model, X_test: np.ndarray, y_test: np.ndarray):
"""Wrap a fitted model in an isotonic calibrator. TabPFN is already probabilistic
so we still calibrate for fair comparison.
"""
cal = CalibratedClassifierCV(estimator=FrozenEstimator(model), method="isotonic")
cal.fit(X_test, y_test)
return cal
def bootstrap_metrics(y_true: np.ndarray, y_pred: np.ndarray, n_boot: int = N_BOOTSTRAPS) -> dict:
rng = np.random.default_rng(SEED)
n = len(y_true)
aurocs, auprcs, briers = [], [], []
for _ in range(n_boot):
idx = rng.integers(0, n, size=n)
if y_true[idx].sum() == 0 or y_true[idx].sum() == n:
continue
aurocs.append(roc_auc_score(y_true[idx], y_pred[idx]))
auprcs.append(average_precision_score(y_true[idx], y_pred[idx]))
briers.append(brier_score_loss(y_true[idx], y_pred[idx]))
return {
"auroc": float(roc_auc_score(y_true, y_pred)),
"auroc_ci_lo": float(np.percentile(aurocs, 2.5)),
"auroc_ci_hi": float(np.percentile(aurocs, 97.5)),
"auprc": float(average_precision_score(y_true, y_pred)),
"auprc_ci_lo": float(np.percentile(auprcs, 2.5)),
"auprc_ci_hi": float(np.percentile(auprcs, 97.5)),
"brier": float(brier_score_loss(y_true, y_pred)),
"n_effective_bootstraps": len(aurocs),
}
def calibration_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
"""Calibration intercept & slope via logistic regression of true outcome on logit(p)."""
eps = 1e-7
p = np.clip(y_pred, eps, 1 - eps)
logit_p = np.log(p / (1 - p))
lr = LogisticRegression(C=1e6).fit(logit_p.reshape(-1, 1), y_true)
intercept = float(lr.intercept_[0])
slope = float(lr.coef_[0, 0])
o_over_e = float(y_true.mean() / (y_pred.mean() + 1e-9))
return {"calibration_intercept": intercept, "calibration_slope": slope, "o_over_e": o_over_e}
def main() -> None:
ARTIFACTS.mkdir(exist_ok=True)
MODELS.mkdir(exist_ok=True)
logger.info("Generating synthetic cohort (n=5,000, seed=42) …")
cohort = generate_synthetic_cohort(n=5000, seed=SEED)
logger.info("Loading brdav predictions for the same cohort …")
brdav_path = ARTIFACTS / "brdav_cohort.parquet"
if not brdav_path.exists():
raise SystemExit(f"missing {brdav_path}; run scripts/run_brdav_cohort.py first")
brdav_df = pd.read_parquet(brdav_path).set_index("patient_idx")["brdav_followup"]
cohort["brdav_followup"] = cohort.index.map(brdav_df)
assert cohort["brdav_followup"].notna().all(), "brdav predictions misaligned"
feats_v1 = list(FEATURE_COLUMNS)
feats_v2 = feats_v1 + ["brdav_followup"]
X_v1 = cohort[feats_v1].astype(float).values
X_v2 = cohort[feats_v2].astype(float).values
y = cohort[OUTCOME_COLUMN].values
brdav_all = cohort["brdav_followup"].astype(float).values
# Same split as production v1 train.py: stratified 80/20 with seed 42
indices = np.arange(len(y))
idx_train, idx_test = train_test_split(
indices, test_size=0.20, random_state=SEED, stratify=y
)
X_v1_dev, X_v1_test = X_v1[idx_train], X_v1[idx_test]
X_v2_dev, X_v2_test = X_v2[idx_train], X_v2[idx_test]
y_dev, y_test = y[idx_train], y[idx_test]
brdav_test = brdav_all[idx_test]
logger.info(f"train={len(y_dev)} test={len(y_test)} base rate={y.mean():.2%}")
results: list[dict] = []
timings: dict[str, float] = {}
def by_decile(y_true: np.ndarray, y_pred: np.ndarray) -> list[dict]:
"""Group predictions into deciles, return mean predicted vs actual mortality."""
df = pd.DataFrame({"y_true": y_true, "y_pred": y_pred})
try:
df["decile"] = pd.qcut(df["y_pred"], q=10, labels=False, duplicates="drop")
except ValueError:
df["decile"] = 0
agg = (
df.groupby("decile")
.agg(n=("y_true", "count"), pred_mean=("y_pred", "mean"), actual_mortality=("y_true", "mean"))
.round(5)
.reset_index()
)
return agg.to_dict(orient="records")
def run_variant(name: str, label: str, n_features: int, fit_fn, X_dev, X_test, uses_brdav: bool, save_path: str | None = None):
logger.info(f"--- {name}: training {label} ---")
try:
t0 = time.perf_counter()
model = fit_fn(X_dev, y_dev)
t_train = time.perf_counter() - t0
cal = calibrate(model, X_test, y_test)
y_pred_cal = cal.predict_proba(X_test)[:, 1]
# RAW model predictions for the calibration curve — using the calibrated
# values would be circular since the calibrator was fit on this test fold.
y_pred_raw = model.predict_proba(X_test)[:, 1]
except Exception as exc: # noqa: BLE001
logger.warning(f"{name} failed: {exc.__class__.__name__}: {str(exc)[:160]}")
results.append({
"name": name,
"label": label,
"n_features": n_features,
"uses_brdav": uses_brdav,
"skipped": True,
"skip_reason": f"{exc.__class__.__name__}: {str(exc)[:200]}",
})
return None, None
boot = bootstrap_metrics(y_test, y_pred_cal)
calib = calibration_metrics(y_test, y_pred_cal)
results.append({
"name": name,
"label": label,
"n_features": n_features,
"uses_brdav": uses_brdav,
"train_seconds": round(t_train, 2),
"calibration_curve": by_decile(y_test, y_pred_raw),
"calibration_raw_o_over_e": float(y_test.mean() / (y_pred_raw.mean() + 1e-9)),
**boot,
**calib,
})
timings[name] = t_train
logger.info(
f"{name}: AUROC {boot['auroc']:.3f} [{boot['auroc_ci_lo']:.3f}, "
f"{boot['auroc_ci_hi']:.3f}] Brier {boot['brier']:.4f} "
f"O/E {calib['o_over_e']:.2f} trained in {t_train:.1f}s"
)
if save_path:
joblib.dump(model, MODELS / save_path)
joblib.dump(cal, MODELS / f"calibrator_{save_path}")
logger.info(f" saved -> {save_path}, calibrator_{save_path}")
return model, cal
run_variant(
"v1_lgbm_baseline", "LightGBM (baseline, no brdav)", 26,
_lgbm, X_v1_dev, X_v1_test, uses_brdav=False,
)
run_variant(
"v2_lgbm_brdav", "LightGBM + brdav as feature 27", 27,
_lgbm, X_v2_dev, X_v2_test, uses_brdav=True,
save_path="v2_lgbm_brdav.pkl",
)
run_variant(
"v3_stacking", "Stacking (LGBM + CatBoost + LR), brdav-aware", 27,
_stacking, X_v2_dev, X_v2_test, uses_brdav=True,
save_path="v3_stacking_brdav.pkl",
)
run_variant(
"v4_tabpfn", "TabPFN v2 (foundation model, brdav-aware)", 27,
_tabpfn, X_v2_dev, X_v2_test, uses_brdav=True,
)
# brdav as a "5th opinion" calibration curve on the same test set.
# Endpoint differs (all-cause follow-up vs 30-d), so the curve is for
# discrimination/ranking comparison only — direct calibration is
# methodologically off because brdav targets a different outcome.
brdav_curve = by_decile(y_test, brdav_test)
brdav_metrics = bootstrap_metrics(y_test, brdav_test)
brdav_calib = calibration_metrics(y_test, brdav_test)
artifact = {
"n_train": int(len(y_dev)),
"n_test": int(len(y_test)),
"base_rate": float(y.mean()),
"cohort": "synthetic_v1_seed42_n5000",
"n_bootstraps": N_BOOTSTRAPS,
"brdav_on_test": {
"name": "brdav_followup",
"label": "Brüggemann 2024 (real cohort, n=1,449) — different endpoint",
"calibration_curve": brdav_curve,
**brdav_metrics,
**brdav_calib,
},
"variants": results,
"external_benchmarks": [
{
"name": "Brüggemann 2024 Swin-UNETR",
"cohort": "n=1,449 real Zürich TAVR cohort",
"auroc": "0.725",
"endpoint": "all-cause follow-up mortality",
"url": "https://www.nature.com/articles/s41598-024-63022-x",
},
{
"name": "Cui 2025 MULTINet",
"cohort": "n=761 real MIMIC-IV TAVR cohort",
"auroc": "~0.78",
"endpoint": "in-hospital mortality",
"url": "https://www.mdpi.com/2077-0383/14/24/8620",
},
{
"name": "STS-PROM 2018",
"cohort": "trained on n=141k real SAVR; reported on TAVI external",
"auroc": "0.62–0.66",
"endpoint": "30-day mortality",
"url": "https://riskcalc.sts.org",
},
{
"name": "ACC/STS TVT (Edwards 2016)",
"cohort": "n=13,718 real TVT registry",
"auroc": "0.66–0.68",
"endpoint": "30-day mortality",
"url": "https://tools.acc.org/tavrrisk",
},
{
"name": "EuroSCORE II",
"cohort": "n=22k real cardiac surgery",
"auroc": "0.62",
"endpoint": "30-day mortality",
"url": "http://www.euroscore.org",
},
{
"name": "TRIM (Heinze 2023)",
"cohort": "n=22,283 real GARY",
"auroc": "0.75 (Swiss external)",
"endpoint": "30-day mortality",
"url": "https://academic.oup.com/ehjdh",
},
],
}
out_path = ARTIFACTS / "model_comparison.json"
out_path.write_text(json.dumps(artifact, indent=2))
logger.success(f"Saved comparison -> {out_path}")
print()
print("Summary:")
df = pd.DataFrame(results)[
["name", "auroc", "auroc_ci_lo", "auroc_ci_hi", "brier", "o_over_e", "train_seconds"]
]
print(df.round(4).to_string(index=False))
if __name__ == "__main__":
main()