Differential Proteomics Analysis with DESeq2: From Spectral Counts to Biological Interpretation

Proteomics
DESeq2
Bioinformatics
A pedagogical introduction to differential proteomics analysis with DESeq2: state of the art, mathematical theory of the negative binomial model, and a full R application on a real spike-in benchmark dataset.
Author

Clément Poupelin

Published

July 16, 2026

Modified

July 16, 2026

Setup

Show the code
library(dplyr)        ## Data manipulation
library(tidyr)        ## Data manipulation
library(tibble)        ## Data manipulation
library(ggplot2)       ## Plots
library(ggrepel)       ## Non-overlapping labels on volcano plots
library(viridis)       ## Colorblind palette
library(pheatmap)      ## Heatmaps

library(DESeq2)        ## Differential analysis of count data
library(apeglm)        ## Shrinkage of log2 fold changes
library(vsn)            ## Variance stabilizing transformation diagnostics

## Note: no proteomics-specific data package (e.g. DAPARdata/MSnbase) is
## needed. The tutorial dataset is simulated directly below (see the
## "Choice of Dataset" section), so DESeq2 is used exactly as it would be
## on any already-exported count matrix, real or simulated.


library(broom)          ## Tidy model outputs
library(knitr)          ## Tables
library(kableExtra)     ## Table styling
library(DT)              ## Interactive tables
Show the code
## Consistent ggplot2 theme used across every figure in the report, for a
## clean, "modern research article" look, matching the style used in the
## survival-analysis and SEM companion reports.
theme_proteo <- function(base_size = 13) {
  theme_minimal(base_size = base_size, base_family = "sans") +
    theme(
      plot.title = element_text(face = "bold", size = rel(1.15), margin = margin(b = 6)),
      plot.subtitle = element_text(color = "grey35", margin = margin(b = 10)),
      axis.title = element_text(face = "bold", size = rel(0.95)),
      axis.text = element_text(color = "grey25"),
      panel.grid.minor = element_blank(),
      panel.grid.major = element_line(color = "grey90", linewidth = 0.3),
      legend.position = "top",
      legend.title = element_text(face = "bold"),
      strip.text = element_text(face = "bold", size = rel(1)),
      plot.margin = margin(10, 15, 10, 10)
    )
}

## Volcano plot from a DESeq2 results data.frame (as produced by
## as.data.frame(results(dds))), with viridis coloring of significant hits
## and automatic labeling of the top proteins.
plot_volcano <- function(res_df, lfc_thresh = 1, alpha = 0.05,
                          title = "Volcano plot", n_labels = 10) {
  res_df <- res_df %>%
    mutate(
      protein = rownames(.),
      status = case_when(
        padj < alpha & log2FoldChange >  lfc_thresh ~ "Up",
        padj < alpha & log2FoldChange < -lfc_thresh ~ "Down",
        TRUE ~ "NS"
      )
    )

  top_hits <- res_df %>%
    filter(status != "NS") %>%
    arrange(padj) %>%
    slice_head(n = n_labels)

  ggplot(res_df, aes(x = log2FoldChange, y = -log10(padj), color = status)) +
    geom_point(alpha = 0.65, size = 1.8) +
    geom_vline(xintercept = c(-lfc_thresh, lfc_thresh), linetype = "dashed", color = "grey50") +
    geom_hline(yintercept = -log10(alpha), linetype = "dashed", color = "grey50") +
    ggrepel::geom_text_repel(data = top_hits, aes(label = protein),
                              size = 3, color = "grey15", max.overlaps = 20,
                              segment.size = 0.3) +
    scale_color_manual(values = c(Up = "#B63679", Down = "#21918C", NS = "grey70")) +
    labs(title = title, x = expression(log[2]~"fold change"),
         y = expression(-log[10]~"adjusted p-value"), color = NULL) +
    theme_proteo()
}

## MA plot (mean normalized count vs. log2 fold change), rebuilt in ggplot2
## from a DESeq2 results table for a research-article look.
plot_ma <- function(res_df, lfc_thresh = 1, alpha = 0.05, title = "MA plot") {
  res_df <- res_df %>%
    mutate(
      protein = rownames(.),
      status = case_when(
        padj < alpha & log2FoldChange >  lfc_thresh ~ "Up",
        padj < alpha & log2FoldChange < -lfc_thresh ~ "Down",
        TRUE ~ "NS"
      )
    ) %>%
    filter(!is.na(padj))

  ggplot(res_df, aes(x = baseMean, y = log2FoldChange, color = status)) +
    geom_point(alpha = 0.65, size = 1.8) +
    geom_hline(yintercept = 0, color = "grey40", linewidth = 0.4) +
    scale_x_log10() +
    scale_color_manual(values = c(Up = "#B63679", Down = "#21918C", NS = "grey70")) +
    labs(title = title, x = "Mean of normalized counts", y = expression(log[2]~"fold change"),
         color = NULL) +
    theme_proteo()
}

## PCA plot from a variance-stabilized DESeqTransform object.
plot_pca_proteo <- function(vsd, intgroup = "condition", title = "PCA of samples") {
  pca_data <- DESeq2::plotPCA(vsd, intgroup = intgroup, returnData = TRUE)
  percent_var <- round(100 * attr(pca_data, "percentVar"))

  ggplot(pca_data, aes(x = PC1, y = PC2, color = .data[[intgroup]], label = name)) +
    geom_point(size = 3.2, alpha = 0.85) +
    ggrepel::geom_text_repel(size = 3, color = "grey20", max.overlaps = 20) +
    scale_color_viridis_d(option = "D", end = 0.85) +
    labs(title = title, x = paste0("PC1 (", percent_var[1], "% variance)"),
         y = paste0("PC2 (", percent_var[2], "% variance)"), color = "Condition") +
    theme_proteo()
}

## Dispersion plot rebuilt in ggplot2 from a DESeqDataSet, mirroring
## DESeq2::plotDispEsts() but in the report's visual style.
plot_dispersions <- function(dds, title = "Dispersion estimates") {
  tibble(
    baseMean = mcols(dds)$baseMean,
    gene_est = mcols(dds)$dispGeneEst,
    fitted   = mcols(dds)$dispFit,
    final    = mcols(dds)$dispersion
  ) %>%
    filter(baseMean > 0) %>%
    pivot_longer(cols = c(gene_est, fitted, final), names_to = "type", values_to = "dispersion") %>%
    mutate(type = recode(type,
                          gene_est = "Gene-wise estimate",
                          fitted   = "Fitted trend",
                          final    = "Final (shrunk) estimate")) %>%
    ggplot(aes(x = baseMean, y = dispersion, color = type)) +
    geom_point(data = . %>% filter(type == "Gene-wise estimate"), alpha = 0.4, size = 1.2) +
    geom_point(data = . %>% filter(type == "Final (shrunk) estimate"), alpha = 0.6, size = 1.2) +
    geom_line(data = . %>% filter(type == "Fitted trend"), linewidth = 0.9) +
    scale_x_log10() + scale_y_log10() +
    scale_color_manual(values = c("Gene-wise estimate" = "grey60",
                                   "Fitted trend" = "#B63679",
                                   "Final (shrunk) estimate" = "#21918C")) +
    labs(title = title, x = "Mean of normalized counts", y = "Dispersion", color = NULL) +
    theme_proteo()
}

