10  Module 4: Survival and Prognostic Modeling — Advanced

Using the PMID37633276 example data, the advanced PrognosiX workflow keeps development and validation cohorts separate and covers univariable Cox and LASSO-based feature selection, survival-learner benchmarking, tuning, Kaplan–Meier risk groups, subgroup analysis, time-dependent AUC, calibration, nomograms, robustness checks, decision curves, SHAP, and deployment.

10.1 Important Notes

The optional development check runs only when the referenced local package path exists; GitHub and installed-package users can leave it skipped. Main settings are in OUTPUT_ROOT, the analysis version, outcome definitions, and the data-import section. Fix the prediction horizons and clinical decision thresholds in the analysis plan before examining validation results.

10.2 Step-by-step Workflow

The workflow is divided into short stages. Each note explains the purpose, key checks, and interpretation limits. Generated plots and tables appear directly below their code chunk.

10.2.1 About this chapter

Audience. Advanced users building and validating a clinical survival model with all diagnostic and explanatory tools.

Input. Reads data/PMID37633276_DIA_plasmaproomic.xlsx (sheet Table S1B) directly.

The chapter runs end to end as one session:

  1. CONFIG & Environment Setup
  2. Data Preparation (custom Excel data)
  3. Feature Selection (ONLY on training set)
  4. Algorithm Benchmarking & Hyperparameter Tuning
  5. Model Evaluation
  6. Robustness Analysis
  7. DCA & SHAP
  8. Save Final PrognosiX Object
  9. Deployment: Prediction for New Samples
  10. Final Report

10.2.2 0 CONFIG & Environment Setup

This section defines seeds, output paths, and dependencies. Update these paths and key parameters first when using new data.

Code
# 0. CONFIG & Environment Setup
# ---------------------------------------------------------------------------
set.seed(2025)

# Define version and output root
VERSION <- "Advanced"
OUTPUT_ROOT <- "./PrognosiX_Output"
OUTPUT_DIR <- file.path(OUTPUT_ROOT, VERSION)

# Load required packages
library(icare)
required_pkgs <- c("mlr3", "mlr3proba", "mlr3viz", "mlr3learners", 
                   "survival", "survminer", "ggplot2", "caret", "dplyr",
                   "tidyr", "rms", "risksetROC", "survex", "dcurves",
                   "readxl", "patchwork", "ggrepel", "maxstat")
missing_pkgs <- required_pkgs[!sapply(required_pkgs, requireNamespace, quietly = TRUE)]
if (length(missing_pkgs)) install.packages(missing_pkgs)
invisible(lapply(required_pkgs, library, character.only = TRUE))

# Register OUTPUT_DIR with the package rather than defining a local sub_dir().
# icare already ships sub_dir(); a local definition shadows it only inside this
# script, so functions INSIDE the package that call sub_dir() themselves (e.g.
# plot_tdROC_comparison(), plot_multitime_calibration()) would still resolve it
# in the icare namespace and write to a session-temp directory. Calling
# set_output_root() points both this script and the package at the same tree.
set_output_root(OUTPUT_DIR)

# Create version root and standard subfolders
sub_dir()
sub_dir("data")
sub_dir("01_feature_selection")
sub_dir("02_model")
sub_dir("03_evaluation")
sub_dir("04_robustness")
sub_dir("05_xai")

# ---------------------------------------------------------------------------

10.2.3 1 Data Preparation (custom Excel data)

This section imports and organizes the data. Check dimensions and variable coding before continuing.

Code
# 1. Data Preparation (custom Excel data)
# ---------------------------------------------------------------------------
raw_dis <- as.data.frame(read_excel("data/PMID37633276_DIA_plasmaproomic.xlsx", sheet = "Table S1B"))
raw_dis <- raw_dis[!grepl(x = raw_dis$`Case ID`, pattern = "^KC"), ]

# Extract numeric features
mat <- raw_dis[, c("White blood cell count", 
                   "Lymphocytes%", "Monocytes%", "Neutrophilic granulocyte%", "Eosinophil%", 
                   "Basophil%", "Lymphocytes count", "Monocytes count", "Neutrophilic granulocyte count", 
                   "Eosinophil count", "Basophil count", "Red blood cell count", 
                   "Hemoglobin", "Hematocrit", "Mean red blood cell volume", "Mean cell hemoglobin", 
                   "Mean corpuscular hemoglobin concentration", "Red blood cell distribution width CV", 
                   "Red blood cell distribution width SD", "Platelet count", "Mean platelet volume", 
                   "PCT", "Platelet volume distribution width")]

