ProteinBatcher workflow

Overview

ProteinBatcher is a downstream analysis package for label-free quantitative proteomics. It does not perform upstream quantification: it operates on a protein-level abundance matrix (it specifically targets DIA-NN pg_matrix outputs) together with a sample-annotation table, and turns the sequence of filtering → imputation → differential abundance testing → effect organization → visualization into a single, reproducible function call, run_proteomics_pipeline().

The design goal is standardization and automation of a downstream workflow built on top of established Bioconductor methods, rather than the introduction of new statistical methodology or new data containers. Concretely, ProteinBatcher contributes:

  1. A turnkey pipeline that enforces a fixed input schema and a consistent output structure, so that experiments with similar structure but different designs can be analysed with the same code.
  2. A systematic separation of each contrast into main, common (batch- or factor-independent) and interaction (factor-dependent) effects, each returned as a SummarizedExperiment.
  3. Interpretation-oriented visualizations, in particular the deregulogram, which compares the full effect of a contrast across the two levels of an interaction factor and emphasises effect concordance and magnitude rather than statistical significance alone.

All intermediate and final results are stored in SummarizedExperiment objects, and all linear modelling is delegated to limma.

Relationship with the Bioconductor ecosystem

Bioconductor already provides mature infrastructure for the individual steps of a proteomics analysis, and several packages support fully integrated, design-aware differential-abundance workflows. ProteinBatcher is intended to sit alongside this infrastructure, not to replace it. It is built around two established pillars:

How ProteinBatcher differs from msqrob2

The reviewer specifically asked how ProteinBatcher relates to msqrob2. The two packages overlap in purpose (differential abundance in MS-based proteomics) but occupy clearly different design points, and they are complementary rather than competing.

msqrob2 contributes statistical methodology. It implements a robust linear (mixed) model framework whose parameter estimates can be stabilised by ridge regression, empirical-Bayes variance estimation and robust M-estimation, and it offers a hurdle workflow that handles missing values without requiring imputation. It is built on the QFeatures infrastructure, so it can model the data while consistently linking the PSM, peptide and protein levels, and can start either from raw peptide intensities or from summarised protein values. Its value lies in the modelling engine itself.

ProteinBatcher, by contrast, does not introduce new statistical methods. It orchestrates established limma modelling into a fixed, reproducible downstream pipeline that operates on a plain protein-level SummarizedExperiment. Its distinctive contributions are operational rather than statistical: a single-call pipeline with a fixed annotation schema, a specific organisation of results into main / common / interaction effects, and dedicated interpretation plots (most notably the deregulogram). In short, msqrob2 is primarily a method (and a peptide-to-protein modelling framework), whereas ProteinBatcher is primarily a standardised wrapper and visualisation layer around protein-level limma analysis.

We therefore do not claim that flexible designs, interaction testing or integrated workflows are unavailable elsewhere in Bioconductor: msqrob2, among others, supports them. The motivation for ProteinBatcher is to provide a convention-driven, low-boilerplate route for teams that repeatedly run protein-level limma analyses with the same input/output conventions and that want the specific effect-separation and deregulogram outputs described below.

Input data and workflow parameters

ProteinBatcher needs two input datasets and a set of workflow parameters. The inputs define what is analysed; the parameters control how the analysis is performed.

Input data

  1. Quantitative matrix. A protein-by-sample matrix of quantitative measurements, as produced by DIA-NN (pg_matrix). Rows are protein groups and columns are samples.
  2. Sample annotation table. A tab-delimited table describing the experimental design and linking each sample to its biological and technical metadata.

Both are shipped with the package as example files. The chunk below locates them with system.file() and reads the annotation table with base R (read.delim()); no external reader package is required. We also record whether the example files are available, so that the rest of the vignette degrades gracefully if they are not.

# Load package
library(ProteinBatcher)

# Input files shipped with the package
path_annotation <- system.file("extdata", "annotation_HaCaT.tsv",
                                package = "ProteinBatcher")
path_pgmatrix   <- system.file(
  "extdata", "2024MK017_HaCaT_Stimulation_1to24_Astral_report.pg_matrix.tsv",
  package = "ProteinBatcher")
path_output <- tempdir()

# Inspect annotation structure (base R, sin readr)
ann <- utils::read.delim(path_annotation, check.names = FALSE)

The annotation table read above has one row per sample. We can confirm its shape and look at the first rows:

dim(ann)
#> [1] 24  7
head(ann)
#>                                                                          file
#> 1 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_001_01_4pto.raw
#> 2 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_002_01_4pto.raw
#> 3 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_003_01_4pto.raw
#> 4 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_004_01_4pto.raw
#> 5 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_005_01_4pto.raw
#> 6 Y:\\data\\orbitrap_astral1\\Raw\\2502\\ELMO\\2024MK017_ELMO_006_01_4pto.raw
#>        sample sample_name condition replicate batch donor_id
#> 1 NoTreated_1 NoTreated_1 NoTreated         1  day1        1
#> 2     IL13 _1     IL13 _1      IL13         1  day1        1
#> 3      IL22_1      IL22_1      IL22         1  day1        1
#> 4    COMBO _1    COMBO _1     COMBO         1  day1        1
#> 5 NoTreated_2 NoTreated_2 NoTreated         1  day1        1
#> 6     IL13 _2     IL13 _2      IL13         1  day1        1

Sample annotation structure

For the high-level run_proteomics_pipeline() entry point, the annotation table is validated against a fixed set of column names. These names are required (not merely suggested): the input validator stops with an informative error if any of them is missing, because the pipeline locates each piece of metadata by name when it builds colData and when it assembles the limma design. The required columns are:

The set is intentionally fixed so that the same pipeline code can be reused across experiments without per-experiment column-mapping: the conventions are the contract. Which of these factors actually enters the model is then determined by the design formula and by the paired / block_effect parameters; columns that are present but not referenced by the formula simply remain available in colData for inspection.

Workflow parameters

In addition to the two inputs, the pipeline takes parameters that control filtering, modelling and output generation. The most important are:

The example data set

The shipped example is a HaCaT keratinocyte stimulation experiment (2024MK017). It contains 24 samples organised as a two-factor design:

Each condition is measured in three samples on each of the two days (4 conditions × 2 days × 3 = 24 samples), and all samples come from the same cell line (donor_id = 1). This balanced design is what makes the condition:batch interaction estimable: it lets us ask not only whether a treatment changes protein abundance on average, but also whether that change differs between the two acquisition days. The cross-tabulation of the design is:

table(condition = ann$condition, batch = ann$batch)
#>            batch
#> condition   day1 day2
#>   COMBO        3    3
#>   IL13         3    3
#>   IL22         3    3
#>   NoTreated    3    3

Example 1: workflow with interaction testing (condition-by-batch)

In this first example we model the four conditions together with the batch factor and their interaction, ~ 0 + condition + batch + condition:batch. We request the three treatment-vs-control contrasts and the matching condition-by-day interaction coefficients. The chunk below only defines the parameters; nothing is computed yet.

experiment <- "HaCaT"
percent_missing <- 50

formula <- formula(~ 0 + condition + batch + condition:batch)
tests <- c(
  "IL13_vs_NoTreated",
  "IL22_vs_NoTreated",
  "COMBO_vs_NoTreated"
)
tests_interaction <- c(
  "IL13.batchday2",
  "IL22.batchday2",
  "COMBO.batchday2"
)
reference_condition <- "NoTreated"

We now run the full pipeline. With plots = FALSE the function performs filtering, imputation, differential testing and effect organisation in memory and returns a single list; it does not write any files. (Set plots = TRUE to additionally export the tables and PDF figures shown later in this vignette.)

res <- run_proteomics_pipeline(
  path_pgmatrix        = path_pgmatrix,
  path_annotation      = path_annotation,
  path_output          = path_output,
  tests                = tests,
  tests_interaction    = tests_interaction,
  formula              = formula,
  reference_condition  = reference_condition,
  percent_missing      = percent_missing,
  ldv_source           = "per-condition",
  experiment           = experiment,
  plots                = FALSE
)
#> Fitting limma model without block

The returned object is a named list. se_raw, se_filt and se_imp are the SummarizedExperiment objects after import, filtering and imputation respectively; removed is a data.frame of the proteins dropped by the missingness filter; and effects holds the organised results. Inspecting the imputed object shows the protein-by-sample assay together with the metadata the pipeline attached during imputation:

names(res)
#> [1] "se_raw"  "se_filt" "removed" "se_imp"  "effects"
res$se_imp
#> class: SummarizedExperiment 
#> dim: 9600 24 
#> metadata(5): log2transform exp lfq_type level imputation_map
#> assays(1): intensity
#> rownames(9600): A0A024RBG1 A0A096LP01 ... Q9Y6Y0 Q9Y6Y8
#> rowData names(7): Protein.Group Protein.Names ... Index imputed
#> colnames(24): NoTreated_1 IL13 _1 ... IL22_6 COMBO _6
#> colData names(8): file sample ... donor_id label

Accessing the organised effects

effects is a list keyed by the main contrast, so a given contrast can be retrieved directly with $. Each element is itself a list of three SummarizedExperiment objects — all_common_effect, common_effect and interaction_effect — and the per-contrast statistics (log2FC, CI.L, CI.R, p.val, p.adj) are stored in their rowData() under the "<stat>_<contrast>" naming convention (for example log2FC_IL13_vs_NoTreated). This is the structure consumed by the downstream visualisation helpers.