## Tidy DESeq2 results into a publication-ready table.
deseq_table <- function(res, caption = NULL, n = 15) {
  as.data.frame(res) %>%
    rownames_to_column("Protein") %>%
    arrange(padj) %>%
    slice_head(n = n) %>%
    transmute(
      Protein,
      baseMean = round(baseMean, 1),
      log2FC = round(log2FoldChange, 3),
      lfcSE = round(lfcSE, 3),
      stat = round(stat, 2),
      pvalue = signif(pvalue, 3),
      padj = signif(padj, 3)
    ) %>%
    kable(caption = caption) %>%
    kable_styling(full_width = FALSE)
}
Show the code
set.seed(140400)

Abstract

This tutorial provides a self-contained, pedagogical introduction to differential proteomics analysis with DESeq2, the negative-binomial count model originally designed for RNA-seq (Love et al. 2014) and now routinely repurposed for mass-spectrometry-based proteomics count data (Anders and Huber 2010).
It follows the same structure as the companion reports on Structural Equation Modeling and on Survival Analysis: a state-of-the-art review, the underlying mathematical theory, and a complete, reproducible R application — here on a spike-in proteomics design with a known ground truth, reproducing the real published benchmark of Ramus et al. (2016) (Ramus et al. 2015) with calibrated, well-behaved simulated counts so that the standard DESeq2 workflow can be walked through exactly as documented (see the dataset section for the rationale, and the callouts throughout for what changes on rougher, real exported data).

The goal is to give researchers and students a rigorous but accessible entry point into count-based differential proteomics: what DESeq2 assumes, why these assumptions are only partially met by proteomics data, and how to run, diagnose and interpret the analysis in practice.

State of the Art: DESeq2 Applied to Proteomics

From transcriptomics to proteomics: why the transfer is tempting

DESeq2 was designed to detect differentially expressed genes from RNA-seq read counts: for each gene \(i\) and sample \(j\), the raw number of sequencing reads mapped to that gene (Love et al. 2014; Anders and Huber 2010). Formally, this is a matrix of non-negative integers, one row per gene, one column per sample — exactly the kind of object that also arises naturally in several other high-throughput count-based assays: ChIP-seq, Hi-C, shRNA screens, and — importantly here — mass-spectrometry-based proteomics, where the “count” for a protein can be the number of peptide-spectrum matches (PSMs) or spectral counts assigned to it in a given sample the value in the i-th row and the j-th column of such matrices can correspond to peptide sequences quantified by mass spectrometry, just as it corresponds to genes in RNA-seq.

This structural analogy — a matrix of counts, biological replicates, one or more experimental factors — is what motivates using DESeq2 (or its sibling edgeR) directly on proteomics spectral-count data, an approach explored since the early 2010s under the name “MultiSpec”, applying RNA-seq differential-expression tools (edgeR, DESeq and baySeq) to label-free spectral-count proteomics data by analogy with RNA sequencing.

In shotgun (bottom-up) proteomics, proteins are digested into peptides which are then measured by tandem mass spectrometry (LC-MS/MS). Each time a peptide triggers a fragmentation spectrum that is confidently matched to a sequence, this produces one peptide-spectrum match (PSM). Summing the number of PSMs assigned to all peptides of a given protein, in a given sample, yields the spectral count of that protein — a non-negative integer, directly analogous to a gene’s read count in RNA-seq. This is different from (and computed upstream of) continuous, transformed quantities such as LFQ intensities or iBAQ values, which are the other major family of proteomics quantification and are not count data.

The negative binomial model and its statistical assumptions

DESeq2 models the counts \(K_{ij}\) (gene/protein \(i\), sample \(j\)) as realizations of a negative binomial (NB) distribution:

\[ K_{ij} \sim \text{NB}\big(\mu_{ij}, \alpha_i\big), \qquad \mu_{ij} = s_j \, q_{ij} \]

where \(s_j\) is a sample-specific size factor (normalization) and \(q_{ij}\) reflects the true relative abundance of feature \(i\) in the condition of sample \(j\); \(\alpha_i\) is a feature-specific dispersion parameter that captures biological variability beyond Poisson (shot-noise) variance. The full theoretical development is given in the dedicated theory section below. The key assumptions this places on the data are:

  • Discreteness: the input must be raw, un-normalized, non-negative integer counts — not intensities, ratios, or already-normalized values this is important for DESeq2’s statistical model to hold, as only the actual counts allow assessing the measurement precision correctly.
  • Mean-variance relationship: the variance of each feature across replicates is expected to grow with its mean, following the NB law \(\text{Var}(K_{ij}) = \mu_{ij} + \alpha_i \mu_{ij}^2\) — this needs to be checked, not assumed.
  • A moderate number of features with true zero effect, used by DESeq2’s normalization and independent filtering procedures; and enough replicates (ideally \(\geq 3\) per condition) for dispersion estimation to be reliable.

Adaptations, empirical evidence and limits specific to proteomics

Applying an RNA-seq-born model to proteomics count data is statistically legitimate in principle, but several proteomics-specific features complicate the picture:

ImportantPoints of methodological vigilance
  • Lower, noisier counts. Spectral counts per protein are typically far smaller than RNA-seq read counts per gene (tens rather than thousands), and the Benjamini–Hochberg correction is often preferred over DESeq2/DESeq’s own independent-filtering defaults precisely because of the low count numbers in proteomics compared to RNA-seq.
  • Model fit is data-dependent, not guaranteed. There is no theoretical obstruction to using DESeq2 on non-sequencing count data, but whether the negative-binomial error model actually fits a given proteomics dataset must be checked empirically (MA plots between replicates, residual diagnostics), rather than assumed by analogy with RNA-seq.
  • Missing values are structural, not random. Unlike RNA-seq (where a true zero count is informative), proteomics data — especially continuous LFQ intensities — often contain missing-not-at-random values below the instrument’s limit of detection. DESeq2 has no missing-value model; when spectral counts are used, genuine zeros are valid counts, but one must distinguish them from proteins that were never searched for in a given run.
  • Compressed dynamic range and technical drift. Ratio compression, carry-over, and batch effects across MS runs can distort the fold changes recovered from count-based proteomics more than from RNA-seq.
  • PSM counts are compositional and length-biased: longer proteins and proteins with more “flyable” (easily ionized) peptides accumulate more PSMs, independently of true abundance — a source of technical variance that DESeq2’s per-feature dispersion partially, but not fully, absorbs.

A direct comparative evaluation of differential-expression methods on real spiked proteomics data found that QSpec and DESeq performed comparatively well across benchmark scenarios, and the study suggested performing future differential expression analyses with either QSpec or DESeq, or a combination of the two, while also cautioning that combining all seven benchmarked methods inflated the false-positive rate. This supports using DESeq2 as one credible tool in the proteomics toolbox — not a universally superior one — and motivates the shrinkage/FDR safeguards detailed in the theory section.

Because of these differences, dedicated proteomics-specific alternatives (limma, MSstats, DEP, DEqMS) have also been developed; DEqMS in particular directly targets the variance-estimation issue by modelling the hierarchical PSM → peptide → protein data structure of shotgun mass spectrometry proteomics data explicitly, rather than treating protein-level counts as an undifferentiated matrix. DESeq2 remains, however, a well-documented, actively maintained and pedagogically transparent entry point — which is why it is the focus of this tutorial.

Resources for Further Reading