index <- grepl(x = raw_dis$`Case ID`, pattern = "^HC")

inf <- raw_dis[, c("Case ID", "Sample type", "Plasma proteome ID", "Tissue proteome ID", 
                   "Gender", "Age at diagnosis", "Tobacco smoking history", "Drinking history", 
                   "Vital status", "Days to last followup", "Days until death", 
                   "Combined days to last followup or death", "Progressive-free survival_Status", 
                   "Progressive-free survival_Months", "Tumor location", "Tumor size (cm)", 
                   "T-category", "Lymph node involvement", "Distant metastasis", 
                   "TNM Stage", "Grade", "Morphology")]

# The group label lives in `inf` ONLY -- see the Module 1 chapter for why.
# Keeping it out of the feature matrix also frees its type: readable labels are
# fine, and they become the Table 1 column headers.
inf$group <- factor(ifelse(index, "Healthy Control", "Case"),
                    levels = c("Healthy Control", "Case"))
mat <- as.matrix(mat)
storage.mode(mat) <- "numeric"

rownames(mat) <- inf$`Case ID`
rownames(inf) <- inf$`Case ID`
mat <- as.data.frame(mat)
mat$age <- inf$`Age at diagnosis`
mat$sex <- inf$Gender

inf$time <- inf$`Combined days to last followup or death`
inf$event <- inf$`Vital status`
inf$event <- ifelse(inf$event == "Dead", 1, 0)

# Keep only disease samples (comparing against the label text, not a code --
# the group column is a factor, so `== 1` would silently match nothing)
inf <- inf[inf$group == "Case", ]
mat <- mat[rownames(inf), ]
mat$time <- inf$time
mat$status <- inf$event

stat_obj <- CreateStatObject(
  clean.data = mat,
  info.data = inf,
  group_col = "group",
  na.action = "allow"
)
Data prepared for clean.data 
Data prepared for info.data 
Final info.data prepared.
Stat object created.
Code
cat("Stat object created.\n")
Stat object created.
Code
prog <- Stat_to_PrognosiX(stat_obj, "time", "status",
                          na_action = "omit",
                          min_events = 10,
                          verbose = TRUE)
PrognosiX object created successfully.
  Features: 24 | Samples: 362
  Metadata columns: 27 (including time/status)
Code
# ---------------------------------------------------------------------------

10.2.4 2 Feature Selection (ONLY on training set)

This section selects candidate features. Consider effect size, stability, and biological plausibility rather than a single significance measure.

Code
# 2. Feature Selection (ONLY on training set)
# ---------------------------------------------------------------------------
task_full <- surv_extract_task(prog)
n <- task_full$nrow
train_idx <- caret::createDataPartition(task_full$data()[[task_full$target_names[2]]],
                                        p = 0.7, list = FALSE)
val_idx <- setdiff(seq_len(n), train_idx)

train_task <- task_full$clone()$filter(train_idx)
val_task <- task_full$clone()$filter(val_idx)

methods_to_run <- c("uni_cox", "lasso", "rf_imp", "ridge", "enet", "vimp")
feat_sel <- surv_feature_selection_multi(
  object = train_task,
  methods = methods_to_run,
  p_threshold = 0.1,
  top_ratio = 0.5,
  combine = "union",
  verbose = TRUE
)

[Multi-Feature Selection] Methods: uni_cox, lasso, rf_imp, ridge, enet, vimp 
  - Running univariate Cox (p < 0.1 )...
  - Running glmnet family (LASSO, Ridge, Elastic Net)...
  - Running Random Forest (ranger) importance...
  - Running VIMP variable importance...

[Combined] UNION selection: 17 features out of 24
Code
selected_feats <- feat_sel$selected
cat("Selected features:", paste(selected_feats, collapse = ", "), "\n")
Selected features: Hematocrit, Hemoglobin, Lymphocytes, Lymphocytes_count, Mean_cell_hemoglobin, Monocytes, Monocytes_count, Neutrophilic_granulocyte, Neutrophilic_granulocyte_count, Red_blood_cell_distribution_width_CV, Red_blood_cell_distribution_width_SD, age, Red_blood_cell_count, Basophil, Platelet_count, Mean_corpuscular_hemoglobin_concentration, Basophil_count 
Code
write.csv(feat_sel$method_table,
          file = file.path(sub_dir("01_feature_selection"), "feature_selection_methods.csv"),
          row.names = FALSE)

