Organoid scRNAseq timecourse

Published

June 20, 2025

Load packages

suppressPackageStartupMessages({
    library(fishpond)
    library(SummarizedExperiment)
    library(SingleCellExperiment)
    library(scuttle)
    library(scater)
    library(scran)
    library(dplyr)
    library(tidyr)
    library(ggplot2)
    library(msigdbr)
    library(cowplot)
    library(ComplexHeatmap)
    library(circlize)
    library(scDblFinder)
    library(Seurat)
    library(sketchR)
    library(tricycle)
    library(org.Hs.eg.db)
    library(parallel)
    library(bluster)
    library(BiocParallel)
    library(igraph)
    library(DT)
    library(swissknife)
    library(SingleR)
})

Define parameters and reference files

## Sample annotation
sampleannot <- "scrnaseq_timecourse_samples.txt"

## Gene annotation (derived from GENCODE v38 gtf file)
t2g <- "reference/gencode.v38.annotation.tx2gene_full.rds"

## Cell cycle marker genes
## https://github.com/ventolab/HECA-Human-Endometrial-Cell-Atlas/tree/main/utils
s_genes <- "data/reproductivecellatlas/2023/S_genes_HECA.tsv"
g2m_genes <- "data/reproductivecellatlas/2023/G2M_genes_HECA.tsv"

## Tissue atlas
tissue_atlas_sce <- "../scRNAseq_invivo_endometrium_epithelial_cell_atlas/invivo_epithelial_cell_atlas.rds"

Assemble gene annotation

geneannot <- readRDS(t2g) |>
    dplyr::select(gene_id, seqnames, gene_type, gene_name) |>
    dplyr::rename(chromosome = seqnames) |>
    dplyr::distinct()

## Annotate ribosomal protein genes
## GO term "structural constituent of ribosome" (GO:0003735)
golist <- as.list(org.Hs.egGO2ALLEGS)
ribo_entrez <- unique(golist[["GO:0003735"]])
length(ribo_entrez)
[1] 175
tmp <- select(org.Hs.eg.db, keys = ribo_entrez, columns = "ENSEMBL", 
              keytype = "ENTREZID")
'select()' returned 1:many mapping between keys and columns
ribo_ensembl <- unique(na.omit(tmp[, "ENSEMBL"]))
length(ribo_ensembl)
[1] 195
summary(ribo_ensembl %in% sub("\\.[0-9]+$", "", geneannot$gene_id))
   Mode   FALSE    TRUE 
logical      33     162 
geneannot$is_ribosomal_protein <- 
    sub("\\.[0-9]+$", "", geneannot$gene_id) %in% ribo_ensembl
geneannot$gene_type_ribo <- ifelse(
    geneannot$gene_type == "protein_coding",
    ifelse(geneannot$is_ribosomal_protein, "protein_coding_ribosome",
           geneannot$gene_type),
    geneannot$gene_type)

Read data

## Get sample IDs
tbl <- read.delim(sampleannot)
DT::datatable(tbl, options = list(scrollX = TRUE, pageLength = 50))
## Read alevin-fry data and combine
dat <- do.call(cbind, lapply(seq_len(nrow(tbl)), function(i) {
    x <- fishpond::loadFry(
        fryDir = file.path("data", tbl$dir[i], tbl$sample[i]), 
        outputFormat = list(counts = c("S", "A"), spliced = c("S", "A"),
                            unspliced = c("U"), total = c("U", "S", "A")))
    stopifnot(all(!duplicated(colnames(x))))
    rowData(x)$gene_name <- 
        geneannot$gene_name[match(rowData(x)$gene_ids, geneannot$gene_id)]
    rowData(x)$chromosome <- 
        geneannot$chromosome[match(rowData(x)$gene_ids, geneannot$gene_id)]
    rowData(x)$gene_type <- 
        geneannot$gene_type[match(rowData(x)$gene_ids, geneannot$gene_id)]
    rowData(x)$gene_type_ribo <- 
        geneannot$gene_type_ribo[match(rowData(x)$gene_ids, geneannot$gene_id)]
    
    ## Keep only cells with at least 1,000 detected genes
    x <- x[, colSums(assay(x, "total") > 0) > 1000]
    
    ## Add sample ID to colnames
    x$sample <- tbl$sample[i]
    colnames(x) <- paste0(tbl$sample[i], "_", colnames(x))
    x
}))
dat
class: SingleCellExperiment 
dim: 60649 114374 
metadata(0):
assays(4): counts spliced unspliced total
rownames(60649): ENSG00000223972.5 ENSG00000243485.5 ...
  ENSG00000210194.1 ENSG00000210196.2
rowData names(5): gene_ids gene_name chromosome gene_type
  gene_type_ribo
colnames(114374): 3684F1_ATGGTTGCAATTTCTC 3684F1_AAGTACCTCTCTGGTC ...
  3773F9_ACAGCCGGTGAGTAAT 3773F9_TGACCCTCAACACGTT
colData names(2): barcodes sample
reducedDimNames(0):
mainExpName: NULL
altExpNames(0):
## Set rownames to gene names where possible
rownames(dat) <- scater::uniquifyFeatureNames(
    ID = rownames(dat), 
    names = rowData(dat)$gene_name
)

Add sample annotations

## Add 'cell' column
dat$cell <- colnames(dat)

## Any other annotations
if (nrow(tbl) > 0 && (length(tbl$sample) == length(unique(tbl$sample)))) {
    for (cn in colnames(tbl)) {
        if (!all(is.na(tbl[[cn]])) && !(cn %in% colnames(colData(dat)))) {
            colData(dat)[[cn]] <- tbl[[cn]][match(dat$sample, tbl$sample)]
        }
    }
}

## Create donorCol
dat$donorCol <- dat$donor

## Order timepoints
dat$timepoint <- c(d8 = "Horm Withdr 48h", d9_12h = "Post-breakdown 12h", 
                   d9_24h = "Post-breakdown 24h", d10 = "Post-breakdown 48h", 
                   d11 = "E2-diff 24h", d12 = "E2-diff 48h")[dat$timepoint]
dat$timepoint <- factor(dat$timepoint, 
                        levels = c("Horm Withdr 48h", "Post-breakdown 12h", 
                                   "Post-breakdown 24h", "Post-breakdown 48h", 
                                   "E2-diff 24h", "E2-diff 48h"))
table(dat$timepoint, useNA = "ifany")

   Horm Withdr 48h Post-breakdown 12h Post-breakdown 24h Post-breakdown 48h 
             24451              19557              17708              15825 
       E2-diff 24h        E2-diff 48h 
             18038              18795 