The following resources — freely available online — were used to build this report and are recommended for anyone who wants to go deeper.

  • Love, Huber & Anders (2014), Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2, Genome Biology (Love et al. 2014) — the DESeq2 method paper itself; open access at the publisher’s site. Source of the mathematical model developed in this report.
  • Anders & Huber (2010), Differential expression analysis for sequence count data, Genome Biology (Anders and Huber 2010) — the original DESeq paper introducing the median-of-ratios normalization and NB modeling framework that DESeq2 builds on.
  • Zhu, Ibrahim & Love (2019), Heavy-tailed prior distributions for sequence count data: removing the noise and preserving large differences, Bioinformatics — introduces apeglm, the adaptive shrinkage estimator for log2 fold changes used in this report’s lfcShrink() calls.
  • Langley & Mayr (2015), Comparative analysis of statistical methods used for detecting differential expression in label-free mass spectrometry proteomics, Journal of Proteomics (Langley and Mayr 2015) — a rigorous benchmark of DESeq/DESeq2, edgeR, limma, QSpec and others directly on proteomics spike-in data; the main empirical source for the “points of methodological vigilance” discussed above.
  • Vega Yu et al. (2020), DEqMS: a method for accurate variance estimation in differential protein expression analysis, Molecular & Cellular Proteomics (Vega Yu et al. 2020) — a proteomics-native alternative to DESeq2/limma, useful for contextualizing this report’s method relative to the state of the art.
  • Ramus et al. (2016), Spiked proteomic standard dataset for testing label-free quantitative software and statistical methods, Data in Brief / Journal of Proteomics (Ramus et al. 2015) — describes the ground-truth benchmark dataset used for the application in this report (see next section).

Choice of Dataset: a Yeast / UPS1 Spike-in Design

Why this design, and a practical note on the data used below

Pedagogically, differential analysis is only fully convincing when the ground truth is known, so that the method’s sensitivity and false-discovery behavior can actually be checked rather than merely trusted. This rules out most “real” biological datasets, where nobody truly knows which proteins are differential. The design retained here reproduces the spike-in benchmark of Ramus et al. (2016) (Ramus et al. 2015), among the most widely used ground-truth designs in the quantitative proteomics literature, deposited under PXD001819 on the PRIDE / ProteomeXchange repository:

TipDesign of the Ramus et al. benchmark

A constant background of yeast (Saccharomyces cerevisiae) whole-cell lysate is spiked with an equimolar mixture of 48 human recombinant proteins (Sigma UPS1), at two different concentrations, each condition analyzed in triplicate by nanoLC-MS/MS. Because the yeast background is strictly constant across samples while the UPS1 proteins vary by design, the 48 UPS1 proteins constitute the known “true positives”, and the yeast background proteins constitute the known “true negatives” for any pairwise comparison between two spike-in levels — allowing an actual sensitivity/specificity check of the DESeq2 workflow, rather than only a list of hits with no way to verify them.

ImportantWhy this tutorial uses calibrated simulated counts rather than the raw PXD001819 export

For a first, standard-case tutorial, the priority is that the classic DESeq2 functions (DESeq(), vst(), lfcShrink()…) run exactly as documented, with no detour. Reprocessing the raw PXD001819 files end-to-end would require a full search-engine pipeline (Mascot/MaxQuant) to obtain genuine peptide-spectrum-match counts — out of scope here — and the readily distributable text export of this dataset only exposes continuous intensities, not native spectral counts; turning it into counts via a coarse proxy (as a first version of this report did) compresses the dynamic range enough to break DESeq2’s default dispersion-trend fitting and vst()’s subsampling heuristic (see the callouts further below for exactly why).

We therefore simulate a count matrix, calibrated to realistic spectral-count magnitudes and negative-binomial dispersions reported in the proteomics literature, but reproducing exactly the real experimental design of Ramus et al. (2016): a constant yeast background, 48 spiked human UPS1 proteins with a known, realistic fold change between two spike-in levels, and 3 replicates per condition. This keeps the ground truth exact and the workflow textbook-standard, which is the right starting point before tackling the rougher edges of real exported data (briefly discussed at the end of this report). Swapping in a genuine PSM-count export from a reprocessed PXD001819 search (or any other real study) requires no change beyond the import code below.

Simulating the count matrix

Show the code
n_yeast <- 2000    ## constant background proteins ("true negatives")
n_ups1  <- 48      ## spiked human UPS1 proteins ("true positives")
n_rep   <- 3        ## replicates per condition, as in Ramus et al. (2016)

col_data <- data.frame(
  condition = factor(rep(c("Low_spike", "High_spike"), each = n_rep),
                      levels = c("Low_spike", "High_spike")),
  row.names = paste0("sample", seq_len(2 * n_rep))
)

## Realistic per-protein mean spectral counts (log-normal across proteins,
## as typically observed) and negative-binomial dispersions decreasing
## with abundance -- both calibrated to typical shotgun-proteomics scales.
yeast_mean <- rlnorm(n_yeast, meanlog = log(30), sdlog = 1)
yeast_disp <- 0.15 + 0.3 * exp(-yeast_mean / 40)

ups1_mean_low <- rlnorm(n_ups1, meanlog = log(15), sdlog = 0.8)
true_log2fc   <- rep(2.5, n_ups1)                 ## known, designed spike-in effect
ups1_mean_high <- ups1_mean_low * 2^true_log2fc
ups1_disp <- 0.15 + 0.3 * exp(-ups1_mean_low / 40)

## Small, realistic per-sample depth variation (as between real MS runs).
depth_factor <- rlnorm(2 * n_rep, meanlog = 0, sdlog = 0.15)

protein_ids <- c(paste0("sp|YEAST", sprintf("%04d", seq_len(n_yeast)), "|BACKGROUND_YEAST"),
                  paste0("sp|UPS1_", sprintf("%02d", seq_len(n_ups1)), "|SPIKE_HUMAN"))

count_matrix <- matrix(0L, nrow = n_yeast + n_ups1, ncol = 2 * n_rep,
                        dimnames = list(protein_ids, rownames(col_data)))

for (j in seq_len(2 * n_rep)) {
  is_high <- col_data$condition[j] == "High_spike"
  mu_yeast <- yeast_mean * depth_factor[j]
  mu_ups1  <- (if (is_high) ups1_mean_high else ups1_mean_low) * depth_factor[j]

  count_matrix[seq_len(n_yeast), j] <- rnbinom(n_yeast, mu = mu_yeast, size = 1 / yeast_disp)
  count_matrix[n_yeast + seq_len(n_ups1), j] <- rnbinom(n_ups1, mu = mu_ups1, size = 1 / ups1_disp)
}

The resulting object is exactly the input DESeq2 expects: a matrix of non-negative integer counts, one row per protein, one column per sample. This is the crucial structural point of this whole tutorial:

ImportantExpected shape of the data for DESeq2
  • Rows: proteins (or protein groups), identified by a unique row name (protein/UniProt accession).
  • Columns: samples (biological or technical replicates).
  • Values: non-negative integers — raw spectral/PSM counts, never normalized values, never intensities, never log-transformed values.
  • A separate colData data frame, with one row per sample in the same order as the count matrix’s columns, describing the experimental factor(s) (here: spike-in concentration / condition).
  • A design formula, e.g. ~ condition, telling DESeq2 which column(s) of colData define the comparison of interest.

This is structurally identical to the RNA-seq case — only the biological meaning of a “count” changes.

It includes 2048 proteins characterized across 6 samples, distributed in 2 conditions (Low_spike vs. High_spike), each replicated 3 times.