# Contrasts available, and direct access with `$`
names(res$effects)
#> [1] "IL13_vs_NoTreated"  "IL22_vs_NoTreated"  "COMBO_vs_NoTreated"
il13 <- res$effects$IL13_vs_NoTreated
names(il13)
#> [1] "common_effect"      "interaction_effect" "all_common_effect"

# Each slot is a SummarizedExperiment; statistics live in rowData()
il13$all_common_effect
#> class: SummarizedExperiment 
#> dim: 9600 24 
#> metadata(5): log2transform exp lfq_type level imputation_map
#> assays(1): intensity
#> rownames(9600): A0A024RBG1 A0A096LP01 ... Q9Y6Y0 Q9Y6Y8
#> rowData names(11): Protein.Group Protein.Names ...
#>   CI.R_IL13_vs_NoTreated log2FC_IL13_vs_NoTreated
#> colnames(24): NoTreated_1 IL13 _1 ... IL22_6 COMBO _6
#> colData names(8): file sample ... donor_id label
head(colnames(SummarizedExperiment::rowData(il13$all_common_effect)))
#> [1] "Protein.Group" "Protein.Names" "Genes"         "name"         
#> [5] "Index"         "imputed"

Filtering and imputation summary tables

When plots = TRUE, the pipeline also writes plain-text (CSV) summaries of the preprocessing steps so that they are fully transparent and can be re-read with base R. The package ships precomputed copies of these tables as example outputs. The chunk below reads them with read.csv() if they are present:

path_filtered <- system.file(
  "extdata", "HaCaT_filtered_out_proteins.csv",
  package = "ProteinBatcher"
)
path_before <- system.file(
  "extdata", "HaCaT_Imputation_before.csv",
  package = "ProteinBatcher"
)
path_after <- system.file(
  "extdata", "HaCaT_Imputation_after.csv",
  package = "ProteinBatcher"
)

if (nzchar(path_filtered)) {
  filtered_df <- read.csv(path_filtered, check.names = FALSE,
                          stringsAsFactors = FALSE)
  head(filtered_df)
}
if (nzchar(path_before) && nzchar(path_after)) {
  before_df <- read.csv(path_before, check.names = FALSE,
                        stringsAsFactors = FALSE)
  after_df  <- read.csv(path_after,  check.names = FALSE,
                        stringsAsFactors = FALSE)
  # First proteins, first few columns, before vs after imputation
  head(before_df[, seq_len(min(5, ncol(before_df)))])
  head(after_df[, seq_len(min(5, ncol(after_df)))])
}

The “before” table still contains missing values (NA); in the “after” table these have been replaced either by a within-condition mean (when a protein is only partially missing in a condition) or by a left-censored low-value draw (when it is essentially absent), which is why the imputed matrix has no missing entries.

Differential effects: IL13 vs NoTreated

From here we focus on the IL13_vs_NoTreated contrast to illustrate how the three effect types are read. The figures below are the PNG outputs shipped with the package; each chunk renders the figure only if it is present.

Main effect (all proteins)

The main-effect volcano shows the average IL13 effect across both batch days, ignoring the interaction term. Every tested protein is included regardless of whether its response depends on the day.

The x-axis is the estimated log2 fold change (IL13 vs NoTreated) and the y-axis is statistical significance (−log10 adjusted p-value); points away from the centre and high up are strongly and significantly regulated. This view answers “which proteins are affected by IL13 on average?”, but it does not yet separate stable effects from day-dependent ones.

Common effect (batch-independent proteins)

The common-effect volcano restricts attention to proteins whose IL13 response is not significantly modulated by batch, i.e. those that fail the interaction test and are therefore consistent across days.

Because their direction and magnitude reproduce across batches, these proteins are usually the most robust candidates for biological interpretation. This view answers “which IL13-responsive proteins behave consistently across days?”.

Interaction effect (batch-dependent proteins)

The interaction volcano highlights proteins whose IL13 response differs between day1 and day2. Here significance is driven by the interaction coefficient, not by the average effect, so a protein can look weak in the main-effect plot yet be strongly significant here if its response changes between days.

This view answers “which proteins show day-specific IL13 effects?”.

Deregulogram: interpreting batch-dependent effects

The deregulogram compares the full IL13 effect between the two batch days. The y-axis is the full IL13 effect in the reference day (day1) and the x-axis is the full effect in the other day (day2), computed as main effect + interaction effect.

Proteins on the diagonal behave the same on both days; proteins off the diagonal are regulated differently between days. Highlighted points are those for which the interaction is both statistically significant and large enough to pass an effect-size threshold. The deregulogram is deliberately more restrictive than the interaction volcano: it emphasises interpretable, direction-changing or magnitude-shifting effects, so its highlighted set is expected to be a stricter subset of the significant interactions rather than a one-to-one match.

Example 2: workflow without interaction testing