train_task <- train_task$select(selected_feats)
val_task <- val_task$select(selected_feats)

# Save train/validation data
train_data <- as.data.frame(train_task$data())
val_data <- as.data.frame(val_task$data())
write.csv(train_data, file = file.path(sub_dir("data"), "training_data.csv"), row.names = FALSE)
write.csv(val_data,   file = file.path(sub_dir("data"), "validation_data.csv"), row.names = FALSE)

# ---------------------------------------------------------------------------

10.2.5 3 Algorithm Benchmarking & Hyperparameter Tuning

This section trains and compares candidate models. Select models using discrimination, calibration, stability, and clinical utility together.

Code
# 3. Algorithm Benchmarking & Hyperparameter Tuning
# ---------------------------------------------------------------------------
learners_list <- list(
  surv_get_learner("surv.coxph",     train_task),
  surv_get_learner("surv.cv_glmnet", train_task),
  surv_get_learner("surv.ranger",    train_task)
)
bmr1 <- surv_run_algorithm_benchmark(train_task, learners_list)
dev.new()
bmr2 <- surv_benchmark_learners(train_task, learner_ids = c("surv.coxph", "surv.ranger"), tune = FALSE)
bmr2_summary <- surv_summarize_benchmark(bmr2)
write.csv(bmr2_summary, file = file.path(sub_dir("02_model"), "algorithm_benchmark.csv"), row.names = FALSE)

best_id <- "surv.ranger"
tune <- surv_train_and_tune(train_task, best_id, tuning_budget = 25)
best_lrn <- tune$learner
cv_cindex <- tune$cv_performance
cat("CV C-index after tuning:", round(cv_cindex, 4), "\n")
CV C-index after tuning: 0.6619 
Code
# ---------------------------------------------------------------------------

10.2.6 4 Model Evaluation

This section trains and compares candidate models. Select models using discrimination, calibration, stability, and clinical utility together.

Code
# 4. Model Evaluation
# ---------------------------------------------------------------------------
best_lrn$predict_type <- "distr"
train_perf <- surv_evaluate_model(best_lrn, train_task,
                                  measures = list(msr("surv.cindex"), msr("surv.graf")))
cat("Training apparent C-index:", round(train_perf$surv.cindex, 4), "\n")
Training apparent C-index: 0.8791 
Code
val_pred <- best_lrn$predict(val_task)
val_cindex <- val_pred$score(msr("surv.cindex"))
cat("Validation C-index:", round(val_cindex, 4), "\n")
Validation C-index: 0.5 
Code
# Save performance metrics
perf_df <- data.frame(
  Dataset = c("Training", "Validation"),
  C_index = c(train_perf$surv.cindex, val_cindex)
)
write.csv(perf_df, file = file.path(sub_dir("03_evaluation"), "performance.csv"), row.names = FALSE)

# ---- 4.1 KM Curves ----
# IMPORTANT: The following KM curves and the cutoff derived from the training set
# are APPARENT (optimistic) performance. They are useful for internal exploration
# but MUST be re‑evaluated on an independent validation set before reporting.
pred_train <- best_lrn$predict(train_task)
cut_df <- as.data.frame(train_task$data())
cut_df$risk <- pred_train$crank

# Compute optimal cutoff using multiple methods.
# For publication, consider using 'maxstat' (Hothorn & Lausen, 2003) with
# bootstrap resampling to obtain a stable, data‑driven threshold.
# The 'p_optimize' method is also provided for historical consistency.
cut_res <- tryCatch({
  surv_cutpoint(cut_df, time = "time", event = "status", variables = "risk")
}, error = function(e) {
  warning("surv_cutpoint failed: ", e$message, ". Using median split.")
  list(cutpoint = data.frame(cutpoint = median(cut_df$risk, na.rm = TRUE)))
})
opt_cutoff <- cut_res$cutpoint$cutpoint
cat("Optimal cutoff (from surv_cutpoint):", round(opt_cutoff, 4), "\n")
Optimal cutoff (from surv_cutpoint): 22.4282 
Code
# Generate KM plots for different cutoff methods
# Note: 'p_optimize' and 'maxstat' use bootstrap/maximally selected statistics
# and are exploratory; their p‑values should not be used as confirmatory.
km_median    <- surv_plot_risk_km(best_lrn, train_task, "median", risk_table = TRUE)
km_tertile   <- surv_plot_risk_km(best_lrn, train_task, "tertile")
km_quartile  <- surv_plot_risk_km(best_lrn, train_task, "quartile")
km_p_optimize <- surv_plot_risk_km(best_lrn, train_task, "p_optimize",
                                   n_boot = 100, fraction = 0.7)
