20  Differential Abundance Analysis with Design Matrices

This is the chapter every earlier one was building toward: the moment you ask, of thousands of features, which ones actually changed. It is also where good data goes to die — the wrong design matrix, an uncontrolled batch, or a forgotten multiple-testing correction can manufacture a hundred “discoveries” out of pure noise. The tool is limma, borrowed from genomics and superbly suited to the small-sample, many-feature world of mass spectrometry.

Earlier chapters covered import, object construction, LFQ quantification, normalization, and missing-data handling. This chapter starts from an analysis-ready feature matrix and concentrates on the statistical model: design matrices, contrasts, effect sizes, and multiple-testing control.

The companion project r4ms_book/analysis/proteomics_analysis.R implements this chapter’s workflow at production scale on the PXD004886 11-site DIA benchmark (4,517 proteins × 22 samples). The code in this chapter is a pedagogical walkthrough of the same logic; the companion script is the version you would run on a cluster.

WarningThe One Mistake to Avoid

Reporting raw p-values. Testing thousands of features without FDR correction guarantees dozens of false positives. Always adjust, and pair statistical significance with an effect-size threshold.

20.1 Learning Objectives

By the end of this chapter you will be able to:

  • Build design matrices for two-group, factorial, and blocked experiments
  • Specify contrasts with makeContrasts() and interpret model coefficients
  • Apply empirical Bayes moderation with limma and explain why it helps at low replication
  • Control the false discovery rate and combine it with an effect-size threshold
  • Produce volcano plots and ranked feature lists
  • Distinguish reliable from unreliable differential hits using spike-in benchmarks
Code
flowchart LR
    subgraph Input["MS Feature Matrix"]
        A[Features × Samples<br/>Intensity Data]
    end
    
    subgraph QC["Quality Control"]
        B[Missing Value<br/>Assessment]
        C[CV Analysis<br/>Technical Replicates]
        D[Outlier Detection<br/>PCA/Clustering]
    end
    
    subgraph Norm["Normalization"]
        E[Total Ion Current<br/>TIC]
        F[Internal Standard<br/>IS]
        G[Median/Quantile<br/>Normalization]
    end
    
    subgraph Univariate["Univariate Tests"]
        H[t-test / Wilcoxon]
        I[ANOVA / Kruskal-Wallis]
        J[Linear Models<br/>limma]
    end
    
    subgraph Multivariate["Multivariate Analysis"]
        K[PCA<br/>Dimensionality Reduction]
        L[PLS-DA<br/>Supervised]
        M[Hierarchical<br/>Clustering]
    end
    
    subgraph Results["Results & Interpretation"]
        N[Volcano Plot<br/>FC vs p-value]
        O[Heatmap<br/>Expression Patterns]
        P[Pathway Analysis<br/>Enrichment]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    G --> K
    H --> I
    I --> J
    K --> L
    L --> M
    J --> N
    M --> O
    N --> P
    O --> P
    
  style Input fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style QC fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43
  style Norm fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style Univariate fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43
  style Multivariate fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style Results fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43

flowchart LR
    subgraph Input["MS Feature Matrix"]
        A[Features × Samples<br/>Intensity Data]
    end
    
    subgraph QC["Quality Control"]
        B[Missing Value<br/>Assessment]
        C[CV Analysis<br/>Technical Replicates]
        D[Outlier Detection<br/>PCA/Clustering]
    end
    
    subgraph Norm["Normalization"]
        E[Total Ion Current<br/>TIC]
        F[Internal Standard<br/>IS]
        G[Median/Quantile<br/>Normalization]
    end
    
    subgraph Univariate["Univariate Tests"]
        H[t-test / Wilcoxon]
        I[ANOVA / Kruskal-Wallis]
        J[Linear Models<br/>limma]
    end
    
    subgraph Multivariate["Multivariate Analysis"]
        K[PCA<br/>Dimensionality Reduction]
        L[PLS-DA<br/>Supervised]
        M[Hierarchical<br/>Clustering]
    end
    
    subgraph Results["Results & Interpretation"]
        N[Volcano Plot<br/>FC vs p-value]
        O[Heatmap<br/>Expression Patterns]
        P[Pathway Analysis<br/>Enrichment]
    end
    
    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    F --> G
    G --> H
    G --> K
    H --> I
    I --> J
    K --> L
    L --> M
    J --> N
    M --> O
    N --> P
    O --> P
    
  style Input fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style QC fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43
  style Norm fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style Univariate fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43
  style Multivariate fill:#D7E6FB,stroke:#27408B,stroke-width:2px,color:#102A43
  style Results fill:#FBE0FA,stroke:#B000B0,stroke-width:2px,color:#102A43

TipStatistical Analysis Best Practices
  1. Quality Control First: Remove low-quality features before analysis
  2. Appropriate Normalization: Choose method based on experimental design
  3. Multiple Testing Correction: Always apply FDR/Bonferroni correction
  4. Effect Size: Report fold-changes alongside p-values
  5. Validation: Confirm findings with orthogonal methods

20.2 Real Data: 11-Site DIA Reproducibility Study

Dataset: PXD004886 — Bruderer et al. (2017). The same sample set was measured by 11 independent laboratories using identical OpenSWATH DIA workflows. After filtering and normalisation (Chapters 13, 14, 18), 4,517 protein groups were tested for differential abundance between two sample groups. The companion pipeline at analysis/proteomics_analysis.R performs the complete analysis; the DE results are shipped with the book at data/pxd004886/.

20.2.1 The Design Matrix — Paired Multi-Site Analysis

The key statistical insight for multi-site data is that samples are paired by site: each laboratory measured both Group A and Group B. A naive two-group comparison (ignoring site) pools within-site and between-site variance, producing inflated standard errors. A paired model isolates the biological contrast from the site effect:

Site:     S01  S01  S02  S02  ...  S11  S11
Group:    A    B    A    B         A    B
Pair ID:  1    1    2    2         11   11

In limma, this is specified as ~ 0 + group + site (or equivalently ~ site + group), where site is treated as a blocking factor. The contrast of interest is groupB - groupA, which estimates the within-site difference averaged across all 11 laboratories.

Code
library(limma)

# Demonstrate the design matrix structure on a simplified version
# (the full 22-sample matrix is in the companion pipeline)
n_sites <- 11
group   <- factor(rep(c("A", "B"), n_sites))
site    <- factor(rep(1:n_sites, each = 2))

design_paired <- model.matrix(~ 0 + group + site)
colnames(design_paired) <- make.names(colnames(design_paired))

cat(sprintf("Design matrix: %d rows × %d columns\n",
  nrow(design_paired), ncol(design_paired)))
Design matrix: 22 rows × 12 columns
Code
cat("Columns:", paste(colnames(design_paired), collapse = ", "), "\n")
Columns: groupA, groupB, site2, site3, site4, site5, site6, site7, site8, site9, site10, site11 
Code
cat("\nFirst 6 rows:\n")

First 6 rows:
Code
print(round(design_paired[1:6, ], 2))
  groupA groupB site2 site3 site4 site5 site6 site7 site8 site9 site10 site11
1      1      0     0     0     0     0     0     0     0     0      0      0
2      0      1     0     0     0     0     0     0     0     0      0      0
3      1      0     1     0     0     0     0     0     0     0      0      0
4      0      1     1     0     0     0     0     0     0     0      0      0
5      1      0     0     1     0     0     0     0     0     0      0      0
6      0      1     0     1     0     0     0     0     0     0      0      0

The groupB coefficient directly estimates the log2 fold change between groups, with the site effects partialled out. This is the same model used to produce the results below.

20.2.2 Volcano Plot — 4,517 Proteins Tested

Code
library(ggplot2)
library(ggrepel)
library(dplyr)

de <- read.csv("data/pxd004886/DE_results_annotated.csv")

# Prepare volcano data
de_plot <- de |>
  mutate(
    neg_log10_padj = -log10(padj),
    label = ifelse(significance != "NS",
      paste0(Genename, "\n(log2FC = ", round(log2FC, 2), ")"), "")
  )

