This file runs the full Objective 2 pipeline on the current analysis set: 23 patients with usable survival follow-up (13 deaths, 10 censored), out of the 64 patients in the Objective 1 harmonized multi-omics dataset (1,221 proteins, 29 metabolites, 1,250 features total).
Every step below was first run and checked interactively; this file
consolidates those checks into one reproducible script, with a fixed
random seed and the tie-handling method
(cox.ties = "efron", matching survival::coxph)
set explicitly throughout. Numbers here may differ very slightly from
earlier exploratory console output, because the console runs used
glmnet’s default tie method before this was fixed.
If your supervisor’s response on the 41 excluded patients changes the
analysis set, only the “Build the survival dataset” section below needs
updating; every later step re-runs on whatever setB and
X/y it produces.
Caution on knit time: the permutation test (500
shuffles) and the two stability-selection loops (200 resamples each) are
the slow steps, together taking roughly 15–20 minutes on a laptop.
cache = TRUE is set above so that later edits to text or
downstream chunks do not force a full re-run.
harm <- readRDS("harmonized_multiomics_dataset.rds")
clin <- readRDS("clinical_clean.rds")
standardize_id <- function(id) {
id <- str_trim(id)
prefix <- str_extract(id, "^[A-Za-z]+")
number <- str_extract(id, "\\d+")
paste0(toupper(prefix), sprintf("%03d", as.integer(number)))
}
surv_full <- clin %>%
mutate(id_std = standardize_id(linked_id)) %>%
filter(id_std %in% harm$id_std) %>%
mutate(event = case_when(
dead_alive == 0 ~ 1L, # dead_alive is inverted in the source file: 0 = dead
dead_alive == 1 ~ 0L, # 1 = alive
TRUE ~ NA_integer_
))
setB <- surv_full %>%
mutate(followup_use = case_when(
!is.na(followup) ~ followup,
# P034: Excel #VALUE! error in source file; recovered from test/last-visit dates
id_std == "P034" ~ as.numeric(date_of_last_visit_parsed - date_of_test_parsed),
TRUE ~ NA_real_
)) %>%
filter(!is.na(followup_use), !is.na(event)) %>%
mutate(stage_grp = if_else(pathology == "RPC", "Resectable", "Advanced"))
cat("Analysis set:", nrow(setB), "patients,", sum(setB$event), "events\n")
## Analysis set: 23 patients, 13 events
table(setB$pathology, setB$event, dnn = c("pathology", "event"))
## event
## pathology 0 1
## LAPC 2 3
## MPC 0 2
## RPC 8 8
# Descriptive follow-up
survfit(Surv(followup_use, 1 - event) ~ 1, data = setB) # reverse KM: median follow-up
## Call: survfit(formula = Surv(followup_use, 1 - event) ~ 1, data = setB)
##
## n events median 0.95LCL 0.95UCL
## [1,] 23 10 358 253 NA
survdiff(Surv(followup_use, event) ~ stage_grp, data = setB)
## Call:
## survdiff(formula = Surv(followup_use, event) ~ stage_grp, data = setB)
##
## N Observed Expected (O-E)^2/E (O-E)^2/V
## stage_grp=Advanced 7 5 5.02 7.62e-05 0.000145
## stage_grp=Resectable 16 8 7.98 4.80e-05 0.000145
##
## Chisq= 0 on 1 degrees of freedom, p= 1
The 41 patients excluded from setB were deliberately
left out: of them, 13 have a recorded death with no usable follow-up
start/end, 4 are recorded alive with no last-visit date, and 24 have no
recorded vital status at all. Adding the recorded deaths without being
able to censor the survivors on the same basis would bias the analysis
towards poor survival, so they are excluded here.
dat <- setB %>%
select(id_std, followup_use, event) %>%
inner_join(harm, by = "id_std")
X <- dat %>% select(-id_std, -followup_use, -event) %>% as.matrix()
rownames(X) <- dat$id_std
y <- cbind(time = dat$followup_use, status = dat$event)
stopifnot(nrow(X) == 23, sum(!is.finite(X)) == 0, sum(apply(X, 2, var) == 0) == 0)
dim(X)
## [1] 23 1250
# Stratified k-fold split: keeps events spread evenly across folds
strat_folds <- function(status, k = 5) {
fid <- integer(length(status))
for (s in unique(status)) {
idx <- which(status == s)
fid[idx] <- sample(rep(1:k, length.out = length(idx)))
}
fid
}
# Cross-validated deviance gain of the best model over a no-feature model,
# averaged over nrep independent fold splits
cv_gain <- function(X, y, nrep = 10, k = 5) {
lam <- glmnet(X, y, family = "cox", cox.ties = "efron")$lambda
res <- lapply(1:nrep, function(r) {
fid <- strat_folds(y[, "status"], k)
cv.glmnet(X, y, family = "cox", lambda = lam, foldid = fid, cox.ties = "efron")$cvm
})
L <- min(lengths(res))
m <- rowMeans(sapply(res, function(v) v[1:L]))
m[1] - min(m)
}
# Honest cross-validated C-index: feature selection is repeated inside every
# training fold, so this is not inflated by in-sample selection
cv_cindex <- function(X, y, nrep = 20, k = 5, seed_offset = 500) {
sapply(1:nrep, function(r) {
set.seed(seed_offset + r)
fid <- strat_folds(y[, "status"], k)
lp <- numeric(nrow(X))
for (f in 1:k) {
tr <- fid != f
cvf <- cv.glmnet(X[tr, ], y[tr, ], family = "cox", nfolds = 4, cox.ties = "efron")
lp[!tr] <- as.numeric(predict(cvf, X[!tr, ], s = "lambda.min"))
}
concordance(Surv(y[, "time"], y[, "status"]) ~ lp, reverse = TRUE)$concordance
})
}
The penalty strength (lambda) is chosen by 5-fold cross-validation, repeated 100 times with independent fold splits, because a single split is too noisy with only 13 events.
lam <- glmnet(X, y, family = "cox", cox.ties = "efron")$lambda
res <- lapply(1:100, function(r) {
set.seed(1000 + r)
fid <- strat_folds(y[, "status"], 5)
cv.glmnet(X, y, family = "cox", lambda = lam, foldid = fid, cox.ties = "efron")$cvm
})
L <- min(lengths(res))
mean_cvm <- rowMeans(sapply(res, function(v) v[1:L]))
lam_best <- lam[which.min(mean_cvm)]
c(null_deviance = mean_cvm[1], best_deviance = min(mean_cvm),
gain = mean_cvm[1] - min(mean_cvm))
## null_deviance best_deviance gain
## 3.5958439 3.4186484 0.1771955
fit_all <- glmnet(X, y, family = "cox", lambda = lam, cox.ties = "efron")
b <- as.matrix(coef(fit_all, s = lam_best))
b <- b[b[, 1] != 0, , drop = FALSE]
b
## 1
## dag1 -0.4147170
## gstk1 0.1806352
## sptan1 0.4563536
The model is refitted 200 times, each on a random 70% of patients (stratified by event status), and we count how often each feature is selected. Features chosen only occasionally are not credible signature members.
set.seed(11)
nsub <- 200
counts <- setNames(numeric(ncol(X)), colnames(X))
ev <- which(y[, "status"] == 1); ce <- which(y[, "status"] == 0)
for (i in 1:nsub) {
idx <- c(sample(ev, round(0.7 * length(ev))), sample(ce, round(0.7 * length(ce))))
f <- glmnet(X[idx, ], y[idx, ], family = "cox", lambda = lam_best, cox.ties = "efron")
counts <- counts + (as.numeric(coef(f)) != 0)
}
freq <- sort(counts / nsub, decreasing = TRUE)
head(freq, 10)
## sptan1 dag1 gstk1 man2a1 mcam wasf2 anxa2 diaph1 g3bp1 pcyox1
## 0.705 0.355 0.335 0.240 0.230 0.185 0.170 0.170 0.120 0.110
top <- head(freq, 10)
barplot(top, horiz = TRUE, las = 1, xlim = c(0, 1),
col = ifelse(top >= 0.6, "#1F9E93", "#B0B0B0"),
xlab = "Selected in resamples (proportion)",
main = "Stability selection (200 resamples)")
abline(v = 0.6, lty = 2, col = "grey40")
The survival outcomes are shuffled among patients 500 times, and the entire cross-validated fitting procedure is repeated on each shuffle. This shows how good a result can look by chance alone, given the same feature matrix and sample size.
set.seed(1)
obs_gain <- cv_gain(X, y, nrep = 10)
obs_gain
## [1] 0.1734671
set.seed(3)
n_perm <- 500
perm_gain <- replicate(n_perm, cv_gain(X, y[sample(nrow(X)), ], nrep = 10))
perm_p <- (1 + sum(perm_gain >= obs_gain)) / (1 + length(perm_gain))
perm_p
## [1] 0.05788423
quantile(perm_gain, c(0.5, 0.9, 0.95, 0.99))
## 50% 90% 95% 99%
## 0.00000000 0.08419071 0.17896396 0.36435471
hist(perm_gain, breaks = 30, col = "#D9CBEA", border = "white",
main = "Permutation test: shuffled deviance gain",
xlab = "Cross-validated deviance gain")
abline(v = obs_gain, col = "#5B3A8C", lwd = 2)
text(obs_gain, par("usr")[4] * 0.9, "observed", pos = 4, col = "#5B3A8C")
Feature selection is repeated inside every training fold below, so this C-index is not inflated by having chosen features on the same patients it is tested on (unlike a plain in-sample Cox summary).
cidx <- cv_cindex(X, y, nrep = 20)
summary(cidx)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.4968 0.5815 0.5918 0.6016 0.6313 0.7025
P009 is the retained metabolomics outlier (flagged in Objective 1) and is one of the 13 deaths in this set. This checks whether the SPTAN1 finding depends on this one patient.
keep <- rownames(X) != "P009"
X2 <- X[keep, ]; y2 <- y[keep, ]
table(y2[, "status"])
##
## 0 1
## 10 12
lam2 <- glmnet(X2, y2, family = "cox", cox.ties = "efron")$lambda
res2 <- lapply(1:100, function(r) {
set.seed(1000 + r)
fid <- strat_folds(y2[, "status"], 5)
cv.glmnet(X2, y2, family = "cox", lambda = lam2, foldid = fid, cox.ties = "efron")$cvm
})
L2 <- min(lengths(res2))
m2 <- rowMeans(sapply(res2, function(v) v[1:L2]))
lam2_best <- lam2[which.min(m2)]
c(null = m2[1], best = min(m2), gain = m2[1] - min(m2))
## null best gain
## 3.4411011 3.1791917 0.2619093
f2 <- glmnet(X2, y2, family = "cox", lambda = lam2, cox.ties = "efron")
b2 <- as.matrix(coef(f2, s = lam2_best))
b2[b2[, 1] != 0, , drop = FALSE]
## 1
## dag1 -0.315854368
## gstk1 0.117034207
## mylk 0.007592185
## sptan1 0.619883244
set.seed(21)
ev2 <- which(y2[, "status"] == 1); ce2 <- which(y2[, "status"] == 0)
counts2 <- setNames(numeric(ncol(X2)), colnames(X2))
for (i in 1:200) {
idx <- c(sample(ev2, round(0.7 * length(ev2))), sample(ce2, round(0.7 * length(ce2))))
f <- glmnet(X2[idx, ], y2[idx, ], family = "cox", lambda = lam2_best, cox.ties = "efron")
counts2 <- counts2 + (as.numeric(coef(f)) != 0)
}
freq_noP009 <- sort(counts2 / 200, decreasing = TRUE)
head(freq_noP009, 8)
## sptan1 anxa2 dag1 g3bp1 gstk1 mcam wasf2 man2a1
## 0.765 0.305 0.275 0.250 0.210 0.210 0.160 0.135
The full model above searches 1,221 proteins against only 29 metabolites, and the LASSO penalty tends to favour the larger block. This tests the 29 metabolites on their own, so the proteins cannot crowd them out.
met_names <- setdiff(names(readRDS("harmonized_metabolomics.rds")), "id_std")
Xm <- X[, intersect(colnames(X), met_names)]
dim(Xm)
## [1] 23 29
set.seed(1)
obs_m <- cv_gain(Xm, y, nrep = 10)
set.seed(2)
perm_m <- replicate(200, cv_gain(Xm, y[sample(nrow(Xm)), ], nrep = 10))
obs_m
## [1] 0
(1 + sum(perm_m >= obs_m)) / (1 + length(perm_m))
## [1] 1
top_feature <- names(freq)[1] # SPTAN1, based on the stability results above
d1 <- data.frame(
time = y[, "time"], status = y[, "status"],
feature = scale(X[, top_feature])[, 1]
)
fit_cox <- coxph(Surv(time, status) ~ feature, data = d1, ties = "efron")
summary(fit_cox)
## Call:
## coxph(formula = Surv(time, status) ~ feature, data = d1, ties = "efron")
##
## n= 23, number of events= 13
##
## coef exp(coef) se(coef) z Pr(>|z|)
## feature 1.0880 2.9684 0.3382 3.217 0.0013 **
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## exp(coef) exp(-coef) lower .95 upper .95
## feature 2.968 0.3369 1.53 5.76
##
## Concordance= 0.81 (se = 0.071 )
## Likelihood ratio test= 12.6 on 1 df, p=4e-04
## Wald test = 10.35 on 1 df, p=0.001
## Score (logrank) test = 13.09 on 1 df, p=3e-04
cox.zph(fit_cox)
## chisq df p
## feature 0.0446 1 0.83
## GLOBAL 0.0446 1 0.83
Caution: the hazard ratio and p-value above are
optimistic, because top_feature was chosen from ~1,250
candidates using the same 23 patients it is now tested on. Report the
permutation p-value and the cross-validated C-index above as the primary
evidence; report this Cox summary only as an inflated, in-sample
figure.
results <- data.frame(
Measure = c("Analysis set", "Events",
"Features selected (best lambda)",
"Top stable feature", "Stability (all 23)", "Stability (excl. P009)",
"Permutation p-value", "Cross-validated C-index (median)",
"Cross-validated C-index (range)",
"Metabolite-only permutation p-value"),
Value = c(
paste(nrow(X), "patients"),
paste(sum(y[, "status"]), "deaths"),
nrow(b),
top_feature,
sprintf("%.1f%%", freq[[top_feature]] * 100),
sprintf("%.1f%%", freq_noP009[[top_feature]] * 100),
sprintf("%.3f", perm_p),
sprintf("%.2f", median(cidx)),
sprintf("%.2f\u2013%.2f", min(cidx), max(cidx)),
sprintf("%.3f", (1 + sum(perm_m >= obs_m)) / (1 + length(perm_m)))
)
)
knitr::kable(results)
| Measure | Value |
|---|---|
| Analysis set | 23 patients |
| Events | 13 deaths |
| Features selected (best lambda) | 3 |
| Top stable feature | sptan1 |
| Stability (all 23) | 70.5% |
| Stability (excl. P009) | 76.5% |
| Permutation p-value | 0.058 |
| Cross-validated C-index (median) | 0.59 |
| Cross-validated C-index (range) | 0.50–0.70 |
| Metabolite-only permutation p-value | 1.000 |
saveRDS(
list(
setB = setB, X = X, y = y,
lam_best = lam_best, coef_best = b,
freq = freq, freq_noP009 = freq_noP009,
obs_gain = obs_gain, perm_gain = perm_gain, perm_p = perm_p,
cidx = cidx,
obs_m = obs_m, perm_m = perm_m,
fit_cox_top_feature = fit_cox
),
"objective2_final_results.rds"
)
sessionInfo()
## R version 4.6.1 (2026-06-24 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## time zone: Africa/Johannesburg
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] survival_3.8-6 glmnet_5.0 Matrix_1.7-5 stringr_1.6.0 dplyr_1.2.1
##
## loaded via a namespace (and not attached):
## [1] jsonlite_2.0.0 compiler_4.6.1 tidyselect_1.2.1 Rcpp_1.1.2
## [5] jquerylib_0.1.4 splines_4.6.1 yaml_2.3.12 fastmap_1.2.0
## [9] lattice_0.22-9 R6_2.6.1 generics_0.1.4 shape_1.4.6.1
## [13] knitr_1.51 iterators_1.0.14 tibble_3.3.1 bslib_0.11.0
## [17] pillar_1.11.1 rlang_1.2.0 cachem_1.1.0 stringi_1.8.7
## [21] xfun_0.59 sass_0.4.10 otel_0.2.0 cli_3.6.6
## [25] withr_3.0.3 magrittr_2.0.5 digest_0.6.39 foreach_1.5.2
## [29] grid_4.6.1 rstudioapi_0.19.0 lifecycle_1.0.5 vctrs_0.7.3
## [33] evaluate_1.0.5 glue_1.8.1 codetools_0.2-20 rmarkdown_2.31
## [37] tools_4.6.1 pkgconfig_2.0.3 htmltools_0.5.9