km_maxstat   <- surv_plot_risk_km(best_lrn, train_task, "maxstat",
                                  n_boot = 100, minprop = 0.3)

# Save training KM plots
print(km_median)

print(km_tertile)

print(km_quartile)

print(km_p_optimize)

print(km_maxstat)

# Apply a fixed cutoff (here we use the median from training) to the validation set
train_cutoff <- get_cf(km_median)
if (is.null(train_cutoff)) train_cutoff <- opt_cutoff
km_val_fixed <- surv_plot_risk_km(best_lrn, val_task,
                                  cutoff_method = "custom",
                                  custom_cutoffs = train_cutoff,
                                  risk_table = TRUE)

print(km_val_fixed)

# ---- 4.2 Subgroup Forest (FIXED: use subsetted prog) ----
# Subset prog to training samples to match train_task rows
train_prog <- prog
train_prog@clean.data <- prog@clean.data[train_idx, , drop = FALSE]
train_prog@info.data <- prog@info.data[train_idx, , drop = FALSE]
train_prog@survival.data <- prog@survival.data[train_idx, , drop = FALSE]

clinical_cols <- colnames(train_prog@info.data)
# info.data names are normalised by CreateStatObject(): spaces and dots become
# underscores. Passing the dotted spelling would make intersect() drop
# everything except "Gender" and silently shrink the subgroup forest.
candidate_vars <- c("Gender", "Tobacco_smoking_history", "Drinking_history", "TNM_Stage")
subgroup_vars <- intersect(candidate_vars, clinical_cols)

forest <- surv_plot_subgroup_forest(
  learner = best_lrn,
  object = train_task,
  subgroup_vars = subgroup_vars,
  prog = train_prog,
  save_plot = TRUE,
  save_dir = sub_dir("03_evaluation"),
  file_name = "subgroup_forest.pdf"
)
print(forest)
$plot

$data
                     Label   N       HR     Lower     Upper            P
1                   Gender  NA       NA        NA        NA           NA
2                   Female 101 1.196770 1.1394522  1.256970 7.313916e-13
3                     Male 153 1.097912 1.0769747  1.119257 1.934766e-21
4  Tobacco_smoking_history  NA       NA        NA        NA           NA
5                        0 178 1.127559 1.1028967  1.152773 1.941129e-26
6                        1  76 1.099281 1.0687141  1.130721 4.741393e-11
7         Drinking_history  NA       NA        NA        NA           NA
8                        0 222 1.116181 1.0965843  1.136127 4.935843e-34
9                        1  32 1.096843 1.0441842  1.152158 2.311103e-04
10               TNM_Stage  NA       NA        NA        NA           NA
11                  T2N0M0  54 1.107238 1.0659896  1.150082 1.448284e-07
12                  T3N0M0  83 1.116426 1.0827190  1.151183 1.909931e-12
13                  T3N1M0   6 2.185277 0.4728211 10.099874 3.168661e-01
14                  T3N2M0  11 1.023321 0.9460484  1.106906 5.649632e-01
15                  TxNxM1   5 0.901045 0.7149484  1.135581 3.773508e-01
    RowType            disp_hr disp_p disp_n
1    header                                 
2  subgroup  1.20 (1.14, 1.26)  0.000    101
3  subgroup  1.10 (1.08, 1.12)  0.000    153
4    header                                 
5  subgroup  1.13 (1.10, 1.15)  0.000    178
6  subgroup  1.10 (1.07, 1.13)  0.000     76
7    header                                 
8  subgroup  1.12 (1.10, 1.14)  0.000    222
9  subgroup  1.10 (1.04, 1.15)  0.000     32
10   header                                 
11 subgroup  1.11 (1.07, 1.15)  0.000     54
12 subgroup  1.12 (1.08, 1.15)  0.000     83
13 subgroup 2.19 (0.47, 10.10)  0.317      6
14 subgroup  1.02 (0.95, 1.11)  0.565     11
15 subgroup  0.90 (0.71, 1.14)  0.377      5
                                          