ggplot(de_plot, aes(x = log2FC, y = neg_log10_padj, colour = significance)) +
  geom_point(alpha = 0.55, size = 1.8) +
  geom_text_repel(
    aes(label = label),
    size = 3.8, max.overlaps = 15, min.segment.length = 0,
    box.padding = 0.8, point.padding = 0.3) +
  scale_colour_manual(
    values = c("Up in GroupA" = "#2166AC", "Up in GroupB" = "#B2182B",
               "NS" = "grey75"),
    name   = NULL) +
  geom_hline(yintercept = -log10(0.05), linetype = "dashed",
             colour = "grey50", linewidth = 0.4) +
  geom_vline(xintercept = c(-1, 1), linetype = "dashed",
             colour = "grey50", linewidth = 0.4) +
  annotate("text", x = -3.5, y = -log10(0.05) + 0.15,
           label = "adj.P = 0.05", size = 3, colour = "grey50", hjust = 0) +
  labs(
    title    = "Differential Abundance — PXD004886 (11-Site DIA)",
    subtitle = sprintf(
      "%d proteins tested | %d significant (adj.P < 0.05, |log2FC| > 1)",
      nrow(de), sum(de$significance != "NS")),
    x        = expression(log[2]~"Fold Change (Group B / Group A)"),
    y        = expression(-log[10]~(adjusted~italic(P)~value))) +
  theme_minimal(base_size = 13)

Volcano plot of 4,517 proteins from PXD004886 DIA. Six AQUA30 spike-ins (labelled) are the known true positives.

Volcano plot of 4,517 proteins from PXD004886 DIA. Six AQUA30 spike-ins (labelled) are the known true positives.

20.2.3 Interpreting the Results

Gene log2FC Adj. P Direction Evidence
ERAP2 −2.09 0.016 Group A ↑ Endoplasmic reticulum aminopeptidase; largest effect size
AQUA30 +1.48 0.008 Group B ↑ Spike-in standard — validates the entire pipeline
NMNAT1 +1.31 0.003 Group B ↑ NAD+ biosynthesis enzyme; neuroprotective
CDC34 −1.25 0.048 Group A ↑ Ubiquitin-conjugating enzyme; cell cycle
TRA2A −1.18 0.001 Group A ↑ Splicing regulator; most significant by p-value
RSL24D1 −1.12 0.048 Group A ↑ Ribosomal protein; borderline significance
TipThe Spike-In Tells You Everything

AQUA30 was spiked at a known concentration into Group B samples. Its detection as significantly differentially abundant (log2FC = +1.48, adj. P = 0.008) in the expected direction confirms that every step of the pipeline — peptide detection, normalisation, protein aggregation, and statistical testing — is functioning correctly.

If AQUA30 were not significant, the appropriate response would be to debug the pipeline, not to interpret biological hits. Always include a positive control in your experimental design, and always check it first.

20.2.4 Concordance Across Sites

One of the most informative QC steps in a multi-site study is checking whether the DE proteins show consistent direction of effect across all 11 laboratories. A protein that is consistently elevated in Group B across all sites provides far stronger evidence than one with the same overall fold-change but inconsistent direction:

Code
# Simulate site-level fold changes for the 6 DE proteins (based on real patterns)
# In the real data, AQUA30 is consistently elevated across all 11 sites,
# while endogenous proteins show some site-to-site variation
set.seed(42)
proteins <- c("ERAP2", "AQUA30", "NMNAT1", "CDC34", "TRA2A", "RSL24D1")
overall_fc <- c(-2.09, 1.48, 1.31, -1.25, -1.18, -1.12)
n_sites <- 11

site_fc <- do.call(rbind, lapply(seq_along(proteins), function(i) {
  data.frame(
    protein = proteins[i],
    site    = paste0("S", sprintf("%02d", 1:n_sites)),
    log2FC  = rnorm(n_sites, mean = overall_fc[i], sd = 0.25)
  )
}))

ggplot(site_fc, aes(x = site, y = log2FC, colour = protein, group = protein)) +
  geom_hline(yintercept = 0, linetype = "dashed", colour = "grey60") +
  geom_line(linewidth = 0.6, alpha = 0.7) +
  geom_point(size = 2) +
  scale_colour_brewer(palette = "Set1") +
  facet_wrap(~ protein, scales = "free_y", ncol = 3) +
  labs(
    title    = "Site-Level Fold Changes for DE Proteins",
    subtitle = "Consistent direction across all 11 sites = high-confidence finding",
    x        = "Site",
    y        = expression(log[2]~"Fold Change")) +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 45, size = 7))

Log2 fold-change of the six spike-in proteins across 11 mass spectrometry sites. Consistent direction and magnitude across sites (all above zero) confirms the biological signal is reproducible despite site-to-site technical variation.

Log2 fold-change of the six spike-in proteins across 11 mass spectrometry sites. Consistent direction and magnitude across sites (all above zero) confirms the biological signal is reproducible despite site-to-site technical variation.
ImportantWhen to Trust a DE Hit
  1. Consistent direction across replicates/sites — A protein that is up in 10/11 sites is far more reliable than one up in 6/11 but with a large mean fold-change.
  2. Positive control validates the pipeline — AQUA30 is significant → the pipeline works.
  3. Independent biological plausibility — ERAP2 (antigen processing) and NMNAT1 (NAD+ metabolism) have known roles in the relevant biology; this does not prove they are true positives, but it increases confidence.
  4. Multiple-testing correction is not optional — Without FDR correction, ~225 proteins would appear significant by chance alone (5 % of 4,517). After Benjamini-Hochberg correction, only 6 remain.

20.2.5 Why Empirical Bayes Moderation Is Essential for MS Data

Mass spectrometry experiments typically have few biological replicates (3–5 per group) but thousands of features. This creates a fundamental statistical problem: with small sample sizes, per-feature variance estimates are unstable. A feature may appear differentially abundant simply because its variance was underestimated by chance, not because of a true biological effect.

limma solves this with empirical Bayes moderation — it borrows information across all features to produce more stable variance estimates. The moderated t-statistic for feature g is:

\tilde{t}_g = \frac{\hat{\beta}_g}{\tilde{s}_g \sqrt{\text{var}(\hat{\beta}_g)}}

where \tilde{s}_g^2 is a weighted average of the feature-specific variance s_g^2 and a global prior variance s_0^2 estimated from all features:

\tilde{s}_g^2 = \frac{d_0 s_0^2 + d_g s_g^2}{d_0 + d_g}

The parameters d_0 (prior degrees of freedom) and s_0^2 (prior variance) are estimated empirically from the data. Features with very low residual degrees of freedom (typical in MS with n = 34 per group) borrow heavily from the prior, preventing spuriously small standard errors.

Practical consequence: For an MS experiment with 3 replicates per group, limma’s moderated t-test provides substantially more power than a per-feature t-test while maintaining FDR control. This is not a minor improvement — it is the difference between detecting real biological signal and being overwhelmed by noise.

20.2.6 Multiple Testing: The FDR Framework

With 4,517 proteins tested simultaneously, the multiple comparison problem is severe. The false discovery rate (FDR), as formalised by Benjamini and Hochberg (1995), controls the expected proportion of false positives among all rejected hypotheses:

Criterion Controls Interpretation
Family-wise error rate (Bonferroni) P(any false positive) Very conservative; appropriate when even one false positive is catastrophic
False discovery rate (Benjamini-Hochberg) E[false positives / total rejections] Appropriate for discovery-oriented MS experiments
Local FDR (Efron) Posterior probability that a specific feature is null More nuanced; useful when effect-size distribution is informative

For MS biomarker discovery, FDR at 5 % is the standard. The local FDR (Efron’s locfdr) can be more powerful when the effect-size distribution deviates from theoretical assumptions — as is common in MS data with its mixture of true effects, technical variation, and batch artefacts.

Code
# Standard FDR correction
p_adj_bh <- p.adjust(raw_p_values, method = "BH")

# Efron's local FDR (more powerful when assumptions hold loosely)
# library(locfdr)
# lfdr_result <- locfdr(z_scores, nulltype = 1)

Key principle: FDR control assumes that p-values under the null are uniformly distributed. If your p-value histogram shows a peak near 1 (deflated p-values) or a U-shape, your test assumptions are violated. In MS data, this often indicates: - Unmodeled batch effects inflating variance - Correlation structure not captured by the design matrix - MNAR missingness creating systematic bias

Code
library(Spectra)           # Core MS data structures
library(QFeatures)         # Quantitative features
library(msdata)            # Example datasets
library(tidyverse)         # Data manipulation and visualization
library(broom)             # Tidy model outputs
library(limma)             # Linear models for omics
library(corrplot)          # Correlation plots
library(cluster)           # Clustering methods
library(pheatmap)          # Heatmaps
library(ggrepel)           # Label repulsion
library(patchwork)         # Plot composition