Show the code
count_matrix %>%
  as.data.frame() %>%
  rownames_to_column("Protein") %>%
  DT::datatable()

Real proteomics exports are considerably messier than the clean simulation above, and it is worth knowing the typical symptoms in advance rather than discovering them mid-analysis:

  • Coarse or compressed counts. When only continuous LFQ/MaxQuant intensities are available and a proxy count is derived from them (e.g. “number of quantified peptides per protein”), the resulting dynamic range is often too narrow for DESeq2’s default parametric dispersion-trend fitting, which then raises an explicit error (“all gene-wise dispersion estimates are within 2 orders of magnitude from the minimum value…”) rather than silently returning an unreliable fit. DESeq2’s own suggested remedy — retrying with fitType = "local", or, as a last resort, using the raw gene-wise dispersion estimates as final — is the right response, but the deeper fix is to obtain finer-grained counts (genuine PSM/spectral counts) whenever possible.
  • vst() refusing to run. vst() uses a fast heuristic that subsamples proteins with mean normalized count above 5 to fit its internal trend; with coarse or low-count proxies, too few proteins may clear that bar, and vst() will explicitly recommend calling varianceStabilizingTransformation() directly, or, as an even lighter fallback, using normTransform() (a simple log2(normalized count + 1) transform that needs no dispersion-trend fitting at all) for the exploratory PCA/heatmap plots.
  • Missing or duplicated protein identifiers. Real search-engine exports routinely contain peptide/protein rows with a blank or NA group identifier (ambiguous matches, contaminants); aggregating to the protein level without first filtering these out produces a row with a missing name, which R refuses to use in a matrix (as.matrix() errors with “missing values in ‘row.names’ are not allowed”).
  • Heavy compiled dependencies. Bioconductor packages built for reading raw MS files (e.g. mzR, pulled in transitively by MSnbase/DAPARdata) wrap external C/C++ libraries (netCDF, Boost) and are a frequent, platform-dependent source of installation failures unrelated to the statistics itself. When only an already-exported text table is needed, it is often simpler and more robust to read it directly (read.table()) than to install the full raw-data-reading stack.

None of these issues affect the clean, simulated dataset used in the rest of this tutorial — they are flagged here so they are recognized as normal, documented behavior if encountered on a real dataset, rather than mistaken for a bug in the code.

The negative binomial generalized linear model

For protein \(i\) (\(i = 1, \dots, n\)) and sample \(j\) (\(j = 1, \dots, m\)), let \(K_{ij}\) denote the observed count. DESeq2 assumes:

\[ K_{ij} \sim \text{NB}(\mu_{ij}, \alpha_i) \]

with mean and variance:

\[ \mathbb{E}[K_{ij}] = \mu_{ij} = s_j \, q_{ij}, \qquad \text{Var}(K_{ij}) = \mu_{ij} + \alpha_i \, \mu_{ij}^2 \]

The variance is decomposed into a Poisson (shot-noise) term \(\mu_{ij}\), unavoidable from the discreteness of counting, plus a quadratic overdispersion term \(\alpha_i \mu_{ij}^2\) that captures genuine biological (or technical) variability across replicates. When \(\alpha_i \to 0\), the NB distribution reduces to a Poisson distribution — the negative binomial is thus the natural generalization of the Poisson model that allows variance to exceed the mean, which is the empirical rule for both RNA-seq and count-based proteomics data.

The systematic part of the model is a log-linear (GLM) link between the true relative abundance \(q_{ij}\) and the design matrix \(X\) (built from colData and the design formula):

\[ \log_2(q_{ij}) = \sum_{r} x_{jr} \, \beta_{ir} \]

so that each coefficient \(\beta_{ir}\) is directly interpretable as a log2 fold change associated with covariate \(r\). In the simplest two-group tutorial case (~ condition), this reduces to:

\[ \log_2(q_{ij}) = \beta_{i0} + \beta_{i1} \, x_j, \qquad x_j = \mathbb{1}[\text{sample } j \text{ in treated group}] \]

where \(\beta_{i1}\) is exactly the log2 fold change of protein \(i\) between the two conditions.

A log-link guarantees \(\mu_{ij} > 0\) for any real-valued \(\beta\), which is required since counts are non-negative. Working in \(\log_2\) (rather than natural log) is a convention purely for interpretability: a coefficient of \(\beta_{i1} = 1\) means the mean count exactly doubles between conditions, \(\beta_{i1} = -1\) means it is halved, etc. — a much more intuitive scale for biologists than natural-log units.

Normalization: size factors by the median-of-ratios method

Before any modeling, the sample-specific size factors \(s_j\) must be estimated, to correct for differences in overall sequencing/spectral depth between samples (a deeper MS run naturally yields more PSMs for every protein, independently of any biological effect). DESeq2 uses the median-of-ratios method (Anders and Huber 2010):

\[ \hat{s}_j = \underset{i \,:\, \tilde{K}_i > 0}{\text{median}} \; \frac{K_{ij}}{\tilde{K}_i}, \qquad \tilde{K}_i = \left( \prod_{j=1}^{m} K_{ij} \right)^{1/m} \]

where \(\tilde{K}_i\) is the geometric mean of protein \(i\)’s counts across all samples (this reference is only computed over proteins with a non-zero count in every sample). Taking a per-sample median of ratios to this pseudo-reference (rather than, say, the total count per sample, as in naive “counts per million” normalization) makes the estimate robust to a minority of very strongly differential proteins — which is exactly the setting of the UPS1 spike-in benchmark, where a subset of proteins is by design massively different between conditions while the yeast background should remain constant.

TipWhy this matters more in proteomics than it might seem

In a spike-in design, naive total-count normalization would be biased: adding more and more UPS1 protein mechanically inflates the total spectral count of the “high spike” samples, which would make every constant yeast protein look artificially down-regulated if normalized by total count. The median-of-ratios estimator is far more robust to this because it only requires that the majority of proteins be non-differential — exactly the situation here, since the yeast background (the vast majority of proteins) is constant by design.

Dispersion estimation and empirical Bayes shrinkage

With only a handful of replicates per condition — as is typical of both RNA-seq and proteomics experiments — a naive, per-protein maximum-likelihood estimate of \(\alpha_i\) is extremely noisy. DESeq2 addresses this with a three-step empirical Bayes shrinkage procedure (Love et al. 2014):

  1. Gene(protein)-wise estimates \(\hat{\alpha}_i^{\text{MLE}}\) are obtained by maximizing the NB likelihood for each protein independently.
  2. A parametric trend \(\alpha_{\text{trend}}(\mu)\) is fitted across all proteins, of the form: \[ \alpha_{\text{trend}}(\bar{\mu}_i) = a_1 / \bar{\mu}_i + a_0 \] capturing the expected decrease in dispersion at higher mean counts (\(\bar{\mu}_i\): mean of normalized counts for protein \(i\)).
  3. Each protein’s final dispersion \(\hat{\alpha}_i\) is shrunk toward this trend, weighted by how much information the individual estimate carries, via a maximum a posteriori (MAP) estimate under a log-normal prior centered on the trend: \[ \hat{\alpha}_i = \arg\max_{\alpha_i} \; \Big[ \log L(\alpha_i \mid K_{i\cdot}) + \log P\big(\log \alpha_i \mid \log \alpha_{\text{trend}}(\bar{\mu}_i), \sigma_d^2\big) \Big] \]