1                                         
2                                         
3                                         
4                                         
5                                         
6                                         
7                                         
8                                         
9                                         
10                                        
11                                        
12                                        
13                                        
14                                        
15                                        

$fits
$fits$Gender_Male
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

               coef exp(coef) se(coef)     z      p
risk_score 0.093410  1.097912 0.009824 9.508 <2e-16

Likelihood ratio test=87.01  on 1 df, p=< 2.2e-16
n= 153, number of events= 51 

$fits$Gender_Female
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z        p
risk_score 0.17963   1.19677  0.02504 7.173 7.31e-13

Likelihood ratio test=85.28  on 1 df, p=< 2.2e-16
n= 101, number of events= 34 

$fits$Tobacco_smoking_history_1
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z        p
risk_score 0.09466   1.09928  0.01439 6.579 4.74e-11

Likelihood ratio test=41.53  on 1 df, p=1.16e-10
n= 76, number of events= 24 

$fits$Tobacco_smoking_history_0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z      p
risk_score 0.12006   1.12756  0.01128 10.64 <2e-16

Likelihood ratio test=122.5  on 1 df, p=< 2.2e-16
n= 178, number of events= 61 

$fits$Drinking_history_1
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z        p
risk_score 0.09244   1.09684  0.02510 3.682 0.000231

Likelihood ratio test=13.2  on 1 df, p=0.0002799
n= 32, number of events= 10 

$fits$Drinking_history_0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

               coef exp(coef) se(coef)     z      p
risk_score 0.109913  1.116181 0.009037 12.16 <2e-16

Likelihood ratio test=148.8  on 1 df, p=< 2.2e-16
n= 222, number of events= 75 

$fits$TNM_Stage_T3N2M0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z     p
risk_score 0.02305   1.02332  0.04006 0.575 0.565

Likelihood ratio test=0.32  on 1 df, p=0.5701
n= 11, number of events= 7 

$fits$TNM_Stage_T3N1M0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

             coef exp(coef) se(coef)     z     p
risk_score 0.7817    2.1853   0.7810 1.001 0.317

Likelihood ratio test=8.37  on 1 df, p=0.003812
n= 6, number of events= 5 

$fits$TNM_Stage_T3N0M0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z        p
risk_score 0.11013   1.11643  0.01564 7.041 1.91e-12

Likelihood ratio test=52.77  on 1 df, p=3.742e-13
n= 83, number of events= 33 

$fits$TNM_Stage_T2N0M0
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)     z        p
risk_score 0.10187   1.10724  0.01937 5.259 1.45e-07

Likelihood ratio test=30.65  on 1 df, p=3.095e-08
n= 54, number of events= 15 

$fits$TNM_Stage_TxNxM1
Call:
survival::coxph(formula = survival::Surv(subset_data[[time_col]], 
    subset_data[[status_col]]) ~ risk_score, data = subset_data)

              coef exp(coef) se(coef)      z     p
risk_score -0.1042    0.9010   0.1180 -0.883 0.377

Likelihood ratio test=0.78  on 1 df, p=0.3782
n= 5, number of events= 5 


$xlim
[1]  0.07532262 63.39973251

$ticks_at
[1]  0.1353353  1.0000000  7.3890561 54.5981500

$ticks_digits
[1] 2
Code
# ---- 4.3 Time-dependent AUC ----
# Note: These are also apparent performance when computed on the training set.
auc_train <- surv_plot_time_dependent_auc(best_lrn, train_task)
auc_val   <- surv_plot_time_dependent_auc(best_lrn, val_task)
auc_cmp   <- surv_plot_comparison_auc(best_lrn, train_task, val_task)
ggsave(file.path(sub_dir("03_evaluation"), "AUC_comparison.pdf"), auc_cmp, width = 8, height = 5)

# ---- 4.4 Calibration ----
cal_train <- surv_plot_calibration(best_lrn, train_task, time_point = 200)

