OSCR

Spatially-resolved single-cell imaging of melanoma brain metastases identifies localized immune patterns predictive of immune checkpoint blockade response.

Code ↔ Paper

18 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

The 18 matches
  1. [1] § Methods › Survival Analyses ↔ FinalFigures_public.Rmd, lines 4116–4166 · score 0.81 · dummy encoded, categorical variables, continuous variables, missing clinical, clinical variables, imputed
  2. [2] § Results › Phenotypic and Spatial Features of the TME Are Associated With Survival of Patients With MBM ↔ FinalFigures_public.Rmd, lines 3657–3717 · score 0.80 · Myeloid enriched, CD8Tc border, CD8Tc core, Tumor core, CD4Tc, plasma cell
  3. [3] § Results › Highly Multiplexed IMC Analysis of Human MBM Samples ↔ FinalFigures_public.Rmd, lines 119–227 · score 0.79 · extracranial controlled disease, NRAS mutations, histological subtype, wt, anatomical, wild
  4. [4] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 2120–2201 · score 0.73 · median PFS, median OS, ICI responder, infiltration pattern, postoperative ICI, dichotomizing
  5. [5] § Results › The TMEs of MBM Are Phenotypically Heterogeneous ↔ FinalFigures_public.Rmd, lines 3031–3095 · score 0.72 · CD4Tc, CD8Tc, Neu, exh, Mf, astrocytes
  6. [6] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 3031–3095 · score 0.70 · NK cells, ICI pretreated, ICI naive, IDO, Arginase, GZB
  7. [7] § Results › Phenotypic and Spatial Features of the TME Are Associated With Survival of Patients With MBM ↔ FinalFigures_public.Rmd, lines 2431–2454 · score 0.70 · plasma cell enriched, CN clusters, tumor core, perivascular, Treg, myeloid
  8. [8] § Results › Highly Multiplexed IMC Analysis of Human MBM Samples ↔ FinalFigures_public.Rmd, lines 119–227 · score 0.68 · BRAF mutation, NRAS mutations, clinical cohort, age, human, ICI
  9. [9] § Methods › Phenotypic and Spatial Analyses ↔ FinalFigures_public.Rmd, lines 1663–1750 · score 0.67 · imcRtools, Spatial interactions, classic, sum, avoidances, scores
  10. [10] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 2120–2201 · score 0.66 · median OS, ICI responders, ICI naive, ICI response, longer, postoperatively
  11. [11] § Results › The TMEs of MBM Are Phenotypically Heterogeneous ↔ FinalFigures_public.Rmd, lines 1055–1166 · score 0.65 · intraclass correlation coefficient, linear mixed, variance, identity, inter, ROI
  12. [12] § Results › Phenotypic and Spatial Features of the TME Are Associated With Survival of Patients With MBM ↔ FinalFigures_public.Rmd, lines 3252–3293 · score 0.63 · multivariate penalized Cox, Cox proportional hazards, proportional hazards model, Ridge regression, CN, Likelihood
  13. [13] § Methods › Cell Type Annotation ↔ FinalFigures_public.Rmd, lines 699–806 · score 0.63 · MelanA, broad cell, CD3, cytomapper, myeloid, tumor
  14. [14] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 4116–4166 · score 0.62 · logistic regression models, clinical variables, ICI response, ROC, elastic, responders
  15. [15] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 2964–3029 · score 0.62 · spatial co localization, ICI pretreated, ICI responders, naive, tumors, cell
  16. [16] § Results › Phenotypic and Spatial Features of the TME Are Associated With Survival of Patients With MBM ↔ FinalFigures_public.Rmd, lines 2964–3029 · score 0.59 · cold samples, median OS, tumor samples, localized, PFS, survival
  17. [17] § Methods › Single-Cell Segmentation, Data Processing and Quality Control ↔ steinbock/measurement/neighbors.py, lines 139–160 · score 0.56 · Euclidean pixel expansion, dmax, Neighboring, Steinbock, masks
  18. [18] § Results › TMEs of Intracranial ICI Responders and Non-Responders Are Phenotypically and Spatially Different ↔ FinalFigures_public.Rmd, lines 2879–2962 · score 0.56 · logFC, ICI responders, ICI naive, FDR, clinical, cell

Paper

Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC

The paper is loaded when this pane is shown.

The authors' code