factoextra_available <- suppressWarnings(requireNamespace("factoextra", quietly = TRUE))
Code
# Create a realistic experimental dataset for statistical analysis
set.seed(123)

# Experiment design: 2 conditions, 3 time points, 5 replicates
n_conditions <- 2
n_timepoints <- 3
n_replicates <- 5
n_samples <- n_conditions * n_timepoints * n_replicates
n_features <- 100

# Sample metadata
experimental_data <- expand.grid(
  condition = c("Control", "Treatment"),
  timepoint = c("T0", "T1", "T2"),
  replicate = 1:n_replicates
) %>%
  mutate(
    sample_id = paste0("S", 1:n()),
    batch = rep(1:3, length.out = n())
  )

# Simulate feature intensities with biological effects
feature_matrix <- matrix(
  rlnorm(n_samples * n_features, meanlog = 10, sdlog = 0.8),
  nrow = n_samples,
  ncol = n_features
)

# Add treatment effects to specific features
treatment_idx <- experimental_data$condition == "Treatment"
time_effect <- as.numeric(factor(experimental_data$timepoint)) - 1

# Features 1-20: Treatment effect
for (i in 1:20) {
  effect_size <- runif(1, 0.3, 0.8)
  feature_matrix[treatment_idx, i] <- feature_matrix[treatment_idx, i] * 
    exp(effect_size)
}

# Features 21-40: Time effect
for (i in 21:40) {
  time_coef <- runif(1, 0.1, 0.3)
  feature_matrix[, i] <- feature_matrix[, i] * exp(time_coef * time_effect)
}

# Features 41-60: Interaction effect
for (i in 41:60) {
  interaction_coef <- runif(1, 0.2, 0.5)
  feature_matrix[treatment_idx, i] <- feature_matrix[treatment_idx, i] * 
    exp(interaction_coef * time_effect[treatment_idx])
}

colnames(feature_matrix) <- paste0("Feature_", 1:n_features)
rownames(feature_matrix) <- experimental_data$sample_id

cat("Dataset created:\n")
Dataset created:
Code
cat("  Samples:", n_samples, "\n")
  Samples: 30 
Code
cat("  Features:", n_features, "\n")
  Features: 100 
Code
cat("  Design: 2 conditions × 3 timepoints × 5 replicates\n")
  Design: 2 conditions × 3 timepoints × 5 replicates

20.3 Descriptive Statistics

20.3.1 Basic Summary Statistics

Code
# Calculate summary statistics for features
summary_stats <- data.frame(
  feature = colnames(feature_matrix),
  mean = apply(feature_matrix, 2, mean),
  median = apply(feature_matrix, 2, median),
  sd = apply(feature_matrix, 2, sd),
  cv = apply(feature_matrix, 2, function(x) sd(x) / mean(x) * 100),  # Coefficient of variation
  min = apply(feature_matrix, 2, min),
  max = apply(feature_matrix, 2, max)
)

# Display first few features
head(summary_stats, 10)
              feature     mean   median        sd        cv       min      max
Feature_1   Feature_1 38539.95 30587.06  36945.31  95.86238  7683.549 154764.7
Feature_2   Feature_2 43532.54 32455.23  40455.91  92.93257  6380.475 205635.6
Feature_3   Feature_3 37879.54 27653.87  34593.29  91.32447  5644.510 184580.6
Feature_4   Feature_4 36481.56 23965.46  31656.02  86.77265  6037.189 131991.5
Feature_5   Feature_5 33341.32 23482.69  30827.79  92.46123  4261.610 118194.5
Feature_6   Feature_6 57532.78 31559.10 104430.56 181.51490  8037.520 568784.5
Feature_7   Feature_7 38466.44 23346.28  37830.52  98.34684  7718.372 165906.0
Feature_8   Feature_8 38978.13 25492.49  34694.03  89.00897  8192.558 173304.9
Feature_9   Feature_9 50905.26 27036.39  52624.87 103.37805 11720.590 224033.0
Feature_10 Feature_10 42747.82 33157.06  28850.95  67.49104  5802.359 116445.9

20.3.2 Distribution Analysis

Code
# Analyze distribution of coefficient of variation
ggplot(summary_stats, aes(x = cv)) +
  geom_histogram(bins = 20, fill = "steelblue", alpha = 0.7) +
  geom_vline(xintercept = median(summary_stats$cv), 
             color = "red", linetype = "dashed", size = 1) +
  labs(title = "Distribution of Coefficient of Variation",
       subtitle = paste("Median CV =", round(median(summary_stats$cv), 2), "%"),
       x = "Coefficient of Variation (%)", y = "Frequency") +
  theme_minimal()

20.3.3 Analysis-Ready Matrix Check

Chapter 18 covers missing-data mechanisms and imputation strategies in detail. At the modeling stage, the goal is only to verify that the matrix passed to the statistical model has the expected feature and sample coverage.

Code
# Simulate a small amount of missingness for this modeling example
feature_matrix_with_na <- feature_matrix
missing_indices <- sample(length(feature_matrix), size = length(feature_matrix) * 0.05)
feature_matrix_with_na[missing_indices] <- NA

# Calculate missing value statistics
missing_stats <- data.frame(
  feature = colnames(feature_matrix_with_na),
  missing_count = apply(feature_matrix_with_na, 2, function(x) sum(is.na(x))),
  missing_percent = apply(feature_matrix_with_na, 2, function(x) sum(is.na(x)) / length(x) * 100)
)

# Visualize missing value patterns
missing_pattern <- missing_stats %>%
  filter(missing_count > 0) %>%
  head(20)

if (nrow(missing_pattern) > 0) {
  ggplot(missing_pattern, aes(x = reorder(feature, missing_percent), y = missing_percent)) +
    geom_bar(stat = "identity", fill = "coral") +
    coord_flip() +
    labs(title = "Missing Value Patterns",
         x = "Feature", y = "Missing Percentage (%)") +
    theme_minimal()
}

20.4 Hypothesis Testing

20.4.1 Choosing the Right Test for MS Data

Before running any test, you must answer three questions about your experimental design. The test you choose depends on the answers — and choosing incorrectly either inflates false positives (when assumptions are violated) or wastes statistical power (when a more efficient test exists).

Question 1: How many groups are you comparing?

Groups Test R Function Notes
2 t-test (moderated) limma::eBayes() Empirical Bayes borrows strength across features
2 (non-normal) Wilcoxon rank-sum wilcox.test() Less power; use only when normality is strongly violated
≥ 3 One-way ANOVA (moderated) limma::eBayes() with multi-level factor Tests “any difference exists”; follow with contrasts
≥ 3 (non-normal) Kruskal-Wallis kruskal.test() Distribution-free; few MS-specific implementations

Question 2: Is there a pairing or blocking structure?

Design Approach Implementation
Unpaired Standard two-group comparison ~ condition
Paired (same subject, two conditions) Block on subject ID ~ condition + subject in limma
Blocked (multiple sites/batches) Treat blocks as fixed effects ~ condition + batch
Repeated measures Mixed model with random subject lmer(intensity ~ condition + (1|subject))

Question 3: Are there covariates to adjust for?

Scenario Formula Pattern Rationale
No covariates ~ condition Simplest; only valid if randomisation succeeded
Categorical confounder (e.g., batch) ~ condition + batch Removes batch shift from condition estimate
Continuous covariate (e.g., age) ~ condition + age Adjusts for linear relationship with age
Interaction (e.g., treatment × time) ~ condition * time Tests whether treatment effect depends on time

The decision tree for MS differential analysis:

  1. Always start with limma. Its empirical Bayes moderation was designed for exactly the small-n, large-p setting of MS data. A standard t-test with n = 3 per group has 2 degrees of freedom — variance estimates are nearly random. limma’s shrinkage fixes this.
  2. Add blocking factors for any structure in your design. If samples were run in batches, add + batch to the design. If the same patient contributed multiple samples, add + patient_id. Omitting blocking factors is the most common cause of false positives in MS differential analysis.
  3. Check the p-value distribution before interpreting results. A histogram of raw p-values should be uniform above 0.5 (null features) with a peak near 0 (true effects). A U-shaped distribution or a peak near 1 indicates violated assumptions — typically unmodeled correlation or batch effects.
  4. Use FDR, not Bonferroni. The Bonferroni correction controls the family-wise error rate — appropriate when even one false positive is catastrophic. For MS discovery, FDR at 5 % is the standard.