========== Calibration Metrics at t = 200 ==========
** APPARENT (training-set) calibration -- interpret with caution **
Calibration slope    (ideal 1): 2.1034
Calibration intercept (ideal 0): -0.6006
R-squared:                       0.9148
Mean Absolute Error (MAE):       0.2256
Integrated Calibration Index:    0.1723
E50 (median abs error):          0.2450
E90 (90th pct abs error):        0.2857
====================================================
Code
cal_cmp   <- surv_plot_comparison_calibration(best_lrn, train_task, val_task, time_point = 200)

========== Calibration Metrics at t = 200 ==========
** TRAINING (apparent -- optimistic for flexible learners): **
  Slope=2.1034 | Intercept=-0.6006 | R2=0.9148 | MAE=0.2256 | ICI=0.1723
** VALIDATION (unbiased estimate): **
  Slope=0.0372 | Intercept=0.6140 | R2=0.0018 | MAE=0.1911 | ICI=0.1478
  Note: Training calibration is always expected to be more optimistic.
=================================================================
Code
ggsave(file.path(sub_dir("03_evaluation"), "Calibration_train.pdf"), cal_train, width = 8, height = 6)
ggsave(file.path(sub_dir("03_evaluation"), "Calibration_comparison.pdf"), cal_cmp, width = 8, height = 6)

# ---- 4.5 External Validation (using validation set) ----
val_data_ext <- as.data.frame(task_full$data())[val_idx, selected_feats, drop = FALSE]
val_data_ext$time <- task_full$data()[[task_full$target_names[1]]][val_idx]
val_data_ext$status <- task_full$data()[[task_full$target_names[2]]][val_idx]
val_res <- surv_predict_on_validation(best_lrn, val_data_ext, train_task)
val_cindex_alt <- val_res$prediction$score(msr("surv.cindex"))
cat("Validation C-index (alt):", round(val_cindex_alt, 4), "\n")
Validation C-index (alt): 0.5 
Code
# ---- 4.6 Nomogram ----
# The revized surv_generate_nomogram now includes a proportional hazards test
# (cox.zph) which will be printed to the console. If any variable violates
# the PH assumption, a warning is shown.
nom <- surv_generate_nomogram(train_task,
                              selected_features = head(selected_feats, 5),
                              time_points = c(90, 180, 365),
                              time_unit = "days")

========== Proportional Hazards Assumption Test ==========
                      chisq df     p
Hematocrit           0.5235  1 0.469
Hemoglobin           0.0765  1 0.782
Lymphocytes          1.5817  1 0.209
Lymphocytes_count    2.9681  1 0.085
Mean_cell_hemoglobin 0.0819  1 0.775
GLOBAL               5.3030  5 0.380
  If any p-value < 0.05, the PH assumption may be violated.
  Consider including time-dependent covariates or stratification.
===========================================================
Code
# Nomogram is drawn; optionally save via pdf().

# ---------------------------------------------------------------------------

10.2.7 5 Robustness Analysis

This section tests sensitivity to sampling, censoring, and feature changes. Robust conclusions should remain similar under reasonable perturbations.

Code
# 5. Robustness Analysis
# ---------------------------------------------------------------------------
stab <- surv_analyze_feature_stability(train_task, "time", "status", n_repeat = 20, alpha = 1)
cat("Stability index:", round(stab$stability_index, 4), "\n")
Stability index: 0.6146 
Code
ggsave(file.path(sub_dir("04_robustness"), "stability_plot.pdf"), stab$plot, width = 8, height = 6)
write.csv(stab$frequencies, file.path(sub_dir("04_robustness"), "stability_frequencies.csv"), row.names = FALSE)

abl <- surv_analyze_feature_ablation(train_task, best_id, selected_feats)
ggsave(file.path(sub_dir("04_robustness"), "ablation_plot.pdf"), abl$plot, width = 8, height = 6)
write.csv(abl$results, file.path(sub_dir("04_robustness"), "ablation_results.csv"), row.names = FALSE)

sens_sample <- surv_analyze_model_sensitivity(train_task, best_id, analysis_type = "sample_size")
sens_censor <- surv_analyze_model_sensitivity(train_task, best_id, analysis_type = "censoring")
ggsave(file.path(sub_dir("04_robustness"), "sensitivity_sample.pdf"), sens_sample$plot, width = 8, height = 5)
ggsave(file.path(sub_dir("04_robustness"), "sensitivity_censor.pdf"), sens_censor$plot, width = 8, height = 5)
write.csv(sens_sample$results, file.path(sub_dir("04_robustness"), "sensitivity_sample.csv"), row.names = FALSE)
write.csv(sens_censor$results, file.path(sub_dir("04_robustness"), "sensitivity_censor.csv"), row.names = FALSE)

