log2FoldChange (set by the reference level), padj, and when to shrink with lfcShrink/apeglmairway)Note
Why bulk DESeq2 first?
Note
Why negative binomial, not Poisson?
airway data)library(DESeq2)
library(airway)
data(airway)
dds <- DESeqDataSetFromMatrix(
countData = assay(airway),
colData = colData(airway),
design = ~ cell + dex) # cell line as covariate, dex as test factor
# Pre-filter: drop genes with < 10 total reads (speed only — does not affect FDR)
dds <- dds[ rowSums(counts(dds)) >= 10, ]
# Set the reference level so log2FC sign is interpretable
dds$dex <- relevel(dds$dex, ref = "untrt")
dds <- DESeq(dds)
res <- results(dds, contrast = c("dex", "trt", "untrt"))
summary(res)Tip
relevel() matters.
log2FoldChange.| Column | What it is | Watch-out |
|---|---|---|
baseMean |
mean of normalized counts across all samples | Very low values = unstable estimates |
log2FoldChange |
effect size on log2 scale | Use shrunk values for ranking |
lfcSE |
standard error of the LFC | |
stat |
Wald statistic | |
pvalue |
raw p-value | Don’t filter on this directly |
padj |
BH-adjusted p-value | This is your FDR — filter on padj < 0.05 |
Warning
NA in padj?
padj = NA for genes filtered by independent filtering or with extreme outliers (Cook’s distance).Tip
Why shrink?
apeglm / ashr) pulls those down toward zero — by an amount set by how much information the gene carries (low counts, high dispersion, or few degrees of freedom → more shrinkage)baseMean = 2.Important
Shrinkage is decoupled from the hypothesis test.
pvalue / padj from the unshrunken (MLE) log-fold-changes — shrinkage does not enter the Wald test.Scientific Question: In cell type X, which genes differ between treated and control subjects?
Important
FindMarkers, wilcox, MAST) are fine for exploratory marker discovery; they are not condition-level DE.Pseudobulk is our default, but a second family models each cell while still respecting non-independence:
nebula, glmmTMB, MAST with a random effect) add a per-sample random effect, so the subject remains the unit of replication even though the model fits at cell levelThe literature is genuinely split - some benchmarks favour pseudobulk (Squair 2021), others find well-specified mixed models competitive (Zimmerman 2021; Gagnon 2022).
Tip
Practical stance:
DESeq2 / edgeR / limma-voomlibrary(Seurat)
library(DESeq2)
pb <- AggregateExpression(seu, assays = "RNA",
slot = "counts", # sum raw counts, not log-normalized data
group.by = c("sample_id", "celltype"),
return.seurat = FALSE)$RNA
meta <- data.frame(
sample_id = sub("_.*", "", colnames(pb)),
celltype = sub("^[^_]+_", "", colnames(pb))
) |>
dplyr::left_join(sample_meta, by = "sample_id")
run_de <- function(ct) {
keep <- meta$celltype == ct
dds <- DESeqDataSetFromMatrix(countData = pb[, keep],
colData = meta[keep, ],
design = ~ condition)
dds <- DESeq(dds)
results(dds, contrast = c("condition", "treated", "control")) |>
as.data.frame() |> tibble::rownames_to_column("gene") |>
dplyr::mutate(celltype = ct)
}
de_all <- purrr::map_dfr(unique(meta$celltype), run_de)import decoupler as dc
import pydeseq2.dds as dds_mod
from pydeseq2.ds import DeseqStats
pdata = dc.get_pseudobulk(adata, sample_col="sample_id",
groups_col="celltype",
layer="counts", mode="sum",
min_cells=10, min_counts=1000)
ct = "T_CD4"
mask = pdata.obs["celltype"] == ct
dds = dds_mod.DeseqDataSet(
counts = pdata.layers["counts"][mask].T,
metadata = pdata.obs[mask],
design_factors = "condition")
dds.deseq2()
DeseqStats(dds, contrast=["condition", "treated", "control"]).summary()Warning
Common pitfalls.
< 10 cells before DE; the pseudobulk profile is too noisy.lfcShrink() with apeglm) for ranking.Note
ifnb-specific: the ind column and the g-prefix issue.
ifnb dataset, donor IDs are stored in the metadata column ind (values like "101", "1015", "1016", …).AggregateExpression() pastes group values into column names, a donor ID that starts with a digit produces an invalid R column name; R silently prepends g to make it syntactically valid (g101, g1015, …).donor_id = paste0("d", ind) before aggregation, so all pseudobulk columns start with "d".g101_CD14 Mono, you have hit this issue — just rename the column before running DESeq2.Downstream analysis hub: from annotated object to claims
After DE, summarize gene lists into pathways:
enrichR, clusterProfilerfgsea, clusterProfiler::gseGOdecoupleR, AUCell, AddModuleScorerelevel() your factor before running DESeq — the reference level determines the sign of the log-fold-changelfcShrink(dds, type = "apeglm") — but the pvalue / padj come from the unshrunken estimates (shrinkage is not part of the test since 2016)padj is the FDR — filter on padj < 0.05, not on raw pvalueAggregateExpression, then test each cell type separatelySingle Cell RNA-seq Workshop · Lecture 06