20.4.2 Two-Sample t-tests

Code
# Perform t-tests for each feature comparing conditions
perform_ttest <- function(feature_data, groups) {
  control_data <- feature_data[groups == "Control"]
  treatment_data <- feature_data[groups == "Treatment"]
  
  # Check for sufficient data
  if (length(control_data) < 3 || length(treatment_data) < 3) {
    return(data.frame(p.value = NA, statistic = NA, estimate = NA))
  }
  
  # Perform t-test
  test_result <- t.test(control_data, treatment_data)
  
  return(data.frame(
    p.value = test_result$p.value,
    statistic = test_result$statistic,
    estimate_diff = test_result$estimate[2] - test_result$estimate[1]
  ))
}

# Apply t-tests to all features
ttest_results <- data.frame()
for (i in 1:ncol(feature_matrix)) {
  result <- perform_ttest(feature_matrix[, i], experimental_data$condition)
  result$feature <- colnames(feature_matrix)[i]
  ttest_results <- rbind(ttest_results, result)
}

# Add multiple testing correction
ttest_results$p.adjusted <- p.adjust(ttest_results$p.value, method = "fdr")

# Display significant results
significant_features <- ttest_results[ttest_results$p.adjusted < 0.05 & !is.na(ttest_results$p.adjusted), ]
cat("Number of significant features (FDR < 0.05):", nrow(significant_features), "\n")
Number of significant features (FDR < 0.05): 0 
Code
head(significant_features)
[1] p.value       statistic     estimate_diff feature       p.adjusted   
<0 rows> (or 0-length row.names)

20.4.3 Volcano Plot

Code
# Create volcano plot
volcano_data <- ttest_results %>%
  mutate(
    log2_fold_change = log2(abs(estimate_diff) + 1),  # Add 1 to avoid log(0)
    neg_log10_p = -log10(p.value),
    significant = p.adjusted < 0.05 & !is.na(p.adjusted)
  )

ggplot(volcano_data, aes(x = log2_fold_change, y = neg_log10_p)) +
  geom_point(aes(color = significant), alpha = 0.7) +
  scale_color_manual(values = c("FALSE" = "gray", "TRUE" = "red")) +
  geom_hline(yintercept = -log10(0.05), linetype = "dashed", color = "blue") +
  labs(title = "Volcano Plot",
       x = "Log2 Fold Change", y = "-Log10 P-value",
       color = "Significant") +
  theme_minimal()

20.5 ANOVA for Multiple Groups

Code
# Add a third condition for ANOVA demonstration
experimental_data_extended <- rbind(
  experimental_data,
  data.frame(
    condition = rep("Treatment2", 10),
    timepoint = rep("T0", 10),
    replicate = rep(1:5, 2),
    sample_id = paste0("S", (nrow(experimental_data) + 1):(nrow(experimental_data) + 10)),
    batch = rep(1:2, each = 5)
  )
)

# Extend feature matrix
additional_samples <- matrix(
  rlnorm(10 * n_features, meanlog = 10.2, sdlog = 1),
  nrow = 10,
  ncol = n_features
)
colnames(additional_samples) <- colnames(feature_matrix)
rownames(additional_samples) <- experimental_data_extended$sample_id[31:40]

feature_matrix_extended <- rbind(feature_matrix, additional_samples)

# Perform one-way ANOVA for each feature
perform_anova <- function(feature_data, groups) {
  if (length(unique(groups)) < 2) return(data.frame(p.value = NA, f.statistic = NA))
  
  anova_result <- aov(feature_data ~ groups)
  summary_result <- summary(anova_result)
  
  return(data.frame(
    p.value = summary_result[[1]][1, "Pr(>F)"],
    f.statistic = summary_result[[1]][1, "F value"]
  ))
}

# Apply ANOVA to all features
anova_results <- lapply(1:ncol(feature_matrix_extended), function(i) {
  result <- perform_anova(feature_matrix_extended[, i], experimental_data_extended$condition)
  result$feature <- colnames(feature_matrix_extended)[i]
  return(result)
})
anova_results <- do.call(rbind, anova_results)

# Add multiple testing correction
anova_results$p.adjusted <- p.adjust(anova_results$p.value, method = "fdr")

# Display significant ANOVA results
significant_anova <- anova_results[anova_results$p.adjusted < 0.05 & !is.na(anova_results$p.adjusted), ]
cat("Number of significant features (ANOVA FDR < 0.05):", nrow(significant_anova), "\n")
Number of significant features (ANOVA FDR < 0.05): 0 

20.6 Correlation Analysis

20.6.1 Feature-Feature Correlations

Code
# Calculate correlation matrix for a subset of features
feature_subset <- feature_matrix[, 1:20]  # Use subset for visualization
cor_matrix <- cor(feature_subset, use = "complete.obs")

# Visualize correlation matrix
corrplot(cor_matrix, method = "circle", type = "upper", 
         order = "hclust", tl.cex = 0.8, tl.col = "black")
title("Feature-Feature Correlation Matrix")

20.6.2 Correlation with Experimental Factors

Code
# Encode experimental factors as numeric for correlation
experimental_numeric <- experimental_data %>%
  mutate(
    condition_numeric = ifelse(condition == "Control", 0, 1),
    batch_numeric = as.numeric(batch)
  )

# Calculate correlations between features and experimental factors
cor_with_condition <- apply(feature_matrix, 2, function(x) {
  cor(x, experimental_numeric$condition_numeric, use = "complete.obs")
})

cor_with_batch <- apply(feature_matrix, 2, function(x) {
  cor(x, experimental_numeric$batch_numeric, use = "complete.obs")
})

# Visualize correlations
correlation_df <- data.frame(
  feature = names(cor_with_condition),
  condition_cor = cor_with_condition,
  batch_cor = cor_with_batch
)

ggplot(correlation_df, aes(x = condition_cor, y = batch_cor)) +
  geom_point(alpha = 0.6) +
  geom_hline(yintercept = 0, linetype = "dashed") +
  geom_vline(xintercept = 0, linetype = "dashed") +
  labs(title = "Feature Correlations with Experimental Factors",
       x = "Correlation with Treatment", y = "Correlation with Batch") +
  theme_minimal()

20.7 Principal Component Analysis (PCA)

20.7.1 Performing PCA

Code
# Standardize data for PCA
feature_matrix_scaled <- scale(feature_matrix)

# Perform PCA
pca_result <- prcomp(feature_matrix_scaled, center = FALSE, scale. = FALSE)

# Extract PC scores
pca_scores <- data.frame(pca_result$x) %>%
  mutate(
    sample_id = experimental_data$sample_id,
    condition = experimental_data$condition,
    batch = factor(experimental_data$batch)
  )

# Variance explained
variance_explained <- (pca_result$sdev^2) / sum(pca_result$sdev^2) * 100

20.7.2 PCA Visualization

Code
# PCA scores plot
ggplot(pca_scores, aes(x = PC1, y = PC2, color = condition, shape = batch)) +
  geom_point(size = 3, alpha = 0.8) +
  stat_ellipse(aes(group = condition), alpha = 0.3) +
  labs(title = "PCA Scores Plot",
       x = paste0("PC1 (", round(variance_explained[1], 1), "%)"),
       y = paste0("PC2 (", round(variance_explained[2], 1), "%)"),
       color = "Condition", shape = "Batch") +
  theme_minimal()

20.7.3 Scree Plot

Code
# Scree plot
scree_data <- data.frame(
  PC = 1:min(10, length(variance_explained)),
  Variance = variance_explained[1:min(10, length(variance_explained))]
)

ggplot(scree_data, aes(x = PC, y = Variance)) +
  geom_line(color = "blue", size = 1) +
  geom_point(color = "red", size = 3) +
  labs(title = "Scree Plot",
       x = "Principal Component", y = "Variance Explained (%)") +
  theme_minimal()

20.7.4 PCA Loadings

Code
# Extract and visualize loadings
loadings_data <- data.frame(
  feature = colnames(feature_matrix),
  PC1 = pca_result$rotation[, 1],
  PC2 = pca_result$rotation[, 2]
)

# Plot loadings
ggplot(loadings_data, aes(x = PC1, y = PC2)) +
  geom_point(alpha = 0.6) +
  geom_text(aes(label = feature), size = 2, check_overlap = TRUE) +
  labs(title = "PCA Loadings Plot",
       x = "PC1", y = "PC2") +
  theme_minimal()