# ---------------------------------------------------------------------------

10.2.8 6 DCA & SHAP

This section examines clinical utility and model explanations. Explanatory plots describe model behavior and do not establish causality.

Code
# 6. DCA & SHAP
# ---------------------------------------------------------------------------
val_time <- val_task$data()[[val_task$target_names[1]]]
val_status <- val_task$data()[[val_task$target_names[2]]]
event_times <- val_time[val_status == 1]
if (length(event_times) == 0) stop("No events in validation set.")
eval_time <- round(quantile(event_times, 0.5, na.rm = TRUE), 0)
if (eval_time < min(event_times)) eval_time <- min(event_times)
cat("Using eval_time =", eval_time, "for DCA\n")
Using eval_time = 27 for DCA
Code
dca_res <- plot_dca_survival(
  learners = list("Tuned" = best_lrn),
  object = val_task,
  eval_time = eval_time,
  clin_range = c(0.05, 0.5),
  print_stats = TRUE
)

========== DCA Summary (Clinical Range: [0.05, 0.5]) ==========
  Calculated using standard Kaplan-Meier survival adjustments via 'dcurves'.
 Model ClinRange_Min ClinRange_Max Max_NetBenefit Threshold_at_Max
 Tuned          0.05           0.5         0.0904             0.05
 Avg_NetBenefit Avg_NetBenefit_Gain
        -0.0157              0.1572
========================================================================
Code
ggsave(file.path(sub_dir("05_xai"), "dca_plot.pdf"), dca_res$plot, width = 7, height = 5)
write.csv(dca_res$table, file.path(sub_dir("05_xai"), "dca_table.csv"), row.names = FALSE)

shap_global <- surv_explain_shap(
  learner = best_lrn,
  task = val_task,
  type = "global",
  n_explain = 30,
  n_background = 10,
  n_timepoints = 10,
  n_top_features = 6,
  verbose = TRUE
)
[SurvSHAP] mode=global, n_explain=30, n_background=10
[SurvSHAP] Subsampling times: 29 -> 10 points (2.9x speedup)
[SurvSHAP] Computing GLOBAL SurvSHAP(t)...
[SurvSHAP] Complete. 30 observations x 17 features x 10 times.
Code
ggsave(file.path(sub_dir("05_xai"), "shap_global_bar.pdf"), shap_global$plots$bar_plot, width = 8, height = 6)
if (!is.null(shap_global$plots$line_plot)) {
  ggsave(file.path(sub_dir("05_xai"), "shap_global_line.pdf"), shap_global$plots$line_plot, width = 8, height = 6)
}
write.csv(shap_global$shap_long, file.path(sub_dir("05_xai"), "shap_values_global.csv"), row.names = FALSE)

beeswarm_plot <- surv_plot_shap_beeswarm(shap_global, top_n = 6, method = "beeswarm",
                                         title = "Global SurvSHAP (validation set)")
ggsave(file.path(sub_dir("05_xai"), "shap_beeswarm.pdf"), beeswarm_plot, width = 9, height = 6)

# ---------------------------------------------------------------------------

10.2.9 7 Save Final PrognosiX Object

This section saves model objects, tables, and figures for reproducibility, review, and reuse.

Code
# 7. Save Final PrognosiX Object
# ---------------------------------------------------------------------------
prog@best.model <- list(
  learner_id = best_id,
  learner = best_lrn,
  best_params = tune$best_params,
  cv_cindex = cv_cindex,
  features = selected_feats,
  cutoff = opt_cutoff,
  decision_type = "binary",
  train_cols = train_task$feature_names
)
prog@subgroup.risk <- list(
  benchmark_table = bmr1$table,
  stability = stab,
  ablation = abl,
  validation = list(cindex = val_cindex, n = val_task$nrow)
)
prog@split.data <- list(train_idx = train_idx, val_idx = val_idx)
saveRDS(prog, file = file.path(sub_dir("data"), "prog_obj_final.rds"))

# ---------------------------------------------------------------------------