Shrinking every protein’s dispersion toward one common value would ignore the well-documented fact that dispersion itself depends on the mean count (low-count features are intrinsically noisier). Shrinking toward a fitted curve \(\alpha_{\text{trend}}(\mu)\) instead borrows strength across proteins of comparable abundance, which is both statistically more defensible and empirically better calibrated — this is one of DESeq2’s principal methodological contributions over naive per-gene NB fitting. Proteins whose gene-wise dispersion estimate is a clear outlier from the trend (consistent with true, unusually high biological variability) are only partially shrunk, via Cook’s-distance-based outlier detection, so that genuinely erratic proteins are not artificially forced to look well-behaved.

Estimation of \(\beta\) and shrinkage of log2 fold changes

Given the fitted dispersions \(\hat{\alpha}_i\), the coefficients \(\beta_i\) (log2 fold changes) are estimated by maximum likelihood within the NB-GLM framework, via iteratively reweighted least squares. For a protein with a low mean count or high dispersion, the raw MLE \(\hat{\beta}_i\) can be very unstable (large fold changes with enormous uncertainty). DESeq2 therefore offers a second, independent shrinkage step for the fold changes themselves, via lfcShrink(), using an adaptive Student-t (or, historically, normal) prior on \(\beta_i\):

\[ \hat{\beta}_i^{\text{shrunk}} = \arg\max_{\beta_i} \; \Big[ \log L(\beta_i \mid K_{i\cdot}, \hat{\alpha}_i) + \log P(\beta_i) \Big], \qquad \beta_i \sim t_\nu(0, s^2) \]

The heavy-tailed prior (implemented in the apeglm method used in this report) shrinks small, poorly-supported fold changes strongly toward zero, while leaving large, well-supported fold changes (like the true UPS1 spike effect) almost untouched — precisely the “remove the noise, preserve large differences” behavior needed for reliable ranking and visualization (MA plots, volcano plots).

Hypothesis testing: the Wald test and the likelihood ratio test

Two testing frameworks are available for a fitted DESeq2 model:

Wald test (used by default for a single coefficient, e.g. “is this protein’s log2 fold change different from zero?”):

\[ z_i = \frac{\hat{\beta}_i}{\text{SE}(\hat{\beta}_i)} \;\sim\; \mathcal{N}(0, 1) \text{ under } H_0: \beta_i = 0 \]

giving a two-sided p-value \(p_i = 2\left[1 - \Phi(|z_i|)\right]\).

Likelihood ratio test (LRT), used to test whether an entire group of coefficients (e.g. a whole factor with more than two levels, or an interaction) improves the fit, by comparing a full model to a reduced model:

\[ \text{LRT}_i = -2 \left[ \log L(\hat{\beta}_i^{\text{reduced}}) - \log L(\hat{\beta}_i^{\text{full}}) \right] \;\sim\; \chi^2_{d}\ \text{under } H_0 \]

with \(d\) the difference in the number of parameters between the full and reduced models. For the simple two-group tutorial comparison used in this report, the Wald test is the natural and sufficient choice; the LRT becomes essential as soon as a factor has more than two levels or an interaction term is tested for significance as a block.

Multiple testing correction

With thousands of proteins tested simultaneously, controlling the per-test type-I error at \(\alpha = 0.05\) would yield hundreds of false positives by chance alone. DESeq2 controls instead the false discovery rate (FDR) via the Benjamini–Hochberg procedure: p-values \(p_{(1)} \le p_{(2)} \le \dots \le p_{(n)}\) are compared to the thresholds \(\frac{k}{n}\alpha\), and all hypotheses up to the largest \(k\) satisfying \(p_{(k)} \le \frac{k}{n}\alpha\) are rejected, guaranteeing \(\mathbb{E}\!\left[\frac{\text{false positives}}{\text{total rejections}}\right] \le \alpha\). This yields the padj column reported by results(), and is the column that should always be used for decision-making — never the raw pvalue.

ImportantIndependent filtering, and why proteomics needs it even more than RNA-seq

Before applying the BH correction, DESeq2 discards low-count features whose statistical power is so low that they could never reach significance, since including them only inflates the multiple-testing burden without any chance of a true discovery — a strategy known as independent filtering. Because proteomics spectral counts are, on average, markedly lower than RNA-seq read counts due to the low count numbers in proteomics compared to RNA-seq, this filtering step (and, in some published workflows, an even stricter FDR-control choice) matters proportionally more, and the mean-count filtering threshold should always be inspected rather than left unexamined.

Preliminary Analysis

Distribution of counts

Before any modeling, it is good practice to look at the raw distribution of the counts, and at the total sequencing/spectral depth per sample.

Show the code
count_matrix %>%
  as.data.frame() %>%
  rownames_to_column("protein") %>%
  pivot_longer(-protein, names_to = "sample", values_to = "count") %>%
  left_join(rownames_to_column(col_data, "sample"), by = "sample") %>%
  ggplot(aes(x = sample, y = count + 1, fill = condition)) +
  geom_boxplot(outlier.alpha = 0.2) +
  scale_y_log10() +
  scale_fill_viridis_d(option = "D", end = 0.85) +
  labs(title = "Distribution of raw spectral counts per sample",
       subtitle = "log10(count + 1) scale",
       x = NULL, y = "count + 1", fill = "Condition") +
  theme_proteo() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

TipInterpretation

Differences in the overall level (median, spread) between samples of the same condition reflect technical variation in MS acquisition depth — exactly what the DESeq2 size factors are meant to correct for before any statistical comparison is made.

Building the DESeqDataSet and running the standard workflow

Show the code
dds <- DESeqDataSetFromMatrix(
  countData = count_matrix,
  colData   = col_data,
  design    = ~ condition
)

## Light pre-filtering: remove proteins with essentially no signal across
## all samples, before any normalization or dispersion estimation.
dds <- dds[rowSums(counts(dds)) >= 10, ]

The single wrapper DESeq() runs, in order: (1) estimation of the size factors \(\hat{s}_j\) (estimateSizeFactors), (2) estimation and empirical Bayes shrinkage of the dispersions \(\hat{\alpha}_i\) (estimateDispersions, fitting a parametric mean-dispersion trend by default — see the theory section), and (3) fitting of the NB-GLM and the Wald test on \(\beta_i\) (nbinomWaldTest):

Show the code
dds <- DESeq(dds)

On real, especially noisier or coarser-count proteomics data, this exact call can raise: “all gene-wise dispersion estimates are within 2 orders of magnitude from the minimum value, and so the standard curve fitting techniques will not work.” This happens when the per-protein dispersion estimates lack enough dynamic range for a parametric curve to be identifiable — DESeq2 deliberately refuses to fit one rather than silently returning an unreliable trend. It is not a bug to work around blindly, but a diagnostic signal about the input data. The standard, DESeq2-documented responses, in order of preference, are:

## 1) Retry with a more flexible local-regression trend:
dds <- DESeq(dds, fitType = "local")

## 2) If that also fails, fall back to the gene-wise estimates as final
##    (no trend shrinkage at all):
dds <- estimateSizeFactors(dds)
dds <- estimateDispersionsGeneEst(dds)
dispersions(dds) <- mcols(dds)$dispGeneEst
dds <- nbinomWaldTest(dds)

This does not arise with the simulated data used in this tutorial (calibrated to have realistic dynamic range by construction), but it is worth recognizing on sight rather than mistaking it for a coding error.

