gvhd-intel-pro / src /cox_train_once.py
Synav's picture
Update src/cox_train_once.py
3e4e0a3 verified
Raw
History Blame Contribute Delete
6.71 kB
# src/cox_train_once.py
from __future__ import annotations
import os
import re
from pathlib import Path
import numpy as np
import pandas as pd
from lifelines.exceptions import ConvergenceError
from src.survival_utils import prepare_cox_df, fit_cox
from src.cox_persist import save_cox_artifacts
from src.calibration_utils import bootstrap_c_index_ci
def _default_cox_dir() -> Path:
"""
Hugging Face Spaces persistent volume (if enabled) mounts at /data.
Fall back to /tmp otherwise.
"""
data_root = Path("/data")
if data_root.exists() and os.access(str(data_root), os.W_OK):
return data_root / "saved_models"
return Path("/tmp") / "saved_models"
def _sanitize_name(value: str) -> str:
"""
Convert free-text label into filesystem-safe token.
"""
value = str(value).strip().lower()
value = re.sub(r"[^a-z0-9]+", "_", value)
value = re.sub(r"_+", "_", value).strip("_")
return value
def _normalize_target_name(target_name: str) -> str:
"""
Map UI / dataset target labels to stable internal names.
"""
t = str(target_name).strip().lower()
if "acute" in t:
return "acute_gvhd"
if "chronic" in t:
return "chronic_gvhd"
return _sanitize_name(target_name)
def _build_risk_col_name(target_name: str) -> str:
"""
Build explicit Cox predictor column name for predicted GVHD risk.
"""
normalized_target = _normalize_target_name(target_name)
return f"Predicted_{normalized_target}_risk"
def train_and_save_cox(
df_surv_ok: pd.DataFrame,
preds_surv: np.ndarray,
covariates: list[str],
target_name: str,
prefix: str | None = None,
save_dir: Path | str | None = None,
):
"""
Train and save a Cox proportional hazards model for overall survival.
Parameters
----------
df_surv_ok : pd.DataFrame
Survival-ready dataframe containing at least:
- OS_time_days
- Event_clean
preds_surv : np.ndarray
Predicted probabilities/risk scores from the selected GVHD classifier.
covariates : list[str]
Clinical covariates to include in the Cox model.
target_name : str
Example:
- "Acute GVHD(<100 days)"
- "Chronic GVHD>100 days"
prefix : str | None
Optional custom prefix for saved artifacts.
If None, a target-specific prefix will be generated automatically.
save_dir : Path | str | None
Output directory for saved artifacts.
Returns
-------
cph : lifelines.CoxPHFitter
df_cox : pd.DataFrame
model_path : Path
meta_path : Path
"""
if df_surv_ok is None or df_surv_ok.empty:
raise ValueError("df_surv_ok is empty. Cannot train Cox model.")
if preds_surv is None:
raise ValueError("preds_surv is None. Cannot train Cox model.")
if not isinstance(covariates, list) or len(covariates) == 0:
raise ValueError("covariates must be a non-empty list.")
if not target_name or not str(target_name).strip():
raise ValueError("target_name must be a non-empty string.")
preds_surv = np.asarray(preds_surv).ravel()
if len(preds_surv) != len(df_surv_ok):
raise ValueError(
f"Length mismatch: preds_surv has {len(preds_surv)} rows but "
f"df_surv_ok has {len(df_surv_ok)} rows."
)
normalized_target = _normalize_target_name(target_name)
risk_col_name = _build_risk_col_name(target_name)
if prefix is None:
prefix = f"cox_os_{normalized_target}"
# Build Cox dataset (one-hot encoding etc.)
# NOTE: prepare_cox_df must accept risk_col_name=
df_cox, design_cols, cat_cols = prepare_cox_df(
df_surv=df_surv_ok,
preds_surv=preds_surv,
covariates=covariates,
risk_col_name=risk_col_name,
)
if df_cox is None or df_cox.empty:
raise RuntimeError("prepare_cox_df returned an empty dataframe.")
if risk_col_name not in df_cox.columns:
raise RuntimeError(
f"Expected risk column '{risk_col_name}' not found in prepared Cox dataframe."
)
# Drop zero-variance columns to reduce convergence problems
zero_var = [c for c in design_cols if c in df_cox.columns and df_cox[c].nunique(dropna=False) <= 1]
if zero_var:
df_cox = df_cox.drop(columns=zero_var)
design_cols = [c for c in design_cols if c not in zero_var]
if risk_col_name not in design_cols:
raise RuntimeError(
f"Expected risk column '{risk_col_name}' not found in design_cols."
)
# Fit Cox model
try:
cph = fit_cox(df_cox)
except ConvergenceError as e:
raise RuntimeError(f"Cox did not converge: {e}")
except Exception as e:
raise RuntimeError(f"Cox fit error: {e}")
# ------------------------------------------------------------------
# Bootstrap 95% CI for the training-cohort C-index.
# Risk scores come from the fitted Cox model's partial hazard prediction.
# ------------------------------------------------------------------
try:
partial_hazard = cph.predict_partial_hazard(df_cox).values.ravel()
c_point, c_lo, c_hi = bootstrap_c_index_ci(
durations=df_cox["OS_time_days"].values,
events=df_cox["Event_clean"].values,
risk_scores=partial_hazard,
n_bootstraps=1000,
seed=42,
)
except Exception:
c_point, c_lo, c_hi = (float(cph.concordance_index_), float("nan"), float("nan"))
# Resolve output directory
out_dir = Path(save_dir) if save_dir is not None else _default_cox_dir()
out_dir.mkdir(parents=True, exist_ok=True)
# Save artifacts
# NOTE: save_cox_artifacts should accept extra_meta=
model_path, meta_path = save_cox_artifacts(
cph=cph,
design_cols=design_cols,
cat_cols=cat_cols,
covariates=covariates,
df_cox=df_cox,
out_dir=out_dir,
prefix=prefix,
extra_meta={
"target_name": target_name,
"normalized_target": normalized_target,
"risk_col_name": risk_col_name,
"model_type": "cox_os",
"n_rows": int(len(df_cox)),
"n_events": int(df_cox["Event_clean"].sum()) if "Event_clean" in df_cox.columns else None,
"duration_col": "OS_time_days",
"event_col": "Event_clean",
"c_index": float(c_point) if c_point is not None else None,
"c_index_ci_low": float(c_lo) if c_lo is not None else None,
"c_index_ci_high": float(c_hi) if c_hi is not None else None,
},
)
return cph, df_cox, model_path, meta_path