OSCR

Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice.

Code ↔ Paper

20 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 20 matches
  1. [1] § Results › Numb+Mg is associated with a late pseudotemporal branch and is temporally restricted during postnatal development ↔ Code for lps.R, lines 4109–4188 · score 0.91 · tumor necrosis factor, synapse pruning, ATP metabolism, immune effector, cell cycle, lymphocyte
  2. [2] § Methods › Pathway enrichment analysis ↔ Code for lps.R, lines 2489–2532 · score 0.80 · clusterProfiler, Entrez identifiers, Gene symbols, Gene Ontology, biological process, enrichment
  3. [3] § Methods › Dimensionality reduction and clustering ↔ Code for lps.R, lines 1147–1210 · score 0.79 · shared nearest neighbor, FindClusters, FindNeighbors, UMAP, resolution, graph
  4. [4] § Methods › Pathway enrichment analysis ↔ scripts/06_GO_enrichment_multi_analysis.R, lines 726–787 · score 0.68 · clusterProfiler, Entrez identifiers, Gene symbols, bitr, enrichment
  5. [5] § Results › A time-resolved single-cell atlas maps the physiological remodeling of microglial states during early postnatal development ↔ scripts/01_load_and_merge_scRNA_data.R, lines 1–46 · score 0.65 · brain immune cells, quality control, neonatal mice, single cell
  6. [6] § Methods › Dimensionality reduction and clustering ↔ scripts/03_integration_and_clustering.R, lines 455–526 · score 0.65 · ElbowPlot, RunPCA, Principal component, clustering
  7. [7] § Methods › Gene set enrichment analysis (GSEA) and functional scoring ↔ scripts/06_GO_enrichment_multi_analysis.R, lines 726–787 · score 0.64 · clusterProfiler, Entrez identifiers, Gene symbols, enrichment
  8. [8] § Methods › Gene set enrichment analysis (GSEA) and functional scoring ↔ Code for lps.R, lines 2489–2532 · score 0.64 · clusterProfiler, Entrez identifiers, Gene symbols, enrichment
  9. [9] § Results › APIM2 diversifies along an inflammatory axis while maintaining glycolytic features ↔ Code for lps.R, lines 4109–4188 · score 0.64 · chromosome segregation, ATP metabolism, production, signaling, positioned, LPS
  10. [10] § Methods › Dimensionality reduction and clustering ↔ scripts/03_integration_and_clustering.R, lines 1–40 · score 0.64 · shared nearest neighbor, UMAP, Seurat, resolution, graph, Dimensionality
  11. [11] § Methods › Dimensionality reduction and clustering ↔ Code for lps.R, lines 958–1002 · score 0.64 · ElbowPlot, RunPCA, Principal component
  12. [12] § Results › APIM2 diversifies along an inflammatory axis while maintaining glycolytic features ↔ Code for lps.R, lines 4295–4340 · score 0.63 · metabolic fluxes, metabolic module, scFEA, min max, post, LPS
  13. [13] § Methods › Single-cell metabolic flux analysis ↔ Code for lps.R, lines 4295–4340 · score 0.60 · cell metabolic flux, scFEA, Seurat, matrices, modules, gene
  14. [14] § Results › APIM2 marks an early glycolytic remodeling phase preceding inflammation-induced Numb+Mg attenuation ↔ Code for lps.R, lines 4343–4395 · score 0.58 · Inf_BAM, Cd11c, APIM1, APIM2, Mg2, PAM
  15. [15] § Results › A time-resolved single-cell atlas maps the physiological remodeling of microglial states during early postnatal development ↔ Code for lps.R, lines 2–42 · score 0.56 · brain immune cells, neonatal mice, single cell, LPS
  16. [16] § Methods › Trajectory inference and pseudotime analysis ↔ scripts/08a_monocle3_trajectory.R, lines 389–442 · score 0.55 · learn_graph, Monocle3, trajectories, nodes, Pseudotime, Root
  17. [17] § Methods › Cross-dataset comparison with an external developmental microglial dataset ↔ scripts/11_cross_dataset_integration.R, lines 1–50 · score 0.53 · reciprocal PCA, Cross, UMAP, embedding, Seurat, Transfer
  18. [18] § Methods › Gene set enrichment analysis (GSEA) and functional scoring ↔ scripts/07_UCell_pathway_scoring_multi_analysis.R, lines 1052–1110 · score 0.53 · AddModuleScore_UCell, UCell scoring, gene
  19. [19] § Methods › Integration and quality control ↔ scripts/02_quality_control_final.R, lines 991–1076 · score 0.53 · mitochondrial transcript, detected genes, quality, batch, cell
  20. [20] § Methods › Cross-dataset comparison with an external developmental microglial dataset ↔ Code for lps.R, lines 7404–7460 · score 0.50 · reciprocal PCA, UMAP, Transfer, Cross, microglial

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 · 8,494 lines · 176 KB · no license · 11 matches

  1. ##############################################
  2. ### Description: Single-cell RNA-seq analysis pipeline
  3. ### Author:Jinjin zhu
  4. ##############################################
  5. ### Load required packages
  6. library(Seurat)
  7. library(dplyr)
  8. library(ggplot2)
  9. library(patchwork)
  10. library(RColorBrewer)
  11. library(ggsci)
  12. library(gplots)
  13. ###-----------------------------
  14. ### # 1. Load count matrices and construct Seurat objects #
  15. # Description: # This script loads neonatal mouse brain immune-cell datasets
  16. #from preconstructed # Seurat objects or feature–barcode count matrices,
  17. #adds sample metadata, and
  18. # assigns unique cell barcodes before downstream integration.
  19. ###-----------------------------
  20. # (1) Load packages
  21. # ==============================================================================
  22. # Load raw single-cell RNA-seq data and construct Seurat objects
  23. #
  24. # Description:
  25. # This script loads neonatal mouse brain immune-cell datasets from Seurat RDS
  26. # files or 10X Genomics filtered feature-barcode matrices, adds sample metadata,
  27. # and assigns unique cell barcodes before downstream integration.
  28. # ==============================================================================
  29. # 1 Load packages -------------------------------------------------------------
  30. suppressPackageStartupMessages({
  31. library(Seurat)
  32. library(dplyr)
  33. })
  34. # 2. Define project directories ------------------------------------------------
  35. rds_dir <- "DATA/INPUT/RDS"
  36. matrix_dir <- "DATA/INPUT/10X"
  37. output_dir <- "DATA/OUTPUT"
  38. dir.create(
  39. output_dir,
  40. recursive = TRUE,
  41. showWarnings = FALSE
  42. )
  43. # 3. Define sample information -------------------------------------------------
  44. sample_information <- data.frame(
  45. sample_id = c(
  46. "LPS_P3_1",
  47. "LPS_P3_2",
  48. "NS_P3_1",
  49. "NS_P3_2",
  50. "NS_P3_3",
  51. "LPS_P7_1",
  52. "LPS_P7_2",
  53. "NS_P7_1",
  54. "NS_P7_2",
  55. "LPS_P12_1",
  56. "LPS_P12_2",
  57. "NS_P12_1",
  58. "NS_P12_2"
  59. ),
  60. treatment = c(
  61. "LPS", "LPS", "NS", "NS","NS",
  62. "LPS", "LPS", "NS", "NS",
  63. "LPS", "LPS", "NS", "NS"
  64. ),
  65. age = c(
  66. "P3", "P3", "P3", "P3","P3",
  67. "P7", "P7", "P7", "P7",
  68. "P12", "P12", "P12", "P12"
  69. ),
  70. replicate = c(
  71. 1, 2, 1, 2,
  72. 1, 2, 1, 2,
  73. 1, 2, 1, 2
  74. ),
  75. input_type = c(
  76. "rds", "rds", "rds", "rds",
  77. "10x", "10x", "10x", "10x",
  78. "10x", "10x", "10x", "10x"
  79. ),
  80. input_path = c(
  81. file.path(rds_dir, "5L_1_seurat.rds"),
  82. file.path(rds_dir, "5L_2_seurat.rds"),
  83. file.path(rds_dir, "NS_1_seurat.rds"),
  84. file.path(rds_dir, "NS_2_seurat.rds"),
  85. file.path(
  86. matrix_dir,
  87. "2308154_LPS_1_P7",
  88. "filtered_feature_bc_matrix"
  89. ),
  90. file.path(
  91. matrix_dir,
  92. "2308154_LPS_2_P7",
  93. "filtered_feature_bc_matrix"
  94. ),
  95. file.path(
  96. matrix_dir,
  97. "2308154_NS_1_P7",
  98. "filtered_feature_bc_matrix"
  99. ),
  100. file.path(
  101. matrix_dir,
  102. "2308154_NS_2_P7",
  103. "filtered_feature_bc_matrix"
  104. ),
  105. file.path(
  106. matrix_dir,
  107. "2308153_5LPS_1_P12",
  108. "filtered_feature_bc_matrix"
  109. ),
  110. file.path(
  111. matrix_dir,
  112. "2308153_5LPS_2_P12",
  113. "filtered_feature_bc_matrix"
  114. ),
  115. file.path(
  116. matrix_dir,
  117. "2308153_NS_1_P12",
  118. "filtered_feature_bc_matrix"
  119. ),
  120. file.path(
  121. matrix_dir,
  122. "2308153_NS_2_P12",
  123. "filtered_feature_bc_matrix"
  124. )
  125. ),
  126. stringsAsFactors = FALSE
  127. )
  128. # 4. Function for loading one sample -------------------------------------------
  129. load_single_sample <- function(
  130. sample_id,
  131. treatment,
  132. age,
  133. replicate,
  134. input_type,
  135. input_path,
  136. min_cells = 3,
  137. min_features = 200
  138. ) {
  139. if (!file.exists(input_path)) {
  140. stop(
  141. paste0(
  142. "Input file or directory not found: ",
  143. input_path
  144. )
  145. )
  146. }
  147. if (input_type == "rds") {
  148. seurat_object <- readRDS(input_path)
  149. if (!inherits(seurat_object, "Seurat")) {
  150. stop(
  151. paste0(
  152. "The RDS file does not contain a Seurat object: ",
  153. input_path
  154. )
  155. )
  156. }
  157. } else if (input_type == "10x") {
  158. count_matrix <- Read10X(
  159. data.dir = input_path
  160. )
  161. # If Read10X returns several assays, retain the gene-expression matrix.
  162. if (is.list(count_matrix)) {
  163. if ("Gene Expression" %in% names(count_matrix)) {
  164. count_matrix <- count_matrix[["Gene Expression"]]
  165. } else {
  166. count_matrix <- count_matrix[[1]]
  167. }
  168. }
  169. seurat_object <- CreateSeuratObject(
  170. counts = count_matrix,
  171. project = sample_id,
  172. min.cells = min_cells,
  173. min.features = min_features
  174. )
  175. } else {
  176. stop(
  177. paste0(
  178. "Unsupported input type for ",
  179. sample_id,
  180. ": ",
  181. input_type
  182. )
  183. )
  184. }
  185. # Add a unique sample prefix to every cell barcode.
  186. seurat_object <- RenameCells(
  187. object = seurat_object,
  188. add.cell.id = sample_id
  189. )
  190. # Add sample-level metadata.
  191. seurat_object$sample_id <- sample_id
  192. seurat_object$treatment <- treatment
  193. seurat_object$age <- age
  194. seurat_object$replicate <- replicate
  195. seurat_object$group <- paste(
  196. treatment,
  197. age,
  198. sep = "_"
  199. )
  200. return(seurat_object)
  201. }
  202. # 5. Load all samples -----------------------------------------------------------
  203. seurat_list <- lapply(
  204. seq_len(nrow(sample_information)),
  205. function(i) {
  206. load_single_sample(
  207. sample_id = sample_information$sample_id[i],
  208. treatment = sample_information$treatment[i],
  209. age = sample_information$age[i],
  210. replicate = sample_information$replicate[i],
  211. input_type = sample_information$input_type[i],
  212. input_path = sample_information$input_path[i]
  213. )
  214. }
  215. )
  216. names(seurat_list) <- sample_information$sample_id
  217. # 6. Check the loaded datasets -------------------------------------------------
  218. sample_summary <- do.call(
  219. rbind,
  220. lapply(
  221. names(seurat_list),
  222. function(sample_name) {
  223. current_object <- seurat_list[[sample_name]]
  224. data.frame(
  225. sample_id = sample_name,
  226. cells = ncol(current_object),
  227. genes = nrow(current_object),
  228. treatment = unique(current_object$treatment),
  229. age = unique(current_object$age),
  230. stringsAsFactors = FALSE
  231. )
  232. }
  233. )
  234. )
  235. print(sample_summary)
  236. write.csv(
  237. sample_summary,
  238. file = file.path(
  239. output_dir,
  240. "sample_loading_summary.csv"
  241. ),
  242. row.names = FALSE
  243. )
  244. # 7. Merge samples --------------------------------------------------------------
  245. scRNA_raw <- merge(
  246. x = seurat_list[[1]],
  247. y = seurat_list[-1],
  248. project = "Neonatal_brain_immune_cells"
  249. )
  250. print(
  251. table(
  252. scRNA_raw$age,
  253. scRNA_raw$treatment
  254. )
  255. )
  256. print(
  257. table(
  258. scRNA_raw$sample_id
  259. )
  260. )
  261. # 8. Save the merged raw Seurat object -----------------------------------------
  262. saveRDS(
  263. scRNA_raw,
  264. file = file.path(
  265. output_dir,
  266. "merged_raw_Seurat_object.rds"
  267. )
  268. )
  269. writeLines(
  270. capture.output(sessionInfo()),
  271. con = file.path(
  272. output_dir,
  273. "Seurat_data_loading_sessionInfo.txt"
  274. )
  275. )
  276. # ==============================================================================
  277. # (2) Merge and quality control of single-cell RNA-seq data
  278. #
  279. # Description:
  280. # This script rebuilds a clean Seurat object from the merged raw RNA-count
  281. # matrix, calculates mitochondrial, ribosomal and haemoglobin transcript
  282. # percentages, applies the prespecified cell-level QC thresholds, assigns batch
  283. # labels, exports QC summaries and saves the filtered Seurat object.
  284. # ==============================================================================
  285. # 1. Load packages -------------------------------------------------------------
  286. suppressPackageStartupMessages({
  287. library(Seurat)
  288. library(dplyr)
  289. library(tibble)
  290. library(ggplot2)
  291. library(patchwork)
  292. })
  293. # 2. Define paths ---------------------------------------------------------------
  294. input_file <- "DATA/OUTPUT/merged_raw_Seurat_object.rds"
  295. output_data_dir <- "DATA/OUTPUT"
  296. output_qc_dir <- "QC"
  297. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  298. dir.create(output_qc_dir, recursive = TRUE, showWarnings = FALSE)
  299. # 3. Define QC thresholds -------------------------------------------------------
  300. min_features <- 500
  301. max_features <- 3000
  302. max_counts <- 22000
  303. max_percent_mt <- 10
  304. # 4. Load merged raw Seurat object ---------------------------------------------
  305. merged_seurat <- readRDS(input_file)
  306. if (!inherits(merged_seurat, "Seurat")) {
  307. stop("The input file must contain a Seurat object.")
  308. }
  309. if (!"RNA" %in% names(merged_seurat@assays)) {
  310. stop("The input Seurat object does not contain an RNA assay.")
  311. }
  312. if (!"orig.ident" %in% colnames([email hidden])) {
  313. stop("The metadata must contain a column named 'orig.ident'.")
  314. }
  315. # 5. Rebuild a clean Seurat object from raw RNA counts -------------------------
  316. # Compatible with Seurat v5 and Seurat v4.
  317. rna_counts <- tryCatch(
  318. GetAssayData(
  319. object = merged_seurat,
  320. assay = "RNA",
  321. layer = "counts"
  322. ),
  323. error = function(e) {
  324. GetAssayData(
  325. object = merged_seurat,
  326. assay = "RNA",
  327. slot = "counts"
  328. )
  329. }
  330. )
  331. # Retain sample-level metadata, but remove assay-derived QC columns that
  332. # CreateSeuratObject recalculates from the raw count matrix.
  333. cell_metadata <- [email hidden][
  334. colnames(rna_counts),
  335. ,
  336. drop = FALSE
  337. ]
  338. recalculated_metadata_columns <- intersect(
  339. c(
  340. "nCount_RNA",
  341. "nFeature_RNA",
  342. "percent.mt",
  343. "percent.rb",
  344. "percent.HB"
  345. ),
  346. colnames(cell_metadata)
  347. )
  348. cell_metadata <- cell_metadata[
  349. ,
  350. setdiff(
  351. colnames(cell_metadata),
  352. recalculated_metadata_columns
  353. ),
  354. drop = FALSE
  355. ]
  356. scRNA <- CreateSeuratObject(
  357. counts = rna_counts,
  358. project = "Neonatal_brain_immune_cells",
  359. meta.data = cell_metadata,
  360. min.cells = 0,
  361. min.features = 0
  362. )
  363. rm(merged_seurat, rna_counts, cell_metadata)
  364. # 6. Calculate QC metrics -------------------------------------------------------
  365. # Percentage of transcripts mapped to mouse mitochondrial genes.
  366. scRNA[["percent.mt"]] <- PercentageFeatureSet(
  367. object = scRNA,
  368. pattern = "^mt-"
  369. )
  370. # Percentage of transcripts mapped to ribosomal protein genes.
  371. scRNA[["percent.rb"]] <- PercentageFeatureSet(
  372. object = scRNA,
  373. pattern = "^Rp[sl]"
  374. )
  375. # Percentage of transcripts mapped to haemoglobin genes.
  376. hb_genes <- c(
  377. "Hba-a1",
  378. "Hba-a2",
  379. "Hbb-b1",
  380. "Hbb-b2",
  381. "Hbe1",
  382. "Hbg1",
  383. "Hbg2",
  384. "Hbm",
  385. "Hbq1",
  386. "Hbz"
  387. )
  388. hb_genes_present <- CaseMatch(
  389. search = hb_genes,
  390. match = rownames(scRNA)
  391. )
  392. if (length(hb_genes_present) == 0) {
  393. warning("None of the predefined haemoglobin genes were found.")
  394. scRNA$percent.HB <- 0
  395. } else {
  396. scRNA[["percent.HB"]] <- PercentageFeatureSet(
  397. object = scRNA,
  398. features = hb_genes_present
  399. )
  400. }
  401. # 7. Assign experimental batches -----------------------------------------------
  402. sample_to_batch <- c(
  403. "NS" = "batch1",
  404. "Lps" = "batch1",
  405. "Lps_5_1" = "batch2",
  406. "Lps_5_2" = "batch2",
  407. "NS_1" = "batch2",
  408. "NS_2" = "batch2",
  409. "NS_P12_1" = "batch3",
  410. "NS_P12_2" = "batch3",
  411. "LPS_5_LPS_P12_1" = "batch3",
  412. "LPS_5_LPS_P12_2" = "batch3",
  413. "NS_P7_1" = "batch4",
  414. "NS_P7_2" = "batch4",
  415. "LPS_5_P7_1" = "batch4",
  416. "LPS_5_P7_2" = "batch4"
  417. )
  418. sample_ids <- as.character(scRNA$orig.ident)
  419. unmatched_samples <- setdiff(
  420. unique(sample_ids),
  421. names(sample_to_batch)
  422. )
  423. if (length(unmatched_samples) > 0) {
  424. stop(
  425. paste0(
  426. "The following orig.ident values have no batch assignment: ",
  427. paste(unmatched_samples, collapse = ", ")
  428. )
  429. )
  430. }
  431. scRNA$batch <- unname(
  432. sample_to_batch[sample_ids]
  433. )
  434. # 8. QC-summary function --------------------------------------------------------
  435. summarise_qc <- function(object, stage) {
  436. [email hidden] |>
  437. rownames_to_column("cell_barcode") |>
  438. group_by(orig.ident) |>
  439. summarise(
  440. stage = stage,
  441. n_cells = n(),
  442. median_nFeature_RNA = median(nFeature_RNA, na.rm = TRUE),
  443. q1_nFeature_RNA = quantile(nFeature_RNA, 0.25, na.rm = TRUE),
  444. q3_nFeature_RNA = quantile(nFeature_RNA, 0.75, na.rm = TRUE),
  445. median_nCount_RNA = median(nCount_RNA, na.rm = TRUE),
  446. q1_nCount_RNA = quantile(nCount_RNA, 0.25, na.rm = TRUE),
  447. q3_nCount_RNA = quantile(nCount_RNA, 0.75, na.rm = TRUE),
  448. median_percent_mt = median(percent.mt, na.rm = TRUE),
  449. q1_percent_mt = quantile(percent.mt, 0.25, na.rm = TRUE),
  450. q3_percent_mt = quantile(percent.mt, 0.75, na.rm = TRUE),
  451. median_percent_rb = median(percent.rb, na.rm = TRUE),
  452. median_percent_HB = median(percent.HB, na.rm = TRUE),
  453. .groups = "drop"
  454. ) |>
  455. relocate(stage, orig.ident)
  456. }
  457. # 9. Export and visualize pre-QC metrics ----------------------------------------
  458. qc_summary_pre <- summarise_qc(
  459. object = scRNA,
  460. stage = "Pre-QC"
  461. )
  462. qc_features <- c(
  463. "nFeature_RNA",
  464. "nCount_RNA",
  465. "percent.mt",
  466. "percent.rb",
  467. "percent.HB"
  468. )
  469. qc_plots_pre <- lapply(
  470. qc_features,
  471. function(feature_name) {
  472. VlnPlot(
  473. object = scRNA,
  474. group.by = "orig.ident",
  475. features = feature_name,
  476. pt.size = 0,
  477. raster = FALSE
  478. ) +
  479. NoLegend() +
  480. ggtitle(feature_name) +
  481. theme(
  482. axis.text.x = element_text(
  483. angle = 45,
  484. hjust = 1
  485. )
  486. )
  487. }
  488. )
  489. ggsave(
  490. filename = file.path(
  491. output_qc_dir,
  492. "QC_metrics_before_filtering.pdf"
  493. ),
  494. plot = wrap_plots(qc_plots_pre, nrow = 3),
  495. width = 16,
  496. height = 8,
  497. units = "in"
  498. )
  499. # 10. Visualize the prespecified QC thresholds ---------------------------------
  500. p_feature_threshold <- VlnPlot(
  501. scRNA,
  502. features = "nFeature_RNA",
  503. pt.size = 0,
  504. raster = FALSE
  505. ) +
  506. geom_hline(
  507. yintercept = c(min_features, max_features),
  508. linetype = "dashed"
  509. )
  510. p_count_threshold <- VlnPlot(
  511. scRNA,
  512. features = "nCount_RNA",
  513. pt.size = 0,
  514. raster = FALSE
  515. ) +
  516. geom_hline(
  517. yintercept = max_counts,
  518. linetype = "dashed"
  519. )
  520. p_mt_threshold <- VlnPlot(
  521. scRNA,
  522. features = "percent.mt",
  523. pt.size = 0,
  524. raster = FALSE
  525. ) +
  526. geom_hline(
  527. yintercept = max_percent_mt,
  528. linetype = "dashed"
  529. )
  530. ggsave(
  531. filename = file.path(
  532. output_qc_dir,
  533. "QC_filtering_thresholds.pdf"
  534. ),
  535. plot = p_feature_threshold +
  536. p_count_threshold +
  537. p_mt_threshold,
  538. width = 12,
  539. height = 4,
  540. units = "in"
  541. )
  542. # 11. Apply cell-level QC thresholds -------------------------------------------
  543. cells_before_qc <- table(scRNA$orig.ident)
  544. scRNA <- subset(
  545. x = scRNA,
  546. subset =
  547. nFeature_RNA > min_features &
  548. nFeature_RNA < max_features &
  549. nCount_RNA < max_counts &
  550. percent.mt < max_percent_mt
  551. )
  552. cells_after_qc <- table(scRNA$orig.ident)
  553. # 12. Export cell-retention summary --------------------------------------------
  554. all_samples <- union(
  555. names(cells_before_qc),
  556. names(cells_after_qc)
  557. )
  558. cell_retention <- data.frame(
  559. orig.ident = all_samples,
  560. cells_before_qc = as.integer(
  561. cells_before_qc[all_samples]
  562. ),
  563. cells_after_qc = as.integer(
  564. cells_after_qc[all_samples]
  565. ),
  566. stringsAsFactors = FALSE
  567. )
  568. cell_retention$cells_before_qc[
  569. is.na(cell_retention$cells_before_qc)
  570. ] <- 0
  571. cell_retention$cells_after_qc[
  572. is.na(cell_retention$cells_after_qc)
  573. ] <- 0
  574. cell_retention$retention_percent <- with(
  575. cell_retention,
  576. ifelse(
  577. cells_before_qc > 0,
  578. 100 * cells_after_qc / cells_before_qc,
  579. NA_real_
  580. )
  581. )
  582. write.csv(
  583. cell_retention,
  584. file = file.path(
  585. output_qc_dir,
  586. "QC_cell_retention_by_sample.csv"
  587. ),
  588. row.names = FALSE
  589. )
  590. # 13. Export and visualize post-QC metrics --------------------------------------
  591. qc_summary_post <- summarise_qc(
  592. object = scRNA,
  593. stage = "Post-QC"
  594. )
  595. qc_summary <- bind_rows(
  596. qc_summary_pre,
  597. qc_summary_post
  598. )
  599. write.csv(
  600. qc_summary,
  601. file = file.path(
  602. output_qc_dir,
  603. "QC_metric_summary_by_sample.csv"
  604. ),
  605. row.names = FALSE
  606. )
  607. qc_plots_post <- lapply(
  608. qc_features,
  609. function(feature_name) {
  610. VlnPlot(
  611. object = scRNA,
  612. group.by = "orig.ident",
  613. features = feature_name,
  614. pt.size = 0,
  615. raster = FALSE
  616. ) +
  617. NoLegend() +
  618. ggtitle(
  619. paste0(
  620. feature_name,
  621. " (Post-QC)"
  622. )
  623. ) +
  624. theme(
  625. axis.text.x = element_text(
  626. angle = 45,
  627. hjust = 1
  628. )
  629. )
  630. }
  631. )
  632. ggsave(
  633. filename = file.path(
  634. output_qc_dir,
  635. "QC_metrics_after_filtering.pdf"
  636. ),
  637. plot = wrap_plots(qc_plots_post, nrow = 3),
  638. width = 16,
  639. height = 8,
  640. units = "in"
  641. )
  642. # 14. Plot nCount_RNA versus nFeature_RNA --------------------------------------
  643. p_feature_scatter <- FeatureScatter(
  644. object = scRNA,
  645. feature1 = "nCount_RNA",
  646. feature2 = "nFeature_RNA"
  647. )
  648. ggsave(
  649. filename = file.path(
  650. output_qc_dir,
  651. "QC_nCount_vs_nFeature_after_filtering.pdf"
  652. ),
  653. plot = p_feature_scatter,
  654. width = 6,
  655. height = 5,
  656. units = "in"
  657. )
  658. # 15. Save the filtered object and analysis information ------------------------
  659. saveRDS(
  660. scRNA,
  661. file = file.path(
  662. output_data_dir,
  663. "scRNA_after_QC.rds"
  664. )
  665. )
  666. qc_parameters <- c(
  667. paste0("Minimum detected genes per cell: > ", min_features),
  668. paste0("Maximum detected genes per cell: < ", max_features),
  669. paste0("Maximum UMI count per cell: < ", max_counts),
  670. paste0("Maximum mitochondrial transcript percentage: < ", max_percent_mt),
  671. "Ribosomal and haemoglobin transcript percentages were calculated for QC visualization but were not used as filtering criteria."
  672. )
  673. writeLines(
  674. qc_parameters,
  675. con = file.path(
  676. output_qc_dir,
  677. "QC_filtering_parameters.txt"
  678. )
  679. )
  680. writeLines(
  681. capture.output(sessionInfo()),
  682. con = file.path(
  683. output_qc_dir,
  684. "QC_sessionInfo.txt"
  685. )
  686. )
  687. # ==============================================================================
  688. # (3) Dimensionality reduction, batch integration and clustering
  689. #
  690. # Description:
  691. # This script performs SCTransform normalization, PCA, UMAP and graph-based
  692. # clustering on the QC-filtered Seurat object. Two parallel analysis branches
  693. # are generated:
  694. # 1) Harmony batch integration using the metadata column "batch"
  695. # 2) No batch correction, using PCA directly
  696. #
  697. # Software used in the original analysis:
  698. # Seurat 4.2.1
  699. # dplyr 1.1.1
  700. # harmony 0.1.1
  701. # ==============================================================================
  702. # 1. Load packages -------------------------------------------------------------
  703. suppressPackageStartupMessages({
  704. library(Seurat)
  705. library(dplyr)
  706. library(harmony)
  707. library(ggplot2)
  708. library(patchwork)
  709. })
  710. set.seed(1234)
  711. # 2. Define input and output paths ---------------------------------------------
  712. input_file <- "DATA/OUTPUT/scRNA_after_QC.rds"
  713. output_data_dir <- "DATA/OUTPUT"
  714. output_figure_dir <- "FIGURE/STEP3_dimension_reduction"
  715. dir.create(
  716. output_data_dir,
  717. recursive = TRUE,
  718. showWarnings = FALSE
  719. )
  720. dir.create(
  721. output_figure_dir,
  722. recursive = TRUE,
  723. showWarnings = FALSE
  724. )
  725. # 3. Define analysis parameters ------------------------------------------------
  726. n_pcs <- 50
  727. dims_use <- 1:30
  728. harmony_max_iterations <- 20
  729. harmony_resolutions <- c(
  730. 0.1,
  731. 0.3,
  732. 0.5,
  733. 0.7,
  734. 1.0
  735. )
  736. pca_resolutions <- c(
  737. 0.1,
  738. 0.2,
  739. 0.3,
  740. 0.5,
  741. 0.7,
  742. 1.0
  743. )
  744. random_seed <- 1234
  745. # 4. Load the QC-filtered Seurat object ----------------------------------------
  746. scRNA_qc <- readRDS(input_file)
  747. if (!inherits(scRNA_qc, "Seurat")) {
  748. stop("The input file must contain a Seurat object.")
  749. }
  750. if (!"RNA" %in% names(scRNA_qc@assays)) {
  751. stop("The Seurat object does not contain an RNA assay.")
  752. }
  753. required_metadata <- c(
  754. "orig.ident",
  755. "group",
  756. "batch"
  757. )
  758. missing_metadata <- setdiff(
  759. required_metadata,
  760. colnames([email hidden])
  761. )
  762. if (length(missing_metadata) > 0) {
  763. stop(
  764. paste0(
  765. "The following required metadata columns are missing: ",
  766. paste(missing_metadata, collapse = ", ")
  767. )
  768. )
  769. }
  770. if (anyNA(scRNA_qc$batch)) {
  771. stop("Missing values were detected in the batch metadata.")
  772. }
  773. # ==============================================================================
  774. # Branch A: Harmony batch integration
  775. # ==============================================================================
  776. # 5. Copy the QC-filtered object -----------------------------------------------
  777. scRNA_harmony <- scRNA_qc
  778. # 6. SCTransform normalization -------------------------------------------------
  779. scRNA_harmony <- SCTransform(
  780. object = scRNA_harmony,
  781. assay = "RNA",
  782. new.assay.name = "SCT",
  783. verbose = FALSE
  784. )
  785. # 7. Principal-component analysis ---------------------------------------------
  786. scRNA_harmony <- RunPCA(
  787. object = scRNA_harmony,
  788. assay = "SCT",
  789. npcs = n_pcs,
  790. verbose = FALSE,
  791. seed.use = random_seed
  792. )
  793. p_elbow_harmony <- ElbowPlot(
  794. object = scRNA_harmony,
  795. ndims = n_pcs
  796. )
  797. ggsave(
  798. filename = file.path(
  799. output_figure_dir,
  800. "Harmony_branch_PCA_elbow_plot.pdf"
  801. ),
  802. plot = p_elbow_harmony,
  803. width = 5,
  804. height = 4,
  805. units = "in"
  806. )
  807. # 8. Harmony integration -------------------------------------------------------
  808. scRNA_harmony <- RunHarmony(
  809. object = scRNA_harmony,
  810. group.by.vars = "batch",
  811. reduction = "pca",
  812. assay.use = "SCT",
  813. max.iter.harmony = harmony_max_iterations,
  814. reduction.save = "harmony",
  815. plot_convergence = FALSE,
  816. verbose = TRUE
  817. )
  818. if (!"harmony" %in% names(scRNA_harmony@reductions)) {
  819. stop("Harmony did not generate a reduction named 'harmony'.")
  820. }
  821. # 9. UMAP using Harmony embeddings ---------------------------------------------
  822. scRNA_harmony <- RunUMAP(
  823. object = scRNA_harmony,
  824. reduction = "harmony",
  825. dims = dims_use,
  826. seed.use = random_seed,
  827. reduction.name = "umap",
  828. reduction.key = "UMAP_",
  829. verbose = FALSE
  830. )
  831. # 10. Shared-nearest-neighbour graph -------------------------------------------
  832. scRNA_harmony <- FindNeighbors(
  833. object = scRNA_harmony,
  834. reduction = "harmony",
  835. dims = dims_use,
  836. verbose = FALSE
  837. )
  838. # 11. Clustering across the prespecified resolution grid -----------------------
  839. for (resolution_value in harmony_resolutions) {
  840. scRNA_harmony <- FindClusters(
  841. object = scRNA_harmony,
  842. resolution = resolution_value,
  843. random.seed = random_seed,
  844. verbose = FALSE
  845. )
  846. }
  847. # 12. Harmony diagnostic UMAPs -------------------------------------------------
  848. p_harmony_batch <- DimPlot(
  849. object = scRNA_harmony,
  850. reduction = "umap",
  851. group.by = "batch",
  852. pt.size = 0.1
  853. ) +
  854. ggtitle("Harmony-integrated UMAP by batch")
  855. p_harmony_group <- DimPlot(
  856. object = scRNA_harmony,
  857. reduction = "umap",
  858. group.by = "group",
  859. pt.size = 0.1
  860. ) +
  861. ggtitle("Harmony-integrated UMAP by group")
  862. ggsave(
  863. filename = file.path(
  864. output_figure_dir,
  865. "Harmony_branch_UMAP_diagnostics.pdf"
  866. ),
  867. plot = p_harmony_batch + p_harmony_group,
  868. width = 10,
  869. height = 4.5,
  870. units = "in"
  871. )
  872. # 13. Save the Harmony-integrated object ---------------------------------------
  873. saveRDS(
  874. scRNA_harmony,
  875. file = file.path(
  876. output_data_dir,
  877. "scRNA_HarmonyIntegrated.rds"
  878. )
  879. )
  880. # ==============================================================================
  881. # Branch B: no batch correction
  882. # ==============================================================================
  883. # 14. Copy the same QC-filtered input object -----------------------------------
  884. scRNA_no_batch <- scRNA_qc
  885. # 15. SCTransform normalization ------------------------------------------------
  886. scRNA_no_batch <- SCTransform(
  887. object = scRNA_no_batch,
  888. assay = "RNA",
  889. new.assay.name = "SCT",
  890. verbose = FALSE
  891. )
  892. # 16. Principal-component analysis --------------------------------------------
  893. scRNA_no_batch <- RunPCA(
  894. object = scRNA_no_batch,
  895. assay = "SCT",
  896. npcs = n_pcs,
  897. verbose = FALSE,
  898. seed.use = random_seed
  899. )
  900. p_elbow_no_batch <- ElbowPlot(
  901. object = scRNA_no_batch,
  902. ndims = n_pcs
  903. )
  904. ggsave(
  905. filename = file.path(
  906. output_figure_dir,
  907. "No_batch_correction_PCA_elbow_plot.pdf"
  908. ),
  909. plot = p_elbow_no_batch,
  910. width = 5,
  911. height = 4,
  912. units = "in"
  913. )
  914. # 17. UMAP using PCA embeddings ------------------------------------------------
  915. scRNA_no_batch <- RunUMAP(
  916. object = scRNA_no_batch,
  917. reduction = "pca",
  918. dims = dims_use,
  919. seed.use = random_seed,
  920. reduction.name = "umap",
  921. reduction.key = "UMAP_",
  922. verbose = FALSE
  923. )
  924. # 18. Shared-nearest-neighbour graph -------------------------------------------
  925. scRNA_no_batch <- FindNeighbors(
  926. object = scRNA_no_batch,
  927. reduction = "pca",
  928. dims = dims_use,
  929. verbose = FALSE
  930. )
  931. # 19. Clustering across the prespecified resolution grid -----------------------
  932. for (resolution_value in pca_resolutions) {
  933. scRNA_no_batch <- FindClusters(
  934. object = scRNA_no_batch,
  935. resolution = resolution_value,
  936. random.seed = random_seed,
  937. verbose = FALSE
  938. )
  939. }
  940. # 20. No-correction diagnostic UMAPs -------------------------------------------
  941. p_no_batch_batch <- DimPlot(
  942. object = scRNA_no_batch,
  943. reduction = "umap",
  944. group.by = "batch",
  945. pt.size = 0.1
  946. ) +
  947. ggtitle("Uncorrected UMAP by batch")
  948. p_no_batch_group <- DimPlot(
  949. object = scRNA_no_batch,
  950. reduction = "umap",
  951. group.by = "group",
  952. pt.size = 0.1
  953. ) +
  954. ggtitle("Uncorrected UMAP by group")
  955. ggsave(
  956. filename = file.path(
  957. output_figure_dir,
  958. "No_batch_correction_UMAP_diagnostics.pdf"
  959. ),
  960. plot = p_no_batch_batch + p_no_batch_group,
  961. width = 10,
  962. height = 4.5,
  963. units = "in"
  964. )
  965. # 21. Save the uncorrected object ----------------------------------------------
  966. saveRDS(
  967. scRNA_no_batch,
  968. file = file.path(
  969. output_data_dir,
  970. "scRNA_NoBatchCorrection.rds"
  971. )
  972. )
  973. # 22. Export clustering metadata -----------------------------------------------
  974. harmony_cluster_columns <- grep(
  975. pattern = "_snn_res\\.",
  976. x = colnames([email hidden]),
  977. value = TRUE
  978. )
  979. pca_cluster_columns <- grep(
  980. pattern = "_snn_res\\.",
  981. x = colnames([email hidden]),
  982. value = TRUE
  983. )
  984. harmony_cluster_table <- [email hidden] |>
  985. tibble::rownames_to_column("cell_barcode") |>
  986. dplyr::select(
  987. cell_barcode,
  988. orig.ident,
  989. group,
  990. batch,
  991. dplyr::all_of(harmony_cluster_columns)
  992. )
  993. pca_cluster_table <- [email hidden] |>
  994. tibble::rownames_to_column("cell_barcode") |>
  995. dplyr::select(
  996. cell_barcode,
  997. orig.ident,
  998. group,
  999. batch,
  1000. dplyr::all_of(pca_cluster_columns)
  1001. )
  1002. write.csv(
  1003. harmony_cluster_table,
  1004. file = file.path(
  1005. output_data_dir,
  1006. "Harmony_cluster_assignments.csv"
  1007. ),
  1008. row.names = FALSE
  1009. )
  1010. write.csv(
  1011. pca_cluster_table,
  1012. file = file.path(
  1013. output_data_dir,
  1014. "No_batch_correction_cluster_assignments.csv"
  1015. ),
  1016. row.names = FALSE
  1017. )
  1018. # 23. Save analysis parameters -------------------------------------------------
  1019. analysis_parameters <- c(
  1020. paste0("Input file: ", input_file),
  1021. "Normalization: SCTransform using the RNA assay",
  1022. paste0("Number of computed principal components: ", n_pcs),
  1023. paste0(
  1024. "Dimensions used for UMAP and graph construction: ",
  1025. min(dims_use),
  1026. "-",
  1027. max(dims_use)
  1028. ),
  1029. "Harmony integration variable: batch",
  1030. paste0(
  1031. "Harmony maximum iterations: ",
  1032. harmony_max_iterations
  1033. ),
  1034. paste0(
  1035. "Harmony clustering resolutions: ",
  1036. paste(harmony_resolutions, collapse = ", ")
  1037. ),
  1038. paste0(
  1039. "No-correction clustering resolutions: ",
  1040. paste(pca_resolutions, collapse = ", ")
  1041. ),
  1042. paste0("Random seed: ", random_seed)
  1043. )
  1044. writeLines(
  1045. analysis_parameters,
  1046. con = file.path(
  1047. output_data_dir,
  1048. "Dimension_reduction_clustering_parameters.txt"
  1049. )
  1050. )
  1051. # 24. Save software information ------------------------------------------------
  1052. writeLines(
  1053. capture.output(sessionInfo()),
  1054. con = file.path(
  1055. output_data_dir,
  1056. "Dimension_reduction_clustering_sessionInfo.txt"
  1057. )
  1058. )
  1059. -------------------------------------------------------------------------------
  1060. ### ----(4)-Marker Gene Identification and DEG Analysis --------------
  1061. #==============================================================================
  1062. # Cluster marker identification, differential expression and cell annotation
  1063. #
  1064. # Description:
  1065. # This script identifies marker genes for graph-based clusters, performs a
  1066. # assigns biological cell-type labels, and identifies marker genes for the
  1067. # annotated cell types.
  1068. # ==============================================================================
  1069. suppressPackageStartupMessages({
  1070. library(Seurat)
  1071. library(dplyr)
  1072. library(tibble)
  1073. library(future)
  1074. })
  1075. set.seed(1234)
  1076. input_file <- "DATA/OUTPUT/scRNA_HarmonyIntegrated.rds"
  1077. output_data_dir <- "DATA/OUTPUT"
  1078. output_table_dir <- "TABLE/DEG"
  1079. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  1080. dir.create(output_table_dir, recursive = TRUE, showWarnings = FALSE)
  1081. cluster_column <- "SCT_snn_res.0.5"
  1082. marker_assay <- "SCT"
  1083. marker_slot <- "data"
  1084. deg_assay <- "RNA"
  1085. # Keep "counts" only if this was the slot used to generate the reported result.
  1086. # For a conventional Wilcoxon-based DEG analysis, the normalized "data" slot is
  1087. # generally preferable.
  1088. deg_slot <- "counts"
  1089. ident_1 <- "0"
  1090. ident_2 <- "6"
  1091. random_seed <- 1234
  1092. workers <- min(
  1093. 10L,
  1094. max(
  1095. 1L,
  1096. parallel::detectCores(logical = TRUE) - 1L
  1097. )
  1098. )
  1099. options(future.globals.maxSize = 20 * 1024^3)
  1100. future::plan(
  1101. future::multisession,
  1102. workers = workers
  1103. )
  1104. scRNA <- readRDS(input_file)
  1105. if (!inherits(scRNA, "Seurat")) {
  1106. stop("The input file must contain a Seurat object.")
  1107. }
  1108. if (!cluster_column %in% colnames([email hidden])) {
  1109. stop(
  1110. paste0(
  1111. "The clustering column '",
  1112. cluster_column,
  1113. "' was not found in the Seurat metadata."
  1114. )
  1115. )
  1116. }
  1117. if (!marker_assay %in% names(scRNA@assays)) {
  1118. stop(
  1119. paste0(
  1120. "The marker assay '",
  1121. marker_assay,
  1122. "' was not found."
  1123. )
  1124. )
  1125. }
  1126. if (!deg_assay %in% names(scRNA@assays)) {
  1127. stop(
  1128. paste0(
  1129. "The DEG assay '",
  1130. deg_assay,
  1131. "' was not found."
  1132. )
  1133. )
  1134. }
  1135. cluster_ids <- sort(
  1136. unique(
  1137. as.character(
  1138. [email hidden][[cluster_column]]
  1139. )
  1140. )
  1141. )
  1142. message(
  1143. "Clusters present in ",
  1144. cluster_column,
  1145. ": ",
  1146. paste(cluster_ids, collapse = ", ")
  1147. )
  1148. # 1. Marker genes for graph-based clusters -------------------------------------
  1149. Idents(scRNA) <- cluster_column
  1150. cluster_markers <- FindAllMarkers(
  1151. object = scRNA,
  1152. assay = marker_assay,
  1153. slot = marker_slot,
  1154. test.use = "wilcox",
  1155. logfc.threshold = 0.25,
  1156. min.pct = 0.10,
  1157. only.pos = FALSE,
  1158. random.seed = random_seed,
  1159. verbose = TRUE
  1160. )
  1161. write.csv(
  1162. cluster_markers,
  1163. file = file.path(
  1164. output_table_dir,
  1165. "Markers_all_clusters_resolution_0.5.csv"
  1166. ),
  1167. row.names = FALSE
  1168. )
  1169. # 2. DEG between clusters 0 and 6 ----------------------------------------------
  1170. missing_deg_clusters <- setdiff(
  1171. c(ident_1, ident_2),
  1172. cluster_ids
  1173. )
  1174. if (length(missing_deg_clusters) > 0) {
  1175. stop(
  1176. paste0(
  1177. "The following DEG comparison cluster(s) are absent from ",
  1178. cluster_column,
  1179. ": ",
  1180. paste(missing_deg_clusters, collapse = ", ")
  1181. )
  1182. )
  1183. }
  1184. deg_cluster_0_vs_6 <- FindMarkers(
  1185. object = scRNA,
  1186. ident.1 = ident_1,
  1187. ident.2 = ident_2,
  1188. group.by = cluster_column,
  1189. assay = deg_assay,
  1190. slot = deg_slot,
  1191. test.use = "wilcox",
  1192. logfc.threshold = 0,
  1193. min.pct = 0,
  1194. random.seed = random_seed,
  1195. verbose = TRUE
  1196. )
  1197. deg_cluster_0_vs_6 <- deg_cluster_0_vs_6 |>
  1198. tibble::rownames_to_column("gene")
  1199. write.csv(
  1200. deg_cluster_0_vs_6,
  1201. file = file.path(
  1202. output_table_dir,
  1203. "Cluster_0_vs_6_DEG.csv"
  1204. ),
  1205. row.names = FALSE
  1206. )
  1207. # 3. Cell-type annotation -------------------------------------------------------
  1208. # Replace or extend this mapping so that every cluster has exactly one label.
  1209. # The previous label "NDM" has been updated to "Numb+ microglia".
  1210. cluster_to_celltype <- c(
  1211. "0" = "Numb+ microglia",
  1212. "1" = "Mg1",
  1213. "2" = "Mg2"
  1214. )
  1215. unannotated_clusters <- setdiff(
  1216. cluster_ids,
  1217. names(cluster_to_celltype)
  1218. )
  1219. if (length(unannotated_clusters) > 0) {
  1220. annotation_template <- data.frame(
  1221. cluster = cluster_ids,
  1222. celltype = unname(
  1223. cluster_to_celltype[cluster_ids]
  1224. ),
  1225. stringsAsFactors = FALSE
  1226. )
  1227. write.csv(
  1228. annotation_template,
  1229. file = file.path(
  1230. output_table_dir,
  1231. "Cluster_annotation_template.csv"
  1232. ),
  1233. row.names = FALSE,
  1234. na = ""
  1235. )
  1236. stop(
  1237. paste0(
  1238. "Cell-type labels are missing for cluster(s): ",
  1239. paste(unannotated_clusters, collapse = ", "),
  1240. ". Complete cluster_to_celltype before continuing. ",
  1241. "A template has been written to TABLE/DEG/Cluster_annotation_template.csv."
  1242. )
  1243. )
  1244. }
  1245. scRNA$celltype <- unname(
  1246. cluster_to_celltype[
  1247. as.character(
  1248. [email hidden][[cluster_column]]
  1249. )
  1250. ]
  1251. )
  1252. if (anyNA(scRNA$celltype)) {
  1253. stop("Missing cell-type labels were generated during annotation.")
  1254. }
  1255. celltype_levels <- unique(
  1256. unname(
  1257. cluster_to_celltype[cluster_ids]
  1258. )
  1259. )
  1260. scRNA$celltype <- factor(
  1261. scRNA$celltype,
  1262. levels = celltype_levels
  1263. )
  1264. print(
  1265. table(
  1266. cluster = [email hidden][[cluster_column]],
  1267. celltype = scRNA$celltype
  1268. )
  1269. )
  1270. # 4. Marker genes for annotated cell types -------------------------------------
  1271. Idents(scRNA) <- "celltype"
  1272. celltype_markers <- FindAllMarkers(
  1273. object = scRNA,
  1274. assay = marker_assay,
  1275. slot = marker_slot,
  1276. test.use = "wilcox",
  1277. logfc.threshold = 0.25,
  1278. min.pct = 0.10,
  1279. only.pos = FALSE,
  1280. random.seed = random_seed,
  1281. verbose = TRUE
  1282. )
  1283. write.csv(
  1284. celltype_markers,
  1285. file = file.path(
  1286. output_table_dir,
  1287. "Markers_all_annotated_celltypes.csv"
  1288. ),
  1289. row.names = FALSE
  1290. )
  1291. # 5. Export annotation metadata -------------------------------------------------
  1292. annotation_metadata <- [email hidden] |>
  1293. tibble::rownames_to_column("cell_barcode") |>
  1294. dplyr::select(
  1295. cell_barcode,
  1296. orig.ident,
  1297. group,
  1298. batch,
  1299. dplyr::all_of(cluster_column),
  1300. celltype
  1301. )
  1302. write.csv(
  1303. annotation_metadata,
  1304. file = file.path(
  1305. output_table_dir,
  1306. "Cell_cluster_and_celltype_annotations.csv"
  1307. ),
  1308. row.names = FALSE
  1309. )
  1310. # 6. Save annotated object ------------------------------------------------------
  1311. saveRDS(
  1312. scRNA,
  1313. file = file.path(
  1314. output_data_dir,
  1315. "scRNA_HarmonyIntegrated_annotated.rds"
  1316. )
  1317. )
  1318. # 7. Save parameters and software information ---------------------------------
  1319. analysis_parameters <- c(
  1320. paste0("Input file: ", input_file),
  1321. paste0("Clustering column: ", cluster_column),
  1322. paste0("Marker assay: ", marker_assay),
  1323. paste0("Marker slot: ", marker_slot),
  1324. "Marker test: Wilcoxon rank-sum test",
  1325. "FindAllMarkers logfc.threshold: 0.25",
  1326. "FindAllMarkers min.pct: 0.10",
  1327. "FindAllMarkers only.pos: FALSE",
  1328. paste0("DEG comparison: cluster ", ident_1, " versus cluster ", ident_2),
  1329. paste0("DEG assay: ", deg_assay),
  1330. paste0("DEG slot: ", deg_slot),
  1331. "FindMarkers logfc.threshold: 0",
  1332. "FindMarkers min.pct: 0",
  1333. paste0("Parallel workers: ", workers),
  1334. "future.globals.maxSize: 20 GiB",
  1335. paste0("Random seed: ", random_seed)
  1336. )
  1337. writeLines(
  1338. analysis_parameters,
  1339. con = file.path(
  1340. output_table_dir,
  1341. "Marker_DEG_annotation_parameters.txt"
  1342. )
  1343. )
  1344. writeLines(
  1345. capture.output(sessionInfo()),
  1346. con = file.path(
  1347. output_table_dir,
  1348. "Marker_DEG_annotation_sessionInfo.txt"
  1349. )
  1350. )
  1351. future::plan(future::sequential)
  1352. ###------------- 4.2: DEG Between Specific Clusters-----------------------------
  1353. options(future.globals.maxSize = 20000 * 1024^3)
  1354. plan(multisession, workers = 20)
  1355. # Compare between cluster "0" and "6" under clustering result SCT_snn_res.0.5
  1356. deg <- FindMarkers(
  1357. object = scRNA,
  1358. ident.1 = "0",
  1359. ident.2 = "6",
  1360. group.by = "SCT_snn_res.0.5",
  1361. logfc.threshold = 0,
  1362. min.pct = 0,
  1363. assay = "RNA",
  1364. slot = "counts")
  1365. # Save DEG result
  1366. write.csv(deg, file = "Cluster_0_vs_6_DEG.csv", row.names = TRUE)
  1367. --------------------------------------------------------------------------------
  1368. ###亚群命名# 5. cluster annotation ###########
  1369. metadata <- [email hidden]
  1370. metadata$celltype <- recode(metadata$SCT_snn_res.0.5,
  1371. `0` = "NDM",`1` = "Mg1",`2` = "Mg2")
  1372. [email hidden] <- metadata
  1373. scRNA$celltype<- factor(scRNA$celltype,levels =
  1374. c("NDM","Mg1","Mg2"))
  1375. --------------------------------------------------------------------------------
  1376. ### ------(5).Visualization ----------------------------------------------------
  1377. # ==============================================================================
  1378. # Visualization of clustering, cell states and marker-gene expression
  1379. #
  1380. # Description:
  1381. # This script generates the cluster tree, UMAPs, cell-composition plot,
  1382. # gene-expression plots, per-cell expression heatmap and marker DotPlot used in
  1383. # the manuscript.
  1384. #
  1385. # Software used in the original analysis:
  1386. # Seurat
  1387. # clustree 0.5.0
  1388. # SCP 0.4.2
  1389. # ComplexHeatmap 2.14.0
  1390. # ==============================================================================
  1391. # 1. Load packages -------------------------------------------------------------
  1392. suppressPackageStartupMessages({
  1393. library(Seurat)
  1394. library(clustree)
  1395. library(SCP)
  1396. library(dplyr)
  1397. library(readxl)
  1398. library(ggplot2)
  1399. library(ComplexHeatmap)
  1400. library(circlize)
  1401. library(grid)
  1402. library(RColorBrewer)
  1403. })
  1404. # 2. Define paths ---------------------------------------------------------------
  1405. input_file <- "DATA/OUTPUT/scRNA_HarmonyIntegrated_annotated.rds"
  1406. heatmap_gene_file <- "DATA/INPUT/p3_all_cell_genes.xlsx"
  1407. dotplot_gene_file <- "DATA/INPUT/dotplot_genes.xlsx"
  1408. output_figure_dir <- "FIGURE"
  1409. output_umap_dir <- file.path(output_figure_dir, "UMAP")
  1410. output_data_dir <- "DATA/OUTPUT"
  1411. dir.create(output_figure_dir, recursive = TRUE, showWarnings = FALSE)
  1412. dir.create(output_umap_dir, recursive = TRUE, showWarnings = FALSE)
  1413. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  1414. # 3. Load annotated Seurat object ----------------------------------------------
  1415. scRNA <- readRDS(input_file)
  1416. if (!inherits(scRNA, "Seurat")) {
  1417. stop("The input file must contain a Seurat object.")
  1418. }
  1419. required_metadata <- c("celltype", "group")
  1420. missing_metadata <- setdiff(
  1421. required_metadata,
  1422. colnames([email hidden])
  1423. )
  1424. if (length(missing_metadata) > 0) {
  1425. stop(
  1426. paste0(
  1427. "Missing metadata column(s): ",
  1428. paste(missing_metadata, collapse = ", ")
  1429. )
  1430. )
  1431. }
  1432. if (!"umap" %in% names(scRNA@reductions)) {
  1433. stop("The Seurat object does not contain a UMAP reduction named 'umap'.")
  1434. }
  1435. if (!"SCT" %in% names(scRNA@assays)) {
  1436. stop("The Seurat object does not contain an SCT assay.")
  1437. }
  1438. # 4. Define final cell-state order and colors ----------------------------------
  1439. celltype_levels <- levels(
  1440. droplevels(
  1441. factor(scRNA$celltype)
  1442. )
  1443. )
  1444. if (length(celltype_levels) == 0) {
  1445. celltype_levels <- unique(
  1446. as.character(scRNA$celltype)
  1447. )
  1448. }
  1449. scRNA$celltype <- factor(
  1450. scRNA$celltype,
  1451. levels = celltype_levels
  1452. )
  1453. # Replace this vector with the exact color mapping used in the final figures if
  1454. # a fixed manuscript-wide palette was applied.
  1455. base_celltype_colors <- c(
  1456. "#E5D2DD",
  1457. "#53A85F",
  1458. "#F3B1A0",
  1459. "#FFDD44",
  1460. "#9467BD",
  1461. "#E377C2",
  1462. "#D62728",
  1463. "#8C564B",
  1464. "#2CA02C",
  1465. "#23452F",
  1466. "#1F77B4",
  1467. "#17BECF",
  1468. "#BCBD22",
  1469. "#7F7F7F",
  1470. "#FF7F0E"
  1471. )
  1472. if (length(celltype_levels) > length(base_celltype_colors)) {
  1473. stop("More cell types were found than colors available in the palette.")
  1474. }
  1475. celltype_colors <- setNames(
  1476. base_celltype_colors[
  1477. seq_along(celltype_levels)
  1478. ],
  1479. celltype_levels
  1480. )
  1481. # ==============================================================================
  1482. # 5. Cluster-tree visualization
  1483. # ==============================================================================
  1484. cluster_columns <- grep(
  1485. pattern = "^SCT_snn_res\\.",
  1486. x = colnames([email hidden]),
  1487. value = TRUE
  1488. )
  1489. if (length(cluster_columns) < 2) {
  1490. warning(
  1491. "Fewer than two SCT_snn_res.* columns were found; the clustree plot was skipped."
  1492. )
  1493. } else {
  1494. p_clustree <- clustree(
  1495. [email hidden],
  1496. prefix = "SCT_snn_res."
  1497. ) +
  1498. guides(
  1499. edge_colour = "none",
  1500. edge_alpha = "none"
  1501. ) +
  1502. scale_color_brewer(
  1503. palette = "Set1"
  1504. ) +
  1505. scale_edge_color_continuous(
  1506. low = "blue",
  1507. high = "red"
  1508. ) +
  1509. theme(
  1510. legend.position = "bottom"
  1511. )
  1512. ggsave(
  1513. filename = file.path(
  1514. output_figure_dir,
  1515. "ClusterTree.tiff"
  1516. ),
  1517. plot = p_clustree,
  1518. width = 8,
  1519. height = 8,
  1520. units = "in",
  1521. dpi = 600,
  1522. compression = "lzw"
  1523. )
  1524. ggsave(
  1525. filename = file.path(
  1526. output_figure_dir,
  1527. "ClusterTree.pdf"
  1528. ),
  1529. plot = p_clustree,
  1530. width = 8,
  1531. height = 8,
  1532. units = "in"
  1533. )
  1534. }
  1535. # ==============================================================================
  1536. # 6. UMAP by cell type and experimental group
  1537. # ==============================================================================
  1538. p_umap_celltype <- SCP::CellDimPlot(
  1539. srt = scRNA,
  1540. group.by = "celltype",
  1541. reduction = "umap",
  1542. theme_use = "theme_blank",
  1543. label = TRUE,
  1544. label_insitu = TRUE
  1545. )
  1546. ggsave(
  1547. filename = file.path(
  1548. output_figure_dir,
  1549. "UMAP_celltype.tiff"
  1550. ),
  1551. plot = p_umap_celltype,
  1552. width = 6,
  1553. height = 4,
  1554. units = "in",
  1555. dpi = 600,
  1556. compression = "lzw"
  1557. )
  1558. ggsave(
  1559. filename = file.path(
  1560. output_figure_dir,
  1561. "UMAP_celltype.pdf"
  1562. ),
  1563. plot = p_umap_celltype,
  1564. width = 4,
  1565. height = 4,
  1566. units = "in"
  1567. )
  1568. p_umap_group <- SCP::CellDimPlot(
  1569. srt = scRNA,
  1570. group.by = "celltype",
  1571. reduction = "umap",
  1572. theme_use = "theme_blank",
  1573. label = FALSE,
  1574. label_insitu = FALSE,
  1575. show_stat = FALSE,
  1576. split.by = "group"
  1577. )
  1578. ggsave(
  1579. filename = file.path(
  1580. output_figure_dir,
  1581. "UMAP_split_by_group.tiff"
  1582. ),
  1583. plot = p_umap_group,
  1584. width = 15,
  1585. height = 12,
  1586. units = "in",
  1587. dpi = 600,
  1588. compression = "lzw"
  1589. )
  1590. ggsave(
  1591. filename = file.path(
  1592. output_figure_dir,
  1593. "UMAP_split_by_group.pdf"
  1594. ),
  1595. plot = p_umap_group,
  1596. width = 15,
  1597. height = 12,
  1598. units = "in"
  1599. )
  1600. # ==============================================================================
  1601. # 7. Feature plot
  1602. # ==============================================================================
  1603. feature_genes <- c("Gpnmb")
  1604. missing_feature_genes <- setdiff(
  1605. feature_genes,
  1606. rownames(scRNA)
  1607. )
  1608. if (length(missing_feature_genes) > 0) {
  1609. warning(
  1610. paste0(
  1611. "Feature gene(s) not found and skipped: ",
  1612. paste(missing_feature_genes, collapse = ", ")
  1613. )
  1614. )
  1615. }
  1616. feature_genes <- intersect(
  1617. feature_genes,
  1618. rownames(scRNA)
  1619. )
  1620. for (gene_name in feature_genes) {
  1621. p_feature <- SCP::FeatureDimPlot(
  1622. object = scRNA,
  1623. features = gene_name,
  1624. reduction = "umap",
  1625. cells.highlight = TRUE,
  1626. theme_use = "theme_blank",
  1627. show_stat = FALSE,
  1628. legend.position = "none"
  1629. )
  1630. ggsave(
  1631. filename = file.path(
  1632. output_umap_dir,
  1633. paste0(gene_name, ".tiff")
  1634. ),
  1635. plot = p_feature,
  1636. width = 2,
  1637. height = 2,
  1638. units = "in",
  1639. dpi = 600,
  1640. compression = "lzw"
  1641. )
  1642. ggsave(
  1643. filename = file.path(
  1644. output_umap_dir,
  1645. paste0(gene_name, ".pdf")
  1646. ),
  1647. plot = p_feature,
  1648. width = 2,
  1649. height = 2,
  1650. units = "in"
  1651. )
  1652. }
  1653. # ==============================================================================
  1654. # 8. Cell-state proportion plot
  1655. # ==============================================================================
  1656. p_cell_ratio <- SCP::CellStatPlot(
  1657. srt = scRNA,
  1658. stat.by = "celltype",
  1659. group.by = "group",
  1660. plot_type = "trend"
  1661. )
  1662. ggsave(
  1663. filename = file.path(
  1664. output_figure_dir,
  1665. "Cell_state_proportions.pdf"
  1666. ),
  1667. plot = p_cell_ratio,
  1668. width = 4,
  1669. height = 3,
  1670. units = "in"
  1671. )
  1672. ggsave(
  1673. filename = file.path(
  1674. output_figure_dir,
  1675. "Cell_state_proportions.tiff"
  1676. ),
  1677. plot = p_cell_ratio,
  1678. width = 4,
  1679. height = 3,
  1680. units = "in",
  1681. dpi = 600,
  1682. compression = "lzw"
  1683. )
  1684. # ==============================================================================
  1685. # 9. Gene-expression boxplots
  1686. # ==============================================================================
  1687. boxplot_genes <- c(
  1688. "Pgk1",
  1689. "Pgam1",
  1690. "Pkm",
  1691. "Ldha",
  1692. "Aif1",
  1693. "P2ry12"
  1694. )
  1695. boxplot_genes <- intersect(
  1696. boxplot_genes,
  1697. rownames(scRNA)
  1698. )
  1699. if (length(boxplot_genes) == 0) {
  1700. warning("None of the requested boxplot genes were found.")
  1701. } else {
  1702. p_gene_boxplot <- SCP::FeatureStatPlot(
  1703. srt = scRNA,
  1704. stat.by = boxplot_genes,
  1705. fill.by = "group",
  1706. plot_type = "box",
  1707. group.by = "celltype",
  1708. bg.by = "celltype",
  1709. stack = TRUE,
  1710. flip = FALSE
  1711. )
  1712. ggsave(
  1713. filename = file.path(
  1714. output_figure_dir,
  1715. "Gene_expression_boxplots.tiff"
  1716. ),
  1717. plot = p_gene_boxplot,
  1718. width = 8,
  1719. height = 4,
  1720. units = "in",
  1721. dpi = 600,
  1722. compression = "lzw"
  1723. )
  1724. ggsave(
  1725. filename = file.path(
  1726. output_figure_dir,
  1727. "Gene_expression_boxplots.pdf"
  1728. ),
  1729. plot = p_gene_boxplot,
  1730. width = 8,
  1731. height = 4,
  1732. units = "in"
  1733. )
  1734. }
  1735. # ==============================================================================
  1736. # 10. Per-cell gene-expression heatmap
  1737. # ==============================================================================
  1738. if (!file.exists(heatmap_gene_file)) {
  1739. warning(
  1740. paste0(
  1741. "Heatmap gene file not found; heatmap skipped: ",
  1742. heatmap_gene_file
  1743. )
  1744. )
  1745. } else {
  1746. gene_data <- readxl::read_excel(
  1747. heatmap_gene_file,
  1748. sheet = 1
  1749. )
  1750. if (ncol(gene_data) < 2) {
  1751. stop("The heatmap gene file must contain at least two columns.")
  1752. }
  1753. colnames(gene_data)[1:2] <- c(
  1754. "cluster",
  1755. "gene"
  1756. )
  1757. gene_data <- gene_data |>
  1758. dplyr::select(cluster, gene) |>
  1759. dplyr::filter(
  1760. !is.na(gene),
  1761. gene != ""
  1762. ) |>
  1763. dplyr::distinct(gene, .keep_all = TRUE)
  1764. expression_matrix <- GetAssayData(
  1765. object = scRNA,
  1766. assay = "SCT",
  1767. slot = "data"
  1768. )
  1769. valid_gene_data <- gene_data |>
  1770. dplyr::filter(
  1771. gene %in% rownames(expression_matrix)
  1772. )
  1773. if (nrow(valid_gene_data) == 0) {
  1774. stop("None of the requested heatmap genes were found in the SCT assay.")
  1775. }
  1776. heatmap_data <- as.matrix(
  1777. expression_matrix[
  1778. valid_gene_data$gene,
  1779. ,
  1780. drop = FALSE
  1781. ]
  1782. )
  1783. rownames(heatmap_data) <- valid_gene_data$gene
  1784. heatmap_celltypes <- factor(
  1785. as.character(
  1786. scRNA$celltype[
  1787. colnames(heatmap_data)
  1788. ]
  1789. ),
  1790. levels = celltype_levels
  1791. )
  1792. row_clusters <- factor(
  1793. valid_gene_data$cluster,
  1794. levels = unique(
  1795. valid_gene_data$cluster
  1796. )
  1797. )
  1798. min_max_scale <- function(x) {
  1799. x_min <- min(x, na.rm = TRUE)
  1800. x_max <- max(x, na.rm = TRUE)
  1801. if (!is.finite(x_min) || !is.finite(x_max)) {
  1802. return(
  1803. rep(
  1804. NA_real_,
  1805. length(x)
  1806. )
  1807. )
  1808. }
  1809. if (x_max == x_min) {
  1810. return(
  1811. rep(
  1812. 0,
  1813. length(x)
  1814. )
  1815. )
  1816. }
  1817. (x - x_min) / (x_max - x_min)
  1818. }
  1819. scaled_heatmap_data <- t(
  1820. apply(
  1821. heatmap_data,
  1822. 1,
  1823. min_max_scale
  1824. )
  1825. )
  1826. rownames(scaled_heatmap_data) <- rownames(heatmap_data)
  1827. colnames(scaled_heatmap_data) <- colnames(heatmap_data)
  1828. row_cluster_levels <- levels(row_clusters)
  1829. row_cluster_palette <- setNames(
  1830. grDevices::hcl.colors(
  1831. n = length(row_cluster_levels),
  1832. palette = "Dark 3"
  1833. ),
  1834. row_cluster_levels
  1835. )
  1836. top_annotation <- columnAnnotation(
  1837. celltype = heatmap_celltypes,
  1838. col = list(
  1839. celltype = celltype_colors
  1840. ),
  1841. show_annotation_name = FALSE
  1842. )
  1843. left_annotation <- rowAnnotation(
  1844. cluster = row_clusters,
  1845. col = list(
  1846. cluster = row_cluster_palette
  1847. ),
  1848. show_annotation_name = FALSE
  1849. )
  1850. heatmap_object <- Heatmap(
  1851. scaled_heatmap_data,
  1852. name = "Expression",
  1853. cluster_rows = FALSE,
  1854. cluster_columns = FALSE,
  1855. show_column_names = FALSE,
  1856. show_row_names = FALSE,
  1857. column_split = heatmap_celltypes,
  1858. row_split = row_clusters,
  1859. top_annotation = top_annotation,
  1860. left_annotation = left_annotation,
  1861. col = circlize::colorRamp2(
  1862. c(0, 0.5, 1),
  1863. c(
  1864. "#4978B3",
  1865. "white",
  1866. "#FF3333"
  1867. )
  1868. ),
  1869. heatmap_legend_param = list(
  1870. at = seq(0, 1, 0.2),
  1871. labels = seq(0, 1, 0.2),
  1872. title = "Expression",
  1873. title_position = "leftcenter-rot"
  1874. ),
  1875. border = TRUE,
  1876. use_raster = TRUE,
  1877. raster_quality = 2,
  1878. column_gap = unit(1, "mm"),
  1879. row_gap = unit(1, "mm")
  1880. )
  1881. pdf(
  1882. file = file.path(
  1883. output_figure_dir,
  1884. "GENE_EXPRESSION.pdf"
  1885. ),
  1886. width = 6.5,
  1887. height = 6,
  1888. useDingbats = FALSE
  1889. )
  1890. draw(
  1891. heatmap_object,
  1892. merge_legends = TRUE
  1893. )
  1894. dev.off()
  1895. write.csv(
  1896. scaled_heatmap_data,
  1897. file = file.path(
  1898. output_data_dir,
  1899. "Heatmap_scaled_expression_matrix.csv"
  1900. ),
  1901. row.names = TRUE
  1902. )
  1903. write.csv(
  1904. valid_gene_data,
  1905. file = file.path(
  1906. output_data_dir,
  1907. "Heatmap_gene_annotations.csv"
  1908. ),
  1909. row.names = FALSE
  1910. )
  1911. }
  1912. # ==============================================================================
  1913. # 11. Marker-gene DotPlot
  1914. # ==============================================================================
  1915. if (!file.exists(dotplot_gene_file)) {
  1916. warning(
  1917. paste0(
  1918. "DotPlot gene file not found; DotPlot skipped: ",
  1919. dotplot_gene_file
  1920. )
  1921. )
  1922. } else {
  1923. marker_table <- readxl::read_excel(
  1924. dotplot_gene_file,
  1925. sheet = 1
  1926. )
  1927. if (!"gene" %in% colnames(marker_table)) {
  1928. stop("The DotPlot gene file must contain a column named 'gene'.")
  1929. }
  1930. marker_genes <- unique(
  1931. as.character(marker_table$gene)
  1932. )
  1933. marker_genes <- marker_genes[
  1934. !is.na(marker_genes) &
  1935. marker_genes != ""
  1936. ]
  1937. missing_marker_genes <- setdiff(
  1938. marker_genes,
  1939. rownames(scRNA)
  1940. )
  1941. if (length(missing_marker_genes) > 0) {
  1942. warning(
  1943. paste0(
  1944. "DotPlot gene(s) not found and skipped: ",
  1945. paste(missing_marker_genes, collapse = ", ")
  1946. )
  1947. )
  1948. }
  1949. marker_genes <- intersect(
  1950. marker_genes,
  1951. rownames(scRNA)
  1952. )
  1953. if (length(marker_genes) == 0) {
  1954. stop("None of the requested DotPlot genes were found.")
  1955. }
  1956. Idents(scRNA) <- "celltype"
  1957. dotplot_base <- DotPlot(
  1958. object = scRNA,
  1959. features = marker_genes,
  1960. assay = "SCT"
  1961. )
  1962. dot_data <- dotplot_base$data
  1963. write.csv(
  1964. dot_data,
  1965. file = file.path(
  1966. output_data_dir,
  1967. "DotPlot_underlying_data.csv"
  1968. ),
  1969. row.names = FALSE
  1970. )
  1971. p_dotplot <- ggplot(
  1972. dot_data,
  1973. aes(
  1974. x = features.plot,
  1975. y = id,
  1976. size = pct.exp,
  1977. fill = avg.exp.scaled
  1978. )
  1979. ) +
  1980. geom_point(
  1981. shape = 21,
  1982. colour = "black",
  1983. stroke = 0.5
  1984. ) +
  1985. guides(
  1986. size = guide_legend(
  1987. override.aes = list(
  1988. shape = 21,
  1989. colour = "black",
  1990. fill = NA
  1991. )
  1992. )
  1993. ) +
  1994. scale_fill_gradientn(
  1995. colours = c(
  1996. "#5749A0",
  1997. "#0F7AB0",
  1998. "#00BBB1",
  1999. "#BEF0B0",
  2000. "#FDF4AF",
  2001. "#F9B64B",
  2002. "#EC840E",
  2003. "#CA443D",
  2004. "#A51A49"
  2005. )
  2006. ) +
  2007. theme(
  2008. panel.background = element_blank(),
  2009. panel.border = element_rect(
  2010. fill = NA
  2011. ),
  2012. panel.grid.major.x = element_line(
  2013. colour = "grey80"
  2014. ),
  2015. panel.grid.major.y = element_line(
  2016. colour = "grey80"
  2017. ),
  2018. axis.title = element_blank(),
  2019. axis.text.y = element_text(
  2020. colour = "black",
  2021. size = 12
  2022. ),
  2023. axis.text.x = element_text(
  2024. colour = "black",
  2025. size = 12,
  2026. angle = 90,
  2027. hjust = 1,
  2028. vjust = 0.5
  2029. )
  2030. )
  2031. ggsave(
  2032. filename = file.path(
  2033. output_figure_dir,
  2034. "DOTPLOT_NOT_SLICED.pdf"
  2035. ),
  2036. plot = p_dotplot,
  2037. width = 6,
  2038. height = 2,
  2039. units = "in"
  2040. )
  2041. }
  2042. # 12. Save analysis information ------------------------------------------------
  2043. analysis_parameters <- c(
  2044. paste0("Input file: ", input_file),
  2045. paste0(
  2046. "Cell-type order: ",
  2047. paste(celltype_levels, collapse = ", ")
  2048. ),
  2049. "Heatmap assay: SCT",
  2050. "Heatmap slot: data",
  2051. "Heatmap scaling: row-wise min-max scaling to 0-1",
  2052. "DotPlot assay: SCT"
  2053. )
  2054. writeLines(
  2055. analysis_parameters,
  2056. con = file.path(
  2057. output_data_dir,
  2058. "Visualization_parameters.txt"
  2059. )
  2060. )
  2061. writeLines(
  2062. capture.output(sessionInfo()),
  2063. con = file.path(
  2064. output_data_dir,
  2065. "Visualization_sessionInfo.txt"
  2066. )
  2067. )
  2068. #==============================================================================
  2069. # (6) Gene Ontology enrichment analysis and bubble-plot visualization
  2070. #
  2071. # Description:
  2072. # This script filters positively regulated genes for each cluster, maps mouse
  2073. # gene symbols to Entrez identifiers, performs GO Biological Process enrichment
  2074. # using compareCluster, exports the complete enrichment results and generates
  2075. # the GO bubble plot used for visualization.
  2076. #
  2077. # Software used in the original analysis:
  2078. # clusterProfiler 4.2.2
  2079. # org.Mm.eg.db 3.14.0
  2080. # GOplot 1.0.2
  2081. # ==============================================================================
  2082. # 1. Load packages -------------------------------------------------------------
  2083. suppressPackageStartupMessages({
  2084. library(clusterProfiler)
  2085. library(org.Mm.eg.db)
  2086. library(dplyr)
  2087. library(openxlsx)
  2088. library(ggplot2)
  2089. library(cowplot)
  2090. library(aplot)
  2091. })
  2092. # 2. Define paths ---------------------------------------------------------------
  2093. deg_file <- "DATA/INPUT/module_gene.xlsx"
  2094. # Optional file containing two columns: Description and Annotation.
  2095. # When absent, the bubble plot is saved without the left annotation panel.
  2096. term_annotation_file <- "DATA/INPUT/GO_term_annotations.xlsx"
  2097. output_dir <- "go_enrichment"
  2098. output_figure_dir <- "FIGURE"
  2099. dir.create(output_dir, recursive = TRUE, showWarnings = FALSE)
  2100. dir.create(output_figure_dir, recursive = TRUE, showWarnings = FALSE)
  2101. # 3. Define analysis parameters ------------------------------------------------
  2102. log2fc_cutoff <- 0
  2103. adjusted_p_cutoff <- 0.01
  2104. go_ontology <- "BP"
  2105. p_adjust_method <- "BH"
  2106. enrichment_p_cutoff <- 0.05
  2107. enrichment_q_cutoff <- 0.20
  2108. min_gene_set_size <- 10
  2109. max_gene_set_size <- 500
  2110. # 4. Read DEG table -------------------------------------------------------------
  2111. deg_table <- openxlsx::read.xlsx(
  2112. deg_file
  2113. )
  2114. required_columns <- c(
  2115. "cluster",
  2116. "gene",
  2117. "avg_log2FC",
  2118. "p_val_adj"
  2119. )
  2120. missing_columns <- setdiff(
  2121. required_columns,
  2122. colnames(deg_table)
  2123. )
  2124. if (length(missing_columns) > 0) {
  2125. stop(
  2126. paste0(
  2127. "The DEG table is missing: ",
  2128. paste(missing_columns, collapse = ", ")
  2129. )
  2130. )
  2131. }
  2132. # 5. Select significant positively regulated genes -----------------------------
  2133. markers <- deg_table |>
  2134. dplyr::filter(
  2135. !is.na(cluster),
  2136. !is.na(gene),
  2137. avg_log2FC > log2fc_cutoff,
  2138. p_val_adj < adjusted_p_cutoff
  2139. ) |>
  2140. dplyr::distinct(
  2141. cluster,
  2142. gene,
  2143. .keep_all = TRUE
  2144. )
  2145. if (nrow(markers) == 0) {
  2146. stop("No genes passed the DEG filtering criteria.")
  2147. }
  2148. write.csv(
  2149. markers,
  2150. file = file.path(
  2151. output_dir,
  2152. "GO_input_genes.csv"
  2153. ),
  2154. row.names = FALSE
  2155. )
  2156. # 6. Map mouse gene symbols to Entrez identifiers ------------------------------
  2157. gene_mapping <- clusterProfiler::bitr(
  2158. unique(markers$gene),
  2159. fromType = "SYMBOL",
  2160. toType = "ENTREZID",
  2161. OrgDb = org.Mm.eg.db
  2162. )
  2163. markers_mapped <- markers |>
  2164. dplyr::inner_join(
  2165. gene_mapping,
  2166. by = c(
  2167. "gene" = "SYMBOL"
  2168. )
  2169. )
  2170. if (nrow(markers_mapped) == 0) {
  2171. stop("None of the selected genes could be mapped to Entrez identifiers.")
  2172. }
  2173. write.csv(
  2174. markers_mapped,
  2175. file = file.path(
  2176. output_dir,
  2177. "GO_input_genes_with_Entrez_IDs.csv"
  2178. ),
  2179. row.names = FALSE
  2180. )
  2181. mapping_summary <- data.frame(
  2182. total_selected_symbols = length(
  2183. unique(markers$gene)
  2184. ),
  2185. mapped_symbols = length(
  2186. unique(markers_mapped$gene)
  2187. ),
  2188. unmapped_symbols = length(
  2189. setdiff(
  2190. unique(markers$gene),
  2191. unique(markers_mapped$gene)
  2192. )
  2193. )
  2194. )
  2195. write.csv(
  2196. mapping_summary,
  2197. file = file.path(
  2198. output_dir,
  2199. "GO_gene_mapping_summary.csv"
  2200. ),
  2201. row.names = FALSE
  2202. )
  2203. # 7. GO Biological Process enrichment by cluster ------------------------------
  2204. ego <- compareCluster(
  2205. ENTREZID ~ cluster,
  2206. data = markers_mapped,
  2207. fun = "enrichGO",
  2208. OrgDb = org.Mm.eg.db,
  2209. keyType = "ENTREZID",
  2210. ont = go_ontology,
  2211. pAdjustMethod = p_adjust_method,
  2212. pvalueCutoff = enrichment_p_cutoff,
  2213. qvalueCutoff = enrichment_q_cutoff,
  2214. minGSSize = min_gene_set_size,
  2215. maxGSSize = max_gene_set_size,
  2216. readable = FALSE
  2217. )
  2218. ego_readable <- setReadable(
  2219. ego,
  2220. OrgDb = org.Mm.eg.db,
  2221. keyType = "ENTREZID"
  2222. )
  2223. ego_df <- as.data.frame(
  2224. ego_readable
  2225. )
  2226. if (nrow(ego_df) == 0) {
  2227. stop("GO enrichment returned no terms.")
  2228. }
  2229. write.csv(
  2230. ego_df,
  2231. file = file.path(
  2232. output_dir,
  2233. "Enrichment_GO_complete.csv"
  2234. ),
  2235. row.names = FALSE
  2236. )
  2237. saveRDS(
  2238. ego_readable,
  2239. file = file.path(
  2240. output_dir,
  2241. "GO_compareCluster_result.rds"
  2242. )
  2243. )
  2244. # 8. Prepare bubble-plot data ---------------------------------------------------
  2245. plot_data <- ego_df |>
  2246. dplyr::filter(
  2247. !is.na(p.adjust)
  2248. ) |>
  2249. dplyr::mutate(
  2250. adjust_group = cut(
  2251. p.adjust,
  2252. breaks = c(
  2253. -Inf,
  2254. 0.0001,
  2255. 0.001,
  2256. 0.01,
  2257. 0.05,
  2258. 0.1,
  2259. Inf
  2260. ),
  2261. labels = c(
  2262. "<0.0001",
  2263. "<0.001",
  2264. "<0.01",
  2265. "<0.05",
  2266. "<0.1",
  2267. ">0.1"
  2268. ),
  2269. right = FALSE
  2270. )
  2271. )
  2272. adjust_group_levels <- c(
  2273. "<0.0001",
  2274. "<0.001",
  2275. "<0.01",
  2276. "<0.05",
  2277. "<0.1",
  2278. ">0.1"
  2279. )
  2280. plot_data$adjust_group <- factor(
  2281. plot_data$adjust_group,
  2282. levels = adjust_group_levels
  2283. )
  2284. # Preserve the cluster order from the input table. Replace this with an explicit
  2285. # final order if a different order was used in the published figure.
  2286. cluster_order <- unique(
  2287. as.character(markers$cluster)
  2288. )
  2289. plot_data$Cluster <- factor(
  2290. as.character(plot_data$Cluster),
  2291. levels = cluster_order
  2292. )
  2293. # Preserve the order of GO terms as they appear in the enrichment output.
  2294. description_order <- unique(
  2295. as.character(plot_data$Description)
  2296. )
  2297. plot_data$Description <- factor(
  2298. plot_data$Description,
  2299. levels = rev(description_order)
  2300. )
  2301. write.csv(
  2302. plot_data,
  2303. file = file.path(
  2304. output_dir,
  2305. "GO_bubble_plot_data.csv"
  2306. ),
  2307. row.names = FALSE
  2308. )
  2309. # 9. Generate the main bubble plot ---------------------------------------------
  2310. bubble_plot <- ggplot(
  2311. plot_data,
  2312. aes(
  2313. x = Cluster,
  2314. y = Description
  2315. )
  2316. ) +
  2317. geom_vline(
  2318. xintercept = seq_along(cluster_order),
  2319. colour = "#D3D3D3"
  2320. ) +
  2321. geom_hline(
  2322. yintercept = seq_along(description_order),
  2323. colour = "#E8E8E8"
  2324. ) +
  2325. geom_point(
  2326. aes(
  2327. colour = adjust_group,
  2328. size = Count
  2329. ),
  2330. shape = 19
  2331. ) +
  2332. scale_colour_manual(
  2333. values = c(
  2334. "<0.0001" = "#67000D",
  2335. "<0.001" = "#EF3B2C",
  2336. "<0.01" = "#FB6A4A",
  2337. "<0.05" = "#FC9272",
  2338. "<0.1" = "#FEE0D2",
  2339. ">0.1" = "#FFF5F0"
  2340. ),
  2341. drop = FALSE
  2342. ) +
  2343. cowplot::theme_cowplot() +
  2344. theme(
  2345. panel.grid.major = element_blank(),
  2346. axis.text.x = element_text(
  2347. angle = 45,
  2348. hjust = 1
  2349. ),
  2350. panel.border = element_rect(
  2351. colour = "black",
  2352. fill = NA,
  2353. linewidth = 1
  2354. ),
  2355. plot.title = element_text(
  2356. hjust = 0.5
  2357. ),
  2358. legend.direction = "vertical",
  2359. legend.position = "right"
  2360. ) +
  2361. labs(
  2362. x = NULL,
  2363. y = NULL,
  2364. colour = "Adjusted P",
  2365. size = "Gene count"
  2366. ) +
  2367. guides(
  2368. colour = guide_legend(
  2369. override.aes = list(
  2370. size = 4
  2371. ),
  2372. order = 2
  2373. ),
  2374. size = guide_legend(
  2375. order = 1
  2376. )
  2377. ) +
  2378. scale_y_discrete(
  2379. position = "right"
  2380. )
  2381. # 10. Optional left annotation panel -------------------------------------------
  2382. final_plot <- bubble_plot
  2383. if (file.exists(term_annotation_file)) {
  2384. term_annotation <- openxlsx::read.xlsx(
  2385. term_annotation_file
  2386. )
  2387. required_annotation_columns <- c(
  2388. "Description",
  2389. "Annotation"
  2390. )
  2391. missing_annotation_columns <- setdiff(
  2392. required_annotation_columns,
  2393. colnames(term_annotation)
  2394. )
  2395. if (length(missing_annotation_columns) > 0) {
  2396. stop(
  2397. paste0(
  2398. "The GO-term annotation file is missing: ",
  2399. paste(missing_annotation_columns, collapse = ", ")
  2400. )
  2401. )
  2402. }
  2403. term_annotation <- term_annotation |>
  2404. dplyr::filter(
  2405. Description %in% as.character(
  2406. plot_data$Description
  2407. )
  2408. ) |>
  2409. dplyr::distinct(
  2410. Description,
  2411. .keep_all = TRUE
  2412. )
  2413. term_annotation$Description <- factor(
  2414. term_annotation$Description,
  2415. levels = levels(
  2416. plot_data$Description
  2417. )
  2418. )
  2419. annotation_levels <- unique(
  2420. as.character(
  2421. term_annotation$Annotation
  2422. )
  2423. )
  2424. annotation_colors <- setNames(
  2425. grDevices::hcl.colors(
  2426. n = length(annotation_levels),
  2427. palette = "Dark 3"
  2428. ),
  2429. annotation_levels
  2430. )
  2431. left_annotation_plot <- term_annotation |>
  2432. dplyr::mutate(
  2433. panel = ""
  2434. ) |>
  2435. ggplot(
  2436. aes(
  2437. x = panel,
  2438. y = Description,
  2439. fill = Annotation
  2440. )
  2441. ) +
  2442. geom_tile() +
  2443. scale_fill_manual(
  2444. values = annotation_colors
  2445. ) +
  2446. scale_y_discrete(
  2447. position = "right",
  2448. limits = levels(
  2449. plot_data$Description
  2450. )
  2451. ) +
  2452. theme_minimal() +
  2453. labs(
  2454. x = NULL,
  2455. y = NULL,
  2456. fill = "Annotations"
  2457. ) +
  2458. theme(
  2459. axis.text.y = element_blank(),
  2460. axis.text.x = element_blank(),
  2461. axis.ticks = element_blank(),
  2462. panel.grid = element_blank(),
  2463. legend.position = "top",
  2464. legend.direction = "horizontal"
  2465. )
  2466. final_plot <- bubble_plot |>
  2467. aplot::insert_left(
  2468. left_annotation_plot,
  2469. width = 0.08
  2470. )
  2471. }
  2472. # 11. Save the final plot -------------------------------------------------------
  2473. ggsave(
  2474. filename = file.path(
  2475. output_figure_dir,
  2476. "GO_Bubble.pdf"
  2477. ),
  2478. plot = final_plot,
  2479. width = 18,
  2480. height = 8,
  2481. units = "in"
  2482. )
  2483. ggsave(
  2484. filename = file.path(
  2485. output_figure_dir,
  2486. "GO_Bubble.tiff"
  2487. ),
  2488. plot = final_plot,
  2489. width = 18,
  2490. height = 8,
  2491. units = "in",
  2492. dpi = 600,
  2493. compression = "lzw"
  2494. )
  2495. ------------------------------------------------------------------------------
  2496. # ==============================================================================
  2497. # (7) UCell pathway scoring and visualization
  2498. #
  2499. # Description:
  2500. # This script calculates a UCell score for the gene set "dendrite development"
  2501. # and visualizes the score distribution across the specified experimental groups.
  2502. #
  2503. # Software used in the original analysis:
  2504. # UCell 2.1.1
  2505. # ggrastr 1.0.1
  2506. # ==============================================================================
  2507. # 1. Load packages -------------------------------------------------------------
  2508. suppressPackageStartupMessages({
  2509. library(Seurat)
  2510. library(UCell)
  2511. library(ggrastr)
  2512. library(ggplot2)
  2513. library(ggpubr)
  2514. library(dplyr)
  2515. })
  2516. # 2. Define paths ---------------------------------------------------------------
  2517. input_file <- "DATA/OUTPUT/scRNA_HarmonyIntegrated_annotated.rds"
  2518. geneset_file <- "DATA/step0-Microglia_Pathways_geneset0.rds"
  2519. output_figure_dir <- "FIGURE"
  2520. output_data_dir <- "DATA/OUTPUT"
  2521. dir.create(output_figure_dir, recursive = TRUE, showWarnings = FALSE)
  2522. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  2523. # 3. Define analysis parameters ------------------------------------------------
  2524. pathway_name <- "dendrite development"
  2525. group_levels <- c(
  2526. "NS_P3",
  2527. "NS_P7",
  2528. "NS_P12"
  2529. )
  2530. comparisons <- list(
  2531. c("NS_P3", "NS_P7"),
  2532. c("NS_P7", "NS_P12"),
  2533. c("NS_P3", "NS_P12")
  2534. )
  2535. group_colors <- c(
  2536. "NS_P3" = "#507BA8",
  2537. "NS_P7" = "#F38D37",
  2538. "NS_P12" = "#F1CE60"
  2539. )
  2540. random_seed <- 1234
  2541. set.seed(random_seed)
  2542. # 4. Load Seurat object and gene sets ------------------------------------------
  2543. scRNA <- readRDS(input_file)
  2544. markers <- readRDS(geneset_file)
  2545. if (!inherits(scRNA, "Seurat")) {
  2546. stop("The input file must contain a Seurat object.")
  2547. }
  2548. if (!"group" %in% colnames([email hidden])) {
  2549. stop("The Seurat metadata must contain a column named 'group'.")
  2550. }
  2551. if (!pathway_name %in% names(markers)) {
  2552. stop(
  2553. paste0(
  2554. "The requested pathway was not found in the gene-set object: ",
  2555. pathway_name
  2556. )
  2557. )
  2558. }
  2559. # 5. Prepare gene set -----------------------------------------------------------
  2560. features <- list()
  2561. features[[pathway_name]] <- unique(as.character(markers[[pathway_name]]))
  2562. features[[pathway_name]] <- features[[pathway_name]][
  2563. !is.na(features[[pathway_name]]) &
  2564. features[[pathway_name]] != ""
  2565. ]
  2566. if (length(features[[pathway_name]]) == 0) {
  2567. stop("The selected pathway gene set is empty.")
  2568. }
  2569. # 6. Calculate UCell score ------------------------------------------------------
  2570. marker_score <- AddModuleScore_UCell(
  2571. object = scRNA,
  2572. features = features
  2573. )
  2574. ucell_columns <- grep(
  2575. "UCell$",
  2576. colnames([email hidden]),
  2577. value = TRUE
  2578. )
  2579. colnames([email hidden])[
  2580. match(ucell_columns, colnames([email hidden]))
  2581. ] <- gsub("\\.", " ", ucell_columns)
  2582. score_column <- paste0(pathway_name, "_UCell")
  2583. if (!score_column %in% colnames([email hidden])) {
  2584. stop(
  2585. paste0(
  2586. "The expected UCell score column was not found: ",
  2587. score_column
  2588. )
  2589. )
  2590. }
  2591. # 7. Extract plotting data ------------------------------------------------------
  2592. plot_data <- FetchData(
  2593. object = marker_score,
  2594. vars = c("group", score_column)
  2595. )
  2596. colnames(plot_data) <- c("group", "score")
  2597. plot_data <- plot_data |>
  2598. dplyr::filter(
  2599. group %in% group_levels
  2600. )
  2601. if (nrow(plot_data) == 0) {
  2602. stop("No cells remained after filtering to the selected groups.")
  2603. }
  2604. plot_data$group <- factor(
  2605. plot_data$group,
  2606. levels = group_levels
  2607. )
  2608. cell_number <- plot_data |>
  2609. dplyr::group_by(group) |>
  2610. dplyr::summarise(
  2611. n_cells = n(),
  2612. y = max(score, na.rm = TRUE) + 0.05,
  2613. .groups = "drop"
  2614. ) |>
  2615. dplyr::mutate(
  2616. label = paste0("n=", n_cells)
  2617. )
  2618. write.csv(
  2619. plot_data,
  2620. file = file.path(
  2621. output_data_dir,
  2622. "UCell_dendrite_development_scores_by_cell.csv"
  2623. ),
  2624. row.names = FALSE
  2625. )
  2626. write.csv(
  2627. cell_number,
  2628. file = file.path(
  2629. output_data_dir,
  2630. "UCell_dendrite_development_group_counts.csv"
  2631. ),
  2632. row.names = FALSE
  2633. )
  2634. # 8. Plot score distributions ---------------------------------------------------
  2635. p <- ggplot(
  2636. plot_data,
  2637. aes(
  2638. x = group,
  2639. y = score,
  2640. fill = group,
  2641. color = group
  2642. )
  2643. ) +
  2644. theme_minimal() +
  2645. theme(
  2646. panel.border = element_blank(),
  2647. panel.grid = element_blank(),
  2648. axis.line = element_line(color = "black"),
  2649. axis.ticks = element_line(color = "black", linewidth = 0.5),
  2650. axis.text.x = element_text(
  2651. size = 6,
  2652. face = "plain",
  2653. color = "black"
  2654. ),
  2655. axis.text.y = element_text(
  2656. size = 6,
  2657. face = "plain",
  2658. color = "black"
  2659. ),
  2660. plot.title = element_blank(),
  2661. axis.title.y = element_text(
  2662. color = "black",
  2663. size = 8,
  2664. face = "bold",
  2665. vjust = 0.5
  2666. ),
  2667. legend.position = "none"
  2668. ) +
  2669. labs(
  2670. x = NULL,
  2671. y = NULL,
  2672. title = pathway_name
  2673. ) +
  2674. ggrastr::geom_jitter_rast(
  2675. color = "#00000033",
  2676. pch = 19,
  2677. size = 0.8,
  2678. stroke = 0.01,
  2679. position = position_jitter(0.15),
  2680. alpha = 0.3
  2681. ) +
  2682. scale_fill_manual(
  2683. values = group_colors
  2684. ) +
  2685. scale_color_manual(
  2686. values = group_colors
  2687. ) +
  2688. geom_boxplot(
  2689. color = "black",
  2690. outlier.shape = NA,
  2691. alpha = 0.8,
  2692. linewidth = 0.5,
  2693. width = 0.4
  2694. ) +
  2695. ggpubr::stat_compare_means(
  2696. comparisons = comparisons,
  2697. method = "t.test",
  2698. label = "p.signif",
  2699. size = 4,
  2700. vjust = -0.5
  2701. ) +
  2702. geom_text(
  2703. data = cell_number,
  2704. aes(
  2705. x = group,
  2706. y = y,
  2707. label = label
  2708. ),
  2709. inherit.aes = FALSE,
  2710. size = 2.5
  2711. )
  2712. ggsave(
  2713. filename = file.path(
  2714. output_figure_dir,
  2715. "dendrite_development_score.pdf"
  2716. ),
  2717. plot = p,
  2718. width = 4.5,
  2719. height = 3.5,
  2720. units = "cm",
  2721. dpi = 600
  2722. )
  2723. ggsave(
  2724. filename = file.path(
  2725. output_figure_dir,
  2726. "dendrite_development_score.tiff"
  2727. ),
  2728. plot = p,
  2729. width = 4.5,
  2730. height = 3.5,
  2731. units = "cm",
  2732. dpi = 600,
  2733. compression = "lzw"
  2734. )
  2735. # ==============================================================================
  2736. # (8) Monocle 3 trajectory analysis
  2737. #
  2738. # Description:
  2739. # This script constructs a Monocle 3 cell_data_set from a Seurat object,
  2740. # imports the Seurat UMAP coordinates, learns the principal graph, and orders
  2741. # cells in pseudotime using PAM microglia as the trajectory root.
  2742. # ==============================================================================
  2743. # 1. Load packages -------------------------------------------------------------
  2744. suppressPackageStartupMessages({
  2745. library(Seurat)
  2746. library(monocle3)
  2747. library(dplyr)
  2748. library(ggplot2)
  2749. library(igraph)
  2750. })
  2751. set.seed(1234)
  2752. # 2. Define input and output paths ---------------------------------------------
  2753. input_file <- "DATA/INPUT/scRNA2.rds"
  2754. output_data_dir <- "DATA/OUTPUT"
  2755. output_figure_dir <- "FIGURE/STEP1.5"
  2756. dir.create(
  2757. output_data_dir,
  2758. recursive = TRUE,
  2759. showWarnings = FALSE
  2760. )
  2761. dir.create(
  2762. output_figure_dir,
  2763. recursive = TRUE,
  2764. showWarnings = FALSE
  2765. )
  2766. # 3. Analysis parameters -------------------------------------------------------
  2767. num_dimensions <- 50
  2768. root_celltypes <- "PAM"
  2769. use_partition <- FALSE
  2770. close_loop <- TRUE
  2771. # 4. Load Seurat object --------------------------------------------------------
  2772. scRNA <- readRDS(input_file)
  2773. if (!inherits(scRNA, "Seurat")) {
  2774. stop("The input file must contain a Seurat object.")
  2775. }
  2776. if (!"celltype" %in% colnames([email hidden])) {
  2777. stop("The Seurat metadata must contain a column named 'celltype'.")
  2778. }
  2779. if (!"SCT" %in% names(scRNA@assays)) {
  2780. stop("The Seurat object does not contain an SCT assay.")
  2781. }
  2782. if (!"umap" %in% names(scRNA@reductions)) {
  2783. stop("The Seurat object does not contain a UMAP reduction.")
  2784. }
  2785. print(table(scRNA$celltype))
  2786. if ("group" %in% colnames([email hidden])) {
  2787. print(table(scRNA$group, scRNA$celltype))
  2788. }
  2789. # 5. Construct the Monocle 3 cell_data_set -------------------------------------
  2790. count_matrix <- GetAssayData(
  2791. object = scRNA,
  2792. assay = "SCT",
  2793. slot = "counts"
  2794. )
  2795. gene_annotation <- data.frame(
  2796. gene_short_name = rownames(count_matrix),
  2797. row.names = rownames(count_matrix)
  2798. )
  2799. cell_metadata <- [email hidden]
  2800. cds <- new_cell_data_set(
  2801. expression_data = count_matrix,
  2802. cell_metadata = cell_metadata,
  2803. gene_metadata = gene_annotation
  2804. )
  2805. # 6. Preprocess the data --------------------------------------------------------
  2806. cds <- preprocess_cds(
  2807. cds,
  2808. num_dim = num_dimensions
  2809. )
  2810. # 7. Perform UMAP dimensionality reduction -------------------------------------
  2811. cds <- reduce_dimension(
  2812. cds,
  2813. preprocess_method = "PCA",
  2814. reduction_method = "UMAP",
  2815. umap.fast_sgd = FALSE,
  2816. cores = 1
  2817. )
  2818. # 8. Cluster cells --------------------------------------------------------------
  2819. # Clustering is required before learning the principal graph.
  2820. cds <- cluster_cells(cds)
  2821. print(table(cds@clusters$UMAP$partitions))
  2822. print(table(cds@clusters$UMAP$clusters))
  2823. # 9. Import the Seurat UMAP coordinates ----------------------------------------
  2824. seurat_umap <- Embeddings(
  2825. object = scRNA,
  2826. reduction = "umap"
  2827. )
  2828. if (!all(colnames(cds) %in% rownames(seurat_umap))) {
  2829. stop("Cell names in the Seurat UMAP do not match those in the Monocle object.")
  2830. }
  2831. seurat_umap <- seurat_umap[
  2832. colnames(cds),
  2833. ,
  2834. drop = FALSE
  2835. ]
  2836. cds@int_colData$reducedDims$UMAP <- seurat_umap
  2837. # 10. Learn the principal graph ------------------------------------------------
  2838. cds <- learn_graph(
  2839. cds,
  2840. use_partition = use_partition,
  2841. close_loop = close_loop,
  2842. verbose = TRUE
  2843. )
  2844. # 11. Define the trajectory root -----------------------------------------------
  2845. get_root_pr_nodes <- function(
  2846. cds,
  2847. root_celltypes,
  2848. celltype_column = "celltype"
  2849. ) {
  2850. cell_metadata <- colData(cds)
  2851. root_cell_indices <- which(
  2852. cell_metadata[[celltype_column]] %in% root_celltypes
  2853. )
  2854. if (length(root_cell_indices) == 0) {
  2855. stop(
  2856. paste0(
  2857. "No cells were found for the selected root cell type(s): ",
  2858. paste(root_celltypes, collapse = ", ")
  2859. )
  2860. )
  2861. }
  2862. closest_vertex <-
  2863. cds@principal_graph_aux[["UMAP"]]$
  2864. pr_graph_cell_proj_closest_vertex
  2865. closest_vertex <- as.matrix(
  2866. closest_vertex[
  2867. colnames(cds),
  2868. ,
  2869. drop = FALSE
  2870. ]
  2871. )
  2872. most_frequent_vertex <- names(
  2873. which.max(
  2874. table(
  2875. closest_vertex[root_cell_indices, 1]
  2876. )
  2877. )
  2878. )
  2879. root_vertex_index <- as.numeric(most_frequent_vertex)
  2880. root_pr_nodes <-
  2881. igraph::V(
  2882. principal_graph(cds)[["UMAP"]]
  2883. )$name[root_vertex_index]
  2884. return(root_pr_nodes)
  2885. }
  2886. root_pr_nodes <- get_root_pr_nodes(
  2887. cds = cds,
  2888. root_celltypes = root_celltypes
  2889. )
  2890. message(
  2891. "Selected root principal node: ",
  2892. paste(root_pr_nodes, collapse = ", ")
  2893. )
  2894. # 12. Order cells in pseudotime ------------------------------------------------
  2895. cds <- order_cells(
  2896. cds,
  2897. root_pr_nodes = root_pr_nodes
  2898. )
  2899. # 13. Define the cell-type colour palette --------------------------------------
  2900. celltype_levels <- levels(
  2901. factor(colData(cds)$celltype)
  2902. )
  2903. celltype_colors <- c(
  2904. "#66C2A5",
  2905. "#FC8D62",
  2906. "#8DA0CB",
  2907. "#E78AC3",
  2908. "#A6D854",
  2909. "#FFD92F",
  2910. "#E5C494",
  2911. "#B3B3B3",
  2912. "#A6CEE3",
  2913. "#1F78B4",
  2914. "#9A3232",
  2915. "#8F6830",
  2916. "#8F8F30",
  2917. "#4F8F30",
  2918. "#308F68"
  2919. )
  2920. if (length(celltype_levels) > length(celltype_colors)) {
  2921. stop("The colour palette contains fewer colours than cell types.")
  2922. }
  2923. celltype_colors <- celltype_colors[
  2924. seq_along(celltype_levels)
  2925. ]
  2926. names(celltype_colors) <- celltype_levels
  2927. # 14. Plot the trajectory by cell type -----------------------------------------
  2928. p_trajectory <- plot_cells(
  2929. cds,
  2930. color_cells_by = "celltype",
  2931. label_groups_by_cluster = FALSE,
  2932. label_leaves = FALSE,
  2933. label_branch_points = FALSE
  2934. ) +
  2935. scale_colour_manual(
  2936. values = celltype_colors
  2937. ) +
  2938. theme(
  2939. panel.border = element_rect(
  2940. fill = NA,
  2941. colour = "black",
  2942. linewidth = 0.5,
  2943. linetype = "solid"
  2944. )
  2945. )
  2946. ggsave(
  2947. filename = file.path(
  2948. output_figure_dir,
  2949. "UMAP_based_on_Seurat_trajectory.pdf"
  2950. ),
  2951. plot = p_trajectory,
  2952. width = 4,
  2953. height = 4,
  2954. units = "in",
  2955. device = cairo_pdf
  2956. )
  2957. # 15. Plot pseudotime -----------------------------------------------------------
  2958. p_pseudotime <- plot_cells(
  2959. cds,
  2960. color_cells_by = "pseudotime",
  2961. label_groups_by_cluster = FALSE,
  2962. label_leaves = FALSE,
  2963. label_branch_points = FALSE
  2964. ) +
  2965. theme(
  2966. panel.border = element_rect(
  2967. fill = NA,
  2968. colour = "black",
  2969. linewidth = 0.5,
  2970. linetype = "solid"
  2971. )
  2972. )
  2973. ggsave(
  2974. filename = file.path(
  2975. output_figure_dir,
  2976. "UMAP_pseudotime.pdf"
  2977. ),
  2978. plot = p_pseudotime,
  2979. width = 4,
  2980. height = 4,
  2981. units = "in",
  2982. device = cairo_pdf
  2983. )
  2984. # 16. Export pseudotime values -------------------------------------------------
  2985. pseudotime_values <- monocle3::pseudotime(cds)
  2986. pseudotime_table <- data.frame(
  2987. cell_id = names(pseudotime_values),
  2988. celltype = colData(cds)[
  2989. names(pseudotime_values),
  2990. "celltype"
  2991. ],
  2992. pseudotime = as.numeric(pseudotime_values),
  2993. row.names = NULL
  2994. )
  2995. if ("group" %in% colnames(colData(cds))) {
  2996. pseudotime_table$group <- colData(cds)[
  2997. pseudotime_table$cell_id,
  2998. "group"
  2999. ]
  3000. }
  3001. write.csv(
  3002. pseudotime_table,
  3003. file = file.path(
  3004. output_data_dir,
  3005. "Monocle3_cell_pseudotime.csv"
  3006. ),
  3007. row.names = FALSE
  3008. )
  3009. # 17. Save the ordered Monocle object ------------------------------------------
  3010. saveRDS(
  3011. cds,
  3012. file = file.path(
  3013. output_data_dir,
  3014. "Monocle3_ordered_cds.rds"
  3015. )
  3016. )
  3017. # 18. Save analysis parameters -------------------------------------------------
  3018. parameter_information <- c(
  3019. paste0("Input file: ", input_file),
  3020. paste0("Expression assay: SCT"),
  3021. paste0("Expression slot: counts"),
  3022. paste0("Number of PCA dimensions: ", num_dimensions),
  3023. paste0(
  3024. "Root cell type(s): ",
  3025. paste(root_celltypes, collapse = ", ")
  3026. ),
  3027. paste0(
  3028. "Root principal node(s): ",
  3029. paste(root_pr_nodes, collapse = ", ")
  3030. ),
  3031. paste0("use_partition: ", use_partition),
  3032. paste0("close_loop: ", close_loop),
  3033. paste0("Random seed: 1234")
  3034. )
  3035. writeLines(
  3036. parameter_information,
  3037. con = file.path(
  3038. output_data_dir,
  3039. "Monocle3_analysis_parameters.txt"
  3040. )
  3041. )
  3042. # 19. Save software and package information ------------------------------------
  3043. writeLines(
  3044. capture.output(sessionInfo()),
  3045. con = file.path(
  3046. output_data_dir,
  3047. "Monocle3_sessionInfo.txt"
  3048. )
  3049. )
  3050. # ==============================================================================
  3051. # Reorder microglial states and plot the final module-expression heatmap
  3052. # ==============================================================================
  3053. custom_order <- c(
  3054. "Mg5",
  3055. "Mg6",
  3056. "Mg8",
  3057. "Mg4",
  3058. "Mg3",
  3059. "Mg7",
  3060. "Mg2",
  3061. "Mg1"
  3062. )
  3063. missing_columns <- setdiff(
  3064. custom_order,
  3065. colnames(aggregate_module_matrix)
  3066. )
  3067. if (length(missing_columns) > 0) {
  3068. stop(
  3069. paste0(
  3070. "The following cell types are missing from the aggregated matrix: ",
  3071. paste(missing_columns, collapse = ", ")
  3072. )
  3073. )
  3074. }
  3075. aggregate_module_matrix <- aggregate_module_matrix[
  3076. ,
  3077. custom_order,
  3078. drop = FALSE
  3079. ]
  3080. dir.create(
  3081. "FIGURE",
  3082. recursive = TRUE,
  3083. showWarnings = FALSE
  3084. )
  3085. pdf(
  3086. file = "FIGURE/CellType_GeneCluster_CombineModule.pdf",
  3087. width = 4.5,
  3088. height = 1.8
  3089. )
  3090. pheatmap::pheatmap(
  3091. aggregate_module_matrix,
  3092. scale = "row",
  3093. show_rownames = TRUE,
  3094. show_colnames = TRUE,
  3095. cluster_cols = FALSE,
  3096. cluster_rows = FALSE,
  3097. annotation_col = NULL,
  3098. annotation_row = NULL,
  3099. fontsize_col = 11,
  3100. angle_col = 45,
  3101. border_color = "white",
  3102. color = rev(
  3103. RColorBrewer::brewer.pal(
  3104. n = 10,
  3105. name = "RdBu"
  3106. )
  3107. )
  3108. )
  3109. dev.off()
  3110. ```
  3111. # ============================================================================
  3112. # Monocle 3 pseudotime heatmap
  3113. #
  3114. # Description:
  3115. # This script generates the pseudotime expression heatmap used in the
  3116. # manuscript. Cells are ordered by Monocle 3 pseudotime, grouped into 0.3-unit
  3117. # pseudotime intervals, and trajectory-associated genes are ordered by the first
  3118. # interval in which their scaled mean expression reaches at least 99% of the
  3119. # gene-specific maximum. Five temporal gene clusters are defined using the
  3120. # prespecified peak-interval boundaries used in the original analysis.
  3121. # ============================================================================
  3122. # 1. Load packages -----------------------------------------------------------
  3123. suppressPackageStartupMessages({
  3124. library(Seurat)
  3125. library(monocle3)
  3126. library(dplyr)
  3127. library(Hmisc)
  3128. library(openxlsx)
  3129. library(ComplexHeatmap)
  3130. library(viridisLite)
  3131. library(grid)
  3132. })
  3133. set.seed(1234)
  3134. # 2. Define input and output paths -------------------------------------------
  3135. cds_file <- "DATA/OUTPUT/Step1.5_cds.rds"
  3136. seurat_file <- "ALL NS_microglia.rds"
  3137. gene_module_file <- "DATA/OUTPUT/gene_module_df.csv"
  3138. label_gene_file <- "DATA/OUTPUT/gene list.xlsx"
  3139. output_data_dir <- "DATA/OUTPUT"
  3140. output_figure_dir <- "FIGURE/STEP3-2"
  3141. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  3142. dir.create(output_figure_dir, recursive = TRUE, showWarnings = FALSE)
  3143. # 3. Analysis parameters -----------------------------------------------------
  3144. pseudotime_bin_width <- 0.3
  3145. # Manual boundaries used in the original analysis. These values refer to the
  3146. # index of the pseudotime interval at which each gene first reaches >=99% of its
  3147. # gene-specific maximum scaled expression.
  3148. temporal_cluster_cutoffs <- c(19, 58, 84, 108)
  3149. cluster_levels <- paste0("cluster", 1:5)
  3150. cluster_colors <- c(
  3151. "cluster1" = "#EDADC5",
  3152. "cluster2" = "#CEAAD0",
  3153. "cluster3" = "#9584C1",
  3154. "cluster4" = "#6CBEC3",
  3155. "cluster5" = "#AAD7C8"
  3156. )
  3157. # 4. Load input objects ------------------------------------------------------
  3158. cds <- readRDS(cds_file)
  3159. scRNA <- readRDS(seurat_file)
  3160. gene_module_df <- read.csv(
  3161. gene_module_file,
  3162. stringsAsFactors = FALSE,
  3163. check.names = FALSE
  3164. )
  3165. if (!inherits(cds, "cell_data_set")) {
  3166. stop("The cds input must be a Monocle 3 cell_data_set object.")
  3167. }
  3168. if (!inherits(scRNA, "Seurat")) {
  3169. stop("The scRNA input must be a Seurat object.")
  3170. }
  3171. if (!"id" %in% colnames(gene_module_df)) {
  3172. stop("The gene-module table must contain a column named 'id'.")
  3173. }
  3174. if (!"SCT" %in% names(scRNA@assays)) {
  3175. stop("The Seurat object does not contain an SCT assay.")
  3176. }
  3177. pseudotime_genes <- unique(as.character(gene_module_df$id))
  3178. pseudotime_genes <- pseudotime_genes[
  3179. pseudotime_genes %in% rownames(scRNA)
  3180. ]
  3181. if (length(pseudotime_genes) == 0) {
  3182. stop("None of the genes in gene_module_df$id were found in the Seurat object.")
  3183. }
  3184. # 5. Extract pseudotime and retain finite values -----------------------------
  3185. cell_pseudotime <- monocle3::pseudotime(cds)
  3186. finite_cells <- names(cell_pseudotime)[
  3187. is.finite(cell_pseudotime)
  3188. ]
  3189. if (length(finite_cells) == 0) {
  3190. stop("No cells have finite pseudotime values.")
  3191. }
  3192. cds <- cds[, finite_cells]
  3193. cell_pseudotime <- cell_pseudotime[finite_cells]
  3194. colData(cds)$pseudotime <- as.numeric(cell_pseudotime)
  3195. # 6. Divide pseudotime into 0.3-unit intervals -------------------------------
  3196. pseudotime_cuts <- seq(
  3197. min(cell_pseudotime),
  3198. max(cell_pseudotime),
  3199. by = pseudotime_bin_width
  3200. )
  3201. if (tail(pseudotime_cuts, 1) < max(cell_pseudotime)) {
  3202. pseudotime_cuts <- c(
  3203. pseudotime_cuts,
  3204. max(cell_pseudotime)
  3205. )
  3206. }
  3207. # Hmisc::cut2 is retained because it was used in the original analysis.
  3208. colData(cds)$pseudotime_bin <- Hmisc::cut2(
  3209. as.numeric(cell_pseudotime),
  3210. cuts = pseudotime_cuts
  3211. )
  3212. write.csv(
  3213. as.data.frame(
  3214. table(
  3215. pseudotime_bin = colData(cds)$pseudotime_bin,
  3216. celltype = colData(cds)$celltype
  3217. )
  3218. ),
  3219. file = file.path(
  3220. output_data_dir,
  3221. "Pseudotime_bin_celltype_counts.csv"
  3222. ),
  3223. row.names = FALSE
  3224. )
  3225. # 7. Transfer pseudotime information to the Seurat object --------------------
  3226. missing_cells <- setdiff(
  3227. colnames(cds),
  3228. colnames(scRNA)
  3229. )
  3230. if (length(missing_cells) > 0) {
  3231. stop(
  3232. paste0(
  3233. "The following Monocle cells are absent from the Seurat object: ",
  3234. paste(head(missing_cells, 10), collapse = ", ")
  3235. )
  3236. )
  3237. }
  3238. scRNA <- scRNA[, colnames(cds)]
  3239. scRNA$pseudotime <- as.numeric(
  3240. colData(cds)$pseudotime
  3241. )
  3242. scRNA$pseudotime_bin <- colData(cds)$pseudotime_bin
  3243. # 8. Calculate mean expression in each pseudotime interval -------------------
  3244. Idents(scRNA) <- "pseudotime_bin"
  3245. average_expression_list <- AverageExpression(
  3246. object = scRNA,
  3247. assays = "SCT",
  3248. features = pseudotime_genes,
  3249. slot = "data",
  3250. verbose = FALSE
  3251. )
  3252. average_expression <- as.matrix(
  3253. average_expression_list[["SCT"]]
  3254. )
  3255. if (nrow(average_expression) == 0 || ncol(average_expression) == 0) {
  3256. stop("AverageExpression returned an empty matrix.")
  3257. }
  3258. # 9. Scale each gene to the range 0-1 ----------------------------------------
  3259. scale_to_unit_interval <- function(x) {
  3260. x_min <- min(x, na.rm = TRUE)
  3261. x_max <- max(x, na.rm = TRUE)
  3262. if (!is.finite(x_min) || !is.finite(x_max)) {
  3263. return(rep(NA_real_, length(x)))
  3264. }
  3265. if (x_max == x_min) {
  3266. return(rep(0, length(x)))
  3267. }
  3268. (x - x_min) / (x_max - x_min)
  3269. }
  3270. scaled_expression <- t(
  3271. apply(
  3272. average_expression,
  3273. 1,
  3274. scale_to_unit_interval
  3275. )
  3276. )
  3277. rownames(scaled_expression) <- rownames(average_expression)
  3278. colnames(scaled_expression) <- colnames(average_expression)
  3279. scaled_expression <- scaled_expression[
  3280. rowSums(is.na(scaled_expression)) == 0,
  3281. ,
  3282. drop = FALSE
  3283. ]
  3284. write.csv(
  3285. scaled_expression,
  3286. file = file.path(
  3287. output_data_dir,
  3288. "Pseudotime_bin_scaled_mean_expression.csv"
  3289. ),
  3290. row.names = TRUE
  3291. )
  3292. # 10. Order genes by the first near-maximum pseudotime interval ---------------
  3293. first_near_maximum_bin <- apply(
  3294. scaled_expression,
  3295. 1,
  3296. function(x) {
  3297. candidate_bins <- which(x >= 0.99)
  3298. if (length(candidate_bins) == 0) {
  3299. return(which.max(x))
  3300. }
  3301. candidate_bins[1]
  3302. }
  3303. )
  3304. gene_order <- order(
  3305. first_near_maximum_bin,
  3306. rownames(scaled_expression)
  3307. )
  3308. ordered_expression <- scaled_expression[
  3309. gene_order,
  3310. ,
  3311. drop = FALSE
  3312. ]
  3313. ordered_peak_bin <- first_near_maximum_bin[
  3314. gene_order
  3315. ]
  3316. # 11. Assign five temporal gene clusters -------------------------------------
  3317. temporal_cluster <- cut(
  3318. ordered_peak_bin,
  3319. breaks = c(
  3320. -Inf,
  3321. temporal_cluster_cutoffs,
  3322. Inf
  3323. ),
  3324. labels = cluster_levels,
  3325. ordered_result = TRUE
  3326. )
  3327. temporal_cluster <- factor(
  3328. temporal_cluster,
  3329. levels = cluster_levels
  3330. )
  3331. gene_cluster_table <- data.frame(
  3332. gene = rownames(ordered_expression),
  3333. peak_pseudotime_bin = as.integer(ordered_peak_bin),
  3334. cluster = as.character(temporal_cluster),
  3335. stringsAsFactors = FALSE
  3336. )
  3337. write.csv(
  3338. gene_cluster_table,
  3339. file = file.path(
  3340. output_data_dir,
  3341. "Pseudotime_gene_temporal_clusters.csv"
  3342. ),
  3343. row.names = FALSE
  3344. )
  3345. openxlsx::write.xlsx(
  3346. gene_cluster_table,
  3347. file = file.path(
  3348. output_data_dir,
  3349. "Pseudotime_gene_temporal_clusters.xlsx"
  3350. ),
  3351. overwrite = TRUE
  3352. )
  3353. # 12. Read genes selected for labelling --------------------------------------
  3354. label_gene_table <- openxlsx::read.xlsx(
  3355. label_gene_file
  3356. )
  3357. if (!"gene" %in% colnames(label_gene_table)) {
  3358. stop("The label-gene file must contain a column named 'gene'.")
  3359. }
  3360. label_genes <- unique(
  3361. as.character(label_gene_table$gene)
  3362. )
  3363. label_positions <- which(
  3364. rownames(ordered_expression) %in% label_genes
  3365. )
  3366. label_names <- rownames(ordered_expression)[
  3367. label_positions
  3368. ]
  3369. # 13. Define pathway labels shown beside each temporal cluster ---------------
  3370. # These labels are display annotations. The enrichment-analysis code and the
  3371. # complete enrichment results used to select these terms should be supplied in
  3372. # a separate script and output table.
  3373. pathway_labels <- c(
  3374. "cluster1" = paste(
  3375. "chromosome segregation",
  3376. "positive regulation of cell cycle",
  3377. "ATP metabolic process",
  3378. sep = "\n"
  3379. ),
  3380. "cluster2" = paste(
  3381. "tumor necrosis factor production",
  3382. "neuron death",
  3383. "lipid localization",
  3384. "synapse pruning",
  3385. sep = "\n"
  3386. ),
  3387. "cluster3" = paste(
  3388. "myeloid leukocyte migration",
  3389. "positive regulation of cytokine production",
  3390. "positive regulation of immune effector process",
  3391. "regulation of vasculature development",
  3392. "regulation of lymphocyte proliferation",
  3393. "regulation of adaptive immune response",
  3394. sep = "\n"
  3395. ),
  3396. "cluster4" = paste(
  3397. "myeloid cell differentiation",
  3398. "lymphocyte differentiation",
  3399. "BMP signaling pathway",
  3400. "response to transforming growth factor beta",
  3401. "actin filament organization",
  3402. sep = "\n"
  3403. ),
  3404. "cluster5" = paste(
  3405. "glial cell migration",
  3406. "gliogenesis",
  3407. "synapse organization",
  3408. "dendrite development",
  3409. "axonogenesis",
  3410. "axon guidance",
  3411. "axon extension",
  3412. "neuron projection guidance",
  3413. sep = "\n"
  3414. )
  3415. )
  3416. pathway_text_colors <- c(
  3417. "cluster1" = "#009E73",
  3418. "cluster2" = "#E69F00",
  3419. "cluster3" = "#E69F00",
  3420. "cluster4" = "#E69F00",
  3421. "cluster5" = "#E69F00"
  3422. )
  3423. # 14. Construct the pseudotime heatmap ---------------------------------------
  3424. cluster_matrix <- matrix(
  3425. as.character(temporal_cluster),
  3426. ncol = 1,
  3427. dimnames = list(
  3428. rownames(ordered_expression),
  3429. "Temporal cluster"
  3430. )
  3431. )
  3432. cluster_heatmap <- Heatmap(
  3433. cluster_matrix,
  3434. name = "Temporal cluster",
  3435. col = cluster_colors,
  3436. cluster_rows = FALSE,
  3437. cluster_columns = FALSE,
  3438. show_row_names = FALSE,
  3439. show_column_names = FALSE,
  3440. width = unit(3, "mm")
  3441. )
  3442. expression_heatmap <- Heatmap(
  3443. ordered_expression,
  3444. name = "%Max",
  3445. col = viridisLite::viridis(256),
  3446. cluster_rows = FALSE,
  3447. cluster_columns = FALSE,
  3448. show_row_names = FALSE,
  3449. show_column_names = FALSE,
  3450. use_raster = FALSE,
  3451. heatmap_legend_param = list(
  3452. title = "%Max"
  3453. )
  3454. )
  3455. gene_label_annotation <- rowAnnotation(
  3456. gene = anno_mark(
  3457. at = label_positions,
  3458. labels = label_names,
  3459. labels_gp = gpar(fontsize = 8)
  3460. )
  3461. )
  3462. heatmap_list <- cluster_heatmap +
  3463. expression_heatmap +
  3464. gene_label_annotation
  3465. # 15. Save the final heatmap --------------------------------------------------
  3466. output_pdf <- file.path(
  3467. output_figure_dir,
  3468. "Pseudotime_heatmap.pdf"
  3469. )
  3470. pdf(
  3471. output_pdf,
  3472. width = 4,
  3473. height = 6,
  3474. useDingbats = FALSE
  3475. )
  3476. draw(
  3477. heatmap_list,
  3478. row_split = temporal_cluster,
  3479. column_title = "Heatmap of pseudotime DEGs",
  3480. column_title_gp = gpar(
  3481. fontsize = 12,
  3482. fontface = "bold"
  3483. ),
  3484. merge_legends = TRUE,
  3485. heatmap_legend_side = "right"
  3486. )
  3487. # Add the pathway terms used as display annotations in the original figure.
  3488. # Their positions are figure-specific and should be checked after rendering.
  3489. for (i in seq_along(cluster_levels)) {
  3490. current_cluster <- cluster_levels[i]
  3491. decorate_heatmap_body(
  3492. "Temporal cluster",
  3493. {
  3494. grid.text(
  3495. pathway_labels[[current_cluster]],
  3496. x = unit(-70, "npc"),
  3497. y = unit(0, "npc"),
  3498. just = "centre",
  3499. hjust = 0,
  3500. vjust = 0,
  3501. gp = gpar(
  3502. fontsize = 12,
  3503. col = pathway_text_colors[[current_cluster]],
  3504. fontface = "italic"
  3505. )
  3506. )
  3507. },
  3508. row_slice = i
  3509. )
  3510. }
  3511. dev.off()
  3512. # 16. Save analysis parameters and software information ----------------------
  3513. analysis_parameters <- c(
  3514. paste0("Pseudotime bin width: ", pseudotime_bin_width),
  3515. paste0(
  3516. "Temporal cluster cutoffs: ",
  3517. paste(temporal_cluster_cutoffs, collapse = ", ")
  3518. ),
  3519. "Gene ordering rule: first pseudotime interval with scaled expression >= 0.99",
  3520. "Expression assay: SCT",
  3521. "Expression slot: data",
  3522. "Gene scaling: row-wise min-max scaling to 0-1",
  3523. "Random seed: 1234"
  3524. )
  3525. writeLines(
  3526. analysis_parameters,
  3527. con = file.path(
  3528. output_data_dir,
  3529. "Pseudotime_heatmap_analysis_parameters.txt"
  3530. )
  3531. )
  3532. writeLines(
  3533. capture.output(sessionInfo()),
  3534. con = file.path(
  3535. output_data_dir,
  3536. "Pseudotime_heatmap_sessionInfo.txt"
  3537. )
  3538. )
  3539. # ==============================================================================
  3540. # (9) scFEA flux post-processing and visualization
  3541. #
  3542. # Description:
  3543. # This script imports the per-cell metabolic flux matrix generated by scFEA,
  3544. # matches cells to Seurat-defined microglial/macrophage states, calculates mean
  3545. # flux for each state, removes modules with no variation across states, orders
  3546. # metabolic modules according to the original analysis, and generates the full
  3547. # and selected-module heatmaps used for visualization.
  3548. #
  3549. # Important:
  3550. # This script does not run the scFEA model itself. The exact scFEA command used
  3551. # to generate adj_flux.csv should be supplied separately.
  3552. # ==============================================================================
  3553. # 1. Load packages -------------------------------------------------------------
  3554. suppressPackageStartupMessages({
  3555. library(Seurat)
  3556. library(dplyr)
  3557. library(openxlsx)
  3558. library(ComplexHeatmap)
  3559. library(circlize)
  3560. library(grid)
  3561. })
  3562. # 2. Define input and output paths ---------------------------------------------
  3563. flux_file <- "DATA/P3 MACROPHAGY/adj_flux.csv"
  3564. module_info_file <- "DATA/P3 MACROPHAGY/scFEA.mouse.moduleinfo.csv"
  3565. seurat_file <- "DATA/P3 MACROPHAGY/p3 all macrophagy NEW NAME.rds"
  3566. selected_module_file <- "DATA/P3 MACROPHAGY/scFEA_Filter.xlsx"
  3567. output_data_dir <- "DATA/OUTPUT"
  3568. output_figure_dir <- "FIGURE/STEP2"
  3569. dir.create(output_data_dir, recursive = TRUE, showWarnings = FALSE)
  3570. dir.create(output_figure_dir, recursive = TRUE, showWarnings = FALSE)
  3571. # 3. Analysis parameters -------------------------------------------------------
  3572. celltype_order <- c(
  3573. "Mg1",
  3574. "Mg2",
  3575. "Cd11c+Mg",
  3576. "PAM",
  3577. "APIM1",
  3578. "APIM2",
  3579. "BAM1",
  3580. "BAM2",
  3581. "BAM3",
  3582. "Inf_BAM"
  3583. )
  3584. broad_celltype <- c(
  3585. "Mg1" = "Microglia",
  3586. "Mg2" = "Microglia",
  3587. "Cd11c+Mg" = "Microglia",
  3588. "PAM" = "Microglia",
  3589. "APIM1" = "Microglia",
  3590. "APIM2" = "Microglia",
  3591. "BAM1" = "BAM",
  3592. "BAM2" = "BAM",
  3593. "BAM3" = "BAM",
  3594. "Inf_BAM" = "BAM"
  3595. )
  3596. # Exact row order used in the original analysis after removal of zero-variance
  3597. # modules. This assumes that 164 modules remain.
  3598. module_order_index <- c(
  3599. 1:142,
  3600. 163,
  3601. 143:159,
  3602. 164,
  3603. 160:162
  3604. )
  3605. heatmap_colors <- circlize::colorRamp2(
  3606. c(-2, -1, 0, 1, 2),
  3607. c(
  3608. "#2166AC",
  3609. "#90C0DC",
  3610. "white",
  3611. "#EF8C65",
  3612. "#B2182B"
  3613. )
  3614. )
  3615. # 4. Read scFEA flux matrix ----------------------------------------------------
  3616. flux_raw <- read.csv(
  3617. flux_file,
  3618. check.names = FALSE,
  3619. stringsAsFactors = FALSE
  3620. )
  3621. if (ncol(flux_raw) < 2) {
  3622. stop("The flux file must contain a barcode column and at least one flux column.")
  3623. }
  3624. colnames(flux_raw)[1] <- "barcode"
  3625. if (anyDuplicated(flux_raw$barcode)) {
  3626. stop("Duplicated cell barcodes were detected in the flux file.")
  3627. }
  3628. rownames(flux_raw) <- flux_raw$barcode
  3629. flux <- flux_raw[
  3630. ,
  3631. setdiff(colnames(flux_raw), "barcode"),
  3632. drop = FALSE
  3633. ]
  3634. flux <- as.data.frame(
  3635. lapply(
  3636. flux,
  3637. function(x) as.numeric(as.character(x))
  3638. ),
  3639. row.names = rownames(flux)
  3640. )
  3641. if (anyNA(flux)) {
  3642. warning("Missing values were detected after converting the flux matrix to numeric.")
  3643. }
  3644. # 5. Read module annotations ---------------------------------------------------
  3645. module_info <- read.csv(
  3646. module_info_file,
  3647. check.names = FALSE,
  3648. stringsAsFactors = FALSE
  3649. )
  3650. required_module_columns <- c(
  3651. "M_id",
  3652. "M_name",
  3653. "SM_anno"
  3654. )
  3655. missing_module_columns <- setdiff(
  3656. required_module_columns,
  3657. colnames(module_info)
  3658. )
  3659. if (length(missing_module_columns) > 0) {
  3660. stop(
  3661. paste0(
  3662. "The module-information file is missing: ",
  3663. paste(missing_module_columns, collapse = ", ")
  3664. )
  3665. )
  3666. )
  3667. # 6. Read Seurat object and match cell states ----------------------------------
  3668. scRNA <- readRDS(seurat_file)
  3669. if (!inherits(scRNA, "Seurat")) {
  3670. stop("The input RDS file must contain a Seurat object.")
  3671. }
  3672. if (!"celltype" %in% colnames([email hidden])) {
  3673. stop("The Seurat metadata must contain a column named 'celltype'.")
  3674. }
  3675. cell_group <- as.character(
  3676. [email hidden][
  3677. match(
  3678. rownames(flux),
  3679. rownames([email hidden])
  3680. ),
  3681. "celltype"
  3682. ]
  3683. )
  3684. if (anyNA(cell_group)) {
  3685. unmatched_barcodes <- rownames(flux)[is.na(cell_group)]
  3686. stop(
  3687. paste0(
  3688. "Some scFEA barcodes were not found in the Seurat metadata. Examples: ",
  3689. paste(head(unmatched_barcodes, 10), collapse = ", ")
  3690. )
  3691. )
  3692. }
  3693. cell_metadata <- data.frame(
  3694. barcode = rownames(flux),
  3695. celltype = cell_group,
  3696. stringsAsFactors = FALSE
  3697. )
  3698. write.csv(
  3699. cell_metadata,
  3700. file = file.path(
  3701. output_data_dir,
  3702. "scFEA_celltype_metadata.csv"
  3703. ),
  3704. row.names = FALSE
  3705. )
  3706. # 7. Calculate mean flux by cell state -----------------------------------------
  3707. flux_with_group <- flux |>
  3708. tibble::rownames_to_column("barcode") |>
  3709. dplyr::left_join(
  3710. cell_metadata,
  3711. by = "barcode"
  3712. )
  3713. mean_flux_by_state <- flux_with_group |>
  3714. dplyr::select(-barcode) |>
  3715. dplyr::group_by(celltype) |>
  3716. dplyr::summarise(
  3717. dplyr::across(
  3718. dplyr::everything(),
  3719. ~ mean(.x, na.rm = TRUE)
  3720. ),
  3721. .groups = "drop"
  3722. )
  3723. missing_celltypes <- setdiff(
  3724. celltype_order,
  3725. mean_flux_by_state$celltype
  3726. )
  3727. if (length(missing_celltypes) > 0) {
  3728. stop(
  3729. paste0(
  3730. "The following cell states are missing from the averaged flux table: ",
  3731. paste(missing_celltypes, collapse = ", ")
  3732. )
  3733. )
  3734. }
  3735. mean_flux_by_state <- mean_flux_by_state[
  3736. match(
  3737. celltype_order,
  3738. mean_flux_by_state$celltype
  3739. ),
  3740. ,
  3741. drop = FALSE
  3742. ]
  3743. df_flux <- t(
  3744. as.matrix(
  3745. mean_flux_by_state[
  3746. ,
  3747. setdiff(
  3748. colnames(mean_flux_by_state),
  3749. "celltype"
  3750. ),
  3751. drop = FALSE
  3752. ]
  3753. )
  3754. )
  3755. colnames(df_flux) <- mean_flux_by_state$celltype
  3756. storage.mode(df_flux) <- "numeric"
  3757. # 8. Remove modules with zero variance -----------------------------------------
  3758. module_sd <- apply(
  3759. df_flux,
  3760. 1,
  3761. stats::sd,
  3762. na.rm = TRUE
  3763. )
  3764. keep_modules <- is.finite(module_sd) & module_sd != 0
  3765. df_flux <- df_flux[
  3766. keep_modules,
  3767. ,
  3768. drop = FALSE
  3769. ]
  3770. message(
  3771. "Number of retained metabolic modules: ",
  3772. nrow(df_flux)
  3773. )
  3774. if (nrow(df_flux) != length(module_order_index)) {
  3775. stop(
  3776. paste0(
  3777. "The original module-order vector expects ",
  3778. length(module_order_index),
  3779. " retained modules, but ",
  3780. nrow(df_flux),
  3781. " were found. Verify the input file and original ordering."
  3782. )
  3783. )
  3784. }
  3785. # 9. Apply the original metabolic-module order ---------------------------------
  3786. df_flux <- df_flux[
  3787. module_order_index,
  3788. ,
  3789. drop = FALSE
  3790. ]
  3791. module_info <- module_info[
  3792. match(
  3793. rownames(df_flux),
  3794. module_info$M_id
  3795. ),
  3796. ,
  3797. drop = FALSE
  3798. ]
  3799. if (anyNA(module_info$M_id)) {
  3800. stop("Some retained flux modules are absent from the module-information file.")
  3801. }
  3802. # 10. Save processed state-level flux data -------------------------------------
  3803. write.csv(
  3804. df_flux,
  3805. file = file.path(
  3806. output_data_dir,
  3807. "scFEA_mean_flux_by_celltype.csv"
  3808. ),
  3809. row.names = TRUE
  3810. )
  3811. write.csv(
  3812. module_info,
  3813. file = file.path(
  3814. output_data_dir,
  3815. "scFEA_retained_module_information.csv"
  3816. ),
  3817. row.names = FALSE
  3818. )
  3819. saveRDS(
  3820. list(
  3821. flux_per_cell = flux,
  3822. cell_metadata = cell_metadata,
  3823. mean_flux_by_celltype = df_flux,
  3824. module_info = module_info
  3825. ),
  3826. file = file.path(
  3827. output_data_dir,
  3828. "scFEA_flux_postprocessing_objects.rds"
  3829. )
  3830. )
  3831. # 11. Read and order selected modules ------------------------------------------
  3832. selected_module_table <- openxlsx::read.xlsx(
  3833. selected_module_file
  3834. )
  3835. if (!"M_id" %in% colnames(selected_module_table)) {
  3836. stop("The selected-module file must contain a column named 'M_id'.")
  3837. }
  3838. selected_module_ids <- as.character(
  3839. selected_module_table$M_id
  3840. )
  3841. missing_selected_modules <- setdiff(
  3842. selected_module_ids,
  3843. rownames(df_flux)
  3844. )
  3845. if (length(missing_selected_modules) > 0) {
  3846. stop(
  3847. paste0(
  3848. "Selected modules missing from the processed flux matrix: ",
  3849. paste(missing_selected_modules, collapse = ", ")
  3850. )
  3851. )
  3852. }
  3853. df_flux_filter <- df_flux[
  3854. selected_module_ids,
  3855. ,
  3856. drop = FALSE
  3857. ]
  3858. # 12. Prepare column annotations -----------------------------------------------
  3859. annotation_col <- data.frame(
  3860. subcelltype = factor(
  3861. celltype_order,
  3862. levels = celltype_order
  3863. ),
  3864. celltype = factor(
  3865. unname(broad_celltype[celltype_order]),
  3866. levels = c("Microglia", "BAM")
  3867. ),
  3868. row.names = celltype_order,
  3869. check.names = FALSE
  3870. )
  3871. celltype_colors <- c(
  3872. "Microglia" = "#92C5DE",
  3873. "BAM" = "#F4A582"
  3874. )
  3875. subcelltype_colors <- c(
  3876. "Mg1" = "#D1E5F0",
  3877. "Mg2" = "#ABD9E9",
  3878. "Cd11c+Mg" = "#92C5DE",
  3879. "PAM" = "#6BAED6",
  3880. "APIM1" = "#3182BD",
  3881. "APIM2" = "#08519C",
  3882. "BAM1" = "#FDDBC7",
  3883. "BAM2" = "#FDD49E",
  3884. "BAM3" = "#FDB863",
  3885. "Inf_BAM" = "#F4A582"
  3886. )
  3887. # 13. Prepare full-matrix row annotations --------------------------------------
  3888. annotation_row_full <- data.frame(
  3889. pathway = factor(
  3890. module_info$SM_anno,
  3891. levels = unique(module_info$SM_anno)
  3892. ),
  3893. row.names = module_info$M_name,
  3894. check.names = FALSE
  3895. )
  3896. df_flux_full_plot <- df_flux
  3897. rownames(df_flux_full_plot) <- module_info$M_name
  3898. pathway_palette_base <- c(
  3899. "#8DD3C7",
  3900. "#FFFFB3",
  3901. "#BEBADA",
  3902. "#FB8072",
  3903. "#80B1D3",
  3904. "#FDB462",
  3905. "#B3DE69",
  3906. "#FCCDE5",
  3907. "#D9D9D9",
  3908. "#BC80BD",
  3909. "#CCEBC5",
  3910. "#FFED6F"
  3911. )
  3912. pathway_levels <- levels(annotation_row_full$pathway)
  3913. pathway_colors <- setNames(
  3914. rep(
  3915. pathway_palette_base,
  3916. length.out = length(pathway_levels)
  3917. ),
  3918. pathway_levels
  3919. )
  3920. annotation_colors_full <- list(
  3921. celltype = celltype_colors,
  3922. subcelltype = subcelltype_colors,
  3923. pathway = pathway_colors
  3924. )
  3925. pathway_run_lengths <- rle(
  3926. as.character(annotation_row_full$pathway)
  3927. )$lengths
  3928. full_row_gaps <- cumsum(pathway_run_lengths)
  3929. full_row_gaps <- full_row_gaps[
  3930. full_row_gaps < nrow(df_flux_full_plot)
  3931. ]
  3932. # 14. Plot the full flux heatmap ------------------------------------------------
  3933. full_heatmap <- ComplexHeatmap::pheatmap(
  3934. df_flux_full_plot,
  3935. scale = "row",
  3936. show_rownames = TRUE,
  3937. show_colnames = FALSE,
  3938. cluster_cols = FALSE,
  3939. cluster_rows = FALSE,
  3940. color = heatmap_colors,
  3941. annotation_col = annotation_col,
  3942. annotation_row = annotation_row_full,
  3943. annotation_names_row = FALSE,
  3944. annotation_names_col = FALSE,
  3945. column_title = NULL,
  3946. row_title = NULL,
  3947. fontsize_col = 8,
  3948. fontsize_row = 4,
  3949. annotation_colors = annotation_colors_full,
  3950. legend = FALSE,
  3951. annotation_legend = TRUE,
  3952. border_color = "white",
  3953. gaps_row = full_row_gaps
  3954. )
  3955. pdf(
  3956. file = file.path(
  3957. output_figure_dir,
  3958. "scFEA_flux_heatmap_full.pdf"
  3959. ),
  3960. width = 12,
  3961. height = 50,
  3962. useDingbats = FALSE
  3963. )
  3964. ComplexHeatmap::draw(
  3965. full_heatmap,
  3966. merge_legends = TRUE
  3967. )
  3968. dev.off()
  3969. # 15. Prepare selected-module annotations --------------------------------------
  3970. selected_module_info <- module_info[
  3971. match(
  3972. selected_module_ids,
  3973. module_info$M_id
  3974. ),
  3975. ,
  3976. drop = FALSE
  3977. ]
  3978. if (anyNA(selected_module_info$M_id)) {
  3979. stop("Some selected modules lack module annotations.")
  3980. }
  3981. df_flux_filter_plot <- df_flux_filter
  3982. rownames(df_flux_filter_plot) <- selected_module_info$M_name
  3983. # In the original analysis, the selected pathway groups were supplied in
  3984. # column X8 of scFEA_Filter.xlsx.
  3985. if (!"X8" %in% colnames(selected_module_table)) {
  3986. stop(
  3987. paste0(
  3988. "The selected-module file must contain column 'X8', ",
  3989. "which stores the display pathway groups used in the original analysis."
  3990. )
  3991. )
  3992. }
  3993. annotation_row_filter <- data.frame(
  3994. pathway = factor(
  3995. as.character(selected_module_table$X8),
  3996. levels = unique(as.character(selected_module_table$X8))
  3997. ),
  3998. row.names = rownames(df_flux_filter_plot),
  3999. check.names = FALSE
  4000. )
  4001. filter_pathway_levels <- levels(
  4002. annotation_row_filter$pathway
  4003. )
  4004. filter_pathway_palette <- c(
  4005. "#709CCC",
  4006. "#8AD2C6",
  4007. "#FB8072"
  4008. )
  4009. if (length(filter_pathway_levels) > length(filter_pathway_palette)) {
  4010. stop(
  4011. "More selected pathway groups were found than colors defined in the original palette."
  4012. )
  4013. }
  4014. filter_pathway_colors <- setNames(
  4015. filter_pathway_palette[
  4016. seq_along(filter_pathway_levels)
  4017. ],
  4018. filter_pathway_levels
  4019. )
  4020. annotation_colors_filter <- list(
  4021. celltype = celltype_colors,
  4022. subcelltype = subcelltype_colors,
  4023. pathway = filter_pathway_colors
  4024. )
  4025. filter_run_lengths <- rle(
  4026. as.character(annotation_row_filter$pathway)
  4027. )$lengths
  4028. filter_row_gaps <- cumsum(filter_run_lengths)
  4029. filter_row_gaps <- filter_row_gaps[
  4030. filter_row_gaps < nrow(df_flux_filter_plot)
  4031. ]
  4032. # 16. Plot the selected-module flux heatmap ------------------------------------
  4033. selected_heatmap <- ComplexHeatmap::pheatmap(
  4034. df_flux_filter_plot,
  4035. scale = "row",
  4036. show_rownames = TRUE,
  4037. show_colnames = FALSE,
  4038. cluster_cols = FALSE,
  4039. cluster_rows = FALSE,
  4040. color = heatmap_colors,
  4041. annotation_col = annotation_col,
  4042. annotation_row = annotation_row_filter,
  4043. annotation_names_row = FALSE,
  4044. annotation_names_col = FALSE,
  4045. column_title = NULL,
  4046. row_title = NULL,
  4047. fontsize_col = 8,
  4048. annotation_colors = annotation_colors_filter,
  4049. legend = FALSE,
  4050. annotation_legend = TRUE,
  4051. border_color = "white",
  4052. gaps_row = filter_row_gaps
  4053. )
  4054. pdf(
  4055. file = file.path(
  4056. output_figure_dir,
  4057. "scFEA_flux_heatmap_selected_modules.pdf"
  4058. ),
  4059. width = 7,
  4060. height = 5,
  4061. useDingbats = FALSE
  4062. )
  4063. ComplexHeatmap::draw(
  4064. selected_heatmap,
  4065. merge_legends = TRUE
  4066. )
  4067. dev.off()
  4068. # 17. Export selected flux data and annotations --------------------------------
  4069. write.csv(
  4070. df_flux_filter,
  4071. file = file.path(
  4072. output_data_dir,
  4073. "scFEA_selected_module_mean_flux.csv"
  4074. ),
  4075. row.names = TRUE
  4076. )
  4077. write.csv(
  4078. data.frame(
  4079. M_id = selected_module_info$M_id,
  4080. M_name = selected_module_info$M_name,
  4081. pathway_group = as.character(annotation_row_filter$pathway),
  4082. stringsAsFactors = FALSE
  4083. ),
  4084. file = file.path(
  4085. output_data_dir,
  4086. "scFEA_selected_module_annotations.csv"
  4087. ),
  4088. row.names = FALSE
  4089. )
  4090. # 18. Save analysis parameters and software information ------------------------
  4091. analysis_parameters <- c(
  4092. paste0(
  4093. "Cell-state order: ",
  4094. paste(celltype_order, collapse = ", ")
  4095. ),
  4096. paste0(
  4097. "Modules retained after zero-variance filtering: ",
  4098. nrow(df_flux)
  4099. ),
  4100. "Flux summary: arithmetic mean across cells within each Seurat-defined state",
  4101. "Heatmap scaling: row-wise z-score",
  4102. "Column clustering: disabled",
  4103. "Row clustering: disabled",
  4104. "Module order: original prespecified order retained"
  4105. )
  4106. writeLines(
  4107. analysis_parameters,
  4108. con = file.path(
  4109. output_data_dir,
  4110. "scFEA_flux_postprocessing_parameters.txt"
  4111. )
  4112. )
  4113. writeLines(
  4114. capture.output(sessionInfo()),
  4115. con = file.path(
  4116. output_data_dir,
  4117. "scFEA_flux_postprocessing_sessionInfo.txt"
  4118. )
  4119. )
  4120. # ==============================================================================
  4121. # (10) Pseudobulk transcription-factor activity analysis
  4122. #
  4123. # Description:
  4124. # This script performs the complete transcription-factor activity analysis for
  4125. # neonatal mouse microglia using two preconstructed Seurat objects.
  4126. #
  4127. # The workflow:
  4128. # 1. loads and validates the LPS and physiological Seurat objects;
  4129. # 2. constructs sample-level pseudobulk profiles by cell type and cluster;
  4130. # 3. infers TF activity with CollecTRI and decoupleR using ULM and MLM;
  4131. # 4. generates summary tables and manuscript-oriented figures;
  4132. # 5. saves a compact RDS result bundle and optional augmented Seurat objects.
  4133. #
  4134. # Important:
  4135. # The metadata column specified as sample_col must identify independent
  4136. # biological samples. Do not use a treatment, age, or pooled group label as the
  4137. # pseudobulk sample identifier.
  4138. # ==============================================================================
  4139. # 1. Load packages -------------------------------------------------------------
  4140. suppressPackageStartupMessages({
  4141. library(Seurat)
  4142. library(Matrix)
  4143. library(decoupleR)
  4144. library(dplyr)
  4145. library(tidyr)
  4146. library(purrr)
  4147. library(readr)
  4148. library(tibble)
  4149. library(ggplot2)
  4150. library(patchwork)
  4151. })
  4152. # 2. Define project directories ------------------------------------------------
  4153. input_dir <- "DATA/INPUT/RDS"
  4154. resource_dir <- "DATA/RESOURCE"
  4155. output_dir <- "DATA/OUTPUT/TF_ACTIVITY"
  4156. qc_dir <- file.path(output_dir, "01_QC")
  4157. table_dir <- file.path(output_dir, "02_TABLES")
  4158. figure_dir <- file.path(output_dir, "03_FIGURES")
  4159. rds_output_dir <- file.path(output_dir, "04_RDS")
  4160. dir.create(resource_dir, recursive = TRUE, showWarnings = FALSE)
  4161. dir.create(qc_dir, recursive = TRUE, showWarnings = FALSE)
  4162. dir.create(table_dir, recursive = TRUE, showWarnings = FALSE)
  4163. dir.create(figure_dir, recursive = TRUE, showWarnings = FALSE)
  4164. dir.create(rds_output_dir, recursive = TRUE, showWarnings = FALSE)
  4165. # 3. Define analysis parameters ------------------------------------------------
  4166. set.seed(11)
  4167. min_cells_per_pseudobulk <- 30
  4168. min_targets_per_tf <- 5
  4169. normalization_target_sum <- 1e6
  4170. tf_methods <- c("ulm", "mlm")
  4171. top_tfs_per_profile <- 15
  4172. top_tfs_for_heatmap <- 18
  4173. top_tfs_for_sample_heatmap <- 20
  4174. condition_levels <- c("LPS", "NS")
  4175. key_tfs <- c(
  4176. "Stat1",
  4177. "Rela",
  4178. "Spi1",
  4179. "Trp53",
  4180. "Hif1a",
  4181. "Nfe2l2"
  4182. )
  4183. analysis_options <- list(
  4184. run_celltype_analysis = TRUE,
  4185. run_cluster_analysis = TRUE,
  4186. run_sample_level_analysis = TRUE,
  4187. make_activity_expression_heatmap = TRUE,
  4188. make_key_tf_umap = TRUE,
  4189. save_augmented_seurat_objects = TRUE
  4190. )
  4191. # 4. Define input-object information -------------------------------------------
  4192. # IMPORTANT:
  4193. # sample_col must identify biological replicates. The current script assumes
  4194. # that the "batch" column corresponds to independent samples. Replace it with
  4195. # the correct animal/sample identifier when this assumption is not valid.
  4196. sample_information <- tribble(
  4197. ~object_name, ~condition, ~file_path, ~assay,
  4198. ~sample_col, ~group_col, ~celltype_col, ~cluster_col,
  4199. "lps_mg", "LPS",
  4200. file.path(input_dir, "All_lps_mg0326.rds"),
  4201. "RNA", "batch", "group", "celltype", "seurat_clusters",
  4202. "ns_mg", "NS",
  4203. file.path(input_dir, "All_ns_Mg_0326.rds"),
  4204. "RNA", "batch", "group", "celltype", "seurat_clusters"
  4205. )
  4206. # 5. Define helper functions ----------------------------------------------------
  4207. get_assay_matrix <- function(
  4208. object,
  4209. assay,
  4210. layer_name = c("counts", "data")
  4211. ) {
  4212. layer_name <- match.arg(layer_name)
  4213. matrix_out <- tryCatch(
  4214. GetAssayData(
  4215. object = object,
  4216. assay = assay,
  4217. layer = layer_name
  4218. ),
  4219. error = function(e) {
  4220. GetAssayData(
  4221. object = object,
  4222. assay = assay,
  4223. slot = layer_name
  4224. )
  4225. }
  4226. )
  4227. return(matrix_out)
  4228. }
  4229. validate_sample_information <- function(sample_information) {
  4230. required_columns <- c(
  4231. "object_name",
  4232. "condition",
  4233. "file_path",
  4234. "assay",
  4235. "sample_col",
  4236. "group_col",
  4237. "celltype_col",
  4238. "cluster_col"
  4239. )
  4240. missing_columns <- setdiff(
  4241. required_columns,
  4242. colnames(sample_information)
  4243. )
  4244. if (length(missing_columns) > 0) {
  4245. stop(
  4246. "sample_information is missing columns: ",
  4247. paste(missing_columns, collapse = ", ")
  4248. )
  4249. }
  4250. if (anyDuplicated(sample_information$object_name)) {
  4251. stop("Duplicated object names were detected in sample_information.")
  4252. }
  4253. missing_files <- sample_information$file_path[
  4254. !file.exists(sample_information$file_path)
  4255. ]
  4256. if (length(missing_files) > 0) {
  4257. stop(
  4258. "The following input files do not exist:\n",
  4259. paste(missing_files, collapse = "\n")
  4260. )
  4261. }
  4262. }
  4263. validate_seurat_object <- function(object, sample_row) {
  4264. object_name <- sample_row$object_name[[1]]
  4265. assay_name <- sample_row$assay[[1]]
  4266. if (!inherits(object, "Seurat")) {
  4267. stop(
  4268. "The input file for ",
  4269. object_name,
  4270. " does not contain a Seurat object."
  4271. )
  4272. }
  4273. if (!assay_name %in% Assays(object)) {
  4274. stop(
  4275. "Assay '",
  4276. assay_name,
  4277. "' is absent from object '",
  4278. object_name,
  4279. "'."
  4280. )
  4281. }
  4282. required_metadata <- c(
  4283. sample_row$sample_col[[1]],
  4284. sample_row$group_col[[1]],
  4285. sample_row$celltype_col[[1]],
  4286. sample_row$cluster_col[[1]]
  4287. )
  4288. missing_metadata <- setdiff(
  4289. required_metadata,
  4290. colnames([email hidden])
  4291. )
  4292. if (length(missing_metadata) > 0) {
  4293. stop(
  4294. "Object '",
  4295. object_name,
  4296. "' is missing metadata columns: ",
  4297. paste(missing_metadata, collapse = ", ")
  4298. )
  4299. }
  4300. sample_col <- sample_row$sample_col[[1]]
  4301. group_col <- sample_row$group_col[[1]]
  4302. if (sample_col == group_col) {
  4303. stop(
  4304. "For object '",
  4305. object_name,
  4306. "', sample_col and group_col are identical. ",
  4307. "sample_col must identify biological replicates."
  4308. )
  4309. }
  4310. sample_group_map <- [email hidden] %>%
  4311. transmute(
  4312. sample_id = as.character(.data[[sample_col]]),
  4313. experimental_group = as.character(.data[[group_col]])
  4314. ) %>%
  4315. distinct()
  4316. duplicated_sample_ids <- sample_group_map %>%
  4317. count(sample_id, name = "n_group_values") %>%
  4318. filter(n_group_values > 1)
  4319. if (nrow(duplicated_sample_ids) > 0) {
  4320. stop(
  4321. "At least one sample identifier in object '",
  4322. object_name,
  4323. "' maps to more than one experimental group. ",
  4324. "Check sample_col and group_col."
  4325. )
  4326. }
  4327. invisible(TRUE)
  4328. }
  4329. build_object_summary <- function(
  4330. object,
  4331. sample_row
  4332. ) {
  4333. tibble(
  4334. object_name = sample_row$object_name[[1]],
  4335. condition = sample_row$condition[[1]],
  4336. file_path = sample_row$file_path[[1]],
  4337. n_cells = ncol(object),
  4338. n_features = nrow(object),
  4339. assays = paste(Assays(object), collapse = ", "),
  4340. default_assay = DefaultAssay(object),
  4341. sample_col = sample_row$sample_col[[1]],
  4342. group_col = sample_row$group_col[[1]],
  4343. celltype_col = sample_row$celltype_col[[1]],
  4344. cluster_col = sample_row$cluster_col[[1]]
  4345. )
  4346. }
  4347. count_metadata_levels <- function(
  4348. object,
  4349. object_name,
  4350. condition,
  4351. metadata_column,
  4352. metadata_role
  4353. ) {
  4354. [email hidden] %>%
  4355. transmute(
  4356. level = as.character(.data[[metadata_column]])
  4357. ) %>%
  4358. count(level, name = "n_cells") %>%
  4359. mutate(
  4360. object_name = object_name,
  4361. condition = condition,
  4362. metadata_role = metadata_role,
  4363. metadata_column = metadata_column,
  4364. .before = 1
  4365. )
  4366. }
  4367. build_pseudobulk <- function(
  4368. object,
  4369. object_name,
  4370. condition,
  4371. assay,
  4372. sample_col,
  4373. group_col,
  4374. grouping_col,
  4375. min_cells = 30
  4376. ) {
  4377. counts <- get_assay_matrix(
  4378. object = object,
  4379. assay = assay,
  4380. layer_name = "counts"
  4381. )
  4382. metadata <- [email hidden] %>%
  4383. rownames_to_column("barcode")
  4384. metadata <- metadata[
  4385. match(colnames(counts), metadata$barcode),
  4386. ,
  4387. drop = FALSE
  4388. ]
  4389. if (!identical(metadata$barcode, colnames(counts))) {
  4390. stop(
  4391. "Cell barcodes in metadata and the count matrix are not aligned for ",
  4392. object_name,
  4393. "."
  4394. )
  4395. }
  4396. sample_values <- as.character(metadata[[sample_col]])
  4397. experimental_group_values <- as.character(metadata[[group_col]])
  4398. grouping_values <- as.character(metadata[[grouping_col]])
  4399. invalid_cells <- (
  4400. is.na(sample_values) |
  4401. sample_values == "" |
  4402. is.na(experimental_group_values) |
  4403. experimental_group_values == "" |
  4404. is.na(grouping_values) |
  4405. grouping_values == ""
  4406. )
  4407. if (any(invalid_cells)) {
  4408. stop(
  4409. "Missing sample, group, or grouping annotations were detected in ",
  4410. object_name,
  4411. "."
  4412. )
  4413. }
  4414. pseudobulk_ids <- paste(
  4415. sample_values,
  4416. grouping_values,
  4417. sep = "__"
  4418. )
  4419. pseudobulk_levels <- unique(pseudobulk_ids)
  4420. pseudobulk_index <- match(
  4421. pseudobulk_ids,
  4422. pseudobulk_levels
  4423. )
  4424. design_matrix <- sparseMatrix(
  4425. i = seq_along(pseudobulk_index),
  4426. j = pseudobulk_index,
  4427. x = 1,
  4428. dims = c(
  4429. length(pseudobulk_index),
  4430. length(pseudobulk_levels)
  4431. ),
  4432. dimnames = list(
  4433. colnames(counts),
  4434. pseudobulk_levels
  4435. )
  4436. )
  4437. pseudobulk_counts <- counts %*% design_matrix
  4438. first_cell_index <- match(
  4439. pseudobulk_levels,
  4440. pseudobulk_ids
  4441. )
  4442. pseudobulk_metadata <- tibble(
  4443. pseudobulk_id = pseudobulk_levels,
  4444. object_name = object_name,
  4445. condition_label = condition,
  4446. sample_id = sample_values[first_cell_index],
  4447. experimental_group = experimental_group_values[first_cell_index],
  4448. grouping_level = grouping_col,
  4449. grouping_value = grouping_values[first_cell_index],
  4450. n_cells = tabulate(
  4451. pseudobulk_index,
  4452. nbins = length(pseudobulk_levels)
  4453. )
  4454. )
  4455. keep_profiles <- (
  4456. pseudobulk_metadata$n_cells >= min_cells
  4457. )
  4458. pseudobulk_counts <- pseudobulk_counts[
  4459. ,
  4460. keep_profiles,
  4461. drop = FALSE
  4462. ]
  4463. pseudobulk_metadata <- pseudobulk_metadata[
  4464. keep_profiles,
  4465. ,
  4466. drop = FALSE
  4467. ]
  4468. if (ncol(pseudobulk_counts) == 0) {
  4469. warning(
  4470. "No pseudobulk profiles passed the minimum-cell threshold for ",
  4471. object_name,
  4472. " grouped by ",
  4473. grouping_col,
  4474. "."
  4475. )
  4476. }
  4477. return(
  4478. list(
  4479. counts = pseudobulk_counts,
  4480. metadata = pseudobulk_metadata
  4481. )
  4482. )
  4483. }
  4484. normalize_pseudobulk <- function(
  4485. count_matrix,
  4486. target_sum = 1e6
  4487. ) {
  4488. if (ncol(count_matrix) == 0) {
  4489. return(count_matrix)
  4490. }
  4491. library_size <- Matrix::colSums(count_matrix)
  4492. keep_profiles <- library_size > 0
  4493. count_matrix <- count_matrix[
  4494. ,
  4495. keep_profiles,
  4496. drop = FALSE
  4497. ]
  4498. library_size <- library_size[keep_profiles]
  4499. normalized_matrix <- count_matrix %*%
  4500. Diagonal(
  4501. x = target_sum / library_size
  4502. )
  4503. normalized_matrix <- as(
  4504. normalized_matrix,
  4505. "dgCMatrix"
  4506. )
  4507. normalized_matrix@x <- log1p(
  4508. normalized_matrix@x
  4509. )
  4510. return(normalized_matrix)
  4511. }
  4512. run_tf_method <- function(
  4513. normalized_matrix,
  4514. network,
  4515. method,
  4516. min_targets = 5
  4517. ) {
  4518. if (ncol(normalized_matrix) == 0) {
  4519. return(tibble())
  4520. }
  4521. input_matrix <- as.matrix(normalized_matrix)
  4522. if (method == "ulm") {
  4523. result <- decoupleR::run_ulm(
  4524. mat = input_matrix,
  4525. network = network,
  4526. .source = "source",
  4527. .target = "target",
  4528. .mor = "mor",
  4529. minsize = min_targets
  4530. )
  4531. } else if (method == "mlm") {
  4532. result <- decoupleR::run_mlm(
  4533. mat = input_matrix,
  4534. network = network,
  4535. .source = "source",
  4536. .target = "target",
  4537. .mor = "mor",
  4538. minsize = min_targets
  4539. )
  4540. } else {
  4541. stop(
  4542. "Unsupported TF-activity method: ",
  4543. method
  4544. )
  4545. }
  4546. result <- result %>%
  4547. group_by(condition) %>%
  4548. mutate(
  4549. p_adjusted = p.adjust(
  4550. p_value,
  4551. method = "BH"
  4552. )
  4553. ) %>%
  4554. ungroup()
  4555. return(result)
  4556. }
  4557. activity_to_matrix <- function(activity_result) {
  4558. if (nrow(activity_result) == 0) {
  4559. return(matrix(numeric(0), nrow = 0, ncol = 0))
  4560. }
  4561. activity_result %>%
  4562. select(
  4563. source,
  4564. condition,
  4565. score
  4566. ) %>%
  4567. pivot_wider(
  4568. names_from = condition,
  4569. values_from = score
  4570. ) %>%
  4571. column_to_rownames("source") %>%
  4572. as.matrix()
  4573. }
  4574. summarize_top_tfs <- function(
  4575. activity_result,
  4576. top_n = 15
  4577. ) {
  4578. if (nrow(activity_result) == 0) {
  4579. return(tibble())
  4580. }
  4581. activity_result %>%
  4582. mutate(
  4583. absolute_score = abs(score),
  4584. activity_direction = if_else(
  4585. score >= 0,
  4586. "activated",
  4587. "repressed"
  4588. )
  4589. ) %>%
  4590. group_by(condition) %>%
  4591. slice_max(
  4592. order_by = absolute_score,
  4593. n = top_n,
  4594. with_ties = FALSE
  4595. ) %>%
  4596. ungroup() %>%
  4597. arrange(
  4598. condition,
  4599. desc(absolute_score)
  4600. )
  4601. }
  4602. write_matrix_csv <- function(
  4603. matrix_object,
  4604. file_path,
  4605. row_name = "feature"
  4606. ) {
  4607. matrix_object %>%
  4608. as.data.frame() %>%
  4609. rownames_to_column(row_name) %>%
  4610. write_csv(file_path)
  4611. }
  4612. write_tf_results <- function(
  4613. result_list,
  4614. output_subdir
  4615. ) {
  4616. dir.create(
  4617. output_subdir,
  4618. recursive = TRUE,
  4619. showWarnings = FALSE
  4620. )
  4621. write_csv(
  4622. result_list$metadata,
  4623. file.path(
  4624. output_subdir,
  4625. "pseudobulk_metadata.csv"
  4626. )
  4627. )
  4628. for (method in names(result_list$activity)) {
  4629. activity_result <- result_list$activity[[method]]
  4630. write_csv(
  4631. activity_result,
  4632. file.path(
  4633. output_subdir,
  4634. paste0(method, "_activity_long.csv")
  4635. )
  4636. )
  4637. write_matrix_csv(
  4638. activity_to_matrix(activity_result),
  4639. file.path(
  4640. output_subdir,
  4641. paste0(method, "_activity_matrix.csv")
  4642. ),
  4643. row_name = "tf"
  4644. )
  4645. write_csv(
  4646. summarize_top_tfs(
  4647. activity_result,
  4648. top_n = top_tfs_per_profile
  4649. ),
  4650. file.path(
  4651. output_subdir,
  4652. paste0(method, "_top_tfs.csv")
  4653. )
  4654. )
  4655. }
  4656. }
  4657. collect_activity_results <- function(
  4658. tf_results,
  4659. grouping_name,
  4660. method_name
  4661. ) {
  4662. map_dfr(
  4663. names(tf_results),
  4664. function(object_name) {
  4665. object_result <- tf_results[[object_name]][[grouping_name]]
  4666. if (
  4667. is.null(object_result) ||
  4668. is.null(object_result$activity[[method_name]])
  4669. ) {
  4670. return(tibble())
  4671. }
  4672. activity_result <- object_result$activity[[method_name]]
  4673. pseudobulk_metadata <- object_result$metadata
  4674. activity_result %>%
  4675. left_join(
  4676. pseudobulk_metadata,
  4677. by = c(
  4678. "condition" = "pseudobulk_id"
  4679. )
  4680. )
  4681. }
  4682. )
  4683. }
  4684. z_score_vector <- function(x) {
  4685. x_sd <- sd(
  4686. x,
  4687. na.rm = TRUE
  4688. )
  4689. if (
  4690. length(x) <= 1 ||
  4691. is.na(x_sd) ||
  4692. x_sd == 0
  4693. ) {
  4694. return(
  4695. rep(0, length(x))
  4696. )
  4697. }
  4698. (
  4699. x - mean(x, na.rm = TRUE)
  4700. ) / x_sd
  4701. }
  4702. save_ggplot <- function(
  4703. plot_object,
  4704. file_stem,
  4705. width,
  4706. height
  4707. ) {
  4708. ggsave(
  4709. filename = file.path(
  4710. figure_dir,
  4711. paste0(file_stem, ".pdf")
  4712. ),
  4713. plot = plot_object,
  4714. width = width,
  4715. height = height,
  4716. units = "in"
  4717. )
  4718. ggsave(
  4719. filename = file.path(
  4720. figure_dir,
  4721. paste0(file_stem, ".tiff")
  4722. ),
  4723. plot = plot_object,
  4724. width = width,
  4725. height = height,
  4726. units = "in",
  4727. dpi = 600,
  4728. compression = "lzw"
  4729. )
  4730. }
  4731. make_group_activity_heatmap <- function(
  4732. tf_results,
  4733. cell_count_table,
  4734. grouping_name,
  4735. method_name = "ulm",
  4736. top_n = 18,
  4737. figure_stem,
  4738. figure_title
  4739. ) {
  4740. activity_data <- collect_activity_results(
  4741. tf_results = tf_results,
  4742. grouping_name = grouping_name,
  4743. method_name = method_name
  4744. )
  4745. if (nrow(activity_data) == 0) {
  4746. warning(
  4747. "No activity data were available for ",
  4748. grouping_name,
  4749. "."
  4750. )
  4751. return(NULL)
  4752. }
  4753. mean_activity <- activity_data %>%
  4754. group_by(
  4755. object_name,
  4756. condition_label,
  4757. grouping_value,
  4758. source
  4759. ) %>%
  4760. summarise(
  4761. mean_score = mean(
  4762. score,
  4763. na.rm = TRUE
  4764. ),
  4765. .groups = "drop"
  4766. )
  4767. selected_tfs <- mean_activity %>%
  4768. group_by(source) %>%
  4769. summarise(
  4770. activity_sd = sd(
  4771. mean_score,
  4772. na.rm = TRUE
  4773. ),
  4774. .groups = "drop"
  4775. ) %>%
  4776. mutate(
  4777. activity_sd = replace_na(
  4778. activity_sd,
  4779. 0
  4780. )
  4781. ) %>%
  4782. arrange(
  4783. desc(activity_sd),
  4784. source
  4785. ) %>%
  4786. slice_head(n = top_n) %>%
  4787. pull(source)
  4788. plot_data <- mean_activity %>%
  4789. filter(
  4790. source %in% selected_tfs
  4791. ) %>%
  4792. left_join(
  4793. cell_count_table %>%
  4794. filter(
  4795. .data$grouping_name == .env$grouping_name
  4796. ),
  4797. by = c(
  4798. "object_name",
  4799. "condition_label",
  4800. "grouping_value"
  4801. )
  4802. ) %>%
  4803. group_by(
  4804. condition_label,
  4805. source
  4806. ) %>%
  4807. mutate(
  4808. z_score = z_score_vector(
  4809. mean_score
  4810. )
  4811. ) %>%
  4812. ungroup() %>%
  4813. mutate(
  4814. condition_label = factor(
  4815. condition_label,
  4816. levels = condition_levels
  4817. ),
  4818. grouping_label = paste0(
  4819. grouping_value,
  4820. "\n(n=",
  4821. n_cells,
  4822. ")"
  4823. ),
  4824. source = factor(
  4825. source,
  4826. levels = rev(selected_tfs)
  4827. )
  4828. )
  4829. grouping_order <- plot_data %>%
  4830. distinct(
  4831. condition_label,
  4832. grouping_label,
  4833. n_cells
  4834. ) %>%
  4835. arrange(
  4836. condition_label,
  4837. desc(n_cells),
  4838. grouping_label
  4839. ) %>%
  4840. pull(grouping_label) %>%
  4841. unique()
  4842. plot_data$grouping_label <- factor(
  4843. plot_data$grouping_label,
  4844. levels = grouping_order
  4845. )
  4846. heatmap_plot <- ggplot(
  4847. plot_data,
  4848. aes(
  4849. x = grouping_label,
  4850. y = source,
  4851. fill = z_score
  4852. )
  4853. ) +
  4854. geom_tile(
  4855. linewidth = 0.2,
  4856. colour = "grey85"
  4857. ) +
  4858. facet_grid(
  4859. . ~ condition_label,
  4860. scales = "free_x",
  4861. space = "free_x"
  4862. ) +
  4863. scale_fill_gradient2(
  4864. low = "#2166AC",
  4865. mid = "white",
  4866. high = "#B2182B",
  4867. midpoint = 0,
  4868. limits = c(-2.5, 2.5),
  4869. oob = scales::squish,
  4870. name = "Row z-score"
  4871. ) +
  4872. labs(
  4873. title = figure_title,
  4874. x = NULL,
  4875. y = "Transcription factor"
  4876. ) +
  4877. theme_classic(base_size = 11) +
  4878. theme(
  4879. axis.text.x = element_text(
  4880. angle = 45,
  4881. hjust = 1,
  4882. vjust = 1
  4883. ),
  4884. axis.ticks = element_blank(),
  4885. panel.border = element_rect(
  4886. colour = "black",
  4887. fill = NA,
  4888. linewidth = 0.5
  4889. ),
  4890. strip.background = element_blank(),
  4891. strip.text = element_text(
  4892. face = "bold",
  4893. size = 11
  4894. ),
  4895. plot.title = element_text(
  4896. face = "bold",
  4897. hjust = 0.5
  4898. )
  4899. )
  4900. write_csv(
  4901. plot_data,
  4902. file.path(
  4903. table_dir,
  4904. paste0(
  4905. figure_stem,
  4906. "_plot_data.csv"
  4907. )
  4908. )
  4909. )
  4910. save_ggplot(
  4911. plot_object = heatmap_plot,
  4912. file_stem = figure_stem,
  4913. width = 14,
  4914. height = 8
  4915. )
  4916. return(
  4917. list(
  4918. plot = heatmap_plot,
  4919. plot_data = plot_data,
  4920. selected_tfs = selected_tfs
  4921. )
  4922. )
  4923. }
  4924. build_sample_level_pseudobulk <- function(
  4925. object,
  4926. sample_row
  4927. ) {
  4928. object_copy <- object
  4929. object_copy$overall_group <- "All_cells"
  4930. build_pseudobulk(
  4931. object = object_copy,
  4932. object_name = sample_row$object_name[[1]],
  4933. condition = sample_row$condition[[1]],
  4934. assay = sample_row$assay[[1]],
  4935. sample_col = sample_row$sample_col[[1]],
  4936. group_col = sample_row$group_col[[1]],
  4937. grouping_col = "overall_group",
  4938. min_cells = min_cells_per_pseudobulk
  4939. )
  4940. }
  4941. extract_timepoint <- function(x) {
  4942. numeric_value <- gsub(
  4943. "[^0-9]",
  4944. "",
  4945. as.character(x)
  4946. )
  4947. numeric_value[numeric_value == ""] <- NA_character_
  4948. as.numeric(numeric_value)
  4949. }
  4950. compute_mean_expression_by_group <- function(
  4951. object,
  4952. assay,
  4953. grouping_col,
  4954. features
  4955. ) {
  4956. object_copy <- object
  4957. DefaultAssay(object_copy) <- assay
  4958. expression_matrix <- tryCatch(
  4959. get_assay_matrix(
  4960. object = object_copy,
  4961. assay = assay,
  4962. layer_name = "data"
  4963. ),
  4964. error = function(e) {
  4965. matrix(
  4966. numeric(0),
  4967. nrow = 0,
  4968. ncol = 0
  4969. )
  4970. }
  4971. )
  4972. if (
  4973. nrow(expression_matrix) == 0 ||
  4974. ncol(expression_matrix) == 0
  4975. ) {
  4976. object_copy <- NormalizeData(
  4977. object = object_copy,
  4978. assay = assay,
  4979. verbose = FALSE
  4980. )
  4981. expression_matrix <- get_assay_matrix(
  4982. object = object_copy,
  4983. assay = assay,
  4984. layer_name = "data"
  4985. )
  4986. }
  4987. available_features <- intersect(
  4988. features,
  4989. rownames(expression_matrix)
  4990. )
  4991. if (length(available_features) == 0) {
  4992. return(tibble())
  4993. }
  4994. expression_matrix <- expression_matrix[
  4995. available_features,
  4996. ,
  4997. drop = FALSE
  4998. ]
  4999. grouping_values <- as.character(
  5000. [email hidden][
  5001. colnames(expression_matrix),
  5002. grouping_col
  5003. ]
  5004. )
  5005. grouping_levels <- unique(
  5006. grouping_values
  5007. )
  5008. grouping_index <- match(
  5009. grouping_values,
  5010. grouping_levels
  5011. )
  5012. design_matrix <- sparseMatrix(
  5013. i = seq_along(grouping_index),
  5014. j = grouping_index,
  5015. x = 1,
  5016. dims = c(
  5017. length(grouping_index),
  5018. length(grouping_levels)
  5019. ),
  5020. dimnames = list(
  5021. colnames(expression_matrix),
  5022. grouping_levels
  5023. )
  5024. )
  5025. group_sum <- expression_matrix %*%
  5026. design_matrix
  5027. group_size <- tabulate(
  5028. grouping_index,
  5029. nbins = length(grouping_levels)
  5030. )
  5031. group_mean <- group_sum %*%
  5032. Diagonal(
  5033. x = 1 / group_size
  5034. )
  5035. group_mean %>%
  5036. as.matrix() %>%
  5037. as.data.frame() %>%
  5038. rownames_to_column("tf") %>%
  5039. pivot_longer(
  5040. cols = -tf,
  5041. names_to = "grouping_value",
  5042. values_to = "mean_value"
  5043. )
  5044. }
  5045. # 6. Load and validate Seurat objects ------------------------------------------
  5046. validate_sample_information(
  5047. sample_information
  5048. )
  5049. seurat_objects <- map(
  5050. sample_information$file_path,
  5051. readRDS
  5052. )
  5053. names(seurat_objects) <- sample_information$object_name
  5054. walk2(
  5055. seurat_objects,
  5056. split(
  5057. sample_information,
  5058. seq_len(nrow(sample_information))
  5059. ),
  5060. validate_seurat_object
  5061. )
  5062. object_summary <- map_dfr(
  5063. seq_len(nrow(sample_information)),
  5064. function(i) {
  5065. build_object_summary(
  5066. object = seurat_objects[[sample_information$object_name[[i]]]],
  5067. sample_row = sample_information[i, ]
  5068. )
  5069. }
  5070. )
  5071. metadata_level_counts <- map_dfr(
  5072. seq_len(nrow(sample_information)),
  5073. function(i) {
  5074. sample_row <- sample_information[i, ]
  5075. object_name <- sample_row$object_name[[1]]
  5076. condition <- sample_row$condition[[1]]
  5077. object <- seurat_objects[[object_name]]
  5078. bind_rows(
  5079. count_metadata_levels(
  5080. object = object,
  5081. object_name = object_name,
  5082. condition = condition,
  5083. metadata_column = sample_row$sample_col[[1]],
  5084. metadata_role = "sample"
  5085. ),
  5086. count_metadata_levels(
  5087. object = object,
  5088. object_name = object_name,
  5089. condition = condition,
  5090. metadata_column = sample_row$group_col[[1]],
  5091. metadata_role = "experimental_group"
  5092. ),
  5093. count_metadata_levels(
  5094. object = object,
  5095. object_name = object_name,
  5096. condition = condition,
  5097. metadata_column = sample_row$celltype_col[[1]],
  5098. metadata_role = "celltype"
  5099. ),
  5100. count_metadata_levels(
  5101. object = object,
  5102. object_name = object_name,
  5103. condition = condition,
  5104. metadata_column = sample_row$cluster_col[[1]],
  5105. metadata_role = "seurat_cluster"
  5106. )
  5107. )
  5108. }
  5109. )
  5110. write_csv(
  5111. object_summary,
  5112. file.path(
  5113. qc_dir,
  5114. "object_summary.csv"
  5115. )
  5116. )
  5117. write_csv(
  5118. metadata_level_counts,
  5119. file.path(
  5120. qc_dir,
  5121. "metadata_level_counts.csv"
  5122. )
  5123. )
  5124. # 7. Load the mouse CollecTRI network ------------------------------------------
  5125. network_file <- file.path(
  5126. resource_dir,
  5127. "collectri_mouse.csv"
  5128. )
  5129. if (file.exists(network_file)) {
  5130. collectri_network <- read_csv(
  5131. network_file,
  5132. show_col_types = FALSE
  5133. )
  5134. } else {
  5135. collectri_network <- decoupleR::get_collectri(
  5136. organism = "mouse",
  5137. split_complexes = FALSE
  5138. )
  5139. write_csv(
  5140. collectri_network,
  5141. network_file
  5142. )
  5143. }
  5144. if (
  5145. "weight" %in% colnames(collectri_network) &&
  5146. !"mor" %in% colnames(collectri_network)
  5147. ) {
  5148. collectri_network <- collectri_network %>%
  5149. rename(
  5150. mor = weight
  5151. )
  5152. }
  5153. required_network_columns <- c(
  5154. "source",
  5155. "target",
  5156. "mor"
  5157. )
  5158. missing_network_columns <- setdiff(
  5159. required_network_columns,
  5160. colnames(collectri_network)
  5161. )
  5162. if (length(missing_network_columns) > 0) {
  5163. stop(
  5164. "The CollecTRI network is missing columns: ",
  5165. paste(
  5166. missing_network_columns,
  5167. collapse = ", "
  5168. )
  5169. )
  5170. }
  5171. collectri_network <- collectri_network %>%
  5172. select(
  5173. source,
  5174. target,
  5175. mor
  5176. ) %>%
  5177. filter(
  5178. !is.na(source),
  5179. !is.na(target),
  5180. !is.na(mor)
  5181. ) %>%
  5182. distinct()
  5183. # 8. Construct pseudobulk profiles and infer TF activity -----------------------
  5184. tf_results <- list()
  5185. method_errors <- tibble()
  5186. grouping_plan <- list()
  5187. if (analysis_options$run_celltype_analysis) {
  5188. grouping_plan$celltype <- "celltype_col"
  5189. }
  5190. if (analysis_options$run_cluster_analysis) {
  5191. grouping_plan$seurat_clusters <- "cluster_col"
  5192. }
  5193. for (i in seq_len(nrow(sample_information))) {
  5194. sample_row <- sample_information[i, ]
  5195. object_name <- sample_row$object_name[[1]]
  5196. object <- seurat_objects[[object_name]]
  5197. message(
  5198. "Processing object: ",
  5199. object_name
  5200. )
  5201. tf_results[[object_name]] <- list()
  5202. for (grouping_name in names(grouping_plan)) {
  5203. grouping_column <- sample_row[[grouping_plan[[grouping_name]]]][[1]]
  5204. message(
  5205. " Grouping by: ",
  5206. grouping_column
  5207. )
  5208. pseudobulk <- build_pseudobulk(
  5209. object = object,
  5210. object_name = object_name,
  5211. condition = sample_row$condition[[1]],
  5212. assay = sample_row$assay[[1]],
  5213. sample_col = sample_row$sample_col[[1]],
  5214. group_col = sample_row$group_col[[1]],
  5215. grouping_col = grouping_column,
  5216. min_cells = min_cells_per_pseudobulk
  5217. )
  5218. normalized_matrix <- normalize_pseudobulk(
  5219. count_matrix = pseudobulk$counts,
  5220. target_sum = normalization_target_sum
  5221. )
  5222. activity_results <- map(
  5223. tf_methods,
  5224. function(method_name) {
  5225. message(
  5226. " Running ",
  5227. toupper(method_name)
  5228. )
  5229. tryCatch(
  5230. run_tf_method(
  5231. normalized_matrix = normalized_matrix,
  5232. network = collectri_network,
  5233. method = method_name,
  5234. min_targets = min_targets_per_tf
  5235. ),
  5236. error = function(e) {
  5237. method_errors <<- bind_rows(
  5238. method_errors,
  5239. tibble(
  5240. object_name = object_name,
  5241. grouping_name = grouping_name,
  5242. method = method_name,
  5243. error_message = conditionMessage(e)
  5244. )
  5245. )
  5246. warning(
  5247. "TF activity inference failed for ",
  5248. object_name,
  5249. " / ",
  5250. grouping_name,
  5251. " / ",
  5252. method_name,
  5253. ": ",
  5254. conditionMessage(e)
  5255. )
  5256. tibble()
  5257. }
  5258. )
  5259. }
  5260. )
  5261. names(activity_results) <- tf_methods
  5262. tf_results[[object_name]][[grouping_name]] <- list(
  5263. metadata = pseudobulk$metadata,
  5264. activity = activity_results
  5265. )
  5266. result_subdir <- file.path(
  5267. table_dir,
  5268. object_name,
  5269. grouping_name
  5270. )
  5271. write_tf_results(
  5272. result_list = tf_results[[object_name]][[grouping_name]],
  5273. output_subdir = result_subdir
  5274. )
  5275. }
  5276. }
  5277. # 9. Build combined TF summary tables ------------------------------------------
  5278. combined_top_tfs <- map_dfr(
  5279. names(tf_results),
  5280. function(object_name) {
  5281. map_dfr(
  5282. names(tf_results[[object_name]]),
  5283. function(grouping_name) {
  5284. map_dfr(
  5285. names(
  5286. tf_results[[object_name]][[grouping_name]]$activity
  5287. ),
  5288. function(method_name) {
  5289. summarize_top_tfs(
  5290. tf_results[[object_name]][[grouping_name]]$activity[[method_name]],
  5291. top_n = top_tfs_per_profile
  5292. ) %>%
  5293. mutate(
  5294. object_name = object_name,
  5295. grouping_name = grouping_name,
  5296. method = method_name,
  5297. .before = 1
  5298. )
  5299. }
  5300. )
  5301. }
  5302. )
  5303. }
  5304. )
  5305. recurrent_top_tfs <- combined_top_tfs %>%
  5306. group_by(
  5307. grouping_name,
  5308. method,
  5309. source
  5310. ) %>%
  5311. summarise(
  5312. n_top_hits = n(),
  5313. mean_absolute_score = mean(
  5314. absolute_score,
  5315. na.rm = TRUE
  5316. ),
  5317. maximum_absolute_score = max(
  5318. absolute_score,
  5319. na.rm = TRUE
  5320. ),
  5321. .groups = "drop"
  5322. ) %>%
  5323. arrange(
  5324. grouping_name,
  5325. method,
  5326. desc(n_top_hits),
  5327. desc(mean_absolute_score),
  5328. source
  5329. )
  5330. write_csv(
  5331. combined_top_tfs,
  5332. file.path(
  5333. table_dir,
  5334. "combined_top_tfs.csv"
  5335. )
  5336. )
  5337. write_csv(
  5338. recurrent_top_tfs,
  5339. file.path(
  5340. table_dir,
  5341. "recurrent_top_tfs.csv"
  5342. )
  5343. )
  5344. if (nrow(method_errors) > 0) {
  5345. write_csv(
  5346. method_errors,
  5347. file.path(
  5348. table_dir,
  5349. "method_errors.csv"
  5350. )
  5351. )
  5352. }
  5353. # 10. Prepare cell-count tables for heatmap labels -----------------------------
  5354. cell_count_table <- map_dfr(
  5355. seq_len(nrow(sample_information)),
  5356. function(i) {
  5357. sample_row <- sample_information[i, ]
  5358. object_name <- sample_row$object_name[[1]]
  5359. condition <- sample_row$condition[[1]]
  5360. object <- seurat_objects[[object_name]]
  5361. celltype_counts <- [email hidden] %>%
  5362. transmute(
  5363. grouping_value = as.character(
  5364. .data[[sample_row$celltype_col[[1]]]]
  5365. )
  5366. ) %>%
  5367. count(
  5368. grouping_value,
  5369. name = "n_cells"
  5370. ) %>%
  5371. mutate(
  5372. object_name = object_name,
  5373. condition_label = condition,
  5374. grouping_name = "celltype",
  5375. .before = 1
  5376. )
  5377. cluster_counts <- [email hidden] %>%
  5378. transmute(
  5379. grouping_value = as.character(
  5380. .data[[sample_row$cluster_col[[1]]]]
  5381. )
  5382. ) %>%
  5383. count(
  5384. grouping_value,
  5385. name = "n_cells"
  5386. ) %>%
  5387. mutate(
  5388. object_name = object_name,
  5389. condition_label = condition,
  5390. grouping_name = "seurat_clusters",
  5391. .before = 1
  5392. )
  5393. bind_rows(
  5394. celltype_counts,
  5395. cluster_counts
  5396. )
  5397. }
  5398. )
  5399. write_csv(
  5400. cell_count_table,
  5401. file.path(
  5402. qc_dir,
  5403. "cell_counts_for_heatmaps.csv"
  5404. )
  5405. )
  5406. # 11. Generate cell-type TF activity heatmap -----------------------------------
  5407. celltype_heatmap_result <- NULL
  5408. if (analysis_options$run_celltype_analysis) {
  5409. celltype_heatmap_result <- make_group_activity_heatmap(
  5410. tf_results = tf_results,
  5411. cell_count_table = cell_count_table,
  5412. grouping_name = "celltype",
  5413. method_name = "ulm",
  5414. top_n = top_tfs_for_heatmap,
  5415. figure_stem = "celltype_tf_activity_heatmap",
  5416. figure_title = paste0(
  5417. "TF activity across major microglial states"
  5418. )
  5419. )
  5420. }
  5421. # 12. Generate cluster-level TF activity heatmap -------------------------------
  5422. cluster_heatmap_result <- NULL
  5423. if (analysis_options$run_cluster_analysis) {
  5424. cluster_heatmap_result <- make_group_activity_heatmap(
  5425. tf_results = tf_results,
  5426. cell_count_table = cell_count_table,
  5427. grouping_name = "seurat_clusters",
  5428. method_name = "ulm",
  5429. top_n = top_tfs_for_heatmap,
  5430. figure_stem = "cluster_tf_activity_heatmap",
  5431. figure_title = paste0(
  5432. "TF activity across Seurat clusters"
  5433. )
  5434. )
  5435. }
  5436. # 13. Run sample-level TF activity analysis ------------------------------------
  5437. sample_level_results <- list()
  5438. sample_level_activity <- tibble()
  5439. if (analysis_options$run_sample_level_analysis) {
  5440. for (i in seq_len(nrow(sample_information))) {
  5441. sample_row <- sample_information[i, ]
  5442. object_name <- sample_row$object_name[[1]]
  5443. object <- seurat_objects[[object_name]]
  5444. sample_pseudobulk <- build_sample_level_pseudobulk(
  5445. object = object,
  5446. sample_row = sample_row
  5447. )
  5448. sample_normalized <- normalize_pseudobulk(
  5449. count_matrix = sample_pseudobulk$counts,
  5450. target_sum = normalization_target_sum
  5451. )
  5452. sample_ulm <- run_tf_method(
  5453. normalized_matrix = sample_normalized,
  5454. network = collectri_network,
  5455. method = "ulm",
  5456. min_targets = min_targets_per_tf
  5457. )
  5458. sample_level_results[[object_name]] <- list(
  5459. metadata = sample_pseudobulk$metadata,
  5460. activity = sample_ulm
  5461. )
  5462. sample_level_activity <- bind_rows(
  5463. sample_level_activity,
  5464. sample_ulm %>%
  5465. left_join(
  5466. sample_pseudobulk$metadata,
  5467. by = c(
  5468. "condition" = "pseudobulk_id"
  5469. )
  5470. )
  5471. )
  5472. }
  5473. write_csv(
  5474. sample_level_activity,
  5475. file.path(
  5476. table_dir,
  5477. "sample_level_ulm_activity.csv"
  5478. )
  5479. )
  5480. # 13.1 Sample-level TF activity heatmap --------------------------------------
  5481. selected_sample_tfs <- sample_level_activity %>%
  5482. group_by(source) %>%
  5483. summarise(
  5484. activity_sd = sd(
  5485. score,
  5486. na.rm = TRUE
  5487. ),
  5488. .groups = "drop"
  5489. ) %>%
  5490. mutate(
  5491. activity_sd = replace_na(
  5492. activity_sd,
  5493. 0
  5494. )
  5495. ) %>%
  5496. arrange(
  5497. desc(activity_sd),
  5498. source
  5499. ) %>%
  5500. slice_head(
  5501. n = top_tfs_for_sample_heatmap
  5502. ) %>%
  5503. pull(source)
  5504. sample_heatmap_data <- sample_level_activity %>%
  5505. filter(
  5506. source %in% selected_sample_tfs
  5507. ) %>%
  5508. group_by(source) %>%
  5509. mutate(
  5510. z_score = z_score_vector(score)
  5511. ) %>%
  5512. ungroup() %>%
  5513. mutate(
  5514. condition_label = factor(
  5515. condition_label,
  5516. levels = condition_levels
  5517. ),
  5518. timepoint = extract_timepoint(
  5519. experimental_group
  5520. ),
  5521. sample_label = paste(
  5522. condition_label,
  5523. experimental_group,
  5524. sample_id,
  5525. sep = " | "
  5526. ),
  5527. source = factor(
  5528. source,
  5529. levels = rev(selected_sample_tfs)
  5530. )
  5531. )
  5532. sample_order <- sample_heatmap_data %>%
  5533. distinct(
  5534. condition_label,
  5535. timepoint,
  5536. sample_id,
  5537. sample_label
  5538. ) %>%
  5539. arrange(
  5540. condition_label,
  5541. timepoint,
  5542. sample_id
  5543. ) %>%
  5544. pull(sample_label)
  5545. sample_heatmap_data$sample_label <- factor(
  5546. sample_heatmap_data$sample_label,
  5547. levels = sample_order
  5548. )
  5549. sample_heatmap_plot <- ggplot(
  5550. sample_heatmap_data,
  5551. aes(
  5552. x = sample_label,
  5553. y = source,
  5554. fill = z_score
  5555. )
  5556. ) +
  5557. geom_tile(
  5558. linewidth = 0.2,
  5559. colour = "grey85"
  5560. ) +
  5561. scale_fill_gradient2(
  5562. low = "#2166AC",
  5563. mid = "white",
  5564. high = "#B2182B",
  5565. midpoint = 0,
  5566. limits = c(-2.5, 2.5),
  5567. oob = scales::squish,
  5568. name = "Row z-score"
  5569. ) +
  5570. labs(
  5571. title = "Sample-level pseudobulk TF activity",
  5572. x = NULL,
  5573. y = "Transcription factor"
  5574. ) +
  5575. theme_classic(base_size = 11) +
  5576. theme(
  5577. axis.text.x = element_text(
  5578. angle = 45,
  5579. hjust = 1,
  5580. vjust = 1
  5581. ),
  5582. axis.ticks = element_blank(),
  5583. panel.border = element_rect(
  5584. colour = "black",
  5585. fill = NA,
  5586. linewidth = 0.5
  5587. ),
  5588. plot.title = element_text(
  5589. face = "bold",
  5590. hjust = 0.5
  5591. )
  5592. )
  5593. write_csv(
  5594. sample_heatmap_data,
  5595. file.path(
  5596. table_dir,
  5597. "sample_level_tf_heatmap_plot_data.csv"
  5598. )
  5599. )
  5600. save_ggplot(
  5601. plot_object = sample_heatmap_plot,
  5602. file_stem = "sample_level_tf_activity_heatmap",
  5603. width = 12,
  5604. height = 7
  5605. )
  5606. # 13.2 Descriptive LPS-versus-NS TF activity contrast ------------------------
  5607. tf_contrast <- sample_level_activity %>%
  5608. group_by(
  5609. source,
  5610. condition_label
  5611. ) %>%
  5612. summarise(
  5613. mean_score = mean(
  5614. score,
  5615. na.rm = TRUE
  5616. ),
  5617. n_samples = n_distinct(
  5618. sample_id
  5619. ),
  5620. .groups = "drop"
  5621. ) %>%
  5622. select(
  5623. source,
  5624. condition_label,
  5625. mean_score,
  5626. n_samples
  5627. ) %>%
  5628. pivot_wider(
  5629. names_from = condition_label,
  5630. values_from = c(
  5631. mean_score,
  5632. n_samples
  5633. ),
  5634. values_fill = 0
  5635. ) %>%
  5636. mutate(
  5637. delta_LPS_minus_NS = (
  5638. mean_score_LPS -
  5639. mean_score_NS
  5640. )
  5641. )
  5642. tf_contrast_statistics <- sample_level_activity %>%
  5643. group_by(source) %>%
  5644. summarise(
  5645. p_value = tryCatch(
  5646. {
  5647. condition_count <- n_distinct(
  5648. condition_label
  5649. )
  5650. if (
  5651. condition_count == 2 &&
  5652. all(
  5653. table(condition_label) >= 2
  5654. )
  5655. ) {
  5656. t.test(
  5657. score ~ condition_label
  5658. )$p.value
  5659. } else {
  5660. NA_real_
  5661. }
  5662. },
  5663. error = function(e) {
  5664. NA_real_
  5665. }
  5666. ),
  5667. .groups = "drop"
  5668. ) %>%
  5669. mutate(
  5670. p_adjusted = p.adjust(
  5671. p_value,
  5672. method = "BH"
  5673. )
  5674. )
  5675. tf_contrast <- tf_contrast %>%
  5676. left_join(
  5677. tf_contrast_statistics,
  5678. by = "source"
  5679. )
  5680. top_positive <- tf_contrast %>%
  5681. arrange(
  5682. desc(delta_LPS_minus_NS)
  5683. ) %>%
  5684. slice_head(n = 10)
  5685. top_negative <- tf_contrast %>%
  5686. arrange(
  5687. delta_LPS_minus_NS
  5688. ) %>%
  5689. slice_head(n = 10)
  5690. contrast_plot_data <- bind_rows(
  5691. top_positive,
  5692. top_negative
  5693. ) %>%
  5694. distinct(source, .keep_all = TRUE) %>%
  5695. arrange(
  5696. delta_LPS_minus_NS
  5697. ) %>%
  5698. mutate(
  5699. source = factor(
  5700. source,
  5701. levels = source
  5702. ),
  5703. direction = if_else(
  5704. delta_LPS_minus_NS >= 0,
  5705. "Higher in LPS",
  5706. "Higher in NS"
  5707. )
  5708. )
  5709. contrast_plot <- ggplot(
  5710. contrast_plot_data,
  5711. aes(
  5712. x = delta_LPS_minus_NS,
  5713. y = source,
  5714. fill = direction
  5715. )
  5716. ) +
  5717. geom_col(
  5718. width = 0.75
  5719. ) +
  5720. geom_vline(
  5721. xintercept = 0,
  5722. linewidth = 0.6
  5723. ) +
  5724. scale_fill_manual(
  5725. values = c(
  5726. "Higher in LPS" = "#B2182B",
  5727. "Higher in NS" = "#2166AC"
  5728. )
  5729. ) +
  5730. labs(
  5731. title = "Descriptive LPS versus NS TF activity contrast",
  5732. x = "Mean ULM activity difference (LPS - NS)",
  5733. y = NULL,
  5734. fill = NULL
  5735. ) +
  5736. theme_classic(base_size = 11) +
  5737. theme(
  5738. legend.position = "top",
  5739. plot.title = element_text(
  5740. face = "bold",
  5741. hjust = 0.5
  5742. )
  5743. )
  5744. write_csv(
  5745. tf_contrast,
  5746. file.path(
  5747. table_dir,
  5748. "sample_level_tf_activity_contrast.csv"
  5749. )
  5750. )
  5751. save_ggplot(
  5752. plot_object = contrast_plot,
  5753. file_stem = "sample_level_tf_activity_contrast",
  5754. width = 8,
  5755. height = 6
  5756. )
  5757. }
  5758. # 14. Compare TF activity with TF expression -----------------------------------
  5759. activity_expression_result <- NULL
  5760. if (
  5761. analysis_options$make_activity_expression_heatmap &&
  5762. analysis_options$run_celltype_analysis
  5763. ) {
  5764. mean_activity_key_tfs <- collect_activity_results(
  5765. tf_results = tf_results,
  5766. grouping_name = "celltype",
  5767. method_name = "ulm"
  5768. ) %>%
  5769. filter(
  5770. source %in% key_tfs
  5771. ) %>%
  5772. group_by(
  5773. object_name,
  5774. condition_label,
  5775. grouping_value,
  5776. source
  5777. ) %>%
  5778. summarise(
  5779. mean_value = mean(
  5780. score,
  5781. na.rm = TRUE
  5782. ),
  5783. .groups = "drop"
  5784. ) %>%
  5785. transmute(
  5786. object_name,
  5787. condition_label,
  5788. grouping_value,
  5789. tf = source,
  5790. measure = "TF activity",
  5791. mean_value
  5792. )
  5793. mean_expression_key_tfs <- map_dfr(
  5794. seq_len(nrow(sample_information)),
  5795. function(i) {
  5796. sample_row <- sample_information[i, ]
  5797. object_name <- sample_row$object_name[[1]]
  5798. compute_mean_expression_by_group(
  5799. object = seurat_objects[[object_name]],
  5800. assay = sample_row$assay[[1]],
  5801. grouping_col = sample_row$celltype_col[[1]],
  5802. features = key_tfs
  5803. ) %>%
  5804. mutate(
  5805. object_name = object_name,
  5806. condition_label = sample_row$condition[[1]],
  5807. measure = "TF expression",
  5808. .before = 1
  5809. )
  5810. }
  5811. )
  5812. activity_expression_data <- bind_rows(
  5813. mean_activity_key_tfs,
  5814. mean_expression_key_tfs
  5815. ) %>%
  5816. group_by(
  5817. condition_label,
  5818. measure,
  5819. tf
  5820. ) %>%
  5821. mutate(
  5822. z_score = z_score_vector(
  5823. mean_value
  5824. )
  5825. ) %>%
  5826. ungroup() %>%
  5827. mutate(
  5828. condition_label = factor(
  5829. condition_label,
  5830. levels = condition_levels
  5831. ),
  5832. measure = factor(
  5833. measure,
  5834. levels = c(
  5835. "TF activity",
  5836. "TF expression"
  5837. )
  5838. ),
  5839. tf = factor(
  5840. tf,
  5841. levels = rev(key_tfs)
  5842. )
  5843. )
  5844. celltype_order <- activity_expression_data %>%
  5845. distinct(grouping_value) %>%
  5846. arrange(grouping_value) %>%
  5847. pull(grouping_value)
  5848. activity_expression_data$grouping_value <- factor(
  5849. activity_expression_data$grouping_value,
  5850. levels = celltype_order
  5851. )
  5852. activity_expression_plot <- ggplot(
  5853. activity_expression_data,
  5854. aes(
  5855. x = grouping_value,
  5856. y = tf,
  5857. fill = z_score
  5858. )
  5859. ) +
  5860. geom_tile(
  5861. linewidth = 0.2,
  5862. colour = "grey85"
  5863. ) +
  5864. facet_grid(
  5865. measure ~ condition_label,
  5866. scales = "free_x",
  5867. space = "free_x"
  5868. ) +
  5869. scale_fill_gradient2(
  5870. low = "#2166AC",
  5871. mid = "white",
  5872. high = "#B2182B",
  5873. midpoint = 0,
  5874. limits = c(-2.5, 2.5),
  5875. oob = scales::squish,
  5876. name = "Row z-score"
  5877. ) +
  5878. labs(
  5879. title = "TF activity versus TF expression across microglial states",
  5880. x = NULL,
  5881. y = NULL
  5882. ) +
  5883. theme_classic(base_size = 11) +
  5884. theme(
  5885. axis.text.x = element_text(
  5886. angle = 45,
  5887. hjust = 1,
  5888. vjust = 1
  5889. ),
  5890. axis.ticks = element_blank(),
  5891. panel.border = element_rect(
  5892. colour = "black",
  5893. fill = NA,
  5894. linewidth = 0.5
  5895. ),
  5896. strip.background = element_blank(),
  5897. strip.text = element_text(
  5898. face = "bold"
  5899. ),
  5900. plot.title = element_text(
  5901. face = "bold",
  5902. hjust = 0.5
  5903. )
  5904. )
  5905. write_csv(
  5906. activity_expression_data,
  5907. file.path(
  5908. table_dir,
  5909. "key_tf_activity_expression_plot_data.csv"
  5910. )
  5911. )
  5912. save_ggplot(
  5913. plot_object = activity_expression_plot,
  5914. file_stem = "key_tf_activity_vs_expression",
  5915. width = 14,
  5916. height = 7
  5917. )
  5918. activity_expression_result <- list(
  5919. plot = activity_expression_plot,
  5920. plot_data = activity_expression_data
  5921. )
  5922. }
  5923. # 15. Generate key TF expression UMAP panels -----------------------------------
  5924. key_tf_umap_panels <- list()
  5925. if (analysis_options$make_key_tf_umap) {
  5926. for (i in seq_len(nrow(sample_information))) {
  5927. sample_row <- sample_information[i, ]
  5928. object_name <- sample_row$object_name[[1]]
  5929. condition <- sample_row$condition[[1]]
  5930. assay_name <- sample_row$assay[[1]]
  5931. object <- seurat_objects[[object_name]]
  5932. DefaultAssay(object) <- assay_name
  5933. if (!"umap" %in% Reductions(object)) {
  5934. warning(
  5935. "Object '",
  5936. object_name,
  5937. "' has no UMAP reduction. UMAP panels were skipped."
  5938. )
  5939. next
  5940. }
  5941. expression_matrix <- tryCatch(
  5942. get_assay_matrix(
  5943. object = object,
  5944. assay = assay_name,
  5945. layer_name = "data"
  5946. ),
  5947. error = function(e) {
  5948. matrix(
  5949. numeric(0),
  5950. nrow = 0,
  5951. ncol = 0
  5952. )
  5953. }
  5954. )
  5955. if (
  5956. nrow(expression_matrix) == 0 ||
  5957. ncol(expression_matrix) == 0
  5958. ) {
  5959. object <- NormalizeData(
  5960. object = object,
  5961. assay = assay_name,
  5962. verbose = FALSE
  5963. )
  5964. }
  5965. available_tfs <- intersect(
  5966. key_tfs,
  5967. rownames(object)
  5968. )
  5969. if (length(available_tfs) == 0) {
  5970. warning(
  5971. "None of the requested TFs were found in object '",
  5972. object_name,
  5973. "'."
  5974. )
  5975. next
  5976. }
  5977. feature_plots <- map(
  5978. available_tfs,
  5979. function(tf_name) {
  5980. FeaturePlot(
  5981. object = object,
  5982. features = tf_name,
  5983. reduction = "umap",
  5984. order = TRUE,
  5985. min.cutoff = "q05",
  5986. max.cutoff = "q95"
  5987. ) +
  5988. ggtitle(tf_name) +
  5989. theme(
  5990. plot.title = element_text(
  5991. size = 11,
  5992. face = "bold",
  5993. hjust = 0.5
  5994. ),
  5995. axis.title = element_blank(),
  5996. axis.text = element_blank(),
  5997. axis.ticks = element_blank(),
  5998. legend.title = element_text(
  5999. size = 8
  6000. ),
  6001. legend.text = element_text(
  6002. size = 7
  6003. )
  6004. )
  6005. }
  6006. )
  6007. panel <- wrap_plots(
  6008. feature_plots,
  6009. ncol = 3
  6010. ) +
  6011. plot_annotation(
  6012. title = paste0(
  6013. condition,
  6014. " microglia: key TF expression"
  6015. ),
  6016. theme = theme(
  6017. plot.title = element_text(
  6018. size = 14,
  6019. face = "bold",
  6020. hjust = 0.5
  6021. )
  6022. )
  6023. )
  6024. key_tf_umap_panels[[object_name]] <- panel
  6025. save_ggplot(
  6026. plot_object = panel,
  6027. file_stem = paste0(
  6028. "key_tf_umap_",
  6029. object_name
  6030. ),
  6031. width = 12,
  6032. height = 8
  6033. )
  6034. }
  6035. if (length(key_tf_umap_panels) > 1) {
  6036. combined_umap_panel <- wrap_plots(
  6037. key_tf_umap_panels,
  6038. ncol = 1
  6039. )
  6040. save_ggplot(
  6041. plot_object = combined_umap_panel,
  6042. file_stem = "key_tf_umap_combined",
  6043. width = 12,
  6044. height = 16
  6045. )
  6046. }
  6047. }
  6048. # 16. Save compact and augmented RDS outputs -----------------------------------
  6049. lightweight_results <- list(
  6050. created_at = as.character(Sys.time()),
  6051. analysis_parameters = list(
  6052. min_cells_per_pseudobulk = min_cells_per_pseudobulk,
  6053. min_targets_per_tf = min_targets_per_tf,
  6054. normalization_target_sum = normalization_target_sum,
  6055. tf_methods = tf_methods,
  6056. top_tfs_per_profile = top_tfs_per_profile,
  6057. top_tfs_for_heatmap = top_tfs_for_heatmap,
  6058. key_tfs = key_tfs
  6059. ),
  6060. sample_information = sample_information,
  6061. object_summary = object_summary,
  6062. metadata_level_counts = metadata_level_counts,
  6063. network_summary = tibble(
  6064. n_interactions = nrow(
  6065. collectri_network
  6066. ),
  6067. n_tfs = n_distinct(
  6068. collectri_network$source
  6069. ),
  6070. n_targets = n_distinct(
  6071. collectri_network$target
  6072. )
  6073. ),
  6074. tf_results = tf_results,
  6075. combined_top_tfs = combined_top_tfs,
  6076. recurrent_top_tfs = recurrent_top_tfs,
  6077. method_errors = method_errors,
  6078. sample_level_results = sample_level_results
  6079. )
  6080. saveRDS(
  6081. lightweight_results,
  6082. file = file.path(
  6083. rds_output_dir,
  6084. "tf_activity_analysis_results.rds"
  6085. ),
  6086. compress = "gzip"
  6087. )
  6088. if (analysis_options$save_augmented_seurat_objects) {
  6089. for (i in seq_len(nrow(sample_information))) {
  6090. sample_row <- sample_information[i, ]
  6091. object_name <- sample_row$object_name[[1]]
  6092. object <- seurat_objects[[object_name]]
  6093. object@misc$tf_activity_analysis <- list(
  6094. created_at = as.character(
  6095. Sys.time()
  6096. ),
  6097. condition = sample_row$condition[[1]],
  6098. analysis_parameters = lightweight_results$analysis_parameters,
  6099. celltype_results = tf_results[[object_name]][["celltype"]],
  6100. cluster_results = tf_results[[object_name]][["seurat_clusters"]],
  6101. sample_level_results = sample_level_results[[object_name]],
  6102. recurrent_top_tfs = recurrent_top_tfs
  6103. )
  6104. output_file <- file.path(
  6105. rds_output_dir,
  6106. paste0(
  6107. object_name,
  6108. "_with_tf_activity.rds"
  6109. )
  6110. )
  6111. saveRDS(
  6112. object,
  6113. file = output_file,
  6114. compress = "gzip"
  6115. )
  6116. # Verify that the saved RDS file can be read.
  6117. verification_object <- tryCatch(
  6118. readRDS(output_file),
  6119. error = function(e) {
  6120. NULL
  6121. }
  6122. )
  6123. if (is.null(verification_object)) {
  6124. stop(
  6125. "RDS verification failed for: ",
  6126. output_file
  6127. )
  6128. }
  6129. rm(verification_object)
  6130. }
  6131. }
  6132. # 17. Save session information -------------------------------------------------
  6133. capture.output(
  6134. sessionInfo(),
  6135. file = file.path(
  6136. output_dir,
  6137. "sessionInfo.txt"
  6138. )
  6139. )
  6140. message(
  6141. "TF activity analysis completed successfully."
  6142. )
  6143. message(
  6144. "Main output directory: ",
  6145. normalizePath(
  6146. output_dir,
  6147. mustWork = FALSE
  6148. )
  6149. )
  6150. # ==============================================================================
  6151. # (11) Cross-dataset integration of developmental microglial states
  6152. #
  6153. # Description:
  6154. # This script transfers microglial-state annotations from a neonatal mouse
  6155. # reference dataset to the Hammond developmental microglia dataset, integrates
  6156. # the two datasets using SCTransform and reciprocal PCA, and generates the three
  6157. # panels used for cross-dataset visualization:
  6158. #
  6159. # 1. integrated UMAP colored by developmental stage;
  6160. # 2. integrated UMAP colored by microglial state;
  6161. # 3. microglial-state composition across developmental stages.
  6162. #
  6163. # The script intentionally excludes marker-gene analysis, RNA velocity,
  6164. # trajectory inference, and additional feature plots because they are not
  6165. # required for these three panels.
  6166. # ==============================================================================
  6167. # 1. Load packages -------------------------------------------------------------
  6168. suppressPackageStartupMessages({
  6169. library(Seurat)
  6170. library(dplyr)
  6171. library(tidyr)
  6172. library(tibble)
  6173. library(ggplot2)
  6174. library(patchwork)
  6175. })
  6176. # 2. Define project directories ------------------------------------------------
  6177. input_dir <- "DATA/INPUT/RDS"
  6178. output_dir <- "DATA/OUTPUT/CROSS_DATASET_INTEGRATION"
  6179. figure_dir <- file.path(output_dir, "FIGURES")
  6180. table_dir <- file.path(output_dir, "TABLES")
  6181. rds_dir <- file.path(output_dir, "RDS")
  6182. dir.create(
  6183. figure_dir,
  6184. recursive = TRUE,
  6185. showWarnings = FALSE
  6186. )
  6187. dir.create(
  6188. table_dir,
  6189. recursive = TRUE,
  6190. showWarnings = FALSE
  6191. )
  6192. dir.create(
  6193. rds_dir,
  6194. recursive = TRUE,
  6195. showWarnings = FALSE
  6196. )
  6197. # 3. Define input files and analysis parameters --------------------------------
  6198. reference_file <- file.path(
  6199. input_dir,
  6200. "All ns Mg 0326.rds"
  6201. )
  6202. hammond_file <- file.path(
  6203. input_dir,
  6204. "ham_microglia_res0.3_annotated.rds"
  6205. )
  6206. reference_celltype_col <- "celltype"
  6207. reference_group_col <- "group"
  6208. hammond_group_col <- "group"
  6209. rna_assay <- "RNA"
  6210. integration_assay <- "SCTint"
  6211. n_integration_features <- 3000
  6212. n_pcs <- 30
  6213. integration_dims <- 1:30
  6214. umap_seed <- 11
  6215. # Re-running SCTransform creates one new SCT model per input object and avoids
  6216. # problems caused by multiple pre-existing SCT models in merged Seurat objects.
  6217. rerun_sctransform <- TRUE
  6218. # The transferred Hammond labels are used without a confidence cutoff by
  6219. # default. Set a numeric value such as 0.40 to label lower-confidence cells as
  6220. # "Low confidence".
  6221. minimum_prediction_score <- NULL
  6222. set.seed(umap_seed)
  6223. # 4. Define developmental-stage and cell-state order ---------------------------
  6224. developmental_stage_levels <- c(
  6225. "E14",
  6226. "P3",
  6227. "P4/P5",
  6228. "P7",
  6229. "P12",
  6230. "P30"
  6231. )
  6232. celltype_levels <- c(
  6233. "NDM",
  6234. "Mg1",
  6235. "Mg2",
  6236. "PEM",
  6237. "PAM",
  6238. "Cd74+Mg",
  6239. "Cd11c+Mg",
  6240. "Pf4+Mg"
  6241. )
  6242. # 5. Define plotting colors -----------------------------------------------------
  6243. developmental_stage_colors <- c(
  6244. "E14" = "#3C78B4",
  6245. "P3" = "#F4B183",
  6246. "P4/P5" = "#7FB8B5",
  6247. "P7" = "#69B34C",
  6248. "P12" = "#F2C66D",
  6249. "P30" = "#FF7F00"
  6250. )
  6251. microglial_state_colors <- c(
  6252. "NDM" = "#E31A1C",
  6253. "Mg1" = "#A6CEE3",
  6254. "Mg2" = "#1F78B4",
  6255. "PEM" = "#B2DF8A",
  6256. "PAM" = "#33A02C",
  6257. "Cd74+Mg" = "#FDBF6F",
  6258. "Cd11c+Mg" = "#FF7F00",
  6259. "Pf4+Mg" = "#FB9A99"
  6260. )
  6261. # 6. Define helper functions ----------------------------------------------------
  6262. standardize_stage_names <- function(x) {
  6263. recode(
  6264. as.character(x),
  6265. "NS_P3" = "P3",
  6266. "NS_P7" = "P7",
  6267. "NS_P12" = "P12",
  6268. "P4_P5" = "P4/P5",
  6269. .default = as.character(x)
  6270. )
  6271. }
  6272. validate_input_object <- function(
  6273. object,
  6274. object_name,
  6275. group_col,
  6276. celltype_col = NULL
  6277. ) {
  6278. if (!inherits(object, "Seurat")) {
  6279. stop(
  6280. "The input file for ",
  6281. object_name,
  6282. " does not contain a Seurat object."
  6283. )
  6284. }
  6285. if (!rna_assay %in% Assays(object)) {
  6286. stop(
  6287. "The RNA assay is absent from object: ",
  6288. object_name
  6289. )
  6290. }
  6291. required_metadata <- group_col
  6292. if (!is.null(celltype_col)) {
  6293. required_metadata <- c(
  6294. required_metadata,
  6295. celltype_col
  6296. )
  6297. }
  6298. missing_metadata <- setdiff(
  6299. required_metadata,
  6300. colnames([email hidden])
  6301. )
  6302. if (length(missing_metadata) > 0) {
  6303. stop(
  6304. "Object '",
  6305. object_name,
  6306. "' is missing metadata columns: ",
  6307. paste(
  6308. missing_metadata,
  6309. collapse = ", "
  6310. )
  6311. )
  6312. }
  6313. invisible(TRUE)
  6314. }
  6315. run_integration_sctransform <- function(
  6316. object,
  6317. new_assay_name
  6318. ) {
  6319. DefaultAssay(object) <- rna_assay
  6320. variables_to_regress <- if (
  6321. "percent.mt" %in% colnames([email hidden])
  6322. ) {
  6323. "percent.mt"
  6324. } else {
  6325. NULL
  6326. }
  6327. object <- SCTransform(
  6328. object = object,
  6329. assay = rna_assay,
  6330. new.assay.name = new_assay_name,
  6331. vars.to.regress = variables_to_regress,
  6332. variable.features.n = n_integration_features,
  6333. verbose = FALSE
  6334. )
  6335. DefaultAssay(object) <- new_assay_name
  6336. return(object)
  6337. }
  6338. save_plot <- function(
  6339. plot_object,
  6340. file_stem,
  6341. width,
  6342. height
  6343. ) {
  6344. ggsave(
  6345. filename = file.path(
  6346. figure_dir,
  6347. paste0(
  6348. file_stem,
  6349. ".pdf"
  6350. )
  6351. ),
  6352. plot = plot_object,
  6353. width = width,
  6354. height = height,
  6355. units = "in"
  6356. )
  6357. ggsave(
  6358. filename = file.path(
  6359. figure_dir,
  6360. paste0(
  6361. file_stem,
  6362. ".tiff"
  6363. )
  6364. ),
  6365. plot = plot_object,
  6366. width = width,
  6367. height = height,
  6368. units = "in",
  6369. dpi = 600,
  6370. compression = "lzw"
  6371. )
  6372. }
  6373. # 7. Load and validate the input objects ---------------------------------------
  6374. if (!file.exists(reference_file)) {
  6375. stop(
  6376. "Reference file does not exist: ",
  6377. reference_file
  6378. )
  6379. }
  6380. if (!file.exists(hammond_file)) {
  6381. stop(
  6382. "Hammond file does not exist: ",
  6383. hammond_file
  6384. )
  6385. }
  6386. reference <- readRDS(reference_file)
  6387. hammond <- readRDS(hammond_file)
  6388. validate_input_object(
  6389. object = reference,
  6390. object_name = "reference",
  6391. group_col = reference_group_col,
  6392. celltype_col = reference_celltype_col
  6393. )
  6394. validate_input_object(
  6395. object = hammond,
  6396. object_name = "Hammond",
  6397. group_col = hammond_group_col
  6398. )
  6399. # Prefix cell names to ensure uniqueness after integration.
  6400. reference <- RenameCells(
  6401. object = reference,
  6402. add.cell.id = "REF"
  6403. )
  6404. hammond <- RenameCells(
  6405. object = hammond,
  6406. add.cell.id = "HAM"
  6407. )
  6408. reference$dataset <- "Reference"
  6409. hammond$dataset <- "Hammond"
  6410. # 8. Prepare one SCT model per input object ------------------------------------
  6411. if (rerun_sctransform) {
  6412. message(
  6413. "Re-running SCTransform for the reference object."
  6414. )
  6415. reference <- run_integration_sctransform(
  6416. object = reference,
  6417. new_assay_name = integration_assay
  6418. )
  6419. message(
  6420. "Re-running SCTransform for the Hammond object."
  6421. )
  6422. hammond <- run_integration_sctransform(
  6423. object = hammond,
  6424. new_assay_name = integration_assay
  6425. )
  6426. } else {
  6427. if (!"SCT" %in% Assays(reference) ||
  6428. !"SCT" %in% Assays(hammond)) {
  6429. stop(
  6430. "Both objects must contain an SCT assay when ",
  6431. "rerun_sctransform is FALSE."
  6432. )
  6433. }
  6434. integration_assay <- "SCT"
  6435. DefaultAssay(reference) <- integration_assay
  6436. DefaultAssay(hammond) <- integration_assay
  6437. }
  6438. # 9. Define shared genes and transfer features ---------------------------------
  6439. shared_rna_genes <- intersect(
  6440. rownames(reference[[rna_assay]]),
  6441. rownames(hammond[[rna_assay]])
  6442. )
  6443. if (length(shared_rna_genes) == 0) {
  6444. stop(
  6445. "No shared RNA features were detected between the two objects."
  6446. )
  6447. }
  6448. transfer_features <- intersect(
  6449. VariableFeatures(reference),
  6450. rownames(hammond[[integration_assay]])
  6451. )
  6452. transfer_features <- intersect(
  6453. transfer_features,
  6454. shared_rna_genes
  6455. )
  6456. if (length(transfer_features) < 1000) {
  6457. warning(
  6458. "Fewer than 1,000 shared variable features are available ",
  6459. "for label transfer: ",
  6460. length(transfer_features)
  6461. )
  6462. }
  6463. feature_summary <- tibble(
  6464. statistic = c(
  6465. "Reference RNA genes",
  6466. "Hammond RNA genes",
  6467. "Shared RNA genes",
  6468. "Transfer features"
  6469. ),
  6470. value = c(
  6471. nrow(reference[[rna_assay]]),
  6472. nrow(hammond[[rna_assay]]),
  6473. length(shared_rna_genes),
  6474. length(transfer_features)
  6475. )
  6476. )
  6477. write_csv(
  6478. feature_summary,
  6479. file.path(
  6480. table_dir,
  6481. "feature_summary.csv"
  6482. )
  6483. )
  6484. # 10. Transfer reference cell-state labels to Hammond cells --------------------
  6485. DefaultAssay(reference) <- integration_assay
  6486. DefaultAssay(hammond) <- integration_assay
  6487. reference <- RunPCA(
  6488. object = reference,
  6489. assay = integration_assay,
  6490. features = transfer_features,
  6491. npcs = n_pcs,
  6492. verbose = FALSE
  6493. )
  6494. transfer_anchors <- FindTransferAnchors(
  6495. reference = reference,
  6496. query = hammond,
  6497. normalization.method = "SCT",
  6498. reference.assay = integration_assay,
  6499. query.assay = integration_assay,
  6500. reduction = "pcaproject",
  6501. reference.reduction = "pca",
  6502. features = transfer_features,
  6503. dims = integration_dims
  6504. )
  6505. celltype_predictions <- TransferData(
  6506. anchorset = transfer_anchors,
  6507. refdata = [email hidden][
  6508. [reference_celltype_col]
  6509. ],
  6510. dims = integration_dims
  6511. )
  6512. hammond <- AddMetaData(
  6513. object = hammond,
  6514. metadata = celltype_predictions
  6515. )
  6516. hammond$predicted_celltype <- as.character(
  6517. hammond$predicted.id
  6518. )
  6519. hammond$predicted_celltype_score <- hammond$prediction.score.max
  6520. if (!is.null(minimum_prediction_score)) {
  6521. hammond$predicted_celltype <- ifelse(
  6522. hammond$predicted_celltype_score >= minimum_prediction_score,
  6523. hammond$predicted_celltype,
  6524. "Low confidence"
  6525. )
  6526. }
  6527. label_transfer_summary <- [email hidden] %>%
  6528. count(
  6529. .data[[hammond_group_col]],
  6530. predicted_celltype,
  6531. name = "n_cells"
  6532. ) %>%
  6533. rename(
  6534. original_group = 1
  6535. ) %>%
  6536. group_by(original_group) %>%
  6537. mutate(
  6538. percentage = 100 * n_cells / sum(n_cells)
  6539. ) %>%
  6540. ungroup()
  6541. write_csv(
  6542. label_transfer_summary,
  6543. file.path(
  6544. table_dir,
  6545. "hammond_label_transfer_summary.csv"
  6546. )
  6547. )
  6548. prediction_score_summary <- [email hidden] %>%
  6549. summarise(
  6550. n_cells = n(),
  6551. minimum_score = min(
  6552. predicted_celltype_score,
  6553. na.rm = TRUE
  6554. ),
  6555. first_quartile = quantile(
  6556. predicted_celltype_score,
  6557. 0.25,
  6558. na.rm = TRUE
  6559. ),
  6560. median_score = median(
  6561. predicted_celltype_score,
  6562. na.rm = TRUE
  6563. ),
  6564. mean_score = mean(
  6565. predicted_celltype_score,
  6566. na.rm = TRUE
  6567. ),
  6568. third_quartile = quantile(
  6569. predicted_celltype_score,
  6570. 0.75,
  6571. na.rm = TRUE
  6572. ),
  6573. maximum_score = max(
  6574. predicted_celltype_score,
  6575. na.rm = TRUE
  6576. )
  6577. )
  6578. write_csv(
  6579. prediction_score_summary,
  6580. file.path(
  6581. table_dir,
  6582. "hammond_prediction_score_summary.csv"
  6583. )
  6584. )
  6585. # 11. Standardize developmental-stage and cell-state metadata ------------------
  6586. reference$developmental_stage <- factor(
  6587. standardize_stage_names(
  6588. [email hidden][
  6589. [reference_group_col]
  6590. ]
  6591. ),
  6592. levels = developmental_stage_levels
  6593. )
  6594. hammond$developmental_stage <- factor(
  6595. standardize_stage_names(
  6596. [email hidden][
  6597. [hammond_group_col]
  6598. ]
  6599. ),
  6600. levels = developmental_stage_levels
  6601. )
  6602. reference$microglial_state <- factor(
  6603. as.character(
  6604. [email hidden][
  6605. [reference_celltype_col]
  6606. ]
  6607. ),
  6608. levels = celltype_levels
  6609. )
  6610. if (is.null(minimum_prediction_score)) {
  6611. hammond$microglial_state <- factor(
  6612. hammond$predicted_celltype,
  6613. levels = celltype_levels
  6614. )
  6615. } else {
  6616. hammond$microglial_state <- factor(
  6617. hammond$predicted_celltype,
  6618. levels = c(
  6619. celltype_levels,
  6620. "Low confidence"
  6621. )
  6622. )
  6623. }
  6624. unexpected_reference_stages <- setdiff(
  6625. unique(
  6626. as.character(
  6627. [email hidden][
  6628. [reference_group_col]
  6629. ]
  6630. )
  6631. ),
  6632. c(
  6633. "NS_P3",
  6634. "NS_P7",
  6635. "NS_P12",
  6636. developmental_stage_levels,
  6637. "P4_P5"
  6638. )
  6639. )
  6640. unexpected_hammond_stages <- setdiff(
  6641. unique(
  6642. as.character(
  6643. [email hidden][
  6644. [hammond_group_col]
  6645. ]
  6646. )
  6647. ),
  6648. c(
  6649. developmental_stage_levels,
  6650. "P4_P5",
  6651. "NS_P3",
  6652. "NS_P7",
  6653. "NS_P12"
  6654. )
  6655. )
  6656. if (length(unexpected_reference_stages) > 0) {
  6657. warning(
  6658. "Unexpected developmental-stage labels were found in the ",
  6659. "reference object: ",
  6660. paste(
  6661. unexpected_reference_stages,
  6662. collapse = ", "
  6663. )
  6664. )
  6665. }
  6666. if (length(unexpected_hammond_stages) > 0) {
  6667. warning(
  6668. "Unexpected developmental-stage labels were found in the ",
  6669. "Hammond object: ",
  6670. paste(
  6671. unexpected_hammond_stages,
  6672. collapse = ", "
  6673. )
  6674. )
  6675. }
  6676. if (anyNA(reference$developmental_stage) ||
  6677. anyNA(hammond$developmental_stage)) {
  6678. stop(
  6679. "At least one developmental-stage label could not be standardized."
  6680. )
  6681. }
  6682. if (anyNA(reference$microglial_state)) {
  6683. stop(
  6684. "At least one reference cell-state label is absent from ",
  6685. "celltype_levels."
  6686. )
  6687. }
  6688. if (is.null(minimum_prediction_score) &&
  6689. anyNA(hammond$microglial_state)) {
  6690. stop(
  6691. "At least one transferred Hammond label is absent from ",
  6692. "celltype_levels."
  6693. )
  6694. }
  6695. # 12. Select SCT integration features ------------------------------------------
  6696. object_list <- list(
  6697. Reference = reference,
  6698. Hammond = hammond
  6699. )
  6700. integration_features_raw <- SelectIntegrationFeatures(
  6701. object.list = object_list,
  6702. nfeatures = n_integration_features,
  6703. assay = rep(
  6704. integration_assay,
  6705. length(object_list)
  6706. )
  6707. )
  6708. integration_features <- intersect(
  6709. integration_features_raw,
  6710. shared_rna_genes
  6711. )
  6712. if (length(integration_features) < 1000) {
  6713. warning(
  6714. "Fewer than 1,000 shared integration features were retained: ",
  6715. length(integration_features)
  6716. )
  6717. }
  6718. write_csv(
  6719. tibble(
  6720. feature = integration_features
  6721. ),
  6722. file.path(
  6723. table_dir,
  6724. "integration_features.csv"
  6725. )
  6726. )
  6727. # 13. Prepare SCT objects and identify RPCA anchors ----------------------------
  6728. object_list <- PrepSCTIntegration(
  6729. object.list = object_list,
  6730. assay = rep(
  6731. integration_assay,
  6732. length(object_list)
  6733. ),
  6734. anchor.features = integration_features
  6735. )
  6736. object_list <- lapply(
  6737. object_list,
  6738. function(object) {
  6739. DefaultAssay(object) <- integration_assay
  6740. object <- RunPCA(
  6741. object = object,
  6742. assay = integration_assay,
  6743. features = integration_features,
  6744. npcs = n_pcs,
  6745. verbose = FALSE
  6746. )
  6747. return(object)
  6748. }
  6749. )
  6750. integration_anchors <- FindIntegrationAnchors(
  6751. object.list = object_list,
  6752. assay = rep(
  6753. integration_assay,
  6754. length(object_list)
  6755. ),
  6756. normalization.method = "SCT",
  6757. anchor.features = integration_features,
  6758. reduction = "rpca",
  6759. reference = 1,
  6760. dims = integration_dims
  6761. )
  6762. # 14. Integrate the datasets and generate a shared UMAP ------------------------
  6763. integrated <- IntegrateData(
  6764. anchorset = integration_anchors,
  6765. normalization.method = "SCT",
  6766. dims = integration_dims
  6767. )
  6768. DefaultAssay(integrated) <- "integrated"
  6769. integrated <- RunPCA(
  6770. object = integrated,
  6771. assay = "integrated",
  6772. npcs = n_pcs,
  6773. verbose = FALSE
  6774. )
  6775. integrated <- RunUMAP(
  6776. object = integrated,
  6777. reduction = "pca",
  6778. dims = integration_dims,
  6779. seed.use = umap_seed,
  6780. verbose = FALSE
  6781. )
  6782. # Reapply factor levels after integration.
  6783. integrated$developmental_stage <- factor(
  6784. as.character(
  6785. integrated$developmental_stage
  6786. ),
  6787. levels = developmental_stage_levels
  6788. )
  6789. if (is.null(minimum_prediction_score)) {
  6790. integrated$microglial_state <- factor(
  6791. as.character(
  6792. integrated$microglial_state
  6793. ),
  6794. levels = celltype_levels
  6795. )
  6796. } else {
  6797. integrated$microglial_state <- factor(
  6798. as.character(
  6799. integrated$microglial_state
  6800. ),
  6801. levels = c(
  6802. celltype_levels,
  6803. "Low confidence"
  6804. )
  6805. )
  6806. }
  6807. # 15. Export integrated-object summaries ---------------------------------------
  6808. integrated_object_summary <- [email hidden] %>%
  6809. count(
  6810. dataset,
  6811. developmental_stage,
  6812. microglial_state,
  6813. name = "n_cells",
  6814. .drop = FALSE
  6815. )
  6816. write_csv(
  6817. integrated_object_summary,
  6818. file.path(
  6819. table_dir,
  6820. "integrated_object_summary.csv"
  6821. )
  6822. )
  6823. # 16. Calculate developmental cell-state composition ---------------------------
  6824. composition_table <- [email hidden] %>%
  6825. count(
  6826. developmental_stage,
  6827. microglial_state,
  6828. name = "n_cells",
  6829. .drop = FALSE
  6830. ) %>%
  6831. group_by(
  6832. developmental_stage
  6833. ) %>%
  6834. mutate(
  6835. total_cells = sum(
  6836. n_cells
  6837. ),
  6838. percentage = 100 * n_cells / total_cells
  6839. ) %>%
  6840. ungroup()
  6841. write_csv(
  6842. composition_table,
  6843. file.path(
  6844. table_dir,
  6845. "microglial_state_composition.csv"
  6846. )
  6847. )
  6848. # 17. Plot the integrated UMAP by developmental stage --------------------------
  6849. p_stage <- DimPlot(
  6850. object = integrated,
  6851. reduction = "umap",
  6852. group.by = "developmental_stage",
  6853. cols = developmental_stage_colors,
  6854. label = TRUE,
  6855. repel = TRUE,
  6856. label.size = 4,
  6857. raster = TRUE
  6858. ) +
  6859. labs(
  6860. title = NULL,
  6861. colour = "Developmental stage"
  6862. ) +
  6863. theme_classic(base_size = 11) +
  6864. theme(
  6865. axis.title = element_blank(),
  6866. axis.text = element_blank(),
  6867. axis.ticks = element_blank(),
  6868. legend.title = element_text(
  6869. size = 9
  6870. ),
  6871. legend.text = element_text(
  6872. size = 8
  6873. ),
  6874. panel.border = element_rect(
  6875. colour = "black",
  6876. fill = NA,
  6877. linewidth = 0.5
  6878. )
  6879. )
  6880. save_plot(
  6881. plot_object = p_stage,
  6882. file_stem = "Figure_I_developmental_stage_UMAP",
  6883. width = 5,
  6884. height = 4
  6885. )
  6886. # 18. Plot the integrated UMAP by microglial state -----------------------------
  6887. plot_celltype_colors <- microglial_state_colors
  6888. if (!is.null(minimum_prediction_score)) {
  6889. plot_celltype_colors <- c(
  6890. plot_celltype_colors,
  6891. "Low confidence" = "grey75"
  6892. )
  6893. }
  6894. p_celltype <- DimPlot(
  6895. object = integrated,
  6896. reduction = "umap",
  6897. group.by = "microglial_state",
  6898. cols = plot_celltype_colors,
  6899. label = TRUE,
  6900. repel = TRUE,
  6901. label.size = 4,
  6902. raster = TRUE
  6903. ) +
  6904. labs(
  6905. title = NULL,
  6906. colour = "Microglial state"
  6907. ) +
  6908. theme_classic(base_size = 11) +
  6909. theme(
  6910. axis.title = element_blank(),
  6911. axis.text = element_blank(),
  6912. axis.ticks = element_blank(),
  6913. legend.title = element_text(
  6914. size = 9
  6915. ),
  6916. legend.text = element_text(
  6917. size = 8
  6918. ),
  6919. panel.border = element_rect(
  6920. colour = "black",
  6921. fill = NA,
  6922. linewidth = 0.5
  6923. )
  6924. )
  6925. save_plot(
  6926. plot_object = p_celltype,
  6927. file_stem = "Figure_I_microglial_state_UMAP",
  6928. width = 5,
  6929. height = 4
  6930. )
  6931. # 19. Plot microglial-state composition across developmental stages ------------
  6932. p_composition <- ggplot(
  6933. composition_table,
  6934. aes(
  6935. x = developmental_stage,
  6936. y = percentage,
  6937. fill = microglial_state
  6938. )
  6939. ) +
  6940. geom_col(
  6941. width = 0.75,
  6942. colour = "black",
  6943. linewidth = 0.2
  6944. ) +
  6945. scale_fill_manual(
  6946. values = plot_celltype_colors,
  6947. limits = names(
  6948. plot_celltype_colors
  6949. ),
  6950. breaks = names(
  6951. plot_celltype_colors
  6952. ),
  6953. drop = FALSE
  6954. ) +
  6955. scale_y_continuous(
  6956. limits = c(
  6957. 0,
  6958. 100
  6959. ),
  6960. breaks = seq(
  6961. 0,
  6962. 100,
  6963. by = 20
  6964. ),
  6965. expand = expansion(
  6966. mult = c(
  6967. 0,
  6968. 0.02
  6969. )
  6970. )
  6971. ) +
  6972. labs(
  6973. x = NULL,
  6974. y = "Percentage of cells",
  6975. fill = "Microglial state"
  6976. ) +
  6977. theme_classic(base_size = 11) +
  6978. theme(
  6979. axis.text.x = element_text(
  6980. angle = 45,
  6981. hjust = 1,
  6982. vjust = 1
  6983. ),
  6984. legend.title = element_text(
  6985. size = 9
  6986. ),
  6987. legend.text = element_text(
  6988. size = 8
  6989. )
  6990. )
  6991. save_plot(
  6992. plot_object = p_composition,
  6993. file_stem = "Figure_J_microglial_state_composition",
  6994. width = 4,
  6995. height = 3.5
  6996. )
  6997. # 20. Assemble and save the combined figure ------------------------------------
  6998. p_umap_pair <- (
  6999. p_stage |
  7000. p_celltype
  7001. ) +
  7002. plot_annotation(
  7003. title = paste0(
  7004. "Integrated UMAP of physiological microglial states ",
  7005. "across datasets"
  7006. ),
  7007. theme = theme(
  7008. plot.title = element_text(
  7009. size = 12,
  7010. face = "plain",
  7011. hjust = 0.5
  7012. )
  7013. )
  7014. )
  7015. p_combined <- (
  7016. p_umap_pair |
  7017. p_composition
  7018. ) +
  7019. plot_layout(
  7020. widths = c(
  7021. 2.2,
  7022. 1
  7023. )
  7024. )
  7025. save_plot(
  7026. plot_object = p_combined,
  7027. file_stem = "Figure_IJ_combined",
  7028. width = 12,
  7029. height = 4
  7030. )
  7031. # 21. Save the integrated Seurat object ----------------------------------------
  7032. saveRDS(
  7033. integrated,
  7034. file = file.path(
  7035. rds_dir,
  7036. "reference_hammond_integrated.rds"
  7037. ),
  7038. compress = "gzip"
  7039. )
  7040. saveRDS(
  7041. hammond,
  7042. file = file.path(
  7043. rds_dir,
  7044. "hammond_with_transferred_celltypes.rds"
  7045. ),
  7046. compress = "gzip"
  7047. )
  7048. # 22. Save session information -------------------------------------------------
  7049. capture.output(
  7050. sessionInfo(),
  7051. file = file.path(
  7052. output_dir,
  7053. "sessionInfo.txt"
  7054. )
  7055. )
  7056. message(
  7057. "Cross-dataset integration completed successfully."
  7058. )
  7059. message(
  7060. "Main output directory: ",
  7061. normalizePath(
  7062. output_dir,
  7063. mustWork = FALSE
  7064. )
  7065. )

Code for lps.R at commit 98b6f9a, no license · at the source

Overview

Authors: Jinjin Zhu1,2, Yiran Xu1,2, Liubo Sun1,2, Ziwei Huang1,2, Wenkai Yu1,2, Shan Zhang1,2, Xiaoli Zhang1,2, Tiantian He1,2, Yiwen Chen1,2, Yanan Wu1,2, Bingbing Li1,2, Huifang Dong1,2, Xiaoyang Wang1,2,3, Changlian Zhu1,2,4,5
  1. Henan Key Laboratory of Child Brain Injury, The Third Affiliated Hospital of Zhengzhou University,Zhengzhou, China
  2. Institute of Neuroscience, Zhengzhou University,Zhengzhou, China
  3. Centre of Perinatal Medicine and Health, Institute of Clinical Science, University of Gothenburg,Gothenburg, Sweden
  4. Department of Women’s and Children’s Health, Karolinska Institutet,Stockholm, Sweden
  5. Center for Brain Repair and Rehabilitation, Institute of Neuroscience and Physiology, University of Gothenburg,Gothenburg, Sweden
Journal: Nature communications, volume 17, issue 1, article 9385
Dates: received 4 October 2025; accepted 22 July 2026; published online 4 August 2026
Type: Research article · Language: English
License: CC BY
Identifiers: DOI 10.1038/s41467-026-76341-6 · PMID 42680728 · PMCID PMC13534487 · OpenAlex W7172428426
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: human (organism), mouse (organism)
Methods: Spectral & time-frequency, Statistics, Smoothing, state filtering, decompositions
Keywords: Development of the nervous system, Microglia, Glial development
MeSH: Inflammation*, Membrane Proteins*, Microglia*, Nerve Tissue Proteins*, Animals, Animals, Newborn, Brain, Female, Lipopolysaccharides, Male, Mice, Mice, Inbred C57BL, Neurodevelopment, Somatosensory Cortex (* major topic)
Topic: Neuroinflammation and Neurodegeneration Mechanisms (Neurology, Neuroscience), according to OpenAlex
Citations: cited by 1 paper (Europe PMC); 73 references in the paper

Abstract

Early-life microglia are diverse and support brain development beyond immune surveillance, but the transient states associated with postnatal maturation remain poorly understood. Here we show, using a time-resolved single-cell atlas of neonatal mouse brain immune cells, that a postnatal Numb-enriched microglial state emerges during early postnatal development, expands during the second postnatal week and subsequently declines. This state is characterized by neurodevelopment-related gene expression programs and distinct metabolic features. Trajectory inference, cross-atlas mapping and RNAscope validation support its temporal pattern. During the period when this state expands, microglial depletion preserves gross myelination but alters synaptic protein composition and disrupts dendritic and cortical layer maturation, particularly in the primary somatosensory cortex. Neonatal lipopolysaccharide challenge impairs the establishment of the Numb-enriched state and induces an early glycolytic response followed by recovery-phase inflammatory states. These findings identify a developmentally timed microglial state associated with cortical maturation and vulnerable to neonatal inflammation in mice.

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

Repositories

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

fafax093-design/scRNA-neonatal-microglia-analyses-2025

License: none: the authors keep all their rights
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Commit: 98b6f9a1efcb8dac54d690aed9892f19e3d58d26, 22 June 2026
Languages: R (17), Python (2)
Size: 21 files, 19 scripts
Software Heritage: not archived
Found in: “Code availability”
Holds: README
Not found: license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: tidyverse (17 files), Seurat (14 files), ggplot2 (9 files), Monocle 3 (5 files), patchwork (5 files), circlize (4 files), ComplexHeatmap (4 files), clusterProfiler (2 files), cowplot (2 files), ggpubr (2 files), Harmony (2 files), igraph (2 files), NumPy (2 files), pandas (2 files), pheatmap (2 files), SciPy (2 files), anndata (1 file), Matplotlib (1 file), SingleCellExperiment (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers
  • 26 September 2026: the link answers
20 files

Zenodo 20795495

License: CC-BY-4.0
State: the link answers, verified on 26 September 2026
Evidence: files inventoried
Size: 1 file
Software Heritage: not checked
Found in: “Code availability”
Not found: README, license file, CITATION.cff, environment file, tests, continuous integration, documentation
Tools: tidyverse (17 files), Seurat (14 files), ggplot2 (9 files), Monocle 3 (5 files), patchwork (5 files), circlize (4 files), ComplexHeatmap (4 files), clusterProfiler (2 files), cowplot (2 files), ggpubr (2 files), Harmony (2 files), igraph (2 files), NumPy (2 files), pandas (2 files), pheatmap (2 files), SciPy (2 files), anndata (1 file), Matplotlib (1 file), SingleCellExperiment (1 file)
Availability: 1 check, the latest on 26 September 2026: the link answers (HTTP 200)
  • 26 September 2026: the link answers (HTTP 200)
20 files
At the source:

Code availability

The custom code used for the scRNA-seq and spatial transcriptomic analyses in this study is available on GitHub at https://github.com/fafax093-design/scRNA-neonatal-microglia-analyses-2025 and has been archived on Zenodo at https://doi.org/10.5281/zenodo.2079549555.

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

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;
  • 38 scripts, each with its path and the digest of its content;
  • 20 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

The scRNA-seq data generated in this study are available in the NCBI Gene Expression Omnibus under accession code GSE337230 (https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE337230) and in the NCBI Sequence Read Archive under BioProject accession PRJNA1329933 (https://www.ncbi.nlm.nih.gov/bioproject/PRJNA1329933). The previously published developmental microglial dataset used for cross-dataset comparison is available in the NCBI Gene Expression Omnibus under accession code GSE121654 (https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=GSE121654). Source data underlying the figures and tables, including uncropped scans of all western blots presented in the main figures, are provided with this paper in the Source Data file. Source data are provided with this paper.

Reproduced under the paper's license (CC BY), 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, 14 authors, 3 keywords, 14 MeSH terms, 2 funders, 73 references.

Cite

This paper

Zhu, J., Xu, Y., Sun, L., Huang, Z., Yu, W., Zhang, S., Zhang, X., He, T., Chen, Y., Wu, Y., Li, B., Dong, H., Wang, X., & Zhu, C. (2026). Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice. Nature communications, 17(1), 9385. https://doi.org/10.1038/s41467-026-76341-6

BibTeX

@article{zhu2026neonatal,
author = {Zhu, Jinjin and Xu, Yiran and Sun, Liubo and Huang, Ziwei and Yu, Wenkai and Zhang, Shan and Zhang, Xiaoli and He, Tiantian and Chen, Yiwen and Wu, Yanan and Li, Bingbing and Dong, Huifang and Wang, Xiaoyang and Zhu, Changlian},
title = {{Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice}},
journal = {Nature communications},
year = {2026},
month = aug,
volume = {17},
number = {1},
pages = {9385},
publisher = {Nature Publishing Group},
issn = {2041-1723},
doi = {10.1038/s41467-026-76341-6},
url = {https://doi.org/10.1038/s41467-026-76341-6},
pmid = {42680728},
pmcid = {PMC13534487}
}

RIS

TY - JOUR
AU - Zhu, Jinjin
AU - Xu, Yiran
AU - Sun, Liubo
AU - Huang, Ziwei
AU - Yu, Wenkai
AU - Zhang, Shan
AU - Zhang, Xiaoli
AU - He, Tiantian
AU - Chen, Yiwen
AU - Wu, Yanan
AU - Li, Bingbing
AU - Dong, Huifang
AU - Wang, Xiaoyang
AU - Zhu, Changlian
TI - Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice
T2 - Nature communications
J2 - Nat Commun
PY - 2026
DA - 2026/08/04
VL - 17
IS - 1
SP - 9385
SN - 2041-1723
PB - Nature Publishing Group
DO - 10.1038/s41467-026-76341-6
UR - https://doi.org/10.1038/s41467-026-76341-6
LA - en
ER -

CSL-JSON

{
"id": "10.1038/s41467-026-76341-6",
"type": "article-journal",
"title": "Neonatal inflammation disrupts a temporally restricted postnatal Numb-enriched microglial state in mice",
"container-title": "Nature communications",
"author": [
{
"family": "Zhu",
"given": "Jinjin"
},
{
"family": "Xu",
"given": "Yiran"
},
{
"family": "Sun",
"given": "Liubo"
},
{
"family": "Huang",
"given": "Ziwei"
},
{
"family": "Yu",
"given": "Wenkai"
},
{
"family": "Zhang",
"given": "Shan"
},
{
"family": "Zhang",
"given": "Xiaoli"
},
{
"family": "He",
"given": "Tiantian"
},
{
"family": "Chen",
"given": "Yiwen"
},
{
"family": "Wu",
"given": "Yanan"
},
{
"family": "Li",
"given": "Bingbing"
},
{
"family": "Dong",
"given": "Huifang"
},
{
"family": "Wang",
"given": "Xiaoyang"
},
{
"family": "Zhu",
"given": "Changlian"
}
],
"container-title-short": "Nat Commun",
"volume": "17",
"issue": "1",
"page": "9385",
"DOI": "10.1038/s41467-026-76341-6",
"PMID": "42680728",
"PMCID": "PMC13534487",
"ISSN": "2041-1723",
"publisher": "Nature Publishing Group",
"URL": "https://doi.org/10.1038/s41467-026-76341-6",
"language": "en",
"issued": {
"date-parts": [
[
2026,
8,
4
]
]
}
}

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: Monocle 3, Harmony, SingleCellExperiment, 16 other tools, 2 references
[2] doi:10.1038/s41586-026-10214-2 [code]
Multidimensional profiling of heterogeneity in supratentorial ependymomas.
Journal: Nature
In common: Monocle 3, Harmony, SingleCellExperiment, 16 other tools, mouse
[3] doi:10.1002/imt2.70163 [code]
Spatial multi-omics unveils sphingolipid metabolic reprogramming within the retinal pathological niche.
Journal: iMeta
In common: Monocle 3, Harmony, SingleCellExperiment, 16 other tools, mouse
[4] doi:10.1038/s41593-026-02367-0 [code]
A reproducible three-dimensional model of human brain tissue to investigate physiological and disease-associated microglia phenotypes.
Journal: Nature neuroscience
In common: Monocle 3, Harmony, SingleCellExperiment, 11 other tools, 3 references
[5] doi:10.1002/advs.77986 [code]
DUET-seq: An Open-Source Droplet Platform for High-Fidelity Joint Chromatin and Transcriptome Profiling Reveals Temporal Regulatory Decoupling in Single Cells.
Journal: Advanced science (Weinheim, Baden-Wurttemberg, Germany)
In common: Harmony, SingleCellExperiment, igraph, 13 other tools, 2 references
[6] doi:10.1016/j.xcrm.2026.102651 [code]
Integrative CSF profiling identifies disease-specific immune responses in leptomeningeal disease.
Journal: Cell reports. Medicine
In common: Harmony, SingleCellExperiment, anndata, 13 other tools, 1 reference
[7] doi:10.1038/s41467-026-71595-6 [code]
A single-cell and spatial atlas of early human olfactory development.
Journal: Nature communications
In common: Harmony, SingleCellExperiment, anndata, 12 other tools, 1 reference
[8] 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: Monocle 3, anndata, igraph, 11 other tools, 1 reference
[9] doi:10.1038/s41586-026-10629-x [code]
Whole-genome duplication shaped cell-type evolution in the vertebrate brain.
Journal: Nature
In common: Harmony, anndata, igraph, 13 other tools, mouse
[10] doi:10.1016/j.cpblue.2026.100007 [code]
An integrated single-cell and spatial proteotranscriptomics atlas of fibroblast-driven immunoregulation within the human adult oral cavity.
Journal: Cell press blue
In common: SingleCellExperiment, anndata, igraph, 13 other tools

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.