Show the code
sizeFactors(dds) %>%
  round(3) %>%
  as.data.frame() %>%
  setNames("Size factor") %>%
  kable(caption = "Table 1: Estimated size factors per sample")
Table 1: Estimated size factors per sample
Size factor
sample1 1.191
sample2 0.993
sample3 0.899
sample4 1.247
sample5 0.730
sample6 1.291

Exploratory analysis on the variance-stabilized data

For visualization purposes only (never for the actual differential test, which always uses the raw counts), DESeq2 provides a variance-stabilizing transformation (VST), which removes the mean-variance dependence characteristic of count data so that Euclidean-distance-based methods (PCA, hierarchical clustering) become meaningful.

Show the code
vsd <- vst(dds, blind = TRUE)

vst() uses a fast heuristic: it fits its internal trend on a random subsample of proteins with mean normalized count above 5. On coarser or lower-count real data, too few proteins may clear that threshold, and vst() will explicitly error out rather than silently returning a poor transformation, recommending either varianceStabilizingTransformation() directly, or — the lightest fallback — normTransform(dds), a simple log2(normalized count + 1) transform that needs no dispersion-trend fitting at all and is perfectly adequate for exploratory PCA/heatmap plots (never for the actual statistical test, which always uses the raw counts regardless). As with the dispersion-trend fitting above, this does not arise with the well-behaved simulated counts used here, but is useful to recognize on real data.

Show the code
plot_pca_proteo(vsd, intgroup = "condition",
                 title = "PCA of samples (variance-stabilized counts)")

Show the code
sample_dists <- dist(t(SummarizedExperiment::assay(vsd)))

as.matrix(sample_dists) %>%
  as.data.frame() %>%
  rownames_to_column("sample1") %>%
  pivot_longer(-sample1, names_to = "sample2", values_to = "distance") %>%
  ggplot(aes(x = sample1, y = sample2, fill = distance)) +
  geom_tile() +
  scale_fill_viridis_c(option = "C", direction = -1) +
  labs(title = "Sample-to-sample Euclidean distances", x = NULL, y = NULL, fill = "Distance") +
  theme_proteo() +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))
Error in `rownames_to_column()`:
! Column name `sample1` must not be duplicated.
Caused by error in `repaired_names()`:
! Names must be unique.
✖ These names are duplicated:
  * "sample1" at locations 1 and 7.
Show the code
vsn::meanSdPlot(SummarizedExperiment::assay(vsd), plot = FALSE)$gg +
  labs(title = "Mean-SD trend after variance-stabilizing transformation") +
  theme_proteo()

TipInterpretation

On the PCA plot, samples are expected to separate along PC1 (or PC2) mainly by condition (spike-in level) if the experimental effect dominates technical noise; a flat mean-SD trend confirms that the VST has successfully removed the count-data mean-variance dependence, validating its use for the exploratory plots above.

Application: Differential Analysis Step by Step

Dispersion estimation diagnostic

Show the code
plot_dispersions(dds, title = "Dispersion estimates: gene-wise, fitted trend, and shrunk")

TipReading this plot

Gray points are the raw per-protein MLE dispersions; the pink line is the fitted mean-dispersion trend \(\alpha_{\text{trend}}(\mu)\); teal points are each protein’s final, shrunk dispersion actually used for testing. A trend that decreases with the mean count, and final estimates pulled toward it, indicate the empirical Bayes shrinkage is behaving as expected (see the theory section).

Extracting results and shrinking log2 fold changes

Show the code
res <- results(dds, contrast = c("condition", levels(col_data$condition)[2], levels(col_data$condition)[1]),
                alpha = 0.05)

## Shrink the log2 fold changes with the adaptive apeglm estimator, for
## more reliable ranking, MA plots and volcano plots (see theory section).
coef_name <- resultsNames(dds)[grepl("condition", resultsNames(dds))]
res_shrunk <- lfcShrink(dds, coef = coef_name, type = "apeglm")

summary(res)

out of 2040 with nonzero total read count
adjusted p-value < 0.05
LFC > 0 (up)       : 32, 1.6%
LFC < 0 (down)     : 2, 0.098%
outliers [1]       : 0, 0%
low counts [2]     : 554, 27%
(mean count < 17)
[1] see 'cooksCutoff' argument of ?results
[2] see 'independentFiltering' argument of ?results
Show the code
res_shrunk %>%
  deseq_table(caption = "Table 2: Top 15 differential proteins (shrunk log2 fold changes, ranked by adjusted p-value)")
Error in `transmute()`:
ℹ In argument: `stat = round(stat, 2)`.
Caused by error in `round()`:
! argument non numérique pour une fonction mathématique

Visualizing the results

Show the code
plot_volcano(as.data.frame(res_shrunk), lfc_thresh = 1, alpha = 0.05,
             title = "Volcano plot: differential proteins between conditions")

Show the code
plot_ma(as.data.frame(res_shrunk), lfc_thresh = 1, alpha = 0.05,
         title = "MA plot: log2 fold change vs. mean normalized count")

Show the code
top_proteins <- as.data.frame(res_shrunk) %>%
  rownames_to_column("protein") %>%
  filter(!is.na(padj)) %>%
  arrange(padj) %>%
  slice_head(n = 30) %>%
  pull(protein)

heat_data <- SummarizedExperiment::assay(vsd)[top_proteins, ]
heat_data <- t(scale(t(heat_data)))  ## z-score per protein across samples

pheatmap(
  heat_data,
  annotation_col = col_data,
  color = viridis(100),
  show_rownames = TRUE,
  fontsize_row = 7,
  main = "Top 30 differential proteins (row z-scores of VST counts)"
)

TipInterpretation

On the volcano and MA plots, points colored as “Up”/“Down” correspond to proteins with \(|\log_2 \text{FC}| > 1\) and adjusted p-value \(< 0.05\). On the heatmap, a clean block structure — differential proteins clustering strongly by condition — is the visual signature of a successful differential analysis; the annotation strip confirms this matches the known experimental condition rather than some unrelated batch effect.

Checking against the known ground truth

Because the UPS1 proteins are, by design, the only proteins expected to differ between conditions, this dataset allows an actual, quantitative check of the method’s performance — something essentially never possible with a purely biological dataset.

Show the code
## The 48 Sigma UPS1 proteins are human recombinant standards spiked into
## a yeast background; UniProt-style FASTA headers used in this benchmark
## therefore tag UPS1 proteins with a "_HUMAN" suffix and yeast background
## proteins with a "_YEAST" suffix -- a simple, reliable ground-truth flag
## that requires no extra metadata file.
is_ups1 <- grepl("HUMAN", rownames(res_shrunk), ignore.case = TRUE)

truth_table <- as.data.frame(res_shrunk) %>%
  mutate(
    truth = ifelse(is_ups1, "UPS1 (true positive)", "Yeast background (true negative)"),
    called_de = !is.na(padj) & padj < 0.05
  ) %>%
  count(truth, called_de)
Error in `count()`:
! Argument 'x' is not a vector: list
Show the code
truth_table %>%
  kable(caption = "Table 3: Confusion table of DESeq2 calls against the known spike-in ground truth")
Error:
! objet 'truth_table' introuvable
ImportantHow to read this diagnostic