Check total count for each gene type and removed unwanted gene types

keepGeneTypes <- c("protein_coding", "IG_V_gene", "IG_C_gene", "IG_J_gene",
                   "TR_C_gene", "TR_J_gene", "TR_V_gene", "TR_D_gene",
                   "IG_D_gene", "Mt_tRNA", "Mt_rRNA")

pd <- data.frame(gene = rownames(dat), 
                 gene_type_ribo = rowData(dat)$gene_type_ribo, 
                 total_count = rowSums(counts(dat))) |>
    group_by(gene_type_ribo) |>
    summarize(nbr_genes = length(gene), 
              total_count = sum(total_count)) |>
    mutate(keep = gene_type_ribo %in% keepGeneTypes) |>
    arrange(desc(nbr_genes))
pd$gene_type_ribo <- factor(pd$gene_type_ribo, levels = unique(pd$gene_type_ribo))

p1 <- ggplot(pd, aes(x = gene_type_ribo, y = nbr_genes, fill = keep)) +
    geom_col() +
    labs(x = "", y = "Number of genes", fill = "keep") +
    theme_cowplot() +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))
p2 <- ggplot(pd, aes(x = gene_type_ribo, y = total_count, fill = keep)) +
    geom_col() +
    labs(x = "", y = "Total UMI count", fill = "keep") +
    theme_cowplot() +
    theme(axis.text.x = element_text(angle = 45, hjust = 1))
plot_grid(p1, p2, ncol = 1)

dat <- dat[rowData(dat)$gene_type_ribo %in% keepGeneTypes, ]
dim(dat)
[1]  20231 114374

Add QC metrics

(MT <- grep("^MT-", rownames(dat), value = TRUE))
 [1] "MT-TF"   "MT-RNR1" "MT-TV"   "MT-RNR2" "MT-TL1"  "MT-ND1"  "MT-TI"  
 [8] "MT-TM"   "MT-ND2"  "MT-TW"   "MT-CO1"  "MT-TD"   "MT-CO2"  "MT-TK"  
[15] "MT-ATP8" "MT-ATP6" "MT-CO3"  "MT-TG"   "MT-ND3"  "MT-TR"   "MT-ND4L"
[22] "MT-ND4"  "MT-TH"   "MT-TS2"  "MT-TL2"  "MT-ND5"  "MT-CYB"  "MT-TT"  
[29] "MT-TQ"   "MT-TA"   "MT-TN"   "MT-TC"   "MT-TY"   "MT-TS1"  "MT-ND6" 
[36] "MT-TE"   "MT-TP"  
dat <- scuttle::addPerCellQC(dat, assay.type = "counts", subsets = list(MT = MT))
dat <- scuttle::addPerFeatureQC(dat, assay.type = "counts")
dat$intronic_fraction <- 
    colSums(assay(dat, "unspliced")) / colSums(assay(dat, "total"))
rowData(dat)$intronic_fraction <- 
    rowSums(assay(dat, "unspliced")) / rowSums(assay(dat, "total"))

QC plots

total_count_threshold <- 1000
detected_threshold <- 500
mt_percent_threshold <- 25

dat$keep <- (dat$total > total_count_threshold) & 
    (dat$detected > detected_threshold) & 
    (dat$subsets_MT_percent < mt_percent_threshold)
table(dat$keep)

FALSE  TRUE 
26943 87431 
cdplot <- as.data.frame(colData(dat))

sid <- "sample"
stid <- "timepoint"

ggplot(cdplot, aes(x = .data[[sid]], y = sum, fill = .data[[stid]])) +
    geom_hline(yintercept = total_count_threshold) + 
    geom_violin(scale = "width") + theme_minimal() +
    labs(y = "Total UMI count per cell") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

ggplot(cdplot, aes(x = .data[[sid]], y = sum, fill = .data[[stid]])) +
    geom_hline(yintercept = total_count_threshold) + 
    geom_violin(scale = "width") + theme_minimal() +
    scale_y_log10() + 
    labs(y = "Total UMI count per cell") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

ggplot(cdplot, aes(x = .data[[sid]], y = detected, fill = .data[[stid]])) +
    geom_hline(yintercept = detected_threshold) + 
    geom_violin(scale = "width") + theme_minimal() +
    labs(y = "Number of detected genes per cell") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

ggplot(cdplot, aes(x = .data[[sid]], y = subsets_MT_percent, fill = .data[[stid]])) +
    geom_hline(yintercept = mt_percent_threshold) + 
    geom_violin(scale = "width") + theme_minimal() +
    labs(y = "Percentage of MT count per cell") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

ggplot(cdplot, aes(x = detected, y = subsets_MT_percent, color = keep)) + 
    geom_point(size = 0.5, alpha = 0.5) + theme_minimal() + 
    labs(x = "Number of detected genes per cell",
         y = "Percentage of MT count per cell")

ggplot(cdplot, aes(x = detected, y = intronic_fraction, color = keep)) + 
    geom_point(size = 0.5, alpha = 0.5) + theme_minimal() + 
    labs(x = "Number of detected genes per cell",
         y = "Intronic fraction per cell")

Filter and remove samples with too few cells

(tbl <- table(sample = dat$sample, keep = dat$keep))
         keep
sample    FALSE TRUE
  3684F1    598 2439
  3684F2   1081 7184
  3684F3    425 4714
  3684F4    795 5266
  3684F5    593 5643
  3684F6    615 4637
  3773F1   7053 8838
  3773F10   893 4581
  3773F11  1526 4277
  3773F12  1495 6245
  3773F2   2726 2797
  3773F3   1356 3941
  3773F4   1585 4410
  3773F5   1437 4527
  3773F6   1314 5291
  3773F7   1668 5127
  3773F8    752 2217
  3773F9   1031 5297
ggplot(as.data.frame(tbl), aes(x = sample, y = Freq, fill = keep)) + 
    geom_col(position = "stack") + theme_bw() + 
    theme(axis.text.x = element_text(angle = 45, hjust = 1)) + 
    labs(x = "", y = "Number of cells")

ggplot(as.data.frame(tbl), aes(x = sample, y = Freq, fill = keep)) + 
    geom_col(position = "fill") + theme_bw() + 
    theme(axis.text.x = element_text(angle = 45, hjust = 1)) + 
    labs(x = "", y = "Fraction of cells")