10.2.10 8 Deployment: Prediction for New Samples

This section applies the trained object to new samples. Validate input format, performance, and target population in an independent cohort before deployment.

Code
# 8. Deployment: Prediction for New Samples
# ---------------------------------------------------------------------------
# Instead of the veteran example, use actual rows from your training data
# (or real new data) that contain all selected features.
# For demonstration, we take the first 2 rows of the original feature matrix.
#new_patients <- data.frame(
#  Mean_corpuscular_hemoglobin_concentration = c(33, 34),
#  Basophil_count = c(0.02, 0.03),
#  Monocytes = c(0.6, 0.7),
#  Neutrophilic_granulocyte = c(4.2, 5.1),
#  Red_blood_cell_distribution_width_CV = c(12.3, 11.8),
#  Mean_red_blood_cell_volume = c(89, 92),
#  Lymphocytes_count = c(1.8, 2.1),
#  Platelet_volume_distribution_width = c(12.0, 13.1),
#  Lymphocytes = c(1.8, 2.1),   # note: this may be same as Lymphocytes_count? check your data
#  Monocytes_count = c(0.6, 0.7),
#  Neutrophilic_granulocyte_count = c(4.2, 5.1),
#  Mean_platelet_volume = c(10.5, 11.0),
#  row.names = c("patient_1", "patient_2")
#)

#risk_scores <- predict_prognosix(prog, new_patients, impute = TRUE)
#pred_groups_med <- predict_risk_groups(prog, new_patients, cutoff_method = "median")
#pred_groups_cus <- predict_risk_groups(prog, new_patients,
#                                       cutoff_method = "custom",
#                                       custom_cutoffs = opt_cutoff)
#cat("Risk scores:\n"); print(risk_scores)
#cat("Median groups:\n"); print(pred_groups_med)
#cat("Custom groups:\n"); print(pred_groups_cus)

manager <- New_Prog_Manager(prog)

-- PrognosiX Manager ---------------------------------
  Algorithm  : surv.ranger
  CV C-index : 0.6619
  Features   : 17  (Hematocrit, Hemoglobin, Lymphocytes, Lymphocytes_count, ...)
  Training N : 362 (events: 113)
------------------------------------------------------
Code
if (interactive()) {
  use_app_theme_grey()
  set_prog_app_text(
    title = "Survival Risk Predictor",
    citation_text = "Data: PMID37633276_DIA_plasmaproomic cohort."
  )
  set_prog_app_theme(
    primary_color = "#2c7fb8", background_color = "#f0f4f8", sidebar_color = "#e9ecef",
    box_background = "#ffffff", label_color = "#2c7fb8",
    run_button_gradient_start = "#2c7fb8", run_button_gradient_end = "#1d4e6e",
    risk_high_color = "#d9534f", risk_medium_color = "#f0ad4e", risk_low_color = "#5cb85c",
    table_header_color = "#2c7fb8", font_family = "Arial, sans-serif", font_size_base = 14
  )
  launch_prog_deploy_app(
    manager,
    var_dict = data.frame(
      Feature = selected_feats,
      Description = paste("Predictor", seq_along(selected_feats)),
      Units = rep("--", length(selected_feats))
    ),
    project_info = list(
      abstract = paste("Prognostic model trained on", train_task$nrow, "patients."),
      citation = "PMID37633276_DIA_plasmaproomic cohort."
    )
  )
}

# ---------------------------------------------------------------------------

10.2.11 9 Final Report

This section saves model objects, tables, and figures for reproducibility, review, and reuse.

Code
# 9. Final Report
# ---------------------------------------------------------------------------
cat("\n========================================\n")

========================================
Code
cat("Module 4 (advanced, leakage‑free) finished successfully!\n")
Module 4 (advanced, leakage‑free) finished successfully!
Code
cat("Best model:", best_id,
    "| CV C-index (training):", round(cv_cindex, 4),
    "| Validation C-index:", round(val_cindex, 4), "\n")
Best model: surv.ranger | CV C-index (training): 0.6619 | Validation C-index: 0.5 
Code
cat("Optimal cutoff:", round(opt_cutoff, 4), "\n")
Optimal cutoff: 22.4282 
Code
cat("All results saved under:", OUTPUT_DIR, "\n")
All results saved under: ./PrognosiX_Output/Advanced 
Code
cat("========================================\n")
========================================