# Integrated, SCT or RNA assay for differential expression: the simulation behind the post.
# Run from the post folder: Rscript integration_sim.R
# Writes integration_sim-results.csv (name,value) and integration_sim-genes.csv (per gene, seed 1).
# Runtime: several minutes per seed (SCTransform without glmGamPoi and the anchor integration
# are the slow steps).
# Expect many warnings. Without the glmGamPoi package, SCTransform falls back to its native fit,
# which warns "iteration limit reached" and "alternation limit reached"; installing glmGamPoi
# removes most of them. IntegrateData() warns "Different cells in new layer data than already
# exists for scale.data" (suppressed below). Both are harmless here.
suppressPackageStartupMessages({
library(Seurat)
library(edgeR)
library(limma)
library(Matrix)
})
stopifnot(packageVersion("Seurat") >= "5.0.0", packageVersion("SeuratObject") >= "5.0.0",
packageVersion("edgeR") >= "4.0.0", packageVersion("limma") >= "3.58.0",
packageVersion("mclust") >= "6.0.0")
options(Seurat.presto.wilcox.msg = FALSE, future.globals.maxSize = 2e9)
alpha <- 0.05
seeds <- 1:6
# ---- simulated counts -----------------------------------------------------------------------
# 2 control and 2 treated samples, 3 cell types, negative binomial counts. Every gene gets a
# random shift per sample (the sample effect), the same in all cell types. The first n_de genes
# change with treatment, in cell type A only, by a known log2 fold change.
simulate <- function(seed, n_genes = 1500, cells_per_sample = 500, n_per_group = 2,
type_prob = c(A = 0.5, B = 0.3, C = 0.2), n_marker = 80, marker_fold = 4,
n_de = 100, lfc_range = c(0.5, 2), batch_sd = 0.3, phi = 0.2) {
set.seed(seed)
n_samples <- 2 * n_per_group
samples <- sprintf("s%d", seq_len(n_samples))
cond_s <- rep(c("ctrl", "trt"), each = n_per_group)
types <- names(type_prob)
sample <- rep(samples, each = cells_per_sample)
celltype <- sample(types, length(sample), replace = TRUE, prob = type_prob)
mu0 <- exp(rnorm(n_genes, log(0.3), 1.2)) # baseline mean UMI per cell
type_eff <- matrix(0, n_genes, length(types)) # marker genes of each type
for (k in seq_along(types))
type_eff[n_de + (k - 1) * n_marker + seq_len(n_marker), k] <- log(marker_fold)
lfc2 <- c(sample(c(-1, 1), n_de, TRUE) * runif(n_de, lfc_range[1], lfc_range[2]),
rep(0, n_genes - n_de))
batch <- matrix(rnorm(n_genes * n_samples, 0, batch_sd), n_genes, n_samples)
depth <- exp(rnorm(length(sample), 0, 0.3)) *
rep(exp(rnorm(n_samples, 0, 0.3)), each = cells_per_sample) # cell and sample depth
si <- match(sample, samples)
trt_A <- as.numeric(cond_s[si] == "trt" & celltype == "A")
eta <- log(mu0) + type_eff[, match(celltype, types)] + batch[, si] +
outer(lfc2 * log(2), trt_A)
m <- exp(eta) * rep(depth, each = n_genes)
counts <- matrix(rnbinom(length(m), mu = m, size = 1 / phi), n_genes)
dimnames(counts) <- list(sprintf("g%04d", seq_len(n_genes)),
sprintf("c%05d", seq_along(sample)))
meta <- data.frame(sample = sample, condition = cond_s[si], celltype = celltype,
row.names = colnames(counts))
list(counts = counts, meta = meta, lfc2 = setNames(lfc2, rownames(counts)),
is_de = setNames(seq_len(n_genes) <= n_de, rownames(counts)))
}
# ---- helpers --------------------------------------------------------------------------------
# cell-level FindMarkers, treated vs control cells of type A, every gene tested
find_markers_A <- function(obj, assay, genes) {
Idents(obj) <- paste(obj$celltype, obj$condition, sep = "_")
fm <- FindMarkers(obj, ident.1 = "A_trt", ident.2 = "A_ctrl", assay = assay,
slot = "data", logfc.threshold = 0, min.pct = 0, verbose = FALSE)
fm[genes, ] # genes absent from the assay give empty rows
}
# per-sample means of a layer in type A, then limma-trend (samples as replicates)
sample_means_limma <- function(x, meta, genes) {
x <- x[intersect(genes, rownames(x)), ]
a <- meta$celltype == "A"
mm <- sapply(split(which(a), meta$sample[a]), function(j) rowMeans(x[, j, drop = FALSE]))
g <- factor(meta$condition[match(colnames(mm), meta$sample)])
fit <- eBayes(lmFit(mm, model.matrix(~ g)), trend = TRUE)
tt <- topTable(fit, coef = 2, number = nrow(mm), sort.by = "none")
setNames(tt$adj.P.Val, rownames(tt))[genes] # genes absent from the layer: not called
}
# pseudobulk: summed raw counts per sample in type A, edgeR quasi-likelihood
pseudobulk_edger <- function(counts, meta) {
a <- meta$celltype == "A"
pb <- t(rowsum(t(counts[, a]), meta$sample[a]))
g <- factor(meta$condition[match(colnames(pb), meta$sample)])
design <- model.matrix(~ g)
y <- DGEList(pb, group = g)
keep <- filterByExpr(y, design)
y <- normLibSizes(y[keep, , keep.lib.sizes = FALSE])
fit <- glmQLFit(y, design, robust = TRUE, legacy = FALSE)
tab <- glmQLFTest(fit, coef = 2)$table
lfc <- fdr <- setNames(rep(NA_real_, nrow(pb)), rownames(pb))
lfc[rownames(tab)] <- tab$logFC
fdr[rownames(tab)] <- p.adjust(tab$PValue, "BH")
list(lfc = lfc, fdr = fdr)
}
slope_through_origin <- function(est, truth) unname(coef(lm(est ~ 0 + truth)))
# ---- one simulated data set -----------------------------------------------------------------
run_one <- function(seed) {
sim <- simulate(seed)
de <- names(which(sim$is_de)); null <- names(which(!sim$is_de))
so <- CreateSeuratObject(as(sim$counts, "CsparseMatrix"), meta.data = sim$meta)
# Seurat v5 workflow: layers split by sample, IntegrateLayers() for the embedding
v5 <- so
v5[["RNA"]] <- split(v5[["RNA"]], f = v5$sample)
v5 <- NormalizeData(v5, verbose = FALSE)
v5 <- FindVariableFeatures(v5, verbose = FALSE)
v5 <- ScaleData(v5, verbose = FALSE)
v5 <- RunPCA(v5, npcs = 20, verbose = FALSE)
v5 <- IntegrateLayers(v5, method = CCAIntegration, orig.reduction = "pca",
new.reduction = "integrated.cca", verbose = FALSE)
v5_assays <- paste(Assays(v5), collapse = " ")
v5_reductions <- paste(Reductions(v5), collapse = " ")
ari <- sapply(c(unintegrated = "pca", integrated = "integrated.cca"), function(r) {
x <- FindNeighbors(v5, reduction = r, dims = 1:20, verbose = FALSE)
x <- FindClusters(x, resolution = 0.1, verbose = FALSE)
mclust::adjustedRandIndex(x$seurat_clusters, x$celltype)
})
v5 <- JoinLayers(v5) # one "data" layer again, for DE
fm_rna <- find_markers_A(v5, "RNA", rownames(so))
# Seurat v4-style workflow: anchors + IntegrateData() creates the "integrated" assay
ol <- lapply(SplitObject(so, split.by = "sample"), function(x)
FindVariableFeatures(NormalizeData(x, verbose = FALSE), verbose = FALSE))
feats <- SelectIntegrationFeatures(ol, verbose = FALSE)
anchors <- FindIntegrationAnchors(ol, anchor.features = feats, verbose = FALSE)
int <- suppressWarnings(IntegrateData(anchors, verbose = FALSE))
int <- int[, colnames(so)]
n_int_genes <- nrow(int[["integrated"]])
fm_int <- find_markers_A(int, "integrated", rownames(so))
# SCTransform, one model per sample, then PrepSCTFindMarkers() before FindMarkers()
sct <- so
sct[["RNA"]] <- split(sct[["RNA"]], f = sct$sample)
sct <- suppressMessages(SCTransform(sct, verbose = FALSE))
sct <- PrepSCTFindMarkers(sct, verbose = FALSE)
fm_sct <- find_markers_A(sct, "SCT", rownames(so))
n_sct_genes <- nrow(sct[["SCT"]])
# the same layers, averaged per sample, with the sample as the replicate
genes <- rownames(so)
lm_rna <- sample_means_limma(LayerData(v5, assay = "RNA", layer = "data"), sim$meta, genes)
lm_int <- sample_means_limma(LayerData(int, assay = "integrated", layer = "data"),
sim$meta, genes)
lm_sct <- sample_means_limma(LayerData(sct, assay = "SCT", layer = "data"), sim$meta, genes)
pb <- pseudobulk_edger(sim$counts, sim$meta)
lfc <- list(rna = fm_rna$avg_log2FC, integrated = fm_int$avg_log2FC,
sct = fm_sct$avg_log2FC, pseudobulk = pb$lfc)
lfc <- lapply(lfc, setNames, rownames(so))
q <- list(cell_rna = p.adjust(fm_rna$p_val, "BH"), cell_integrated = p.adjust(fm_int$p_val, "BH"),
cell_sct = p.adjust(fm_sct$p_val, "BH"), mean_rna = lm_rna, mean_integrated = lm_int,
mean_sct = lm_sct, pseudobulk = pb$fdr)
q <- lapply(q, function(v) setNames(v, rownames(so))[rownames(so)])
called <- lapply(q, function(v) !is.na(v) & v < alpha)
out <- c(
setNames(sapply(lfc, function(e) slope_through_origin(e[de], sim$lfc2[de])),
paste0("slope_", names(lfc))),
setNames(sapply(called, function(k) sum(k[de])), paste0("tp_", names(called))),
setNames(sapply(called, function(k) sum(k[null])), paste0("fp_", names(called))),
ari_unintegrated = ari[["unintegrated"]], ari_integrated = ari[["integrated"]],
n_int_genes = n_int_genes, n_sct_genes = n_sct_genes, n_cells_A = sum(sim$meta$celltype == "A"))
genes <- data.frame(gene = rownames(so), is_de = sim$is_de, true_lfc = sim$lfc2,
lfc_rna = lfc$rna, lfc_integrated = lfc$integrated,
lfc_sct = lfc$sct, lfc_pseudobulk = lfc$pseudobulk)
message("seed ", seed, " done")
list(out = out, genes = genes, v5_assays = v5_assays, v5_reductions = v5_reductions)
}
res <- lapply(seeds, run_one)
num <- do.call(rbind, lapply(seq_along(seeds), function(i)
data.frame(name = paste0(names(res[[i]]$out), "_seed", seeds[i]), value = unname(res[[i]]$out))))
f <- lapply(formals(simulate)[-1], eval)
meta_rows <- data.frame(
name = c("n_seeds", "n_genes", "n_de", "cells_per_sample", "batch_sd", "alpha",
"n_per_group", paste0("type_prob_", names(f$type_prob)), "n_marker", "marker_fold",
"lfc_min", "lfc_max",
"v5_assays", "v5_reductions", "r_version", "seurat_version", "seuratobject_version",
"sctransform_version", "edger_version", "limma_version", "glmgampoi_installed"),
value = c(length(seeds), f$n_genes, f$n_de, f$cells_per_sample, f$batch_sd, alpha,
f$n_per_group, f$type_prob, f$n_marker, f$marker_fold, f$lfc_range,
res[[1]]$v5_assays, res[[1]]$v5_reductions,
paste(R.version$major, R.version$minor, sep = "."),
as.character(packageVersion("Seurat")), as.character(packageVersion("SeuratObject")),
as.character(packageVersion("sctransform")), as.character(packageVersion("edgeR")),
as.character(packageVersion("limma")),
as.character(requireNamespace("glmGamPoi", quietly = TRUE))))
write.csv(rbind(meta_rows, num), "integration_sim-results.csv", row.names = FALSE)
write.csv(res[[1]]$genes, "integration_sim-genes.csv", row.names = FALSE)