dat <- dat[, which(dat$keep)]
dim(dat)
[1] 20231 87431
tbl <- table(dat[[sid]], dat[[stid]])
draw(Heatmap(tbl, col = circlize::colorRamp2(c(0, max(tbl)), c("white", "steelblue")),
             cluster_columns = FALSE, cluster_rows = FALSE, 
             row_names_gp = gpar(fontsize = 8),
             name = "Number of\ncells per\nsample",
             rect_gp = gpar(col = "grey", lwd = 1),
             cell_fun = function(j, i, x, y, width, height, fill) {
                 grid.text(sprintf("%d", tbl[i, j]), x, y, gp = gpar(fontsize = 8))
             }))

Recalculate QC metrics for genes based on remaining cells

dat <- scuttle::addPerFeatureQC(dat, assay.type = "counts")
rowData(dat)$intronic_fraction <- 
    rowSums(assay(dat, "unspliced")) / rowSums(assay(dat, "total"))

Most highly expressed genes

scater::plotHighestExprs(dat, n = 25, colour_cells_by = stid, 
                         exprs_values = "counts")

Calculate size factors and logcounts

size_factor_threshold <- 0.1
set.seed(123)
clusters <- scran::quickCluster(
    dat, method = "igraph", min.mean = 0.1,
    use.ranks = FALSE, BSPARAM = BiocSingular::ExactParam()
)
dat <- scran::computeSumFactors(dat, min.mean = 0.1, cluster = clusters)
summary(sizeFactors(dat))
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
0.04152 0.54822 0.91279 1.00000 1.32102 6.98682 
ggplot(data.frame(libSize = dat$sum,
                  sizeFactor = sizeFactors(dat),
                  group = dat$sample,
                  stringsAsFactors = FALSE),
       aes(x = libSize, y = sizeFactor, color = group)) + 
    geom_point(alpha = 0.5) + theme_bw() + 
    geom_hline(yintercept = size_factor_threshold, linetype = "dashed") + 
    xlab("Total UMI count") + ylab("Size factor")

dat <- dat[, sizeFactors(dat) > size_factor_threshold]
dim(dat)
[1] 20231 85628
dat <- logNormCounts(dat)

Cell cycle assignment

## tricycle
set.seed(123)
dat <- project_cycle_space(dat, species = "human", gname.type = "SYMBOL")
dat <- estimate_cycle_position(dat)
dat <- estimate_Schwabe_stage(dat, gname.type = "SYMBOL", species = "human", 
                              tolerance = 0)
plotReducedDim(dat, "tricycleEmbedding", point_size = 0.5, 
               colour_by = "CCStage")

tbl <- table(dat$timepoint, dat$CCStage)
draw(Heatmap(tbl, col = circlize::colorRamp2(c(0, max(tbl)), c("white", "steelblue")),
             cluster_columns = FALSE, cluster_rows = FALSE, 
             row_names_gp = gpar(fontsize = 8),
             name = "Number of\ncells",
             rect_gp = gpar(col = "grey", lwd = 1),
             cell_fun = function(j, i, x, y, width, height, fill) {
                 grid.text(sprintf("%d", tbl[i, j]), x, y, gp = gpar(fontsize = 8))
             }))

## Seurat (using genes from HECA)
s_genes <- read.delim(s_genes)[, 1]
g2m_genes <- read.delim(g2m_genes)[, 1]
seurat <- CreateSeuratObject(counts = assay(dat, "counts"))
seurat <- NormalizeData(seurat, verbose = FALSE)
seurat <- FindVariableFeatures(seurat, selection.method = "vst", verbose = FALSE)
seurat <- ScaleData(seurat, features = rownames(seurat), verbose = FALSE)
seurat <- CellCycleScoring(seurat, s.features = s_genes, g2m.features = g2m_genes, 
                           set.ident = TRUE)
stopifnot(all(rownames(seurat@meta.data) == colnames(dat)))
dat$SeuratCC <- seurat@meta.data$Phase
tbl <- table(dat$timepoint, dat$SeuratCC)
draw(Heatmap(tbl, col = circlize::colorRamp2(c(0, max(tbl)), c("white", "steelblue")),
             cluster_columns = FALSE, cluster_rows = FALSE, 
             row_names_gp = gpar(fontsize = 8),
             name = "Number of\ncells",
             rect_gp = gpar(col = "grey", lwd = 1),
             cell_fun = function(j, i, x, y, width, height, fill) {
                 grid.text(sprintf("%d", tbl[i, j]), x, y, gp = gpar(fontsize = 8))
             }))

Find and mark doublets

set.seed(123)
dat <- scDblFinder(dat, samples = "name", verbose = FALSE, 
                   BPPARAM = MulticoreParam(workers = 8L))
table(dat$scDblFinder.class, dat$name)
         
          B050_post_psg_d10 B050_post_psg_d11 B050_post_psg_d12
  singlet              4706              4837              3947
  doublet               412               451               324
         
          B050_post_psg_d9_12h B050_post_psg_d9_24h B050_pre_psg_d8
  singlet                 3662                 4846            6539
  doublet                  277                  440             760
         
          B066_post_psg_d10 B066_post_psg_d11 B066_post_psg_d12
  singlet              4749              5111              4289
  doublet               429               519               348
         
          B066_post_psg_d9 B066_post_psg_d9_12h B066_pre_psg_d8
  singlet             4332                 6349            2297
  doublet              375                  738             142
         
          B080_post_psg_d10 B080_post_psg_d11 B080_post_psg_d12
  singlet              2099              4227              5680
  doublet               118               349               557
         
          B080_post_psg_d9_12h B080_post_psg_d9_24h B080_pre_psg_d8
  singlet                 4060                 4163            2633
  doublet                  349                  358             156

Sketching

set.seed(123)
geneVar <- scran::modelGeneVar(dat)
subhvgs <- scran::getTopHVGs(geneVar, n = 2000)
subd <- scater::runPCA(dat, exprs_values = "logcounts", ncomponents = 30, 
                       name = "PCAsub", 
                       subset_row = subhvgs, scale = FALSE)
idxsub <- geosketch(reducedDim(subd, "PCAsub"), 
                    N = min(ncol(subd), max(0.1 * ncol(subd), 150)), 
                    seed = 123)
sketchcells <- colnames(subd)[idxsub]

dat$sketched <- ifelse(colnames(dat) %in% sketchcells, TRUE, FALSE)
table(sketched = dat$sketched)
sketched
FALSE  TRUE 
77066  8562 

Find an alternative reduced dimension representation, after sketching