When the goal is only a global condition effect — for instance with a single experimental factor, with well-balanced and negligible batches, or when interaction testing would be underpowered — the interaction terms can be dropped from the model and tests_interaction set to "NA". The parameters below specify a condition-only model:

experiment <- "HaCaT"
percent_missing <- 50

formula <- ~ 0 + condition
tests <- c(
  "IL13_vs_NoTreated",
  "IL22_vs_NoTreated",
  "COMBO_vs_NoTreated"
)
tests_interaction <- "NA"
reference_condition <- "NoTreated"

Running the pipeline with these parameters proceeds exactly as before, but only the main condition effects are estimated:

res_no_int <- run_proteomics_pipeline(
  path_pgmatrix        = path_pgmatrix,
  path_annotation      = path_annotation,
  path_output          = path_output,
  tests                = tests,
  tests_interaction    = tests_interaction,
  formula              = formula,
  reference_condition  = reference_condition,
  percent_missing      = percent_missing,
  ldv_source           = "per-condition",
  experiment           = experiment,
  plots                = FALSE
)
#> Fitting limma model without block

With tests_interaction = "NA" only main effects are estimated and reported, all proteins are treated as responding homogeneously across samples, and no common-versus-interaction separation is performed. The effects list is still keyed by contrast, so the results are accessed the same way; the interaction_effect slot is simply empty. This configuration answers “which proteins are differentially abundant between conditions on average?”:

names(res_no_int$effects)
#> [1] "IL13_vs_NoTreated"  "IL22_vs_NoTreated"  "COMBO_vs_NoTreated"
res_no_int$effects$IL13_vs_NoTreated$all_common_effect
#> class: SummarizedExperiment 
#> dim: 9600 24 
#> metadata(5): log2transform exp lfq_type level imputation_map
#> assays(1): intensity
#> rownames(9600): A0A024RBG1 A0A096LP01 ... Q9Y6Y0 Q9Y6Y8
#> rowData names(11): Protein.Group Protein.Names ...
#>   CI.R_IL13_vs_NoTreated log2FC_IL13_vs_NoTreated
#> colnames(24): NoTreated_1 IL13 _1 ... IL22_6 COMBO _6
#> colData names(8): file sample ... donor_id label

Session info

sessionInfo()
#> R version 4.6.1 Patched (2026-06-24 r90190)
#> Platform: x86_64-apple-darwin20
#> Running under: macOS Ventura 13.7.8
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRblas.0.dylib 
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.6-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
#> 
#> locale:
#> [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#> 
#> time zone: America/New_York
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats     graphics  grDevices utils     datasets  methods   base     
#> 
#> other attached packages:
#> [1] ProteinBatcher_0.99.7
#> 
#> loaded via a namespace (and not attached):
#>  [1] sass_0.4.10                 generics_0.1.4             
#>  [3] SparseArray_1.13.2          lattice_0.23-1             
#>  [5] digest_0.6.39               magrittr_2.0.5             
#>  [7] evaluate_1.0.5              grid_4.6.1                 
#>  [9] RColorBrewer_1.1-3          fastmap_1.2.0              
#> [11] jsonlite_2.0.0              Matrix_1.7-6               
#> [13] ggrepel_0.9.8               limma_3.99.0               
#> [15] scales_1.4.0                jquerylib_0.1.4            
#> [17] abind_1.4-8                 cli_3.6.6                  
#> [19] rlang_1.3.0                 XVector_0.53.0             
#> [21] Biobase_2.73.2              cachem_1.1.0               
#> [23] DelayedArray_0.39.6         yaml_2.3.12                
#> [25] otel_0.2.0                  S4Arrays_1.13.0            
#> [27] tools_4.6.1                 dplyr_1.2.1                
#> [29] ggplot2_4.0.3               SummarizedExperiment_1.43.0
#> [31] BiocGenerics_0.59.12        vctrs_0.7.3                
#> [33] R6_2.6.1                    matrixStats_1.5.0          
#> [35] stats4_4.6.1                lifecycle_1.0.5            
#> [37] Seqinfo_1.3.2               S4Vectors_0.51.9           
#> [39] IRanges_2.47.5              pkgconfig_2.0.3            
#> [41] bslib_0.12.0                pillar_1.11.1              
#> [43] gtable_0.3.6                Rcpp_1.1.2                 
#> [45] data.table_1.18.6.1         glue_1.8.1                 
#> [47] statmod_1.5.2               xfun_0.60                  
#> [49] tibble_3.3.1                GenomicRanges_1.65.4       
#> [51] tidyselect_1.2.1            MatrixGenerics_1.25.0      
#> [53] knitr_1.52                  dichromat_2.0-1            
#> [55] farver_2.1.2                htmltools_0.5.9            
#> [57] rmarkdown_2.32              compiler_4.6.1             
#> [59] S7_0.2.2