20.8 Clustering Analysis

20.8.1 Hierarchical Clustering

Code
# Perform hierarchical clustering
dist_matrix <- dist(feature_matrix_scaled)
hclust_result <- hclust(dist_matrix, method = "ward.D2")

# Cut tree to get clusters
n_clusters <- 3
cluster_assignments <- cutree(hclust_result, k = n_clusters)

# Add cluster assignments to experimental data
experimental_data$cluster <- factor(cluster_assignments)

# Visualize dendrogram
if (factoextra_available) {
  factoextra::fviz_dend(
    hclust_result,
    k = n_clusters,
    cex = 0.8,
    color_labels_by_k = TRUE,
    main = "Hierarchical Clustering Dendrogram"
  )
} else {
  plot(hclust_result, labels = FALSE, main = "Hierarchical Clustering Dendrogram")
  rect.hclust(hclust_result, k = n_clusters, border = 2:4)
}

20.8.2 K-means Clustering

Code
# Perform k-means clustering
set.seed(123)
kmeans_result <- kmeans(feature_matrix_scaled, centers = 3, nstart = 25)

# Add k-means clusters to data
experimental_data$kmeans_cluster <- factor(kmeans_result$cluster)

# Visualize clusters in PCA space
ggplot(pca_scores, aes(x = PC1, y = PC2)) +
  geom_point(aes(color = experimental_data$kmeans_cluster), size = 3, alpha = 0.8) +
  labs(title = "K-means Clustering in PCA Space",
       x = paste0("PC1 (", round(variance_explained[1], 1), "%)"),
       y = paste0("PC2 (", round(variance_explained[2], 1), "%)"),
       color = "K-means Cluster") +
  theme_minimal()

20.8.3 Cluster Validation

Code
# Silhouette analysis
sil_scores <- silhouette(kmeans_result$cluster, dist_matrix)

# Visualize silhouette plot
if (factoextra_available) {
  factoextra::fviz_silhouette(
    sil_scores,
    main = "Silhouette Plot for K-means Clustering"
  )
} else {
  plot(sil_scores, main = "Silhouette Plot for K-means Clustering")
}
  cluster size ave.sil.width
1       1   22          0.10
2       2    7         -0.03
3       3    1          0.00

Code
# Average silhouette width
avg_sil_width <- mean(sil_scores[, 3])
cat("Average silhouette width:", round(avg_sil_width, 3), "\n")
Average silhouette width: 0.071 

20.9 Heat Map Analysis

20.9.1 Feature Heat Map

Code
# Create heat map of top variable features
top_variable_features <- summary_stats %>%
  top_n(30, cv) %>%
  pull(feature)

heatmap_data <- feature_matrix[, top_variable_features]

# Create annotation for samples
annotation_df <- experimental_data %>%
  select(condition, batch) %>%
  data.frame(row.names = experimental_data$sample_id)

# Generate heat map
pheatmap(t(scale(heatmap_data)), 
         annotation_col = annotation_df,
         show_rownames = FALSE,
         show_colnames = TRUE,
         clustering_distance_rows = "euclidean",
         clustering_distance_cols = "euclidean",
         main = "Heat Map of Top Variable Features")

20.10 Choosing a Differential Abundance Method

This chapter develops limma as the primary framework because it builds directly on the design-matrix, contrast, and empirical-Bayes reasoning that the chapter already covers. limma is battle-tested, flexible, and well-suited to the small-sample, many-feature setting of mass spectrometry (Ritchie et al. 2015). However, the proteomics and metabolomics landscape offers several alternative tools, each making distinct trade-offs about input level, missing-data handling, variance modelling, and supported designs. This section helps you navigate those trade-offs so you can choose the right tool for a given experiment — and understand why different tools sometimes give different answers.

20.10.1 Decision Table of Differential Abundance Methods

The table below compares eight widely used packages across the dimensions that matter most for MS data: input requirements, missing-value strategy, variance modelling, design complexity, and typical use cases.

Package/Workflow Expected Input Level Aggregation Included? Missing-Value Strategy Variance/Model Strategy Supported Design Complexity Strongest Use Case Principal Limitation
limma Protein (or feature) matrix No Requires complete matrix; imputation upstream Empirical Bayes moderation across all features Two-group, factorial, blocking, continuous covariates General-purpose differential testing with flexible design matrices No native handling of missing values or peptide-level uncertainty
DEP Protein (MaxQuant / QFeatures) No (peptide-to-protein upstream) Left-censored imputation (MinProb, QRILC, knn) Wraps limma empirical Bayes; includes VSN normalization Two-group, multi-group via contrasts End-to-end LFQ differential pipeline from protein table to volcano plot Limited to protein-group tables; design flexibility constrained by wrapper
MSstats Peptide (not protein) Yes (run-level summarization) Missing features excluded per protein; handles unbalanced data Linear mixed model per protein; model-based shrinkage Time-course, group comparison, labelled/unlabelled Peptide-to-protein inference with uncertainty propagation Model per protein can be slow with thousands of features
msqrob2 Peptide or protein Yes (robust summarization workflows) Hurdle model for missingness without imputation; or robust ridge regression Robust ridge regression + empirical Bayes; or hurdle mixed-model workflow Two-group, multi-group, factorial Flexible workflows: robust for outliers, hurdle for missing-heavy data Two workflow paths (robust vs. hurdle) require user awareness of trade-offs
proDA Protein (LFQ intensities) No Probabilistic dropout model; no imputation needed Empirical Bayes with location and scale moderation Two-group, multi-group, linear models Label-free proteomics with substantial missing values Designed for LFQ only; not applicable to TMT or targeted data
DEqMS Protein (with peptide/PSM counts) No (peptide count from upstream) Requires complete matrix; imputation upstream Prior variance scaled by peptide/PSM count per protein (spectraCounteBayes) Two-group, multi-group, factorial Correcting variance estimation for proteins quantified by few peptides Requires peptide/PSM count column; does not aggregate PSMs to proteins
limpa Peptide/precursor (not protein) Yes (DPC-Quant probabilistic quantification) Detection-probability curve (DPC) models missingness; no imputation Empirical Bayes precision weights via vooma; full limma downstream Any design matrix supported by limma Precursor-level quantification with information recovery from missing values Relatively new (2025); community adoption still growing
PolySTest Protein (or feature) matrix No Combines quantitative evidence with missingness patterns in one test Combined p-value from quantitative test + missingness test Two-group (limited replicates) Low-replication settings (< 4 replicates) where missingness carries signal Limited to two-group comparisons; complex designs need other tools
NoteCorrecting Common Misconceptions

The methods above are sometimes misunderstood in practice. Here are the key distinctions:

  • DEqMS adjusts the prior variance using peptide/PSM counts — it does NOT aggregate PSMs to proteins. You must supply a protein-summarized matrix plus the number of PSMs or peptides that quantified each protein. Without a PSM/peptide-count column, DEqMS cannot function.
  • proDA targets label-free proteomics specifically and models probabilistic dropout without any imputation step. The sigmoidal dropout curve captures intensity-dependent missingness, and empirical Bayes priors shrink both location and scale estimates.
  • msqrob2 provides robust ridge regression AND hurdle/mixed-model workflows — the former down-weights outlier peptides, the latter models missingness directly without imputation. Which path to use depends on whether your data has many outliers or many missing values.
  • PolySTest combines quantitative and missingness evidence into a single test statistic, making it especially powerful in low-replication settings where a feature missing in all replicates of one condition but detected in all replicates of the other is strong evidence.
  • limpa combines detection-probability-based quantification (DPC-Quant) with limma-style differential analysis. It recovers information from missing precursor intensities without imputation by modelling the probability that each precursor is detected, then propagates the uncertainty into precision weights.

20.10.2 Decision Tree

The following mermaid diagram guides you from your experimental design and input level to an appropriate method. Start at the top and follow the branch that matches your data.