set.seed(123)
## First find a new set of HVGs
dattmp <- dat[, sketchcells]
stopifnot(all(rownames(dattmp) == rownames(dat)))
rowData(dat)$scran_geneVar_geosketch <- scran::modelGeneVar(dattmp)
hvgs <- scran::getTopHVGs(rowData(dat)$scran_geneVar_geosketch, n = 3000)
metadata(dat)$hvg.scran.genevar.geosketch <- hvgs

head(
    as.data.frame(
        rowData(dat)$scran_geneVar_geosketch[
            order(rowData(dat)$scran_geneVar_geosketch$bio, 
                  decreasing = TRUE), 
            c("mean", "total", "tech", "bio", "p.value", "FDR")]
    ), 
    25
)
                mean    total     tech      bio      p.value          FDR
IFI27       4.424344 7.167424 1.492293 5.675131 2.001221e-64 3.574380e-60
MMP7        4.581938 6.970558 1.492493 5.478065 3.697836e-60 3.302352e-56
CCL20       2.261824 6.167145 1.470837 4.696308 4.922014e-46 2.930403e-42
ISG15       2.580489 5.891723 1.510108 4.381614 2.269297e-38 1.013298e-34
IFI6        2.880933 5.903557 1.532789 4.370768 3.964083e-37 1.416050e-33
IGFBP7      2.213738 5.596281 1.463240 4.133041 1.812628e-36 5.395892e-33
IFITM1      2.716437 5.632226 1.522108 4.110119 1.678515e-33 4.282852e-30
CXCL5       3.173366 5.530787 1.540538 3.990249 5.533206e-31 1.235357e-27
CAPS        3.026924 5.330290 1.537656 3.792634 2.803111e-28 4.551487e-25
CXCL1       2.745286 5.212095 1.524355 3.687740 2.802465e-27 3.850372e-24
TFF3        3.065632 5.201724 1.538530 3.663194 1.746567e-26 1.949715e-23
LCN2        3.534783 5.100962 1.527170 3.573792 1.193569e-25 1.254020e-22
SAA2        1.850499 4.946963 1.391459 3.555504 3.312646e-30 5.916716e-27
GDF15       2.770725 4.706322 1.526227 3.180095 9.916005e-21 7.379574e-18
CXCL8       3.016898 4.556193 1.537494 3.018700 1.288311e-18 8.850203e-16
SAA1        1.716913 4.324135 1.354940 2.969195 9.951645e-23 8.079379e-20
RSPH1       2.175335 4.208529 1.456934 2.751595 2.304293e-17 1.469892e-14
C20orf85    1.306453 3.805729 1.185746 2.619982 4.477304e-23 3.808054e-20
S100A4      2.857985 3.940606 1.531681 2.408925 1.355683e-12 5.044554e-10
VIM         2.388246 3.866303 1.488350 2.377953 6.105068e-13 2.478241e-10
RPS10-NUDT3 2.810523 3.890953 1.528968 2.361985 3.258842e-12 1.164123e-09
CXCL3       1.746871 3.708716 1.363797 2.344919 1.052977e-14 5.224231e-12
SCGB2A1     2.870294 3.865640 1.532276 2.333364 6.439930e-12 2.170257e-09
KRT17       1.321341 3.510942 1.193576 2.317366 3.027459e-18 2.002720e-15
C15orf48    1.087644 3.306294 1.056237 2.250057 1.392162e-21 1.081104e-18
## Next run PCA on the sketched cells
set.seed(123)
dattmp <- dat[, sketchcells]
pca <- calculatePCA(dattmp, ncomponents = 30, 
                    subset_row = metadata(dat)$hvg.scran.genevar.geosketch)
projection <- pca
rotation <- attr(pca, "rotation")
centers <- rowMeans(assay(dattmp[metadata(dat)$hvg.scran.genevar.geosketch, ],
                          "logcounts"))
## Sanity check
stopifnot(rownames(rotation) == names(centers))
tmp <- t(sweep(assay(dattmp[names(centers), ], "logcounts"), MARGIN = 1, 
               STATS = centers, FUN = "-")) %*% rotation
max(abs(tmp - projection))
[1] 1.998401e-13
## Project all cells
pca <- t(sweep(assay(dat[names(centers), ], "logcounts"), MARGIN = 1, 
               STATS = centers, FUN = "-")) %*% rotation
Warning in asMethod(object): sparse->dense coercion: allocating vector of size
1.9 GiB
stopifnot(all(rownames(pca) == colnames(dat)))
reducedDim(dat, "PCA_sketch") <- as.matrix(pca)

Dimension reduction

Apply tSNE and UMAP to sketched cells and project the others.

## TSNE
set.seed(123)
tsne <- snifter::fitsne(
    reducedDim(dat[, sketchcells], "PCA_sketch"), 
    n_jobs = 8L, random_state = 123L,
    perplexity = 30, n_components = 2L, 
    pca = FALSE)
proj <- snifter::project(
    x = tsne, 
    new = reducedDim(dat, "PCA_sketch"), 
    old = reducedDim(dat[, sketchcells], "PCA_sketch"))
rownames(proj) <- colnames(dat)
reducedDim(dat, "TSNE_sketch") <- proj

## UMAP
set.seed(123)
umap <- uwot::umap(
    X = reducedDim(dat[, sketchcells], "PCA_sketch"), 
    n_components = 2, n_neighbors = 50,
    pca = NULL, scale = FALSE, 
    ret_model = TRUE, n_threads = 8L)
proj <- uwot::umap_transform(
    X = reducedDim(dat, "PCA_sketch"), 
    model = umap, 
    n_threads = 8L)
rownames(proj) <- colnames(dat)
reducedDim(dat, "UMAP_sketch") <- proj

Shuffle cells (to avoid plotting in layers by sample later)

set.seed(123)
dat <- dat[, sample(seq_len(ncol(dat)), ncol(dat), replace = FALSE)]

Clustering

