← Files Life Sciences NGS AnalysisARCHIVED FILE
workflows/bulk_rnaseq_differential_expression/run_bulk_de.R
20 KB · Oct 5, 2026 · 18:06 UTC
#!/usr/bin/env Rscript
suppressPackageStartupMessages({
library(limma)
})
args <- commandArgs(trailingOnly = TRUE)
if (length(args) != 7) {
stop("usage: run_bulk_de.R <count_matrix.tsv> <sample_metadata.tsv> <contrasts.tsv> <method> <input_mode> <fit_formula> <outdir>")
}
count_path <- args[[1]]
metadata_path <- args[[2]]
contrasts_path <- args[[3]]
method <- args[[4]]
input_mode <- args[[5]]
fit_formula <- args[[6]]
outdir <- args[[7]]
dir.create(outdir, recursive = TRUE, showWarnings = FALSE)
for (child in c("config", "manifest", "logs", "qc", "results", "plots", "versions")) {
dir.create(file.path(outdir, child), recursive = TRUE, showWarnings = FALSE)
}
safe_name <- function(x) {
gsub("[^A-Za-z0-9_.-]+", "_", x)
}
write_matrix_artifact <- function(path, values, counts, gene_name) {
write.table(
data.frame(gene_id = counts$gene_id, gene_name = gene_name, values, check.names = FALSE),
file = path,
sep = "\t",
quote = FALSE,
row.names = FALSE
)
}
mean_without_self <- function(mat) {
if (ncol(mat) < 2) {
return(rep(0, ncol(mat)))
}
apply(mat, 1, function(x) sum(x) / (length(x) - 1))
}
write_log <- function(...) {
write(paste(...), file = file.path(outdir, "logs", "run.log"), append = TRUE)
}
pad_range <- function(values, frac = 0.08) {
rng <- range(values, finite = TRUE)
span <- diff(rng)
if (!is.finite(span) || span == 0) {
span <- max(abs(rng), 1)
}
c(rng[1] - span * frac, rng[2] + span * frac)
}
condition_palette <- function(conditions) {
levs <- unique(as.character(conditions))
cols <- c("#3b6ea8", "#d95f02", "#1b9e77", "#7570b3", "#e7298a", "#66a61e")
setNames(cols[seq_along(levs)], levs)
}
label_top_points <- function(x, y, labels, rank_metric, n = 5, cex = 0.8) {
keep <- is.finite(x) & is.finite(y) & nzchar(labels)
if (!any(keep)) {
return(invisible(NULL))
}
ord <- order(rank_metric[keep], decreasing = FALSE)
idx <- which(keep)[head(ord, min(n, length(ord)))]
text(x[idx], y[idx], labels = labels[idx], pos = 3, cex = cex, xpd = NA)
}
write_log("started_at", format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"))
write_log("count_matrix", normalizePath(count_path))
write_log("sample_metadata", normalizePath(metadata_path))
write_log("contrasts", normalizePath(contrasts_path))
write_log("method", method)
counts <- read.delim(count_path, check.names = FALSE)
metadata <- read.delim(metadata_path, check.names = FALSE, stringsAsFactors = FALSE)
contrast_manifest <- read.delim(contrasts_path, check.names = FALSE, stringsAsFactors = FALSE)
sample_cols <- setdiff(colnames(counts), c("gene_id", "gene_name"))
if (!setequal(sample_cols, metadata$sample_id)) {
stop("count matrix columns and metadata sample_id values do not match")
}
metadata <- metadata[match(sample_cols, metadata$sample_id), ]
expr <- as.matrix(counts[, sample_cols])
mode(expr) <- "numeric"
rownames(expr) <- counts$gene_id
gene_name <- if ("gene_name" %in% colnames(counts)) counts$gene_name else counts$gene_id
is_integer_like <- all(abs(expr - round(expr)) < 1e-8)
condition_counts <- as.data.frame(table(metadata$condition), stringsAsFactors = FALSE)
colnames(condition_counts) <- c("condition", "n_replicates")
contrast_manifest$numerator_replicates <- condition_counts$n_replicates[
match(contrast_manifest$numerator_condition, condition_counts$condition)
]
contrast_manifest$denominator_replicates <- condition_counts$n_replicates[
match(contrast_manifest$denominator_condition, condition_counts$condition)
]
contrast_manifest$status <- ifelse(
contrast_manifest$numerator_replicates >= 2 & contrast_manifest$denominator_replicates >= 2,
"valid",
"insufficient_replicates"
)
contrast_manifest$executed <- FALSE
contrast_manifest$execution_method <- NA_character_
contrast_manifest$stub_result <- NA_character_
design_formula <- fit_formula
write.table(metadata, file = file.path(outdir, "manifest", "sample_metadata.aligned.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
write.table(contrast_manifest, file = file.path(outdir, "manifest", "contrast_status.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
write.table(
data.frame(
role = c("count_matrix", "sample_metadata", "contrasts"),
path = c(normalizePath(count_path), normalizePath(metadata_path), normalizePath(contrasts_path))
),
file = file.path(outdir, "manifest", "input_files.tsv"),
sep = "\t",
quote = FALSE,
row.names = FALSE
)
write.table(
data.frame(
key = c("design_formula", "method", "count_matrix_integer_like"),
value = c(design_formula, method, is_integer_like)
),
file = file.path(outdir, "config", "method_decision.tsv"),
sep = "\t",
quote = FALSE,
row.names = FALSE
)
log_expr <- log2(expr + 1)
model_expr <- log_expr
normalization_warning <- NULL
if (input_mode == "raw_counts") {
write_matrix_artifact(file.path(outdir, "results", "raw_counts.tsv"), expr, counts, gene_name)
} else if (input_mode == "normalized_expression") {
normalization_warning <- paste(
"Normalization skipped because input_mode=normalized_expression.",
"The runner preserved the supplied matrix and generated log2(x+1) only for modeling/QC."
)
write_matrix_artifact(file.path(outdir, "results", "input_normalized_expression_matrix.tsv"), expr, counts, gene_name)
} else if (input_mode == "log_expression") {
normalization_warning <- paste(
"Normalization and log transformation skipped because input_mode=log_expression.",
"The runner used the supplied matrix directly for modeling/QC."
)
log_expr <- expr
model_expr <- expr
write_matrix_artifact(file.path(outdir, "results", "input_log_expression_matrix.tsv"), expr, counts, gene_name)
} else {
stop(paste("unsupported input_mode:", input_mode))
}
if (!is.null(normalization_warning)) {
writeLines(normalization_warning, con = file.path(outdir, "qc", "input_mode_warning.txt"))
}
normalized_counts <- expr
if (method == "edgeR") {
suppressPackageStartupMessages(library(edgeR))
dge <- DGEList(counts = round(expr), group = metadata$condition)
dge <- calcNormFactors(dge)
normalized_counts <- cpm(dge, normalized.lib.sizes = TRUE)
model_expr <- cpm(dge, log = TRUE, prior.count = 1)
} else if (method == "DESeq2") {
suppressPackageStartupMessages(library(DESeq2))
metadata$condition <- factor(metadata$condition)
dds <- DESeqDataSetFromMatrix(countData = round(expr), colData = metadata, design = as.formula(design_formula))
dds <- DESeq(dds, quiet = TRUE)
normalized_counts <- counts(dds, normalized = TRUE)
model_expr <- assay(vst(dds, blind = TRUE))
}
if (input_mode == "raw_counts") {
write_matrix_artifact(file.path(outdir, "results", "normalized_expression_matrix.tsv"), normalized_counts, counts, gene_name)
write_matrix_artifact(file.path(outdir, "results", "log2_expression_matrix.tsv"), model_expr, counts, gene_name)
} else if (input_mode == "normalized_expression") {
write_matrix_artifact(file.path(outdir, "results", "log2_expression_matrix.tsv"), model_expr, counts, gene_name)
} else {
write_matrix_artifact(file.path(outdir, "results", "modeling_expression_matrix.tsv"), model_expr, counts, gene_name)
}
lib_sizes <- data.frame(sample_id = sample_cols, library_size = colSums(expr), condition = metadata$condition)
write.table(lib_sizes, file = file.path(outdir, "qc", "library_sizes.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
condition_cols <- condition_palette(metadata$condition)
bar_cols <- unname(condition_cols[as.character(lib_sizes$condition)])
png(file.path(outdir, "qc", "library_sizes.png"), width = 1300, height = 900, res = 160)
par(mar = c(10, 5, 4, 2) + 0.1)
barplot(
lib_sizes$library_size,
names.arg = lib_sizes$sample_id,
las = 2,
col = bar_cols,
main = "Library Sizes",
ylab = "Sum of provided expression values"
)
legend("topright", legend = names(condition_cols), fill = unname(condition_cols), bty = "n")
dev.off()
pca_input <- model_expr[apply(model_expr, 1, var, na.rm = TRUE) > 0, , drop = FALSE]
if (nrow(pca_input) < 2) {
pca_input <- model_expr
}
pca <- prcomp(t(pca_input), center = TRUE, scale. = FALSE)
pca_df <- data.frame(sample_id = rownames(pca$x), PC1 = pca$x[, 1], PC2 = pca$x[, 2], condition = metadata$condition)
write.table(pca_df, file = file.path(outdir, "qc", "pca_scores.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
pc_var <- (pca$sdev^2 / sum(pca$sdev^2)) * 100
png(file.path(outdir, "qc", "pca.png"), width = 1300, height = 900, res = 160)
par(mar = c(5, 5, 4, 5) + 0.1)
plot(
pca_df$PC1,
pca_df$PC2,
pch = 19,
col = unname(condition_cols[as.character(pca_df$condition)]),
xlab = sprintf("PC1 (%.1f%% variance)", pc_var[1]),
ylab = sprintf("PC2 (%.1f%% variance)", pc_var[2]),
main = "PCA on modeling expression",
xlim = pad_range(pca_df$PC1),
ylim = pad_range(pca_df$PC2)
)
text(pca_df$PC1, pca_df$PC2, labels = pca_df$sample_id, pos = 3, cex = 0.8, xpd = NA)
legend("topright", inset = c(-0.2, 0), legend = names(condition_cols), fill = unname(condition_cols), bty = "n", xpd = NA)
dev.off()
dist_mat <- as.matrix(dist(t(model_expr)))
write.table(
cbind(sample_id = rownames(dist_mat), as.data.frame(dist_mat, check.names = FALSE)),
file = file.path(outdir, "qc", "sample_distance.tsv"),
sep = "\t",
quote = FALSE,
row.names = FALSE
)
png(file.path(outdir, "qc", "sample_distance_heatmap.png"), width = 1400, height = 1100, res = 170)
heatmap(
dist_mat,
symm = TRUE,
scale = "none",
margins = c(12, 12),
cexRow = 0.95,
cexCol = 0.95,
col = colorRampPalette(c("#fff7bc", "#fec44f", "#fe9929", "#d95f0e", "#993404"))(256),
main = "Sample Distance"
)
dev.off()
outlier_mean_distance <- mean_without_self(dist_mat)
outlier_z <- as.numeric(scale(outlier_mean_distance))
if (all(is.na(outlier_z))) {
outlier_z <- rep(0, length(outlier_mean_distance))
}
sample_outliers <- data.frame(
sample_id = names(outlier_mean_distance),
mean_distance = as.numeric(outlier_mean_distance),
z_score = outlier_z,
flag_high_distance = outlier_z >= 2
)
write.table(sample_outliers, file = file.path(outdir, "qc", "sample_outlier_metrics.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
design <- model.matrix(as.formula(design_formula), metadata)
write.table(
cbind(sample_id = metadata$sample_id, as.data.frame(design, check.names = FALSE)),
file = file.path(outdir, "qc", "design_matrix.tsv"),
sep = "\t",
quote = FALSE,
row.names = FALSE
)
design_rank <- qr(design)$rank
design_full_rank <- design_rank == ncol(design)
design_diagnostics <- data.frame(
key = c("design_formula", "input_mode", "sample_count", "design_columns", "design_rank", "design_full_rank"),
value = c(design_formula, input_mode, nrow(metadata), ncol(design), design_rank, design_full_rank)
)
write.table(design_diagnostics, file = file.path(outdir, "qc", "design_diagnostics.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
if ("batch" %in% colnames(metadata)) {
batch_condition_table <- as.data.frame.matrix(table(metadata$batch, metadata$condition))
batch_condition_table <- cbind(batch = rownames(batch_condition_table), batch_condition_table)
rownames(batch_condition_table) <- NULL
write.table(batch_condition_table, file = file.path(outdir, "qc", "condition_by_batch.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
}
if (!design_full_rank) {
stop("design matrix is rank deficient; see qc/design_diagnostics.tsv and qc/condition_by_batch.tsv")
}
valid_contrasts <- contrast_manifest[contrast_manifest$status == "valid", , drop = FALSE]
blocked_contrasts <- contrast_manifest[contrast_manifest$status != "valid", , drop = FALSE]
warnings_df <- data.frame(severity = character(), message = character(), stringsAsFactors = FALSE)
if (nrow(valid_contrasts) == 0) {
warnings_df <- rbind(warnings_df, data.frame(severity = "error", message = "No contrasts were executable after replicate checks.", stringsAsFactors = FALSE))
}
if (nrow(blocked_contrasts) > 0) {
warnings_df <- rbind(warnings_df, data.frame(severity = "warn", message = sprintf("%d contrast(s) were blocked due to insufficient biological replication.", nrow(blocked_contrasts)), stringsAsFactors = FALSE))
}
if (any(valid_contrasts$numerator_replicates == 2 & valid_contrasts$denominator_replicates == 2)) {
warnings_df <- rbind(warnings_df, data.frame(severity = "warn", message = "At least one executed contrast is minimally powered (2 vs 2 replicates); interpret effect sizes and p-values as exploratory.", stringsAsFactors = FALSE))
}
if (input_mode != "raw_counts") {
warnings_df <- rbind(warnings_df, data.frame(severity = "warn", message = sprintf("Input mode is %s; normalization and/or transformation was preserved from the supplied matrix rather than re-derived from raw counts.", input_mode), stringsAsFactors = FALSE))
}
write.table(warnings_df, file = file.path(outdir, "qc", "statistical_warnings.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
write.table(
data.frame(
key = c("sample_count", "gene_count", "valid_contrasts", "blocked_contrasts", "minimal_replicate_contrasts"),
value = c(nrow(metadata), nrow(counts), nrow(valid_contrasts), nrow(blocked_contrasts), sum(valid_contrasts$numerator_replicates == 2 & valid_contrasts$denominator_replicates == 2))
),
file = file.path(outdir, "qc", "statistical_summary.tsv"),
sep = "\t",
quote = FALSE,
row.names = FALSE
)
if (nrow(blocked_contrasts) > 0) {
for (i in seq_len(nrow(blocked_contrasts))) {
out_name <- safe_name(blocked_contrasts$contrast[i])
stub_path <- file.path(outdir, "results", paste0(out_name, ".not_tested.tsv"))
stub <- data.frame(
contrast = blocked_contrasts$contrast[i],
status = blocked_contrasts$status[i],
reason = "Insufficient biological replication for at least one condition",
numerator_condition = blocked_contrasts$numerator_condition[i],
denominator_condition = blocked_contrasts$denominator_condition[i],
numerator_replicates = blocked_contrasts$numerator_replicates[i],
denominator_replicates = blocked_contrasts$denominator_replicates[i],
input_mode = input_mode,
fit_formula = design_formula
)
write.table(stub, file = stub_path, sep = "\t", quote = FALSE, row.names = FALSE)
contrast_manifest$stub_result[contrast_manifest$contrast == blocked_contrasts$contrast[i]] <- basename(stub_path)
}
}
if (nrow(valid_contrasts) > 0) {
metadata$condition <- factor(metadata$condition)
if (method == "limma_log2") {
fit <- lmFit(model_expr, design)
contrast_defs <- setNames(
paste0("condition", valid_contrasts$numerator_condition, " - condition", valid_contrasts$denominator_condition),
valid_contrasts$contrast
)
contrast_matrix <- makeContrasts(contrasts = unname(contrast_defs), levels = design)
colnames(contrast_matrix) <- names(contrast_defs)
fit2 <- eBayes(contrasts.fit(fit, contrast_matrix), trend = TRUE, robust = TRUE)
png(file.path(outdir, "qc", "mean_variance_trend.png"), width = 1000, height = 850, res = 150)
plotSA(fit2, main = "Mean-variance trend")
dev.off()
for (contrast_name in colnames(contrast_matrix)) {
table <- topTable(fit2, coef = contrast_name, number = Inf, sort.by = "P")
table$gene_id <- rownames(table)
table$gene_name <- gene_name[match(rownames(table), counts$gene_id)]
table <- table[, c("gene_id", "gene_name", "logFC", "AveExpr", "t", "P.Value", "adj.P.Val", "B")]
out_name <- safe_name(contrast_name)
write.table(table, file = file.path(outdir, "results", paste0(out_name, ".tsv")), sep = "\t", quote = FALSE, row.names = FALSE)
sig <- !is.na(table$adj.P.Val) & table$adj.P.Val < 0.05
png(file.path(outdir, "plots", paste0(out_name, "_volcano.png")), width = 1200, height = 900, res = 160)
par(mar = c(5, 5, 4, 2) + 0.1)
plot(
table$logFC,
-log10(table$P.Value),
pch = 19,
cex = 0.7,
col = ifelse(sig, "#b22222", "#444444"),
xlab = "log2 fold-change",
ylab = "-log10(P)",
main = paste("Volcano:", contrast_name),
xlim = pad_range(table$logFC),
ylim = pad_range(-log10(table$P.Value))
)
abline(h = -log10(0.05), lty = 2, col = "#1f78b4")
abline(v = c(-1, 1), lty = 3, col = "#9e9e9e")
label_top_points(table$logFC, -log10(table$P.Value), table$gene_name, table$adj.P.Val, n = 6)
dev.off()
png(file.path(outdir, "plots", paste0(out_name, "_ma.png")), width = 1200, height = 900, res = 160)
par(mar = c(5, 5, 4, 2) + 0.1)
plot(
table$AveExpr,
table$logFC,
pch = 19,
cex = 0.7,
col = ifelse(sig, "#b22222", "#444444"),
xlab = "Average expression",
ylab = "log2 fold-change",
main = paste("MA:", contrast_name),
xlim = pad_range(table$AveExpr),
ylim = pad_range(table$logFC)
)
abline(h = 0, col = "red")
label_top_points(table$AveExpr, table$logFC, table$gene_name, table$adj.P.Val, n = 6)
dev.off()
contrast_manifest$executed[contrast_manifest$contrast == contrast_name] <- TRUE
contrast_manifest$execution_method[contrast_manifest$contrast == contrast_name] <- method
}
} else if (method == "edgeR") {
dge <- DGEList(counts = round(expr), group = metadata$condition)
dge <- calcNormFactors(dge)
dge <- estimateDisp(dge, design)
fit <- glmQLFit(dge, design)
png(file.path(outdir, "qc", "mean_variance_trend.png"), width = 1000, height = 850, res = 150)
plotBCV(dge, main = "edgeR mean-variance trend")
dev.off()
for (i in seq_len(nrow(valid_contrasts))) {
contrast_name <- valid_contrasts$contrast[i]
contrast_vec <- rep(0, ncol(design))
names(contrast_vec) <- colnames(design)
contrast_vec[paste0("condition", valid_contrasts$numerator_condition[i])] <- 1
contrast_vec[paste0("condition", valid_contrasts$denominator_condition[i])] <- -1
qlf <- glmQLFTest(fit, contrast = contrast_vec)
table <- topTags(qlf, n = Inf)$table
table$gene_id <- rownames(table)
table$gene_name <- gene_name[match(rownames(table), counts$gene_id)]
out_name <- safe_name(contrast_name)
write.table(table, file = file.path(outdir, "results", paste0(out_name, ".tsv")), sep = "\t", quote = FALSE, row.names = FALSE)
contrast_manifest$executed[contrast_manifest$contrast == contrast_name] <- TRUE
contrast_manifest$execution_method[contrast_manifest$contrast == contrast_name] <- method
}
} else if (method == "DESeq2") {
png(file.path(outdir, "qc", "mean_variance_trend.png"), width = 1000, height = 850, res = 150)
plotDispEsts(dds, main = "DESeq2 dispersion estimates")
dev.off()
for (i in seq_len(nrow(valid_contrasts))) {
contrast_name <- valid_contrasts$contrast[i]
res <- results(dds, contrast = c("condition", valid_contrasts$numerator_condition[i], valid_contrasts$denominator_condition[i]))
table <- as.data.frame(res)
table$gene_id <- rownames(table)
table$gene_name <- gene_name[match(rownames(table), counts$gene_id)]
out_name <- safe_name(contrast_name)
write.table(table, file = file.path(outdir, "results", paste0(out_name, ".tsv")), sep = "\t", quote = FALSE, row.names = FALSE)
contrast_manifest$executed[contrast_manifest$contrast == contrast_name] <- TRUE
contrast_manifest$execution_method[contrast_manifest$contrast == contrast_name] <- method
}
}
}
write.table(contrast_manifest, file = file.path(outdir, "manifest", "contrast_status.tsv"), sep = "\t", quote = FALSE, row.names = FALSE)
writeLines(capture.output(sessionInfo()), con = file.path(outdir, "versions", "sessionInfo.txt"))
summary_lines <- c(
paste("Design formula:", design_formula),
paste("Selected method:", method),
paste("Input mode:", input_mode),
"",
"Replicates by condition:"
)
summary_lines <- c(summary_lines, apply(condition_counts, 1, function(x) paste(" -", x[["condition"]], ":", x[["n_replicates"]])))
summary_lines <- c(summary_lines, "", "Contrast status:")
summary_lines <- c(summary_lines, apply(contrast_manifest, 1, function(x) paste(" -", x[["contrast"]], ":", x[["status"]], "| executed:", x[["executed"]])))
if (!is.null(normalization_warning)) {
summary_lines <- c(summary_lines, "", "Warnings:", paste(" -", normalization_warning))
}
writeLines(summary_lines, con = file.path(outdir, "summary.md"))
write_log("finished_at", format(Sys.time(), "%Y-%m-%dT%H:%M:%S%z"))
SHA-256: 775741b0edb03e3e92d82d4e8c8eda713a961791d30aaac9baa33cff8a055c99