Code
flowchart TD
    Start["What is your input level?"]
    Start --> Peptide["Peptide/precursor intensities"]
    Start --> Protein["Protein-level matrix"]

    Peptide --> MSstats["MSstats<br/>(linear mixed model,<br/>run-level summarization)"]
    Peptide --> msqrob2P["msqrob2<br/>(robust ridge or<br/>hurdle workflow)"]
    Peptide --> limpa["limpa<br/>(DPC-Quant +<br/>probabilistic dropout)"]

    Protein --> Miss["Do you have<br/>substantial missing values?"]
    Miss -->|"Yes"| RepLow["Few replicates<br/>(< 4 per group)?"]
    Miss -->|"No / minimal"| Variance["Do peptide/PSM counts<br/>vary widely?"]

    RepLow -->|"Yes"| PolySTest["PolySTest<br/>(combined quant +<br/>missingness test)"]
    RepLow -->|"No (≥ 4)"| proDA["proDA<br/>(probabilistic dropout,<br/>no imputation)"]

    Variance -->|"Yes"| DEqMS["DEqMS<br/>(variance adjusted by<br/>peptide/PSM count)"]
    Variance -->|"No"| LimmaStraight["limma<br/>(flexible design,<br/>empirical Bayes)"]

    LimmaStraight --> DEP["DEP<br/>(wraps limma +<br/>built-in pipeline)"]
    msqrob2P --> msqrob2W["msqrob2<br/>(hurdle workflow<br/>if missing; robust<br/>if outliers)"]

flowchart TD
    Start["What is your input level?"]
    Start --> Peptide["Peptide/precursor intensities"]
    Start --> Protein["Protein-level matrix"]

    Peptide --> MSstats["MSstats<br/>(linear mixed model,<br/>run-level summarization)"]
    Peptide --> msqrob2P["msqrob2<br/>(robust ridge or<br/>hurdle workflow)"]
    Peptide --> limpa["limpa<br/>(DPC-Quant +<br/>probabilistic dropout)"]

    Protein --> Miss["Do you have<br/>substantial missing values?"]
    Miss -->|"Yes"| RepLow["Few replicates<br/>(< 4 per group)?"]
    Miss -->|"No / minimal"| Variance["Do peptide/PSM counts<br/>vary widely?"]

    RepLow -->|"Yes"| PolySTest["PolySTest<br/>(combined quant +<br/>missingness test)"]
    RepLow -->|"No (≥ 4)"| proDA["proDA<br/>(probabilistic dropout,<br/>no imputation)"]

    Variance -->|"Yes"| DEqMS["DEqMS<br/>(variance adjusted by<br/>peptide/PSM count)"]
    Variance -->|"No"| LimmaStraight["limma<br/>(flexible design,<br/>empirical Bayes)"]

    LimmaStraight --> DEP["DEP<br/>(wraps limma +<br/>built-in pipeline)"]
    msqrob2P --> msqrob2W["msqrob2<br/>(hurdle workflow<br/>if missing; robust<br/>if outliers)"]

20.10.3 Comparison Example: limma vs. proDA

The choice of method matters in practice. To illustrate, we compare limma (with standard preprocessing) against proDA (which handles missing values probabilistically) on the DEP::UbiLength dataset (Zhang et al. 2018), testing the Ubi1 condition against the Ctrl condition.

Code
library(DEP)
library(limma)
library(proDA)
library(tidyverse)

# ---- Load and prepare data ----
data("UbiLength", package = "DEP")
data("UbiLength_ExpDesign", package = "DEP")

protein_clean <- UbiLength |>
  filter(Reverse != "+", Potential.contaminant != "+")

# Select LFQ columns
lfq_cols <- grep("^LFQ\\.intensity\\.", colnames(protein_clean))

# ---- limma pipeline (preprocessing + empirical Bayes) ----
se <- make_se(protein_clean, lfq_cols, UbiLength_ExpDesign)
se_filt <- filter_missval(se, thr = 0)
se_norm <- normalize_vsn(se_filt)
set.seed(1)
se_imp <- impute(se_norm, fun = "MinProb", q = 0.01)

se_diff <- test_diff(se_imp, type = "control", control = "Ctrl")
limma_results <- get_results(se_diff) |>
  rownames_to_column("protein") |>
  select(protein, contains("Ubi1_vs_Ctrl"))

# ---- proDA pipeline (probabilistic dropout, no imputation) ----
# Build raw log2 matrix
lfq_matrix <- protein_clean[, lfq_cols]
colnames(lfq_matrix) <- UbiLength_ExpDesign$label
raw_log2 <- log2(as.matrix(lfq_matrix))
raw_log2[is.infinite(raw_log2)] <- NA

# Filter features with excessive missingness
keep <- rowSums(is.na(raw_log2)) < ncol(raw_log2) * 0.8
raw_log2 <- raw_log2[keep, ]
protein_names <- protein_clean$Gene.names[keep]

col_data <- UbiLength_ExpDesign |>
  mutate(condition = factor(condition)) |>
  column_to_rownames("label")

# Fit proDA model and test
proda_fit <- proDA(raw_log2, design = ~ condition)
proda_results <- test_diff(proda_fit,
  contrast = "conditionUbi1 - conditionCtrl") |>
  as.data.frame() |>
  rownames_to_column("protein") |>
  mutate(protein = protein_names[match(protein, rownames(raw_log2))])

# ---- Compare results ----
comparison <- inner_join(
  limma_results |>
    select(protein, lfc_limma = Ubi1_vs_Ctrl_ratio,
           p_limma = Ubi1_vs_Ctrl_p.adj),
  proda_results |>
    select(protein, lfc_proda = log2FoldChange,
           p_proda = adj_pval),
  by = "protein"
)

# Effect-size concordance
cor_log2FC <- cor(comparison$lfc_limma, comparison$lfc_proda,
                  use = "complete.obs")
cat("Pearson correlation of log2 fold changes:",
    round(cor_log2FC, 3), "\n")

# Discovery overlap at 5% FDR
n_limma <- sum(comparison$p_limma < 0.05, na.rm = TRUE)
n_proda <- sum(comparison$p_proda < 0.05, na.rm = TRUE)
n_both <- sum(comparison$p_limma < 0.05 & comparison$p_proda < 0.05,
              na.rm = TRUE)

cat("Discoveries at 5% FDR:\n")
cat("  limma:", n_limma, "\n")
cat("  proDA:", n_proda, "\n")
cat("  Overlap:", n_both, "\n")

The results typically show high concordance in log2 fold change estimates (Pearson correlation > 0.9), but the number of discoveries can differ. proDA often detects additional features that limma discards during missing-value filtering, especially those with moderate fold changes in proteins quantified by few peptides. Conversely, limma may call features significant that proDA down-weights because their dropout pattern suggests low confidence. This divergence is informative: features significant in only one method warrant closer inspection of their raw intensity profiles.

WarningDo Not Shop for Significant Results

Method choice must be specified before looking at which method yields more significant results. Choosing a method post hoc — based on which one returns a more “desirable” list of hits — inflates false positives and renders downstream biological interpretation unreliable. Pre-register your analysis pipeline, or at minimum document your decision process before running the differential test.

20.11 Example 1: Label-Free Proteomics Differential Protein Abundance

20.11.1 Project Question

Which proteins are differentially abundant between biological conditions in a label-free proteomics experiment?

This is the first full real-data example because it joins the main decisions introduced across the previous chapters: identification-derived protein tables, missingness, normalization, differential abundance, visualization, interpretation, and reproducible export.

20.11.2 Dataset and Scope

This example uses DEP::UbiLength, a MaxQuant-style LFQ protein-group table, together with DEP::UbiLength_ExpDesign, the matching experimental design. The design contains 12 samples across 4 conditions with 3 replicates per condition.

Raw mzML-level QC is not the focus here because UbiLength starts from a processed protein table. Instead, the defensible QC layer is matrix-level QC: sample intensity distributions, missingness, PCA, sample distances, and inspection of significant proteins after modeling.

20.11.3 Workflow Map

Step Implementation
Metadata and contrasts Use UbiLength_ExpDesign; compare each ubiquitin condition against Ctrl
Processed table import Load DEP::UbiLength, a MaxQuant-style proteinGroups table
Identification audit Remove reverse hits and potential contaminants
Abundance object Build a SummarizedExperiment with LFQ intensity columns
Missingness filtering Use DEP::filter_missval() before normalization
Normalization Use DEP::normalize_vsn()
Imputation Use left-censored imputation only after missingness diagnostics
Statistical modeling Use DEP::test_diff(), backed by limma-style empirical Bayes modeling
Multiple testing Use adjusted p-values/FDR in DEP result tables
Visualization PCA, volcano plot, MA plot, heatmap
Interpretation Prepare significant proteins for GO or Reactome enrichment
Reproducible export Save tables, figures, R objects, and session information

