gvhd-intel-pro / src /survival_utils.py
Synav's picture
Update src/survival_utils.py
ad5263b verified
Raw
History Blame Contribute Delete
6.68 kB
import numpy as np
import pandas as pd
from lifelines import CoxPHFitter
DEFAULT_YEARS = [3, 5, 10]
def build_os_time_days(
df: pd.DataFrame,
months_col: str = "Months_from_transplant_to_last_followup",
death_date_col: str = "Date_of_death",
hsct_date_col: str = "HSCT_date",
event_col: str = "Event",
) -> pd.Series:
"""
Build OS_time_days with priority:
1) Months_from_transplant_to_last_followup (converted to days)
2) If missing, and Event == 1, use Date_of_death - HSCT_date
Returns
-------
pd.Series
Survival duration in days.
"""
out = pd.Series(np.nan, index=df.index, dtype="float64")
# First preference: follow-up months already present
if months_col in df.columns:
months = pd.to_numeric(df[months_col], errors="coerce")
valid_months = months.notna() & (months > 0)
out.loc[valid_months] = months.loc[valid_months] * 30.44
# Fallback: calculate from death date - HSCT date for death events
required_cols = [death_date_col, hsct_date_col, event_col]
if all(c in df.columns for c in required_cols):
death_dt = pd.to_datetime(df[death_date_col], errors="coerce")
hsct_dt = pd.to_datetime(df[hsct_date_col], errors="coerce")
event = pd.to_numeric(df[event_col], errors="coerce")
mask = out.isna() & death_dt.notna() & hsct_dt.notna() & (event == 1)
out.loc[mask] = (death_dt.loc[mask] - hsct_dt.loc[mask]).dt.days.astype(float)
# Clean invalid durations
out.loc[out <= 0] = np.nan
return out
def prepare_cox_df(
df_surv: pd.DataFrame,
preds_surv: np.ndarray,
covariates: list[str],
duration_col: str = "OS_time_days",
event_col: str = "Event_clean",
risk_col_name: str = "Predicted_GVHD_Risk",
) -> tuple[pd.DataFrame, list[str], list[str]]:
"""
Prepare Cox dataframe for OS modeling.
Parameters
----------
df_surv : pd.DataFrame
Survival-ready dataframe, usually preprocessed dataframe.
preds_surv : np.ndarray
Predicted risk scores aligned with df_surv rows.
covariates : list[str]
Covariates to include in Cox model.
duration_col : str
Name of duration column to use/create.
event_col : str
Name of cleaned event column to use/create.
risk_col_name : str
Explicit risk predictor name, e.g.:
- Predicted_acute_gvhd_risk
- Predicted_chronic_gvhd_risk
Returns
-------
df_cox : pd.DataFrame
Numeric-only Cox dataset including duration and event columns.
design_cols : list[str]
Final model covariates after one-hot encoding.
cat_cols : list[str]
Original categorical covariates that were one-hot encoded.
"""
df = df_surv.copy()
# Build OS_time_days if not already present or if missing
if duration_col not in df.columns:
df[duration_col] = build_os_time_days(df)
else:
existing_duration = pd.to_numeric(df[duration_col], errors="coerce")
rebuilt_duration = build_os_time_days(df)
df[duration_col] = existing_duration
fill_mask = df[duration_col].isna()
df.loc[fill_mask, duration_col] = rebuilt_duration.loc[fill_mask]
# Build Event_clean if not already present
if event_col not in df.columns:
if "Event" not in df.columns:
raise ValueError(
f"Neither '{event_col}' nor raw 'Event' column found in dataframe."
)
df[event_col] = pd.to_numeric(df["Event"], errors="coerce")
else:
df[event_col] = pd.to_numeric(df[event_col], errors="coerce")
# Add predicted GVHD risk as Cox covariate
preds_surv = np.asarray(preds_surv).astype(float).ravel()
if len(preds_surv) != len(df):
raise ValueError(
f"Length mismatch: preds_surv has {len(preds_surv)} rows but df_surv has {len(df)} rows."
)
df[risk_col_name] = preds_surv
# Keep only required columns
keep_covariates = [c for c in covariates if c in df.columns]
cols = [duration_col, event_col, risk_col_name] + keep_covariates
df = df[cols].copy()
# Coerce survival columns
df[duration_col] = pd.to_numeric(df[duration_col], errors="coerce")
df[event_col] = pd.to_numeric(df[event_col], errors="coerce")
# Drop invalid rows
df = df.dropna(subset=[duration_col, event_col])
df = df[df[duration_col] > 0]
df[event_col] = df[event_col].astype(int).clip(0, 1)
# Identify categorical covariates
from pandas.api.types import is_object_dtype, is_string_dtype, is_categorical_dtype
cat_cols = [
c for c in df.columns
if c not in [duration_col, event_col]
and (is_object_dtype(df[c]) or is_string_dtype(df[c]) or is_categorical_dtype(df[c]))
]
# One-hot encode categoricals
if cat_cols:
df = pd.get_dummies(df, columns=cat_cols, drop_first=True)
# Ensure numeric design matrix
for c in df.columns:
if c in [duration_col, event_col]:
continue
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0)
design_cols = [c for c in df.columns if c not in [duration_col, event_col]]
return df, design_cols, cat_cols
def fit_cox(
df_cox: pd.DataFrame,
duration_col: str = "OS_time_days",
event_col: str = "Event_clean",
) -> CoxPHFitter:
cph = CoxPHFitter(penalizer=0.1)
cph.fit(df_cox, duration_col=duration_col, event_col=event_col)
return cph
def make_patient_design_row(
patient_row: pd.DataFrame,
design_cols: list[str],
cat_cols_original: list[str],
):
x = patient_row.copy()
if cat_cols_original:
available_cat_cols = [c for c in cat_cols_original if c in x.columns]
if available_cat_cols:
x = pd.get_dummies(x, columns=available_cat_cols, drop_first=True)
unseen = sorted(set(x.columns) - set(design_cols))
x = x.reindex(columns=design_cols, fill_value=0)
for c in x.columns:
x[c] = pd.to_numeric(x[c], errors="coerce").fillna(0)
return x, unseen
def predict_patient_survival(cph, patient_design, years=None):
if years is None:
years = DEFAULT_YEARS
surv_fn = cph.predict_survival_function(patient_design)
# survival function is a DataFrame: index=time, column=patient
s_series = surv_fn.iloc[:, 0]
times = s_series.index.values
landmarks = {}
for y in years:
t = 365 * int(y)
le_mask = times <= t
if le_mask.any():
s = float(s_series.loc[times[le_mask].max()])
else:
s = float(s_series.iloc[0])
landmarks[int(y)] = s
return surv_fn, landmarks