HUB (Hands-on
Understanding of Bioinformatics) Workshop
AI-Assisted Bioinformatics
by
Bioinformatics Hub
vst()) of DESeq, and later DESeq2, is derived from the fitted dispersion–mean relationship of a negative binomial model after size-factor normalization, and the DESeq2 documentation cites the vsn work (Huber et al., 2003) among the sources of its variance-stabilizing transformation.
vsn2() estimates a separate offset and scale for every sample from the data, whereas vst() rescales samples by their size factors and applies one transformation derived from the common dispersion trend. This distinction determines how zero counts are represented on the transformed scale and is the central focus of this report.vst() from the DESeq2 package and vsn2() from the vsn package map zero counts in bulk RNA-seq data onto their transformed scalesvsn2() values — and of a Welch t-test applied to the values from both transformations
summarizeOverlaps (union mode, strand ignored)vsn2() values used limma (v3.64.3)group_order fixes the order of the two treatment groups in tables and figures, and group_cols their coloursFDR is the adjusted p-value threshold applied to all four routes, and sig() marks a gene as significant when its adjusted p-value is below itsuppressPackageStartupMessages({
library(airway)
library(SummarizedExperiment)
library(DESeq2)
library(vsn)
library(limma)
library(ggplot2)
library(ggVennDiagram)
})
# Treatment groups, in plotting order, and their colours
group_order <- c("Untreated", "Dexamethasone")
group_cols <- c(Untreated = "#2a78d6", Dexamethasone = "#eb6834")
# Significance threshold (adjusted p-value), applied identically to all four routes
FDR <- 0.05
# A gene is significant when its adjusted p-value is below FDR; NA counts as not significant
sig <- function(p) !is.na(p) & p < FDR
counts holds one row per Ensembl gene and one column per librarysamples records the cell line and treatment group of each librarydata(airway, package = "airway")
counts <- assay(airway, "counts")
samples <- data.frame(
sample = colnames(airway),
cell = as.character(airway$cell),
group = ifelse(as.character(airway$dex) == "trt", "Dexamethasone", "Untreated")
)
group <- factor(samples$group, levels = group_order)
names(group) <- samples$sample
dim(counts)
## [1] 63677 8
head(counts)
## 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
dim(samples)
## [1] 8 3
head(samples)
| sample | cell | group |
|---|---|---|
| SRR1039508 | N61311 | Untreated |
| SRR1039509 | N61311 | Dexamethasone |
| SRR1039512 | N052611 | Untreated |
| SRR1039513 | N052611 | Dexamethasone |
| SRR1039516 | N080611 | Untreated |
| SRR1039517 | N080611 | Dexamethasone |
keep <- rowSums(counts) > 0
filtered <- counts[keep, ]
dim(filtered)
## [1] 33469 8
zero_samples <- data.frame(
Sample = colnames(filtered),
Group = group[colnames(filtered)],
`Library size` = colSums(filtered),
Zeros = colSums(filtered == 0),
`Zero fraction (%)` = round(100 * colMeans(filtered == 0), 1),
check.names = FALSE
)
zero_samples
| Sample | Group | Library size | Zeros | Zero fraction (%) |
|---|---|---|---|---|
| SRR1039508 | Untreated | 20,637,971 | 8,836 | 26.4 |
| SRR1039509 | Dexamethasone | 18,809,481 | 8,942 | 26.7 |
| SRR1039512 | Untreated | 25,348,649 | 7,770 | 23.2 |
| SRR1039513 | Dexamethasone | 15,163,415 | 10,345 | 30.9 |
| SRR1039516 | Untreated | 24,448,408 | 7,961 | 23.8 |
| SRR1039517 | Dexamethasone | 30,818,215 | 7,471 | 22.3 |
| SRR1039520 | Untreated | 19,126,151 | 8,807 | 26.3 |
| SRR1039521 | Dexamethasone | 21,164,133 | 9,478 | 28.3 |
zeros_untrt <- rowSums(filtered[, group == group_order[1]] == 0)
zeros_dex <- rowSums(filtered[, group == group_order[2]] == 0)
zero_pattern <- ifelse(zeros_untrt + zeros_dex == 0, "No zeros",
ifelse(zeros_untrt > 0 & zeros_dex > 0, "Zeros in both groups",
ifelse(zeros_untrt > 0, "Zeros in Untreated only", "Zeros in Dexamethasone only")))
zero_pattern <- factor(zero_pattern, levels = c("No zeros", "Zeros in both groups",
"Zeros in Untreated only", "Zeros in Dexamethasone only"))
zero_pattern_counts <- data.frame(`Zero pattern` = levels(zero_pattern), Genes = as.vector(table(zero_pattern)),
check.names = FALSE)
zero_pattern_counts
| Zero pattern | Genes |
|---|---|
| No zeros | 18,870 |
| Zeros in both groups | 11,993 |
| Zeros in Untreated only | 914 |
| Zeros in Dexamethasone only | 1,692 |
blind = TRUE ignores the group labels when estimating dispersions; this is the DESeq2 default and its recommendation for quality assessment~ group is recorded in the object only because the same object is later used for testing; it does not affect the blind transformationvsn2() was run with its default settings on the filtered raw counts; the calibrated and transformed values are read from the hx slotdds <- DESeqDataSetFromMatrix(filtered, data.frame(group = group, row.names = names(group)), ~group)
vst_obj <- vst(dds, blind = TRUE)
vst_mat <- assay(vst_obj)
head(vst_mat)
## SRR1039508 SRR1039509 SRR1039512 SRR1039513 SRR1039516
## ENSG00000000003 9.463462 9.083112 9.612635 9.347635 9.978952
## ENSG00000000419 8.961712 9.269663 9.152274 9.197839 9.079201
## ENSG00000000457 8.208122 8.114127 8.047259 8.162081 7.962763
## ENSG00000000460 6.601433 6.643869 6.141319 6.497653 6.715452
## ENSG00000000938 4.436956 4.436956 4.839310 4.436956 4.722142
## ENSG00000000971 11.652429 12.018364 12.366393 12.641376 12.489412
## SRR1039517 SRR1039520 SRR1039521
## ENSG00000000003 9.627714 9.779777 9.340292
## ENSG00000000419 9.261302 8.952013 9.180790
## ENSG00000000457 8.120108 8.203470 8.150363
## ENSG00000000460 6.369401 6.930898 6.676050
## ENSG00000000938 4.436956 4.436956 4.436956
## ENSG00000000971 12.952216 12.467750 13.054593
vsn_fit <- vsn2(filtered)
vsn_mat <- vsn_fit@hx
head(vsn_mat)
## SRR1039508 SRR1039509 SRR1039512 SRR1039513 SRR1039516
## ENSG00000000003 9.3877132 8.9815582 9.522508 9.2727246 9.917072
## ENSG00000000419 8.8492157 9.1820610 9.032284 9.1124923 8.964068
## ENSG00000000457 8.0081012 7.9002227 7.798232 7.9634031 7.709425
## ENSG00000000460 5.9208581 5.9865813 5.133182 5.7688873 6.079794
## ENSG00000000938 0.6111713 0.6216318 1.653383 0.6817389 1.216593
## ENSG00000000971 11.6445102 12.0154425 12.342893 12.6508412 12.477417
## SRR1039517 SRR1039520 SRR1039521
## ENSG00000000003 9.5746013 9.6889425 9.2702746
## ENSG00000000419 9.1854709 8.8063566 9.0995492
## ENSG00000000457 7.9192514 7.9704640 7.9550810
## ENSG00000000460 5.5626693 6.3719791 6.0482064
## ENSG00000000938 0.5581231 0.5909049 0.6492472
## ENSG00000000971 12.9687869 12.4356088 13.0718819
vst(), every zero count was mapped to the same transformed value, 4.437, in all 8 samples. This occurs because size-factor normalization leaves a zero unchanged, whereas non-zero counts are rescaled according to sample-specific size factors, which ranged from 0.67 to 1.40.vst_zero_values <- data.frame(
Sample = colnames(filtered),
`Size factor` = round(vst_obj$sizeFactor, 3),
`Value of 0` = sapply(colnames(filtered), function(j) round(unique(vst_mat[filtered[, j] == 0, j]), 3)),
check.names = FALSE
)
vst_zero_values
| Sample | Size factor | Value of 0 |
|---|---|---|
| SRR1039508 | 1.024 | 4.437 |
| SRR1039509 | 0.896 | 4.437 |
| SRR1039512 | 1.179 | 4.437 |
| SRR1039513 | 0.670 | 4.437 |
| SRR1039516 | 1.178 | 4.437 |
| SRR1039517 | 1.399 | 4.437 |
| SRR1039520 | 0.921 | 4.437 |
| SRR1039521 | 0.945 | 4.437 |
vsn2(), zero counts were mapped to values ranging from 0.551 to 0.682. All zeros within a given sample received the same value, whereas the value differed among samples because vsn2() estimates a sample-specific offset and scale before transformation.vsn_zero_values <- data.frame(
Sample = colnames(filtered),
Offset = round(coef(vsn_fit)[1, , 1], 3),
Scale = round(coef(vsn_fit)[1, , 2], 3),
`Value of 0` = sapply(colnames(filtered), function(j) round(unique(round(vsn_mat[filtered[, j] == 0, j], 6)), 3)),
check.names = FALSE
)
vsn_zero_values
| Sample | Offset | Scale | Value of 0 |
|---|---|---|---|
| SRR1039508 | 18.005 | 2.452 | 0.611 |
| SRR1039509 | 18.137 | 2.586 | 0.622 |
| SRR1039512 | 17.265 | 2.294 | 0.551 |
| SRR1039513 | 18.909 | 2.881 | 0.682 |
| SRR1039516 | 17.399 | 2.303 | 0.562 |
| SRR1039517 | 17.354 | 2.149 | 0.558 |
| SRR1039520 | 17.754 | 2.535 | 0.591 |
| SRR1039521 | 18.488 | 2.542 | 0.649 |
vst() distribution occurs at 4.437, whereas the corresponding vsn2() values range from 0.551 to 0.682. These positions reflect the transformed values assigned to zero counts; numerically, the vst() zero value is approximately 3.8 units higher on the displayed scale.vst() values (first tab), the vsn2() values (second tab) and the raw counts on a log2(count + 1) scale (third tab)to_long <- function(m) {
data.frame(
sample = factor(rep(colnames(m), each = nrow(m)), levels = sample_order),
group = rep(group[colnames(m)], each = nrow(m)),
value = as.vector(m)
)
}
sample_order <- names(group)[order(group)]
raw_log2 <- log2(filtered + 1)
value_limits <- range(c(vst_mat, vsn_mat, raw_log2))
# per-gene mean and standard deviation on each scale, used in the mean-variance panel
mean_sd <- rbind(
data.frame(scale = "Raw", mean = log2(rowMeans(filtered) + 1), sd = log2(apply(filtered, 1, sd) + 1)),
data.frame(scale = "vsn", mean = rowMeans(vsn_mat), sd = apply(vsn_mat, 1, sd)),
data.frame(scale = "vst", mean = rowMeans(vst_mat), sd = apply(vst_mat, 1, sd))
)
# drawing order: raw first, vsn last (on top)
mean_sd$scale <- factor(mean_sd$scale, levels = c("Raw", "vst", "vsn"))
scale_cols <- c(Raw = "grey20", vst = "#c51b8a", vsn = "#0b8fb3")
msd_rho <- sapply(split(mean_sd, mean_sd$scale), function(d) cor(d$mean, d$sd, method = "spearman"))
msd_sd_range <- lapply(split(mean_sd$sd, mean_sd$scale), range)
theme_report <- theme_bw(base_size = 11) +
theme(panel.grid.major = element_blank(), panel.grid.minor = element_blank(),
legend.position = "top")
box_plot <- function(m, ylab) {
ggplot(to_long(m), aes(x = sample, y = value, fill = group)) +
geom_boxplot(colour = "grey30", linewidth = 0.3, alpha = 0.7,
outlier.size = 0.3, outlier.alpha = 0.3) +
scale_fill_manual(values = group_cols, name = NULL) +
scale_y_continuous(limits = value_limits) +
labs(x = "Sample", y = ylab) +
theme_report +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
}
dens_plot <- function(m, xlab) {
ggplot(to_long(m), aes(x = value, group = sample, colour = group)) +
geom_density(linewidth = 0.5) +
scale_colour_manual(values = group_cols, name = NULL) +
scale_x_continuous(limits = value_limits) +
labs(x = xlab, y = "Density") +
theme_report
}
box_plot(vst_mat, "vst (log2 scale)")
box_plot(vsn_mat, "vsn (glog2 scale)")
box_plot(raw_log2, "log2(count + 1)")
vst() and 0.6 for vsn2(), compared with 0 on the log2(count + 1) scale. Non-zero counts form a broader distribution at higher transformed values.vst() values (first tab), the vsn2() values (second tab) and the raw counts on a log2(count + 1) scale (third tab)dens_plot(vst_mat, "vst (log2 scale)")
dens_plot(vsn_mat, "vsn (glog2 scale)")
dens_plot(raw_log2, "log2(count + 1)")
vst() and 0.027–5.06 for vsn2(), with substantially weaker mean–variance associations (Spearman rho = 0.354 and -0.223, respectively).vst() scale (magenta), the vsn2() scale (cyan) and for the raw counts (grey, as in 2.2)vst() and vsn2() values are shown as they arevst(), then vsn2(), so where they overlap vsn2() is on topggplot(mean_sd, aes(x = mean, y = sd, colour = scale)) +
geom_point(size = 0.3, alpha = 0.15) +
scale_colour_manual(values = scale_cols, labels = c("Raw count", "vst", "vsn"), name = NULL) +
guides(colour = guide_legend(override.aes = list(size = 3, alpha = 1))) +
labs(x = "Mean across libraries", y = "Standard deviation across libraries") +
theme_report
dds <- DESeq(dds, quiet = TRUE)
res <- results(dds, alpha = FDR)
filter_threshold <- metadata(res)$filterThreshold
welch_p <- function(m) {
apply(m, 1, function(r) {
a <- r[group == group_order[1]]
b <- r[group == group_order[2]]
if (var(a) == 0 && var(b) == 0) return(NA_real_)
t.test(a, b)$p.value
})
}
padj_vst_welch <- p.adjust(welch_p(vst_mat), "BH")
padj_vsn_welch <- p.adjust(welch_p(vsn_mat), "BH")
design <- model.matrix(~group)
fit <- eBayes(lmFit(vsn_mat, design))
lim <- topTable(fit, coef = 2, number = Inf, sort.by = "none")
padj_vsn_limma <- lim$adj.P.Val
head(as.data.frame(res))
| baseMean | log2FoldChange | lfcSE | stat | pvalue | padj | |
|---|---|---|---|---|---|---|
| ENSG00000000003 | 708.6021697 | -0.3788467 | 0.1731411 | -2.1880804 | 0.0286637 | 0.1387715 |
| ENSG00000000419 | 520.2979006 | 0.2037605 | 0.1005987 | 2.0254789 | 0.0428182 | 0.1826292 |
| ENSG00000000457 | 237.1630368 | 0.0340429 | 0.1262790 | 0.2695852 | 0.7874794 | 0.9301523 |
| ENSG00000000460 | 57.9326331 | -0.1171781 | 0.3012365 | -0.3889904 | 0.6972833 | 0.8945814 |
| ENSG00000000938 | 0.3180984 | -1.7245689 | 3.4936334 | -0.4936319 | 0.6215662 | NA |
| ENSG00000000971 | 5,817.3528677 | 0.4409598 | 0.2589239 | 1.7030477 | 0.0885591 | 0.2981093 |
head(lim)
| logFC | AveExpr | t | P.Value | adj.P.Val | B | |
|---|---|---|---|---|---|---|
| ENSG00000000003 | -0.3542690 | 9.4519242 | -2.0174093 | 0.0772206 | 0.4195431 | -4.783590 |
| ENSG00000000419 | 0.2319123 | 9.0289372 | 2.0276322 | 0.0759918 | 0.4160592 | -4.768504 |
| ENSG00000000457 | 0.0629340 | 7.9030226 | 0.5216676 | 0.6155841 | 0.8278613 | -6.449809 |
| ENSG00000000460 | -0.0348674 | 5.8590198 | -0.1313079 | 0.8986707 | 0.9590769 | -6.587618 |
| ENSG00000000938 | -0.3903278 | 0.8228492 | -1.6056575 | 0.1457905 | 0.5564550 | -5.365638 |
| ENSG00000000971 | 0.4516308 | 12.4509227 | 1.6008029 | 0.1468625 | 0.5584779 | -5.372135 |
Summary: DESeq2 called 2,700 genes differentially expressed at adjusted p < 0.05. Its independent filtering gave 14,924 genes with a mean normalized count below 3.743 a nominal p-value but no adjusted p-value, and its count-outlier detection (Cook’s distance) gave a further 51 genes neither; 14,975 genes in all have an adjusted p of NA.
The DESeq2 authors state that differential expression is tested on the raw counts, and that transformed values such as vst() are for other downstream uses, e.g. visualization or clustering (quality control), not for testing:
In order to test for differential expression, we operate on raw counts and use discrete distributions … However for other downstream analyses – e.g. for visualization or clustering – it might be useful to work with transformed versions of the count data.
The table breaks the 33,469 genes into significant, tested but not significant, and adjusted p-value = NA
results()): genes with a low mean normalized count have a nominal p-value but an adjusted p-value of NA, because DESeq2 leaves them out of the Benjamini–Hochberg adjustmentdeseq2_outcome <- data.frame(
`DESeq2 outcome` = c("Significant", "Tested, not significant", "Adjusted *p*-value = NA"),
Genes = c(sum(sig(res$padj)), sum(!is.na(res$padj) & !sig(res$padj)), sum(is.na(res$padj))),
check.names = FALSE
)
deseq2_outcome
| DESeq2 outcome | Genes |
|---|---|
| Significant | 2,700 |
| Tested, not significant | 15,794 |
| Adjusted p-value = NA | 14,975 |
vst() values and 203 on vsn2() values. On the vsn2() values, limma called 864.vst() and vsn2() differ in their treatment of zeros and mean–variance dependence. Applying the same Welch t-test to both profiles isolates differences associated with the transformed data representation while holding the test procedure constant. The 2,700 genes called by DESeq2 on the raw counts (3.6.1) are shown alongsidevsn2() values are analyzed using the Welch t-test and limma. This comparison holds the transformed data profile constant and therefore highlights differences associated with the statistical testing procedure.vst() values — size-factor scaling followed by a transformation derived from the fitted dispersion–mean relationship; every zero is mapped to the same valuevsn2() values — a per-sample offset and scale followed by a glog2 transformation; zeros are mapped to a different value in each sampleroute_counts <- data.frame(
Route = c("Welch, vst", "Welch, vsn", "limma, vsn"),
Significant = c(sum(sig(padj_vst_welch)), sum(sig(padj_vsn_welch)), sum(sig(padj_vsn_limma)))
)
route_counts
| Route | Significant |
|---|---|
| Welch, vst | 223 |
| Welch, vsn | 203 |
| limma, vsn | 864 |
vst() values only and 24 on vsn2() values only. Of the genes called on either profile, 230 were also called by DESeq2.vst() values, magenta), vsn (Welch t-test on vsn2() values, cyan) and DESeq2 (DESeq2 on the raw counts, 3.6.1, grey)gene_ids <- rownames(filtered)
venn_plot <- function(sets, cols, ...) {
ggVennDiagram(sets, label = "count", label_alpha = 0, set_color = cols, edge_size = 1, ...) +
scale_fill_gradient(low = "white", high = "grey80") +
scale_x_continuous(expand = expansion(mult = 0.15)) +
theme(legend.position = "none")
}
venn_plot(list(vst = gene_ids[sig(padj_vst_welch)],
vsn = gene_ids[sig(padj_vsn_welch)],
DESeq2 = rownames(res)[sig(res$padj)]),
cols = c("#c51b8a", "#0b8fb3", "grey20"))
vsn2() values, 155 genes were called by both tests, 709 by limma only and 48 by the Welch t-test only.vsn2() values: Welch (cyan) and limma (dark cyan)venn_plot(list(Welch = gene_ids[sig(padj_vsn_welch)],
limma = gene_ids[sig(padj_vsn_limma)]),
cols = c("#0b8fb3", "#05506a"), shape_id = "201")
tab <- data.frame(
gene_id = rownames(filtered),
zeros_untrt = zeros_untrt,
zeros_dex = zeros_dex,
max_count = apply(filtered, 1, max),
baseMean = res$baseMean,
log2FC_deseq2 = res$log2FoldChange,
padj_deseq2 = res$padj,
padj_vst_welch = padj_vst_welch,
padj_vsn_welch = padj_vsn_welch,
padj_vsn_limma = padj_vsn_limma,
filtered,
check.names = FALSE
)
# significance calls of the four routes, one column per route
routes <- c(`DESeq2, raw counts` = "padj_deseq2", `Welch, vst` = "padj_vst_welch",
`Welch, vsn` = "padj_vsn_welch", `limma, vsn` = "padj_vsn_limma")
sig_mat <- sapply(routes, function(r) sig(tab[[r]]))
# gene-level flags: zeros present, and zeros in one group only
has_zero <- (tab$zeros_untrt + tab$zeros_dex) > 0
one_sided <- xor(tab$zeros_untrt > 0, tab$zeros_dex > 0)
deseq2_sig <- sig(tab$padj_deseq2)
limma_sig <- sig(tab$padj_vsn_limma)
call_class <- ifelse(deseq2_sig & limma_sig, "Both", ifelse(limma_sig, "limma only", ifelse(deseq2_sig, "DESeq2 only", "Neither")))
pick_top <- function(idx) tab$gene_id[idx][which.max(tab$baseMean[idx])]
examples <- c(pick_top(has_zero & call_class == "limma only"), pick_top(has_zero & call_class == "DESeq2 only"))
vsn2()-transformed values. On the same 33,469 genes, DESeq2 called 2,700 genes and limma 864; 743 were called by both, 1,957 by DESeq2 only and 121 by limma only.vsn2() values (3.6.2) — a linear model with empirical Bayes variance moderation on per-sample calibrated, glog2-transformed values; every gene receives an adjusted p-value~ group, the Benjamini–Hochberg adjustment and adjusted p < 0.05vsn2() values, dark cyan); the remaining 30,648 genes were called by neithervenn_plot(list(DESeq2 = gene_ids[deseq2_sig],
limma = gene_ids[limma_sig]),
cols = c("grey20", "#05506a"), shape_id = "201")
vst() and vsn2() scales. The limma-only gene has at most 8 reads in any sample and no DESeq2 adjusted p-value (limma 0.018); the DESeq2-only gene reaches 180 reads (DESeq2 0.002, limma 0.077).vst() values and the vsn2() values; each point is one library, coloured by treatment groupvsn2() scale, the four Untreated values are relatively tightly clustered; limma reports an adjusted p = 0.018.vsn2() scale, both groups show comparatively broad within-group dispersion, and limma does not meet the specified significance threshold (adjusted p = 0.077).vst() values calls neither gene (adjusted p = 0.21 and 0.24)example_labels <- paste0(examples, "\n", c("limma only", "DESeq2 only"))
example_long <- do.call(rbind, lapply(seq_along(examples), function(i) {
g <- examples[i]
data.frame(gene = example_labels[i],
scale = rep(c("Raw count", "vst", "vsn"), each = ncol(filtered)),
group = rep(group[colnames(filtered)], 3),
value = c(filtered[g, ], vst_mat[g, ], vsn_mat[g, ]))
}))
example_long$gene <- factor(example_long$gene, levels = example_labels)
example_long$scale <- factor(example_long$scale, levels = c("Raw count", "vst", "vsn"))
# adjusted p-value of the test run on each scale
p_fmt <- function(p) ifelse(is.na(p), "NA", format(signif(p, 2)))
example_p <- do.call(rbind, lapply(seq_along(examples), function(i) {
k <- match(examples[i], tab$gene_id)
data.frame(gene = example_labels[i],
scale = c("Raw count", "vst", "vsn"),
label = c(paste0("DESeq2~adjusted~italic(p)=='", p_fmt(tab$padj_deseq2[k]), "'"),
paste0("Welch~adjusted~italic(p)=='", p_fmt(tab$padj_vst_welch[k]), "'"),
paste0("limma~adjusted~italic(p)=='", p_fmt(tab$padj_vsn_limma[k]), "'")))
}))
example_p$gene <- factor(example_p$gene, levels = example_labels)
example_p$scale <- factor(example_p$scale, levels = c("Raw count", "vst", "vsn"))
ggplot(example_long, aes(x = group, y = value, colour = group)) +
geom_point(position = position_jitter(width = 0.15, height = 0, seed = 1), size = 2.2) +
geom_text(data = example_p, aes(x = 1.5, y = Inf, label = label), inherit.aes = FALSE,
parse = TRUE, vjust = 1.5, size = 3) +
facet_grid(scale ~ gene, scales = "free_y") +
scale_y_continuous(expand = expansion(mult = c(0.05, 0.25))) +
scale_colour_manual(values = group_cols, name = NULL) +
labs(x = NULL, y = "Value") +
theme_bw(base_size = 10) +
theme(panel.grid.major = element_blank(), panel.grid.minor = element_blank(),
legend.position = "top", axis.text.x = element_blank(), axis.ticks.x = element_blank())
deseq2_status <- ifelse(is.na(res$pvalue), "Count outlier",
ifelse(is.na(res$padj), "Independent filtering",
ifelse(deseq2_sig, "Significant", "Tested, not significant")))
class_levels <- c("Both", "DESeq2 only", "limma only", "Neither")
class_counts <- data.frame(Class = class_levels,
`DESeq2, raw counts` = c("Called", "Called", "Not called", "Not called"),
`limma, vsn` = c("Called", "Not called", "Called", "Not called"),
`All genes` = as.vector(table(factor(call_class, levels = class_levels))),
`Zero-containing genes` = as.vector(table(factor(call_class[has_zero], levels = class_levels))),
check.names = FALSE)
class_counts
| Class | DESeq2, raw counts | limma, vsn | All genes | Zero-containing genes |
|---|---|---|---|---|
| Both | Called | Called | 743 | 32 |
| DESeq2 only | Called | Not called | 1,957 | 32 |
| limma only | Not called | Called | 121 | 88 |
| Neither | Not called | Not called | 30,648 | 14,447 |
class_summary <- do.call(rbind, lapply(c("limma only", "DESeq2 only", "Both"), function(k) {
i <- has_zero & call_class == k
data.frame(Class = k, Genes = sum(i),
`Removed by DESeq2 independent filtering` = sum(deseq2_status[i] == "Independent filtering"),
`Maximum raw count` = max(tab$max_count[i]), `Median of per-gene maximum raw count` = median(tab$max_count[i]),
`Zeros confined to one group` = sum(one_sided[i]), check.names = FALSE)
}))
class_summary
| Class | Genes | Removed by DESeq2 independent filtering | Maximum raw count | Median of per-gene maximum raw count | Zeros confined to one group |
|---|---|---|---|---|---|
| limma only | 88 | 88 | 12 | 3.0 | 88 |
| DESeq2 only | 32 | 0 | 180 | 27.5 | 31 |
| Both | 32 | 0 | 372 | 18.0 | 32 |
vsn2() values, agreed on 743 genes, while 1,957 were called by DESeq2 only and 121 by limma only.vst() maps every zero to 4.437, whereas vsn2() places zeros at a different value in each sample (0.551–0.682), so the same zero enters a test differently on each scale.results() applies independent filtering: genes with too few reads to be tested with useful power are left out of the multiple-testing adjustment. This is the use its authors intend. Love et al. (2014)vst() values — the DESeq2 authors intend vst() values for visualization and clustering and state that differential expression is tested on the raw counts, so this route applies vst() outside its authors’ intended usevsn2() values — vsn2() calibrates each sample with its own offset and scale and applies a glog2 transformation. The vsn method was first developed for microarray intensity data and can be applied to RNA-seq counts, but it treats values as continuous intensities rather than modelling their discrete count nature, including zeros. Its documentation pairs the transformed values with limma, so a Welch test is outside the testing route its authors describevsn2() values — fits a linear model per gene and shrinks gene-wise variances towards a common value (empirical Bayes moderation). This is the testing route the vsn documentation describes. Ritchie et al. (2015)sessioninfo::session_info(pkgs = "attached")
## ─ Session info ───────────────────────────────────────────────────────────────
## setting value
## version R version 4.5.1 (2025-06-13)
## os macOS Tahoe 26.6.2
## system aarch64, darwin20
## ui X11
## language (EN)
## collate en_CA.UTF-8
## ctype en_CA.UTF-8
##
## ─ Packages ───────────────────────────────────────────────────────────────────
## package * version date (UTC) lib source
## airway * 1.28.0 2025-04-17 [1] Bioconductor 3.21 (R 4.5.1)
## Biobase * 2.68.0 2025-04-15 [1] Bioconductor 3.21 (R 4.5.0)
## BiocGenerics * 0.54.1 2025-10-09 [1] Bioconductor 3.21 (R 4.5.1)
## DESeq2 * 1.48.2 2025-08-25 [1] Bioconductor 3.21 (R 4.5.1)
## generics * 0.1.4 2025-05-09 [1] CRAN (R 4.5.0)
## GenomeInfoDb * 1.44.3 2025-09-18 [1] Bioconductor 3.21 (R 4.5.1)
## GenomicRanges * 1.60.0 2025-04-15 [1] Bioconductor 3.21 (R 4.5.0)
## ggplot2 * 4.0.1 2025-11-14 [1] CRAN (R 4.5.2)
## ggVennDiagram * 1.5.7 2026-01-10 [1] CRAN (R 4.5.2)
## IRanges * 2.42.0 2025-04-15 [1] Bioconductor 3.21 (R 4.5.0)
## limma * 3.64.3 2025-07-31 [1] Bioconductor 3.21 (R 4.5.1)
## MatrixGenerics * 1.20.0 2025-04-15 [1] Bioconductor 3.21 (R 4.5.0)
## matrixStats * 1.5.0 2025-01-07 [1] CRAN (R 4.5.0)
## S4Vectors * 0.48.1 2026-04-04 [1] https://bioc-release.r-universe.dev (R 4.5.3)
## SummarizedExperiment * 1.38.1 2025-04-28 [1] Bioconductor 3.21 (R 4.5.0)
## vsn * 3.76.0 2025-04-15 [1] Bioconductor 3.21 (R 4.5.0)
##
## [1] /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library
## * ── Packages attached to the search path.
##
## ──────────────────────────────────────────────────────────────────────────────