R Markdown · 4,281 lines · 195 KB · no license · 17 matches

  1. ---
  2. title: "Spatial single-cell analysis of melanoma brain metastases"
  3. subtitle: "Figure generation code for Voglis et al. 2026, Neuro-Oncology"
  4. output: html_document
  5. date: "2024-01-06"
  6. ---
  7. <!--
  8. ================================================================================
  9. Analysis and figure-generation code accompanying:
  10. Voglis et al. (2026), Neuro-Oncology.
  11. This document reproduces the main and supplementary figures from imaging mass
  12. cytometry (IMC) data of melanoma brain metastases. The starting point is a
  13. pre-processed SingleCellExperiment (SCE) object together with the corresponding
  14. segmentation masks and images. Each top-level section corresponds to a figure
  15. or analysis block; the workflow proceeds from cohort overview (Figure 1) through
  16. the spatial / survival analyses (Figures 2-4) and the revision analyses.
  17. The code requires a pre-built SCE object that is not redistributed here in raw
  18. form; a publication-ready, de-identified version is exported at the end of this
  19. document for deposition on Zenodo.
  20. ================================================================================
  21. -->
  22. ```{r setup, include=FALSE}
  23. knitr::opts_chunk$set(echo = TRUE)
  24. # --- Single-cell / IMC infrastructure ---------------------------------------
  25. library(dittoSeq)
  26. library(CATALYST)
  27. library(scran)
  28. library(scater)
  29. library(bluster)
  30. library(Rphenoannoy)
  31. library(cytomapper)
  32. library(imcRtools)
  33. library(BiocParallel)
  34. # --- Plotting / layout ------------------------------------------------------
  35. library(patchwork)
  36. library(cowplot)
  37. library(viridis)
  38. library(ComplexHeatmap)
  39. library(scales)
  40. library(RColorBrewer)
  41. library(ggrepel)
  42. library(circlize)
  43. library(ggdendro)
  44. library(ggplotify)
  45. library(ggnewscale)
  46. # --- Data wrangling ---------------------------------------------------------
  47. library(tidyverse)
  48. library(data.table)
  49. # --- Statistics / survival --------------------------------------------------
  50. library(survminer)
  51. library(survival)
  52. library(rstatix)
  53. library(edgeR)
  54. # --- Spatial statistics -----------------------------------------------------
  55. library(spatial)
  56. library(spatstat)
  57. library(sf)
  58. library(concaveman)
  59. ```
  60. # Load data
  61. ```{r}
  62. # Load project-specific helper functions (e.g. run.coxph, plot.forrest,
  63. # plot.volcano), which are sourced rather than redefined inline.
  64. source("0_helperFunctions.R")
  65. # Set input/output paths
  66. datadir <- "/mnt/projects_output_data/MBM"
  67. output_path <- file.path(datadir, "data")
  68. # Pre-processed SingleCellExperiment object (after spatial preparation) and the
  69. # corresponding segmentation masks.
  70. sce <- readRDS(file.path(output_path, "sce_after_spatialprepare.rds"))
  71. masks <- readRDS(file.path(output_path, "masks.rds"))
  72. ```
  73. # Select cell types
  74. ```{r}
  75. # Promote selected fine-grained myeloid and T-cell labels into the unified
  76. # `cl_all_spec` annotation used throughout the figures. This keeps the broad
  77. # `cl_all` labels intact while exposing specific functional states
  78. # (e.g. CD38+/IDO+ macrophages and GZB+ activated CD8 T cells).
  79. sce$cl_all_spec[sce$cl_myeloid_combined %in% c("CD38+ Mf", "IDO+ Mf")] <-
  80. sce$cl_myeloid_combined[sce$cl_myeloid_combined %in% c("CD38+ Mf", "IDO+ Mf")]
  81. sce$cl_all_spec[sce$cl_tcells_combined %in% c("GZB+ act.CD8Tc")] <-
  82. sce$cl_tcells_combined[sce$cl_tcells_combined %in% c("GZB+ act.CD8Tc")]
  83. unique(sce$cl_all_spec)
  84. # Build a tumor-only annotation that labels tumor subclones as "Tumor <n>" and
  85. # any remaining tumor cells simply as "Tumor".
  86. sce$cl_all_tumor <- sce$cl_tumor_a
  87. sce$cl_all_tumor[sce$cl_all_tumor != 0 & sce$cl_all_tumor != ""] <-
  88. paste0("Tumor ", sce$cl_tumor_a[sce$cl_all_tumor != 0 & sce$cl_all_tumor != ""])
  89. sce$cl_all_tumor[sce$cl_all_tumor == ""] <- "Tumor"
  90. unique(sce$cl_all_tumor)
  91. ```
  92. # Define proliferative cells
  93. ```{r}
  94. # Dichotomize cells into high vs. low Ki-67 expression based on the cohort-wide
  95. # median of the arcsinh-transformed Ki-67 signal.
  96. median_ki67 <- median(assays(sce)[["exprs"]]["Ki-67", ])
  97. sce$ki67 <- ifelse(assays(sce)[["exprs"]]["Ki-67", ] > median_ki67,
  98. "high_ki67", "low_ki67")
  99. ```
  100. # --------------------------------
  101. # FIGURE 1
  102. ## Metadata heatmap
  103. ```{r, fig.height = 18}
  104. library(SCpubr)
  105. # Collect the clinical variables to be displayed in the metadata heatmap.
  106. clindata <- metadata(sce)$clinical %>%
  107. select(biosample_id, sex, age, localization, primarius_type, braf, nras,
  108. extracranial_control_dos, rt_bm_preop, rt_bm_postop, ici_preop, ici_postop)
  109. # IMPORTANT: restrict to the final analysis cohort, i.e. samples that survived QC
  110. # and are still present in the SCE object.
  111. clindata <- colData(sce) %>%
  112. as_tibble() %>%
  113. distinct(biosample_id, cond.1, cond.2, cond.3) %>%
  114. left_join(clindata)
  115. # Drop identifier / grouping columns not shown in the heatmap body.
  116. clindata.select <- clindata %>% select(-biosample_id, -age, -cond.1, -cond.2, -cond.3)
  117. # Recode BRAF/NRAS mutation status to human-readable labels.
  118. clindata.select$braf <- as.character(clindata.select$braf)
  119. clindata.select$braf[clindata.select$braf == "mut"] <- "mutated"
  120. clindata.select$braf[clindata.select$braf == "wt"] <- "wild type"
  121. clindata.select$braf[is.na(clindata.select$braf)] <- "not determined"
  122. clindata.select$nras <- as.character(clindata.select$nras)
  123. clindata.select$nras[clindata.select$nras == "mut"] <- "mutated"
  124. clindata.select$nras[clindata.select$nras == "wt"] <- "wild type"
  125. clindata.select$nras[is.na(clindata.select$nras)] <- "not determined"
  126. # Rename columns to publication labels.
  127. clindata_renamed <- clindata.select %>%
  128. rename("Sex" = sex,
  129. "Anatomical localization" = localization,
  130. "Histological subtype" = primarius_type,
  131. "BRAF mutation" = braf,
  132. "NRAS mutation" = nras,
  133. "Extracranial controlled disease" = extracranial_control_dos,
  134. "Radiotherapy preop" = rt_bm_preop,
  135. "Radiotherapy postop" = rt_bm_postop,
  136. "ICI preop" = ici_preop,
  137. "ICI postop" = ici_postop)
  138. # Define colour schemes for the categorical annotations.
  139. col_ann_yesno <- c("yes" = "#E69F00", "no" = "#56B4E9")
  140. col_ann_mut <- c("mutated" = "#D55E00", "wild type" = "#56B4E9", "not determined" = "#D3D3D3")
  141. col_ann_metadata <- list(
  142. "Anatomical localization" = setNames(dittoColors()[seq_along(unique(clindata$localization))],
  143. unique(clindata$localization)),
  144. "Histological subtype" = setNames(dittoColors()[seq_along(unique(clindata$primarius_type))],
  145. unique(clindata$primarius_type)),
  146. "BRAF mutation" = col_ann_mut,
  147. "NRAS mutation" = col_ann_mut,
  148. "Extracranial controlled disease" = col_ann_yesno,
  149. "Radiotherapy preop" = col_ann_yesno,
  150. "Radiotherapy postop" = col_ann_yesno,
  151. "ICI preop" = col_ann_yesno,
  152. "ICI postop" = col_ann_yesno
  153. )
  154. # Build the base metadata heatmap.
  155. (p.clin <- do_MetadataPlot(from_df = T,
  156. df = clindata_renamed,
  157. flip = F,
  158. legend.nrow = 7,
  159. font.size = 22,
  160. heatmap.gap = 1,
  161. cluster = F,
  162. colors.use = col_ann_metadata,
  163. grid.color = "black",
  164. border.color = "black",
  165. legend.title.face = "plain",
  166. legend.position = "bottom"))
  167. # Merge paired tracks (preop/postop, BRAF/NRAS) under shared legends and hide the
  168. # redundant per-track legends.
  169. p.clin[[6]] <- p.clin[[6]] + scale_fill_manual(name = "Extracranial\ncontrolled disease",
  170. breaks = c("yes", "no", NA),
  171. values = col_ann_yesno,
  172. na.value = "#D3D3D3")
  173. p.clin[[7]] <- p.clin[[7]] + scale_fill_manual(name = "Radiotherapy preop/postop",
  174. breaks = c("yes", "no", NA),
  175. values = col_ann_yesno,
  176. na.value = "#D3D3D3")
  177. p.clin[[8]] <- p.clin[[8]] + theme(legend.position = "none")
  178. p.clin[[4]] <- p.clin[[4]] + scale_fill_manual(name = "BRAF/NRAS mutation",
  179. values = col_ann_mut,
  180. breaks = c("wild type", "mutated", "not determined"),
  181. na.value = "#D3D3D3")
  182. p.clin[[5]] <- p.clin[[5]] + theme(legend.position = "none")
  183. p.clin[[9]] <- p.clin[[9]] + scale_fill_manual(name = "ICI preop/postop",
  184. breaks = c("yes", "no", NA),
  185. values = col_ann_yesno,
  186. na.value = "#D3D3D3")
  187. p.clin[[10]] <- p.clin[[10]] + theme(legend.position = "none")
  188. ggsave(plot = p.clin, "figures/fig1_hm_clinical.pdf", width = 25, heigh = 15, dpi = 200)
  189. # Tabular summary of the clinical cohort.
  190. library(gtsummary)
  191. clindata %>% select(-biosample_id) %>% tbl_summary()
  192. ```
  193. ## Revision: new metadata plot
  194. ```{r, fig.width = 18, fig.height = 8}
  195. library(ggh4x)
  196. # Ordered row labels for the redesigned metadata grid (bottom-to-top).
  197. metadata.labels <- c("ICI postop",
  198. "ICI preop",
  199. "RT postop",
  200. "RT preop",
  201. "Extracranial controlled disease",
  202. "NRAS mut",
  203. "BRAF mut",
  204. "Histological subtype",
  205. "Anatomical localization",
  206. "Sex")
  207. # Group sizes (number of samples per ICI-pretreatment status), used to scale the
  208. # facet panel widths so each tile is square.
  209. group_sizes <- clindata.select %>%
  210. count(ici_preop) %>%
  211. arrange(ici_preop)
  212. tile_w <- 0.95 # cm per tile
  213. # Build the grid one variable at a time. Each `geom_tile` adds one metadata row;
  214. # `new_scale_fill()` allows an independent fill scale per row. Paired tracks
  215. # (preop/postop, BRAF/NRAS) share a legend, with the second track's legend hidden.
  216. p <- clindata.select %>%
  217. arrange(ici_preop) %>%
  218. group_by(ici_preop) %>%
  219. mutate(local_id = row_number()) %>%
  220. ungroup() %>%
  221. ggplot(aes(x = local_id)) +
  222. geom_tile(aes(y = 1, fill = ici_postop), color = "black", width = .9, height = 0.8) +
  223. scale_fill_manual(name = "ICI preop/postop",
  224. breaks = c("yes", "no", NA),
  225. values = col_ann_yesno,
  226. na.value = "#D3D3D3") +
  227. new_scale_fill() +
  228. geom_tile(aes(y = 2, fill = ici_preop), color = "black", width = .9, height = 0.8) +
  229. scale_fill_manual(name = "ICI preop/postop",
  230. breaks = c("yes", "no", NA),
  231. values = col_ann_yesno,
  232. na.value = "#D3D3D3",
  233. guide = "none") +
  234. new_scale_fill() +
  235. geom_tile(aes(y = 3, fill = rt_bm_postop), color = "black", width = .9, height = 0.8) +
  236. scale_fill_manual(name = "RT BM preop/postop",
  237. breaks = c("yes", "no", NA),
  238. values = col_ann_yesno,
  239. na.value = "#D3D3D3") +
  240. new_scale_fill() +
  241. geom_tile(aes(y = 4, fill = rt_bm_preop), color = "black", width = .9, height = 0.8) +
  242. scale_fill_manual(name = "RT BM preop/postop",
  243. breaks = c("yes", "no", NA),
  244. values = col_ann_yesno,
  245. na.value = "#D3D3D3",
  246. guide = "none") +
  247. new_scale_fill() +
  248. geom_tile(aes(y = 5, fill = extracranial_control_dos), color = "black", width = .9, height = 0.8) +
  249. scale_fill_manual(name = "Extracranial\ncontrolled disease",
  250. breaks = c("yes", "no", NA),
  251. values = col_ann_yesno,
  252. na.value = "#D3D3D3") +
  253. new_scale_fill() +
  254. geom_tile(aes(y = 6, fill = nras), color = "black", width = .9, height = 0.8) +
  255. scale_fill_manual(name = "BRAF/NRAS mutation",
  256. values = col_ann_mut,
  257. breaks = c("wild type", "mutated", "not determined"),
  258. na.value = "#D3D3D3") +
  259. new_scale_fill() +
  260. geom_tile(aes(y = 7, fill = braf), color = "black", width = .9, height = 0.8) +
  261. scale_fill_manual(name = "BRAF/NRAS mutation",
  262. values = col_ann_mut,
  263. breaks = c("wild type", "mutated", "not determined"),
  264. na.value = "#D3D3D3",
  265. guide = "none") +
  266. new_scale_fill() +
  267. geom_tile(aes(y = 8, fill = primarius_type), color = "black", width = .9, height = 0.8) +
  268. scale_fill_manual(name = "Histological subtype",
  269. values = setNames(dittoColors()[seq_along(unique(clindata$primarius_type))],
  270. unique(clindata$primarius_type)),
  271. na.value = "#D3D3D3") +
  272. new_scale_fill() +
  273. geom_tile(aes(y = 9, fill = localization), color = "black", width = .9, height = 0.8) +
  274. scale_fill_manual(name = "Anatomical localization",
  275. values = setNames(dittoColors()[seq_along(unique(clindata$localization))],
  276. unique(clindata$localization)),
  277. na.value = "#D3D3D3") +
  278. new_scale_fill() +
  279. geom_tile(aes(y = 10, fill = sex), color = "black", width = .9, height = 0.8) +
  280. scale_fill_manual(name = "Sex",
  281. values = c("F" = "#477c9e", "M" = "#9e6845")) +
  282. scale_y_continuous(
  283. breaks = 1:length(metadata.labels),
  284. labels = str_wrap(metadata.labels, width = 15)
  285. ) +
  286. scale_x_continuous(
  287. breaks = function(x) seq(ceiling(x[1]), floor(x[2]), by = 1),
  288. expand = expansion(add = 0.5) # half-tile padding on each side
  289. ) +
  290. facet_grid(. ~ ici_preop, scales = "free_x",
  291. labeller = as_labeller(c("yes" = "ICI pretreated", "no" = "ICI naive"))) +
  292. theme_minimal() +
  293. theme(
  294. panel.grid = element_blank(),
  295. axis.title = element_blank(),
  296. axis.text.x = element_blank(),
  297. axis.ticks = element_line(color = "black"),
  298. axis.ticks.length = unit(0.1, "cm"),
  299. axis.text.y = element_text(lineheight = 0.7),
  300. legend.box = "horizontal",
  301. legend.box.just = "top",
  302. strip.text = element_text(face = "bold"),
  303. text = element_text(size = 16.5)
  304. )
  305. # Force square tiles by fixing panel sizes to (n tiles x tile width).
  306. p.metadata.final <- p + force_panelsizes(
  307. cols = unit(group_sizes$n * tile_w, "cm"),
  308. rows = unit(length(metadata.labels) * tile_w, "cm")
  309. )
  310. # Export the legend and the (legend-free) grid separately for figure assembly.
  311. legend <- get_legend(p.metadata.final)
  312. plot_grid(legend)
  313. p.metadata.final + theme(legend.position = "none")
  314. ggsave(plot = p.metadata.final + theme(legend.position = "none"),
  315. "figures/Revision_fig1_hm_clinical.pdf", width = 18, heigh = 8, dpi = 300)
  316. ggsave(plot = plot_grid(legend),
  317. "figures/Revision_fig1_hm_clinical_legend.pdf", width = 18, heigh = 8, dpi = 300)
  318. ```
  319. ## Test: Radial (polar) layout
  320. ```{r, fig.width = 12, fig.height = 15}
  321. # Alternative circular rendering of the metadata grid, wrapping the same tiles
  322. # around a polar coordinate system. The empty central hole is created by giving
  323. # the y-axis a negative lower limit; ring tick marks and labels are drawn
  324. # manually in that central region.
  325. ring_ticks <- data.frame(
  326. xstart = 0, # tick start
  327. xend = 0.5, # tick end (gap centre)
  328. y = seq_along(metadata.labels)
  329. )
  330. ring_labels <- data.frame(
  331. local_id = -0.1, # label anchor, just inside the central hole
  332. y = seq_along(metadata.labels),
  333. label = str_wrap(metadata.labels, width = 15)
  334. )
  335. clindata.select %>%
  336. arrange(ici_preop) %>%
  337. group_by(ici_preop) %>%
  338. mutate(local_id = row_number()) %>%
  339. ungroup() %>%
  340. ggplot(aes(x = local_id)) +
  341. geom_tile(aes(y = 1, fill = ici_postop), color = "black", width = .9, height = 0.7) +
  342. scale_fill_manual(name = "ICI preop/postop",
  343. breaks = c("yes", "no", NA),
  344. values = col_ann_yesno,
  345. na.value = "#D3D3D3") +
  346. new_scale_fill() +
  347. geom_tile(aes(y = 2, fill = ici_preop), color = "black", width = .9, height = 0.7) +
  348. scale_fill_manual(name = "ICI preop/postop",
  349. breaks = c("yes", "no", NA),
  350. values = col_ann_yesno,
  351. na.value = "#D3D3D3",
  352. guide = "none") +
  353. new_scale_fill() +
  354. geom_tile(aes(y = 3, fill = rt_bm_postop), color = "black", width = .9, height = 0.7) +
  355. scale_fill_manual(name = "RT BM preop/postop",
  356. breaks = c("yes", "no", NA),
  357. values = col_ann_yesno,
  358. na.value = "#D3D3D3") +
  359. new_scale_fill() +
  360. geom_tile(aes(y = 4, fill = rt_bm_preop), color = "black", width = .9, height = 0.7) +
  361. scale_fill_manual(name = "RT BM preop/postop",
  362. breaks = c("yes", "no", NA),
  363. values = col_ann_yesno,
  364. na.value = "#D3D3D3",
  365. guide = "none") +
  366. new_scale_fill() +
  367. geom_tile(aes(y = 5, fill = extracranial_control_dos), color = "black", width = .9, height = 0.7) +
  368. scale_fill_manual(name = "Extracranial\ncontrolled disease",
  369. breaks = c("yes", "no", NA),
  370. values = col_ann_yesno,
  371. na.value = "#D3D3D3") +
  372. new_scale_fill() +
  373. geom_tile(aes(y = 6, fill = nras), color = "black", width = .9, height = 0.7) +
  374. scale_fill_manual(name = "BRAF/NRAS mutation",
  375. values = col_ann_mut,
  376. breaks = c("wild type", "mutated", "not determined"),
  377. na.value = "#D3D3D3") +
  378. new_scale_fill() +
  379. geom_tile(aes(y = 7, fill = braf), color = "black", width = .9, height = 0.7) +
  380. scale_fill_manual(name = "BRAF/NRAS mutation",
  381. values = col_ann_mut,
  382. breaks = c("wild type", "mutated", "not determined"),
  383. na.value = "#D3D3D3",
  384. guide = "none") +
  385. new_scale_fill() +
  386. geom_tile(aes(y = 8, fill = primarius_type), color = "black", width = .9, height = 0.7) +
  387. scale_fill_manual(name = "Histological subtype",
  388. values = setNames(dittoColors()[seq_along(unique(clindata$primarius_type))],
  389. unique(clindata$primarius_type)),
  390. na.value = "#D3D3D3") +
  391. new_scale_fill() +
  392. geom_tile(aes(y = 9, fill = localization), color = "black", width = .9, height = 0.7) +
  393. scale_fill_manual(name = "Anatomical localization",
  394. values = setNames(dittoColors()[seq_along(unique(clindata$localization))],
  395. unique(clindata$localization)),
  396. na.value = "#D3D3D3") +
  397. new_scale_fill() +
  398. geom_tile(aes(y = 10, fill = sex), color = "black", width = .9, height = 0.7) +
  399. scale_fill_manual(name = "Sex",
  400. values = c("F" = "#477c9e", "M" = "#9e6845")) +
  401. # Negative lower y-limit creates the empty central hole of the ring.
  402. scale_y_continuous(
  403. limits = c(-9, length(metadata.labels) + 0.5),
  404. breaks = 1:length(metadata.labels),
  405. labels = NULL
  406. ) +
  407. scale_x_continuous(expand = expansion(add = 0.5)) +
  408. # Manual ring tick marks and labels in the central region.
  409. geom_segment(
  410. data = ring_ticks,
  411. aes(x = xstart, xend = xend, y = y, yend = y),
  412. color = "grey30", linewidth = 0.4, inherit.aes = FALSE
  413. ) +
  414. geom_text(
  415. data = ring_labels,
  416. aes(x = local_id, y = y, label = label),
  417. hjust = 1, size = 3.2, lineheight = 0.8,
  418. fontface = "bold", color = "grey30", inherit.aes = FALSE
  419. ) +
  420. # facet_wrap works better with coord_polar than facet_grid.
  421. facet_wrap(~ ici_preop,
  422. ncol = 1,
  423. labeller = as_labeller(c("yes" = "ICI pretreated", "no" = "ICI naive"))) +
  424. coord_polar(theta = "x") +
  425. theme_minimal() +
  426. theme(
  427. panel.grid = element_blank(),
  428. axis.title = element_blank(),
  429. axis.text.x = element_blank(),
  430. axis.ticks.x = element_blank(),
  431. axis.ticks.length = unit(0.1, "cm"),
  432. axis.text.y = element_blank(),
  433. axis.ticks.y = element_blank(),
  434. legend.box = "horizontal",
  435. legend.box.just = "top",
  436. strip.text = element_text(face = "bold"),
  437. legend.position = "none"
  438. )
  439. ```
  440. # --------------------------------
  441. # SUPPL. FIGURE 1
  442. ## Import clinical data
  443. Here we load additional clinical data used for the swimmer and survival plots
  444. (follow-up responses and per-regimen ICI treatment characteristics) and restrict
  445. all tables to the final analysis cohort.
  446. ```{r}
  447. clin.data <- readRDS(file.path(output_path, "clinical_data.rds"))
  448. load(file.path(output_path, "clinical_data_add.RData"))
  449. # The .RData file provides:
  450. # df = clinical data (as imported above for the SCE object)
  451. # resp = follow-up response data for each available timepoint
  452. # tr_ct.ici = ICI treatment characteristics for each individual regimen
  453. # IMPORTANT: restrict to the final analysis cohort (samples present in the SCE
  454. # object after QC).
  455. df <- colData(sce) %>% as_tibble() %>% distinct(biosample_id) %>% left_join(df)
  456. resp <- colData(sce) %>% as_tibble() %>% distinct(biosample_id) %>% left_join(resp)
  457. tr_ct.ici <- colData(sce) %>% as_tibble() %>% distinct(biosample_id) %>% left_join(tr_ct.ici)
  458. ```
  459. ## Swimmer plots
  460. ### Naive vs. pretreated
  461. ```{r}
  462. # Treatment timeline (orange bars) with follow-up response timepoints (coloured
  463. # points) and dates of surgery of other lesions (blue asterisks), for samples
  464. # that received ICI preoperatively.
  465. p1 <- ggplot() +
  466. geom_linerange(data = tr_ct.ici %>% filter(id %in% df$id[df$ici_preop == "yes"]),
  467. aes(ymin = day_start, ymax = day_stop, x = as.factor(id)),
  468. stat = "identity", position = position_dodge(width = 1),
  469. size = 7, colour = "orange") +
  470. geom_point(data = resp %>% filter(id %in% df$id[df$ici_preop == "yes"]),
  471. aes(x = as.factor(id), y = fu_day, color = fu_response), size = 4) +
  472. geom_point(data = df %>% filter(ici_preop == "yes") %>%
  473. select(patnr, id, dos) %>% rowwise() %>%
  474. mutate(other_dos = paste(as.numeric(difftime(df$dos[df$patnr == patnr], dos,
  475. units = "days")), collapse = ";")) %>%
  476. separate_rows(other_dos, sep = ";", convert = T),
  477. aes(x = as.factor(id), y = other_dos), shape = 8, color = "blue", size = 3) +
  478. theme(axis.text.y = element_blank(),
  479. axis.title.y = element_blank()) +
  480. geom_hline(aes(yintercept = 0)) +
  481. coord_flip()
  482. p1.legend <- cowplot::get_legend(p1)
  483. p1 <- p1 + theme(legend.position = "none")
  484. # Patient ID tiles.
  485. p.pat <- ggplot() +
  486. geom_label(data = df %>% filter(ici_preop == "yes") %>% select(id, pid),
  487. aes(x = factor(id), y = "PatientID", label = pid), label.size = NA) +
  488. coord_flip() +
  489. theme_void() + theme(axis.text.x = element_text(angle = 90))
  490. # Sample ID tiles.
  491. p.id <- ggplot() +
  492. geom_label(data = df %>% filter(ici_preop == "yes") %>% select(id, pid),
  493. aes(x = factor(id), y = "SampleID", label = id), label.size = NA) +
  494. coord_flip() +
  495. theme_void() + theme(axis.text.x = element_text(angle = 90))
  496. # Tile annotation: progression after preoperative ICI.
  497. p2 <- ggplot() +
  498. geom_tile(data = df %>% filter(ici_preop == "yes") %>% select(id, ici_preop, pd_after_ici_preop),
  499. aes(x = factor(id), y = 3000, fill = factor(pd_after_ici_preop))) +
  500. scale_fill_manual(name = "pd_after_ici", values = c("no" = "white", "yes" = "blue"),
  501. breaks = c("yes")) +
  502. coord_flip() +
  503. theme_void()
  504. p2.legend <- cowplot::get_legend(p2)
  505. p2 <- p2 + theme(legend.position = "none")
  506. # Tile annotation: progression while on preoperative ICI.
  507. p3 <- ggplot() +
  508. geom_tile(data = df %>% filter(ici_preop == "yes") %>% select(id, ici_preop, pd_while_ici_preop),
  509. aes(x = factor(id), y = 3000, fill = factor(pd_while_ici_preop))) +
  510. scale_fill_manual(name = "pd_while_ici", values = c("no" = "white", "yes" = "green"),
  511. breaks = c("yes")) +
  512. coord_flip() +
  513. theme_void()
  514. p3.legend <- cowplot::get_legend(p3)
  515. p3 <- p3 + theme(legend.position = "none")
  516. # Assemble swimmer plot with side tiles and stacked legends.
  517. ggarrange(p3, p2, p.pat, p.id, p1,
  518. ggarrange(p1.legend, p2.legend, p3.legend, nrow = 3, align = "v"),
  519. ncol = 6, widths = c(1, 1, 3, 3, 100, 15), align = "h")
  520. ```
  521. ### Naive with response
  522. ```{r}
  523. # Note: IDs 6 and 7 can also be treated as effectively ICI-naive (brain
  524. # metastasis diagnosed directly preoperatively, with ICI started thereafter).
  525. # Select ICI-naive cases that received postoperative ICI, plus IDs 6/7.
  526. ids <- df %>% filter((ici_preop == "no" & ici_postop == "yes") | id %in% c(6, 7)) %>% pull(id)
  527. # Treatment timeline with follow-up responses, other surgery dates, and last
  528. # follow-up / death status markers.
  529. p1 <- ggplot() +
  530. geom_linerange(data = tr_ct.ici %>% filter(id %in% ids),
  531. aes(ymin = day_start, ymax = day_stop, x = as.factor(id)),
  532. stat = "identity", position = position_dodge(width = 1),
  533. size = 7, colour = "orange") +
  534. geom_point(data = resp %>% filter(id %in% ids),
  535. aes(x = as.factor(id), y = fu_day, color = fu_response), size = 4) +
  536. geom_point(data = df %>% filter(id %in% ids) %>%
  537. select(patnr, id, dos) %>% rowwise() %>%
  538. mutate(other_dos = paste(as.numeric(difftime(df$dos[df$patnr == patnr], dos,
  539. units = "days")), collapse = ";")) %>%
  540. separate_rows(other_dos, sep = ";", convert = T),
  541. aes(x = as.factor(id), y = other_dos), shape = 8, color = "blue", size = 3) +
  542. geom_point(data = df %>% filter(ici_preop == "no") %>% select(id, days_dos, status),
  543. aes(x = factor(id), y = days_dos, shape = factor(status))) +
  544. theme(axis.text.y = element_blank(),
  545. axis.title.y = element_blank()) +
  546. geom_hline(aes(yintercept = 0)) +
  547. coord_flip()
  548. scale_y_continuous(limits = c(-15, 2000), expand = c(0, 0))
  549. p1
  550. p1.legend <- cowplot::get_legend(p1)
  551. p1 <- p1 + theme(legend.position = "none")
  552. # Tile annotation: postoperative ICI responder status.
  553. p2 <- ggplot() +
  554. geom_tile(data = df %>% filter(id %in% ids) %>% select(id, ici_postop_response),
  555. aes(x = factor(id), y = 1, fill = factor(ici_postop_response))) +
  556. scale_fill_discrete(name = "ICI responder") +
  557. coord_flip() +
  558. theme_void()
  559. p2.legend <- cowplot::get_legend(p2)
  560. p2 <- p2 + theme(legend.position = "none")
  561. # Patient ID tiles.
  562. p.pat <- ggplot() +
  563. geom_label(data = df %>% filter(id %in% ids) %>% select(id, pid),
  564. aes(x = factor(id), y = "PatientID", label = pid), label.size = NA, size = 5) +
  565. coord_flip() +
  566. theme_void() + theme(axis.text.x = element_text(angle = 90))
  567. # Sample ID tiles.
  568. p.id <- ggplot() +
  569. geom_label(data = df %>% filter(id %in% ids) %>% select(id, pid),
  570. aes(x = factor(id), y = "SampleID", label = id), label.size = NA, size = 5) +
  571. coord_flip() +
  572. theme_void() + theme(axis.text.x = element_text(angle = 90))
  573. ggarrange(p2, p.pat, p.id, p1,
  574. ggarrange(p1.legend, p2.legend, nrow = 2, align = "v"),
  575. ncol = 5, widths = c(1, 3, 3, 100, 15), align = "h")
  576. ```
  577. ## Survival plots
  578. ### Overall survival
  579. ```{r survivalplot-os}
  580. library(survival)
  581. library(survminer)
  582. # Clinical data from the SCE object.
  583. dff <- metadata(sce)$clinical
  584. # Condition 1: Kaplan-Meier overall survival.
  585. surv.df <- dff %>% filter(!is.na(cond.1)) %>% select(cond.1, days_dos, status)
  586. fit <- survfit(Surv(days_dos, status) ~ cond.1, data = surv.df)
  587. median <- surv_median(fit)
  588. ggsurvplot(fit, risk.table = TRUE, conf.int = F, pval = TRUE,
  589. xscale = "d_m", surv.median.line = "hv",
  590. break.time.by = 365.25, xlab = "Time in months",
  591. risk.table.title = "Patients at risk",
  592. fontsize = 4,
  593. ggtheme = theme_bw())
  594. # Condition 2: Kaplan-Meier overall survival.
  595. surv.df <- dff %>% filter(!is.na(cond.2)) %>% select(cond.2, days_dos, status)
  596. fit <- survfit(Surv(days_dos, status) ~ cond.2, data = surv.df)
  597. median <- surv_median(fit)
  598. ggsurvplot(fit, risk.table = TRUE, conf.int = F, pval = TRUE,
  599. xscale = "d_m", surv.median.line = "hv",
  600. break.time.by = 365.25, xlab = "Time in months",
  601. risk.table.title = "Patients at risk",
  602. fontsize = 4,
  603. ggtheme = theme_bw())
  604. ```
  605. ### Progression-free survival
  606. ```{r survivalplot-pfs}
  607. dff <- metadata(sce)$clinical
  608. # Condition 1: Kaplan-Meier progression-free survival.
  609. surv.df <- dff %>% filter(!is.na(cond.1)) %>% select(cond.1, days_dos_pfs, status_pfs)
  610. fit <- survfit(Surv(days_dos_pfs, status_pfs) ~ cond.1, data = surv.df)
  611. median <- surv_median(fit)
  612. ggsurvplot(fit, risk.table = TRUE, conf.int = F, pval = TRUE,
  613. xscale = "d_m", surv.median.line = "hv",
  614. break.time.by = 365.25, xlab = "Time in months",
  615. risk.table.title = "Patients at risk",
  616. fontsize = 4,
  617. ggtheme = theme_bw())
  618. # Condition 2: Kaplan-Meier progression-free survival.
  619. surv.df <- dff %>% filter(!is.na(cond.2)) %>% select(cond.2, days_dos_pfs, status_pfs)
  620. fit <- survfit(Surv(days_dos_pfs, status_pfs) ~ cond.2, data = surv.df)
  621. median <- surv_median(fit)
  622. ggsurvplot(fit, risk.table = TRUE, conf.int = F, pval = TRUE,
  623. xscale = "d_m", surv.median.line = "hv",
  624. break.time.by = 365.25, xlab = "Time in months",
  625. risk.table.title = "Patients at risk",
  626. fontsize = 4,
  627. ggtheme = theme_bw())
  628. ```
  629. # --------------------------------
  630. # FIGURE 2
  631. ## Plot images
  632. ```{r}
  633. # Load a subset of the multichannel images for visualization.
  634. images <- readRDS(file.path(output_path, "images_subset.rds"))
  635. # Recurring plotting parameters (scale bar, image title, legend styling).
  636. scalebar_param <- list(length = 100, label = "", colour = "white",
  637. position = "bottomright", cex = 1, margin = c(5, 5))
  638. imagetitle_param <- list(position = "topright", colour = "white",
  639. margin = c(5, 5), font = 2, cex = 1)
  640. legend_param <- list(colour_by.title.cex = 0.6, colour_by.labels.cex = 0.6,
  641. colour_by.legend.cex = 0.6, outline_by.title.cex = 0.6,
  642. outline_by.labels.cex = 0.6, outline_by.legend.cex = 0.6,
  643. margin = 0)
  644. # Select representative ROIs and the matching masks/cells.
  645. cur_masks <- masks[names(masks) %in% c("NB16-121_002", "NB19-309_011",
  646. "NB17-357_006", "NB18-818_003")]
  647. tmp_sce <- sce[, sce$image_id %in% names(cur_masks)]
  648. # Collapse T-cell subsets into a single "T cells" class for this overview.
  649. tmp_sce$cl_all[tmp_sce$cl_all %in% c("CD8Tc", "CD4Tc", "DPTc", "Treg")] <- "T cells"
  650. # Normalize images per-image for consistent display.
  651. cur_images <- images[names(cur_masks)]
  652. cur_images <- cytomapper::normalize(cur_images, separateImages = T)
  653. # Outline segmented cells coloured by broad cell type, over selected markers.
  654. plot.cells <- plotCells(
  655. object = tmp_sce,
  656. mask = cur_masks,
  657. img_id = "image_id",
  658. cell_id = "ObjectNumber",
  659. colour_by = c("MelanA", "CD3", "GFAP", "CD20", "CD68", "CD31"),
  660. exprs_values = "exprs",
  661. outline_by = "cl_all",
  662. thick = T,
  663. colour = list(MelanA = c("black", "#1f78b4"),
  664. CD3 = c("black", "#33a02c"),
  665. GFAP = c("black", "yellow"),
  666. CD20 = c("black", "#a6cee3"),
  667. CD68 = c("black", "#6a3d9a"),
  668. CD31 = c("black", "#e31a1c"),
  669. cl_all = c("Tumor" = "#1f78b4",
  670. "T cells" = "#33a02c",
  671. "Vascular" = "#e31a1c",
  672. "Myeloid" = "#6a3d9a",
  673. "Neutrophils" = "#ff7f00",
  674. "Astrocytes" = "yellow",
  675. "B cells" = "#a6cee3",
  676. "Plasma" = "magenta",
  677. "NK cells" = "#b15928",
  678. "unassigned" = "white",
  679. "BnT" = "#00bfc4")),
  680. legend = legend_param,
  681. scale_bar = scalebar_param,
  682. display = "single",
  683. image_title = NULL,
  684. return_plot = T
  685. )
  686. # Pseudo-coloured pixel-level marker images (raw signal, per-channel bcg).
  687. plot.images <- plotPixels(
  688. cur_images,
  689. img_id = "image_id",
  690. cell_id = "ObjectNumber",
  691. colour_by = c("MelanA", "CD3", "GFAP", "CD20", "CD68", "CD31"),
  692. bcg = list(MelanA = c(0, 8, 1),
  693. CD3 = c(0, 4, 1),
  694. GFAP = c(0, 4, 1),
  695. CD20 = c(0, 7, 1),
  696. CD68 = c(0, 4, 1),
  697. CD31 = c(0, 4, 1)),
  698. thick = F,
  699. colour = list(MelanA = c("black", "#1f78b4"),
  700. CD3 = c("black", "#33a02c"),
  701. GFAP = c("black", "yellow"),
  702. CD20 = c("black", "#a6cee3"),
  703. CD68 = c("black", "#6a3d9a"),
  704. CD31 = c("black", "#e31a1c")),
  705. return_plot = T,
  706. legend = legend_param,
  707. scale_bar = scalebar_param,
  708. image_title = NULL,
  709. margin = 2,
  710. display = "single"
  711. )
  712. # Free large image objects from memory.
  713. rm(cur_images, images)
  714. # Assemble: top row = cell outlines, bottom row = pixel images, per ROI + legend.
  715. p.img <-
  716. (
  717. ggdraw(plot.cells$plot$`NB16-121_002`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 2)) |
  718. ggdraw(plot.cells$plot$`NB17-357_006`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 0)) |
  719. ggdraw(plot.cells$plot$`NB18-818_003`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 0)) |
  720. ggdraw(plot.cells$plot$legend, clip = "on")
  721. ) /
  722. (
  723. ggdraw(plot.images$plot$`NB16-121_002`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 0)) |
  724. ggdraw(plot.images$plot$`NB17-357_006`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 0)) |
  725. ggdraw(plot.images$plot$`NB18-818_003`, clip = "on") + theme(plot.margin = margin(2, 5, 0, 0)) |
  726. ggdraw(plot.images$plot$legend, clip = "on")
  727. )
  728. ggsave(plot = p.img, "figures/fig2_imgs.pdf", width = 12, heigh = 7, dpi = 200)
  729. ```
  730. ## Heatmap cell types
  731. ```{r}
  732. # Drop nuclear markers; keep markers used for any of the clustering steps.
  733. tmp_sce <- sce[rowData(sce)$marker_groups != "nuclei", ]
  734. selected_markers <- rownames(tmp_sce)[
  735. rowSums(as.data.frame(rowData(tmp_sce)[c("cl_celltype", "cl_immune",
  736. "cl_tcells", "cl_myeloid")])) > 0]
  737. # Colour scheme for the cell-type annotation.
  738. col_ann_hm <- c("Tumor" = "#1f78b4",
  739. "B cells" = "#a6cee3",
  740. "Plasma" = "#fdbf6f",
  741. "Vascular" = "#e31a1c",
  742. "CD8Tc" = "#33a02c",
  743. "DPTc" = "#fb9a99",
  744. "exh.CD8Tc" = "#009E73",
  745. "Myeloid" = "#6a3d9a",
  746. "Treg" = "#DCE319FF",
  747. "NK cells" = "#b15928",
  748. "CD4Tc" = "#b2df8a",
  749. "GZB+ act.CD8Tc" = "#00F6B3",
  750. "BnT" = "#00bfc4",
  751. "unassigned" = "#cab2d6",
  752. "IDO+ Mf" = "#B14380",
  753. "CD38+ Mf" = "#4D4D4D",
  754. "Neutrophils" = "#ff7f00",
  755. "Arginase+ Neu" = "#E69F00",
  756. "Astrocytes" = "#ffff99")
  757. # Compute mean marker expression per cluster ID and derive scaled assays.
  758. mean_tmp.sce <- aggregateAcrossCells(tmp_sce, ids = tmp_sce$cl_all_spec, statistics = "mean")
  759. assay(mean_tmp.sce, "exprs") <- asinh(counts(mean_tmp.sce) / 1)
  760. assay(mean_tmp.sce, "scaled") <- t(scale(t(assay(mean_tmp.sce, "exprs")))) # z-score per marker
  761. assay(mean_tmp.sce, "scaled_ztm") <- t(apply(assay(mean_tmp.sce, "exprs"), 1, scales::rescale)) # zero-to-max
  762. # Proportion of Ki-67 high cells per cluster.
  763. prop.ki67 <- colData(tmp_sce) %>% as_tibble() %>%
  764. group_by(cl_all_spec, ki67) %>% summarise(count = n()) %>%
  765. mutate(prop.ki67 = prop.table(count)) %>% select(-count)
  766. cluster_name <- "cl_all_spec"
  767. # Heatmap body: z-scored mean expression (clusters x markers).
  768. hm_body <- t(assay(mean_tmp.sce, "scaled"))
  769. # Row (cluster) annotation.
  770. row_anno <- colData(mean_tmp.sce) %>% as.data.frame %>% select(all_of(cluster_name), ncells)
  771. row_anno[[cluster_name]] <- as.character(row_anno[[cluster_name]])
  772. # Diverging colour scale for z-scores.
  773. col_1 <- colorRamp2(c(-3, 0, 3), c("#4575B4", "white", "#D73027"))
  774. # Right-side annotation: colour bar + cluster names.
  775. ha_right <- HeatmapAnnotation(
  776. cluster_name = anno_simple(row_anno[[cluster_name]], border = TRUE, col = col_ann_hm),
  777. names = anno_text(row_anno[[cluster_name]]),
  778. annotation_label = c(cluster_name, ""),
  779. annotation_name_rot = 90,
  780. which = "row")
  781. # Draw the heatmap, splitting columns by marker group.
  782. hm <- Heatmap(hm_body,
  783. name = "z-score",
  784. col = col_1,
  785. clustering_method_columns = "complete",
  786. clustering_method_rows = "complete",
  787. column_split = rowData(mean_tmp.sce)["marker_groups"],
  788. cluster_column_slices = F,
  789. show_row_names = F,
  790. right_annotation = c(ha_right))
  791. hm <- draw(hm)
  792. p.hm <- grid.grabExpr(draw(hm))
  793. ggsave(plot = p.hm, "figures/fig2_hm_celltypes.pdf", width = 12, heigh = 7, dpi = 200)
  794. # Keep a copy to later extract the cell-type ordering for other plots.
  795. hm.celltypes <- hm
  796. ```
  797. ## Plot heterogeneity
  798. ```{r}
  799. # Per-cell colData used for the composition barplots.
  800. df_plotting <- colData(sce) %>% as_tibble()
  801. # Cluster samples by their broad cell-type composition to order the barplots, and
  802. # build the matching dendrogram.
  803. df_clust <- df_plotting %>%
  804. group_by(biosample_id, cl_all) %>%
  805. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  806. ungroup()
  807. df_clust_wide <- df_clust %>%
  808. pivot_wider(id_cols = biosample_id, names_from = cl_all, values_from = prop)
  809. df_clust_wide <- df_clust_wide %>% replace(is.na(.), 0) # 0 where a cell type is absent
  810. df_clust_m <- as.matrix(df_clust_wide)
  811. rownames(df_clust_m) <- df_clust_wide$biosample_id
  812. # Hierarchical clustering (Ward.D2 on Euclidean distances) -> sample order.
  813. hc <- hclust(dist(df_clust_m[, -1], method = "euclidian"), "ward.D2")
  814. dend <- as.dendrogram(hc)
  815. dend_data <- dendro_data(dend, type = "rectangle")
  816. (p.dend <- ggplot(dend_data$segments) +
  817. geom_segment(aes(x = x, y = y, xend = xend, yend = yend)) +
  818. geom_text(data = dend_data$labels, aes(x, y, label = label), hjust = 1, angle = 90) +
  819. scale_x_continuous(expand = c(0, 0.5)) +
  820. scale_y_continuous(expand = c(0, 0)) +
  821. theme_void() +
  822. theme(plot.margin = margin(0, 0, 0, 0, unit = "mm")))
  823. # Fixed cell-type stacking order for consistent legends/bars.
  824. celltype_order <- c("Tumor", "Vascular", "Astrocytes", "Myeloid", "Neutrophils",
  825. "Plasma", "B cells", "NK cells", "BnT", "DPTc", "CD4Tc",
  826. "Treg", "CD8Tc", "unassigned")
  827. # Per-ROI cell-type counts with sample/condition annotation.
  828. df_barplots <- df_plotting %>%
  829. group_by(image_id, biosample_id, pid, cond.3, cond.2, cond.4, cl_all) %>%
  830. summarise(count = n()) %>%
  831. ungroup()
  832. # Order samples by the clustering above.
  833. df_barplots$biosample_id <- factor(df_barplots$biosample_id, levels = hc$labels[hc$order])
  834. df_barplots$pid <- as.factor(df_barplots$pid)
  835. # Merge dendrogram x-positions so all panels align.
  836. x <- dend_data$labels[c("x", "label")] %>% as_tibble() %>% rename(biosample_id = label)
  837. df_barplots <- df_barplots %>% left_join(x, by = "biosample_id")
  838. # Composition stacked by sample.
  839. (p1 <-
  840. ggplot(data = df_barplots) +
  841. geom_bar(aes(fill = factor(cl_all, levels = celltype_order), x = x, y = count),
  842. stat = "identity", position = "fill", width = 1.1) +
  843. scale_fill_manual(name = "Cell types",
  844. values = metadata(sce)$col_celltypes$celltype_all,
  845. breaks = celltype_order) +
  846. geom_tile(aes(x = x, y = 0, height = 0.001, width = 1.1), fill = "transparent") +
  847. geom_hline(yintercept = 0) +
  848. scale_y_continuous(breaks = c(seq(0.25, 1, by = 0.25), -0.025),
  849. labels = c(c("0.25", "0.50", "0.75", "1.00"), "")) +
  850. facet_grid(~ x, scales = "free_x", space = "free_x", switch = "x") +
  851. ylab("Proportion") + xlab("Sample") +
  852. theme(axis.text.x = element_blank(),
  853. axis.ticks.x = element_blank(),
  854. axis.title.x = element_blank(),
  855. panel.spacing = unit(.2, units = "mm"),
  856. strip.background = element_blank(),
  857. strip.text.x = element_blank(),
  858. panel.background = element_blank(),
  859. panel.grid.major = element_blank(),
  860. panel.grid.minor = element_blank(),
  861. plot.margin = margin(0, 0, 0, 0, unit = "mm"),
  862. axis.text.y = element_text(face = c("plain", "plain", "plain", "plain",
  863. "bold", "bold", "bold"))))
  864. # Composition stacked by individual ROI.
  865. (p2 <-
  866. ggplot(data = df_barplots, aes(x = image_id)) +
  867. geom_bar(aes(fill = factor(cl_all, levels = celltype_order), y = count),
  868. stat = "identity", position = "fill", width = 1) +
  869. scale_fill_manual(name = "Cell types",
  870. values = metadata(sce)$col_celltypes$celltype_all,
  871. breaks = celltype_order) +
  872. geom_hline(yintercept = 0) +
  873. scale_y_continuous(breaks = c(seq(0.25, 1, by = 0.25)),
  874. labels = c(c("0.25", "0.50", "0.75", "1.00"))) +
  875. facet_grid(~ x, scales = "free_x", space = "free_x", switch = "x") +
  876. ylab("Proportion") + xlab("ROI") +
  877. theme(axis.text.x = element_blank(),
  878. axis.ticks.x = element_blank(),
  879. axis.title.x = element_blank(),
  880. panel.spacing = unit(.1, units = "lines"),
  881. strip.background = element_blank(),
  882. strip.text.x = element_blank(),
  883. panel.background = element_blank(),
  884. panel.grid.major = element_blank(),
  885. panel.grid.minor = element_blank(),
  886. plot.margin = margin(0, 0, 0, 0, unit = "mm"),
  887. axis.text.y = element_text(face = c("plain", "plain", "plain", "plain",
  888. "bold", "bold", "bold", "bold"))))
  889. # Stack dendrogram + sample-level + ROI-level composition.
  890. p.het <- ((p.dend / p1 / p2) + plot_layout(guides = "collect", ncol = 1, heights = c(0.25, 1, 1)))
  891. ggsave(plot = p.het, "figures/fig2_het_stacked.pdf", width = 15, heigh = 7, dpi = 300)
  892. ```
  893. ### TME correlation per image/sample
  894. ```{r}
  895. selected_vars <- unique(df_barplots$cl_all)
  896. # Per-ROI cell-type proportions within each sample/condition.
  897. img.props <- df_barplots %>%
  898. group_by(biosample_id, image_id, cond.2) %>%
  899. mutate(prop = prop.table(count)) %>%
  900. select(biosample_id, image_id, cl_all, prop, cond.2)
  901. # For each cell type, compute the SD of its proportion across ROIs within a sample.
  902. output <- lapply(selected_vars, function(celltype) {
  903. sd <- img.props %>% filter(cl_all == celltype) %>% ungroup() %>%
  904. split(.$biosample_id) %>% map(select, prop)
  905. sd.output <- do.call(rbind, lapply(sd, function(x) sd(x$prop)))
  906. colnames(sd.output) <- celltype
  907. sd.output
  908. })
  909. df <- lapply(output, function(x) data.frame(x, biosample_id = row.names(x))) %>%
  910. reduce(full_join, by = "biosample_id")
  911. # Boxplot of intra-sample (between-ROI) variability per cell type.
  912. (p.sd <- df %>% pivot_longer(cols = -c(biosample_id)) %>%
  913. left_join(df_barplots %>% distinct(biosample_id, cond.3, cond.2)) %>%
  914. mutate(cond.new = if_else(!is.na(cond.2), cond.2, cond.3)) %>%
  915. ggplot(aes(x = name, y = (value), fill = name)) +
  916. geom_boxplot(outlier.shape = NA) +
  917. geom_jitter(aes(fill = name), colour = "black", pch = 21, alpha = 0.4, show.legend = FALSE) +
  918. scale_fill_manual(name = "Cell types",
  919. values = metadata(sce)$col_celltypes$celltype_all,
  920. breaks = celltype_order) +
  921. scale_color_manual(values = metadata(sce)$col_celltypes$celltype_all) +
  922. scale_x_discrete(limits = make.names(celltype_order), name = "Cell types") +
  923. scale_y_continuous(limits = c(0, 0.5),
  924. name = "SD of cell type proportions between ROIs per sample") +
  925. theme_minimal() +
  926. theme(axis.text.x = element_text(angle = 45, vjust = 1, hjust = 1),
  927. legend.position = "none"))
  928. # Sample-level variance of cell-type proportions across samples.
  929. img.props <- df_barplots %>% group_by(biosample_id, cl_all) %>%
  930. summarise(countall = sum(count)) %>% mutate(prop = prop.table(countall))
  931. img.props %>% group_by(cl_all) %>%
  932. summarise(var = var(prop), sd = sd(prop)) %>%
  933. ggplot(aes(x = cl_all, y = var)) + geom_boxplot()
  934. ggsave(plot = p.sd, "figures/fig2_het_var.pdf", width = 6, heigh = 5, dpi = 200)
  935. # Median/mean/SD of between-ROI variances per cell type.
  936. df %>% pivot_longer(cols = -c(biosample_id)) %>%
  937. left_join(df_barplots %>% distinct(biosample_id, cond.3, cond.2)) %>%
  938. mutate(cond.new = if_else(!is.na(cond.2), cond.2, cond.3)) %>%
  939. group_by(name) %>%
  940. summarise(median = median(value, na.rm = TRUE),
  941. mean = mean(value, na.rm = TRUE),
  942. sd = sd(value, na.rm = TRUE)) %>%
  943. arrange(desc(median))
  944. ```
  945. ### Revision: Inferential statistics (ICC and PERMANOVA)
  946. To quantify intra-patient versus inter-patient variability we performed two
  947. formal analyses: (1) per-cell-type linear mixed-effects models partitioning
  948. variance between patient-level and ROI-level effects, summarised by the
  949. intraclass correlation coefficient (ICC) and a likelihood-ratio test for the
  950. patient effect; and (2) a global PERMANOVA on Bray-Curtis distances testing
  951. whether patient identity structures the overall ROI composition.
  952. ```{r}
  953. library(lme4)
  954. library(vegan)
  955. library(performance)
  956. # Frequency table of cell types (cl_all) per ROI (image_id), retaining the
  957. # biosample_id so ROIs can be mapped back to patients.
  958. metadata <- as.data.frame(colData(sce))
  959. roi_counts_table <- metadata %>%
  960. group_by(biosample_id, image_id, cl_all) %>%
  961. tally() %>%
  962. ungroup()
  963. # Wide format and row-wise normalization to per-ROI proportions.
  964. df_proportions <- roi_counts_table %>%
  965. pivot_wider(names_from = cl_all, values_from = n, values_fill = 0) %>%
  966. mutate(across(where(is.numeric), ~ .x / rowSums(across(where(is.numeric)))))
  967. # Make cell-type column names syntactically valid (e.g. "B cells" -> "B.cells").
  968. original_cell_types <- setdiff(colnames(df_proportions), c("biosample_id", "image_id"))
  969. valid_cell_types <- make.names(original_cell_types)
  970. colnames(df_proportions)[colnames(df_proportions) %in% original_cell_types] <- valid_cell_types
  971. # --- 1. Per-cell-type ICC and likelihood-ratio test for the patient effect ---
  972. stats_results <- map_df(valid_cell_types, function(ct) {
  973. # Full model: patient as random intercept.
  974. formula_full <- as.formula(paste0(ct, " ~ (1 | biosample_id)"))
  975. model_full <- lmer(formula_full, data = df_proportions, REML = FALSE)
  976. # Null model: no patient effect.
  977. model_null <- lm(as.formula(paste0(ct, " ~ 1")), data = df_proportions)
  978. # Likelihood-ratio test p-value.
  979. lrt <- anova(model_full, model_null)
  980. p_val <- lrt$`Pr(>Chisq)`[2]
  981. # Adjusted ICC = proportion of variance explained by patient.
  982. icc_val <- icc(model_full)$ICC_adjusted
  983. data.frame(CellType = ct, ICC = icc_val, p_value = p_val)
  984. })
  985. # Mean ICC across lineages.
  986. mean_icc <- mean(icc_results$ICC, na.rm = TRUE)
  987. cat("\n--- Quantitative Summary ---\n")
  988. cat("Mean ICC:", round(mean_icc, 3), "\n")
  989. # --- 2. Global PERMANOVA (Bray-Curtis) testing the patient effect ---
  990. comp_matrix <- df_proportions %>% select(all_of(valid_cell_types))
  991. patient_metadata <- df_proportions$biosample_id
  992. set.seed(42)
  993. permanova <- adonis2(comp_matrix ~ patient_metadata, method = "bray")
  994. global_p <- permanova$`Pr(>F)`[1]
  995. r2_val <- permanova$R2[1]
  996. # --- 3. ICC bar plot, coloured by -log10(p) ---
  997. ggplot(stats_results, aes(x = reorder(CellType, ICC), y = ICC)) +
  998. geom_bar(aes(fill = -log10(p_value)), stat = "identity") +
  999. coord_flip() +
  1000. labs(title = "Intraclass Correlation (ICC) by Cell Type",
  1001. subtitle = paste0("Global PERMANOVA p = ", global_p, " (R^2 = ", round(r2_val, 3), ")"),
  1002. x = "Cell Type", y = "ICC (Proportion of Variance by Patient)") +
  1003. theme_minimal() +
  1004. ylim(0, 1.1)
  1005. # --- 4. Formatted summary table (Table S1) ---
  1006. library(gt)
  1007. table_data <- stats_results %>%
  1008. mutate(
  1009. CellType = gsub("\\.", " ", CellType),
  1010. p_formatted = ifelse(p_value < 0.001, "< 0.001", sprintf("%.3f", p_value)),
  1011. ICC = round(ICC, 3)
  1012. ) %>%
  1013. select(CellType, ICC, `p-value` = p_formatted) %>%
  1014. arrange(desc(ICC))
  1015. results_table <- table_data %>%
  1016. gt() %>%
  1017. tab_header(
  1018. title = "Quantitative Assessment of Intra-patient Homogeneity",
  1019. subtitle = "Intraclass Correlation Coefficients (ICC) and Likelihood Ratio Tests per Cell Type"
  1020. ) %>%
  1021. cols_label(
  1022. CellType = "Cell Lineage",
  1023. ICC = "ICC (Patient Variance)",
  1024. `p-value` = "p-value (Patient Effect)"
  1025. ) %>%
  1026. tab_source_note(
  1027. source_note = paste0("Global PERMANOVA (Bray-Curtis): p = ",
  1028. round(global_p, 4), " | R^2 = ", round(r2_val, 3))
  1029. ) %>%
  1030. tab_style(
  1031. style = cell_text(weight = "bold"),
  1032. locations = cells_body(
  1033. columns = `p-value`,
  1034. rows = `p-value` == "< 0.001" | as.numeric(`p-value`) < 0.05
  1035. )
  1036. ) %>%
  1037. opt_stylize(color = "gray", style = 1)
  1038. results_table
  1039. ```
  1040. ## TSNE + total proportions
  1041. ```{r, fig.width=12, fig.height=10}
  1042. ## --- t-SNE on a cell subset across all non-nuclear channels ---
  1043. selected_markers <- rownames(sce[rowData(sce)$marker_groups %notin% c("nuclei"), ])
  1044. # Subsample cells for a tractable embedding.
  1045. set.seed(1234)
  1046. cur_cells <- sample(seq_len(ncol(sce)), 200000)
  1047. tmp_sce <- sce[rownames(sce) %in% selected_markers, cur_cells]
  1048. # Run t-SNE on the batch-corrected (fastMNN) representation.
  1049. tmp_sce <- runTSNE(tmp_sce,
  1050. exprs_values = "fastMNN",
  1051. BPPARAM = MulticoreParam(progressbar = T))
  1052. # Collapse all immune subsets into a single "Immune" class for the overview.
  1053. tmp_sce$cl_all[tmp_sce$cl_all %notin% c("Astrocytes", "Tumor", "Vascular", "unassigned")] <- "Immune"
  1054. col_ann <- metadata(tmp_sce)$col_celltypes$celltype_all
  1055. col_ann["Immune"] <- col_ann[["CD8Tc"]]
  1056. order <- c("Tumor", "Immune", "Vascular", "Astrocytes", "unassigned")
  1057. (p.tsne.cohort <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = "cl_all", point_size = .15) +
  1058. scale_colour_manual(values = col_ann) +
  1059. theme(legend.position = "none"))
  1060. ## --- Overall cohort composition as a single stacked bar with labels ---
  1061. df.barplot.all <- colData(sce) %>% as_tibble()
  1062. col_ann <- metadata(sce)$col_celltypes$celltype_all
  1063. col_ann["Immune"] <- col_ann[["CD8Tc"]]
  1064. order <- c("Tumor", "Immune", "Vascular", "Astrocytes", "unassigned")
  1065. plot.data <- df.barplot.all %>%
  1066. mutate(cl_all = if_else(cl_all %in% c("Astrocytes", "Tumor", "Vascular", "unassigned"),
  1067. cl_all, "Immune")) %>%
  1068. group_by(cl_all) %>% summarise(count = n()) %>% mutate(prop = prop.table(count))
  1069. (p.prop.cohort <- ggplot(data = plot.data, aes(x = 1, y = prop, fill = factor(cl_all, levels = order))) +
  1070. geom_bar(stat = "identity") +
  1071. ggrepel::geom_text_repel(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1072. position = position_stack(vjust = 0.5),
  1073. direction = "y",
  1074. xlim = c(1.5, Inf), ylim = c(-Inf, Inf),
  1075. size = 5, hjust = 0,
  1076. segment.size = .7, segment.alpha = .5,
  1077. segment.linetype = "dotted", box.padding = .4,
  1078. segment.curvature = -0.1, segment.ncp = 3, segment.angle = 20) +
  1079. coord_cartesian(clip = "off") +
  1080. ylab("Proportion") +
  1081. scale_fill_manual(name = "Celltypes", values = col_ann, breaks = order) +
  1082. theme_minimal() +
  1083. theme(legend.position = "right",
  1084. plot.margin = unit(c(0, 7, 0, 0), "cm"),
  1085. axis.title.x = element_blank(),
  1086. axis.ticks.x = element_blank(),
  1087. axis.text.x = element_blank(),
  1088. panel.background = element_blank(),
  1089. panel.grid.minor.x = element_blank(),
  1090. panel.grid.major.x = element_blank()))
  1091. p.comb <- p.tsne.cohort | p.prop.cohort + plot_layout(widths = c(1.5, 1))
  1092. ggsave(plot = p.comb, "figures/fig2_het_stacked_cohort.png", width = 12, heigh = 7, dpi = 300)
  1093. ```
  1094. ### Suppl: Marker expression on t-SNE
  1095. ```{r, fig.width=20, fig.height=20}
  1096. # One t-SNE panel per marker, coloured by z-scored expression.
  1097. plot_list <- lapply(sort(rownames(tmp_sce)), function(x) {
  1098. p <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = x,
  1099. by_exprs_values = "scaled", point_size = 0.2)
  1100. p + scale_colour_gradient2(low = "#313695", mid = "white", high = "#A50026",
  1101. limits = c(-4, 4), oob = scales::squish,
  1102. name = "z-score expression") +
  1103. ggtitle(x) +
  1104. theme(plot.title = element_text(hjust = 0.5),
  1105. legend.position = "none",
  1106. axis.ticks = element_blank(),
  1107. axis.text = element_blank())
  1108. })
  1109. legend <- get_legend(plot_list[[1]] + theme(legend.position = "right"))
  1110. p.wrap <- wrap_plots(c(plot_list, list(legend))) +
  1111. plot_layout(axes = "collect", axis_titles = "collect")
  1112. ggsave(plot = p.wrap, "figures/suppfig_all_expression.png", width = 20, heigh = 20, dpi = 300)
  1113. ```
  1114. ## Suppl: T cell + Myeloid subsets
  1115. ### t-SNE
  1116. #### Lymphoid / T cells
  1117. ```{r}
  1118. ## T cells: t-SNE on T-cell clustering markers.
  1119. selected_markers <- rownames(sce[rowData(sce)$cl_tcells, ])
  1120. # Subset to T-cell populations.
  1121. tmp_sce <- sce[, sce$cl_all %in% c("CD4Tc", "CD8Tc", "DPTc", "Treg")]
  1122. # Subsample and embed.
  1123. set.seed(1234)
  1124. cur_cells <- sample(seq_len(ncol(tmp_sce)), 100000)
  1125. tmp_sce <- tmp_sce[rownames(tmp_sce) %in% selected_markers, cur_cells]
  1126. tmp_sce <- runTSNE(tmp_sce, exprs_values = "fastMNN", BPPARAM = MulticoreParam(progressbar = T))
  1127. col_ann <- metadata(tmp_sce)$col_celltypes$celltype_all
  1128. # Cell-type embedding.
  1129. (p.tsne.lymphoid <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = "cl_all_spec",
  1130. point_size = .2, text_by = "cl_all_spec") +
  1131. scale_colour_manual(values = col_ann_hm) +
  1132. ggtitle("Cell types") +
  1133. guides(color = guide_legend(override.aes = list(size = 5), title = "Cell types")) +
  1134. theme(plot.title = element_text(hjust = 0.5),
  1135. axis.ticks = element_blank(),
  1136. axis.text = element_blank(),
  1137. legend.position = "none")) +
  1138. coord_fixed()
  1139. legend.celltypes_lymphoid <- get_legend(p.tsne.lymphoid + theme(legend.position = "right"))
  1140. # Per-marker expression panels.
  1141. plot_list_lymphoid <- lapply(sort(rownames(tmp_sce)), function(x) {
  1142. p <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = x,
  1143. by_exprs_values = "scaled", point_size = 0.2)
  1144. p + scale_colour_gradient2(low = "#313695", mid = "white", high = "#A50026",
  1145. limits = c(-4, 4), oob = scales::squish,
  1146. name = "z-score expression") +
  1147. ggtitle(x) +
  1148. theme(plot.title = element_text(hjust = 0.5),
  1149. legend.position = "none",
  1150. axis.ticks = element_blank(),
  1151. axis.text = element_blank()) +
  1152. coord_fixed()
  1153. })
  1154. legend_lymphoid <- get_legend(plot_list_lymphoid[[1]] + theme(legend.position = "right"))
  1155. ```
  1156. #### Myeloid
  1157. ```{r}
  1158. ## Myeloid: t-SNE on myeloid clustering markers.
  1159. selected_markers <- rownames(sce[rowData(sce)$cl_myeloid, ])
  1160. # Subset to myeloid + neutrophil populations.
  1161. tmp_sce <- sce[, sce$cl_immune_a %in% c("Myeloid", "Neutrophils")]
  1162. # Subsample and embed.
  1163. set.seed(1234)
  1164. cur_cells <- sample(seq_len(ncol(tmp_sce)), 100000)
  1165. tmp_sce <- tmp_sce[rownames(tmp_sce) %in% selected_markers, cur_cells]
  1166. tmp_sce <- runTSNE(tmp_sce, exprs_values = "fastMNN", BPPARAM = MulticoreParam(progressbar = T))
  1167. col_ann <- metadata(tmp_sce)$col_celltypes$celltype_all
  1168. # Cell-type embedding.
  1169. (p.tsne.myeloid <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = "cl_all_spec",
  1170. point_size = .15, text_by = "cl_all_spec",
  1171. text_colour = "black", text_size = 4) +
  1172. scale_colour_manual(values = col_ann_hm) +
  1173. ggtitle("Cell types") +
  1174. guides(color = guide_legend(override.aes = list(size = 5), title = "Cell types")) +
  1175. theme(plot.title = element_text(hjust = 0.5),
  1176. axis.ticks = element_blank(),
  1177. axis.text = element_blank(),
  1178. legend.position = "none")) +
  1179. coord_fixed()
  1180. legend.celltypes_myeloid <- get_legend(p.tsne.myeloid + theme(legend.position = "right"))
  1181. # Per-marker expression panels.
  1182. plot_list_myeloid <- lapply(sort(rownames(tmp_sce)), function(x) {
  1183. p <- plotReducedDim(tmp_sce, dimred = "TSNE", colour_by = x,
  1184. by_exprs_values = "scaled", point_size = 0.2)
  1185. p + scale_colour_gradient2(low = "#313695", mid = "white", high = "#A50026",
  1186. limits = c(-4, 4), oob = scales::squish,
  1187. name = "z-score expression") +
  1188. ggtitle(x) +
  1189. theme(plot.title = element_text(hjust = 0.5),
  1190. legend.position = "none",
  1191. axis.ticks = element_blank(),
  1192. axis.text = element_blank()) +
  1193. coord_fixed()
  1194. })
  1195. legend_myeloid <- get_legend(plot_list_myeloid[[1]] + theme(legend.position = "right"))
  1196. ```
  1197. ### Heatmaps
  1198. #### T cells
  1199. ```{r}
  1200. cluster_name <- "cl_all_spec"
  1201. selected_markers <- rownames(sce[rowData(sce)$cl_tcells, ])
  1202. # Subset to T-cell populations.
  1203. tmp_sce <- sce[rownames(sce) %in% selected_markers,
  1204. sce$cl_all %in% c("CD4Tc", "CD8Tc", "DPTc", "Treg")]
  1205. # Mean expression per cluster and scaled assays.
  1206. mean_tmp.sce <- aggregateAcrossCells(tmp_sce, ids = tmp_sce[[cluster_name]], statistics = "mean")
  1207. assay(mean_tmp.sce, "exprs") <- asinh(counts(mean_tmp.sce) / 1)
  1208. assay(mean_tmp.sce, "scaled") <- t(scale(t(assay(mean_tmp.sce, "exprs"))))
  1209. assay(mean_tmp.sce, "scaled_ztm") <- t(apply(assay(mean_tmp.sce, "exprs"), 1, scales::rescale))
  1210. # Heatmap body: zero-to-max scaled expression.
  1211. hm_body <- t(assay(mean_tmp.sce, "scaled_ztm"))
  1212. row_anno <- colData(mean_tmp.sce) %>% as.data.frame %>% select(all_of(cluster_name), ncells)
  1213. row_anno[[cluster_name]] <- as.character(row_anno[[cluster_name]])
  1214. # Viridis scale for zero-to-max values.
  1215. col_1 <- viridis(100)
  1216. ha_right <- HeatmapAnnotation(
  1217. cluster_name = anno_simple(row_anno[[cluster_name]], border = TRUE, col = col_ann_hm),
  1218. names = anno_text(str_wrap(row_anno[[cluster_name]], width = 12), just = "left"),
  1219. annotation_label = "",
  1220. annotation_name_rot = 90,
  1221. which = "row")
  1222. hm <- Heatmap(hm_body,
  1223. name = "zero-to-max",
  1224. col = col_1,
  1225. clustering_method_columns = "complete",
  1226. clustering_method_rows = "complete",
  1227. column_split = fct_recode(rowData(mean_tmp.sce)$marker_groups,
  1228. "Cellstate" = "cellstate",
  1229. "Lineage" = "lineage"),
  1230. cluster_column_slices = F,
  1231. show_row_names = F,
  1232. right_annotation = c(ha_right))
  1233. hm <- draw(hm)
  1234. p.hm.tcells <- grid.grabExpr(draw(hm))
  1235. ```
  1236. #### Myeloid
  1237. ```{r}
  1238. cluster_name <- "cl_all_spec"
  1239. selected_markers <- rownames(sce[rowData(sce)$cl_myeloid, ])
  1240. # Subset to myeloid + neutrophil populations.
  1241. tmp_sce <- sce[rownames(sce) %in% selected_markers,
  1242. sce$cl_immune_a %in% c("Myeloid", "Neutrophils")]
  1243. # Mean expression per cluster and scaled assays.
  1244. mean_tmp.sce <- aggregateAcrossCells(tmp_sce, ids = tmp_sce[[cluster_name]], statistics = "mean")
  1245. assay(mean_tmp.sce, "exprs") <- asinh(counts(mean_tmp.sce) / 1)
  1246. assay(mean_tmp.sce, "scaled") <- t(scale(t(assay(mean_tmp.sce, "exprs"))))
  1247. assay(mean_tmp.sce, "scaled_ztm") <- t(apply(assay(mean_tmp.sce, "exprs"), 1, scales::rescale))
  1248. # Heatmap body: zero-to-max scaled expression.
  1249. hm_body <- t(assay(mean_tmp.sce, "scaled_ztm"))
  1250. row_anno <- colData(mean_tmp.sce) %>% as.data.frame %>% select(all_of(cluster_name), ncells)
  1251. row_anno[[cluster_name]] <- as.character(row_anno[[cluster_name]])
  1252. col_1 <- viridis(100)
  1253. ha_right <- HeatmapAnnotation(
  1254. cluster_name = anno_simple(row_anno[[cluster_name]], border = TRUE, col = col_ann_hm),
  1255. names = anno_text(str_wrap(row_anno[[cluster_name]], width = 12), just = "left"),
  1256. annotation_label = "",
  1257. annotation_name_rot = 90,
  1258. which = "row")
  1259. hm <- Heatmap(hm_body,
  1260. name = "zero-to-max",
  1261. col = col_1,
  1262. clustering_method_columns = "complete",
  1263. clustering_method_rows = "complete",
  1264. column_split = fct_recode(rowData(mean_tmp.sce)$marker_groups,
  1265. "Cellstate" = "cellstate",
  1266. "Lineage" = "lineage"),
  1267. cluster_column_slices = F,
  1268. show_row_names = F,
  1269. right_annotation = c(ha_right))
  1270. hm <- draw(hm)
  1271. p.hm.myeloid <- grid.grabExpr(draw(hm))
  1272. ```
  1273. ### Barplots
  1274. #### T cells
  1275. ```{r}
  1276. # T-cell colData and stacking order.
  1277. df.col <- colData(sce) %>% as_tibble() %>%
  1278. filter(cl_all %in% c("CD4Tc", "CD8Tc", "DPTc", "Treg"))
  1279. celltype_order <- unique(df.col$cl_all_spec)
  1280. # Overall T-cell subset composition (single labelled stacked bar).
  1281. plot.data <- df.col %>%
  1282. group_by(cl_all_spec) %>% summarise(count = n()) %>% mutate(prop = prop.table(count))
  1283. (p.prop.tcells <- ggplot(data = plot.data, aes(x = 1, y = prop, fill = cl_all_spec)) +
  1284. geom_bar(stat = "identity") +
  1285. ggrepel::geom_text_repel(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1286. position = position_stack(vjust = 0.5),
  1287. direction = "y",
  1288. xlim = c(1.5, Inf), ylim = c(-Inf, Inf),
  1289. size = 5, hjust = 0,
  1290. segment.size = .7, segment.alpha = .5,
  1291. segment.linetype = "dotted", box.padding = .4,
  1292. segment.curvature = -0.1, segment.ncp = 3, segment.angle = 20) +
  1293. coord_cartesian(clip = "off") +
  1294. ylab("Proportion") +
  1295. scale_fill_manual(name = "Cell types", values = col_ann_hm) +
  1296. theme_minimal() +
  1297. theme(legend.position = "right",
  1298. plot.margin = unit(c(0, 7, 0, 0), "cm"),
  1299. legend.box.margin = margin(0, 0, 0, 25),
  1300. axis.title.x = element_blank(),
  1301. axis.ticks.x = element_blank(),
  1302. axis.text.x = element_blank(),
  1303. panel.background = element_blank(),
  1304. panel.grid.minor.x = element_blank(),
  1305. panel.grid.major.x = element_blank()))
  1306. # Cluster samples by T-cell subset composition and build the dendrogram.
  1307. df_clust <- df.col %>%
  1308. group_by(biosample_id, cl_all_spec) %>%
  1309. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>% ungroup()
  1310. df_clust_wide <- df_clust %>%
  1311. pivot_wider(id_cols = biosample_id, names_from = cl_all_spec, values_from = prop) %>%
  1312. replace(is.na(.), 0)
  1313. df_clust_m <- as.matrix(df_clust_wide)
  1314. rownames(df_clust_m) <- df_clust_wide$biosample_id
  1315. hc <- hclust(dist(df_clust_m[, -1], method = "euclidian"), "ward.D2")
  1316. dend <- as.dendrogram(hc)
  1317. dend_data <- dendro_data(dend, type = "rectangle")
  1318. (p.dend <- ggplot(dend_data$segments) +
  1319. geom_segment(aes(x = x, y = y, xend = xend, yend = yend)) +
  1320. geom_text(data = dend_data$labels, aes(x, y, label = label), hjust = 1, angle = 90) +
  1321. scale_x_continuous(expand = c(0, 0.5)) +
  1322. scale_y_continuous(expand = c(0, 0)) +
  1323. theme_void() +
  1324. theme(plot.margin = margin(0, 0, 0, 0, unit = "mm")))
  1325. # Per-ROI counts with sample/condition annotation, ordered by clustering.
  1326. df_barplots <- df.col %>%
  1327. group_by(image_id, biosample_id, pid, cond.3, cond.2, cond.4, cl_all_spec) %>%
  1328. summarise(count = n()) %>% ungroup()
  1329. df_barplots$biosample_id <- factor(df_barplots$biosample_id, levels = hc$labels[hc$order])
  1330. df_barplots$pid <- as.factor(df_barplots$pid)
  1331. x <- dend_data$labels[c("x", "label")] %>% as_tibble() %>% rename(biosample_id = label)
  1332. df_barplots <- df_barplots %>% left_join(x, by = "biosample_id")
  1333. # Stacked composition by sample, with a condition.2 annotation track.
  1334. (p1 <-
  1335. ggplot(data = df_barplots) +
  1336. geom_bar(aes(fill = factor(cl_all_spec, levels = celltype_order), x = x, y = count),
  1337. stat = "identity", position = "fill", width = 1.1) +
  1338. scale_fill_manual(name = "Celltypes", values = col_ann_hm, breaks = celltype_order) +
  1339. geom_tile(aes(x = x, y = 0, height = 0.001, width = 1.1), fill = "transparent") +
  1340. new_scale_fill() +
  1341. geom_tile(aes(x = x, y = -0.055, height = 0.025, width = 1.1, fill = cond.2)) +
  1342. scale_fill_manual(name = "Condition 2",
  1343. values = metadata(sce)$col_clinical$cond.2,
  1344. na.value = "transparent") +
  1345. scale_y_continuous(breaks = c(seq(0.25, 1, by = 0.25), -0.025),
  1346. labels = c(c("0.25", "0.50", "0.75", "1.00"), "")) +
  1347. facet_grid(~ x, scales = "free_x", space = "free_x", switch = "x") +
  1348. ylab("Proportion") + xlab("Sample") +
  1349. theme(axis.text.x = element_blank(),
  1350. axis.ticks.x = element_blank(),
  1351. axis.title.x = element_blank(),
  1352. panel.spacing = unit(.2, units = "mm"),
  1353. strip.background = element_blank(),
  1354. strip.text.x = element_blank(),
  1355. panel.background = element_blank(),
  1356. panel.grid.major = element_blank(),
  1357. panel.grid.minor = element_blank(),
  1358. plot.margin = margin(0, 0, 0, 0, unit = "mm"),
  1359. axis.text.y = element_text(face = c("plain", "plain", "plain", "plain",
  1360. "bold", "bold", "bold"))))
  1361. (p.dend / p1) + plot_layout(guides = "collect", ncol = 1, heights = c(0.25, 1))
  1362. ```
  1363. #### Myeloid
  1364. ```{r}
  1365. # Myeloid colData and stacking order.
  1366. df.col <- colData(sce) %>% as_tibble() %>%
  1367. filter(cl_immune_a %in% c("Myeloid", "Neutrophils"))
  1368. celltype_order <- unique(df.col$cl_all_spec)
  1369. # Overall myeloid subset composition (single labelled stacked bar).
  1370. plot.data <- df.col %>%
  1371. group_by(cl_all_spec) %>% summarise(count = n()) %>% mutate(prop = prop.table(count))
  1372. (p.prop.myeloid <- ggplot(data = plot.data, aes(x = 1, y = prop, fill = cl_all_spec)) +
  1373. geom_bar(stat = "identity") +
  1374. ggrepel::geom_text_repel(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1375. position = position_stack(vjust = 0.5),
  1376. direction = "y",
  1377. xlim = c(1.5, Inf), ylim = c(-Inf, Inf),
  1378. size = 5, hjust = 0,
  1379. segment.size = .7, segment.alpha = .5,
  1380. segment.linetype = "dotted", box.padding = .4,
  1381. segment.curvature = -0.1, segment.ncp = 3, segment.angle = 20) +
  1382. coord_cartesian(clip = "off") +
  1383. ylab("Proportion") +
  1384. scale_fill_manual(name = "Cell types", values = col_ann_hm) +
  1385. theme_minimal() +
  1386. theme(legend.position = "right",
  1387. plot.margin = unit(c(0, 7, 0, 0), "cm"),
  1388. legend.box.margin = margin(0, 0, 0, 25),
  1389. axis.title.x = element_blank(),
  1390. axis.ticks.x = element_blank(),
  1391. axis.text.x = element_blank(),
  1392. panel.background = element_blank(),
  1393. panel.grid.minor.x = element_blank(),
  1394. panel.grid.major.x = element_blank()))
  1395. # Cluster samples by myeloid subset composition and build the dendrogram.
  1396. df_clust <- df.col %>%
  1397. group_by(biosample_id, cl_all_spec) %>%
  1398. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>% ungroup()
  1399. df_clust_wide <- df_clust %>%
  1400. pivot_wider(id_cols = biosample_id, names_from = cl_all_spec, values_from = prop) %>%
  1401. replace(is.na(.), 0)
  1402. df_clust_m <- as.matrix(df_clust_wide)
  1403. rownames(df_clust_m) <- df_clust_wide$biosample_id
  1404. hc <- hclust(dist(df_clust_m[, -1], method = "euclidian"), "ward.D2")
  1405. dend <- as.dendrogram(hc)
  1406. dend_data <- dendro_data(dend, type = "rectangle")
  1407. (p.dend <- ggplot(dend_data$segments) +
  1408. geom_segment(aes(x = x, y = y, xend = xend, yend = yend)) +
  1409. geom_text(data = dend_data$labels, aes(x, y, label = label), hjust = 1, angle = 90) +
  1410. scale_x_continuous(expand = c(0, 0.5)) +
  1411. scale_y_continuous(expand = c(0, 0)) +
  1412. theme_void() +
  1413. theme(plot.margin = margin(0, 0, 0, 0, unit = "mm")))
  1414. # Per-ROI counts with sample/condition annotation, ordered by clustering.
  1415. df_barplots <- df.col %>%
  1416. group_by(image_id, biosample_id, pid, cond.3, cond.2, cond.4, cl_all_spec) %>%
  1417. summarise(count = n()) %>% ungroup()
  1418. df_barplots$biosample_id <- factor(df_barplots$biosample_id, levels = hc$labels[hc$order])
  1419. df_barplots$pid <- as.factor(df_barplots$pid)
  1420. x <- dend_data$labels[c("x", "label")] %>% as_tibble() %>% rename(biosample_id = label)
  1421. df_barplots <- df_barplots %>% left_join(x, by = "biosample_id")
  1422. # Stacked composition by sample, with a condition.2 annotation track.
  1423. (p1 <-
  1424. ggplot(data = df_barplots) +
  1425. geom_bar(aes(fill = factor(cl_all_spec, levels = celltype_order), x = x, y = count),
  1426. stat = "identity", position = "fill", width = 1.1) +
  1427. scale_fill_manual(name = "Celltypes", values = col_ann_hm, breaks = celltype_order) +
  1428. geom_tile(aes(x = x, y = 0, height = 0.001, width = 1.1), fill = "transparent") +
  1429. new_scale_fill() +
  1430. geom_tile(aes(x = x, y = -0.055, height = 0.025, width = 1.1, fill = cond.2)) +
  1431. scale_fill_manual(name = "Condition 2",
  1432. values = metadata(sce)$col_clinical$cond.2,
  1433. na.value = "transparent") +
  1434. scale_y_continuous(breaks = c(seq(0.25, 1, by = 0.25), -0.025),
  1435. labels = c(c("0.25", "0.50", "0.75", "1.00"), "")) +
  1436. facet_grid(~ x, scales = "free_x", space = "free_x", switch = "x") +
  1437. ylab("Proportion") + xlab("Sample") +
  1438. theme(axis.text.x = element_blank(),
  1439. axis.ticks.x = element_blank(),
  1440. axis.title.x = element_blank(),
  1441. panel.spacing = unit(.2, units = "mm"),
  1442. strip.background = element_blank(),
  1443. strip.text.x = element_blank(),
  1444. panel.background = element_blank(),
  1445. panel.grid.major = element_blank(),
  1446. panel.grid.minor = element_blank(),
  1447. plot.margin = margin(0, 0, 0, 0, unit = "mm"),
  1448. axis.text.y = element_text(face = c("plain", "plain", "plain", "plain",
  1449. "bold", "bold", "bold"))))
  1450. (p.dend / p1) + plot_layout(guides = "collect", ncol = 1, heights = c(0.25, 1))
  1451. ```
  1452. ### Assemble
  1453. ```{r, fig.width=19, fig.height=20}
  1454. # Lymphoid supplementary figure: heatmap + cell-type t-SNE + proportions (top),
  1455. # per-marker expression panels (bottom).
  1456. (( wrap_plots(p.hm.tcells) | p.tsne.lymphoid + coord_fixed() | p.prop.tcells ) +
  1457. plot_layout(widths = c(1.5, 1, .1)) ) /
  1458. (wrap_plots(c(plot_list_lymphoid, list(legend_lymphoid))) +
  1459. plot_layout(axes = "collect", axis_titles = "collect")) +
  1460. plot_annotation(tag_levels = list(c("A", "B", "C", "D"))) +
  1461. plot_layout(height = c(.25, 1))
  1462. ggsave(filename = "figures/suppfig_lymphoid.png", width = 19, heigh = 20, dpi = 300)
  1463. # Myeloid supplementary figure (same layout).
  1464. (( wrap_plots(p.hm.myeloid) | p.tsne.myeloid + coord_fixed() | p.prop.myeloid) +
  1465. plot_layout(widths = c(1.5, 1, .15)) ) /
  1466. (wrap_plots(c(plot_list_myeloid, list(legend_myeloid))) +
  1467. plot_layout(axes = "collect", axis_titles = "collect")) +
  1468. plot_annotation(tag_levels = list(c("A", "B", "C", "D"))) +
  1469. plot_layout(height = c(.25, 1))
  1470. ggsave(filename = "figures/suppfig_myeloid.png", width = 19, heigh = 18, dpi = 300)
  1471. ```
  1472. ## Pearson correlation + spatial interaction
  1473. ```{r, fig.width=10}
  1474. # Permutation-based neighbourhood interaction test (imcRtools) per sample.
  1475. out <- testInteractions(sce[, sce$cl_all_spec != "unassigned"],
  1476. group_by = "biosample_id",
  1477. label = "cl_all_spec",
  1478. colPairName = "neighborhood",
  1479. method = "classic",
  1480. BPPARAM = MulticoreParam(workers = 1, RNGseed = 221029))
  1481. head(out)
  1482. rois <- length(unique(out$group_by))
  1483. # Summarise significant interaction/avoidance counts per cell-type pair.
  1484. df.out <- out %>% as_tibble() %>%
  1485. group_by(from_label, to_label) %>%
  1486. summarize(sum_sigval = sum(sigval, na.rm = TRUE)) %>%
  1487. mutate(prop_sigval = abs(sum_sigval / rois))
  1488. # (Standalone z-scored interaction heatmap for the whole cohort.)
  1489. p <- df.out %>%
  1490. ggplot(aes(from_label, to_label)) +
  1491. geom_tile(aes(fill = scale(sum_sigval))) +
  1492. scale_fill_gradient2(low = "#313695", mid = "white", high = "#A50026",
  1493. limits = c(-2, 2), oob = scales::squish,
  1494. name = "z-score expression") +
  1495. theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
  1496. ggtitle("Entire cohort")
  1497. # Pearson correlation of cell-type proportions across samples.
  1498. df.cor <- colData(sce) %>% as_tibble() %>%
  1499. filter(cl_all_spec != "unassigned") %>%
  1500. group_by(biosample_id, cl_all_spec) %>%
  1501. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  1502. pivot_wider(id_cols = biosample_id, names_from = cl_all_spec, values_from = prop) %>%
  1503. column_to_rownames(var = "biosample_id") %>% replace(is.na(.), 0) %>% cor()
  1504. # Reorder a correlation matrix by hierarchical clustering of (1 - r)/2.
  1505. reorder_corr.matrix <- function(corr.matrix) {
  1506. distance <- as.dist((1 - corr.matrix) / 2)
  1507. hc <- hclust(distance)
  1508. corr.matrix[hc$order, hc$order]
  1509. }
  1510. df.cor <- reorder_corr.matrix(df.cor)
  1511. # Keep only the lower triangle and reshape to long format.
  1512. df.cor[upper.tri(df.cor)] <- NA
  1513. df.cor.long <- reshape2::melt(df.cor, rm.na = T)
  1514. order <- levels(df.cor.long$Var1)
  1515. # Combined plot: tiles = Pearson correlation; points = neighbourhood
  1516. # interaction/avoidance (size = % significant samples, fill = z-scored direction).
  1517. library(colorspace)
  1518. library(ggnewscale)
  1519. df.cor.long %>%
  1520. left_join(df.out, by = c("Var1" = "from_label", "Var2" = "to_label")) %>%
  1521. filter(!is.na(value)) %>%
  1522. mutate(sum_sigval_cat = case_when(sum_sigval == 0 ~ "None",
  1523. sum_sigval > 0 ~ "Interaction",
  1524. sum_sigval < 0 ~ "Avoidance")) %>%
  1525. ggplot(aes(factor(Var1, levels = order), factor(Var2, levels = order))) +
  1526. geom_tile(aes(fill = value), color = "white") +
  1527. scale_fill_gradient2(low = "#313695", mid = "white", high = "#A50026",
  1528. limits = c(-1, 1), midpoint = 0, na.value = "transparent",
  1529. name = "Pearson Correlation of celltype\nproportions in samples") +
  1530. new_scale_fill() +
  1531. geom_point(shape = 21,
  1532. aes(size = prop_sigval,
  1533. fill = scale(sum_sigval, center = T, scale = T),
  1534. alpha = prop_sigval == 0),
  1535. colour = "black") +
  1536. scale_fill_gradient2(low = "blue", mid = "white", high = "red",
  1537. limits = c(-1, 1), na.value = "transparent",
  1538. oob = scales::squish,
  1539. name = "Avoidance/Interaction\n(z-score)",
  1540. breaks = c(-2, -1, 0, 1, 2),
  1541. labels = c("Avoidance", -1, 0, 1, "Interaction")) +
  1542. scale_size_continuous(name = "%Samples with significant\ninteraction/avoidance",
  1543. breaks = c(0.25, 0.5, 0.75, 1.00)) +
  1544. scale_alpha_manual(values = c(1, 0)) +
  1545. guides(alpha = "none") +
  1546. theme_void() +
  1547. theme(axis.text.x = element_text(angle = 45, vjust = 1, size = 12, hjust = 1),
  1548. axis.text.y = element_text(size = 12, hjust = 1)) +
  1549. coord_fixed()
  1550. ggsave(filename = "figures/fig_correlation_interaction.pdf", width = 10, height = 10, dpi = 200)
  1551. ```
  1552. # --------------------------------
  1553. # FIGURE 3
  1554. Survival analysis of the entire cohort.
  1555. ## Proportions vs. survival
  1556. ```{r, fig.width = 7.5, fig.height=6}
  1557. # Clinical/survival data restricted to the cohort.
  1558. coldata <- colData(sce) %>% as_tibble()
  1559. survData <- metadata(sce)$clinical %>%
  1560. select(biosample_id, status, status_pfs, days_dg, days_dos, days_dos_pfs)
  1561. survData <- coldata %>% distinct(biosample_id) %>% left_join(survData)
  1562. sample.ids <- coldata %>% distinct(biosample_id)
  1563. # Per-sample cell-type proportions (tumor excluded), used as continuous predictors.
  1564. celltype.props <- coldata %>% filter(cl_all_spec != "Tumor") %>%
  1565. group_by(biosample_id, cl_all_spec) %>% summarise(count = n()) %>%
  1566. mutate(prop = prop.table(count)) %>% ungroup() %>% select(-count) %>%
  1567. pivot_wider(names_from = "cl_all_spec", values_from = "prop") %>% replace(is.na(.), 0)
  1568. variables <- celltype.props %>% ungroup() %>% select(-biosample_id) %>% colnames()
  1569. # --- Univariate Cox: overall survival (OS) ---
  1570. time <- "days_dos"
  1571. event <- "status"
  1572. survData.subset <- survData %>% filter(biosample_id %in% sample.ids$biosample_id) %>%
  1573. filter(!is.na(!!rlang::sym(time)) & !is.na(!!rlang::sym(event)))
  1574. df <- survData.subset %>% left_join(celltype.props) %>%
  1575. mutate(across(all_of(variables), ~ scale(.x)[, 1])) # z-score each predictor
  1576. res.os <- run.coxph(df, variables, var.time = time, var.status = event, univariate = T)
  1577. res.os$outcome <- "OS"
  1578. res.os <- res.os %>% mutate(p.fdr = p.adjust(p.value, method = "fdr"))
  1579. # --- Univariate Cox: progression-free survival (PFS) ---
  1580. time <- "days_dos_pfs"
  1581. event <- "status_pfs"
  1582. survData.subset <- survData %>% filter(biosample_id %in% sample.ids$biosample_id) %>%
  1583. filter(!is.na(!!rlang::sym(time)) & !is.na(!!rlang::sym(event)))
  1584. df <- survData.subset %>% left_join(celltype.props) %>%
  1585. mutate(across(all_of(variables), ~ scale(.x)[, 1]))
  1586. res.pfs <- run.coxph(df, variables, var.time = time, var.status = event, univariate = T)
  1587. res.pfs$outcome <- "PFS"
  1588. res.pfs <- res.pfs %>% mutate(p.fdr = p.adjust(p.value, method = "fdr"))
  1589. res.prop.all <- rbind(res.os, res.pfs)
  1590. # Colour scheme by outcome and cell-type ordering from the Figure 2 heatmap.
  1591. col_ann <- setNames(dittoColors()[seq_along(unique(res.prop.all$outcome))],
  1592. unique(res.prop.all$outcome))
  1593. order <- rev(mean_tmp.sce$cl_all_spec[row_order(hm.celltypes) &
  1594. mean_tmp.sce$cl_all_spec %in% res.prop.all$term])
  1595. # Panel 0: cell-type colour key.
  1596. p0 <- res.prop.all %>% ggplot(aes(x = 0, width = 1, y = term, fill = term, height = 0.8)) +
  1597. geom_tile(show.legend = F) +
  1598. scale_fill_manual(values = col_ann_hm) +
  1599. scale_y_discrete(limits = order) +
  1600. theme(axis.text.x = element_blank(),
  1601. axis.ticks.x = element_blank(),
  1602. axis.title.x = element_blank(),
  1603. axis.title.y = element_blank(),
  1604. panel.spacing = unit(.2, units = "mm"),
  1605. strip.background = element_blank(),
  1606. strip.text.x = element_blank(),
  1607. panel.background = element_blank(),
  1608. panel.grid.major = element_blank(),
  1609. panel.grid.minor = element_blank(),
  1610. text = element_text(size = 15))
  1611. # Panel 1: hazard ratios with confidence intervals.
  1612. (p1 <- res.prop.all %>% ggplot(aes(y = term, color = outcome)) +
  1613. geom_point(aes(x = (estimate)), shape = 18, size = 5, position = position_dodge(0.7)) +
  1614. geom_errorbarh(aes(xmin = (conf.low), xmax = (conf.high)), height = 0.25,
  1615. position = position_dodge(0.7)) +
  1616. geom_vline(xintercept = (1), color = "black", linetype = "dashed", cex = .5, alpha = 0.5) +
  1617. scale_x_continuous(breaks = c(0, 1, 2), labels = c(0, 1, 2), name = "HR") +
  1618. scale_y_discrete(limits = order) +
  1619. scale_color_manual(values = col_ann) +
  1620. theme_minimal() +
  1621. theme(legend.position = "none",
  1622. axis.title.y = element_blank(),
  1623. axis.text.y = element_blank(),
  1624. text = element_text(size = 15)))
  1625. # Panel 2: -log10(p) bars with FDR-significant markers.
  1626. (p2 <- ggplot(data = res.prop.all, aes(y = term, fill = outcome)) +
  1627. # Invisible dummy layer to inject the "* FDR < 0.1" legend key.
  1628. geom_point(aes(x = -log10(p.value), color = ""), show.legend = TRUE) +
  1629. scale_color_manual(name = "* FDR < 0.1", values = "transparent",
  1630. guide = guide_legend(override.aes = list(color = NA))) +
  1631. geom_bar(aes(x = -log10(p.value)), position = position_dodge(0.7), stat = "identity") +
  1632. geom_text(data = res.prop.all %>% filter(p.fdr < 0.1),
  1633. aes(x = -log10(p.value), label = "*", group = outcome),
  1634. position = position_dodge(0.7), hjust = -0.3, size = 4.5) +
  1635. geom_vline(xintercept = -log10(0.05), color = "black", linetype = "dashed",
  1636. cex = .5, alpha = 0.5) +
  1637. scale_fill_manual(values = col_ann, name = "Outcome") +
  1638. scale_y_discrete(limits = (rev(mean_tmp.sce$cl_all_spec[row_order(hm) &
  1639. mean_tmp.sce$cl_all_spec %in% res.prop.all$term]))) +
  1640. scale_x_continuous(breaks = NULL, name = "-log10(p value)",
  1641. sec.axis = dup_axis(breaks = c(-log10(0.05)),
  1642. labels = c("p = 0.05"), name = NULL)) +
  1643. coord_cartesian(xlim = c(0, max(-log10(res.prop.all$p.value)) + 0.5)) +
  1644. theme_minimal() +
  1645. theme(axis.title.y = element_blank(),
  1646. axis.text.y = element_blank(),
  1647. axis.text.x = element_text(face = "bold"),
  1648. axis.ticks.y = element_blank(),
  1649. text = element_text(size = 15)))
  1650. p.prop.os <- (p0 + ggtitle("Cell type proportions - Cox survival model") + p1 + p2) +
  1651. plot_layout(widths = c(0.5, 6, 2))
  1652. p.prop.os
  1653. ggsave(filename = "figures/suppfig_CoxProportions.pdf", width = 7.5, heigh = 6, dpi = 300)
  1654. ```
  1655. ### Table
  1656. ```{r}
  1657. library(gt)
  1658. # Format Cox results (HR with CI, raw and FDR-adjusted p-values).
  1659. make_tbl <- function(df) {
  1660. df %>%
  1661. mutate(
  1662. `HR (95% CI)` = case_when(
  1663. estimate > 1000 | estimate < 0.001 ~
  1664. sprintf("%.2e (%.2e-%.2e)", estimate, conf.low, conf.high),
  1665. TRUE ~
  1666. sprintf("%.2f (%.2f-%.2f)", estimate, conf.low, conf.high)
  1667. ),
  1668. `p-value` = ifelse(p.value < 0.001, "<0.001", sprintf("%.3f", p.value)),
  1669. `p-adj (FDR)` = ifelse(p.fdr < 0.001, "<0.001", sprintf("%.3f", p.fdr))
  1670. ) %>%
  1671. select(term, `HR (95% CI)`, `p-value`, `p-adj (FDR)`, p.value, p.fdr)
  1672. }
  1673. # Join OS and PFS side by side, keeping raw p-values for conditional styling.
  1674. tbl_data <- left_join(
  1675. res.os %>% make_tbl() %>% rename_with(~ paste0(.x, ".os"), -term),
  1676. res.pfs %>% make_tbl() %>% rename_with(~ paste0(.x, ".pfs"), -term),
  1677. by = "term"
  1678. ) %>%
  1679. slice(match(rev(order), term))
  1680. tbl_data %>%
  1681. select(term,
  1682. `HR (95% CI).os`, `p-value.os`, `p-adj (FDR).os`,
  1683. `HR (95% CI).pfs`, `p-value.pfs`, `p-adj (FDR).pfs`) %>%
  1684. gt() %>%
  1685. cols_label(
  1686. term = "Variable",
  1687. `HR (95% CI).os` = "HR (95% CI)",
  1688. `p-value.os` = "p-value",
  1689. `p-adj (FDR).os` = "Adjusted p-value (FDR)",
  1690. `HR (95% CI).pfs` = "HR (95% CI)",
  1691. `p-value.pfs` = "p-value",
  1692. `p-adj (FDR).pfs` = "Adjusted p-value (FDR)"
  1693. ) %>%
  1694. tab_spanner(label = md("**OS**"), columns = ends_with(".os")) %>%
  1695. tab_spanner(label = md("**Local PFS**"), columns = ends_with(".pfs")) %>%
  1696. tab_header(title = "Univariate Cox Proportional Hazards Model") %>%
  1697. tab_style(style = cell_text(weight = "bold"),
  1698. locations = cells_body(columns = `p-value.os`, rows = tbl_data$p.value.os < 0.05)) %>%
  1699. tab_style(style = cell_text(weight = "bold"),
  1700. locations = cells_body(columns = `p-value.pfs`, rows = tbl_data$p.value.pfs < 0.05)) %>%
  1701. tab_style(style = cell_text(weight = "bold"),
  1702. locations = cells_body(columns = `p-adj (FDR).os`, rows = tbl_data$p.fdr.os < 0.1)) %>%
  1703. tab_style(style = cell_text(weight = "bold"),
  1704. locations = cells_body(columns = `p-adj (FDR).pfs`, rows = tbl_data$p.fdr.pfs < 0.1)) %>%
  1705. tab_style(style = cell_text(weight = "bold"), locations = cells_column_labels()) %>%
  1706. tab_footnote(
  1707. footnote = "p-values adjusted using the Benjamini-Hochberg false discovery rate (FDR) method.",
  1708. locations = cells_column_labels(columns = ends_with("(FDR)"))
  1709. ) %>%
  1710. tab_options(table.font.size = 12, data_row.padding = px(4))
  1711. ```
  1712. ## Lcross function
  1713. ### CD8Tc/DPTc vs. Tumor
  1714. ```{r}
  1715. # Define the cell types used in the cross-type L-function analysis: combine
  1716. # CD8 and double-positive T cells, compared against tumor cells.
  1717. sce$celltype_tcells <- sce$celltype_a
  1718. sce$celltype_tcells[sce$cl_all == "CD8Tc"] <- "CD8Tc/DPTc"
  1719. sce$celltype_tcells[sce$cl_all == "DPTc"] <- "CD8Tc/DPTc"
  1720. celltypeA <- "CD8Tc/DPTc"
  1721. celltypeB <- "Tumor"
  1722. cluster.lvl <- "celltype_tcells"
  1723. type.select <- "distal"
  1724. # Tag cells with their Lcross role.
  1725. sce$Lcross_celltype <- ""
  1726. sce[, sce[[cluster.lvl]] == celltypeA]$Lcross_celltype <- celltypeA
  1727. sce[, sce[[cluster.lvl]] == celltypeB]$Lcross_celltype <- celltypeB
  1728. cur_list <- list()
  1729. sce.sub <- sce[, sce[[cluster.lvl]] %in% c(celltypeA, celltypeB)]
  1730. # For each image, compute the inhomogeneous cross-L function (tumor -> T cells)
  1731. # and integrate the L(r)-r curve over a proximal and a distal radius window.
  1732. for (i in unique(sce.sub$image_id)) {
  1733. cur_sce <- sce[, sce$image_id == i]
  1734. celltypeA_count <- ncol(cur_sce[, cur_sce$Lcross_celltype == celltypeA])
  1735. celltypeB_count <- ncol(cur_sce[, cur_sce$Lcross_celltype == celltypeB])
  1736. # Require at least 10 cells of each type in the image.
  1737. if (celltypeA_count >= 10 & celltypeB_count >= 10) {
  1738. cur_sce <- cur_sce[, cur_sce[[cluster.lvl]] %in% c(celltypeA, celltypeB)]
  1739. cur_sce$Lcross_celltype <- ""
  1740. cur_sce[, cur_sce[[cluster.lvl]] == celltypeA]$Lcross_celltype <- celltypeA
  1741. cur_sce[, cur_sce[[cluster.lvl]] == celltypeB]$Lcross_celltype <- celltypeB
  1742. # Build a point pattern within a concave hull of the cell coordinates.
  1743. cdat <- as.data.frame(colData(cur_sce)[, c("Pos_X", "Pos_Y")])
  1744. pt <- cdat %>% st_as_sf(coords = c("Pos_X", "Pos_Y"))
  1745. pts <- ppp(x = cdat$Pos_X,
  1746. y = cdat$Pos_Y,
  1747. marks = as.factor(cur_sce$Lcross_celltype),
  1748. window = as.owin(concaveman(points = pt, concavity = 2)))
  1749. grid_area <- st_area(concaveman(points = pt, concavity = 2)) / 10^6
  1750. # Inhomogeneous cross-L function.
  1751. Lfun <- Lcross.inhom(pts, from = celltypeB, to = celltypeA)
  1752. radius_close <- 25
  1753. radius_far <- 150
  1754. # Integrated L(r)-r over proximal (10-25) and distal (25-150) windows, plus
  1755. # cell densities per mm^2.
  1756. cur_out <- as.data.frame(matrix(nrow = 2, ncol = 4))
  1757. colnames(cur_out) <- c("type", "integral",
  1758. paste0(celltypeA, "_density"), paste0(celltypeB, "_density"))
  1759. cur_out[1, ] <- c("proximal", sum(Lfun$iso[10:radius_close] - Lfun$r[10:radius_close]),
  1760. round(celltypeA_count / grid_area), round(celltypeB_count / grid_area))
  1761. cur_out[2, ] <- c("distal", sum(Lfun$iso[25:radius_far] - Lfun$r[25:radius_far]),
  1762. round(celltypeA_count / grid_area), round(celltypeB_count / grid_area))
  1763. cur_list[[i]] <- cur_out
  1764. }
  1765. }
  1766. # Combine per-image results into one data frame.
  1767. df <- as.data.frame(do.call(rbind, cur_list))
  1768. df$image_id <- str_sub(rownames(df), start = 1, end = -3)
  1769. df$integral <- as.numeric(df$integral)
  1770. df[[paste0(celltypeA, "_density")]] <- as.numeric(df[[paste0(celltypeA, "_density")]])
  1771. df[[paste0(celltypeB, "_density")]] <- as.numeric(df[[paste0(celltypeB, "_density")]])
  1772. df.cd8tcells <- df
  1773. # Select images with above-median T-cell density and extreme integrals
  1774. # (most dispersed vs. most localized).
  1775. df <- df.cd8tcells
  1776. median.density.target <- median(df[[paste0(celltypeA, "_density")]])
  1777. imgids.max <- df %>%
  1778. filter(!!rlang::sym(paste0(celltypeA, "_density")) > median.density.target) %>%
  1779. filter(type == type.select) %>%
  1780. slice_max(order_by = integral, n = 20) %>% pull(image_id)
  1781. imgids.min <- df %>%
  1782. filter(!!rlang::sym(paste0(celltypeA, "_density")) > median.density.target) %>%
  1783. filter(type == type.select) %>%
  1784. slice_min(order_by = integral, n = 20) %>% pull(image_id)
  1785. # Colour scheme for spatial plots.
  1786. col_vec["CD8Tc/DPTc"] <- "red"
  1787. col_vec["Tumor"] <- "blue"
  1788. # Example spatial plots: dispersed vs. localized T-cell patterns.
  1789. (p.disp.tcells <- plotSpatial(sce.sub[, sce.sub$image_id %in% imgids.max], "image_id",
  1790. c("Pos_X", "Pos_Y"), node_color_by = cluster.lvl,
  1791. node_size_fix = 0.5) +
  1792. scale_colour_manual(values = col_vec, name = "Celltypes") +
  1793. theme_void() +
  1794. theme(strip.text.x = element_text(size = 4), legend.position = "bottom"))
  1795. (p.loc.tcells <- plotSpatial(sce.sub[, sce.sub$image_id %in% imgids.min], "image_id",
  1796. c("Pos_X", "Pos_Y"), node_color_by = cluster.lvl,
  1797. node_size_fix = 0.5) +
  1798. scale_colour_manual(values = col_vec, name = "Celltypes") +
  1799. theme_void() +
  1800. theme(strip.text.x = element_text(size = 4), legend.position = "bottom"))
  1801. ```
  1802. ### Assign samples to Lcross category
  1803. ```{r}
  1804. celltypeA <- "CD8Tc/DPTc"
  1805. celltypeB <- "Tumor"
  1806. cluster.lvl <- "celltype_tcells"
  1807. type.select <- "distal"
  1808. df <- df.cd8tcells
  1809. # Median target-cell (T-cell) density across images.
  1810. median.density.target <- median(df[[paste0(celltypeA, "_density")]])
  1811. # Median distal integral among images with above-median target-cell density.
  1812. median.lcross.value <- median(df$integral[df$type == type.select &
  1813. df[paste0(celltypeA, "_density")] >= median.density.target])
  1814. # Map images to samples.
  1815. sample.ids <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, image_id)
  1816. df.samples <- df %>% filter(type == type.select) %>% left_join(sample.ids)
  1817. # Per-sample mean density/integral, dichotomized into:
  1818. # high = dispersed, low = localized (both above density threshold),
  1819. # cold = below density threshold.
  1820. df.samples.mean <- df.samples %>% group_by(biosample_id) %>%
  1821. summarise(mean.CD8Tc_density = mean(`CD8Tc/DPTc_density`),
  1822. mean.integral = mean(integral)) %>%
  1823. mutate(lcross.cat.biosample = case_when(
  1824. mean.CD8Tc_density >= median.density.target & mean.integral >= median.lcross.value ~ "high",
  1825. mean.CD8Tc_density >= median.density.target & mean.integral < median.lcross.value ~ "low",
  1826. mean.CD8Tc_density < median.density.target ~ "cold"))
  1827. # Same classification at the ROI (image) level, to assess within-sample variability.
  1828. df.images <- df.samples %>% group_by(image_id) %>%
  1829. mutate(lcross.cat.image = case_when(
  1830. `CD8Tc/DPTc_density` >= median.density.target & integral >= median.lcross.value ~ "high",
  1831. `CD8Tc/DPTc_density` >= median.density.target & integral < median.lcross.value ~ "low",
  1832. `CD8Tc/DPTc_density` < median.density.target ~ "cold"))
  1833. # Two samples (NB18-1420, NB17-754) had too few CD8/DP T cells to classify; assign
  1834. # them manually as "cold" at both sample and image level.
  1835. df.samples.mean <- df.samples.mean %>%
  1836. add_row(biosample_id = "NB18-1420", lcross.cat.biosample = "cold") %>%
  1837. add_row(biosample_id = "NB17-754", lcross.cat.biosample = "cold")
  1838. add.rows <- tibble(image_id = unique(sce$image_id[sce$biosample_id == "NB18-1420"]),
  1839. biosample_id = "NB18-1420",
  1840. lcross.cat.image = "cold")
  1841. add.rows <- add.rows %>% bind_rows(
  1842. tibble(image_id = unique(sce$image_id[sce$biosample_id == "NB17-754"]),
  1843. biosample_id = "NB17-754",
  1844. lcross.cat.image = "cold"))
  1845. df.images <- df.images %>% bind_rows(add.rows)
  1846. ```
  1847. #### Suppl plot: spatial assignment ROI vs. Sample
  1848. ```{r, fig.width= 8, fig.height = 6}
  1849. # Compare per-ROI infiltration-pattern assignment against the overall sample
  1850. # assignment (how consistent are ROIs within a sample).
  1851. df.plot <- df.images %>%
  1852. left_join(df.samples.mean) %>%
  1853. group_by(biosample_id, lcross.cat.biosample, lcross.cat.image) %>%
  1854. summarise(count = n()) %>%
  1855. left_join(colData(sce) %>% as_tibble() %>% distinct(biosample_id, pid))
  1856. (p.suppl <- df.plot %>%
  1857. ggplot(aes(x = reorder(biosample_id, pid))) +
  1858. geom_bar(aes(y = count, fill = lcross.cat.image), stat = "identity", position = "fill") +
  1859. scale_fill_discrete(name = "Assigned infiltration\npattern per ROI",
  1860. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  1861. new_scale_fill() +
  1862. geom_tile(aes(y = -0.04, height = .04, fill = as.factor(pid))) +
  1863. scale_fill_manual(values = metadata(sce)$col_clinical$pid, guide = "none") +
  1864. scale_y_continuous(breaks = c(-0.04, 0.00, 0.25, 0.5, 0.75, 1),
  1865. labels = c("Patient", "0.00", "0.25", "0.50", "0.75", "1.00")) +
  1866. facet_grid(~lcross.cat.biosample, scales = "free_x", space = "free",
  1867. labeller = as_labeller(c("cold" = "cold", "low" = "localized", "high" = "dispersed"))) +
  1868. ylab("Proportions") + xlab("Sample") +
  1869. theme(axis.text.x = element_blank()))
  1870. ggsave(plot = p.suppl, filename = "figures/suppfig_spatialAssignement.pdf",
  1871. width = 8, height = 4, dpi = 300)
  1872. ```
  1873. #### Suppl plot: spatial assignment vs. ICI responder/non-responder
  1874. ```{r, fig.height = 10, fig.width = 8}
  1875. # Relate the spatial infiltration category to ICI response and pretreatment.
  1876. df.cond <- colData(sce) %>% as_tibble() %>%
  1877. distinct(biosample_id, cond.2, cond.3) %>% left_join(df.samples.mean)
  1878. # Responder vs. non-responder (ICI-naive samples).
  1879. p1 <- df.cond %>% filter(!is.na(cond.2) & !is.na(lcross.cat.biosample)) %>%
  1880. group_by(cond.2, lcross.cat.biosample) %>%
  1881. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  1882. ggplot(aes(x = cond.2, y = prop, fill = lcross.cat.biosample)) +
  1883. geom_bar(stat = "identity") +
  1884. geom_text(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1885. position = position_stack(vjust = 0.5)) +
  1886. scale_fill_discrete(name = "Spatial infiltration pattern",
  1887. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  1888. scale_x_discrete(name = "", labels = c("non-responder" = "Non-responder", "responder" = "Responder")) +
  1889. ylab("Proportion") + theme_minimal() + ggtitle("ICI naive with postoperative ICI")
  1890. # ICI pretreated vs. naive.
  1891. p2 <- df.cond %>% filter(!is.na(cond.3) & !is.na(lcross.cat.biosample)) %>%
  1892. group_by(cond.3, lcross.cat.biosample) %>%
  1893. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  1894. ggplot(aes(x = cond.3, y = prop, fill = lcross.cat.biosample)) +
  1895. geom_bar(stat = "identity") +
  1896. geom_text(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1897. position = position_stack(vjust = 0.5)) +
  1898. scale_fill_discrete(name = "Spatial infiltration pattern",
  1899. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  1900. scale_x_discrete(name = "", labels = c("ici_naive" = "ICI naive", "ici_pretreated" = "ICI pretreated")) +
  1901. ylab("Proportion") + theme_minimal() + ggtitle("ICI pretreated vs. naive")
  1902. # Within responders, dichotomize by median OS/PFS and test association with the
  1903. # spatial category (Fisher's exact test).
  1904. clin <- metadata(sce)$clinical %>%
  1905. select(id, biosample_id, days_dos, days_dg, days_dos_pfs, status, status_pfs)
  1906. df.clin.cond <- df.cond %>% left_join(clin)
  1907. medianOS_cond.2 <- df.clin.cond %>% filter(cond.2 == "responder") %>% summarise(median(days_dos)) %>% pull()
  1908. medianPFS_cond.2 <- df.clin.cond %>% filter(cond.2 == "responder") %>% summarise(median(days_dos_pfs)) %>% pull()
  1909. df.clin.cond <- df.clin.cond %>% rowwise() %>%
  1910. mutate(cond.2.new_OS = case_when(cond.2 == "responder" & days_dos >= medianOS_cond.2 ~ "OS_high",
  1911. cond.2 == "responder" & days_dos < medianOS_cond.2 ~ "OS_low"),
  1912. cond.2.new_PFS = case_when(cond.2 == "responder" & days_dos_pfs >= medianPFS_cond.2 ~ "PFS_high",
  1913. cond.2 == "responder" & days_dos_pfs < medianPFS_cond.2 ~ "PFS_low"))
  1914. df.clin.cond %>% filter(cond.2 == "responder") %>% filter(!is.na(lcross.cat.biosample)) %>%
  1915. select(cond.2.new_OS, lcross.cat.biosample) %>% ungroup() %>%
  1916. summarise(pvalue = fisher.test(cond.2.new_OS, lcross.cat.biosample)$p.value)
  1917. df.clin.cond %>% filter(cond.2 == "responder") %>% filter(!is.na(lcross.cat.biosample)) %>%
  1918. select(cond.2.new_PFS, lcross.cat.biosample) %>% ungroup() %>%
  1919. summarise(pvalue = fisher.test(cond.2.new_PFS, lcross.cat.biosample)$p.value)
  1920. # Group sizes.
  1921. df.clin.cond %>% filter(cond.2 == "responder") %>% filter(!is.na(lcross.cat.biosample)) %>%
  1922. group_by(cond.2.new_OS) %>% summarise(count = n())
  1923. df.clin.cond %>% filter(cond.2 == "responder") %>% filter(!is.na(lcross.cat.biosample)) %>%
  1924. group_by(cond.2.new_PFS) %>% summarise(count = n())
  1925. # Stacked composition of spatial categories by survival cutoff within responders.
  1926. p3 <- df.clin.cond %>% filter(cond.2 == "responder") %>% filter(!is.na(lcross.cat.biosample)) %>%
  1927. pivot_longer(cols = c("cond.2.new_OS", "cond.2.new_PFS"),
  1928. names_to = "cond", names_prefix = "cond.2.new_", values_to = "surv") %>%
  1929. rowwise() %>% mutate(surv = str_split(surv, "_")[[1]][2]) %>%
  1930. group_by(cond.2, cond, surv, lcross.cat.biosample) %>%
  1931. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  1932. ggplot(aes(x = surv, y = count, fill = lcross.cat.biosample)) +
  1933. geom_bar(stat = "identity") +
  1934. geom_text(aes(label = scales::percent(prop, accuracy = 1, suffix = "%")),
  1935. position = position_stack(vjust = 0.5)) +
  1936. facet_wrap(~ cond) +
  1937. scale_fill_discrete(name = "Spatial infiltration pattern",
  1938. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  1939. scale_x_discrete(name = "Survival cutoff") +
  1940. ylab("Proportion") + theme_minimal() + ggtitle("ICI responder")
  1941. (p1 | plot_spacer()) / (p2 | plot_spacer()) / p3 +
  1942. plot_layout(guides = "collect") + plot_annotation(tag_levels = "A")
  1943. ggsave("figures/supplfig_spatialAssignement2.pdf", dpi = 300, width = 8, height = 10)
  1944. ```
  1945. ### Example images
  1946. ```{r}
  1947. df <- df.cd8tcells
  1948. cluster.lvl <- "celltype_tcells"
  1949. # Identify samples per spatial category (dispersed/localized/cold) using the
  1950. # mean integral (dispersed = high, localized = low) or density (cold).
  1951. biosampleids.dispersed <- df.samples.mean %>% filter(lcross.cat.biosample == "high") %>%
  1952. slice_max(order_by = mean.integral, n = 100) %>% pull(biosample_id)
  1953. biosampleids.localized <- df.samples.mean %>% filter(lcross.cat.biosample == "low") %>%
  1954. slice_min(order_by = mean.integral, n = 100) %>% pull(biosample_id)
  1955. biosampleids.cold <- df.samples.mean %>% filter(lcross.cat.biosample == "cold") %>%
  1956. slice_min(order_by = mean.CD8Tc_density, n = 100) %>% pull(biosample_id)
  1957. # Within those samples, select representative images (above-median target density
  1958. # for dispersed/localized; below-median for cold), ranked by integral/density.
  1959. imgids.dispersed <- df %>% filter(type == type.select) %>% left_join(sample.ids) %>%
  1960. filter(biosample_id %in% biosampleids.dispersed) %>%
  1961. filter(!!rlang::sym(paste0(celltypeA, "_density")) > median.density.target) %>%
  1962. slice_max(order_by = integral, n = 20) %>%
  1963. slice_max(order_by = !!rlang::sym(paste0(celltypeA, "_density")), n = 16) %>% pull(image_id)
  1964. imgids.localized <- df %>% filter(type == type.select) %>% left_join(sample.ids) %>%
  1965. filter(biosample_id %in% biosampleids.localized) %>%
  1966. filter(!!rlang::sym(paste0(celltypeA, "_density")) > median.density.target) %>%
  1967. slice_min(order_by = integral, n = 20) %>%
  1968. slice_max(order_by = !!rlang::sym(paste0(celltypeA, "_density")), n = 16) %>% pull(image_id)
  1969. imgids.cold <- df %>% filter(type == type.select) %>% left_join(sample.ids) %>%
  1970. filter(biosample_id %in% biosampleids.cold) %>%
  1971. filter(!!rlang::sym(paste0(celltypeA, "_density")) < median.density.target) %>%
  1972. slice_max(order_by = !!rlang::sym(paste0(celltypeA, "_density")), n = 16) %>% pull(image_id)
  1973. col_vec["CD8Tc/DPTc"] <- "red"
  1974. col_vec["Tumor"] <- "blue"
  1975. # Spatial example plots for each pattern.
  1976. (p.disp.tcells <- plotSpatial(sce.sub[, sce.sub$image_id %in% imgids.dispersed],
  1977. "image_id", c("Pos_X", "Pos_Y"),
  1978. node_color_by = cluster.lvl, node_size_fix = 0.5, ncol = 4) +
  1979. scale_colour_manual(values = col_vec, name = "Celltypes") +
  1980. theme_void() +
  1981. theme(legend.position = "bottom",
  1982. strip.text.x = element_text(size = 4),
  1983. strip.background = element_blank(),
  1984. strip.text.x.top = element_blank()))
  1985. (p.loc.tcells <- plotSpatial(sce.sub[, sce.sub$image_id %in% imgids.localized],
  1986. "image_id", c("Pos_X", "Pos_Y"),
  1987. node_color_by = cluster.lvl, node_size_fix = 0.5, ncol = 4) +
  1988. scale_colour_manual(values = col_vec, name = "Celltypes") +
  1989. theme_void() +
  1990. theme(legend.position = "bottom",
  1991. strip.text.x = element_text(size = 4),
  1992. strip.background = element_blank(),
  1993. strip.text.x.top = element_blank()))
  1994. (p.cold.tcells <- plotSpatial(sce.sub[, sce.sub$image_id %in% imgids.cold],
  1995. "image_id", c("Pos_X", "Pos_Y"),
  1996. node_color_by = cluster.lvl, node_size_fix = 0.5, ncol = 4) +
  1997. scale_colour_manual(values = col_vec, name = "Celltypes") +
  1998. theme_void() +
  1999. theme(legend.position = "bottom",
  2000. strip.text.x = element_text(size = 4),
  2001. strip.background = element_blank(),
  2002. strip.text.x.top = element_blank()))
  2003. ```
  2004. #### Select survival plot
  2005. ```{r, fig.height= 4, fig.width = 5}
  2006. library(ggsurvfit)
  2007. # Combine sample-level spatial category with clinical/survival data.
  2008. df.all <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, .keep_all = T) %>%
  2009. select(id, biosample_id, contains("cond"))
  2010. clin <- metadata(sce)$clinical %>%
  2011. select(id, biosample_id, days_dos, days_dg, days_dos_pfs, status, status_pfs)
  2012. df.subset_all <- df.all %>% left_join(df.samples.mean) %>% left_join(clin)
  2013. # Overall survival by spatial infiltration pattern.
  2014. fit <- survfit2(Surv(days_dos / 365.25, status) ~ lcross.cat.biosample, data = df.subset_all)
  2015. p.os <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2016. coord_cartesian(xlim = c(0, 8)) +
  2017. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2018. scale_color_discrete(labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  2019. theme_minimal() + theme(text = element_text(size = 15)) +
  2020. xlab("Follow-up time, years") + ggtitle("Overall survival")
  2021. p.os <- ggsurvfit_build(p.os)
  2022. # Median survival per group.
  2023. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2024. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2025. print(median_table)
  2026. p.os
  2027. # Local progression-free survival by spatial infiltration pattern.
  2028. fit <- survfit2(Surv(days_dos_pfs / 365.25, status_pfs) ~ lcross.cat.biosample, data = df.subset_all)
  2029. p.pfs <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2030. coord_cartesian(xlim = c(0, 8)) +
  2031. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2032. scale_color_discrete(labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  2033. theme_minimal() + theme(text = element_text(size = 15)) +
  2034. xlab("Follow-up time, years") + ggtitle("Local progression-free survival")
  2035. p.pfs <- ggsurvfit_build(p.pfs)
  2036. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2037. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2038. print(median_table)
  2039. p.pfs
  2040. ```
  2041. ### Plot cell abundances for Lcross high/low
  2042. ```{r, fig.width = 12, fig.height=10}
  2043. coldata <- colData(sce) %>% as_tibble()
  2044. cluster.lvl <- "cl_all_spec"
  2045. # Within the target T-cell population, compute per-sample subset proportions for
  2046. # dispersed ("high") vs. localized ("low") samples (cold excluded).
  2047. df <- coldata %>% left_join(df.subset_all %>% select(biosample_id, lcross.cat.biosample)) %>%
  2048. filter(biosample_id %in% df.subset_all$biosample_id[!is.na(df.subset_all$lcross.cat.biosample)] &
  2049. celltype_tcells == celltypeA) %>%
  2050. filter(lcross.cat.biosample != "cold") %>%
  2051. group_by(biosample_id, lcross.cat.biosample, cl_all_spec) %>%
  2052. summarise(count = n()) %>% mutate(pct = prop.table(count))
  2053. # Wilcoxon test per cell type (FDR-adjusted), with formatted p-value labels.
  2054. stat.test <- df %>%
  2055. group_by(across(cluster.lvl)) %>%
  2056. wilcox_test(as.formula(paste("pct", "~", "lcross.cat.biosample"))) %>%
  2057. adjust_pvalue(p.col = "p", method = "fdr", output.col = "p.adj") %>%
  2058. add_significance(p.col = "p.adj",
  2059. cutpoints = c(0, 1e-04, 0.001, 0.01, 0.1, 1),
  2060. output.col = "p.adj.signif") %>%
  2061. add_xy_position(scales = "free", x = "lcross.cat.biosample") %>%
  2062. mutate(x.position = 1.5) %>%
  2063. rowwise() %>%
  2064. mutate(p.adj.format = p_format(p.adj, add.p = T, leading.zero = F, space = F),
  2065. p.format = p_format(p, add.p = T, leading.zero = F, space = F),
  2066. p.adj.format = str_replace(p.adj.format, "p=", "p.adj="))
  2067. df <- df %>% left_join(stat.test) %>%
  2068. rowwise() %>%
  2069. mutate(signif = if_else(p.adj.signif != "ns" & p < 0.05, "sign", "ns"),
  2070. p.adj.signif.plot = if_else(signif == "ns", "ns", p.adj.signif))
  2071. (p.wilcox.lcrossprop <- ggplot(df, aes(x = lcross.cat.biosample, y = pct, fill = cl_all_spec)) +
  2072. geom_boxplot(aes(alpha = signif), outlier.shape = NA) +
  2073. geom_quasirandom(alpha = 0.35, dodge.width = 0.75) +
  2074. scale_alpha_manual(values = c(1, 1), guide = "none") +
  2075. geom_text(aes(label = p.adj.signif.plot, x = x.position, y = y.position, vjust = 0.8), size = 3) +
  2076. facet_wrap(as.formula(paste("~", cluster.lvl, "+ p.format + p.adj.format")), scales = "free") +
  2077. scale_fill_manual(name = "Celltypes", values = col_ann_hm) +
  2078. ylab("Proportions") + xlab("") +
  2079. scale_x_discrete(guide = guide_axis(n.dodge = 2),
  2080. labels = c("high" = "dispersed", "low" = "localized")) +
  2081. theme(text = element_text(size = 15)))
  2082. ```
  2083. ## Export single panels
  2084. ```{r, fig.width=5, fig.height=5}
  2085. # Figure 3, panel row 1: Cox proportions + example spatial patterns.
  2086. (p.prop.os | p.disp.tcells + ggtitle("Dispersed CD8/DP T cells") & theme(legend.position = "none") |
  2087. p.loc.tcells + ggtitle("Localized CD8/DP T cells") |
  2088. p.cold.tcells + ggtitle("Cold CD8/DP T cells") & theme(legend.position = "none")) +
  2089. plot_layout(widths = c(0.5, 1, 1, 1)) +
  2090. plot_annotation(tag_levels = list(c("A", "", "", "B", "C", "D")))
  2091. ggsave("figures/fig3_1.pdf", dpi = 200, width = 18, height = 6)
  2092. # Survival panels.
  2093. p.os + plot_annotation(tag_levels = list(c("D")))
  2094. ggsave("figures/fig3_2_os.pdf", dpi = 300, width = 5, height = 4)
  2095. p.pfs + plot_annotation(tag_levels = list(c("E")))
  2096. ggsave("figures/fig3_2_pfs.pdf", dpi = 300, width = 5, height = 4)
  2097. p.wilcox.lcrossprop + theme(legend.position = "none") + plot_annotation(tag_levels = list(c("F")))
  2098. ggsave("figures/fig3_2_wilcox.pdf", dpi = 300, width = 5, height = 5)
  2099. # Figure 3, panel row 3: cellular-neighbourhood boxplot + magnified spatial
  2100. # examples. NOTE: this depends on `output.plot` and the `p.min.*` / `p.max.*`
  2101. # panels created later in the CN ggmagnify section, so run that section first.
  2102. (output.plot | ((p.min.1 | p.min.2) / (p.max.1 | p.max.2) +
  2103. plot_layout(guides = "collect") &
  2104. theme(legend.position = "bottom", plot.margin = margin(15, 18, 0, 0, "mm")))) +
  2105. plot_layout(widths = c(2, 1))
  2106. ggsave("figures/fig3_3.eps", dpi = 200, width = 18, height = 7)
  2107. p.magnify <- (p.min.1 + theme(legend.position = "none", plot.margin = margin(13, 16, 0, 0, "mm")) |
  2108. p.min.2 + theme(legend.position = "none", plot.margin = margin(0, 12, 0, 0, "mm"))) /
  2109. (p.max.1 + theme(legend.position = "none", plot.margin = margin(0, 15, 0, 0, "mm")) |
  2110. p.max.2 + theme(legend.position = "none", plot.margin = margin(0, 0, 0, 0, "mm")))
  2111. p.magnify
  2112. ggsave("figures/fig3_3_magnify.pdf", dpi = 600, width = 5, height = 5)
  2113. ```
  2114. # Cellular neighbourhood (CN) analysis
  2115. ## 1. Aggregate neighbors
  2116. ```{r}
  2117. # Aggregate each cell's neighbourhood into cell-type composition vectors
  2118. # (counts of cl_all_spec among neighbours defined by the "neighborhood" graph).
  2119. sce <- aggregateNeighbors(sce, colPairName = "neighborhood",
  2120. aggregate_by = "metadata", count_by = "cl_all_spec",
  2121. name = "aggregatedNeighbors_cl_all_spec")
  2122. ```
  2123. ## 2. Optimal cluster number
  2124. ```{r}
  2125. # Estimate a reasonable number of neighbourhood clusters (k) on a 1% subsample,
  2126. # using the elbow and silhouette criteria.
  2127. cn <- as.data.frame(sce$aggregatedNeighbors_cl_all_spec)
  2128. cn_subset <- cn[sample(nrow(cn), 0.01 * nrow(cn)), ]
  2129. set.seed(12345)
  2130. library(factoextra)
  2131. # Elbow method (within-cluster sum of squares).
  2132. fviz_nbclust(cn_subset, kmeans, k.max = 25, method = "wss") +
  2133. labs(subtitle = "cl_all_spec - Elbow method")
  2134. # Silhouette method.
  2135. fviz_nbclust(cn_subset, kmeans, k.max = 25, method = "silhouette") +
  2136. labs(subtitle = "cl_all_spec - Silhouette method")
  2137. ```
  2138. ## 3. Define SCE subset
  2139. Exclude unassigned cells for CN detection/analysis.
  2140. ```{r}
  2141. sce.subset <- sce
  2142. sce.subset <- sce.subset[, sce.subset$cl_all_spec != "unassigned"]
  2143. ```
  2144. ## 4. CN plotting
  2145. ### 4.1. Define k and subset
  2146. ```{r}
  2147. k <- 7
  2148. cond.group <- "lcross.cat.biosample"
  2149. CN <- "aggregatedNeighbors_cl_all_spec"
  2150. celltypeCluster <- "cl_all_spec"
  2151. # Biological interpretation of each CN cluster.
  2152. cn.labels <- c("cn_1" = "Perivascular",
  2153. "cn_2" = "Tumor core",
  2154. "cn_3" = "Tumor/CD8Tc\nborder",
  2155. "cn_4" = "Myeloid enriched",
  2156. "cn_5" = "CD8Tc core",
  2157. "cn_6" = "B cell, Neutrophil,\nMf enriched",
  2158. "cn_7" = "CD4Tc/Treg,\nPlasma cell enriched")
  2159. # Re-create the subset (exclude unassigned) and run k-means on neighbourhood
  2160. # composition vectors.
  2161. sce.subset <- sce[, sce$cl_all_spec != "unassigned"]
  2162. set.seed(1234)
  2163. cn_1 <- kmeans(sce.subset[[CN]], centers = k)
  2164. sce.subset$cn_celltypes <- as.factor(paste0("cn_", cn_1$cluster))
  2165. ```
  2166. ### 4.2. Heatmap + boxplot
  2167. ```{r, fig.width=12}
  2168. color.palette <- colorRampPalette(rev(brewer.pal(n = 7, name = "RdYlBu")))(100)
  2169. # Cell-type composition per CN (row-normalized), as a clustered heatmap.
  2170. for_plot <- prop.table(table(sce.subset$cn_celltypes, sce.subset[[celltypeCluster]]), margin = 1)
  2171. hm.matrix <- scale(for_plot, center = T, scale = T)
  2172. row.names(hm.matrix) <- as.vector(cn.labels)
  2173. hm <- pheatmap(hm.matrix, cluster_rows = T, color = color.palette,
  2174. legend = T, annotation_legend = F)
  2175. row.order <- row_order(draw(hm))
  2176. # CN frequency per sample, with the per-CN cohort median.
  2177. df.cn.sum <- colData(sce.subset) %>% as_tibble() %>%
  2178. group_by(biosample_id, cn_celltypes, .drop = FALSE) %>%
  2179. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  2180. group_by(cn_celltypes) %>% mutate(median = median(prop))
  2181. # Group samples high/low per CN relative to the median.
  2182. df.cn.group <- df.cn.sum %>% distinct(biosample_id, cn_celltypes, .keep_all = T) %>%
  2183. rowwise() %>% mutate(cn_group = if_else(prop < median, "low", "high")) %>%
  2184. left_join(clin)
  2185. # Combine CN frequencies with the spatial infiltration category.
  2186. df.comb <- df.cn.sum %>%
  2187. left_join(df.samples.mean %>% select(biosample_id, all_of(cond.group))) %>%
  2188. filter(!is.na(!!rlang::sym(cond.group)))
  2189. df.comb$cn_celltypes <- factor(df.comb$cn_celltypes, levels = rev(rownames(for_plot)[row.order]))
  2190. # Boxplot of CN proportions across infiltration categories.
  2191. boxplot <- ggboxplot(df.comb, x = "cn_celltypes", y = "prop",
  2192. fill = "lcross.cat.biosample", outlier.shape = NA)
  2193. stat.test <- df.comb %>% group_by(cn_celltypes) %>%
  2194. wilcox_test(as.formula(paste("prop", "~", cond.group))) %>%
  2195. adjust_pvalue(p.col = "p", method = "fdr", output.col = "p.adj") %>%
  2196. add_significance(p.col = "p.adj", cutpoints = c(0, 1e-04, 0.001, 0.01, 0.1, 1),
  2197. output.col = "p.adj.signif") %>%
  2198. add_xy_position(x = "cn_celltypes", dodge = 0.8)
  2199. p.boxplot <- boxplot +
  2200. stat_pvalue_manual(stat.test, label = "p.adj.signif", bracket.nudge.y = -0.1,
  2201. tip.length = 0.01, hide.ns = T, coord.flip = T, size = 8) +
  2202. coord_flip() +
  2203. scale_fill_discrete(labels = c(cold = "cold", high = "dispersed", low = "localized"), name = "") +
  2204. ylab("Proportion") + xlab("") +
  2205. theme(axis.text.y = element_blank())
  2206. (output.plot <- wrap_plots(grid.grabExpr(draw(hm)),
  2207. p.boxplot & theme(legend.position = "bottom"), widths = c(7, 2)))
  2208. ```
  2209. ### 4.3. Spatial plotting
  2210. ```{r}
  2211. # CN colour scheme.
  2212. col_ann <- setNames(dittoColors()[seq_along(unique(sce.subset$cn_celltypes))],
  2213. unique(sce.subset$cn_celltypes))
  2214. # Example CN spatial maps for dispersed (imgids.max) and localized (imgids.min) ROIs.
  2215. plotSpatial(sce.subset[, sce.subset$sample_id %in% imgids.max],
  2216. node_color_by = "cn_celltypes", img_id = "image_id", node_size_fix = 0.4) +
  2217. scale_color_manual(values = col_ann) +
  2218. theme(axis.text.x = element_blank(), axis.text.y = element_blank(),
  2219. aspect.ratio = 1, strip.text.x = element_blank())
  2220. plotSpatial(sce.subset[, sce.subset$sample_id %in% imgids.min],
  2221. node_color_by = "cn_celltypes", img_id = "image_id", node_size_fix = 0.4) +
  2222. scale_color_manual(values = col_ann) +
  2223. theme(axis.text.x = element_blank(), axis.text.y = element_blank(),
  2224. aspect.ratio = 1, strip.text.x = element_blank())
  2225. ```
  2226. ### 4.4. ggmagnify
  2227. ```{r}
  2228. # CN to highlight in the magnified examples.
  2229. cn.select <- c("cn_3")
  2230. df.coldata <- colData(sce.subset) %>% as_tibble()
  2231. # Overview of selected example images, highlighting the chosen CN.
  2232. image.select <- c("NB16-121_005", "NB18-818_007", "NB21-437_011", "NB21-42_010", "NB21-42_002")
  2233. ggplot(df.coldata %>% filter(image_id %in% image.select),
  2234. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2235. geom_point(aes(fill = cn_celltypes, colour = cn_celltypes), shape = 21, size = 1.) +
  2236. scale_colour_manual(values = col_ann) +
  2237. scale_fill_manual(values = col_ann) +
  2238. scale_alpha_discrete(range = c(0.25, 1), guide = "none") +
  2239. facet_wrap(~ image_id) +
  2240. theme_void() +
  2241. theme(axis.text.x = element_blank(), axis.text.y = element_blank(), aspect.ratio = 1) +
  2242. guides(color = guide_legend(override.aes = list(size = 4)))
  2243. library(ggmagnify)
  2244. # Two "dispersed" examples with a magnified inset over the highlighted CN.
  2245. p.max <- ggplot(df.coldata %>% filter(image_id %in% c("NB14-599_004", "NB16-121_011")),
  2246. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2247. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0) +
  2248. scale_color_manual(values = col_ann) +
  2249. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2250. facet_wrap(~ image_id) +
  2251. theme_void() +
  2252. coord_cartesian(clip = "off") +
  2253. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2254. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2255. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2256. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2257. guides(color = guide_legend(override.aes = list(size = 4))) +
  2258. geom_magnify(aes(from = cn_celltypes %in% cn.select &
  2259. Pos_Y > 150 & Pos_Y < 400 & Pos_X > 150 & Pos_X < 350),
  2260. to = c(500, 800, 500, 800)) +
  2261. scale_alpha_discrete(range = c(0.2, 1), guide = "none")
  2262. # Same examples as individual panels (for flexible figure assembly).
  2263. (p.max.1 <- ggplot(df.coldata %>% filter(image_id %in% c("NB14-599_004")),
  2264. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2265. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0, show.legend = F) +
  2266. scale_color_manual(values = col_ann, labels = cn.labels, name = "CN") +
  2267. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2268. theme_void() + coord_cartesian(clip = "off") +
  2269. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2270. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2271. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2272. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2273. guides(color = guide_legend(override.aes = list(size = 4))) +
  2274. geom_magnify(from = c(250, 400, 100, 250), to = c(500, 800, 500, 800)) +
  2275. scale_alpha_discrete(range = c(0.2, 1), guide = "none") +
  2276. theme(legend.position = "none"))
  2277. (p.max.2 <- ggplot(df.coldata %>% filter(image_id %in% c("NB16-121_011")),
  2278. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2279. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0, show.legend = F) +
  2280. scale_color_manual(values = col_ann, labels = cn.labels, name = "CN") +
  2281. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2282. theme_void() + coord_cartesian(clip = "off") +
  2283. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2284. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2285. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2286. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2287. guides(color = guide_legend(override.aes = list(size = 4))) +
  2288. geom_magnify(from = c(150, 300, 200, 350), to = c(500, 800, 500, 800)) +
  2289. scale_alpha_discrete(range = c(0.2, 1), guide = "none") +
  2290. theme(legend.position = "none"))
  2291. # Two "localized" examples with magnified insets.
  2292. p.min <- ggplot(df.coldata %>% filter(image_id %in% c("NB20-1792_006", "NB14-151_008")),
  2293. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2294. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0) +
  2295. scale_color_manual(values = col_ann) +
  2296. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2297. facet_wrap(~ image_id) +
  2298. theme_void() + coord_cartesian(clip = "off") +
  2299. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2300. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2301. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2302. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2303. guides(color = guide_legend(override.aes = list(size = 4))) +
  2304. geom_magnify(aes(from = cn_celltypes %in% cn.select &
  2305. Pos_Y > 150 & Pos_Y < 400 & Pos_X > 150 & Pos_X < 350),
  2306. to = c(500, 800, 500, 800)) +
  2307. scale_alpha_discrete(range = c(0.2, 1), guide = "none")
  2308. (p.min.1 <- ggplot(df.coldata %>% filter(image_id %in% c("NB20-1792_006")),
  2309. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2310. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0, show.legend = F) +
  2311. scale_color_manual(values = col_ann, labels = cn.labels, name = "CN") +
  2312. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2313. theme_void() + coord_cartesian(clip = "off") +
  2314. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2315. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2316. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2317. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2318. guides(color = guide_legend(override.aes = list(size = 4))) +
  2319. geom_magnify(from = c(250, 400, 250, 400), to = c(500, 800, 500, 800)) +
  2320. scale_alpha_discrete(range = c(0.2, 1), guide = "none") +
  2321. theme(legend.position = "none"))
  2322. (p.min.2 <- ggplot(df.coldata %>% filter(image_id %in% c("NB14-151_008")),
  2323. aes(x = Pos_X, y = Pos_Y, alpha = cn_celltypes %in% cn.select)) +
  2324. geom_point(aes(color = cn_celltypes), shape = 19, size = 1.8, stroke = 0) +
  2325. scale_color_manual(values = col_ann, labels = cn.labels, name = "CN") +
  2326. scale_alpha_discrete(range = c(1, 1), guide = "none") +
  2327. theme_void() + coord_cartesian(clip = "off") +
  2328. theme(panel.background = element_rect(fill = 'white', color = "transparent"),
  2329. panel.spacing.x = unit(5, "lines"), panel.spacing.y = unit(4, "lines"),
  2330. axis.text.x = element_blank(), axis.text.y = element_blank(),
  2331. aspect.ratio = 1, plot.margin = ggplot2::margin(100, 0, 0, 0)) +
  2332. guides(color = guide_legend(override.aes = list(size = 4))) +
  2333. geom_magnify(from = c(150, 300, 200, 350), to = c(500, 800, 500, 800)) +
  2334. scale_alpha_discrete(range = c(0.2, 1), guide = "none"))
  2335. # Combined dispersed/localized magnified examples.
  2336. p.max / p.min + plot_layout(guides = "collect") & theme(legend.position = "bottom")
  2337. ```
  2338. # --------------------------------
  2339. # FIGURE 4
  2340. Clinical groups, e.g. ICI responder vs. non-responder.
  2341. ## Survival for clinical groups
  2342. ```{r}
  2343. library(ggsurvfit)
  2344. df.all <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, .keep_all = T) %>%
  2345. select(id, biosample_id, contains("cond"))
  2346. clin <- metadata(sce)$clinical %>%
  2347. select(id, biosample_id, days_dos, days_dg, days_dos_pfs, status, status_pfs)
  2348. df.subset_all <- df.all %>% left_join(clin)
  2349. # Overall survival by ICI response (cond.2).
  2350. fit <- survfit2(Surv(days_dos / 365.25, status) ~ cond.2, data = df.subset_all)
  2351. p.os <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2352. coord_cartesian(xlim = c(0, 8)) +
  2353. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2354. theme_minimal() + xlab("Follow-up time, years") +
  2355. ggtitle("ICI: Overall survival") + theme(text = element_text(size = 15))
  2356. p.os <- ggsurvfit_build(p.os)
  2357. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2358. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2359. print(median_table)
  2360. p.os
  2361. # Progression-free survival by ICI response (cond.2).
  2362. fit <- survfit2(Surv(days_dos_pfs / 365.25, status_pfs) ~ cond.2, data = df.subset_all)
  2363. p.pfs <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2364. coord_cartesian(xlim = c(0, 8)) +
  2365. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2366. theme_minimal() + xlab("Follow-up time, years") +
  2367. ggtitle("ICI: Progression-free survival") + theme(text = element_text(size = 15))
  2368. p.pfs <- ggsurvfit_build(p.pfs)
  2369. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2370. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2371. print(median_table)
  2372. p.pfs
  2373. p.os + p.pfs + plot_layout(ncol = 1, nrow = 4)
  2374. ```
  2375. ## Differential abundance
  2376. ### Wilcoxon
  2377. #### cond.2 responder vs. non-responder
  2378. ```{r, fig.width= 11, fig.height=9}
  2379. conditions <- c("cond.2", "cond.3")
  2380. cluster.lvl <- "cl_all_spec"
  2381. coldata <- colData(sce) %>% as_tibble()
  2382. cond <- "cond.2"
  2383. # Per-sample cell-type proportions with the ICI-response condition attached.
  2384. df <- coldata %>%
  2385. filter(!is.na(cond.2)) %>%
  2386. group_by(id, across(cluster.lvl)) %>%
  2387. dplyr::summarise(count = n()) %>% mutate(pct = prop.table(count)) %>%
  2388. filter(!!rlang::sym(cluster.lvl) != 0 & !is.na(!!rlang::sym(cluster.lvl))) %>%
  2389. ungroup() %>%
  2390. left_join(coldata %>% distinct(id, .keep_all = T) %>% select(id, all_of(cond)), by = "id")
  2391. # Wilcoxon test per cell type (FDR-adjusted), with formatted labels.
  2392. stat.test <- df %>%
  2393. group_by(across(cluster.lvl)) %>%
  2394. wilcox_test(as.formula(paste("pct", "~", cond))) %>%
  2395. adjust_pvalue(p.col = "p", method = "fdr", output.col = "p.adj") %>%
  2396. add_significance(p.col = "p.adj", cutpoints = c(0, 1e-04, 0.001, 0.01, 0.1, 1),
  2397. output.col = "p.adj.signif") %>%
  2398. add_xy_position(scales = "free", x = cond) %>%
  2399. mutate(x.position = 1.5) %>%
  2400. rowwise() %>%
  2401. mutate(p.adj.format = p_format(p.adj, add.p = T, leading.zero = F, space = F),
  2402. p.format = p_format(p, add.p = T, leading.zero = F, space = F),
  2403. p.adj.format = str_replace(p.adj.format, "p=", "p.adj="))
  2404. df <- df %>% left_join(stat.test) %>%
  2405. rowwise() %>%
  2406. mutate(signif = if_else(p.adj.signif != "ns" & p < 0.05, "sign", "ns"),
  2407. p.adj.signif.plot = if_else(signif == "ns", "", p.adj.signif))
  2408. (p.wilcox <- ggplot(df, aes(x = !!rlang::sym(cond), y = pct)) +
  2409. geom_boxplot(aes(alpha = signif), outlier.shape = NA) +
  2410. geom_quasirandom(aes(alpha = signif), varwidth = T) +
  2411. scale_alpha_manual(values = c(0.1, 1)) +
  2412. geom_text(aes(label = p.adj.signif.plot, x = x.position, y = y.position, vjust = 0.8), size = 4) +
  2413. facet_wrap(as.formula(paste("~", cluster.lvl, "+ p.format + p.adj.format")), scales = "free") +
  2414. ylab("Proportions") + xlab("") +
  2415. scale_x_discrete(guide = guide_axis(n.dodge = 2)) +
  2416. ggtitle(paste(cluster.lvl, ": ", unique(df[cond][!is.na(df[cond])])[1], " vs. ",
  2417. unique(df[cond][!is.na(df[cond])])[2])))
  2418. ```
  2419. #### cond.3 ICI naive vs. pretreated
  2420. ```{r, fig.width= 11, fig.height=9}
  2421. cluster.lvl <- "cl_all_spec"
  2422. coldata <- colData(sce) %>% as_tibble()
  2423. cond <- "cond.3"
  2424. # Per-sample cell-type proportions with the ICI-pretreatment condition attached.
  2425. df <- coldata %>%
  2426. filter(!is.na(cond.3)) %>%
  2427. group_by(id, across(cluster.lvl)) %>%
  2428. dplyr::summarise(count = n()) %>% mutate(pct = prop.table(count)) %>%
  2429. filter(!!rlang::sym(cluster.lvl) != 0 & !is.na(!!rlang::sym(cluster.lvl))) %>%
  2430. ungroup() %>%
  2431. left_join(coldata %>% distinct(id, .keep_all = T) %>% select(id, all_of(cond)), by = "id")
  2432. stat.test <- df %>%
  2433. group_by(across(cluster.lvl)) %>%
  2434. wilcox_test(as.formula(paste("pct", "~", cond))) %>%
  2435. adjust_pvalue(p.col = "p", method = "fdr", output.col = "p.adj") %>%
  2436. add_significance(p.col = "p.adj", cutpoints = c(0, 1e-04, 0.001, 0.01, 0.1, 1),
  2437. output.col = "p.adj.signif") %>%
  2438. add_xy_position(scales = "free", x = cond) %>%
  2439. mutate(x.position = 1.5) %>%
  2440. rowwise() %>%
  2441. mutate(p.adj.format = p_format(p.adj, add.p = T, leading.zero = F, space = F),
  2442. p.format = p_format(p, add.p = T, leading.zero = F, space = F),
  2443. p.adj.format = str_replace(p.adj.format, "p=", "p.adj="))
  2444. df <- df %>% left_join(stat.test) %>%
  2445. rowwise() %>%
  2446. mutate(signif = if_else(p.adj.signif != "ns" & p < 0.05, "sign", "ns"),
  2447. p.adj.signif.plot = if_else(signif == "ns", "", p.adj.signif))
  2448. (p.wilcox <- ggplot(df, aes(x = !!rlang::sym(cond), y = pct)) +
  2449. geom_boxplot(aes(alpha = signif), outlier.shape = NA) +
  2450. geom_quasirandom(aes(alpha = signif), varwidth = T) +
  2451. scale_alpha_manual(values = c(0.1, 1)) +
  2452. geom_text(aes(label = p.adj.signif.plot, x = x.position, y = y.position, vjust = 0.8), size = 4) +
  2453. facet_wrap(as.formula(paste("~", cluster.lvl, "+ p.format + p.adj.format")), scales = "free") +
  2454. ylab("Proportions") + xlab("") +
  2455. scale_x_discrete(guide = guide_axis(n.dodge = 2)) +
  2456. ggtitle(paste(cluster.lvl, ": ", unique(df[cond][!is.na(df[cond])])[1], " vs. ",
  2457. unique(df[cond][!is.na(df[cond])])[2])))
  2458. ```
  2459. ### edgeR (differential abundance)
  2460. Differential-abundance testing of cell-type proportions between clinical groups,
  2461. following the OSCA workflow
  2462. (https://bioconductor.org/books/3.18/OSCA.multisample/differential-abundance.html).
  2463. ```{r}
  2464. dat <- coldata
  2465. dat$id <- as.numeric(dat$id)
  2466. # New condition cond.5: ICI non-responder vs. ICI pretreated.
  2467. dat$cond.5[dat$cond.2 == "non-responder"] <- "non-responder"
  2468. dat$cond.5[dat$cond.3 == "ici_pretreated" & is.na(dat$cond.5)] <- "ici_pretreated"
  2469. # Set factor reference levels (first level = reference).
  2470. dat$cond.2 <- factor(dat$cond.2, levels = c("non-responder", "responder"))
  2471. dat$cond.3 <- factor(dat$cond.3, levels = c("ici_naive", "ici_pretreated"))
  2472. dat$cond.4 <- factor(dat$cond.4, levels = c("rt_naive", "rt_bm_preop"))
  2473. dat$cond.5 <- factor(dat$cond.5, levels = c("non-responder", "ici_pretreated"))
  2474. conditions <- c("cond.2", "cond.3", "cond.4", "cond.5")
  2475. # Drop cells with missing cluster labels.
  2476. dat <- dat %>% filter(!is.na(!!rlang::sym(cluster.lvl)))
  2477. plot.list <- list()
  2478. for (cond in conditions) {
  2479. dat.l <- dat # fresh copy per iteration
  2480. dat.l$group_id <- dat.l[[cond]]
  2481. dat.l <- dat.l %>% drop_na(paste(cond))
  2482. dat.l$cluster.lvl <- dat.l[[cluster.lvl]]
  2483. dat.l$cond <- dat.l[[cond]]
  2484. # Cell-type x sample abundance table.
  2485. abundances <- table(dat.l$cluster.lvl, dat.l$id)
  2486. abundances <- unclass(abundances)
  2487. # Attach sample metadata and build the DGEList.
  2488. extra.info <- dat.l[match(colnames(abundances), dat.l$id), ]
  2489. y.ab <- DGEList(abundances, samples = extra.info)
  2490. # Filter low-abundance cell types and fit a quasi-likelihood model.
  2491. keep <- filterByExpr(y.ab, group = y.ab$samples$cond)
  2492. y.ab <- y.ab[keep, ]
  2493. design <- model.matrix(~factor(cond), y.ab$samples)
  2494. y.ab <- estimateDisp(y.ab, design, trend = "none")
  2495. fit.ab <- glmQLFit(y.ab, design, robust = TRUE, abundance.trend = FALSE)
  2496. res <- glmQLFTest(fit.ab, coef = ncol(design))
  2497. top <- topTags(res, adjust.method = "BH", n = Inf)
  2498. ref.group <- str_split(top$comparison, "1*cond\\)")[[1]][2] # reference group
  2499. t.op <- as.data.frame(top)
  2500. t.op <- tibble::rownames_to_column(t.op, "Cluster")
  2501. # logFC bar plot.
  2502. plot.list[["bar"]][[cond]] <-
  2503. ggplot(t.op, aes(reorder(Cluster, logFC), logFC)) +
  2504. geom_col(aes(fill = FDR < 0.1)) +
  2505. coord_flip() +
  2506. labs(x = "Cell type", y = "logFC",
  2507. title = paste(cluster.lvl, ": ", unique(dat[cond][!is.na(dat[cond])])[1], " vs. ",
  2508. unique(dat[cond][!is.na(dat[cond])])[2], " / ref.group: ", ref.group)) +
  2509. theme_bw() +
  2510. theme(strip.background = element_blank(),
  2511. panel.background = element_rect(fill = 'white', colour = 'black'),
  2512. panel.grid.major = element_blank(),
  2513. panel.grid.minor = element_blank(),
  2514. axis.text.y = element_text(size = 10))
  2515. # Volcano plot.
  2516. plot.list[["scatter"]][[cond]] <-
  2517. plot.volcano(t.op, col_ann = NULL) +
  2518. labs(title = paste(cluster.lvl, ": ", unique(dat[cond][!is.na(dat[cond])])[1], " vs. ",
  2519. unique(dat[cond][!is.na(dat[cond])])[2], " / ref.group: ", ref.group))
  2520. }
  2521. # Collect results for the "pretreated" comparisons (cond.3, cond.4, cond.5).
  2522. data.pretreated <- plot.list$bar$cond.3$data %>%
  2523. add_column(condition = "cond.3") %>%
  2524. add_column(ref.group = str_split(plot.list$bar$cond.3$labels$title, "ref.group: ")[[1]][2])
  2525. data.pretreated <- rbind(data.pretreated,
  2526. plot.list$bar$cond.4$data %>% add_column(condition = "cond.4") %>%
  2527. add_column(ref.group = str_split(plot.list$bar$cond.4$labels$title, "ref.group: ")[[1]][2]))
  2528. data.pretreated <- rbind(data.pretreated,
  2529. plot.list$bar$cond.5$data %>% add_column(condition = "cond.5") %>%
  2530. add_column(ref.group = str_split(plot.list$bar$cond.5$labels$title, "ref.group: ")[[1]][2]))
  2531. # Collect results for the ICI-naive responder comparison (cond.2).
  2532. data.naive <- plot.list$bar$cond.2$data %>%
  2533. add_column(condition = "cond.2") %>%
  2534. add_column(ref.group = str_split(plot.list$bar$cond.2$labels$title, "ref.group: ")[[1]][2])
  2535. ```
  2536. #### Plot
  2537. ```{r, fig.width=12, fig.height = 3.5}
  2538. data.full <- rbind(data.pretreated, data.naive)
  2539. # Define the contrasting group label for each comparison.
  2540. data.full <- data.full %>%
  2541. mutate(alt.group = case_when(
  2542. ref.group == "ici_pretreated" & condition == "cond.3" ~ "ici_naive",
  2543. ref.group == "ici_pretreated" & condition == "cond.5" ~ "non-responder",
  2544. ref.group == "rt_bm_preop" ~ "rt_naive",
  2545. ref.group == "responder" ~ "non-responder"))
  2546. # Plot only the ICI-naive responder comparison in this figure.
  2547. data.subset <- data.full %>% filter(condition %in% c("cond.2"))
  2548. labels.conditions <- c("cond.3" = "naive vs. PD-while/-after ICI",
  2549. "cond.2" = "ICI-naive: responder vs. non-responder",
  2550. "cond.4" = "RT-naive vs. RT-preop",
  2551. "cond.5" = "ICI non-responder vs. ICI pretreated")
  2552. # Dot plot: point size = p-value, fill = logFC, asterisk = FDR < 0.1.
  2553. (p.da.1 <- ggplot(data = data.subset) +
  2554. geom_point(data = data.subset %>% filter(PValue >= 0.05),
  2555. aes(x = Cluster, y = condition, size = PValue, fill = logFC),
  2556. alpha = 0.75, shape = 21, stroke = 2) +
  2557. geom_point(data = data.subset %>% filter(PValue < 0.05),
  2558. aes(x = Cluster, y = condition, size = PValue, fill = logFC),
  2559. alpha = 1, shape = 21, stroke = 2) +
  2560. geom_point(data = data.subset %>% filter(FDR < 0.1),
  2561. aes(x = Cluster, y = condition), shape = 8) +
  2562. scale_size(range = c(10, 1), breaks = c(0.01, 0.05, 0.1)) +
  2563. scale_colour_manual(values = c("transparent", "black")) +
  2564. scale_fill_gradient2(low = muted("blue"), mid = "white", high = muted("red"),
  2565. breaks = c(-3, -2, 0, 2, 4), limits = c(-3, 4),
  2566. labels = c("Group 1", 2, "0", 2, "Group 2")) +
  2567. scale_y_discrete(labels = labels.conditions) +
  2568. geom_point(aes(x = Cluster, y = condition, shape = " "), alpha = 0) + # dummy for legend
  2569. scale_shape_manual(name = "* FDR < 0.1", values = 16,
  2570. guide = guide_legend(override.aes = list(alpha = 0))) +
  2571. xlab("") + ylab("Clinical groups") +
  2572. theme_bw() +
  2573. theme(axis.text.x = element_blank(), axis.ticks.x = element_blank(),
  2574. axis.text.y = element_blank(), panel.border = element_blank(),
  2575. text = element_text(size = 15)))
  2576. # Cell-type colour key (x-axis tiles).
  2577. p.x.tiles <- ggplot(data = data.subset) +
  2578. geom_tile(aes(x = Cluster, y = 0, fill = Cluster)) +
  2579. scale_fill_manual(values = col_ann_hm, guide = "none") +
  2580. xlab("Cell types") +
  2581. theme_void() +
  2582. theme(axis.text.x = element_text(angle = 45, hjust = 1),
  2583. axis.text.y = element_blank(), axis.title.x = element_text(),
  2584. panel.border = element_blank(), text = element_text(size = 15))
  2585. # Group-label tiles (which clinical group is "Group 1" vs "Group 2").
  2586. p.da.2 <- data.subset %>% distinct(ref.group, alt.group, condition) %>%
  2587. pivot_longer(cols = c(ref.group, alt.group)) %>%
  2588. mutate(value = case_when(value == "ici_pretreated" ~ "ICI pretreated",
  2589. value == "ici_pretreated2" ~ "ICI pretreated",
  2590. value == "ici_naive" ~ "ICI naive",
  2591. value == "rt_bm_preop" ~ "RT preoperative",
  2592. value == "rt_naive" ~ "RT naive",
  2593. value == "responder" ~ "ICI responder",
  2594. value == "non-responder" ~ "ICI non-responder")) %>%
  2595. ggplot() +
  2596. geom_label(aes(x = name, y = condition, label = value, fill = name),
  2597. color = "white", hjust = "left") +
  2598. scale_fill_manual(values = c("ref.group" = muted("red"), "alt.group" = muted("blue"))) +
  2599. scale_x_discrete(name = "Clinical groups", labels = c("Group 1", "Group 2")) +
  2600. theme(axis.text.y = element_blank(), axis.ticks = element_blank(),
  2601. axis.title.y = element_blank(), panel.grid.major = element_blank(),
  2602. panel.grid.minor = element_blank(), panel.background = element_blank(),
  2603. legend.position = "none", axis.text.x = element_text(hjust = 0),
  2604. text = element_text(size = 15))
  2605. p.da.1 + p.da.2 + plot_layout(widths = c(3, 1))
  2606. p.da.1.comb <- p.da.1 / p.x.tiles + plot_layout(heights = c(5, .75), ncol = 1, nrow = 2)
  2607. p.da.1.comb
  2608. p.da.2.comb <- p.da.2 / plot_spacer() + plot_layout(heights = c(5, .1), ncol = 1, nrow = 2)
  2609. p.da.2.comb
  2610. wrap_plots(p.da.1.comb, p.da.2.comb) + plot_layout(widths = c(2, 1))
  2611. ```
  2612. ## SpicyR
  2613. ```{r}
  2614. library(spicyR)
  2615. # Clinical data; dichotomize tumor samples by median OS/PFS within each response
  2616. # group into "high" vs. "low".
  2617. df.clin <- metadata(sce)$clinical
  2618. median_os <- df.clin %>% filter(!is.na(cond.2)) %>% group_by(cond.2) %>% summarise(median = median(days_dos))
  2619. median_pfs <- df.clin %>% filter(!is.na(cond.2)) %>% group_by(cond.2) %>% summarise(median = median(days_dos_pfs))
  2620. survival.cond.2.dich <- df.clin %>% filter(!is.na(cond.2)) %>%
  2621. distinct(biosample_id, days_dos, days_dos_pfs, cond.2) %>% rowwise() %>%
  2622. mutate(os_dich = if_else(days_dos >= median_os$median[median_os$cond.2 == cond.2], "high", "low"),
  2623. pfs_dich = if_else(days_dos_pfs >= median_pfs$median[median_pfs$cond.2 == cond.2], "high", "low")) %>%
  2624. select(biosample_id, cond.2, os_dich, pfs_dich)
  2625. # Same dichotomization, but using medians computed only over non-cold samples.
  2626. median_os_responder_noncold <- df.clin %>% filter(!is.na(cond.2)) %>%
  2627. left_join(df.samples.mean %>% select(biosample_id, lcross.cat.biosample)) %>%
  2628. filter(lcross.cat.biosample != "cold") %>% group_by(cond.2) %>% summarise(median = median(days_dos))
  2629. median_pfs_responder_noncold <- df.clin %>% filter(!is.na(cond.2)) %>%
  2630. left_join(df.samples.mean %>% select(biosample_id, lcross.cat.biosample)) %>%
  2631. filter(lcross.cat.biosample != "cold") %>% group_by(cond.2) %>% summarise(median = median(days_dos_pfs))
  2632. survival.responder_noncold_dich <- df.clin %>% filter(!is.na(cond.2)) %>%
  2633. left_join(df.samples.mean %>% select(biosample_id, lcross.cat.biosample)) %>%
  2634. filter(lcross.cat.biosample != "cold") %>%
  2635. distinct(biosample_id, days_dos, days_dos_pfs, cond.2) %>% rowwise() %>%
  2636. mutate(os_dich = if_else(days_dos >= median_os_responder_noncold$median[median_os_responder_noncold$cond.2 == cond.2], "high", "low"),
  2637. pfs_dich = if_else(days_dos_pfs >= median_pfs_responder_noncold$median[median_pfs_responder_noncold$cond.2 == cond.2], "high", "low")) %>%
  2638. select(biosample_id, cond.2, os_dich, pfs_dich)
  2639. # Map the dichotomized survival categories back onto the SCE object.
  2640. sce$cond.2.dich.os <- survival.cond.2.dich$os_dich[match(sce$biosample_id, survival.cond.2.dich$biosample_id)]
  2641. sce$cond.2.dich.os <- factor(sce$cond.2.dich.os, levels = c("low", "high"))
  2642. sce$cond.2.dich.pfs <- survival.cond.2.dich$pfs_dich[match(sce$biosample_id, survival.cond.2.dich$biosample_id)]
  2643. sce$cond.2.dich.pfs <- factor(sce$cond.2.dich.pfs, levels = c("low", "high"))
  2644. sce$cond.2.dich.os_responder_noncold <- survival.responder_noncold_dich$os_dich[match(sce$biosample_id, survival.responder_noncold_dich$biosample_id)]
  2645. sce$cond.2.dich.os_responder_noncold <- factor(sce$cond.2.dich.os_responder_noncold, levels = c("low", "high"))
  2646. sce$cond.2.dich.pfs_responder_noncold <- survival.responder_noncold_dich$pfs_dich[match(sce$biosample_id, survival.responder_noncold_dich$biosample_id)]
  2647. sce$cond.2.dich.pfs_responder_noncold <- factor(sce$cond.2.dich.pfs_responder_noncold, levels = c("low", "high"))
  2648. # spicyR: pairwise spatial co-localization tests.
  2649. # cond.2 = ICI responder vs. non-responder.
  2650. spicyTest_cond.2 <- spicy(
  2651. sce[, (!is.na(sce$cond.2) & sce$cl_all_spec != "unassigned")],
  2652. condition = "cond.2",
  2653. subject = "biosample_id",
  2654. imageID = "image_id",
  2655. spatialCoords = c("Pos_X", "Pos_Y"),
  2656. cellType = "cl_all_spec",
  2657. BPPARAM = MulticoreParam(progressbar = T))
  2658. # cond.3 = ICI pretreated vs. naive.
  2659. spicyTest_cond.3 <- spicy(
  2660. sce[, (!is.na(sce$cond.3) & sce$cl_all_spec != "unassigned")],
  2661. condition = "cond.3",
  2662. subject = "biosample_id",
  2663. imageID = "image_id",
  2664. spatialCoords = c("Pos_X", "Pos_Y"),
  2665. cellType = "cl_all_spec",
  2666. BPPARAM = MulticoreParam(progressbar = T))
  2667. ```
  2668. ### For plot
  2669. ```{r}
  2670. library(ggplotify)
  2671. # Default spicyR significance/heatmap views.
  2672. signifPlot(spicyTest_cond.2, fdr = F, cutoff = 0.05, breaks = c(-2, 2, 1)) +
  2673. scale_shape_manual(labels = c("responder", "non-responder"),
  2674. values = c(GroupA = "\u25D6", GroupB = "\u25D7")) +
  2675. ggtitle("All patients: responder vs. non-responder")
  2676. p1 <- as.ggplot(signifPlot(spicyTest_cond.2, type = "heatmap")) +
  2677. ggtitle("ICI-naive: responders vs. non-responders")
  2678. as.ggplot(signifPlot(spicyTest_cond.2, type = "heatmap")) +
  2679. ggtitle("ICI-naive: responders vs. non-responders")
  2680. # Rebuild the spicyR heatmap in ggplot for full customization.
  2681. celltype_order <- c("Tumor", "DPTc", "CD4Tc", "CD8Tc", "exh.CD8Tc", "GZB+ act.CD8Tc",
  2682. "Treg", "NK cells", "BnT", "B cells", "Plasma", "Myeloid",
  2683. "CD38+ Mf", "IDO+ Mf", "Arginase+ Neu", "Neutrophils",
  2684. "Vascular", "Astrocytes")
  2685. # Signed -log10(p): positive = attraction, negative = avoidance (cond.2).
  2686. spicyObject <- spicyTest_cond.2
  2687. ref.condition <- "responder"
  2688. df <- data.frame(coef = spicyObject$coefficient[[paste0("condition", ref.condition)]],
  2689. pval = spicyObject$p.value[[paste0("condition", ref.condition)]],
  2690. FDR = p.adjust(spicyObject$p.value[[paste0("condition", ref.condition)]], method = "fdr"),
  2691. from = spicyObject$comparisons$from,
  2692. to = spicyObject$comparisons$to) %>%
  2693. as_tibble() %>% mutate(log10.pval = if_else(coef >= 0, -log10(pval), +log10(pval)))
  2694. p.spicy.1 <- df %>% ggplot(aes((from), (to))) +
  2695. geom_tile(aes(fill = log10.pval), color = "black", lwd = 0.25, linetype = 1) +
  2696. scale_fill_gradient2(low = "#4575B4", mid = "white", high = "#D73027",
  2697. breaks = c(min(df$log10.pval), -2, -1, 0, 1, 2, max(df$log10.pval)),
  2698. labels = c("Avoidance\nin responders", -2, -1, 0, 1, 2, "Attractance\nin responders")) +
  2699. scale_x_discrete(limits = celltype_order) +
  2700. scale_y_discrete(limits = rev(celltype_order)) +
  2701. theme_minimal() +
  2702. theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
  2703. ggtitle("ICI: Responder vs. non-responder")
  2704. p.spicy.1
  2705. # Same, for cond.3 (ICI pretreated vs. naive).
  2706. spicyObject <- spicyTest_cond.3
  2707. ref.condition <- "ici_pretreated"
  2708. df <- data.frame(coef = spicyObject$coefficient[[paste0("condition", ref.condition)]],
  2709. pval = spicyObject$p.value[[paste0("condition", ref.condition)]],
  2710. FDR = p.adjust(spicyObject$p.value[[paste0("condition", ref.condition)]], method = "fdr"),
  2711. from = spicyObject$comparisons$from,
  2712. to = spicyObject$comparisons$to) %>%
  2713. as_tibble() %>% mutate(log10.pval = if_else(coef >= 0, -log10(pval), +log10(pval)))
  2714. p.spicy.4 <- df %>% ggplot(aes((from), (to))) +
  2715. geom_tile(aes(fill = log10.pval), color = "black", lwd = 0.25, linetype = 1) +
  2716. scale_fill_gradient2(low = "#4575B4", mid = "white", high = "#D73027",
  2717. breaks = c(min(df$log10.pval), -2, -1, 0, 1, max(df$log10.pval)),
  2718. labels = c("Avoidance\nin ici_pretreated", -2, -1, 0, 1, "Attractance\nin ici_pretreated")) +
  2719. scale_x_discrete(limits = celltype_order) +
  2720. scale_y_discrete(limits = rev(celltype_order)) +
  2721. theme_minimal() +
  2722. theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
  2723. ggtitle("ICI pretreated vs. naive")
  2724. p.spicy.4
  2725. ```
  2726. ## Survival of ICI-naive stratified by spatial infiltration pattern
  2727. ```{r}
  2728. df.all <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, .keep_all = T) %>%
  2729. select(id, biosample_id, contains("cond"))
  2730. clin <- metadata(sce)$clinical %>%
  2731. select(id, biosample_id, days_dos, days_dg, days_dos_pfs, status, status_pfs)
  2732. # ICI-naive samples only.
  2733. df.subset_all <- df.all %>%
  2734. filter(!is.na(cond.2)) %>%
  2735. left_join(df.samples.mean) %>% left_join(clin)
  2736. # Overall survival by spatial infiltration pattern.
  2737. fit <- survfit2(Surv(days_dos / 365.25, status) ~ lcross.cat.biosample, data = df.subset_all)
  2738. p.spat.os <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2739. coord_cartesian(xlim = c(0, 8)) +
  2740. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2741. scale_color_discrete(name = "CD8Tc infiltration pattern:",
  2742. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  2743. theme_minimal() + xlab("Follow-up time, years") +
  2744. ggtitle("ICI-naive: Overall survival") + theme(text = element_text(size = 15))
  2745. (p.spat.os <- ggsurvfit_build(p.spat.os))
  2746. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2747. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2748. print(median_table)
  2749. p.spat.os
  2750. # Local progression-free survival by spatial infiltration pattern.
  2751. fit <- survfit2(Surv(days_dos_pfs / 365.25, status_pfs) ~ lcross.cat.biosample, data = df.subset_all)
  2752. p.spat.pfs <- fit %>% ggsurvfit() + add_pvalue() + add_censor_mark() + add_quantile(y_value = 0.5) +
  2753. coord_cartesian(xlim = c(0, 8)) +
  2754. scale_y_continuous(limits = c(0, 1), labels = scales::percent, expand = c(0.01, 0)) +
  2755. scale_color_discrete(name = "CD8Tc infiltration pattern:",
  2756. labels = c("cold" = "cold", "low" = "localized", "high" = "dispersed")) +
  2757. theme_minimal() + xlab("Follow-up time, years") +
  2758. ggtitle("ICI-naive: Local progression-free survival") + theme(text = element_text(size = 15))
  2759. (p.spat.pfs <- ggsurvfit_build(p.spat.pfs))
  2760. median_table <- fit %>% tidy_survfit() %>% group_by(strata) %>%
  2761. summarize(median_survival = time[which(estimate <= 0.5)[1]], .groups = "drop")
  2762. print(median_table)
  2763. p.spat.pfs
  2764. ```
  2765. ## Assemble
  2766. ```{r, fig.width=20, fig.height=12}
  2767. # Main Figure 4 layout.
  2768. ((((p.os + p.pfs) + plot_layout(ncol = 1, nrow = 2)) |
  2769. ((p.da.1 + p.da.2 + plot_layout(widths = c(1.5, 0.5))))) +
  2770. plot_layout(widths = c(0.5, 3))) /
  2771. ((p.spicy.1 + theme(text = element_text(size = 14)) |
  2772. (p.spat.os / p.spat.pfs) | plot_spacer()) + plot_layout(widths = c(2, 0.5, 1))) +
  2773. plot_annotation(tag_levels = list(c("A", "B", "C", "", "D", "E", "F")))
  2774. ggsave("figures/fig4.pdf", width = 20, height = 12, dpi = 200)
  2775. # Supplementary spicyR figure.
  2776. (p.spicy.4) + plot_annotation(tag_levels = list(c("A", "B")))
  2777. ggsave("figures/suppfig_spicyR.pdf", width = 10, height = 6, dpi = 200)
  2778. # Additional assembled views / single panels.
  2779. ((p.da.1 + p.da.2 + plot_layout(widths = c(1.5, 1)))) +
  2780. (((p.spat.os / p.spat.pfs) | (plot_spacer())) + plot_layout(widths = c(1, 1.5))) +
  2781. plot_layout(heights = c(1, 1.5))
  2782. ggsave("figures/fig4_add.pdf", width = 15, height = 11, dpi = 300)
  2783. ((p.os / p.pfs) | (p.spat.os / p.spat.pfs))
  2784. ggsave("figures/fig4_survival.pdf", width = 13, height = 8, dpi = 300)
  2785. wrap_plots(p.da.1.comb, p.da.2.comb) + plot_layout(widths = c(2, 1))
  2786. ggsave("figures/fig4_DA_revised.pdf", width = 12, height = 4.5, dpi = 300)
  2787. p.spicy.1 + theme(text = element_text(size = 14))
  2788. ggsave("figures/fig4_spicy.pdf", width = 9, height = 5, dpi = 300)
  2789. ```
  2790. # Save workspace
  2791. ```{r}
  2792. save.image(file = file.path(output_path, "FINALFIGURES.RData"))
  2793. ```
  2794. # ------------------------------
  2795. # ------------------------------
  2796. # REVISION: Survival model
  2797. ## Load workspace
  2798. ```{r}
  2799. datadir <- "/mnt/projects_output_data/MBM"
  2800. output_path <- file.path(datadir, "data")
  2801. load(file = file.path(output_path, "FINALFIGURES.RData"))
  2802. ```
  2803. # CLINICAL OUTCOME
  2804. ## Collect all parameters
  2805. ```{r}
  2806. library(survival)
  2807. library(survcomp)
  2808. library(survminer)
  2809. library(glmnet)
  2810. library(pROC)
  2811. library(caret)
  2812. # --- Spatial features: cellular-neighbourhood proportions ---
  2813. # Image-level CN frequencies.
  2814. df.cn.sum_image <- colData(sce.subset) %>% as_tibble() %>%
  2815. group_by(image_id, cn_celltypes, .drop = FALSE) %>%
  2816. summarise(count = n()) %>% mutate(prop = prop.table(count)) %>%
  2817. group_by(cn_celltypes) %>% mutate(median = median(prop))
  2818. # Combine image-level CN frequencies with the per-image infiltration category.
  2819. df.comb_image <- df.cn.sum_image %>%
  2820. left_join(df.images %>% select(image_id, lcross.cat.image)) %>%
  2821. filter(!is.na(lcross.cat.image))
  2822. # Sample-level CN proportions (one row per sample).
  2823. df.spatialFeatures_CN <- df.comb %>% ungroup() %>%
  2824. select(biosample_id, cn_celltypes, prop, median) %>%
  2825. pivot_wider(id_cols = "biosample_id", names_from = "cn_celltypes", values_from = "prop") %>%
  2826. replace(is.na(.), 0) %>%
  2827. column_to_rownames("biosample_id")
  2828. # Sample-level L-cross infiltration category.
  2829. df.spatialFeatures_lcross <- df.comb %>% ungroup() %>%
  2830. distinct(biosample_id, lcross.cat.biosample) %>%
  2831. column_to_rownames("biosample_id")
  2832. # --- Cell-type proportions per sample ---
  2833. df.celltypeProportions <- colData(sce) %>% as_tibble() %>%
  2834. group_by(biosample_id, cl_all) %>%
  2835. dplyr::summarise(count = n()) %>% mutate(pct = prop.table(count)) %>%
  2836. ungroup() %>%
  2837. pivot_wider(id_cols = "biosample_id", names_from = "cl_all", values_from = "pct") %>%
  2838. replace(is.na(.), 0) %>%
  2839. column_to_rownames("biosample_id")
  2840. # --- Clinical features ---
  2841. cohort_ids <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, image_id)
  2842. cohort_clin.data <- cohort_ids %>% distinct(biosample_id) %>%
  2843. left_join(metadata(sce)$clinical) %>%
  2844. filter(biosample_id %in% cohort_ids$biosample_id)
  2845. df.clinicalData <- cohort_clin.data %>%
  2846. column_to_rownames("biosample_id") %>%
  2847. select(age, sex, braf, nras, rt_bm_postop, ici_postop, extracranial_control_dos)
  2848. # --- Survival outcome ---
  2849. survival_outcome <- cohort_clin.data %>%
  2850. select(biosample_id, days_dos, status, days_dos_pfs, status_pfs) %>%
  2851. column_to_rownames("biosample_id")
  2852. # --- Condition labels and cohort alignment ---
  2853. cohort.conditions <- colData(sce) %>% as_tibble() %>% distinct(biosample_id, .keep_all = T) %>%
  2854. select(biosample_id, contains("cond.")) %>% as.data.frame()
  2855. rownames(cohort.conditions) <- cohort.conditions$biosample_id
  2856. survival_outcome <- survival_outcome[cohort.conditions$biosample_id, ]
  2857. ```
  2858. # Multivariate penalized Cox proportional hazards model
  2859. A ridge-penalized Cox model (glmnet, alpha = 0) was fitted jointly on four feature
  2860. blocks (clinical variables, cell-type proportions, cellular-neighbourhood spatial
  2861. features, and L-cross spatial features). Factor variables were full-rank dummy
  2862. encoded; missing values were imputed (median for continuous, mode for categorical);
  2863. zero-variance features were dropped; and all features were z-scored, so hazard
  2864. ratios reflect the change in hazard per one standard deviation. Ridge regression
  2865. keeps all features with continuously shrunk coefficients (no hard selection). The
  2866. penalty lambda was selected by k-fold cross-validated partial-likelihood deviance
  2867. (k = 10, reduced to 5 if events < 30). Coefficient uncertainty was quantified by a
  2868. non-parametric bootstrap (refitting at the fixed lambda); percentile 95% CIs and
  2869. empirical two-sided p-values (FDR-adjusted) were derived from the bootstrap
  2870. distribution. Discrimination was summarised by Harrell's C-index, and results are
  2871. shown as forest plots on a log scale.
  2872. ## PFS
  2873. ```{r}
  2874. # Survival endpoint: local progression-free survival.
  2875. outcome.variable_days <- "days_dos_pfs"
  2876. outcome.variable_status <- "status_pfs"
  2877. survival_outcome.subset <- survival_outcome %>%
  2878. select(!!rlang::sym(outcome.variable_days), !!rlang::sym(outcome.variable_status)) %>% drop_na()
  2879. cohort.subset <- cohort.conditions
  2880. df.clinicalData.subset <- df.clinicalData
  2881. # Align all feature blocks to a common set of samples.
  2882. common_samples <- Reduce(intersect, list(
  2883. rownames(df.clinicalData.subset),
  2884. rownames(df.celltypeProportions),
  2885. rownames(df.spatialFeatures_CN),
  2886. rownames(df.spatialFeatures_lcross),
  2887. rownames(cohort.subset),
  2888. rownames(survival_outcome.subset)
  2889. ))
  2890. clin <- df.clinicalData.subset[common_samples, ]
  2891. ctp <- df.celltypeProportions[common_samples, ]
  2892. cn <- df.spatialFeatures_CN[common_samples, ]
  2893. lcross <- df.spatialFeatures_lcross[common_samples, , drop = F]
  2894. days <- survival_outcome.subset[common_samples, outcome.variable_days]
  2895. status <- survival_outcome.subset[common_samples, outcome.variable_status]
  2896. # Impute clinical NAs (median for numeric, mode for categorical).
  2897. clin_imputed <- clin
  2898. for (col in colnames(clin_imputed)) {
  2899. if (is.numeric(clin_imputed[[col]])) {
  2900. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- median(clin_imputed[[col]], na.rm = TRUE)
  2901. } else {
  2902. mode_val <- names(sort(table(clin_imputed[[col]]), decreasing = TRUE))[1]
  2903. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- mode_val
  2904. }
  2905. }
  2906. # Dummy-encode factor blocks and drop the intercept.
  2907. clin <- model.matrix(~ . , data = clin_imputed)[, -1]
  2908. lcross <- model.matrix(~ . , data = lcross)[, -1]
  2909. set.seed(42)
  2910. # Assemble and z-score the feature matrix (drop zero-variance columns).
  2911. X <- cbind(clin, ctp, cn, lcross)
  2912. X <- X[, apply(X, 2, var, na.rm = TRUE) > 0]
  2913. X_scaled <- scale(X)
  2914. y <- Surv(days, status)
  2915. # Annotate each feature with its originating block.
  2916. block_map <- bind_rows(
  2917. data.frame(feature = colnames(clin), block = "Clinical", stringsAsFactors = FALSE),
  2918. data.frame(feature = colnames(ctp), block = "Cell type proportions", stringsAsFactors = FALSE),
  2919. data.frame(feature = colnames(cn), block = "Spatial", stringsAsFactors = FALSE),
  2920. data.frame(feature = colnames(lcross), block = "Spatial", stringsAsFactors = FALSE)
  2921. ) %>% filter(feature %in% colnames(X_scaled))
  2922. # Ridge Cox with cross-validated lambda (deviance); fall back to AIC if CV fails.
  2923. ALPHA <- 0
  2924. n_events <- sum(status)
  2925. nfolds <- if (n_events < 30) 5 else 10
  2926. cat(sprintf("Samples: %d | Events: %d | Features: %d | CV folds: %d\n",
  2927. nrow(X_scaled), n_events, ncol(X_scaled), nfolds))
  2928. cv_fit <- tryCatch(
  2929. cv.glmnet(X_scaled, y, family = "cox", alpha = ALPHA, nfolds = nfolds, type.measure = "deviance"),
  2930. error = function(e) {
  2931. message("cv.glmnet failed: ", conditionMessage(e), "\nFalling back to glmnet + AIC.")
  2932. NULL
  2933. }
  2934. )
  2935. if (is.null(cv_fit)) {
  2936. fit_nocv <- glmnet(X_scaled, y, family = "cox", alpha = ALPHA)
  2937. aic_vals <- deviance(fit_nocv) + 2 * fit_nocv$df
  2938. lambda_chosen <- fit_nocv$lambda[which.min(aic_vals)]
  2939. cv_fit <- list(lambda.min = lambda_chosen, lambda.1se = lambda_chosen,
  2940. lambda = fit_nocv$lambda, cvm = aic_vals, glmnet.fit = fit_nocv)
  2941. class(cv_fit) <- "cv.glmnet.fallback"
  2942. cat(sprintf("AIC-chosen lambda: %.5f\n", lambda_chosen))
  2943. } else {
  2944. lambda_chosen <- cv_fit$lambda.min
  2945. cat(sprintf("CV-chosen lambda (lambda.min): %.5f\n", lambda_chosen))
  2946. }
  2947. # Bootstrap coefficient distribution at the fixed lambda.
  2948. N_BOOT <- 1000
  2949. CI_LEVEL <- 0.95
  2950. alpha_ci <- (1 - CI_LEVEL) / 2
  2951. n <- nrow(X_scaled)
  2952. boot_coefs <- matrix(NA_real_, nrow = N_BOOT, ncol = ncol(X_scaled),
  2953. dimnames = list(NULL, colnames(X_scaled)))
  2954. cat(sprintf("\nRunning %d bootstrap iterations (Ridge, alpha=0)...\n", N_BOOT))
  2955. pb <- txtProgressBar(min = 0, max = N_BOOT, style = 3)
  2956. for (i in seq_len(N_BOOT)) {
  2957. idx <- sample(n, n, replace = TRUE)
  2958. y_boot <- y[idx]
  2959. if (sum(y_boot[, "status"]) < 2) next
  2960. if (length(unique(y_boot[y_boot[, "status"] == 1, "time"])) < 2) next
  2961. fit_boot <- tryCatch(
  2962. glmnet(X_scaled[idx, , drop = FALSE], y_boot, family = "cox", alpha = ALPHA, lambda = lambda_chosen),
  2963. error = function(e) NULL
  2964. )
  2965. if (!is.null(fit_boot)) boot_coefs[i, ] <- as.vector(coef(fit_boot, s = lambda_chosen))
  2966. setTxtProgressBar(pb, i)
  2967. }
  2968. close(pb)
  2969. n_success <- sum(complete.cases(boot_coefs))
  2970. cat(sprintf("\nSuccessful bootstrap iterations: %d / %d\n", n_success, N_BOOT))
  2971. # Point estimates, bootstrap CIs, and empirical p-values.
  2972. coef_full <- as.vector(coef(cv_fit, s = lambda_chosen))
  2973. names(coef_full) <- colnames(X_scaled)
  2974. ci_lower <- apply(boot_coefs, 2, quantile, probs = alpha_ci, na.rm = TRUE)
  2975. ci_upper <- apply(boot_coefs, 2, quantile, probs = 1 - alpha_ci, na.rm = TRUE)
  2976. emp_p <- sapply(colnames(X_scaled), function(f) {
  2977. b <- boot_coefs[, f]; b <- b[!is.na(b)]
  2978. if (length(b) == 0) return(NA_real_)
  2979. p_raw <- if (coef_full[f] >= 0) mean(b <= 0) else mean(b >= 0)
  2980. pmin(2 * p_raw, 1)
  2981. })
  2982. emp_p <- pmax(emp_p, 1 / n_success) # resolution floor
  2983. # C-index on the full data.
  2984. lp <- as.vector(predict(cv_fit, newx = X_scaled, s = lambda_chosen, type = "link"))
  2985. c_index <- as.numeric(concordance(y ~ lp)$concordance)
  2986. cat(sprintf("C-index (full data): %.3f\n", c_index))
  2987. # Results table with FDR and CI-crossing flag.
  2988. results <- data.frame(
  2989. feature = colnames(X_scaled),
  2990. HR = exp(coef_full),
  2991. HR_lower = exp(ci_lower),
  2992. HR_upper = exp(ci_upper),
  2993. p = emp_p,
  2994. stringsAsFactors = FALSE
  2995. ) %>%
  2996. left_join(block_map, by = "feature") %>%
  2997. mutate(block = coalesce(block, "Unknown")) %>%
  2998. mutate(p_adj = p.adjust(p, method = "BH"),
  2999. ci_cross = HR_lower < 1 & HR_upper > 1,
  3000. sig = case_when(p_adj < 0.1 ~ "*", TRUE ~ ""),
  3001. label = paste0(feature, sig)) %>%
  3002. arrange(block, HR) %>%
  3003. mutate(row_id = factor(seq_len(n()), levels = rev(seq_len(n()))))
  3004. cat(sprintf("Features with CI crossing HR=1: %d / %d\n", sum(results$ci_cross), nrow(results)))
  3005. # Forest plot (grey = CI crosses HR=1).
  3006. block_colors <- c("Clinical" = "#1B9E77", "Cell type proportions" = "#D95F02", "Spatial" = "#7570B3")
  3007. results <- results %>% mutate(pt_colour = if_else(ci_cross, "grey70", block_colors[block]))
  3008. x_lo <- min(results$HR_lower, na.rm = TRUE) * 0.90
  3009. x_hi <- max(results$HR_upper, na.rm = TRUE) * 1.10
  3010. active_breaks <- exp(pretty(log(c(x_lo, x_hi)), n = 5))
  3011. active_breaks <- signif(active_breaks, 2)
  3012. active_breaks <- sort(unique(c(1, active_breaks)))
  3013. active_breaks <- active_breaks[active_breaks >= x_lo & active_breaks <= x_hi]
  3014. size_breaks <- signif(quantile(abs(log(results$HR)), probs = c(0.25, 0.5, 0.75, 1.0), na.rm = TRUE), 2)
  3015. size_breaks <- sort(unique(size_breaks[size_breaks > 0]))
  3016. subtitle_txt <- sprintf(
  3017. paste0("Ridge Cox (\u03b1=0, \u03bb=%.4f) | ALL %d features shown | ",
  3018. "C-index = %.3f | %d bootstrap resamples | ",
  3019. "Grey = CI crosses HR=1 | * FDR<0.05 ** <0.01 *** <0.001"),
  3020. lambda_chosen, nrow(results), c_index, N_BOOT)
  3021. p_forest <- ggplot(results, aes(y = row_id)) +
  3022. geom_vline(xintercept = 1, linetype = "dashed", colour = "grey40", linewidth = 0.5) +
  3023. geom_errorbarh(aes(xmin = HR_lower, xmax = HR_upper, colour = pt_colour), height = 0.25, linewidth = 0.55) +
  3024. geom_point(aes(x = HR, colour = pt_colour, size = abs(log(HR))), shape = 18) +
  3025. geom_text(aes(x = HR_upper, label = sig), hjust = -0.2, vjust = 0.5, size = 3, colour = "black") +
  3026. facet_grid(block ~ ., scales = "free_y", space = "free_y", switch = "y") +
  3027. scale_colour_identity() +
  3028. scale_size_continuous(name = "|log(HR)|") +
  3029. scale_x_log10(limits = c(x_lo, x_hi), breaks = active_breaks, labels = as.character(active_breaks)) +
  3030. scale_y_discrete(labels = setNames(results$label, results$row_id)) +
  3031. labs(title = "Multivariate Ridge Cox PH \u2013 Forest Plot (all features)",
  3032. subtitle = subtitle_txt,
  3033. x = "Hazard Ratio (log scale, per-SD)", y = NULL,
  3034. caption = paste0("All features shown; no hard selection threshold applied.\n",
  3035. sprintf("%d%% bootstrap percentile CIs (n=%d resamples). ", round(CI_LEVEL * 100), N_BOOT),
  3036. "Grey = CI includes HR=1. Stars = FDR-adjusted empirical p-value.")) +
  3037. theme_bw(base_size = 11) +
  3038. theme(strip.placement = "outside",
  3039. strip.background = element_rect(fill = "grey93", colour = NA),
  3040. strip.text.y.left = element_text(angle = 0, face = "bold", size = 9),
  3041. panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(),
  3042. panel.spacing = unit(0.35, "lines"), axis.text.y = element_text(size = 7.5),
  3043. plot.title = element_text(face = "bold"),
  3044. plot.subtitle = element_text(size = 7, colour = "grey35"),
  3045. plot.caption = element_text(size = 7, colour = "grey50"),
  3046. legend.position = "bottom")
  3047. p_forest
  3048. results_pfs <- results
  3049. ```
  3050. ## OS
  3051. Identical workflow to the PFS block above, using overall survival as the endpoint.
  3052. ```{r}
  3053. # Survival endpoint: overall survival.
  3054. outcome.variable_days <- "days_dos"
  3055. outcome.variable_status <- "status"
  3056. survival_outcome.subset <- survival_outcome %>%
  3057. select(!!rlang::sym(outcome.variable_days), !!rlang::sym(outcome.variable_status)) %>% drop_na()
  3058. cohort.subset <- cohort.conditions
  3059. df.clinicalData.subset <- df.clinicalData
  3060. # Align all feature blocks to a common set of samples.
  3061. common_samples <- Reduce(intersect, list(
  3062. rownames(df.clinicalData.subset),
  3063. rownames(df.celltypeProportions),
  3064. rownames(df.spatialFeatures_CN),
  3065. rownames(df.spatialFeatures_lcross),
  3066. rownames(cohort.subset),
  3067. rownames(survival_outcome.subset)
  3068. ))
  3069. clin <- df.clinicalData.subset[common_samples, ]
  3070. ctp <- df.celltypeProportions[common_samples, ]
  3071. cn <- df.spatialFeatures_CN[common_samples, ]
  3072. lcross <- df.spatialFeatures_lcross[common_samples, , drop = F]
  3073. days <- survival_outcome.subset[common_samples, outcome.variable_days]
  3074. status <- survival_outcome.subset[common_samples, outcome.variable_status]
  3075. # Impute clinical NAs (median for numeric, mode for categorical).
  3076. clin_imputed <- clin
  3077. for (col in colnames(clin_imputed)) {
  3078. if (is.numeric(clin_imputed[[col]])) {
  3079. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- median(clin_imputed[[col]], na.rm = TRUE)
  3080. } else {
  3081. mode_val <- names(sort(table(clin_imputed[[col]]), decreasing = TRUE))[1]
  3082. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- mode_val
  3083. }
  3084. }
  3085. clin <- model.matrix(~ . , data = clin_imputed)[, -1]
  3086. lcross <- model.matrix(~ . , data = lcross)[, -1]
  3087. set.seed(42)
  3088. X <- cbind(clin, ctp, cn, lcross)
  3089. X <- X[, apply(X, 2, var, na.rm = TRUE) > 0]
  3090. X_scaled <- scale(X)
  3091. y <- Surv(days, status)
  3092. block_map <- bind_rows(
  3093. data.frame(feature = colnames(clin), block = "Clinical", stringsAsFactors = FALSE),
  3094. data.frame(feature = colnames(ctp), block = "Cell type proportions", stringsAsFactors = FALSE),
  3095. data.frame(feature = colnames(cn), block = "Spatial", stringsAsFactors = FALSE),
  3096. data.frame(feature = colnames(lcross), block = "Spatial", stringsAsFactors = FALSE)
  3097. ) %>% filter(feature %in% colnames(X_scaled))
  3098. ALPHA <- 0
  3099. n_events <- sum(status)
  3100. nfolds <- if (n_events < 30) 5 else 10
  3101. cat(sprintf("Samples: %d | Events: %d | Features: %d | CV folds: %d\n",
  3102. nrow(X_scaled), n_events, ncol(X_scaled), nfolds))
  3103. cv_fit <- tryCatch(
  3104. cv.glmnet(X_scaled, y, family = "cox", alpha = ALPHA, nfolds = nfolds, type.measure = "deviance"),
  3105. error = function(e) {
  3106. message("cv.glmnet failed: ", conditionMessage(e), "\nFalling back to glmnet + AIC.")
  3107. NULL
  3108. }
  3109. )
  3110. if (is.null(cv_fit)) {
  3111. fit_nocv <- glmnet(X_scaled, y, family = "cox", alpha = ALPHA)
  3112. aic_vals <- deviance(fit_nocv) + 2 * fit_nocv$df
  3113. lambda_chosen <- fit_nocv$lambda[which.min(aic_vals)]
  3114. cv_fit <- list(lambda.min = lambda_chosen, lambda.1se = lambda_chosen,
  3115. lambda = fit_nocv$lambda, cvm = aic_vals, glmnet.fit = fit_nocv)
  3116. class(cv_fit) <- "cv.glmnet.fallback"
  3117. cat(sprintf("AIC-chosen lambda: %.5f\n", lambda_chosen))
  3118. } else {
  3119. lambda_chosen <- cv_fit$lambda.min
  3120. cat(sprintf("CV-chosen lambda (lambda.min): %.5f\n", lambda_chosen))
  3121. }
  3122. N_BOOT <- 1000
  3123. CI_LEVEL <- 0.95
  3124. alpha_ci <- (1 - CI_LEVEL) / 2
  3125. n <- nrow(X_scaled)
  3126. boot_coefs <- matrix(NA_real_, nrow = N_BOOT, ncol = ncol(X_scaled),
  3127. dimnames = list(NULL, colnames(X_scaled)))
  3128. cat(sprintf("\nRunning %d bootstrap iterations (Ridge, alpha=0)...\n", N_BOOT))
  3129. pb <- txtProgressBar(min = 0, max = N_BOOT, style = 3)
  3130. for (i in seq_len(N_BOOT)) {
  3131. idx <- sample(n, n, replace = TRUE)
  3132. y_boot <- y[idx]
  3133. if (sum(y_boot[, "status"]) < 2) next
  3134. if (length(unique(y_boot[y_boot[, "status"] == 1, "time"])) < 2) next
  3135. fit_boot <- tryCatch(
  3136. glmnet(X_scaled[idx, , drop = FALSE], y_boot, family = "cox", alpha = ALPHA, lambda = lambda_chosen),
  3137. error = function(e) NULL
  3138. )
  3139. if (!is.null(fit_boot)) boot_coefs[i, ] <- as.vector(coef(fit_boot, s = lambda_chosen))
  3140. setTxtProgressBar(pb, i)
  3141. }
  3142. close(pb)
  3143. n_success <- sum(complete.cases(boot_coefs))
  3144. cat(sprintf("\nSuccessful bootstrap iterations: %d / %d\n", n_success, N_BOOT))
  3145. coef_full <- as.vector(coef(cv_fit, s = lambda_chosen))
  3146. names(coef_full) <- colnames(X_scaled)
  3147. ci_lower <- apply(boot_coefs, 2, quantile, probs = alpha_ci, na.rm = TRUE)
  3148. ci_upper <- apply(boot_coefs, 2, quantile, probs = 1 - alpha_ci, na.rm = TRUE)
  3149. emp_p <- sapply(colnames(X_scaled), function(f) {
  3150. b <- boot_coefs[, f]; b <- b[!is.na(b)]
  3151. if (length(b) == 0) return(NA_real_)
  3152. p_raw <- if (coef_full[f] >= 0) mean(b <= 0) else mean(b >= 0)
  3153. pmin(2 * p_raw, 1)
  3154. })
  3155. emp_p <- pmax(emp_p, 1 / n_success)
  3156. lp <- as.vector(predict(cv_fit, newx = X_scaled, s = lambda_chosen, type = "link"))
  3157. c_index <- as.numeric(concordance(y ~ lp)$concordance)
  3158. cat(sprintf("C-index (full data): %.3f\n", c_index))
  3159. results <- data.frame(
  3160. feature = colnames(X_scaled),
  3161. HR = exp(coef_full),
  3162. HR_lower = exp(ci_lower),
  3163. HR_upper = exp(ci_upper),
  3164. p = emp_p,
  3165. stringsAsFactors = FALSE
  3166. ) %>%
  3167. left_join(block_map, by = "feature") %>%
  3168. mutate(block = coalesce(block, "Unknown")) %>%
  3169. mutate(p_adj = p.adjust(p, method = "BH"),
  3170. ci_cross = HR_lower < 1 & HR_upper > 1,
  3171. sig = case_when(p_adj < 0.1 ~ "*", TRUE ~ ""),
  3172. label = paste0(feature, sig)) %>%
  3173. arrange(block, HR) %>%
  3174. mutate(row_id = factor(seq_len(n()), levels = rev(seq_len(n()))))
  3175. cat(sprintf("Features with CI crossing HR=1: %d / %d\n", sum(results$ci_cross), nrow(results)))
  3176. block_colors <- c("Clinical" = "#1B9E77", "Cell type proportions" = "#D95F02", "Spatial" = "#7570B3")
  3177. results <- results %>% mutate(pt_colour = if_else(ci_cross, "grey70", block_colors[block]))
  3178. x_lo <- min(results$HR_lower, na.rm = TRUE) * 0.90
  3179. x_hi <- max(results$HR_upper, na.rm = TRUE) * 1.10
  3180. active_breaks <- exp(pretty(log(c(x_lo, x_hi)), n = 5))
  3181. active_breaks <- signif(active_breaks, 2)
  3182. active_breaks <- sort(unique(c(1, active_breaks)))
  3183. active_breaks <- active_breaks[active_breaks >= x_lo & active_breaks <= x_hi]
  3184. size_breaks <- signif(quantile(abs(log(results$HR)), probs = c(0.25, 0.5, 0.75, 1.0), na.rm = TRUE), 2)
  3185. size_breaks <- sort(unique(size_breaks[size_breaks > 0]))
  3186. subtitle_txt <- sprintf(
  3187. paste0("Ridge Cox (\u03b1=0, \u03bb=%.4f) | ALL %d features shown | ",
  3188. "C-index = %.3f | %d bootstrap resamples | ",
  3189. "Grey = CI crosses HR=1 | * FDR<0.05 ** <0.01 *** <0.001"),
  3190. lambda_chosen, nrow(results), c_index, N_BOOT)
  3191. p_forest <- ggplot(results, aes(y = row_id)) +
  3192. geom_vline(xintercept = 1, linetype = "dashed", colour = "grey40", linewidth = 0.5) +
  3193. geom_errorbarh(aes(xmin = HR_lower, xmax = HR_upper, colour = pt_colour), height = 0.25, linewidth = 0.55) +
  3194. geom_point(aes(x = HR, colour = pt_colour, size = abs(log(HR))), shape = 18) +
  3195. geom_text(aes(x = HR_upper, label = sig), hjust = -0.2, vjust = 0.5, size = 3, colour = "black") +
  3196. facet_grid(block ~ ., scales = "free_y", space = "free_y", switch = "y") +
  3197. scale_colour_identity() +
  3198. scale_size_continuous(name = "|log(HR)|") +
  3199. scale_x_log10(limits = c(x_lo, x_hi), breaks = active_breaks, labels = as.character(active_breaks)) +
  3200. scale_y_discrete(labels = setNames(results$label, results$row_id)) +
  3201. labs(title = "Multivariate Ridge Cox PH \u2013 Forest Plot (all features)",
  3202. subtitle = subtitle_txt,
  3203. x = "Hazard Ratio (log scale, per-SD)", y = NULL,
  3204. caption = paste0("All features shown; no hard selection threshold applied.\n",
  3205. sprintf("%d%% bootstrap percentile CIs (n=%d resamples). ", round(CI_LEVEL * 100), N_BOOT),
  3206. "Grey = CI includes HR=1. Stars = FDR-adjusted empirical p-value.")) +
  3207. theme_bw(base_size = 11) +
  3208. theme(strip.placement = "outside",
  3209. strip.background = element_rect(fill = "grey93", colour = NA),
  3210. strip.text.y.left = element_text(angle = 0, face = "bold", size = 9),
  3211. panel.grid.major.y = element_blank(), panel.grid.minor = element_blank(),
  3212. panel.spacing = unit(0.35, "lines"), axis.text.y = element_text(size = 7.5),
  3213. plot.title = element_text(face = "bold"),
  3214. plot.subtitle = element_text(size = 7, colour = "grey35"),
  3215. plot.caption = element_text(size = 7, colour = "grey50"),
  3216. legend.position = "bottom")
  3217. p_forest
  3218. results_os <- results
  3219. ```
  3220. ### Combined plot
  3221. ```{r, fig.width = 10, figh.height = 25}
  3222. library(ggh4x)
  3223. # Combine OS and PFS results and order panels so colours match the block scheme.
  3224. results_os$outcome <- "Overall survival"
  3225. results_pfs$outcome <- "Local progression-free surrival"
  3226. results_all <- rbind(results_os, results_pfs)
  3227. results_all$block <- factor(results_all$block, levels = names(block_colors))
  3228. # Relabel features with readable names; append "*" for FDR < 0.1.
  3229. results_all$label <- results_all$feature
  3230. results_all <- results_all %>%
  3231. mutate(label = case_when(
  3232. label == "sexM" ~ "Sex male",
  3233. label == "ici_postopyes" ~ "ICI postop",
  3234. label == "nrasmut" ~ "NRAS mutated",
  3235. label == "brafmut" ~ "BRAF mutated",
  3236. label == "rt_bm_postopyes" ~ "Radiotherapy postop",
  3237. label == "age" ~ "Age",
  3238. label == "extracranial_control_dosyes" ~ "Extracranial controlled disease",
  3239. label == "lcross.cat.biosamplelow" ~ "CD8Tc infiltration: Localized",
  3240. label == "lcross.cat.biosamplehigh" ~ "CD8Tc infiltration: Dispersed",
  3241. label == "cn_1" ~ "CN: Perivascular",
  3242. label == "cn_2" ~ "CN: Tumor core",
  3243. label == "cn_3" ~ "CN: Tumor CD8Tc border",
  3244. label == "cn_4" ~ "CN: Myeloid enriched",
  3245. label == "cn_5" ~ "CN: CD8Tc core",
  3246. label == "cn_6" ~ "CN: B cell, Neutrophil, Mf enriched",
  3247. label == "cn_7" ~ "CN: CD4Tc/Treg, Plasma cell enriched",
  3248. TRUE ~ label)) %>%
  3249. mutate(label = if_else(sig == "*", paste0(label, "*"), label))
  3250. p.multivariate.coxph <- ggplot(results_all, aes(y = feature)) +
  3251. geom_vline(xintercept = 1, linetype = "dashed", colour = "grey40", linewidth = 0.5) +
  3252. geom_errorbarh(aes(xmin = HR_lower, xmax = HR_upper, colour = block, alpha = p < 0.05),
  3253. height = 0.25, linewidth = 0.55) +
  3254. geom_point(aes(x = HR, colour = block, alpha = p < 0.05, size = -log10(p), fill = "")) +
  3255. scale_fill_manual(name = "* FDR < 0.1", values = "transparent",
  3256. guide = guide_legend(override.aes = list(fill = NA, color = NA),
  3257. theme = theme(legend.key = element_blank()))) +
  3258. geom_text(data = results_all %>% filter(sig == "*"),
  3259. aes(x = HR, label = "*"), vjust = .8, size = 4, colour = "black") +
  3260. facet_grid2(block ~ outcome, scales = "free_y", space = "free_y", switch = "y",
  3261. strip = strip_themed(background_y = elem_list_rect(fill = block_colors),
  3262. text_y = elem_list_text(colour = c("white")))) +
  3263. scale_colour_manual(values = block_colors, guide = "none") +
  3264. scale_alpha_discrete(name = "p-value < 0.05", range = c(0.4, 1)) +
  3265. scale_size_continuous(name = "-log10(p-value)") +
  3266. scale_y_discrete(labels = setNames(str_wrap(results_all$label, width = 50), results_all$feature)) +
  3267. labs(title = "Multivariate Ridge Cox PH \u2013 Forest Plot (all features)",
  3268. x = "Hazard Ratio (per-SD)", y = NULL,
  3269. caption = paste0("All features shown; no hard selection threshold applied.\n",
  3270. sprintf("%d%% bootstrap percentile CIs (n=%d resamples). ", round(CI_LEVEL * 100), N_BOOT))) +
  3271. theme(plot.title = element_text(face = "bold"),
  3272. plot.subtitle = element_text(size = 7, colour = "grey35"),
  3273. plot.caption = element_text(size = 7, colour = "grey50"),
  3274. legend.position = "right",
  3275. axis.text.y = element_text(hjust = 0, lineheight = .7))
  3276. p.multivariate.coxph
  3277. ```
  3278. # Regularised Cox model comparison (Ridge)
  3279. Ridge-regularised Cox models (glmnet, alpha = 0) were fitted for all additive
  3280. combinations of the four feature blocks (clinical, cell-type proportions, CN, and
  3281. L-cross spatial), yielding the candidate models below. Given the small cohort
  3282. (n < 50), leave-one-out cross-validation was used to select lambda and Ridge was
  3283. preferred over Lasso for coefficient stability. Discrimination was summarised by
  3284. the concordance index (C-index) with bootstrap 95% CIs (1,000 resamples); each
  3285. model was compared to the clinical baseline via cindex.comp(), a likelihood-ratio
  3286. test on the linear predictors, and a permutation test for the best model.
  3287. ## PFS
  3288. ```{r}
  3289. outcome.variable_days <- "days_dos_pfs"
  3290. outcome.variable_status <- "status_pfs"
  3291. survival_outcome.subset <- survival_outcome %>%
  3292. select(!!rlang::sym(outcome.variable_days), !!rlang::sym(outcome.variable_status)) %>% drop_na()
  3293. # Align all feature blocks to common samples.
  3294. common_samples <- Reduce(intersect, list(
  3295. rownames(df.clinicalData),
  3296. rownames(df.celltypeProportions),
  3297. rownames(df.spatialFeatures_CN),
  3298. rownames(df.spatialFeatures_lcross),
  3299. rownames(survival_outcome.subset)
  3300. ))
  3301. clin <- df.clinicalData[common_samples, ]
  3302. ctp <- df.celltypeProportions[common_samples, ]
  3303. cn <- df.spatialFeatures_CN[common_samples, ]
  3304. lcross <- df.spatialFeatures_lcross[common_samples, , drop = F]
  3305. n <- length(common_samples)
  3306. # Impute clinical NAs (median numeric, mode categorical) and dummy-encode.
  3307. clin_imputed <- clin
  3308. for (col in colnames(clin_imputed)) {
  3309. if (is.numeric(clin_imputed[[col]])) {
  3310. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- median(clin_imputed[[col]], na.rm = TRUE)
  3311. } else {
  3312. mode_val <- names(sort(table(clin_imputed[[col]]), decreasing = TRUE))[1]
  3313. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- mode_val
  3314. }
  3315. }
  3316. clin_mat <- model.matrix(~ . - 1, data = clin_imputed)
  3317. lcross <- model.matrix(~ . - 1, data = lcross)
  3318. days <- survival_outcome.subset[common_samples, outcome.variable_days]
  3319. status <- survival_outcome.subset[common_samples, outcome.variable_status]
  3320. surv <- Surv(days, status)
  3321. cat(sprintf("Proceeding with %d samples after NA removal.\n", n))
  3322. # Candidate feature matrices: each block alone and all additive combinations.
  3323. spatial_mat <- cbind(as.matrix(cn), lcross)
  3324. mat <- list(
  3325. "Clinical" = clin_mat,
  3326. "Cell type proportions" = as.matrix(ctp),
  3327. "Spatial" = spatial_mat,
  3328. "Clinical + Cell type proportions" = cbind(clin_mat, as.matrix(ctp)),
  3329. "Clinical + Spatial" = cbind(clin_mat, spatial_mat),
  3330. "Cell type proportions + Spatial" = cbind(as.matrix(ctp), spatial_mat),
  3331. "Clinical + Cell type proportions + Spatial" = cbind(clin_mat, as.matrix(ctp), spatial_mat)
  3332. )
  3333. set.seed(42)
  3334. # Fit Ridge Cox (LOO-CV), extract C-index (Noether) + bootstrap CI + logLik/AIC.
  3335. fit_model <- function(feature_mat, days, status, surv, n, B = 1000) {
  3336. cv_fit <- cv.glmnet(feature_mat, surv, family = "cox", alpha = 0, nfolds = n)
  3337. lp <- as.vector(predict(cv_fit, newx = feature_mat, s = "lambda.min"))
  3338. # Unpenalized Cox on the linear predictor -> logLik / AIC.
  3339. std_fit <- coxph(Surv(days, status) ~ lp)
  3340. ll <- logLik(std_fit)
  3341. aic_val <- AIC(std_fit)
  3342. # C-index (Noether method, required for cindex.comp()).
  3343. ci_noether <- concordance.index(x = lp, surv.time = days, surv.event = status, method = "noether")
  3344. # Bootstrap 95% CI for the C-index.
  3345. boot_c <- replicate(B, {
  3346. idx <- sample(n, replace = TRUE)
  3347. tryCatch(concordance.index(x = lp[idx], surv.time = days[idx], surv.event = status[idx])$c.index,
  3348. error = function(e) NA, warning = function(w) NA)
  3349. })
  3350. n_failed <- sum(is.na(boot_c))
  3351. if (n_failed > 0) message(sprintf("Bootstrap: %d/%d resamples failed and were skipped.", n_failed, B))
  3352. ci_noether$lower <- quantile(boot_c, 0.025, na.rm = TRUE)
  3353. ci_noether$upper <- quantile(boot_c, 0.975, na.rm = TRUE)
  3354. list(lp = lp, ci = ci_noether, logLik = ll, aic = aic_val)
  3355. }
  3356. results <- lapply(mat, fit_model, days = days, status = status, surv = surv, n = n, B = 1000)
  3357. # Summary table (C-index, CI, AIC, p) ordered by C-index.
  3358. summary_df <- data.frame(
  3359. Model = names(results),
  3360. C_index = sapply(results, \(r) round(r$ci$c.index, 3)),
  3361. CI_lower = sapply(results, \(r) round(r$ci$lower, 3)),
  3362. CI_upper = sapply(results, \(r) round(r$ci$upper, 3)),
  3363. AIC = sapply(results, \(r) round(r$aic, 2)),
  3364. p_value = sapply(results, \(r) signif(r$ci$p.value, 3))
  3365. )
  3366. summary_df <- summary_df[order(-summary_df$C_index), ]
  3367. print(summary_df, row.names = FALSE)
  3368. # Pairwise C-index comparison vs. the clinical baseline.
  3369. baseline <- results[["Clinical"]]$ci
  3370. cat("\n-- C-index comparison vs. Clinical baseline --\n")
  3371. for (nm in setdiff(names(results), "Clinical")) {
  3372. comp <- cindex.comp(baseline, results[[nm]]$ci)
  3373. cat(sprintf(" Clinical vs %-35s p = %.4f\n", nm, comp$p.value))
  3374. }
  3375. # Likelihood-ratio test vs. clinical (df = 1).
  3376. cat("\n-- Likelihood Ratio Test (LRT) vs. Clinical --\n")
  3377. ll_baseline <- results[["Clinical"]]$logLik
  3378. for (nm in setdiff(names(results), "Clinical")) {
  3379. ll_complex <- results[[nm]]$logLik
  3380. lrt_stat <- as.numeric(2 * (ll_complex - ll_baseline))
  3381. lrt_p <- pchisq(lrt_stat, df = 1, lower.tail = FALSE)
  3382. cat(sprintf(" Clinical vs %-20s | dLL: %6.2f | LRT p = %.4f\n",
  3383. nm, (ll_complex - ll_baseline), lrt_p))
  3384. }
  3385. # Permutation test: best combined model vs. clinical.
  3386. best_name <- summary_df$Model[1]
  3387. if (best_name != "Clinical") {
  3388. observed_diff <- results[[best_name]]$ci$c.index - results[["Clinical"]]$ci$c.index
  3389. perm_diffs <- replicate(1000, {
  3390. idx <- sample(n)
  3391. c1 <- concordance.index(results[["Clinical"]]$lp[idx], days, status, method = "noether")$c.index
  3392. c2 <- concordance.index(results[[best_name]]$lp[idx], days, status, method = "noether")$c.index
  3393. c2 - c1
  3394. })
  3395. perm_p <- mean(abs(perm_diffs) >= abs(observed_diff))
  3396. cat(sprintf("\nPermutation test - Clinical vs %s: dC = %.3f, p = %.4f\n",
  3397. best_name, observed_diff, perm_p))
  3398. }
  3399. # C-index forest plot.
  3400. summary_df$Model <- factor(summary_df$Model, levels = rev(summary_df$Model))
  3401. ggplot(summary_df, aes(x = C_index, y = Model)) +
  3402. geom_vline(xintercept = 0.5, linetype = "dashed", color = "grey60") +
  3403. geom_errorbarh(aes(xmin = CI_lower, xmax = CI_upper), height = 0.25, color = "grey40") +
  3404. geom_point(size = 3, color = "#E64B35") +
  3405. geom_vline(xintercept = results[[best_name]]$ci$c.index) +
  3406. geom_vline(xintercept = results[["Clinical"]]$ci$c.index) +
  3407. scale_x_continuous(limits = c(0.3, 1), breaks = seq(0.3, 1, 0.1)) +
  3408. labs(x = "C-index (bootstrap 95% CI)", y = NULL, title = "Survival prediction: model comparison") +
  3409. theme_classic(base_size = 13) +
  3410. theme(axis.text.y = element_text(hjust = 1))
  3411. # Kaplan-Meier risk groups (median split of linear predictor): clinical vs. best.
  3412. risk_group <- function(lp) factor(ifelse(lp > median(lp), "High", "Low"))
  3413. df_plot <- data.frame(days = days, status = status,
  3414. risk_clin = risk_group(results[["Clinical"]]$lp),
  3415. risk_best = risk_group(results[[best_name]]$lp))
  3416. p1 <- ggsurvplot(survfit(Surv(days, status) ~ risk_clin, data = df_plot),
  3417. data = df_plot, pval = TRUE, risk.table = TRUE,
  3418. title = "Clinical only", palette = c("#E64B35", "#4DBBD5"))
  3419. p2 <- ggsurvplot(survfit(Surv(days, status) ~ risk_best, data = df_plot),
  3420. data = df_plot, pval = TRUE, risk.table = TRUE,
  3421. title = best_name, palette = c("#E64B35", "#4DBBD5"))
  3422. arrange_ggsurvplots(list(p1, p2), ncol = 2)
  3423. # Ridge coefficient heatmap across all models.
  3424. library(pheatmap)
  3425. get_coefs <- function(feature_mat, surv, n) {
  3426. cv_fit <- cv.glmnet(feature_mat, surv, family = "cox", alpha = 0, nfolds = n)
  3427. coefs <- as.vector(coef(cv_fit, s = "lambda.min"))
  3428. names(coefs) <- rownames(coef(cv_fit, s = "lambda.min"))
  3429. coefs
  3430. }
  3431. set.seed(42)
  3432. mat_scaled <- lapply(mat, scale)
  3433. coef_list <- lapply(mat, get_coefs, surv = surv, n = n)
  3434. # Unify into a variables x models matrix (0 where a feature is absent).
  3435. all_vars <- unique(unlist(lapply(coef_list, names)))
  3436. coef_mat <- sapply(coef_list, function(coefs) {
  3437. out <- setNames(rep(0, length(all_vars)), all_vars)
  3438. out[names(coefs)] <- coefs
  3439. out
  3440. })
  3441. pheatmap(coef_mat, cluster_rows = TRUE, cluster_cols = FALSE, scale = "row",
  3442. na_col = "grey90", angle_col = 45, main = "Ridge-Cox coefficients by model")
  3443. summary_df_pfs <- summary_df
  3444. ```
  3445. ## OS
  3446. Identical workflow to the PFS block above, using overall survival as the endpoint.
  3447. ```{r}
  3448. outcome.variable_days <- "days_dos"
  3449. outcome.variable_status <- "status"
  3450. survival_outcome.subset <- survival_outcome %>%
  3451. select(!!rlang::sym(outcome.variable_days), !!rlang::sym(outcome.variable_status)) %>% drop_na()
  3452. common_samples <- Reduce(intersect, list(
  3453. rownames(df.clinicalData),
  3454. rownames(df.celltypeProportions),
  3455. rownames(df.spatialFeatures_CN),
  3456. rownames(df.spatialFeatures_lcross),
  3457. rownames(survival_outcome.subset)
  3458. ))
  3459. clin <- df.clinicalData[common_samples, ]
  3460. ctp <- df.celltypeProportions[common_samples, ]
  3461. cn <- df.spatialFeatures_CN[common_samples, ]
  3462. lcross <- df.spatialFeatures_lcross[common_samples, , drop = F]
  3463. n <- length(common_samples)
  3464. clin_imputed <- clin
  3465. for (col in colnames(clin_imputed)) {
  3466. if (is.numeric(clin_imputed[[col]])) {
  3467. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- median(clin_imputed[[col]], na.rm = TRUE)
  3468. } else {
  3469. mode_val <- names(sort(table(clin_imputed[[col]]), decreasing = TRUE))[1]
  3470. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- mode_val
  3471. }
  3472. }
  3473. clin_mat <- model.matrix(~ . - 1, data = clin_imputed)
  3474. lcross <- model.matrix(~ . - 1, data = lcross)
  3475. days <- survival_outcome.subset[common_samples, outcome.variable_days]
  3476. status <- survival_outcome.subset[common_samples, outcome.variable_status]
  3477. surv <- Surv(days, status)
  3478. cat(sprintf("Proceeding with %d samples after NA removal.\n", n))
  3479. spatial_mat <- cbind(as.matrix(cn), lcross)
  3480. mat <- list(
  3481. "Clinical" = clin_mat,
  3482. "Cell type proportions" = as.matrix(ctp),
  3483. "Spatial" = spatial_mat,
  3484. "Clinical + Cell type proportions" = cbind(clin_mat, as.matrix(ctp)),
  3485. "Clinical + Spatial" = cbind(clin_mat, spatial_mat),
  3486. "Cell type proportions + Spatial" = cbind(as.matrix(ctp), spatial_mat),
  3487. "Clinical + Cell type proportions + Spatial" = cbind(clin_mat, as.matrix(ctp), spatial_mat)
  3488. )
  3489. set.seed(42)
  3490. fit_model <- function(feature_mat, days, status, surv, n, B = 1000) {
  3491. cv_fit <- cv.glmnet(feature_mat, surv, family = "cox", alpha = 0, nfolds = n)
  3492. lp <- as.vector(predict(cv_fit, newx = feature_mat, s = "lambda.min"))
  3493. std_fit <- coxph(Surv(days, status) ~ lp)
  3494. ll <- logLik(std_fit)
  3495. aic_val <- AIC(std_fit)
  3496. ci_noether <- concordance.index(x = lp, surv.time = days, surv.event = status, method = "noether")
  3497. boot_c <- replicate(B, {
  3498. idx <- sample(n, replace = TRUE)
  3499. tryCatch(concordance.index(x = lp[idx], surv.time = days[idx], surv.event = status[idx])$c.index,
  3500. error = function(e) NA, warning = function(w) NA)
  3501. })
  3502. n_failed <- sum(is.na(boot_c))
  3503. if (n_failed > 0) message(sprintf("Bootstrap: %d/%d resamples failed and were skipped.", n_failed, B))
  3504. ci_noether$lower <- quantile(boot_c, 0.025, na.rm = TRUE)
  3505. ci_noether$upper <- quantile(boot_c, 0.975, na.rm = TRUE)
  3506. list(lp = lp, ci = ci_noether, logLik = ll, aic = aic_val)
  3507. }
  3508. results <- lapply(mat, fit_model, days = days, status = status, surv = surv, n = n, B = 1000)
  3509. summary_df <- data.frame(
  3510. Model = names(results),
  3511. C_index = sapply(results, \(r) round(r$ci$c.index, 3)),
  3512. CI_lower = sapply(results, \(r) round(r$ci$lower, 3)),
  3513. CI_upper = sapply(results, \(r) round(r$ci$upper, 3)),
  3514. AIC = sapply(results, \(r) round(r$aic, 2)),
  3515. p_value = sapply(results, \(r) signif(r$ci$p.value, 3))
  3516. )
  3517. summary_df <- summary_df[order(-summary_df$C_index), ]
  3518. print(summary_df, row.names = FALSE)
  3519. baseline <- results[["Clinical"]]$ci
  3520. cat("\n-- C-index comparison vs. Clinical baseline --\n")
  3521. for (nm in setdiff(names(results), "Clinical")) {
  3522. comp <- cindex.comp(baseline, results[[nm]]$ci)
  3523. cat(sprintf(" Clinical vs %-35s p = %.4f\n", nm, comp$p.value))
  3524. }
  3525. cat("\n-- Likelihood Ratio Test (LRT) vs. Clinical --\n")
  3526. ll_baseline <- results[["Clinical"]]$logLik
  3527. for (nm in setdiff(names(results), "Clinical")) {
  3528. ll_complex <- results[[nm]]$logLik
  3529. lrt_stat <- as.numeric(2 * (ll_complex - ll_baseline))
  3530. lrt_p <- pchisq(lrt_stat, df = 1, lower.tail = FALSE)
  3531. cat(sprintf(" Clinical vs %-20s | dLL: %6.2f | LRT p = %.4f\n",
  3532. nm, (ll_complex - ll_baseline), lrt_p))
  3533. }
  3534. best_name <- summary_df$Model[1]
  3535. if (best_name != "Clinical") {
  3536. observed_diff <- results[[best_name]]$ci$c.index - results[["Clinical"]]$ci$c.index
  3537. perm_diffs <- replicate(1000, {
  3538. idx <- sample(n)
  3539. c1 <- concordance.index(results[["Clinical"]]$lp[idx], days, status, method = "noether")$c.index
  3540. c2 <- concordance.index(results[[best_name]]$lp[idx], days, status, method = "noether")$c.index
  3541. c2 - c1
  3542. })
  3543. perm_p <- mean(abs(perm_diffs) >= abs(observed_diff))
  3544. cat(sprintf("\nPermutation test - Clinical vs %s: dC = %.3f, p = %.4f\n",
  3545. best_name, observed_diff, perm_p))
  3546. }
  3547. summary_df$Model <- factor(summary_df$Model, levels = rev(summary_df$Model))
  3548. ggplot(summary_df, aes(x = C_index, y = Model)) +
  3549. geom_vline(xintercept = 0.5, linetype = "dashed", color = "grey60") +
  3550. geom_errorbarh(aes(xmin = CI_lower, xmax = CI_upper), height = 0.25, color = "grey40") +
  3551. geom_point(size = 3, color = "#E64B35") +
  3552. geom_vline(xintercept = results[[best_name]]$ci$c.index) +
  3553. geom_vline(xintercept = results[["Clinical"]]$ci$c.index) +
  3554. scale_x_continuous(limits = c(0.3, 1), breaks = seq(0.3, 1, 0.1)) +
  3555. labs(x = "C-index (bootstrap 95% CI)", y = NULL, title = "Survival prediction: model comparison") +
  3556. theme_classic(base_size = 13) +
  3557. theme(axis.text.y = element_text(hjust = 1))
  3558. risk_group <- function(lp) factor(ifelse(lp > median(lp), "High", "Low"))
  3559. df_plot <- data.frame(days = days, status = status,
  3560. risk_clin = risk_group(results[["Clinical"]]$lp),
  3561. risk_best = risk_group(results[[best_name]]$lp))
  3562. p1 <- ggsurvplot(survfit(Surv(days, status) ~ risk_clin, data = df_plot),
  3563. data = df_plot, pval = TRUE, risk.table = TRUE,
  3564. title = "Clinical only", palette = c("#E64B35", "#4DBBD5"))
  3565. p2 <- ggsurvplot(survfit(Surv(days, status) ~ risk_best, data = df_plot),
  3566. data = df_plot, pval = TRUE, risk.table = TRUE,
  3567. title = best_name, palette = c("#E64B35", "#4DBBD5"))
  3568. arrange_ggsurvplots(list(p1, p2), ncol = 2)
  3569. library(pheatmap)
  3570. get_coefs <- function(feature_mat, surv, n) {
  3571. cv_fit <- cv.glmnet(feature_mat, surv, family = "cox", alpha = 0, nfolds = n)
  3572. coefs <- as.vector(coef(cv_fit, s = "lambda.min"))
  3573. names(coefs) <- rownames(coef(cv_fit, s = "lambda.min"))
  3574. coefs
  3575. }
  3576. set.seed(42)
  3577. mat_scaled <- lapply(mat, scale)
  3578. coef_list <- lapply(mat, get_coefs, surv = surv, n = n)
  3579. all_vars <- unique(unlist(lapply(coef_list, names)))
  3580. coef_mat <- sapply(coef_list, function(coefs) {
  3581. out <- setNames(rep(0, length(all_vars)), all_vars)
  3582. out[names(coefs)] <- coefs
  3583. out
  3584. })
  3585. pheatmap(coef_mat, cluster_rows = TRUE, cluster_cols = FALSE, scale = "row",
  3586. na_col = "grey90", angle_col = 45, main = "Ridge-Cox coefficients by model")
  3587. summary_df_os <- summary_df
  3588. ```
  3589. ### Combined plot
  3590. ```{r, fig.height=8, fig.width = 6.5}
  3591. summary_df_os$outcome <- "Overall survival"
  3592. summary_df_pfs$outcome <- "Local progression-free survival"
  3593. summary_df_all <- rbind(summary_df_os, summary_df_pfs)
  3594. # FDR-adjusted p-values and significance flag.
  3595. summary_df_all <- summary_df_all %>%
  3596. mutate(p_adj = p.adjust(p_value, method = "BH"),
  3597. sign = if_else(p_adj < 0.1, "*", ""))
  3598. # Helper: colour the feature-block names inside model labels via inline HTML.
  3599. colorize_label <- function(label, colors) {
  3600. for (category in names(colors)) {
  3601. color_val <- colors[category]
  3602. replacement <- paste0("<span style='color:", color_val, "'>", category, "</span>")
  3603. label <- str_replace_all(label, fixed(category), replacement)
  3604. }
  3605. label
  3606. }
  3607. summary_df_all$Model_html <- sapply(summary_df_all$Model, colorize_label, colors = block_colors)
  3608. # C-index plots per outcome (ggtext renders the coloured model labels).
  3609. p.model.os <- ggplot(summary_df_all %>% filter(outcome == "Overall survival"),
  3610. aes(x = C_index, y = reorder(Model_html, C_index))) +
  3611. geom_vline(xintercept = 0.5, linetype = "dashed", color = "grey60") +
  3612. geom_errorbarh(aes(xmin = CI_lower, xmax = CI_upper), height = 0.25, color = "grey40") +
  3613. geom_point(aes(size = -log10(p_value))) +
  3614. scale_x_continuous(limits = c(0.3, 1), breaks = seq(0.3, 1, 0.1)) +
  3615. labs(x = "C-index (bootstrap 95% CI)", y = NULL, title = "Overall survival") +
  3616. theme(axis.text.y = element_markdown(hjust = 1, size = 10, lineheight = 1.2))
  3617. p.model.os
  3618. p.model.pfs <- ggplot(summary_df_all %>% filter(outcome == "Local progression-free survival"),
  3619. aes(x = C_index, y = reorder(Model_html, C_index))) +
  3620. geom_vline(xintercept = 0.5, linetype = "dashed", color = "grey60") +
  3621. geom_errorbarh(aes(xmin = CI_lower, xmax = CI_upper), height = 0.25, color = "grey40") +
  3622. geom_point(aes(size = -log10(p_value))) +
  3623. scale_x_continuous(limits = c(0.3, 1), breaks = seq(0.3, 1, 0.1)) +
  3624. labs(x = "C-index (bootstrap 95% CI)", y = NULL, title = "Local progression-free survival") +
  3625. theme(axis.text.y = element_markdown(hjust = 1, size = 10, lineheight = 1.2))
  3626. p.model.os + p.model.pfs + plot_layout(nrow = 2) +
  3627. plot_annotation(title = "Survival prediction: Model comparison", tag_levels = "A")
  3628. ```
  3629. # ICI treatment response prediction
  3630. To evaluate the predictive value of distinct feature sets for treatment response,
  3631. elastic net logistic regression models were trained using the glmnet algorithm as
  3632. implemented in the caret R package (v6.0). Feature sets included clinical variables,
  3633. cell type proportions, and spatial features (cellular neighborhood composition and
  3634. cross-K statistics), each evaluated individually and in combination. Prior to model
  3635. training, all features were mean-centered and variance-scaled, and missing clinical
  3636. values were imputed using column-wise medians (continuous variables) or modes
  3637. (categorical variables). Hyperparameters (alpha and lambda) were selected over a grid
  3638. of 10 candidate values. Model performance was assessed using repeated 5-fold
  3639. cross-validation (20 repeats), with area under the receiver operating characteristic
  3640. curve (AUROC) as the primary metric, computed using the pROC R package. Ninety-five
  3641. percent confidence intervals for AUROC values were estimated via DeLong's method, and
  3642. pairwise comparisons between feature sets and the clinical baseline model were
  3643. performed using DeLong's test with Benjamini-Hochberg correction for multiple testing.
  3644. ```{r}
  3645. # Subset cohort to the ICI-response condition (cond.2).
  3646. cohort.subset <- cohort.conditions %>% filter(!is.na(cond.2))
  3647. # Remove ici_postop, which would have only one factor level in this subset.
  3648. df.clinicalData.subset <- df.clinicalData %>% select(-ici_postop)
  3649. # Align all feature blocks to a common set of samples.
  3650. common_samples <- Reduce(intersect, list(
  3651. rownames(df.clinicalData.subset),
  3652. rownames(df.celltypeProportions),
  3653. rownames(df.spatialFeatures_CN),
  3654. rownames(df.spatialFeatures_lcross),
  3655. rownames(cohort.subset)
  3656. ))
  3657. clin <- df.clinicalData.subset[common_samples, ]
  3658. ctp <- df.celltypeProportions[common_samples, ]
  3659. cn <- df.spatialFeatures_CN[common_samples, ]
  3660. lcross <- df.spatialFeatures_lcross[common_samples, , drop = F]
  3661. # Binary outcome: responder vs. non-responder.
  3662. outcome <- cohort.subset[common_samples, "cond.2"]
  3663. outcome <- factor(outcome, levels = c("responder", "non-responder"))
  3664. # Impute clinical NAs (median for numeric, mode for categorical) and dummy-encode.
  3665. clin_imputed <- clin
  3666. for (col in colnames(clin_imputed)) {
  3667. if (is.numeric(clin_imputed[[col]])) {
  3668. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- median(clin_imputed[[col]], na.rm = TRUE)
  3669. } else {
  3670. mode_val <- names(sort(table(clin_imputed[[col]]), decreasing = TRUE))[1]
  3671. clin_imputed[[col]][is.na(clin_imputed[[col]])] <- mode_val
  3672. }
  3673. }
  3674. clin <- model.matrix(~ . - 1, data = clin_imputed)
  3675. lcross <- model.matrix(~ . - 1, data = lcross)
  3676. # Individual feature blocks.
  3677. X_clin <- clin
  3678. X_ctp <- ctp
  3679. X_cn <- cn
  3680. X_lcross <- lcross
  3681. X_spatial <- cbind(cn, lcross)
  3682. # Combination blocks.
  3683. X_clin_ctp <- cbind(clin, ctp)
  3684. X_clin_cn <- cbind(clin, cn)
  3685. X_clin_lcross <- cbind(clin, lcross)
  3686. X_clin_spatial <- cbind(clin, X_spatial)
  3687. X_ctp_spatial <- cbind(ctp, X_spatial)
  3688. X_clin_ctp_spatial <- cbind(clin, ctp, X_spatial)
  3689. feature_sets <- list(
  3690. "Clinical" = X_clin,
  3691. "Cell type proportions" = X_ctp,
  3692. "Spatial" = X_spatial,
  3693. "Clinical + Cell type proportions" = X_clin_ctp,
  3694. "Clinical + Spatial" = X_clin_spatial,
  3695. "Cell type proportions + Spatial" = X_ctp_spatial,
  3696. "Clinical + Cell type proportions + Spatial" = X_clin_ctp_spatial
  3697. )
  3698. # Cross-validated AUROC via elastic net.
  3699. library(MLmetrics)
  3700. # Custom CV summary: ROC/Sens/Spec plus PR-AUC.
  3701. my_summary <- function(data, lev = NULL, model = NULL) {
  3702. roc_stats <- twoClassSummary(data, lev, model)
  3703. pr_auc <- PRAUC(data[, lev[1]], ifelse(data$obs == lev[1], 1, 0))
  3704. c(roc_stats, PR_AUC = pr_auc)
  3705. }
  3706. set.seed(42)
  3707. cv_ctrl <- trainControl(
  3708. method = "repeatedcv",
  3709. number = 5,
  3710. repeats = 20,
  3711. classProbs = TRUE,
  3712. summaryFunction = my_summary,
  3713. savePredictions = "final"
  3714. )
  3715. # caret requires syntactically valid class labels.
  3716. levels(outcome) <- make.names(levels(outcome))
  3717. # Train a glmnet (elastic net) model on one feature block.
  3718. fit_model <- function(X, y, ctrl) {
  3719. X_scaled <- scale(X) |> as.data.frame() # center + scale
  3720. X_scaled[is.na(X_scaled)] <- 0 # impute residual NAs with 0
  3721. train(
  3722. x = X_scaled,
  3723. y = y,
  3724. method = "glmnet",
  3725. metric = "ROC",
  3726. trControl = ctrl,
  3727. tuneLength = 10
  3728. )
  3729. }
  3730. results <- map(feature_sets, \(X) fit_model(X, outcome, cv_ctrl))
  3731. # Extract AUROC (DeLong CI) and PR-AUC from the held-out CV predictions.
  3732. library(PRROC)
  3733. extract_metrics <- function(fit, name) {
  3734. preds <- fit$pred |>
  3735. filter(alpha == fit$bestTune$alpha, lambda == fit$bestTune$lambda)
  3736. roc_obj <- roc(preds$obs, preds[, levels(preds$obs)[1]], quiet = TRUE)
  3737. ci_roc <- ci.auc(roc_obj)
  3738. pos_scores <- preds[preds$obs == levels(preds$obs)[1], levels(preds$obs)[1]]
  3739. neg_scores <- preds[preds$obs == levels(preds$obs)[2], levels(preds$obs)[1]]
  3740. pr_obj <- pr.curve(scores.class0 = pos_scores, scores.class1 = neg_scores, curve = FALSE)
  3741. tibble(model = name,
  3742. AUC = as.numeric(auc(roc_obj)),
  3743. CI_low = ci_roc[1],
  3744. CI_high = ci_roc[3],
  3745. PR_AUC = pr_obj$auc.integral)
  3746. }
  3747. roc_df <- imap_dfr(results, extract_metrics) |> arrange(desc(AUC))
  3748. # AUROC bar chart with 95% CI.
  3749. roc_df |>
  3750. mutate(model = fct_reorder(model, AUC)) |>
  3751. ggplot(aes(x = AUC, y = model)) +
  3752. geom_col(aes(fill = AUC), width = 0.6, show.legend = FALSE) +
  3753. geom_errorbarh(aes(xmin = CI_low, xmax = CI_high), height = 0.25) +
  3754. geom_vline(xintercept = 0.5, linetype = "dashed", colour = "red") +
  3755. scale_fill_gradient(low = "#91bfdb", high = "#1a6dad") +
  3756. scale_x_continuous(limits = c(0, 1), expand = c(0, 0)) +
  3757. labs(title = "Predictive performance per feature set",
  3758. subtitle = "Elastic net, 5-fold CV x 10 repeats | 95 % CI",
  3759. x = "AUROC", y = NULL) +
  3760. theme_bw(base_size = 13)
  3761. # Overlaid ROC curves for all feature sets.
  3762. roc_curves <- imap(results, function(fit, name) {
  3763. preds <- fit$pred |>
  3764. filter(alpha == fit$bestTune$alpha, lambda == fit$bestTune$lambda)
  3765. roc(preds$obs, preds[, levels(outcome)[2]], quiet = TRUE)
  3766. })
  3767. library(pROC)
  3768. roc_plot_df <- imap_dfr(roc_curves, function(r, name) {
  3769. data.frame(model = name, specificity = r$specificities, sensitivity = r$sensitivities)
  3770. }) |>
  3771. mutate(fpr = 1 - specificity,

FinalFigures_public.Rmd at commit 86bf55d, no license · at the source

Overview

Authors: Stefanos Voglis1,2,3, Daniel Schulz1,2, Nils Eling1,2, Natalie De Souza1,2, Luca Regli3, Marian Christoph Neidert4, Marcus Czabanka5, Michael Weller6, Emilie Le Rhun7, Daniela Mihic-Probst8, Mitchell Levesque9, Bernd Bodenmiller1,2
  1. Department of Quantitative Biomedicine, University of Zurich, Zurich, Switzerland
  2. Institute of Molecular Health Sciences, ETH Zurich, Zurich, Switzerland
  3. Department of Neurosurgery, Clinical Neuroscience Center, University Hospital Zurich, University of Zurich, Zurich, Switzerland
  4. Department of Neurosurgery, HOCH Health Ostschweiz, Cantonal Hospital of St. Gallen, St. Gallen, Switzerland
  5. Department of Neurosurgery, University Hospital Frankfurt, Goethe University Frankfurt, Frankfurt, Germany
  6. Department of Neurology, Clinical Neuroscience Center, University Hospital Zurich, University of Zurich, Zurich, Switzerland
  7. Department of Medical Oncology and Hematology, University Hospital Zurich, University of Zurich, Zurich, Switzerland
  8. Department of Pathology and Molecular Pathology, University Hospital Zurich, University of Zurich, Zurich, Switzerland
  9. Department of Dermatology, University Hospital Zurich, University of Zurich, Zurich, Switzerland
Institutions: University of Zurich (Switzerland); ETH Zurich (Switzerland); University Hospital Zurich (Switzerland); Institute of Molecular Health Sciences (Switzerland); Kantonsspital St. Gallen (Switzerland); Goethe University Frankfurt (Germany); University Hospital Frankfurt (Germany)
Journal: Neuro-oncology, volume 28, issue 9, pages 2211-2223
Dates: received 20 January 2026; accepted 26 May 2026; published online 28 May 2026; in print September 2026
Type: Research article · Language: English
License: CC BY-NC
Identifiers: DOI 10.1093/neuonc/noag128 · PMID 42209753 · PMCID PMC13550667 · OpenAlex W7162786992
Open access: hybrid, a free copy (OpenAlex)
Status: code verified
Categories: genetics / omics (modality), human (organism), other condition (population), clinical / translational (subfield)
Methods: Statistics, Machine learning, Preprocessing, fMRI & imaging
Keywords: melanoma brain metastases, precision oncology, predictive biomarkers, proteomics, tumor microenvironment
MeSH: Brain Neoplasms*, Immune Checkpoint Inhibitors*, Melanoma*, Single-Cell Analysis*, Tumor Microenvironment*, Adult, Aged, Biomarkers, Tumor, Female, Humans, Male, Middle Aged, Prognosis (* major topic)
Topic: Cancer Immunotherapy and Biomarkers (Oncology, Medicine), according to OpenAlex
Funding: S.V. by the Swiss Academy of Medical Sciences/Gottfried and Julia Bangerter-Rhyner-Foundation (YTCR 58/20); the Swiss Academy of Medical Sciences/Gottfried; the Brihaye EANS Research Fund; Julia Bangerter-Rhyner-Foundation (58/20); “Stiftung Tumorforschung Kopf-Hals.”
Citations: not cited yet (Europe PMC); 44 references in the paper

Abstract

Background: A subset of patients with advanced-stage melanoma with brain metastases responds intracranially to immune-checkpoint inhibitors (ICIs). We reasoned that features of the spatial architecture of the tumor microenvironment correlate with treatment responses.

Methods: We used highly multiplexed single-cell imaging to characterize the TME in 44 samples of MBM patients; both treatment naïve subjects and subjects who had received ICI were included in the cohort. Samples were stained with a 42-plex metal-isotope tagged antibody panel and subsequently imaged using imaging mass cytometry. Downstream analysis focused on phenotypic and spatial characteristics of the tumor microenvironments of ICI responders and non-responders and on correlations of protein expression with overall and local progression-free survival.

Results: Single-cell phenotyping identified more than 1.1 million cells, predominantly tumor (67%) and immune cells (29%). ICI responders exhibited higher baseline proportions of B cells, IDO+ macrophages, and specific T cell subsets (regulatory and exhausted/activated CD8+). Conversely, neutrophil infiltration was negatively associated with intracranial response and survival. Spatial analysis revealed that responders possess a more localized, clustered immune cell pattern compared to the dispersed patterns seen in non-responders. Modeling CD8+ T cell distribution showed that localized infiltration was positively associated with overall and local progression-free survival, while dispersed patterns correlated with poorer outcomes. Furthermore, ICI-pretreated samples showed higher immune-tumor co-localization than treatment-naïve samples.

Conclusions: Our single-cell, spatially resolved characterization of the tumor microenvironment of human melanoma brain metastases demonstrated distinct phenotypic and spatial infiltration patterns of various immune cells that are associated with ICI response and survival outcomes.

Reproduced under the paper's license (CC BY-NC), from the paper cited above.

Repositories

Its files are read in the Code ↔ Paper reader above, with 18 matches between paragraphs and lines of code.

BodenmillerGroup/steinbock

License: MIT
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 848fc5ed547c5c98f25d69b29b75de9be9824f3c, 27 March 2026
Languages: Python (88), Shell (1)
Size: 153 files, 89 scripts
Software Heritage: not archived
Found in: the text, “Single-Cell Segmentation, Data Processing and Qu”
Holds: README, license file, environment (docker-compose.yml, Dockerfile, pyproject.toml, requirements-deepcell.txt, requirements-napari.txt, requirements.txt, requirements_devel.txt, requirements_docs.txt, requirements_test.txt, setup.cfg), tests, continuous integration, documentation
Not found: CITATION.cff
Tools: NumPy (27 files), pandas (13 files), scikit-image (4 files), SciPy (4 files), Keras (3 files), TensorFlow (3 files), tifffile (3 files), anndata (2 files), NetworkX (2 files), Cellpose (1 file), h5py (1 file), imageio (1 file), napari (1 file), OpenCV (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
91 files

StefanosVoglis/Voglis_et_al_2026_Neuro-Oncology

License: none: the authors keep all their rights
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 86bf55d7d4faebe861966172590a76694892d500, 11 June 2026
Languages: R (1)
Size: 1 file, 1 script
Software Heritage: not archived
Found in: “Data Availability”
Holds: 1 notebook
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: caret (1 file), circlize (1 file), ComplexHeatmap (1 file), cowplot (1 file), data.table (1 file), easystats (1 file), edgeR (1 file), ggplot2 (1 file), glmnet (1 file), lme4 (1 file), patchwork (1 file), pheatmap (1 file), pROC (1 file), reshape2 (1 file), rstatix (1 file), survival (1 file), tidyverse (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
1 file

The paper's code and data availability statement is in the Data section.

Tracing map

Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.

What the map holds:

  • 2 repositories of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
  • 90 scripts, each with its path and the digest of its content;
  • 18 matches between paragraphs of the paper and lines of the code (method lexical-v1);
  • neither the text of the paper nor the code itself.

Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.

Data

Datasets cited

Data Availability

Code used for data analysis and figure/table generation will be made publicly available upon publication via a GitHub repository (https://github.com/StefanosVoglis/Voglis_et_al_2026_Neuro-Oncology). Processed data is available under https://doi.org/10.5281/zenodo.20539275.

Reproduced under the paper's license (CC BY-NC), from the paper cited above.

Versions

The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.

Version 1, 27 September 2026: the first record

Recorded: type, language, journal, volume, issue, pages, dates, 12 authors, 5 keywords, 13 MeSH terms, 5 funders, 42 references.

Cite

This paper

Voglis, S., Schulz, D., Eling, N., De Souza, N., Regli, L., Neidert, M. C., Czabanka, M., Weller, M., Le Rhun, E., Mihic-Probst, D., Levesque, M., & Bodenmiller, B. (2026). Spatially-resolved single-cell imaging of melanoma brain metastases identifies localized immune patterns predictive of immune checkpoint blockade response. Neuro-oncology, 28(9), 2211-2223. https://doi.org/10.1093/neuonc/noag128

BibTeX

@article{voglis2026spatially,
author = {Voglis, Stefanos and Schulz, Daniel and Eling, Nils and De Souza, Natalie and Regli, Luca and Neidert, Marian Christoph and Czabanka, Marcus and Weller, Michael and Le Rhun, Emilie and Mihic-Probst, Daniela and Levesque, Mitchell and Bodenmiller, Bernd},
title = {{Spatially-resolved single-cell imaging of melanoma brain metastases identifies localized immune patterns predictive of immune checkpoint blockade response}},
journal = {Neuro-oncology},
year = {2026},
month = sep,
volume = {28},
number = {9},
pages = {2211--2223},
publisher = {Oxford University Press},
issn = {1522-8517},
doi = {10.1093/neuonc/noag128},
url = {https://doi.org/10.1093/neuonc/noag128},
pmid = {42209753},
pmcid = {PMC13550667}
}

RIS

TY - JOUR
AU - Voglis, Stefanos
AU - Schulz, Daniel
AU - Eling, Nils
AU - De Souza, Natalie
AU - Regli, Luca
AU - Neidert, Marian Christoph
AU - Czabanka, Marcus
AU - Weller, Michael
AU - Le Rhun, Emilie
AU - Mihic-Probst, Daniela
AU - Levesque, Mitchell
AU - Bodenmiller, Bernd
TI - Spatially-resolved single-cell imaging of melanoma brain metastases identifies localized immune patterns predictive of immune checkpoint blockade response
T2 - Neuro-oncology
J2 - Neuro Oncol
PY - 2026
DA - 2026/09/01
VL - 28
IS - 9
SP - 2211
EP - 2223
SN - 1522-8517
PB - Oxford University Press
DO - 10.1093/neuonc/noag128
UR - https://doi.org/10.1093/neuonc/noag128
LA - en
ER -

CSL-JSON

{
"id": "10.1093/neuonc/noag128",
"type": "article-journal",
"title": "Spatially-resolved single-cell imaging of melanoma brain metastases identifies localized immune patterns predictive of immune checkpoint blockade response",
"container-title": "Neuro-oncology",
"author": [
{
"family": "Voglis",
"given": "Stefanos"
},
{
"family": "Schulz",
"given": "Daniel"
},
{
"family": "Eling",
"given": "Nils"
},
{
"family": "De Souza",
"given": "Natalie"
},
{
"family": "Regli",
"given": "Luca"
},
{
"family": "Neidert",
"given": "Marian Christoph"
},
{
"family": "Czabanka",
"given": "Marcus"
},
{
"family": "Weller",
"given": "Michael"
},
{
"family": "Le Rhun",
"given": "Emilie"
},
{
"family": "Mihic-Probst",
"given": "Daniela"
},
{
"family": "Levesque",
"given": "Mitchell"
},
{
"family": "Bodenmiller",
"given": "Bernd"
}
],
"container-title-short": "Neuro Oncol",
"volume": "28",
"issue": "9",
"page": "2211-2223",
"DOI": "10.1093/neuonc/noag128",
"PMID": "42209753",
"PMCID": "PMC13550667",
"ISSN": "1522-8517",
"publisher": "Oxford University Press",
"URL": "https://doi.org/10.1093/neuonc/noag128",
"language": "en",
"issued": {
"date-parts": [
[
2026,
9,
1
]
]
}
}

The tracing map gets a citation of its own once an author has validated it and it has a DOI.

Similar papers

The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.

[1] doi:10.1016/j.xcrm.2026.102766 [code]
A longitudinal single-cell and spatial multiomic atlas of pediatric high-grade glioma.
Journal: Cell reports. Medicine
In common: pROC, imageio, edgeR, 21 other tools, genetics / omics, other condition, 2 references
[2] doi:10.1038/s42003-026-10957-8 [code]
Brain defence by the extracellular matrix protein Cochlin.
Journal: Communications biology
In common: glmnet, survival, caret, 18 other tools
[3] doi:10.1093/bioinformatics/btag592 [code]
Network-based stratification of allele-specific expression reveals patient subgroups in Huntington's disease.
Journal: Bioinformatics (Oxford, England)
In common: pROC, survival, edgeR, 15 other tools, genetics / omics, other condition
[4] doi:10.1016/j.isci.2026.115657 [code]
Integration of machine learning to develop a disulfidptosis model for predicting glioma prognosis, immunotherapy response, and drug.
Journal: iScience
In common: glmnet, pROC, survival, 13 other tools, clinical / translational, other condition
[5] doi:10.1016/j.xcrm.2026.102682 [code]
TET CpG sequence-context-specific DNA demethylation shapes progression of IDH-mutant gliomas.
Journal: Cell reports. Medicine
In common: glmnet, pROC, survival, 13 other tools, genetics / omics, other condition
[6] doi:10.3390/ijms27104466 [code]
Uncovering the Key Circuit FOSL2/FOS/EGR3/EGR1, Contributing to the Hyperexcitability of Excitatory Neurons in the Epileptic Temporal Cortex and Hippocampus.
Journal: International journal of molecular sciences
In common: glmnet, pROC, caret, 12 other tools, genetics / omics
[7] doi:10.1002/imt2.70163 [code]
Spatial multi-omics unveils sphingolipid metabolic reprogramming within the retinal pathological niche.
Journal: iMeta
In common: edgeR, rstatix, anndata, 14 other tools, genetics / omics
[8] doi:10.1073/pnas.2609132123 [code]
A human lysosomal storage disorder toolkit for decoding proteome landscapes in cortical-like and dopaminergic-like induced neurons.
Journal: Proceedings of the National Academy of Sciences of the United States of America
In common: Cellpose, tifffile, rstatix, 13 other tools, genetics / omics
[9] doi:10.1371/journal.pcbi.1014571 [code]
SynAPSeg: A novel dataset and image analysis framework for deep learning-based synapse detection and quantification.
Journal: PLoS computational biology
In common: Cellpose, napari, imageio, 9 other tools, 1 reference
[10] doi:10.1016/j.isci.2026.116439 [code]
Decoding the role of transcriptomic clocks in the human prefrontal cortex.
Journal: iScience
In common: glmnet, Keras, circlize, 12 other tools, genetics / omics

Contribute

The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.

Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.

Request its removal

To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).

Discussion, reproductions, activity

Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.

Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.

Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.