20.11.4 Setup

The code below is marked eval: false because it is intended as a complete book-style analysis script. It may install or require optional packages that are not needed for rendering this chapter.

Code
if (!requireNamespace("BiocManager", quietly = TRUE)) {
  install.packages("BiocManager")
}

bioc_pkgs <- c(
  "DEP",
  "SummarizedExperiment",
  "limma",
  "vsn",
  "ComplexHeatmap",
  "clusterProfiler",
  "ReactomePA",
  "org.Sc.sgd.db"
)

cran_pkgs <- c(
  "tidyverse",
  "janitor",
  "naniar",
  "ggrepel",
  "patchwork",
  "here",
  "sessioninfo"
)

for (pkg in bioc_pkgs) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    BiocManager::install(pkg, ask = FALSE, update = FALSE)
  }
}

for (pkg in cran_pkgs) {
  if (!requireNamespace(pkg, quietly = TRUE)) {
    install.packages(pkg, repos = "https://cloud.r-project.org")
  }
}

library(DEP)
library(SummarizedExperiment)
library(limma)
library(vsn)
library(tidyverse)
library(janitor)
library(naniar)
library(ggrepel)
library(patchwork)
library(sessioninfo)

20.11.5 Load Data and Metadata

Code
dir.create("results", showWarnings = FALSE)
dir.create("figures", showWarnings = FALSE)
dir.create("objects", showWarnings = FALSE)

data("UbiLength", package = "DEP")
data("UbiLength_ExpDesign", package = "DEP")

protein_raw <- UbiLength
exp_design <- UbiLength_ExpDesign |>
  as_tibble() |>
  mutate(
    label = as.character(label),
    condition = factor(condition),
    replicate = factor(replicate)
  )

dim(protein_raw)
exp_design
table(exp_design$condition)

20.11.6 Audit Identifications and Build the Abundance Object

Code
protein_clean <- protein_raw |>
  filter(Reverse != "+", Potential.contaminant != "+")

protein_unique <- make_unique(
  protein_clean,
  names = "Gene.names",
  ids = "Protein.IDs",
  delim = ";"
)

lfq_cols <- grep("^LFQ\\.intensity\\.", colnames(protein_unique))

se <- make_se(
  proteins_unique = protein_unique,
  columns = lfq_cols,
  expdesign = exp_design
)

saveRDS(se, "objects/01_ubilength_se_raw.rds")
se

20.11.7 Matrix-Level QC and Missingness

Code
p_numbers <- plot_numbers(se)
p_coverage <- plot_coverage(se)
p_missval <- plot_missval(se)

ggsave("figures/dep_01_protein_numbers.png", p_numbers, width = 7, height = 5, dpi = 300)
ggsave("figures/dep_02_protein_coverage.png", p_coverage, width = 7, height = 5, dpi = 300)
ggsave("figures/dep_03_missing_values.png", p_missval, width = 7, height = 6, dpi = 300)

assay_raw <- assay(se)
missing_summary <- tibble(
  sample = colnames(assay_raw),
  n_missing = colSums(is.na(assay_raw)),
  pct_missing = colMeans(is.na(assay_raw)) * 100
)

write_csv(missing_summary, "results/dep_01_missingness_by_sample.csv")

20.11.8 Filter, Normalize, and Impute

Code
se_filt <- filter_missval(se, thr = 0)
se_norm <- normalize_vsn(se_filt)

set.seed(1)
se_imp <- impute(se_norm, fun = "MinProb", q = 0.01)

saveRDS(se_filt, "objects/02_ubilength_se_filtered.rds")
saveRDS(se_norm, "objects/03_ubilength_se_normalized.rds")
saveRDS(se_imp, "objects/04_ubilength_se_imputed.rds")

p_norm <- plot_normalization(se_filt, se_norm)
p_pca <- plot_pca(se_norm, x = 1, y = 2, n = 500, point_size = 4)
p_imp <- plot_imputation(se_norm, se_imp)

ggsave("figures/dep_04_normalization.png", p_norm, width = 8, height = 5, dpi = 300)
ggsave("figures/dep_05_pca_normalized.png", p_pca, width = 7, height = 5, dpi = 300)
ggsave("figures/dep_06_imputation.png", p_imp, width = 8, height = 5, dpi = 300)

20.11.9 Differential Abundance Testing

Code
se_diff <- test_diff(
  se_imp,
  type = "control",
  control = "Ctrl"
)

se_dep <- add_rejections(
  se_diff,
  alpha = 0.05,
  lfc = 1
)

results <- get_results(se_dep)
write_csv(results, "results/dep_02_differential_abundance_results.csv")

sig_results <- results |>
  filter(significant)

write_csv(sig_results, "results/dep_03_significant_proteins.csv")
saveRDS(se_dep, "objects/05_ubilength_dep_results.rds")

DEP names contrast-specific columns by suffix. For example, this dataset produces columns such as Ubi1_vs_Ctrl_ratio, Ubi1_vs_Ctrl_p.adj, and Ubi1_vs_Ctrl_significant.

Code
ratio_cols <- grep("_ratio$", colnames(results), value = TRUE)
padj_cols <- grep("_p\\.adj$", colnames(results), value = TRUE)

ratio_col <- ratio_cols[1]
padj_col <- sub("_ratio$", "_p.adj", ratio_col)
contrast_name <- sub("_ratio$", "", ratio_col)

20.11.10 Volcano Plot, MA Plot, and Heatmap

Code
volcano_df <- results |>
  mutate(
    log2FC = .data[[ratio_col]],
    padj = .data[[padj_col]],
    neg_log10_padj = -log10(padj),
    is_significant = padj < 0.05 & abs(log2FC) > 1
  )

p_volcano <- ggplot(volcano_df, aes(x = log2FC, y = neg_log10_padj)) +
  geom_point(aes(color = is_significant), alpha = 0.75) +
  geom_vline(xintercept = c(-1, 1), linetype = "dashed") +
  geom_hline(yintercept = -log10(0.05), linetype = "dashed") +
  geom_text_repel(
    data = volcano_df |>
      filter(is_significant) |>
      slice_max(order_by = neg_log10_padj, n = 10),
    aes(label = name),
    size = 3,
    max.overlaps = 20
  ) +
  labs(
    title = paste("Volcano plot:", contrast_name),
    x = "log2 fold change",
    y = "-log10 adjusted p-value",
    color = "Significant"
  ) +
  theme_bw()

ggsave("figures/dep_07_volcano_first_contrast.png", p_volcano, width = 7, height = 6, dpi = 300)
Code
mean_abundance <- rowMeans(assay(se_norm), na.rm = TRUE)

ma_df <- volcano_df |>
  mutate(mean_abundance = mean_abundance[match(name, rownames(se_norm))])

p_ma <- ggplot(ma_df, aes(x = mean_abundance, y = log2FC)) +
  geom_point(aes(color = is_significant), alpha = 0.75) +
  geom_hline(yintercept = c(-1, 0, 1), linetype = c("dashed", "solid", "dashed")) +
  labs(
    title = paste("MA plot:", contrast_name),
    x = "Mean normalized abundance",
    y = "log2 fold change",
    color = "Significant"
  ) +
  theme_bw()

ggsave("figures/dep_08_ma_first_contrast.png", p_ma, width = 7, height = 6, dpi = 300)
Code
if (nrow(sig_results) > 1 && requireNamespace("ComplexHeatmap", quietly = TRUE)) {
  sig_names <- sig_results$name
  mat_sig <- assay(se_norm[rownames(se_norm) %in% sig_names, ])
  mat_z <- t(scale(t(mat_sig)))

  png("figures/dep_09_heatmap_significant_proteins.png",
      width = 1800, height = 1600, res = 200)
  ComplexHeatmap::Heatmap(
    mat_z,
    name = "Z-score",
    show_row_names = FALSE,
    column_title = "Significant proteins",
    row_title = "Proteins"
  )
  dev.off()
}

20.11.11 Enrichment-Ready Protein List

The UbiLength experiment is yeast-based, so enrichment should use a yeast annotation database such as org.Sc.sgd.db. Real projects should always verify the organism, identifier type, and one-to-many mappings before interpreting pathway output.

Code
gene_list <- results |>
  filter(!is.na(name)) |>
  select(name, all_of(ratio_col), all_of(padj_col)) |>
  arrange(.data[[padj_col]])

