AMDRisk / survival /train_cox.py
Hou
add src
a7c73c5
Raw
History Blame Contribute Delete
20.6 kB
import argparse
import contextlib
import json
import os
import pickle
import warnings
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
ENDPOINTS = {
"late_amd": "Status_late_amd",
"anyga": "Status_anyga",
"nv": "Status_nv",
}
TIME_COL = "Survival_in_years"
DEFAULT_TRAIN_FOLDS = [3, 4, 5, 6, 7, 8, 9]
DEFAULT_VAL_FOLDS = [2]
DEFAULT_TEST_FOLDS = [0, 1]
def suppress_noisy_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
warnings.filterwarnings("ignore", message=".*Ill-conditioned matrix.*")
warnings.filterwarnings("ignore", message=".*Newton-Raphson failed to converge sufficiently.*")
warnings.filterwarnings("ignore", message=".*ConvergenceWarning.*")
warnings.filterwarnings("ignore", message=".*overflow encountered.*")
warnings.filterwarnings("ignore", message=".*invalid value encountered.*")
warnings.filterwarnings("ignore", message=".*matrix inversion problems.*")
warnings.filterwarnings("ignore", message=".*delta contains nan.*")
os.environ.setdefault("PYTHONWARNINGS", "ignore")
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--json", required=True, help="Survival JSON file.")
parser.add_argument("--features", default=None, help="DeepSeeNet .npz feature file.")
parser.add_argument(
"--endpoint",
required=True,
choices=list(ENDPOINTS.keys()),
help="Survival endpoint.",
)
parser.add_argument(
"--feature-set",
default="deep_clinical",
choices=[
"grading",
"grading_clinical",
"deep",
"deep_clinical",
"deep_clinical_genotype",
],
)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--train-folds", nargs="+", type=int, default=DEFAULT_TRAIN_FOLDS)
parser.add_argument("--val-folds", nargs="+", type=int, default=DEFAULT_VAL_FOLDS)
parser.add_argument("--test-folds", nargs="+", type=int, default=DEFAULT_TEST_FOLDS)
parser.add_argument(
"--penalizer",
type=float,
default=0.01,
help="L2/elastic-net penalizer for lifelines CoxPHFitter.",
)
parser.add_argument(
"--l1-ratio",
type=float,
default=0.0,
help="Elastic-net L1 ratio for lifelines CoxPHFitter. 0 = pure L2.",
)
parser.add_argument(
"--min-std",
type=float,
default=1e-6,
help="Drop features whose train-set std is <= this value.",
)
parser.add_argument(
"--top-k",
type=int,
default=None,
help="Keep only the top-k features by univariate train-set survival ranking.",
)
parser.add_argument(
"--top-k-per-block",
type=int,
default=None,
help=(
"Select top-k features within each DeepSeeNet feature block "
"(LE_DRUS, RE_DRUS, LE_PIG, RE_PIG). "
"Example: --top-k-per-block 4 gives 16 image features total."
),
)
parser.add_argument(
"--show-progress",
action="store_true",
help="Show lifelines optimization progress.",
)
return parser.parse_args()
def require_lifelines():
try:
from lifelines import CoxPHFitter
from lifelines.utils import concordance_index
except ImportError as e:
raise ImportError(
"lifelines is required. Install with:\n\n"
" pip install lifelines\n"
) from e
return CoxPHFitter, concordance_index
def load_json_rows(path):
with open(path, "r") as f:
rows = json.load(f)
if not isinstance(rows, list):
raise ValueError(f"Expected JSON list, got {type(rows)}")
return pd.DataFrame(rows)
def load_deep_features(path):
data = np.load(path, allow_pickle=True)
features = data["features"].astype(np.float32)
patids = data["patids"]
feature_names = data["feature_names"] if "feature_names" in data.files else None
if feature_names is None:
feature_names = np.array([f"deep_{i:03d}" for i in range(features.shape[1])])
feature_names = [str(x) for x in feature_names]
if features.shape[1] != len(feature_names):
raise ValueError(
f"features has {features.shape[1]} columns but feature_names has "
f"{len(feature_names)} entries."
)
print("Deep feature sanity check:")
print(f" shape: {features.shape}")
print(f" NaN: {int(np.isnan(features).sum())}")
print(f" Inf: {int(np.isinf(features).sum())}")
print(f" min: {float(np.nanmin(features)):.6g}")
print(f" max: {float(np.nanmax(features)):.6g}")
print(f" mean: {float(np.nanmean(features)):.6g}")
print(f" std: {float(np.nanstd(features)):.6g}")
feature_df = pd.DataFrame(features, columns=feature_names)
feature_df.insert(0, "PATID", patids)
return feature_df
def merge_features(df, features_path):
if features_path is None:
raise ValueError("--features is required for deep feature sets.")
feature_df = load_deep_features(features_path)
before = len(df)
df = df.merge(feature_df, on="PATID", how="inner")
after = len(df)
if after == 0:
raise ValueError("No rows remained after merging JSON with feature NPZ by PATID.")
if after < before:
print(f"[warning] Rows dropped after feature merge: {before - after} / {before}")
return df
def get_feature_columns(df, feature_set):
grading_cols = ["LE_DRUS", "RE_DRUS", "LE_PIG", "RE_PIG"]
clinical_cols = ["age", "smkever"]
genotype_cols = ["rs1061170_CFH", "rs10490924_ARMS2", "RiskScore"]
deep_cols = [
c for c in df.columns
if (
c.startswith("LE_DRUS_")
or c.startswith("RE_DRUS_")
or c.startswith("LE_PIG_")
or c.startswith("RE_PIG_")
or c.startswith("deep_")
)
]
if feature_set == "grading":
cols = grading_cols
elif feature_set == "grading_clinical":
cols = grading_cols + clinical_cols
elif feature_set == "deep":
cols = deep_cols
elif feature_set == "deep_clinical":
cols = deep_cols + clinical_cols
elif feature_set == "deep_clinical_genotype":
cols = deep_cols + clinical_cols + genotype_cols
else:
raise ValueError(f"Unknown feature set: {feature_set}")
missing = [c for c in cols if c not in df.columns]
if missing:
raise ValueError(f"Missing required feature columns: {missing}")
if len(cols) == 0:
raise ValueError(
f"No feature columns found for feature_set={feature_set}. "
"Check feature_names inside the .npz."
)
return cols
def clean_dataframe(df, endpoint_col, feature_cols):
required = ["PATID", "fold", TIME_COL, endpoint_col] + feature_cols
df = df[required].copy()
df[endpoint_col] = df[endpoint_col].astype(int)
df[TIME_COL] = pd.to_numeric(df[TIME_COL], errors="coerce")
for col in feature_cols:
df[col] = pd.to_numeric(df[col], errors="coerce")
before = len(df)
df = df.replace([np.inf, -np.inf], np.nan)
df = df.dropna(subset=[TIME_COL, endpoint_col] + feature_cols)
after = len(df)
if after < before:
print(f"[warning] Dropped rows with missing/invalid values: {before - after} / {before}")
return df
def split_dataframe(df, train_folds, val_folds, test_folds):
train_df = df[df["fold"].isin(train_folds)].copy()
val_df = df[df["fold"].isin(val_folds)].copy()
test_df = df[df["fold"].isin(test_folds)].copy()
if len(train_df) == 0:
raise ValueError("Train split is empty.")
if len(val_df) == 0:
print("[warning] Val split is empty.")
if len(test_df) == 0:
print("[warning] Test split is empty.")
return train_df, val_df, test_df
def filter_low_variance_features(train_df, feature_cols, min_std):
std = train_df[feature_cols].std(axis=0, ddof=0)
keep_cols = std[std > min_std].index.tolist()
drop_cols = [c for c in feature_cols if c not in keep_cols]
if drop_cols:
print(
f"[info] Dropping low-variance features: "
f"{len(drop_cols)} / {len(feature_cols)}"
)
if len(keep_cols) == 0:
raise ValueError("All features were dropped by low-variance filtering.")
return keep_cols, drop_cols
def get_deep_feature_block(col):
if col.startswith("LE_DRUS_"):
return "LE_DRUS"
if col.startswith("RE_DRUS_"):
return "RE_DRUS"
if col.startswith("LE_PIG_"):
return "LE_PIG"
if col.startswith("RE_PIG_"):
return "RE_PIG"
return None
def rank_features_by_univariate_cindex(train_df, endpoint_col, feature_cols):
_, concordance_index = require_lifelines()
times = train_df[TIME_COL].values
events = train_df[endpoint_col].values.astype(int)
scores = []
for col in feature_cols:
values = train_df[col].values.astype(float)
if not np.all(np.isfinite(values)):
continue
if np.std(values) < 1e-8:
continue
try:
c = concordance_index(
event_times=times,
predicted_scores=values,
event_observed=events,
)
score = max(float(c), 1.0 - float(c))
scores.append((col, score, float(c)))
except Exception:
continue
return sorted(scores, key=lambda x: x[1], reverse=True)
def select_top_k_features(train_df, endpoint_col, feature_cols, top_k):
if top_k is None:
return feature_cols, []
if top_k <= 0:
raise ValueError("--top-k must be positive.")
if top_k >= len(feature_cols):
print(f"[info] --top-k {top_k} >= n_features {len(feature_cols)}; keeping all.")
return feature_cols, []
scores = rank_features_by_univariate_cindex(train_df, endpoint_col, feature_cols)
if len(scores) == 0:
raise ValueError("Could not rank any features for top-k selection.")
selected = [x[0] for x in scores[:top_k]]
dropped = [c for c in feature_cols if c not in selected]
print(f"[info] Selected top {len(selected)} / {len(feature_cols)} features")
print("[info] Top selected features:")
for col, score, raw_c in scores[: min(10, len(scores))]:
print(f" {col}: score={score:.4f}, raw_c={raw_c:.4f}")
return selected, dropped
def select_top_k_per_block_features(train_df, endpoint_col, feature_cols, top_k_per_block):
if top_k_per_block is None:
return feature_cols, []
if top_k_per_block <= 0:
raise ValueError("--top-k-per-block must be positive.")
block_to_cols = {
"LE_DRUS": [],
"RE_DRUS": [],
"LE_PIG": [],
"RE_PIG": [],
"OTHER": [],
}
for col in feature_cols:
block = get_deep_feature_block(col)
if block is None:
block_to_cols["OTHER"].append(col)
else:
block_to_cols[block].append(col)
selected = []
for block in ["LE_DRUS", "RE_DRUS", "LE_PIG", "RE_PIG"]:
scores = rank_features_by_univariate_cindex(
train_df=train_df,
endpoint_col=endpoint_col,
feature_cols=block_to_cols[block],
)
chosen = scores[:top_k_per_block]
chosen_cols = [x[0] for x in chosen]
selected.extend(chosen_cols)
print(f"[info] Selected {len(chosen_cols)} features from {block}")
for col, score, raw_c in chosen[: min(5, len(chosen))]:
print(f" {col}: score={score:.4f}, raw_c={raw_c:.4f}")
other_cols = block_to_cols["OTHER"]
selected.extend(other_cols)
dropped = [c for c in feature_cols if c not in selected]
print(
f"[info] Block-balanced selection kept {len(selected)} / {len(feature_cols)} features "
f"including {len(other_cols)} non-deep features"
)
if len(selected) == 0:
raise ValueError("Block-balanced feature selection selected no features.")
return selected, dropped
def scale_features(train_df, val_df, test_df, feature_cols):
scaler = StandardScaler()
train_x = scaler.fit_transform(train_df[feature_cols].values)
if not np.all(np.isfinite(train_x)):
raise ValueError("Scaled train features contain NaN or Inf.")
train_scaled = train_df.copy()
val_scaled = val_df.copy()
test_scaled = test_df.copy()
train_scaled.loc[:, feature_cols] = train_x
if len(val_scaled):
val_x = scaler.transform(val_scaled[feature_cols].values)
if not np.all(np.isfinite(val_x)):
raise ValueError("Scaled val features contain NaN or Inf.")
val_scaled.loc[:, feature_cols] = val_x
if len(test_scaled):
test_x = scaler.transform(test_scaled[feature_cols].values)
if not np.all(np.isfinite(test_x)):
raise ValueError("Scaled test features contain NaN or Inf.")
test_scaled.loc[:, feature_cols] = test_x
return train_scaled, val_scaled, test_scaled, scaler
def make_cox_dataframe(df, endpoint_col, feature_cols):
cols = [TIME_COL, endpoint_col] + feature_cols
out = df[cols].copy()
out = out.rename(columns={TIME_COL: "duration", endpoint_col: "event"})
return out
def fit_cox(train_df, endpoint_col, feature_cols, penalizer, l1_ratio, show_progress):
CoxPHFitter, _ = require_lifelines()
cox_df = make_cox_dataframe(train_df, endpoint_col, feature_cols)
if not np.all(np.isfinite(cox_df[feature_cols].values)):
raise ValueError("Cox training matrix contains NaN or Inf before fitting.")
model = CoxPHFitter(
penalizer=penalizer,
l1_ratio=l1_ratio,
)
if show_progress:
model.fit(
cox_df,
duration_col="duration",
event_col="event",
show_progress=True,
)
else:
with open(os.devnull, "w") as devnull:
with contextlib.redirect_stdout(devnull), contextlib.redirect_stderr(devnull):
model.fit(
cox_df,
duration_col="duration",
event_col="event",
show_progress=False,
)
return model
def evaluate_split(model, df, endpoint_col, feature_cols, split_name):
_, concordance_index = require_lifelines()
if len(df) == 0:
return {
"split": split_name,
"n": 0,
"events": 0,
"c_index": None,
}, pd.DataFrame()
x = df[feature_cols]
risk_score = model.predict_partial_hazard(x).values.reshape(-1)
if not np.all(np.isfinite(risk_score)):
raise ValueError(f"{split_name} risk scores contain NaN or Inf.")
c_index = concordance_index(
event_times=df[TIME_COL].values,
predicted_scores=-risk_score,
event_observed=df[endpoint_col].values,
)
pred_df = pd.DataFrame(
{
"PATID": df["PATID"].values,
"fold": df["fold"].values,
"time": df[TIME_COL].values,
"event": df[endpoint_col].values,
"risk_score": risk_score,
}
)
horizons = [1, 2, 3, 4, 5]
surv = model.predict_survival_function(x, times=horizons)
for year in horizons:
survival_prob = surv.loc[year].values
pred_df[f"survival_{year}y"] = survival_prob
pred_df[f"risk_{year}y"] = 1.0 - survival_prob
metrics = {
"split": split_name,
"n": int(len(df)),
"events": int(df[endpoint_col].sum()),
"c_index": float(c_index),
}
return metrics, pred_df
def save_json(obj, path):
with open(path, "w") as f:
json.dump(obj, f, indent=2)
def save_pickle(obj, path):
with open(path, "wb") as f:
pickle.dump(obj, f)
def main():
suppress_noisy_warnings()
args = parse_args()
if args.top_k is not None and args.top_k_per_block is not None:
raise ValueError("Use either --top-k or --top-k-per-block, not both.")
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
endpoint_col = ENDPOINTS[args.endpoint]
print(f"Endpoint: {args.endpoint} ({endpoint_col})")
print(f"Feature set: {args.feature_set}")
df = load_json_rows(args.json)
print(f"Loaded rows: {len(df)}")
if args.feature_set.startswith("deep"):
df = merge_features(df, args.features)
print(f"Rows after feature merge: {len(df)}")
initial_feature_cols = get_feature_columns(df, args.feature_set)
feature_cols = list(initial_feature_cols)
print(f"Initial number of features: {len(feature_cols)}")
df = clean_dataframe(df, endpoint_col, feature_cols)
print(f"Rows after cleaning: {len(df)}")
train_df, val_df, test_df = split_dataframe(
df,
train_folds=args.train_folds,
val_folds=args.val_folds,
test_folds=args.test_folds,
)
print(
"Split sizes: "
f"train={len(train_df)}, val={len(val_df)}, test={len(test_df)}"
)
print(
"Events: "
f"train={int(train_df[endpoint_col].sum())}, "
f"val={int(val_df[endpoint_col].sum())}, "
f"test={int(test_df[endpoint_col].sum())}"
)
feature_cols, dropped_low_variance = filter_low_variance_features(
train_df=train_df,
feature_cols=feature_cols,
min_std=args.min_std,
)
print(f"Features after variance filter: {len(feature_cols)}")
dropped_top_k = []
dropped_top_k_per_block = []
if args.top_k_per_block is not None:
feature_cols, dropped_top_k_per_block = select_top_k_per_block_features(
train_df=train_df,
endpoint_col=endpoint_col,
feature_cols=feature_cols,
top_k_per_block=args.top_k_per_block,
)
print(f"Features after block-balanced top-k selection: {len(feature_cols)}")
else:
feature_cols, dropped_top_k = select_top_k_features(
train_df=train_df,
endpoint_col=endpoint_col,
feature_cols=feature_cols,
top_k=args.top_k,
)
print(f"Features after top-k selection: {len(feature_cols)}")
train_df, val_df, test_df, scaler = scale_features(
train_df,
val_df,
test_df,
feature_cols,
)
model = fit_cox(
train_df=train_df,
endpoint_col=endpoint_col,
feature_cols=feature_cols,
penalizer=args.penalizer,
l1_ratio=args.l1_ratio,
show_progress=args.show_progress,
)
metrics = {}
for split_name, split_df in [
("train", train_df),
("val", val_df),
("test", test_df),
]:
split_metrics, split_preds = evaluate_split(
model=model,
df=split_df,
endpoint_col=endpoint_col,
feature_cols=feature_cols,
split_name=split_name,
)
metrics[split_name] = split_metrics
if len(split_preds):
split_preds.to_csv(output_dir / f"{split_name}_predictions.csv", index=False)
config = {
"json": args.json,
"features": args.features,
"endpoint": args.endpoint,
"endpoint_col": endpoint_col,
"feature_set": args.feature_set,
"n_features_initial": int(len(initial_feature_cols)),
"n_features_final": int(len(feature_cols)),
"feature_cols": feature_cols,
"dropped_low_variance": dropped_low_variance,
"dropped_top_k": dropped_top_k,
"dropped_top_k_per_block": dropped_top_k_per_block,
"train_folds": args.train_folds,
"val_folds": args.val_folds,
"test_folds": args.test_folds,
"penalizer": args.penalizer,
"l1_ratio": args.l1_ratio,
"min_std": args.min_std,
"top_k": args.top_k,
"top_k_per_block": args.top_k_per_block,
}
save_pickle(model, output_dir / "cox_model.pkl")
save_pickle(scaler, output_dir / "scaler.pkl")
save_json(metrics, output_dir / "metrics.json")
save_json(config, output_dir / "config.json")
print("\nMetrics")
print(json.dumps(metrics, indent=2))
print(f"\nSaved outputs to: {output_dir}")
if __name__ == "__main__":
main()