# Simulation behind the post "Can you use TPM for differential expression?"
# Run from the post folder: Rscript tpm_sim.R
# Writes tpm_sim-results.csv (one quantity per row, columns name,value, package versions included)
# and tpm_sim-example.csv (counts and TPM of one sample, for the first figure).
suppressPackageStartupMessages({
library(DESeq2)
library(edgeR)
library(limma)
})
stopifnot(packageVersion("DESeq2") >= "1.36", packageVersion("edgeR") >= "3.42.0",
packageVersion("limma") >= "3.50")
fdr_level <- 0.05 # genes are called at this BH-adjusted p-value
n_all <- 20000 # genes in the whole transcriptome
n_sim <- 3000 # genes simulated one by one and tested
n_per <- 3 # samples per group
n_exp <- 30 # simulated experiments per setting
de_frac <- 0.1 # share of tested genes that truly change
lfc_min <- 0.5; lfc_max <- 2 # size of true changes, |log2 fold change|
len_median <- 2000 # median gene length in bases
depth_vary <- rep(c(0.5, 1, 2), 2) # relative sequencing depth, same pattern in both groups
depth_equal <- rep(1, 2 * n_per) # for comparison: all samples equally deep
reads_deep <- 20e6; reads_shallow <- 2e6 # whole-transcriptome reads at depth 1
tpm_min <- 1 # the usual fixed TPM filter
min_count <- eval(formals(edgeR:::filterByExpr.default)$min.count) # filterByExpr's count
group <- factor(rep(c("ctrl", "treat"), each = n_per))
design <- model.matrix(~ group)
simulate <- function(reads, depth) {
len <- round(exp(rnorm(n_all, log(len_median), 0.6))) # gene length in bases
per_base <- exp(rnorm(n_all, 0, 1.8)) # molecules x length gives reads
mu_all <- per_base * len / sum(per_base * len) * reads # expected reads at depth 1
tested <- seq_len(n_sim)
mu0 <- mu_all[tested]
rest_per_base <- sum(mu_all[-tested] / len[-tested]) # the untested genes, unchanged
is_de <- tested <= de_frac * n_sim
lfc <- ifelse(is_de, sample(c(-1, 1), n_sim, TRUE) * runif(n_sim, lfc_min, lfc_max), 0)
phi <- (0.05 + 0.2 / sqrt(mu0 + 1)) * exp(rnorm(n_sim, 0, 0.3)) # dispersion
mu <- outer(mu0, depth) * 2^outer(lfc, as.numeric(group == "treat"))
counts <- matrix(rnbinom(length(mu), mu = mu, size = 1 / phi), nrow = n_sim)
storage.mode(counts) <- "integer"
# TPM exactly as defined: reads per base, rescaled so that the whole transcriptome sums to 1e6
per_base_obs <- counts / len[tested]
tpm <- sweep(per_base_obs, 2, colSums(per_base_obs) + rest_per_base * depth, "/") * 1e6
list(counts = counts, tpm = tpm, is_de = is_de, mu = mu0 * mean(depth), len = len[tested],
total_reads = reads * depth) # what a GEO sample page might report
}
# Benjamini-Hochberg on the genes in `keep` only; the rest are never called
bh_on <- function(p, keep) {
padj <- rep(NA_real_, length(p))
ok <- keep & !is.na(p)
padj[ok] <- p.adjust(p[ok], "BH")
padj
}
put <- function(p, keep) replace(rep(NA_real_, length(keep)), keep, p)
# Welch two-sample t-test, one gene per row (checked against t.test() below)
welch <- function(x) {
a <- x[, group == "ctrl"]; b <- x[, group == "treat"]
va <- apply(a, 1, var) / ncol(a); vb <- apply(b, 1, var) / ncol(b)
se <- sqrt(va + vb)
t <- (rowMeans(b) - rowMeans(a)) / se
df <- se^4 / (va^2 / (ncol(a) - 1) + vb^2 / (ncol(b) - 1))
p <- 2 * pt(-abs(t), df)
p[!is.finite(p)] <- NA_real_
list(p = p, df = df)
}
x_check <- matrix(sin(1:60) * (1:60), nrow = 10)
p_ttest <- apply(x_check, 1, function(x) t.test(x[group == "treat"], x[group == "ctrl"])$p.value)
stopifnot(isTRUE(all.equal(welch(x_check)$p, p_ttest)))
# limma-trend on a log-expression matrix, genes in `keep` only
trend <- function(logx, keep) {
fit <- eBayes(lmFit(logx[keep, ], design), trend = TRUE)
list(padj = bh_on(put(fit$p.value[, 2], keep), keep), df_prior = fit$df.prior)
}
# every analysis except DESeq2 (which is fitted on stacked experiments further down)
analyse <- function(s) {
# on the counts: the limma user's guide recipes
y <- DGEList(s$counts)
fbe <- filterByExpr(y, group = group)
y <- normLibSizes(y[fbe, , keep.lib.sizes = FALSE])
voom_fit <- eBayes(lmFit(voom(y, design), design))
log_cpm <- cpm(y, log = TRUE, prior.count = 3) # limma-trend input, as in the guide
cpm_fit <- eBayes(lmFit(log_cpm, design), trend = TRUE)
# on TPM
log_tpm <- log2(s$tpm + 1)
keep_fixed <- rowSums(s$tpm >= tpm_min) >= n_per # the usual fixed TPM filter
tpm_cut <- min_count / (median(s$total_reads) / 1e6) # count threshold converted to TPM
keep_depth <- rowSums(s$tpm >= tpm_cut) >= n_per
tr_fixed <- trend(log_tpm, keep_fixed)
w_tpm <- welch(log_tpm)
list(
padj = cbind(
voom_counts = bh_on(put(voom_fit$p.value[, 2], fbe), fbe),
trend_cpm = bh_on(put(cpm_fit$p.value[, 2], fbe), fbe),
trend_tpm = tr_fixed$padj,
trend_tpm_fbe = trend(log_tpm, fbe)$padj, # same genes as the count analyses
trend_tpm_depth = trend(log_tpm, keep_depth)$padj,
welch_tpm = bh_on(w_tpm$p, keep_fixed),
welch_cpm = bh_on(put(welch(log_cpm)$p, fbe), fbe),
welch_tpm_raw = ifelse(keep_fixed, w_tpm$p, NA_real_)), # unadjusted p-values
trend_small = trend(log2(s$tpm + 0.1), keep_fixed)$padj,
kept = c(fbe = sum(fbe), fixed = sum(keep_fixed), depth = sum(keep_depth)),
df_prior = tr_fixed$df_prior, welch_df = median(w_tpm$df[keep_fixed], na.rm = TRUE))
}
run_setting <- function(reads, depth, block = 10) {
sims <- replicate(n_exp, simulate(reads, depth), simplify = FALSE)
fit_deseq <- function(m) DESeq(DESeqDataSetFromMatrix(m, data.frame(group), ~ group), quiet = TRUE)
rounded <- lapply(sims, function(s) { m <- round(s$tpm); storage.mode(m) <- "integer"; m })
out <- vector("list", n_exp)
# DESeq2 is fitted on `block` experiments stacked on top of each other, to save time;
# every experiment is filtered, corrected and scored on its own
for (b in split(seq_len(n_exp), ceiling(seq_len(n_exp) / block))) {
dds_counts <- fit_deseq(do.call(rbind, lapply(sims[b], `[[`, "counts")))
dds_tpm <- fit_deseq(do.call(rbind, rounded[b]))
for (k in seq_along(b)) {
e <- b[k]; s <- sims[[e]]; i <- (k - 1) * n_sim + seq_len(n_sim)
a <- analyse(s)
a$padj <- cbind(
deseq_counts = results(dds_counts[i, ], alpha = fdr_level)$padj,
deseq_tpm = results(dds_tpm[i, ], alpha = fdr_level)$padj, a$padj)
a$is_de <- s$is_de; a$mu <- s$mu; a$len <- s$len
a$ratio <- s$counts[, 2] / s$tpm[, 2] # reads per TPM, a depth-1 sample
a$zero_tpm <- mean(rounded[[e]][s$counts > 0] == 0) # nonzero counts rounded to 0 TPM
if (e == 1) a$example <- data.frame(count = s$counts[, 2], tpm = s$tpm[, 2], len = s$len)
out[[e]] <- a
}
}
out
}
set.seed(2026); deep <- run_setting(reads_deep, depth_vary)
set.seed(2027); shallow <- run_setting(reads_shallow, depth_vary)
set.seed(2028); shallow_eq <- run_setting(reads_shallow, depth_equal)
runs <- list(deep = deep, shallow = shallow, shallow_eq = shallow_eq)
# ---- summaries: everything the post quotes ----
res <- c(n_all = n_all, n_sim = n_sim, n_per = n_per, n_exp = n_exp, de_frac = de_frac,
lfc_min = lfc_min, lfc_max = lfc_max, len_median = len_median, fdr_level = fdr_level,
reads_deep = reads_deep, reads_shallow = reads_shallow, tpm_min = tpm_min,
min_count = min_count, depth_range = max(depth_vary) / min(depth_vary))
add <- function(name, value) res[[name]] <<- value
score <- function(padj, is_de) {
call <- !is.na(padj) & padj < fdr_level
c(n_call = sum(call), fdp = if (any(call)) mean(!is_de[call]) else 0,
power = sum(call & is_de) / sum(is_de), fp = sum(call & !is_de))
}
low_count <- 10
all_genes <- do.call(rbind, lapply(names(runs), function(st) do.call(rbind, lapply(runs[[st]],
function(r) data.frame(setting = st, len = r$len)))))
len_cut <- quantile(all_genes$len, c(1/3, 2/3))
add("len_cut1", len_cut[[1]]); add("len_cut2", len_cut[[2]]); add("low_count", low_count)
for (st in names(runs)) {
rr <- runs[[st]]
s <- lapply(rr, function(r) apply(r$padj, 2, score, is_de = r$is_de))
for (m in colnames(rr[[1]]$padj)) {
x <- sapply(s, function(z) z[, m])
add(paste("power", st, m, sep = "."), mean(x["power", ]))
add(paste("fdr", st, m, sep = "."), mean(x["fdp", ]))
add(paste("se_fdr", st, m, sep = "."), sd(x["fdp", ]) / sqrt(n_exp))
add(paste("calls", st, m, sep = "."), mean(x["n_call", ]))
add(paste("calls_total", st, m, sep = "."), sum(x["n_call", ]))
add(paste("false_total", st, m, sep = "."), sum(x["fp", ]))
add(paste("exp_no_call", st, m, sep = "."), sum(x["n_call", ] == 0))
add(paste("fdr_min", st, m, sep = "."), min(x["fdp", ]))
}
add(paste("de_total", st, sep = "."), sum(sapply(rr, function(r) sum(r$is_de))))
add(paste("power_small", st, sep = "."),
mean(sapply(rr, function(r) score(r$trend_small, r$is_de)[["power"]])))
for (k in c("fbe", "fixed", "depth"))
add(paste("kept", st, k, sep = "."), mean(sapply(rr, function(r) r$kept[[k]])))
add(paste("ratio", st, sep = "."), median(unlist(lapply(rr, `[[`, "ratio")), na.rm = TRUE))
add(paste("zero_tpm", st, sep = "."), mean(sapply(rr, `[[`, "zero_tpm")))
add(paste("df_prior", st, sep = "."), median(sapply(rr, `[[`, "df_prior")))
add(paste("welch_df", st, sep = "."), median(sapply(rr, `[[`, "welch_df")))
add(paste("share_low", st, sep = "."), mean(unlist(lapply(rr, `[[`, "mu")) < low_count))
# pooled over experiments, by expression level and by gene length
g <- do.call(rbind, lapply(rr, function(r) data.frame(is_de = r$is_de, mu = r$mu, len = r$len, r$padj)))
g$expr <- cut(g$mu, c(0, low_count, 100, 1000, max(g$mu)), labels = paste0("e", 1:4))
g$length <- cut(g$len, c(0, len_cut, max(g$len)), labels = c("short", "middle", "long"))
for (m in colnames(rr[[1]]$padj)) for (strat in c("expr", "length")) {
for (k in levels(g[[strat]])) {
x <- g[g[[strat]] == k, ]
call <- !is.na(x[[m]]) & x[[m]] < fdr_level
add(paste("spower", st, m, k, sep = "."), sum(call & x$is_de) / sum(x$is_de))
add(paste("scalls", st, m, k, sep = "."), sum(call))
add(paste("sfdr", st, m, k, sep = "."), if (any(call)) mean(!x$is_de[call]) else NA_real_)
}
}
}
versions <- sapply(c("DESeq2", "edgeR", "limma"), function(p) as.character(packageVersion(p)))
out <- rbind(data.frame(name = names(res), value = as.character(res)),
data.frame(name = paste0(names(versions), "_version"), value = versions))
write.csv(out, "tpm_sim-results.csv", row.names = FALSE)
ex <- rbind(cbind(deep[[1]]$example, setting = "deep"), cbind(shallow[[1]]$example, setting = "shallow"))
write.csv(ex, "tpm_sim-example.csv", row.names = FALSE)