DESeq2 Differential Expression Analysis (R/Bioconductor)
Overview
DESeq2 is the Bioconductor R package for differential gene expression analysis from bulk RNA-seq count data. It fits a negative binomial generalized linear model per gene, estimates dispersion parameters using empirical Bayes shrinkage across genes, and tests differential expression using Wald tests (two-group) or likelihood ratio tests (complex designs). DESeq2 is the R gold standard for RNA-seq DE analysis, with native Bioconductor integration for seamless import from Salmon (tximeta/tximport), featureCounts, or HTSeq.
When to Use
- Identifying differentially expressed genes between two experimental conditions (treated vs. control, disease vs. healthy) from bulk RNA-seq count data
- Analyzing multi-factor designs that account for batch effects or covariates (e.g.,
~ batch + condition) - Testing complex hypotheses with interaction terms (e.g., time × treatment) or reduced models using likelihood ratio tests (LRT)
- Importing Salmon pseudoalignment output via tximeta or tximport for transcript-level uncertainty propagation
- Performing LFC shrinkage with apeglm for ranked gene lists, volcano plots, and downstream pathway analysis
- Conducting time-series experiments or any design with more than two levels requiring model comparison
- Working in an R/Bioconductor ecosystem where integration with SummarizedExperiment, clusterProfiler, or EnhancedVolcano is needed
- Use pydeseq2-differential-expression instead for Python-based pipelines with the same statistical model
- Use edgeR for negative binomial DE with TMM normalization, quasi-likelihood F-tests, or TREAT testing
- Use gseapy-gene-enrichment after DE to interpret results at the pathway level
Prerequisites
- R packages:
DESeq2(Bioconductor),tximetaortximport(Salmon import),apeglm(LFC shrinkage),pheatmap,ggplot2,EnhancedVolcano - Data requirements: Raw (unnormalized) integer count matrix — gene rows × sample columns — plus a sample metadata data frame with matching column names. If using Salmon: per-sample
quant.sffiles and a transcript-to-gene mapping - Environment: R ≥ 4.2, Bioconductor ≥ 3.16
# Install Bioconductor packages
if (!require("BiocManager", quietly = TRUE))
install.packages("BiocManager")
BiocManager::install(c("DESeq2", "tximeta", "tximport", "apeglm",
"EnhancedVolcano"))
install.packages(c("pheatmap", "ggplot2", "dplyr"))
Quick Start
Complete two-group comparison from a count matrix in under 20 lines.
library(DESeq2)
# Load count matrix (genes x samples) and metadata
counts <- as.matrix(read.csv("counts.csv", row.names = 1))
coldata <- read.csv("metadata.csv", row.names = 1)
coldata$condition <- factor(coldata$condition)
# Build DESeqDataSet, run full pipeline
dds <- DESeqDataSetFromMatrix(countData = counts,
colData = coldata,
design = ~ condition)
dds <- dds[rowSums(counts(dds)) >= 10, ] # pre-filter
dds <- DESeq(dds)
# Extract results (treated vs. control)
res <- results(dds, contrast = c("condition", "treated", "control"),
alpha = 0.05)
summary(res)
# LFC shrinkage for visualization
res_shrunk <- lfcShrink(dds, contrast = c("condition", "treated", "control"),
type = "apeglm", coef = 2)
Workflow
Step 1: Prepare Count Matrix and Sample Metadata
Build a DESeqDataSet from a gene × sample count matrix and a colData data frame. Column names of the count matrix must match row names of colData.
library(DESeq2)
library(dplyr)
# Load raw count matrix (genes as rows, samples as columns)
counts <- as.matrix(read.csv("featureCounts_matrix.csv", row.names = 1))
# Or from featureCounts tab-delimited output — skip first comment line
# fc <- read.table("featurecounts_output.txt", header = TRUE, skip = 1, row.names = 1)
# counts <- as.matrix(fc[, 7:ncol(fc)]) # columns 7+ are sample counts
# Load sample metadata
coldata <- data.frame(
condition = factor(c("control", "control", "control",
"treated", "treated", "treated")),
batch = factor(c("A", "A", "B", "A", "B", "B")),
row.names = colnames(counts)
)
# Build DESeqDataSet
dds <- DESeqDataSetFromMatrix(countData = counts,
colData = coldata,
design = ~ condition)
cat("Samples:", ncol(dds), "\n")
cat("Genes:", nrow(dds), "\n")
cat("Condition levels:", levels(coldata$condition), "\n")
Step 2: Import from Salmon via tximeta
When reads were quantified with Salmon, use tximeta to import transcript-level estimates with proper offset correction that accounts for transcript length and GC bias.
library(tximeta)
# Build a coldata with a "files" column pointing to quant.sf files
quant_dirs <- file.path("salmon_output", coldata$sample_id, "quant.sf")
coldata$files <- quant_dirs
coldata$names <- coldata$sample_id
# Import with tximeta — automatically fetches transcript metadata from Ensembl
se <- tximeta(coldata) # SummarizedExperiment with transcript-level data
# Summarize to gene level (requires Ensembl or custom txdb)
gse <- summarizeToGene(se) # gene-level SummarizedExperiment
# Build DESeqDataSet from the SummarizedExperiment
dds <- DESeqDataSet(gse, design = ~ condition)
cat("Gene-level SE dimensions:", dim(gse), "\n")
cat("Assay names:", assayNames(gse), "\n")
# Assay "counts" = estimated counts; "abundance" = TPM; "length" = eff. lengths
Step 3: Pre-Filtering and Quality Control
Remove genes with very low counts to improve statistical power and reduce the multiple testing burden. Explore sample quality with PCA on variance-stabilized counts.
library(ggplot2)
# Pre-filter: keep genes with at least 10 reads total
keep <- rowSums(counts(dds)) >= 10
dds <- dds[keep, ]
cat("Genes after pre-filtering:", nrow(dds), "\n")
# Variance-stabilizing transformation for QC visualization
vsd <- vst(dds, blind = TRUE) # blind = TRUE for exploratory QC
# PCA plot
pca_data <- plotPCA(vsd, intgroup = c("condition", "batch"), returnData = TRUE)
percent_var <- round(100 * attr(pca_data, "percentVar"))
ggplot(pca_data, aes(PC1, PC2, color = condition, shape = batch)) +
geom_point(size = 3) +
xlab(paste0("PC1: ", percent_var[1], "% variance")) +
ylab(paste0("PC2: ", percent_var[2], "% variance")) +
ggtitle("PCA — Variance-Stabilized Counts") +
theme_bw()
ggsave("pca_plot.pdf", width = 6, height = 5)
cat("Saved pca_plot.pdf\n")
Step 4: Run DESeq() — Normalization, Dispersion, and Model Fitting
DESeq() runs three sequential steps: (1) median-of-ratios size factor estimation, (2) gene-wise and shrunken dispersion estimation, (3) negative binomial GLM fitting and Wald statistics.
# Run full DESeq2 pipeline
dds <- DESeq(dds)
# Inspect size factors (library normalization)
cat("Size factors:\n")
print(sizeFactors(dds))
# Expected range: 0.3–3.0; outliers indicate library quality issues
# Dispersion plot — should show decreasing dispersion trend with mean
plotDispEsts(dds, main = "Dispersion Estimates")
# Inspect fitted model coefficients
resultsNames(dds)
# [1] "Intercept" "condition_treated_vs_control"
Step 5: Extract Results and Apply FDR Correction
Use results() to extract Wald test statistics. Specify the contrast explicitly for clarity. Independent filtering maximizes the number of detectable genes.
# Extract results for the contrast of interest
res <- results(dds,
contrast = c("condition", "treated", "control"),
alpha = 0.05, # FDR threshold for independent filtering
lfcThreshold = 0, # Test H0: |LFC| = 0 (Wald test)
independentFilter = TRUE) # Maximize power via adaptive filtering
# Summary: how many genes are up/down/NA
summary(res)
#