## Helper function
## method should be "louvain" or "leiden"
clusterfcn <- function(sce, dimred = "fastMNNcorr", method = "leiden",
                       resolutions = c(0.05, 0.1, 0.2, 0.4, 0.6, 1.0), 
                       sketched_cells = NULL) {
    if (is.null(sketched_cells)) {
        for (r in resolutions) {
            set.seed(123)
            mat <- reducedDim(sce, dimred)
            clst <- bluster::clusterRows(
                mat, 
                BLUSPARAM = bluster::SNNGraphParam(
                    k = 10, type = "rank", 
                    num.threads = 1L, 
                    cluster.fun = method, 
                    cluster.args = list(resolution = r)))
            colData(sce)[[paste0(method, "_res", r)]] <- as.character(clst)
        }
    } else {
        train <- reducedDim(sce[, sketched_cells], dimred)
        test <- reducedDim(sce[, setdiff(colnames(sce), sketched_cells)], dimred)
        for (r in resolutions) {
            set.seed(123)
            df <- data.frame(cell = colnames(sce), cluster = NA_character_)
            clst <- as.character(bluster::clusterRows(
                train,  
                BLUSPARAM = bluster::SNNGraphParam(
                    k = 10, type = "rank", 
                    num.threads = 1L, 
                    cluster.fun = method, 
                    cluster.args = list(resolution = r))))
            preds <- class::knn(train = train, test = test, 
                                cl = clst, k = 1) |> as.character()
            df$cluster[match(rownames(train), df$cell)] <- clst
            df$cluster[match(rownames(test), df$cell)] <- preds
            stopifnot(colnames(sce) == df$cell)
            colData(sce)[[paste0(method, "_res", r)]] <- df$cluster
        }
    }
    
    sce
}

dat <- clusterfcn(dat, dimred = "PCA_sketch", method = "leiden",
                  resolutions = c(0.125), 
                  sketched_cells = sketchcells)

Annotate clusters

clust_annot <- stack(
    list("pre-ciliated" = 7,
         "ciliated stress responsive" = 24,
         "ciliated wound responsive 12h" = c(23, 15, 27),
         "ciliated wound responsive 24h" = 6,
         "ciliated immunomodulatory" = c(25, 8), 
         "ciliated cycling" = 26,
         "ciliated" = c(1, 5, 3, 12, 16),
         "cycling" = 2,
         "stress responsive" = 17,
         "wound responsive 12h" = c(19, 20),
         "immunomodulatory" = 9,
         "wound responsive 24h" = c(10, 11, 14, 22),
         "OxPhos high" = 21,
         "secretory late" = 18,
         "secretory early" = 4,
         "hormone responsive" = 13)
)
dat$leiden_res0.125_annot <- 
    clust_annot$ind[match(dat$leiden_res0.125, 
                          as.character(clust_annot$values))]
table(dat$leiden_res0.125_annot)

                 pre-ciliated    ciliated stress responsive 
                         1046                           500 
ciliated wound responsive 12h ciliated wound responsive 24h 
                         6598                          3030 
    ciliated immunomodulatory              ciliated cycling 
                         5299                           821 
                     ciliated                       cycling 
                         9757                         19009 
            stress responsive          wound responsive 12h 
                          398                          8645 
             immunomodulatory          wound responsive 24h 
                         3463                         16592 
                  OxPhos high                secretory late 
                          836                          1634 
              secretory early            hormone responsive 
                         2289                          5711 
leiden_res0.125_annot_cols <- c(
    "secretory late" = "firebrick",
    "secretory early" = "firebrick1",
    "hormone responsive" = "orchid3",
    "cycling" = "forestgreen",
    "stress responsive" = "grey20", 
    "wound responsive 12h" = "navajowhite",
    "wound responsive 24h" = "goldenrod1",
    "OxPhos high" = "coral4",
    "immunomodulatory" = "#FF6347", 
    "pre-ciliated" = "lightskyblue",
    "ciliated" = "royalblue",
    "ciliated stress responsive" = "navy",
    "ciliated wound responsive 12h" = "turquoise",
    "ciliated wound responsive 24h" = "turquoise4", 
    "ciliated immunomodulatory" = "tan1",  
    "ciliated cycling" = "olivedrab2"
) 

atlas_leiden_res0.4_annot_cols <- c(
    "pre-ciliated" = "lightskyblue", 
    "ciliated" = "royalblue", 
    "secretory early" = "#C8AEE2", 
    "secretory mid" = "#7D4DBA", 
    "secretory late" = "#3A1260", 
    "luminal early" = "gold1", 
    "luminal late" = "darkorange", 
    "transcriptionally active" = "turquoise4", 
    "MUC5B+" = "salmon", 
    "KRT5+" = "firebrick", 
    "remodeling" = "#4CAF50", 
    "cycling" = "mediumaquamarine", 
    "hormone responsive early" = "hotpink2", 
    "hormone responsive late" = "hotpink4" 
) 

Dot plots of clusters and marker genes of interest

col = circlize::colorRamp2(
    breaks = seq(0, 7.5, length.out = 64),
    colors = colorRampPalette(c("grey90", "purple"))(64))

markers <- intersect(
    c("MKI67", "MMP7", "STMN1", "PKM", "ALDH1A1", "VIM", "LGR5", "LEFTY1",
      "IL6", "ENPP3", "WNT7A", "IL32", "SAA1", "SAA2", "CLU", "MUC5B", "LCN2", 
      "LY6E", "S100A6", "PLAUR", "PLAU", "SFN", "TNFRSF12A", "IL23A", "KRT17",
      "KRT5", "ANXA1", "CXCL8", "SCGB2A1", "SBGB2A2", "SCGB1D2", "IL6ST", 
      "PAEP", "FGF7", "PGR", "ESR1", "CCNO", "FOXJ1", "PIFO", "RSPH1", "DKK1"),
    rownames(dat))
plotDots(dat, features = markers,
         group = "leiden_res0.125_annot", 
         swap_rownames = "gene_name", zlim = c(0, 7.5),
         colour = col(seq(0, 7.5, length.out = 64))) + 
    scale_size(limits=c(0.2, 1)) + 
    labs(x = "") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

Marker genes from HECA paper (https://www.nature.com/articles/s41588-024-01873-w, Fig 2a).

markers <- intersect(
    c("PAX8", "EPCAM", "FOXA2", "MMP7", "SOX9", "AXIN2", "TRH", "ALDH1A1",
      "CDH2", "KLK11", "SLC7A11", "DKK1", "PHLDA1", "KMO", "IHH", "EMID1", 
      "HPRT1", "SUFU", "OPRK1", "CBR3", "S100P", "SCGB2A2", "ABCG1", "PAEP", 
      "DPP4", "GPX3", "PTPRR", "FGF7", "FXYD2", "IL32", "WNT7A", "LGR5", 
      "MMP7", "TNF", "VTCN1", "CLDN22", "SULT1E1", "LEFTY1", "PTGS1", "IL6",
      "CCNO", "CDC20B", "MUC12", "PIFO", "FOXJ1", "TP73", "BPIFB1", "TFF3", 
      "MUC5B", "SAA1", "TP63", "SNCG", "KRT5", "PGR", "AR", "ESR1"), 
    rownames(dat))
plotDots(dat, features = markers,
         group = "leiden_res0.125_annot", 
         swap_rownames = "gene_name", zlim = c(0, 7.5),
         colour = col(seq(0, 7.5, length.out = 64))) + 
    scale_size(limits=c(0.2, 1)) + 
    labs(x = "") + 
    theme(axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))

