The dataset used here is from the official DESeq2 tutorial and countless RNA-seq workshops, run here with actual raw counts and DESeq2.
Dataset: Himes et al. 2014, PLOS ONE,
“RNA-Seq Transcriptome Profiling Identifies CRISPLD2 as a
Glucocorticoid Responsive Gene that Modulates Cytokine Function in
Airway Smooth Muscle Cells” — GEO
accession GSE52778. Four human airway smooth muscle cell lines, each
treated with dexamethasone (a synthetic glucocorticoid
used to treat asthma) or left untreated — 8 samples, paired by cell
line. This is the same experiment behind Bioconductor’s
airway package that DESeq2’s own vignette uses as its
primary example.
Where this data came from: GEO’s supplementary files
for this series only include FPKM — but the airway
package’s raw gene-level counts have also been re-published as plain CSV
files by the bioconnector.org
teaching materials (airway_rawcounts.csv,
airway_metadata.csv), which is what this notebook loads.
Same 8 samples, same SRA run accessions, same GEO sample IDs — just as
raw integer counts instead of FPKM, which is what DESeq2 actually
needs.
(If you already have the airway Bioconductor package
installed, there’s an even shorter path — please see the note at the
very end.)
counts: rows = genes (Ensembl IDs), columns = samples,
raw integer read counts — the input DESeq2 expects, and the natural
orientation of the CSV, so no transposing needed. meta:
which of the 4 cell lines each sample came from, and whether it was
treated with dexamethasone.
counts <- as.matrix(read.csv("airway_rawcounts.csv", row.names = 1, check.names = FALSE))
meta <- read.csv("airway_metadata.csv", row.names = 1)
meta <- meta[colnames(counts), , drop = FALSE]
cat(sprintf("Count matrix: %d genes x %d samples\n", nrow(counts), ncol(counts)))
## Count matrix: 64102 genes x 8 samples
meta
## dex celltype geo_id
## SRR1039508 control N61311 GSM1275862
## SRR1039509 treated N61311 GSM1275863
## SRR1039512 control N052611 GSM1275866
## SRR1039513 treated N052611 GSM1275867
## SRR1039516 control N080611 GSM1275870
## SRR1039517 treated N080611 GSM1275871
## SRR1039520 control N061011 GSM1275874
## SRR1039521 treated N061011 GSM1275875
#looking at the top 6 count rows
counts[1:6, ]
## SRR1039508 SRR1039509 SRR1039512 SRR1039513 SRR1039516
## ENSG00000000003 679 448 873 408 1138
## ENSG00000000005 0 0 0 0 0
## ENSG00000000419 467 515 621 365 587
## ENSG00000000457 260 211 263 164 245
## ENSG00000000460 60 55 40 35 78
## ENSG00000000938 0 0 2 0 1
## SRR1039517 SRR1039520 SRR1039521
## ENSG00000000003 1047 770 572
## ENSG00000000005 0 0 0
## ENSG00000000419 799 417 508
## ENSG00000000457 331 233 229
## ENSG00000000460 63 76 60
## ENSG00000000938 0 0 0
Same first check as the presentation — how many total reads landed in each sample.
lib_sizes <- colSums(counts)
lib_df <- data.frame(sample = names(lib_sizes), total_counts = lib_sizes, dex = meta$dex)
ggplot(lib_df, aes(x = sample, y = total_counts, fill = dex)) +
geom_col() +
scale_fill_manual(values = c("control" = NAVY, "treated" = ORANGE)) +
labs(y = "Total counts (library size)", x = NULL,
title = "Library size per sample (orange = Dex-treated, navy = control)") +
theme_minimal() +
theme(axis.text.x = element_text(angle = 30, hjust = 1), legend.position = "none")
keep <- rowSums(counts) >= 10
counts_filt <- counts[keep, ]
cat(sprintf("Genes before filtering: %d\n", nrow(counts)))
## Genes before filtering: 64102
cat(sprintf("Genes after filtering: %d\n", nrow(counts_filt)))
## Genes after filtering: 22369
The design formula is ~ celltype + dex rather than just
~ dex — the celltype term tells DESeq2 to
account for baseline differences between the 4 donor cell lines before
estimating the treatment effect.
dds <- DESeqDataSetFromMatrix(
countData = counts_filt,
colData = meta,
design = ~ celltype + dex
)
dds$dex <- relevel(factor(dds$dex), ref = "control")
dds <- DESeq(dds)
plotDispEsts(dds)
res <- results(dds, contrast = c("dex", "treated", "control"))
res <- res[order(res$padj), ]
cat(sprintf("Genes tested: %d\n", nrow(res)))
## Genes tested: 22369
cat(sprintf("padj < 0.05 (genome-wide significant): %d\n", sum(res$padj < 0.05, na.rm = TRUE)))
## padj < 0.05 (genome-wide significant): 4000
head(as.data.frame(res), 10)
## baseMean log2FoldChange lfcSE stat pvalue
## ENSG00000152583 997.4447 4.574967 0.18424142 24.83137 4.110667e-136
## ENSG00000165995 495.0957 3.291099 0.13305274 24.73530 4.463384e-135
## ENSG00000120129 3409.0384 2.947850 0.12187644 24.18720 3.033839e-129
## ENSG00000101347 12703.4128 3.767022 0.15599195 24.14882 7.682657e-129
## ENSG00000189221 2341.7807 3.353655 0.14218099 23.58722 5.212706e-123
## ENSG00000211445 12285.7001 3.730439 0.16639840 22.41872 2.585059e-111
## ENSG00000157214 3009.2729 1.976796 0.09042512 21.86113 6.091348e-106
## ENSG00000162614 5393.1145 2.035697 0.09468470 21.49974 1.565462e-102
## ENSG00000125148 3656.2674 2.211006 0.10609056 20.84074 1.849644e-96
## ENSG00000154734 30315.1132 2.345635 0.11635963 20.15849 2.266757e-90
## padj
## ENSG00000152583 7.412355e-132
## ENSG00000165995 4.024187e-131
## ENSG00000120129 1.823540e-125
## ENSG00000101347 3.463342e-125
## ENSG00000189221 1.879910e-119
## ENSG00000211445 7.768964e-108
## ENSG00000157214 1.569131e-102
## ENSG00000162614 3.528552e-99
## ENSG00000125148 3.705865e-93
## ENSG00000154734 4.087415e-87
A gene-symbol sanity check, the same idea as before: these are well-established glucocorticoid receptor target genes. If a new pipeline doesn’t recover them here, something is wrong with the pipeline, not the biology.
known_glucocorticoid_genes <- c(
ENSG00000096060 = "FKBP5",
ENSG00000101347 = "SAMHD1",
ENSG00000163884 = "KLF15",
ENSG00000157214 = "STEAP2"
)
known_ids <- intersect(names(known_glucocorticoid_genes), rownames(res))
known_df <- as.data.frame(res[known_ids, ])
rownames(known_df) <- known_glucocorticoid_genes[known_ids]
known_df
## baseMean log2FoldChange lfcSE stat pvalue padj
## FKBP5 2564.384 4.046656 0.36977841 10.94346 7.141983e-28 9.005891e-26
## SAMHD1 12703.413 3.767022 0.15599195 24.14882 7.682657e-129 3.463342e-125
## KLF15 561.111 4.459169 0.23479342 18.99188 1.990619e-80 2.243428e-77
## STEAP2 3009.273 1.976796 0.09042512 21.86113 6.091348e-106 1.569131e-102
vsd <- vst(dds, blind = FALSE)
pca <- prcomp(t(assay(vsd)))
pct_var <- round(100 * (pca$sdev^2 / sum(pca$sdev^2)), 1)
pca_df <- data.frame(PC1 = pca$x[, 1], PC2 = pca$x[, 2], meta[rownames(pca$x), ])
ggplot(pca_df, aes(x = PC1, y = PC2, color = dex, label = celltype)) +
geom_point(size = 4) +
geom_text_repel(size = 3, show.legend = FALSE) +
scale_color_manual(values = c("control" = NAVY, "treated" = ORANGE)) +
labs(x = sprintf("PC1 (%.1f%%)", pct_var[1]), y = sprintf("PC2 (%.1f%%)", pct_var[2]),
title = "PCA (VST counts) — cell line labeled") +
theme_minimal()
PC1 separates treatment; PC2 largely separates cell line (you can see
each cell line’s treated/control pair sitting at a similar position on
PC2) — an illustration of exactly what the ~ celltype + dex
design formula is modeling explicitly.
res_plot <- as.data.frame(res)
res_plot$gene_id <- rownames(res_plot)
res_plot$neg_log10_padj <- -log10(pmax(res_plot$padj, 1e-300))
res_plot$group <- "Not significant"
res_plot$group[!is.na(res_plot$padj) & res_plot$padj < 0.05 & res_plot$log2FoldChange > 1] <- "Up with Dex"
res_plot$group[!is.na(res_plot$padj) & res_plot$padj < 0.05 & res_plot$log2FoldChange < -1] <- "Down with Dex"
label_df <- res_plot[res_plot$gene_id %in% names(known_glucocorticoid_genes), ]
label_df$symbol <- known_glucocorticoid_genes[label_df$gene_id]
ggplot(res_plot, aes(x = log2FoldChange, y = neg_log10_padj, color = group)) +
geom_point(alpha = 0.6, size = 1.2) +
geom_text_repel(data = label_df, aes(label = symbol), color = "black",
fontface = "bold", size = 3.2, show.legend = FALSE) +
geom_hline(yintercept = -log10(0.05), linetype = "dashed", color = "grey40") +
scale_color_manual(values = c("Not significant" = GREY, "Up with Dex" = ORANGE, "Down with Dex" = TEAL)) +
labs(x = "log2 fold change (Dex vs. control)", y = "-log10(adjusted p-value)",
title = "Volcano plot — airway smooth muscle cells (real DESeq2)", color = NULL) +
theme_minimal()
top_genes <- rownames(res)[1:20]
vst_mat <- assay(vsd)[top_genes, ]
z_mat <- t(scale(t(vst_mat))) # row z-score
sample_order <- rownames(meta)[order(meta$dex, meta$celltype)]
z_mat <- z_mat[, sample_order]
annotation_col <- data.frame(dex = meta[sample_order, "dex"], row.names = sample_order)
ann_colors <- list(dex = c(control = NAVY, treated = ORANGE))
pheatmap(
z_mat,
cluster_rows = TRUE,
cluster_cols = FALSE,
annotation_col = annotation_col,
annotation_colors = ann_colors,
color = colorRampPalette(c(TEAL, "white", ORANGE))(100),
main = "Top 20 genes by adjusted p-value"
)
write.csv(as.data.frame(res), "airway_DESeq2_DexVsControl_results.csv")
cat("Saved: airway_DESeq2_DexVsControl_results.csv\n")
## Saved: airway_DESeq2_DexVsControl_results.csv
airway
packageSince this is literally the dataset the airway
Bioconductor package ships, you can skip the CSVs and manual data
wrangling entirely by running the code below:
# BiocManager::install("airway")
library(airway)
data(airway)
dds <- DESeqDataSet(airway, design = ~ cell + dex)
dds$dex <- relevel(dds$dex, ref = "untrt")
dds <- DESeq(dds)
res <- results(dds, contrast = c("dex", "trt", "untrt"))
res <- res[order(res$padj), ]
vsd <- vst(dds, blind = FALSE)
plotPCA(vsd, intgroup = c("dex", "cell"))
airway’s own column names differ slightly
(cell instead of celltype,
trt/untrt instead of
treated/control) but everything else — design
formula, workflow, expected results — is identical to what this document
just ran from the CSVs.