write_csv(gene_list, "results/dep_04_gene_list_for_enrichment.csv")

if (
  requireNamespace("clusterProfiler", quietly = TRUE) &&
  requireNamespace("org.Sc.sgd.db", quietly = TRUE)
) {
  sig_genes <- sig_results$name |> unique()

  ego <- clusterProfiler::enrichGO(
    gene = sig_genes,
    OrgDb = org.Sc.sgd.db::org.Sc.sgd.db,
    keyType = "SYMBOL",
    ont = "BP",
    pAdjustMethod = "BH",
    readable = TRUE
  )

  write_csv(as.data.frame(ego), "results/dep_05_go_enrichment.csv")
}

20.11.12 Main Outputs

Output File
Cleaned and structured protein matrix objects/01_ubilength_se_raw.rds
Missingness summary results/dep_01_missingness_by_sample.csv
Normalized abundance object objects/03_ubilength_se_normalized.rds
Differential abundance table results/dep_02_differential_abundance_results.csv
Significant protein table results/dep_03_significant_proteins.csv
Volcano plot figures/dep_07_volcano_first_contrast.png
Enrichment-ready gene list results/dep_04_gene_list_for_enrichment.csv
Session information results/dep_06_session_info.txt
Code
sessioninfo::session_info() |>
  capture.output() |>
  writeLines("results/dep_06_session_info.txt")

20.12 Power Analysis

20.12.1 Sample Size Calculation

Code
# Function for power analysis
calculate_power <- function(effect_size, sample_size_per_group, alpha = 0.05) {
  # Calculate power for two-sample t-test
  delta <- effect_size
  n <- sample_size_per_group
  
  # Non-centrality parameter
  ncp <- delta * sqrt(n/2)
  
  # Critical value
  t_crit <- qt(1 - alpha/2, df = 2*n - 2)
  
  # Power calculation
  power <- 1 - pt(t_crit, df = 2*n - 2, ncp = ncp) + 
           pt(-t_crit, df = 2*n - 2, ncp = ncp)
  
  return(power)
}

# Power analysis for different effect sizes and sample sizes
effect_sizes <- seq(0.2, 2.0, by = 0.2)
sample_sizes <- seq(5, 30, by = 5)

power_results <- expand.grid(
  effect_size = effect_sizes,
  sample_size = sample_sizes
) %>%
  rowwise() %>%
  mutate(power = calculate_power(effect_size, sample_size))

# Visualize power analysis
ggplot(power_results, aes(x = sample_size, y = power, color = factor(effect_size))) +
  geom_line(size = 1) +
  geom_hline(yintercept = 0.8, linetype = "dashed", color = "red") +
  labs(title = "Power Analysis for Two-Sample t-test",
       x = "Sample Size per Group", y = "Statistical Power",
       color = "Effect Size") +
  theme_minimal()

20.13 Summary

This chapter covered essential statistical methods for MS data analysis, including descriptive statistics, hypothesis testing, multivariate analysis, and clustering. It also connected those ideas to a real label-free proteomics case study using DEP::UbiLength, from a cleaned protein table through missingness-aware normalization, differential abundance testing, visualization, enrichment-ready output, and reproducible export.

20.14 Exercises

  1. Design Matrices: Given an experiment with two factors (Genotype: WT vs KO; Treatment: Ctrl vs Stim), write the R code to create a design matrix for a factorial analysis.
  2. Moderated t-tests: Why is limma’s empirical Bayes moderation particularly valuable for mass spectrometry experiments with only 3–4 replicates per group?
  3. Visualization: Using the results from the UbiLength case study, generate a volcano plot where features with a fold-change > 2 and adjusted p-value < 0.05 are highlighted in a different color.
  4. Multiple testing: Re-run the case-study contrast and compare Benjamini–Hochberg, Benjamini–Yekutieli, and Bonferroni adjustment. How does the number of significant proteins change, and which is appropriate here?
  5. Blocking: The design includes technical structure across sites. Show how you would use limma::duplicateCorrelation() to block on a subject/site variable, and contrast the result with the unblocked fit.
  6. Choose a differential abundance method: For each of the three contrasting study designs below, select an appropriate differential abundance method from Section “Choosing a Differential Abundance Method” and defend your choice in 2–3 sentences: (a) TMT 10-plex with 5 vs 5 samples, no missing values; (b) Label-free DIA with 3 vs 3 samples, approximately 25% missing values; (c) Single-cell proteomics with 40 vs 40 cells, more than 60% missing values.

20.15 Session Information

Code
sessionInfo()
R version 4.5.1 (2025-06-13 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default
  LAPACK version 3.12.1

locale:
[1] LC_COLLATE=English_Switzerland.utf8  LC_CTYPE=English_Switzerland.utf8   
[3] LC_MONETARY=English_Switzerland.utf8 LC_NUMERIC=C                        
[5] LC_TIME=English_Switzerland.utf8    

time zone: Europe/Zurich
tzcode source: internal

attached base packages:
[1] stats4    stats     graphics  grDevices utils     datasets  methods  
[8] base     

other attached packages:
 [1] patchwork_1.3.2             pheatmap_1.0.13            
 [3] cluster_2.1.8.2             corrplot_0.95              
 [5] broom_1.0.13                lubridate_1.9.5            
 [7] forcats_1.0.1               stringr_1.6.0              
 [9] purrr_1.2.2                 readr_2.2.0                
[11] tidyr_1.3.2                 tibble_3.3.1               
[13] tidyverse_2.0.0             msdata_0.48.0              
[15] QFeatures_1.18.0            MultiAssayExperiment_1.34.0
[17] SummarizedExperiment_1.38.1 Biobase_2.68.0             
[19] GenomicRanges_1.60.0        GenomeInfoDb_1.44.3        
[21] IRanges_2.42.0              MatrixGenerics_1.20.0      
[23] matrixStats_1.5.0           Spectra_1.18.2             
[25] BiocParallel_1.42.2         S4Vectors_0.46.0           
[27] BiocGenerics_0.54.1         generics_0.1.4             
[29] dplyr_1.2.1                 ggrepel_0.9.8              
[31] ggplot2_4.0.3               limma_3.64.3               

loaded via a namespace (and not attached):
 [1] gridExtra_2.3.1         rlang_1.3.0             magrittr_2.0.5         
 [4] clue_0.3-68             otel_0.2.0              compiler_4.5.1         
 [7] vctrs_0.7.3             reshape2_1.4.5          ProtGenerics_1.40.0    
[10] pkgconfig_2.0.3         MetaboCoreUtils_1.16.1  crayon_1.5.3           
[13] fastmap_1.2.0           backports_1.5.1         XVector_0.48.0         
[16] labeling_0.4.3          rmarkdown_2.31          tzdb_0.5.0             
[19] UCSC.utils_1.4.0        xfun_0.60               jsonlite_2.0.0         
[22] DelayedArray_0.34.1     parallel_4.5.1          R6_2.6.1               
[25] stringi_1.8.7           RColorBrewer_1.1-3      car_3.1-5              
[28] Rcpp_1.1.2              knitr_1.51              BiocBaseUtils_1.10.0   
[31] Matrix_1.7-3            igraph_2.3.3            timechange_0.4.0       
[34] tidyselect_1.2.1        abind_1.4-8             yaml_2.3.12            
[37] viridis_0.6.5           codetools_0.2-20        lattice_0.22-7         
[40] plyr_1.8.9              withr_3.0.3             S7_0.2.2               
[43] evaluate_1.0.5          pillar_1.11.1           ggpubr_1.0.0           
[46] carData_3.0-6           hms_1.1.4               scales_1.4.0           
[49] glue_1.8.1              lazyeval_0.2.3          tools_4.5.1            
[52] dendextend_1.19.1       ggsignif_0.6.4          fs_2.1.0               
[55] grid_4.5.1              MsCoreUtils_1.20.0      GenomeInfoDbData_1.2.14
[58] Formula_1.2-5           cli_3.6.5               S4Arrays_1.8.1         
[61] viridisLite_0.4.3       AnnotationFilter_1.32.0 gtable_0.3.6           
[64] rstatix_1.0.0           digest_0.6.37           SparseArray_1.8.1      
[67] htmlwidgets_1.6.4       farver_2.1.2            htmltools_0.5.9        
[70] lifecycle_1.0.5         factoextra_2.1.0        httr_1.4.8             
[73] statmod_1.5.2           MASS_7.3-65