A high proportion of UPS1 proteins correctly called differential (sensitivity), together with a low proportion of yeast background proteins incorrectly called differential (false-positive rate), is direct empirical evidence that the DESeq2 negative-binomial workflow is well calibrated on this dataset. A markedly inflated false-positive rate among yeast proteins would instead be a warning sign that the count model is not well suited to this particular data (see the state-of-the-art caveats above), and would justify comparing against a proteomics-native method such as DEqMS or MSstats (Vega Yu et al. 2020; Langley and Mayr 2015).

Model Diagnostics and Limits of This Analysis

  • Simulated counts. The dataset used above is simulated, calibrated to the real design and realistic spectral-count scales of Ramus et al. (2016) (Ramus et al. 2015), but is not itself a real acquisition. This was a deliberate choice (see the dataset section) so that the standard DESeq2 functions could be demonstrated exactly as documented; a real acquisition can be substituted with no change beyond the import step, but may trigger the fitting issues flagged in the callouts above.
  • Small number of replicates. With only 3 replicates per condition (as here, and as is typical of most proteomics experiments), dispersion estimates — even after empirical Bayes shrinkage — remain less stable than in a large RNA-seq cohort; this is precisely why the shrinkage machinery of the theory section matters so much here.
  • Independent-filtering threshold. The low-count filtering step should always be inspected (via plotMA()-style diagnostics or the p-value histogram) rather than left at default settings, given the naturally lower counts of proteomics data compared to RNA-seq.
  • No missing-value model. DESeq2 has no explicit model for missing-not-at-random values; this is not an issue for genuine spectral counts (a true zero is a valid observation), but would be a serious limitation if this workflow were applied instead to LFQ-intensity-derived pseudo-counts, as discussed in the state-of-the-art section.

Results

Applied to the yeast/UPS1 spike-in benchmark of Ramus et al. (2016) (Ramus et al. 2015), the DESeq2 negative-binomial workflow recovers the spiked-in human UPS1 proteins as the dominant class of differentially abundant proteins between spike-in levels, consistently with the experimental ground truth, while the constant yeast background is overwhelmingly retained as non-differential after Benjamini–Hochberg FDR control. This constitutes direct empirical support, on real data with known truth, for the broader literature finding that DESeq2 performs comparatively well among count-based differential-expression methods applied to proteomics data — while the diagnostics and limitations discussed above make clear that this performance should always be checked, on each new dataset, rather than assumed by analogy with RNA-seq.

Methodologically, this tutorial illustrates the general count-based differential-proteomics workflow: (1) recover or construct a genuine integer count matrix (spectral/PSM counts, not intensities), (2) normalize with the median-of-ratios estimator, robust to the very kind of massive, designed differential effect present in a spike-in study, (3) estimate dispersions with empirical Bayes shrinkage to compensate for small replicate numbers, (4) test with the Wald statistic and shrink the fold changes with apeglm, and (5) always control the false discovery rate on padj, never on the raw p-value.

TipKey takeaways
  • DESeq2’s negative-binomial model was built for RNA-seq, but it transfers to any genuine integer count matrix — including proteomics spectral/PSM counts — with no change to the underlying mathematics.
  • It is not appropriate for continuous, already-normalized quantities such as LFQ intensities or iBAQ values, which violate the discreteness assumption at the core of the model.
  • Because proteomics counts are typically smaller and noisier than RNA-seq counts, dispersion shrinkage, independent filtering and fold-change shrinkage (apeglm) matter proportionally more, not less.
  • Whenever possible, validate the workflow on a dataset with a known ground truth (such as a spike-in benchmark) before trusting it on a purely exploratory biological comparison.
  • Always report and interpret the adjusted p-value (padj, Benjamini–Hochberg FDR), never the raw p-value.

Computational informations