Plot dimension reductions

timepoint_cols <- c(
    "Horm Withdr 48h" = "grey40",
    "Post-breakdown 12h" = "#f3a9c0",
    "Post-breakdown 24h" = "#e27897",
    "Post-breakdown 48h" = "#cb3b63",
    "E2-diff 24h" = "#debaf5",
    "E2-diff 48h" = "#BE9FD2"
)
donor_cols <- c(
    "B050" = "#FF9966", 
    "B066" = "slateblue", 
    "B080" = "darkgreen"
)
seurat_cols <- c( 
    "S" = "gold",
    "G1" = "grey80",
    "G2M" = "brown"
) 

for (vrb in c("scDblFinder.class", "leiden_res0.125")) {
    print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                         colour_by = vrb, point_size = 0.5) + 
              guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))) + 
              theme_void())
}

print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                     colour_by = "leiden_res0.125_annot", point_size = 0.5) + 
          scale_color_manual(values = leiden_res0.125_annot_cols, name = "") + 
          guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))) + 
          theme_void())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                     colour_by = "timepoint", point_size = 0.5) + 
          scale_color_manual(values = timepoint_cols, name = "") + 
          guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))) + 
          theme_void())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                     colour_by = "donorCol", point_size = 0.5) + 
          scale_color_manual(values = donor_cols, name = "") + 
          guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))) + 
          theme_void())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                     colour_by = "SeuratCC", point_size = 0.5) + 
          scale_color_manual(values = seurat_cols, name = "") + 
          guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))) + 
          theme_void())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

for (vrb in c("subsets_MT_percent", "detected", "intronic_fraction",
              "CCNO", "FOXJ1", "MKI67")) {
    print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                         colour_by = vrb, point_size = 0.5) + 
              theme_void())
}

Transfer labels from tissue atlas

set.seed(123)
atlas <- readRDS(tissue_atlas_sce)
ref_summed <- aggregateAcrossCells(atlas, id = atlas$leiden_res0.4_annot,
                                   use.assay.type = "logcounts",
                                   statistics = "mean")
pred <- SingleR(test = dat, ref = ref_summed, 
                labels = ref_summed$leiden_res0.4_annot)
plotScoreHeatmap(pred)

stopifnot(all(rownames(pred) == colnames(dat)))
dat$pred_from_invivo <- pred$labels
dat$pred_from_invivo_pruned <- pred$pruned.labels
dat$pred_from_invivo_delta <- pred$delta.next

Visualize predicted labels

print(plotReducedDim(dat, dimred = "UMAP_sketch", 
                     colour_by = "pred_from_invivo", point_size = 0.5) +
          scale_color_manual(values = atlas_leiden_res0.4_annot_cols, name = "") + 
          guides(color = guide_legend(override.aes = list(size = 2, alpha = 1))))
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

Plot cluster abundance over time

for (col in c("leiden_res0.125_annot", "pred_from_invivo")) {
    df <- data.frame(colData(dat)[, c("timepoint", col)]) |>
        # mutate(timepoint = factor(timepoint, 
        #                           levels = c("d8", "d9_12h", "d9_24h", 
        #                                      "d10", "d11", "d12"))) |>
        group_by(timepoint, .data[[col]]) |>
        tally() |>
        group_by(timepoint) |>
        mutate(perc = n / sum(n) * 100) |>
        group_by(.data[[col]]) |>
        mutate(scaledperc = perc / sum(perc)) |>
        ungroup()
    
    # by donor
    dfdonor <- data.frame(colData(dat)[, c("timepoint", "donor", col)]) |>
        group_by(timepoint, donor, .data[[col]]) |>
        tally() |>
        group_by(timepoint, donor) |>
        mutate(perc = n / sum(n) * 100) |>
        group_by(.data[[col]]) |>
        mutate(scaledperc = perc / sum(perc)) |>
        ungroup()
    
    if (col == "leiden_res0.125_annot") {
        df$cluster_group <- ifelse(grepl("ciliated", df[[col]]), 
                                   "ciliated", "non-ciliated")
    }
    gg <- ggplot(df, aes(x = timepoint, y = perc, color = .data[[col]])) + 
        geom_point() + 
        geom_line(aes(x = as.numeric(timepoint)), linewidth = 1) + 
        theme_cowplot() + 
        labs(x = "Timepoint", y = "Relative abundance (%)",
             title = col) + 
        theme(legend.position = "bottom")
    if (col == "leiden_res0.125_annot") {
        gg <- gg + facet_wrap(~ cluster_group, ncol = 1) + 
            scale_color_manual(values = leiden_res0.125_annot_cols, name = "")
    } else {
        gg <- gg + 
            scale_color_manual(values = atlas_leiden_res0.4_annot_cols, name = "")
    }
    print(gg)
    
    ## One panel per cluster
    gg <- ggplot(df, aes(x = timepoint, y = perc, color = .data[[col]])) + 
        geom_point() + 
        geom_line(aes(x = as.numeric(timepoint)), linewidth = 1) + 
        theme_cowplot() + 
        labs(x = "Timepoint", y = "Relative abundance (%)",
             title = col) + 
        facet_wrap(~ .data[[col]], scales = "free_y") + 
        theme(legend.position = "none",
              axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))
    if (col == "leiden_res0.125_annot") {
        gg <- gg + scale_color_manual(values = leiden_res0.125_annot_cols, name = "")
    } else {
        gg <- gg + 
            scale_color_manual(values = atlas_leiden_res0.4_annot_cols, name = "")
    }
    print(gg)
    
    ## One panel per cluster, add lines for donors
    gg <- ggplot(bind_rows(df |> mutate(donor = "overall"), dfdonor), 
                 aes(x = timepoint, y = perc, group = donor, alpha = donor == "overall", 
                     linewidth = donor, color = .data[[col]])) + 
        geom_point() + 
        geom_line(aes(x = as.numeric(timepoint)), linewidth = 1) + 
        scale_alpha_manual(values = c(`TRUE` = 1, `FALSE` = 0.25)) + 
        scale_linewidth_manual(values = c(`TRUE` = 2, `FALSE` = 0.75)) + 
        theme_cowplot() + 
        labs(x = "Timepoint", y = "Relative abundance (%)",
             title = col) + 
        facet_wrap(~ .data[[col]], scales = "free_y") + 
        theme(legend.position = "none",
              axis.text.x = element_text(angle = 90, hjust = 1, vjust = 0.5))
    if (col == "leiden_res0.125_annot") {
        gg <- gg + scale_color_manual(values = leiden_res0.125_annot_cols, name = "")
    } else {
        gg <- gg + 
            scale_color_manual(values = atlas_leiden_res0.4_annot_cols, name = "")
    }
    print(gg)
    
    ## Bar plot
    gg <- ggplot(df, aes(x = timepoint, y = perc, fill = .data[[col]])) + 
        geom_col() + 
        theme_cowplot() + 
        labs(x = "Timepoint", y = "Relative abundance (%)", title = col) +
        coord_flip() + 
        theme(legend.position = "bottom")
    if (col == "leiden_res0.125_annot") {
        gg <- gg + scale_fill_manual(values = leiden_res0.125_annot_cols, name = "")
    } else {
        gg <- gg + 
            scale_fill_manual(values = atlas_leiden_res0.4_annot_cols, name = "")
    }
    print(gg)
}