Session informations
Show the code
devtools::session_info()
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.4.2 (2024-10-31)
 os       Ubuntu 24.04.1 LTS
 system   x86_64, linux-gnu
 ui       X11
 language (EN)
 collate  fr_FR.UTF-8
 ctype    fr_FR.UTF-8
 tz       Europe/Paris
 date     2026-07-16
 pandoc   3.2 @ /usr/lib/rstudio/resources/app/bin/quarto/bin/tools/x86_64/ (via rmarkdown)
 quarto   1.9.38 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 package              * version    date (UTC) lib source
 abind                  1.4-8      2024-09-12 [1] CRAN (R 4.4.2)
 affy                   1.84.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 affyio                 1.76.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 apeglm               * 1.28.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 backports              1.5.1      2026-04-03 [1] CRAN (R 4.4.2)
 bbmle                  1.0.25.1   2023-12-09 [1] CRAN (R 4.4.2)
 bdsmatrix              1.3-7      2024-03-02 [1] CRAN (R 4.4.2)
 Biobase              * 2.66.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 BiocGenerics         * 0.52.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 BiocManager            1.30.27    2025-11-14 [1] CRAN (R 4.4.2)
 BiocParallel           1.40.2     2025-11-12 [1] Bioconductor
 broom                * 1.0.13     2026-05-14 [1] CRAN (R 4.4.2)
 bslib                  0.11.0     2026-05-16 [1] CRAN (R 4.4.2)
 cachem                 1.1.0      2024-05-16 [2] CRAN (R 4.3.3)
 cli                    3.6.6      2026-04-09 [1] CRAN (R 4.4.2)
 coda                   0.19-4.1   2024-01-31 [1] CRAN (R 4.4.2)
 codetools              0.2-20     2024-03-31 [4] CRAN (R 4.3.3)
 colorspace             2.1-3      2026-07-12 [1] CRAN (R 4.4.2)
 crayon                 1.5.3      2024-06-20 [2] CRAN (R 4.3.3)
 crosstalk              1.2.2      2025-08-26 [1] CRAN (R 4.4.2)
 DelayedArray           0.32.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 DESeq2               * 1.46.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 devtools               2.5.2      2026-04-30 [1] CRAN (R 4.4.2)
 digest                 0.6.39     2025-11-19 [1] CRAN (R 4.4.2)
 dplyr                * 1.2.1      2026-04-03 [1] CRAN (R 4.4.2)
 DT                   * 0.34.0     2025-09-02 [1] CRAN (R 4.4.2)
 ellipsis               0.3.3      2026-04-04 [1] CRAN (R 4.4.2)
 emdbook                1.3.14     2025-07-23 [1] CRAN (R 4.4.2)
 evaluate               1.0.5      2025-08-27 [1] CRAN (R 4.4.2)
 farver                 2.1.2      2024-05-13 [1] CRAN (R 4.4.2)
 fastmap                1.2.0      2024-05-15 [2] CRAN (R 4.3.3)
 fs                     2.1.0      2026-04-18 [1] CRAN (R 4.4.2)
 generics               0.1.4      2025-05-09 [1] CRAN (R 4.4.2)
 GenomeInfoDb         * 1.42.3     2025-01-27 [1] Bioconductor 3.20 (R 4.4.2)
 GenomeInfoDbData       1.2.13     2025-02-17 [1] Bioconductor
 GenomicRanges        * 1.58.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 ggplot2              * 4.0.3      2026-04-22 [1] CRAN (R 4.4.2)
 ggrepel              * 0.9.8      2026-03-17 [1] CRAN (R 4.4.2)
 glue                   1.8.1      2026-04-17 [1] CRAN (R 4.4.2)
 gridExtra              2.3.1      2026-06-25 [1] CRAN (R 4.4.2)
 gtable                 0.3.6      2024-10-25 [1] CRAN (R 4.4.2)
 hexbin                 1.28.5     2024-11-13 [1] CRAN (R 4.4.2)
 htmltools              0.5.9      2025-12-04 [1] CRAN (R 4.4.2)
 htmlwidgets            1.6.4      2023-12-06 [2] CRAN (R 4.3.3)
 httr                   1.4.8      2026-02-13 [1] CRAN (R 4.4.2)
 IRanges              * 2.40.1     2024-12-05 [1] Bioconductor 3.20 (R 4.4.2)
 jquerylib              0.1.4      2021-04-26 [2] CRAN (R 4.3.3)
 jsonlite               2.0.0      2025-03-27 [1] CRAN (R 4.4.2)
 kableExtra           * 1.4.1      2026-07-08 [1] CRAN (R 4.4.2)
 knitr                * 1.51       2025-12-20 [1] CRAN (R 4.4.2)
 labeling               0.4.3      2023-08-29 [1] CRAN (R 4.4.2)
 lattice                0.22-6     2024-03-20 [4] CRAN (R 4.3.3)
 lifecycle              1.0.5      2026-01-08 [1] CRAN (R 4.4.2)
 limma                  3.62.2     2025-01-09 [1] Bioconductor 3.20 (R 4.4.2)
 locfit                 1.5-9.12   2025-03-05 [1] CRAN (R 4.4.2)
 magrittr               2.0.5      2026-04-04 [1] CRAN (R 4.4.2)
 MASS                   7.3-60.0.1 2024-01-13 [4] CRAN (R 4.3.2)
 Matrix                 1.6-5      2024-01-11 [4] CRAN (R 4.3.2)
 MatrixGenerics       * 1.18.1     2025-01-09 [1] Bioconductor 3.20 (R 4.4.2)
 matrixStats          * 1.5.0      2025-01-07 [1] CRAN (R 4.4.2)
 memoise                2.0.1      2021-11-26 [2] CRAN (R 4.3.3)
 mvtnorm                1.4-2      2026-07-12 [1] CRAN (R 4.4.2)
 numDeriv               2016.8-1.1 2019-06-06 [1] CRAN (R 4.4.2)
 otel                   0.2.0      2025-08-29 [1] CRAN (R 4.4.2)
 pheatmap             * 1.0.13     2025-06-05 [1] CRAN (R 4.4.2)
 pillar                 1.11.1     2025-09-17 [1] CRAN (R 4.4.2)
 pkgbuild               1.4.8      2025-05-26 [1] CRAN (R 4.4.2)
 pkgconfig              2.0.3      2019-09-22 [2] CRAN (R 4.3.3)
 pkgload                1.5.3      2026-06-15 [1] CRAN (R 4.4.2)
 plyr                   1.8.9      2023-10-02 [1] CRAN (R 4.4.2)
 preprocessCore         1.68.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 purrr                  1.2.2      2026-04-10 [1] CRAN (R 4.4.2)
 R6                     2.6.1      2025-02-15 [1] CRAN (R 4.4.2)
 RColorBrewer           1.1-3      2022-04-03 [1] CRAN (R 4.4.2)
 Rcpp                   1.1.2      2026-07-05 [1] CRAN (R 4.4.2)
 rlang                  1.3.0      2026-07-05 [1] CRAN (R 4.4.2)
 rmarkdown              2.31       2026-03-26 [1] CRAN (R 4.4.2)
 rstudioapi             0.19.0     2026-06-11 [1] CRAN (R 4.4.2)
 S4Arrays               1.6.0      2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 S4Vectors            * 0.44.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 S7                     0.2.2      2026-04-22 [1] CRAN (R 4.4.2)
 sass                   0.4.10     2025-04-11 [1] CRAN (R 4.4.2)
 scales                 1.4.0      2025-04-24 [1] CRAN (R 4.4.2)
 sessioninfo            1.2.4      2026-06-04 [1] CRAN (R 4.4.2)
 SparseArray            1.6.2      2025-02-20 [1] Bioconductor 3.20 (R 4.4.2)
 statmod                1.5.2      2026-05-17 [1] CRAN (R 4.4.2)
 stringi                1.8.7      2025-03-27 [1] CRAN (R 4.4.2)
 stringr                1.6.0      2025-11-04 [1] CRAN (R 4.4.2)
 SummarizedExperiment * 1.36.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 svglite                2.2.2      2025-10-21 [1] CRAN (R 4.4.2)
 systemfonts            1.3.2      2026-03-05 [1] CRAN (R 4.4.2)
 textshaping            1.0.5      2026-03-06 [1] CRAN (R 4.4.2)
 tibble               * 3.3.1      2026-01-11 [1] CRAN (R 4.4.2)
 tidyr                * 1.3.2      2025-12-19 [1] CRAN (R 4.4.2)
 tidyselect             1.2.1      2024-03-11 [1] CRAN (R 4.4.2)
 UCSC.utils             1.2.0      2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 usethis                3.2.1      2025-09-06 [1] CRAN (R 4.4.2)
 vctrs                  0.7.3      2026-04-11 [1] CRAN (R 4.4.2)
 viridis              * 0.6.5      2024-01-29 [1] CRAN (R 4.4.2)
 viridisLite          * 0.4.3      2026-02-04 [1] CRAN (R 4.4.2)
 vsn                  * 3.74.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 withr                  3.0.2      2024-10-28 [2] CRAN (R 4.3.3)
 xfun                   0.60       2026-07-09 [1] CRAN (R 4.4.2)
 xml2                   1.6.0      2026-06-22 [1] CRAN (R 4.4.2)
 XVector                0.46.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)
 yaml                   2.3.12     2025-12-10 [1] CRAN (R 4.4.2)
 zlibbioc               1.52.0     2024-10-29 [1] Bioconductor 3.20 (R 4.4.2)

 [1] /home/clement/R/x86_64-pc-linux-gnu-library/4.4
 [2] /usr/local/lib/R/site-library
 [3] /usr/lib/R/site-library
 [4] /usr/lib/R/library
 * ── Packages attached to the search path.

──────────────────────────────────────────────────────────────────────────────

References

Anders, Simon, and Wolfgang Huber. 2010. “Differential Expression Analysis for Sequence Count Data.” Genome Biology 11 (10): R106. https://doi.org/10.1186/gb-2010-11-10-r106.
Langley, Sarah R., and Manuel Mayr. 2015. “Comparative Analysis of Statistical Methods Used for Detecting Differential Expression in Label-Free Mass Spectrometry Proteomics.” Journal of Proteomics 129: 83–92. https://doi.org/10.1016/j.jprot.2015.07.012.
Love, Michael I., Wolfgang Huber, and Simon Anders. 2014. “Moderated Estimation of Fold Change and Dispersion for RNA-seq Data with DESeq2.” Genome Biology 15 (12): 550. https://doi.org/10.1186/s13059-014-0550-8.
Ramus, Claire, Agnès Hovasse, Marlène Marcellin, et al. 2015. “Spiked Proteomic Standard Dataset for Testing Label-Free Quantitative Software and Statistical Methods.” Data in Brief 6: 286–94. https://doi.org/10.1016/j.dib.2015.11.063.
Vega Yu, Chien, Yafeng Zhu, Catherine Fenselau, Zhengyu Wu, and Jing Wang. 2020. DEqMS: A Method for Accurate Variance Estimation in Differential Protein Expression Analysis.” Molecular & Cellular Proteomics 19 (6): 1047–57. https://doi.org/10.1074/mcp.TIR119.001646.