Find marker genes

First, we compare all clusters in a pairwise manner. The comparison is only performed for genes with a non-zero count in at least 0.05% of the cells (corresponding to 43 of the 85628 cells in the data set). These gene IDs are also written to a file, for potential use as the ‘universe’ in over-representation analyses.

frac_detected <- rowMeans(assay(dat, "counts") > 0)
genes_to_test <- rownames(dat)[frac_detected >= (0.05 / 100)]
write.table(genes_to_test, file = "organoid_timecourse_gene_universe.txt", 
            row.names = FALSE, col.names = FALSE, quote = FALSE, sep = "\t")

clc <- "leiden_res0.125_annot"
ptts <- pairwiseTTests(dat, groups = dat[[clc]], 
                       direction = "up", 
                       subset.row = genes_to_test)
# markers wrt all other clusters
markers <- combineMarkers(ptts$statistics, ptts$pairs, pval.type = "all")
# markers within lineage
keep_ciliated <- which(grepl("ciliated", ptts$pairs$first) & 
                           grepl("ciliated", ptts$pairs$second))
markers_ciliated <- combineMarkers(ptts$statistics[keep_ciliated], 
                                   ptts$pairs[keep_ciliated, ], 
                                   pval.type = "all")
keep_nonciliated <- which(!grepl("ciliated", ptts$pairs$first) & 
                              !grepl("ciliated", ptts$pairs$second))
markers_nonciliated <- combineMarkers(ptts$statistics[keep_nonciliated], 
                                      ptts$pairs[keep_nonciliated, ], 
                                      pval.type = "all")

## Write results to files
ttestdir <- paste0("pairwise_ttests_", clc)
if (!file.exists(ttestdir)) {
    dir.create(ttestdir, recursive = TRUE)
}
for (i in seq_len(nrow(ptts$pairs))) {
    df <- cbind(gene = rownames(ptts$statistics[[i]]), 
                ptts$statistics[[i]])
    df <- df[order(df$p.value, -df$logFC), ]
    filename <- paste0(clc, "_cluster_", ptts$pairs$first[i],
                       "_vs_", ptts$pairs$second[i], ".txt")
    write.table(df, file = file.path(ttestdir, filename), quote = FALSE,
                sep = "\t", row.names = FALSE, col.names = TRUE)
}

markerdir <- paste0("marker_genes_all_", clc)
if (!file.exists(markerdir)) {
    dir.create(markerdir, recursive = TRUE)
}
for (i in seq_along(markers)) {
    df <- cbind(gene = rownames(markers[[i]]), markers[[i]])
    filename <- paste0(clc, "_cluster_", names(markers)[i], "_markers.txt")
    write.table(df, file = file.path(markerdir, filename), quote = FALSE,
                sep = "\t", row.names = FALSE, col.names = TRUE)
}

markerdirlin <- paste0("marker_genes_within_lineage_all_", clc)
if (!file.exists(markerdirlin)) {
    dir.create(markerdirlin, recursive = TRUE)
}
for (i in seq_along(markers_ciliated)) {
    df <- cbind(gene = rownames(markers_ciliated[[i]]), 
                markers_ciliated[[i]])
    filename <- paste0(clc, "_cluster_", names(markers_ciliated)[i],
                       "_markers_within_lineage.txt")
    write.table(df, file = file.path(markerdirlin, filename), quote = FALSE,
                sep = "\t", row.names = FALSE, col.names = TRUE)
}
for (i in seq_along(markers_nonciliated)) {
    df <- cbind(gene = rownames(markers_nonciliated[[i]]), 
                markers_nonciliated[[i]])
    filename <- paste0(clc, "_cluster_", names(markers_nonciliated)[i],
                       "_markers_within_lineage.txt")
    write.table(df, file = file.path(markerdirlin, filename), quote = FALSE,
                sep = "\t", row.names = FALSE, col.names = TRUE)
}

Save object

saveRDS(dat, file = "organoid_scrnaseq_timecourse.rds")

Session info

sessionInfo()
R version 4.4.2 (2024-10-31)
Platform: x86_64-pc-linux-gnu
Running under: Rocky Linux 8.10 (Green Obsidian)

Matrix products: default
BLAS/LAPACK: FlexiBLAS OPENBLAS;  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=en_US.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=en_US.UTF-8        LC_COLLATE=en_US.UTF-8    
 [5] LC_MONETARY=en_US.UTF-8    LC_MESSAGES=en_US.UTF-8   
 [7] LC_PAPER=en_US.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C       

time zone: Europe/Zurich
tzcode source: system (glibc)

attached base packages:
 [1] parallel  grid      stats4    stats     graphics  grDevices utils    
 [8] datasets  methods   base     

other attached packages:
 [1] SingleR_2.8.0               swissknife_0.43            
 [3] DT_0.33                     igraph_2.1.4               
 [5] BiocParallel_1.40.2         bluster_1.16.0             
 [7] org.Hs.eg.db_3.20.0         AnnotationDbi_1.68.0       
 [9] tricycle_1.14.0             sketchR_1.2.0              
[11] Seurat_5.2.1                SeuratObject_5.0.2         
[13] sp_2.2-0                    scDblFinder_1.20.2         
[15] circlize_0.4.16             ComplexHeatmap_2.22.0      
[17] cowplot_1.1.3               msigdbr_10.0.2             
[19] tidyr_1.3.1                 dplyr_1.1.4                
[21] scran_1.34.0                scater_1.34.1              
[23] ggplot2_3.5.2               scuttle_1.16.0             
[25] SingleCellExperiment_1.28.1 SummarizedExperiment_1.36.0
[27] Biobase_2.66.0              GenomicRanges_1.58.0       
[29] GenomeInfoDb_1.42.3         IRanges_2.40.1             
[31] S4Vectors_0.44.0            BiocGenerics_0.52.0        
[33] MatrixGenerics_1.18.1       matrixStats_1.5.0          
[35] fishpond_2.12.0            

loaded via a namespace (and not attached):
  [1] fs_1.6.6                  spatstat.sparse_3.1-0    
  [3] bitops_1.0-9              httr_1.4.7               
  [5] RColorBrewer_1.1-3        doParallel_1.0.17        
  [7] tools_4.4.2               sctransform_0.4.1        
  [9] R6_2.6.1                  lazyeval_0.2.2           
 [11] uwot_0.2.3                GetoptLong_1.0.5         
 [13] withr_3.0.2               gridExtra_2.3            
 [15] progressr_0.15.1          cli_3.6.4                
 [17] Cairo_1.6-2               spatstat.explore_3.4-2   
 [19] fastDummies_1.7.5         labeling_0.4.3           
 [21] sass_0.4.10               mvtnorm_1.3-3            
 [23] spatstat.data_3.1-6       readr_2.1.5              
 [25] ggridges_0.5.6            pbapply_1.7-2            
 [27] Rsamtools_2.22.0          dichromat_2.0-0.1        
 [29] parallelly_1.43.0         limma_3.62.2             
 [31] RSQLite_2.3.9             circular_0.5-1           
 [33] generics_0.1.3            shape_1.4.6.1            
 [35] BiocIO_1.16.0             crosstalk_1.2.1          
 [37] gtools_3.9.5              ica_1.0-3                
 [39] spatstat.random_3.3-3     Matrix_1.7-1             
 [41] ggbeeswarm_0.7.2          abind_1.4-8              
 [43] lifecycle_1.0.4           yaml_2.3.10              
 [45] edgeR_4.4.2               SparseArray_1.6.2        
 [47] Rtsne_0.17                blob_1.2.4               
 [49] promises_1.3.2            dqrng_0.4.1              
 [51] crayon_1.5.3              dir.expiry_1.14.0        
 [53] miniUI_0.1.2              lattice_0.22-6           
 [55] beachmat_2.22.0           KEGGREST_1.46.0          
 [57] magick_2.8.6              pillar_1.10.2            
 [59] knitr_1.50                metapod_1.14.0           
 [61] boot_1.3-31               rjson_0.2.23             
 [63] xgboost_1.7.9.1           future.apply_1.11.3      
 [65] codetools_0.2-20          glue_1.8.0               
 [67] spatstat.univar_3.1-2     data.table_1.17.0        
 [69] vctrs_0.6.5               png_0.1-8                
 [71] spam_2.11-1               gtable_0.3.6             
 [73] assertthat_0.2.1          cachem_1.1.0             
 [75] xfun_0.52                 S4Arrays_1.6.0           
 [77] mime_0.13                 survival_3.7-0           
 [79] pheatmap_1.0.12           iterators_1.0.14         
 [81] statmod_1.5.0             fitdistrplus_1.2-2       
 [83] ROCR_1.0-11               nlme_3.1-166             
 [85] usethis_3.1.0             bit64_4.6.0-1            
 [87] filelock_1.0.3            RcppAnnoy_0.0.22         
 [89] bslib_0.9.0               irlba_2.3.5.1            
 [91] vipor_0.4.7               KernSmooth_2.23-24       
 [93] DBI_1.2.3                 colorspace_2.1-1         
 [95] tidyselect_1.2.1          bit_4.6.0                
 [97] compiler_4.4.2            curl_6.2.2               
 [99] BiocNeighbors_2.0.1       basilisk.utils_1.18.0    
[101] DelayedArray_0.32.0       plotly_4.10.4            
[103] msigdbdf_24.1.0           rtracklayer_1.66.0       
[105] scales_1.3.0.9000         lmtest_0.9-40            
[107] stringr_1.5.1             digest_0.6.37            
[109] goftest_1.2-3             spatstat.utils_3.1-3     
[111] rmarkdown_2.29            basilisk_1.18.0          
[113] XVector_0.46.0            htmltools_0.5.8.1        
[115] pkgconfig_2.0.3           sparseMatrixStats_1.18.0 
[117] fastmap_1.2.0             rlang_1.1.6              
[119] GlobalOptions_0.1.2       htmlwidgets_1.6.4        
[121] UCSC.utils_1.2.0          DelayedMatrixStats_1.28.1
[123] shiny_1.10.0              jquerylib_0.1.4          
[125] farver_2.1.2              zoo_1.8-14               
[127] jsonlite_2.0.0            BiocSingular_1.22.0      
[129] RCurl_1.98-1.17           magrittr_2.0.3           
[131] GenomeInfoDbData_1.2.13   dotCall64_1.2            
[133] patchwork_1.3.0           Rcpp_1.0.14              
[135] ggnewscale_0.5.1          babelgene_22.9           
[137] viridis_0.6.5             reticulate_1.42.0        
[139] stringi_1.8.7             zlibbioc_1.52.0          
[141] MASS_7.3-61               plyr_1.8.9               
[143] listenv_0.9.1             ggrepel_0.9.6            
[145] deldir_2.0-4              Biostrings_2.74.1        
[147] splines_4.4.2             tensor_1.5               
[149] hms_1.1.3                 locfit_1.5-9.12          
[151] spatstat.geom_3.3-6       RcppHNSW_0.6.0           
[153] reshape2_1.4.4            ScaledMatrix_1.14.0      
[155] XML_3.99-0.18             evaluate_1.0.3           
[157] snifter_1.16.0            tzdb_0.5.0               
[159] foreach_1.5.2             httpuv_1.6.16            
[161] RANN_2.6.2                purrr_1.0.4              
[163] polyclip_1.10-7           future_1.40.0            
[165] clue_0.3-66               scattermore_1.2          
[167] rsvd_1.0.5                xtable_1.8-4             
[169] restfulr_0.0.15           svMisc_1.4.3             
[171] RSpectra_0.16-2           later_1.4.2              
[173] class_7.3-22              viridisLite_0.4.2        
[175] tibble_3.2.1              memoise_2.0.1            
[177] beeswarm_0.4.0            GenomicAlignments_1.42.0 
[179] cluster_2.1.6             globals_0.17.0