Single-Cell Proteomics Analysis with scp

Learning Objectives

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

  • Identify the key analytical challenges that distinguish single-cell proteomics (SCP) from bulk proteomics
  • Describe how the scp package extends QFeatures with SingleCellExperiment assays
  • Load and inspect an SCP dataset from scpdata
  • Perform cell-level quality control and annotation audit
  • Filter PSMs and aggregate to peptide- and protein-level quantification
  • Normalize and assess batch structure in SCP data
  • Conduct or cross-reference statistical methods for differential abundance in SCP

What Changes from Bulk Proteomics

Low Input and Identification Sparsity

Single-cell proteomics starts with picogram amounts of protein per cell — orders of magnitude less than a typical bulk proteomics experiment. The immediate consequence is identification sparsity: a bulk TMT experiment may quantify 6000–8000 proteins per sample, while a single cell typically yields 1000–3000 identified proteins. Many peptides that are present are not detected in every cell, producing a high proportion of missing values that follows a structure-dependent pattern rather than a purely random one.

WarningSparsity is not a bug

Do not treat SCP missingness as a universal property. The fraction of missing values depends on the acquisition method (TMT, label-free, diaPASEF), the instrument, the carrier-channel strategy, and the depth of the fractionation. Always report the detection rate and missingness pattern for your specific dataset.

Batch and Acquisition Structure

Most SCP experiments use multiplexed acquisition (TMT, TMTpro, or similar isobaric labels) to measure multiple single cells simultaneously. A typical design places each cell in one TMT channel, includes a carrier channel (100–200 cell-equivalents of peptide) to boost identification, and often a reference channel (a pool of all samples) for normalization across batches. Several TMT runs are combined into an experiment, so the data has a nested structure:

  • Cells are the biological unit (individual cells loaded into channels)
  • Channels are the TMT reporter-ion slots within a single run
  • Acquisition runs (also called batches) group a set of channels measured together
  • Biological replicates are independent cell populations; a single experiment may have only technical replication at the cell level
NoteDistinguishing levels of replication

A 16-plex TMTpro run with 14 single cells, one carrier, and one reference channel provides 14 technical replicates of the cell type, not 14 biological replicates. True biological replication requires cells from independent cultures, organisms, or tissue samples. State your replication structure explicitly in any SCP analysis.

Carrier and Reference Channels

The carrier (or booster) channel is a distinctive feature of TMT-based SCP. It contains a relatively large amount of peptide (often 50–200 cell-equivalents) from the same cell type. During MS acquisition, the carrier boosts the precursor intensity, improving the chance that low-abundance single-cell peptides are selected for fragmentation and identification. The carrier’s reporter ion signal is usually an order of magnitude higher than the single-cell channels and is excluded from downstream quantification.

The reference channel (a pooled sample from all conditions) provides a common anchor across batches. After median normalization, each single-cell channel within a run is scaled so that the reference channel has the same median intensity across runs, enabling between-run comparison.

The scp Data Model

The scp package builds on the QFeatures container (Chapter 5) to represent the multi-level structure of SCP data. Each acquisition run (a single TMT experiment) is stored as one assay in the QFeatures object:

  • Each assay is a SingleCellExperiment (SCE) object, which extends SummarizedExperiment with methods designed for single-cell data
  • Columns of the SCE correspond to channels (individual cell samples, plus carrier and reference channels)
  • Rows correspond to identified PSMs (in the raw assays) or to peptides/proteins (in aggregated assays)
  • colData stores channel-level metadata: the cell annotation, the acquisition run, the TMT channel index, and quality metrics
  • rowData stores feature-level identification information: peptide sequence, protein accession, search score, posterior error probability (PEP), and any decoy/contaminant flags

Multiple acquisition runs are kept as separate assays until they are joined (via joinAssays()) for a combined analysis. The aggregation functions aggregateFeatures() from QFeatures collapse PSMs first to peptides and then to proteins, each step producing a new SingleCellExperiment assay within the same QFeatures container.

QFeatures object
├── Assay 1: single_cell_psms (SCE)   ← PSM-level, one per run
├── Assay 2: single_cell_psms_2 (SCE) ← PSM-level, run 2
├── ...
├── joined_psms (SCE)                  ← all runs joined
├── peptides (SCE)                     ← aggregated PSMs → peptides
└── proteins (SCE)                     ← aggregated peptides → proteins

This hierarchical structure lets each step of the workflow — filtering, normalization, aggregation, and modeling — operate at the appropriate level while preserving the provenance of each quantitative value.

Import and Inspection

We use the leduc2022 dataset from scpdata for this appendix. This is a TMTpro-16 experiment profiling the proteome of naive, primed, and naive-reset pluripotent stem cell states (Leduc et al. 2022). It was chosen because it represents a single 16-plex run, making it compact enough for routine book rendering while illustrating the core SCP workflow. The full dataset includes a carrier channel and a reference channel alongside single-cell channels from three pluripotency conditions.

NoteDataset rationale

leduc2022 is a single-run TMTpro-16 experiment, so it avoids the complexity of multi-run joining while preserving all essential SCP features: carrier channel, reference channel, and cell-level annotation with condition labels. If your computer has limited memory, further subset the PSM table by removing the carrier channel rows before aggregation; the code below demonstrates this.

Code
library(scp)
Loading required package: QFeatures
Loading required package: MultiAssayExperiment
Loading required package: SummarizedExperiment
Loading required package: MatrixGenerics
Loading required package: matrixStats

Attaching package: 'MatrixGenerics'
The following objects are masked from 'package:matrixStats':

    colAlls, colAnyNAs, colAnys, colAvgsPerRowSet, colCollapse,
    colCounts, colCummaxs, colCummins, colCumprods, colCumsums,
    colDiffs, colIQRDiffs, colIQRs, colLogSumExps, colMadDiffs,
    colMads, colMaxs, colMeans2, colMedians, colMins, colOrderStats,
    colProds, colQuantiles, colRanges, colRanks, colSdDiffs, colSds,
    colSums2, colTabulates, colVarDiffs, colVars, colWeightedMads,
    colWeightedMeans, colWeightedMedians, colWeightedSds,
    colWeightedVars, rowAlls, rowAnyNAs, rowAnys, rowAvgsPerColSet,
    rowCollapse, rowCounts, rowCummaxs, rowCummins, rowCumprods,
    rowCumsums, rowDiffs, rowIQRDiffs, rowIQRs, rowLogSumExps,
    rowMadDiffs, rowMads, rowMaxs, rowMeans2, rowMedians, rowMins,
    rowOrderStats, rowProds, rowQuantiles, rowRanges, rowRanks,
    rowSdDiffs, rowSds, rowSums2, rowTabulates, rowVarDiffs, rowVars,
    rowWeightedMads, rowWeightedMeans, rowWeightedMedians,
    rowWeightedSds, rowWeightedVars
Loading required package: GenomicRanges
Loading required package: stats4
Loading required package: BiocGenerics
Loading required package: generics

Attaching package: 'generics'
The following objects are masked from 'package:base':

    as.difftime, as.factor, as.ordered, intersect, is.element, setdiff,
    setequal, union

Attaching package: 'BiocGenerics'
The following objects are masked from 'package:stats':

    IQR, mad, sd, var, xtabs
The following objects are masked from 'package:base':

    anyDuplicated, aperm, append, as.data.frame, basename, cbind,
    colnames, dirname, do.call, duplicated, eval, evalq, Filter, Find,
    get, grep, grepl, is.unsorted, lapply, Map, mapply, match, mget,
    order, paste, pmax, pmax.int, pmin, pmin.int, Position, rank,
    rbind, Reduce, rownames, sapply, saveRDS, table, tapply, unique,
    unsplit, which.max, which.min
Loading required package: S4Vectors

Attaching package: 'S4Vectors'
The following object is masked from 'package:utils':

    findMatches
The following objects are masked from 'package:base':

    expand.grid, I, unname
Loading required package: IRanges

Attaching package: 'IRanges'
The following object is masked from 'package:grDevices':

    windows
Loading required package: GenomeInfoDb
Loading required package: Biobase
Welcome to Bioconductor

    Vignettes contain introductory material; view with
    'browseVignettes()'. To cite Bioconductor, see
    'citation("Biobase")', and for packages 'citation("pkgname")'.

Attaching package: 'Biobase'
The following object is masked from 'package:MatrixGenerics':

    rowMedians
The following objects are masked from 'package:matrixStats':

    anyMissing, rowMedians

Attaching package: 'QFeatures'
The following object is masked from 'package:base':

    sweep
Code
library(scpdata)
Loading required package: ExperimentHub
Loading required package: AnnotationHub
Loading required package: BiocFileCache
Loading required package: dbplyr

Attaching package: 'AnnotationHub'
The following object is masked from 'package:Biobase':

    cache

This is scpdata version 1.16.1.
Use 'scpdata()' to list available data sets.
Code
library(scater)
Loading required package: SingleCellExperiment
Loading required package: scuttle
Loading required package: ggplot2
Code
library(ggplot2)
library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:dbplyr':

    ident, sql, sql_escape_ident, sql_escape_string
The following object is masked from 'package:Biobase':

    combine
The following objects are masked from 'package:GenomicRanges':

    intersect, setdiff, union
The following object is masked from 'package:GenomeInfoDb':

    intersect
The following objects are masked from 'package:IRanges':

    collapse, desc, intersect, setdiff, slice, union
The following objects are masked from 'package:S4Vectors':

    first, intersect, rename, setdiff, setequal, union
The following objects are masked from 'package:BiocGenerics':

    combine, intersect, setdiff, setequal, union
The following object is masked from 'package:generics':

    explain
The following object is masked from 'package:matrixStats':

    count
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
Code
library(tidyr)

Attaching package: 'tidyr'
The following object is masked from 'package:S4Vectors':

    expand
Code
library(patchwork)
Code
# Load the leduc2022 dataset
leduc <- leduc2022()
see ?scpdata and browseVignettes('scpdata') for documentation
loading from cache
Code
leduc
An instance of class QFeatures containing 138 set(s):
 [1] eAL00219: SingleCellExperiment with 6269 rows and 18 columns 
 [2] eAL00220: SingleCellExperiment with 6603 rows and 18 columns 
 [3] eAL00221: SingleCellExperiment with 6511 rows and 18 columns 
 ...
 [136] peptides_log: SingleCellExperiment with 12284 rows and 1543 columns 
 [137] proteins_norm2: SingleCellExperiment with 2844 rows and 1543 columns 
 [138] proteins_processed: SingleCellExperiment with 2844 rows and 1543 columns 

Inspect the available assays and their dimensions:

Code
names(leduc)
  [1] "eAL00219"           "eAL00220"           "eAL00221"          
  [4] "eAL00222"           "eAL00223"           "eAL00224"          
  [7] "eAL00225"           "eAL00226"           "eAL00227"          
 [10] "eAL00228"           "eAL00229"           "eAL00230"          
 [13] "eAL00231"           "eAL00232"           "eAL00233"          
 [16] "eAL00234"           "eAL00235"           "eAL00236"          
 [19] "eAL00237"           "eAL00238"           "eAL00239"          
 [22] "eAL00240"           "eAL00241"           "eAL00242"          
 [25] "eAL00243"           "eAL00244"           "eAL00245"          
 [28] "eAL00246"           "eAL00247"           "eAL00248"          
 [31] "eAL00249"           "eAL00250"           "eAL00251"          
 [34] "eAL00252"           "eAL00253"           "eAL00254"          
 [37] "eAL00255"           "eAL00256"           "eAL00257"          
 [40] "eAL00258"           "eAL00259"           "eAL00260"          
 [43] "eAL00261"           "eAL00262"           "eAL00263"          
 [46] "eAL00264"           "eAL00265"           "eAL00266"          
 [49] "wAL00200"           "wAL00201"           "wAL00202"          
 [52] "wAL00203"           "wAL00204"           "wAL00205"          
 [55] "wAL00206"           "wAL00207"           "wAL00208"          
 [58] "wAL00209"           "wAL00210"           "wAL00211"          
 [61] "wAL00212"           "wAL00213"           "wAL00214"          
 [64] "wAL00215"           "wAL00216"           "wAL00217"          
 [67] "wAL00218"           "wAL00219"           "wAL00220"          
 [70] "wAL00221"           "wAL00222"           "wAL00223"          
 [73] "wAL00224"           "wAL00225"           "wAL00226"          
 [76] "wAL00227"           "wAL00228"           "wAL00229"          
 [79] "wAL00230"           "wAL00231"           "wAL00232"          
 [82] "wAL00233"           "wAL00234"           "wAL00235"          
 [85] "wAL00236"           "wAL00237"           "wAL00238"          
 [88] "wAL00239"           "wAL00240"           "wAL00241"          
 [91] "wAL00242"           "wAL00243"           "wAL00244"          
 [94] "wAL00245"           "wAL00246"           "wAL00247"          
 [97] "wAL00248"           "wAL00249"           "wAL00250"          
[100] "wAL00251"           "wAL00252"           "wAL00254"          
[103] "wAL00255"           "wAL00256"           "wAL00257"          
[106] "wAL00258"           "wAL00259"           "wAL00260"          
[109] "wAL00261"           "wAL00262"           "wAL00263"          
[112] "wAL00264"           "wAL00265"           "wAL00266"          
[115] "wAL00267"           "wAL00268"           "wAL00269"          
[118] "wAL00270"           "wAL00271"           "wAL00272"          
[121] "wAL00273"           "wAL00274"           "wAL00275"          
[124] "wAL00276"           "wAL00277"           "wAL00278"          
[127] "wAL00279"           "wAL00280"           "wAL00281"          
[130] "wAL00282"           "wAL00283"           "wAL00284"          
[133] "wAL00285"           "wAL00286"           "peptides"          
[136] "peptides_log"       "proteins_norm2"     "proteins_processed"
Code
dims(leduc)
     eAL00219 eAL00220 eAL00221 eAL00222 eAL00223 eAL00224 eAL00225 eAL00226
[1,]     6269     6603     6511     6591     6525     6542     6474     6548
[2,]       18       18       18       18       18       18       18       18
     eAL00227 eAL00228 eAL00229 eAL00230 eAL00231 eAL00232 eAL00233 eAL00234
[1,]     6506     6641     6502     6576     6400     6531     6333     6558
[2,]       18       18       18       18       18       18       18       18
     eAL00235 eAL00236 eAL00237 eAL00238 eAL00239 eAL00240 eAL00241 eAL00242
[1,]     6423     6293     6379     6372     6436     6487     6273     6320
[2,]       18       18       18       18       18       18       18       18
     eAL00243 eAL00244 eAL00245 eAL00246 eAL00247 eAL00248 eAL00249 eAL00250
[1,]     6420     6392     6362     6427     6365     6361     6331     6435
[2,]       18       18       18       18       18       18       18       18
     eAL00251 eAL00252 eAL00253 eAL00254 eAL00255 eAL00256 eAL00257 eAL00258
[1,]     6271     6327     6320     6330     6272     6311     6249     6273
[2,]       18       18       18       18       18       18       18       18
     eAL00259 eAL00260 eAL00261 eAL00262 eAL00263 eAL00264 eAL00265 eAL00266
[1,]     6192     6240     6331     6252     4540     5020     4655     5968
[2,]       18       18       18       18       18       18       18       18
     wAL00200 wAL00201 wAL00202 wAL00203 wAL00204 wAL00205 wAL00206 wAL00207
[1,]     5151     6539     6561     6529     6479     4987     5208     5699
[2,]       18       18       18       18       18       18       18       18
     wAL00208 wAL00209 wAL00210 wAL00211 wAL00212 wAL00213 wAL00214 wAL00215
[1,]     6180     6491     6085     6284     6176     6298     5426     5810
[2,]       18       18       18       18       18       18       18       18
     wAL00216 wAL00217 wAL00218 wAL00219 wAL00220 wAL00221 wAL00222 wAL00223
[1,]     6111     5022     5293     6333     6345     6445     6402     5375
[2,]       18       18       18       18       18       18       18       18
     wAL00224 wAL00225 wAL00226 wAL00227 wAL00228 wAL00229 wAL00230 wAL00231
[1,]     6340     6014     6295     5807     6417     6363     3028     5609
[2,]       18       18       18       18       18       18       18       18
     wAL00232 wAL00233 wAL00234 wAL00235 wAL00236 wAL00237 wAL00238 wAL00239
[1,]     6434     4986     5679     5141     6476     6349     6388     6296
[2,]       18       18       18       18       18       18       18       18
     wAL00240 wAL00241 wAL00242 wAL00243 wAL00244 wAL00245 wAL00246 wAL00247
[1,]     6348     6119     6338     6355     6218     6207     6216     6347
[2,]       18       18       18       18       18       18       18       18
     wAL00248 wAL00249 wAL00250 wAL00251 wAL00252 wAL00254 wAL00255 wAL00256
[1,]     6316     6340     6338     6294     6306     6313     6278     6333
[2,]       18       18       18       18       18       18       18       18
     wAL00257 wAL00258 wAL00259 wAL00260 wAL00261 wAL00262 wAL00263 wAL00264
[1,]     6310     5220     6340     4636     6350     5296     6303     5403
[2,]       18       18       18       18       18       18       18       18
     wAL00265 wAL00266 wAL00267 wAL00268 wAL00269 wAL00270 wAL00271 wAL00272
[1,]     6276     6085     6399     6278     6254     6218     6183     6273
[2,]       18       18       18       18       18       18       18       18
     wAL00273 wAL00274 wAL00275 wAL00276 wAL00277 wAL00278 wAL00279 wAL00280
[1,]     6346     6293     6243     6328     6167     6259     6295     6182
[2,]       18       18       18       18       18       18       18       18
     wAL00281 wAL00282 wAL00283 wAL00284 wAL00285 wAL00286 peptides
[1,]     6306     6179     6213     6264     6281     6389    20804
[2,]       18       18       18       18       18       18     1556
     peptides_log proteins_norm2 proteins_processed
[1,]        12284           2844               2844
[2,]         1543           1543               1543
Code
# Inspect column metadata
colData(leduc) |> head()
DataFrame with 6 rows and 18 columns
                    Set  WellPooled  Channel SampleAnnotation  SampleType
            <character> <character> <factor>      <character> <character>
eAL00219RI1    eAL00219          N1  TMT126           carrier     Carrier
eAL00219RI2    eAL00219          N1  TMT127N        reference   Reference
eAL00219RI3    eAL00219          N1  TMT127C           unused      Unused
eAL00219RI4    eAL00219          N1  TMT128N           unused      Unused
eAL00219RI5    eAL00219          N1  TMT128C                u    Monocyte
eAL00219RI6    eAL00219          N1  TMT129N                u    Monocyte
                lcbatch     sortday      digest MelanomaSubCluster
            <character> <character> <character>        <character>
eAL00219RI1           C           B          88                 NA
eAL00219RI2           C           B          88                 NA
eAL00219RI3           C           B          88                 NA
eAL00219RI4           C           B          88                 NA
eAL00219RI5           C           B          88                 NA
eAL00219RI6           C           B          88                 NA
            IsolationTimeStamp  Diameter Elongation GlassSlide     Field
                     <POSIXlt> <numeric>  <numeric>  <numeric> <numeric>
eAL00219RI1                 NA        NA         NA         NA        NA
eAL00219RI2                 NA        NA         NA         NA        NA
eAL00219RI3                 NA        NA         NA         NA        NA
eAL00219RI4                 NA        NA         NA         NA        NA
eAL00219RI5      2021-09-28...     17.60       1.58          2         2
eAL00219RI6      2021-09-28...     22.78       1.53          2         2
             XPosDrop  YPosDrop XPosPickup YPosPickup
            <numeric> <numeric>  <numeric>  <numeric>
eAL00219RI1        NA        NA         NA         NA
eAL00219RI2        NA        NA         NA         NA
eAL00219RI3        NA        NA         NA         NA
eAL00219RI4        NA        NA         NA         NA
eAL00219RI5        21        56         15         58
eAL00219RI6         9        60         15         58
Code
# Inspect row metadata of the PSM-level assay
rowData(leduc[[1]]) |>
    as.data.frame() |>
    select(Sequence, Proteins, Reverse, Potential.contaminant, PEP, Score) |>
    head()
              Sequence             Proteins Reverse Potential.contaminant
PSM1       AAAAAAALQAK  sp|P36578|RL4_HUMAN                              
PSM67  AAAEDVNVTFEDQQK sp|Q9NQP4|PFD4_HUMAN                              
PSM68  AAAEDVNVTFEDQQK sp|Q9NQP4|PFD4_HUMAN                              
PSM110  AAAEVNQDYGLDPK sp|P07954|FUMH_HUMAN                              
PSM236   AAALQGVMGQSAR                            +                      
PSM240   AAAPGVEDEPLLR sp|P31350|RIR2_HUMAN                              
              PEP   Score
PSM1   1.5936e-05 96.3310
PSM67  9.9245e-02  5.9041
PSM68  6.3073e-11 76.5880
PSM110 9.8826e-08 59.2270
PSM236 9.5802e-02 12.1650
PSM240 1.2308e-03 38.3030
Code
# Table of cell types / conditions represented
colData(leduc)$SampleType |> table()

  Carrier  Melanoma  Monocyte  Negative Reference    Unused 
      134       878       877       120       134       269 

The output confirms the presence of single-cell channels, a carrier channel, and a reference channel. The colData contains the annotation we need for downstream analysis: the cell condition (SampleType), the TMT channel, and the acquisition run.

NoteSubsetting for memory-constrained rendering

If the full PSM table causes memory issues during book rendering, subset the dataset to exclude the carrier channel and keep only the single-cell channels before aggregation:

Code
leduc <- subsetByColData(leduc,
    colData(leduc)$SampleType != "Carrier")

This removes the high-intensity carrier channel that contributes many PSM identifications but is not part of the quantitative comparison.

Cell Annotation Audit and Quality Control

We compute per-channel quality metrics directly from the PSM-level assay:

Code
# Number of PSMs identified per channel (non-NA quantifications)
n_psms <- colSums(!is.na(assay(leduc[[1]])))
nFeatures <- data.frame(
    leduc2022_psms = n_psms,
    row.names = names(n_psms)
)
names(nFeatures)
[1] "leduc2022_psms"
Code
head(nFeatures)
            leduc2022_psms
eAL00219RI2           6269
eAL00219RI3           6269
eAL00219RI4           6269
eAL00219RI5           6269
eAL00219RI6           6269
eAL00219RI7           6269

These metrics help flag problematic channels: channels with very few identifications, abnormally low total intensity, or unusual missingness patterns.

Code
# Visualise per-channel identification counts
nPSM_data <- nFeatures |>
    tibble::rownames_to_column("channel") |>
    left_join(
        colData(leduc) |>
            as.data.frame() |>
            tibble::rownames_to_column("channel"),
        by = "channel"
    )

p1 <- ggplot(nPSM_data, aes(x = reorder(channel, leduc2022_psms), y = leduc2022_psms, fill = SampleType)) +
    geom_col() +
    coord_flip() +
    labs(x = "Channel", y = "Number of PSMs", fill = "Sample type") +
    theme_minimal()

# Total intensity per channel
qplot(colSums(assay(leduc[[1]]), na.rm = TRUE)) +
    labs(x = "Total reporter-ion intensity (log10)", y = "Channels") +
    scale_x_log10() +
    theme_minimal()

QC metrics per channel: number of identified PSMs and total intensity.

QC metrics per channel: number of identified PSMs and total intensity.
Code
p1

QC metrics per channel: number of identified PSMs and total intensity.

QC metrics per channel: number of identified PSMs and total intensity.

Carrier channels should show substantially higher intensity and more identifications. If a single-cell channel approaches carrier-level intensity, it may indicate a doublet (two cells in one channel). Conversely, channels with very low counts may represent empty channels or failed labeling.

Code
# Proportion of missing values per column
pct_missing <- apply(assay(leduc[[1]]), 2, function(x) mean(is.na(x))) * 100
qplot(pct_missing, bins = 15) +
    labs(x = "Missing PSMs (%)", y = "Number of channels") +
    theme_minimal()

Missing value proportion per channel.

Missing value proportion per channel.
WarningDo not impute SCP data with bulk methods

Bulk-proteomics imputation methods (Chapter 18), especially those designed for MCAR/MAR mechanisms, assume a relatively low proportion of missing values distributed across features. SCP data routinely exceeds 40–60 % missing values, and the missingness is driven by the stochastic nature of precursor selection in data-dependent acquisition (DDA). Bulk-style imputation under these conditions can introduce severe bias. Filter features with excessive missingness instead, and consider specialized SCP imputation approaches only after careful evaluation.

Filtering and Aggregation from PSMs to Proteins

PSM-Level Filtering

Before aggregation, remove common artefactual identifications:

Code
cat("PSMs before filtering:", nrow(leduc[[1]]), "\n")
PSMs before filtering: 6269 
Code
leduc <- filterFeatures(leduc,
    i = 1,
    ~ Reverse != "+" &
        Potential.contaminant != "+" &
        PEP < 0.05)

cat("PSMs after filtering:", nrow(leduc[[1]]), "\n")
PSMs after filtering: 4597 

Optional: remove the carrier channel from the quantitative assays, keeping only single-cell and reference channels:

Code
# Remove carrier channel (high-intensity booster not used in quant comparison)
leduc <- subsetByColData(leduc,
    colData(leduc)$SampleType != "Carrier")

cat("Channels after carrier removal:", ncol(leduc[[1]]), "\n")
Channels after carrier removal: 17 
Code
colData(leduc)$SampleType |> table()

 Melanoma  Monocyte  Negative Reference    Unused 
      878       877       120       134       269 

Aggregation: PSM to Peptide, Peptide to Protein

Aggregation collapses multiple quantitative observations (PSMs mapping to the same peptide, or peptides mapping to the same protein) into a single value per cell. The scp package uses aggregateFeatures() from QFeatures, which creates a new assay at each aggregation level.

The leduc2022 dataset ships pre-aggregated, so we can inspect the peptide and protein assays directly rather than re-aggregating from the individual PSM files:

Code
cat("Peptide assay dimensions:", dim(leduc[["peptides"]]), "\n")
Peptide assay dimensions: 20804 1556 
Code
cat("Protein assay dimensions:", dim(leduc[["proteins_processed"]]), "\n")
Protein assay dimensions: 2844 1543 

The `QFeatures` container retains all preceding assays, so you can always trace a protein's quantification back to its constituent peptides and PSMs:


::: {.cell}

```{.r .cell-code}
# Trace the provenance of a specific protein
which_protein <- "P14625"  # example accession
provenance <- leduc |>
    subsetByFeature(which_protein)
provenance
An instance of class QFeatures containing 138 set(s):
 [1] eAL00219: SingleCellExperiment with 10 rows and 17 columns 
 [2] eAL00220: SingleCellExperiment with 16 rows and 17 columns 
 [3] eAL00221: SingleCellExperiment with 15 rows and 17 columns 
 ...
 [136] peptides_log: SingleCellExperiment with 35 rows and 1543 columns 
 [137] proteins_norm2: SingleCellExperiment with 1 rows and 1543 columns 
 [138] proteins_processed: SingleCellExperiment with 1 rows and 1543 columns 

:::

Code
peptides_per_protein <- rowData(leduc[["peptides"]]) |>
    as.data.frame() |>
    count(Leading.razor.protein)

qplot(peptides_per_protein$n, bins = 20) +
    labs(x = "Peptides per protein", y = "Protein groups") +
    theme_minimal()

Number of peptides per protein in the aggregated data.

Number of peptides per protein in the aggregated data.

Normalization and Batch Assessment

Normalization Strategy

SCP normalization must address technical variation between cells within a run and, when multiple runs are present, between runs. The scp package provides normalizeScp() for this purpose.

The key assumptions underlying median-based normalization in SCP are:

  1. Most proteins do not change between the cells being compared — the median protein intensity is assumed stable.
  2. Technical variation is multiplicative — a scaling factor per channel is sufficient to align the intensity distributions.
  3. The reference channel (if present) is compositionally identical across runs — it serves as an anchor for cross-run scaling.

When these assumptions are violated — for example, when comparing very different cell types — more conservative normalization (e.g., quantile) or the use of spike-in controls may be warranted.

Code
# Median normalization of the protein assay
prot_mat <- assay(leduc[["proteins_processed"]])
scale_factors <- apply(prot_mat, 2, median, na.rm = TRUE)
prot_norm <- sweep(prot_mat, 2, scale_factors, "/", check.margin = FALSE)

cat("Normalized protein matrix dimensions:", dim(prot_norm), "\n")
Normalized protein matrix dimensions: 2844 1543 
Code
# Extract protein data before and after normalization
before <- assay(leduc[["proteins_processed"]])
after <- prot_norm

compare <- data.frame(
    channel = colnames(before),
    median_before = apply(before, 2, median, na.rm = TRUE),
    median_after = apply(after, 2, median, na.rm = TRUE)
) |>
    pivot_longer(-channel, names_to = "step", values_to = "median_intensity")

ggplot(compare, aes(x = step, y = log10(median_intensity), group = channel)) +
    geom_line(alpha = 0.5) +
    geom_point(aes(color = step)) +
    labs(x = "", y = "log10(median intensity)") +
    theme_minimal()

Per-channel median intensity before and after normalization.

Per-channel median intensity before and after normalization.

Batch Assessment

In the leduc2022 dataset, all cells come from a single TMTpro-16 run, so there is only one batch. The concepts below apply when your experiment includes multiple TMT runs.

Code
# Log-transform normalized protein data
prots_log <- log2(prot_norm + 1)

# Replace non-finite values with NA and keep proteins
# detected in at least 50 % of cells
prots_log[!is.finite(prots_log)] <- NA
detect_rate <- rowMeans(!is.na(prots_log))
prots_log <- prots_log[detect_rate >= 0.5, ]

# Impute remaining NAs with row means for PCA (prcomp cannot handle NAs)
k <- which(is.na(prots_log), arr.ind = TRUE)
if (nrow(k) > 0) {
    row_means <- rowMeans(prots_log, na.rm = TRUE)
    prots_log[k] <- row_means[k[, 1]]
}

# PCA
pca <- prcomp(t(prots_log), scale. = TRUE, center = TRUE)

# Prepare plot data using colData matching the assay columns
pca_data <- as.data.frame(pca$x)
pca_data$SampleType <- colData(leduc[["proteins_processed"]])$SampleType

pca_var <- summary(pca)$importance[2, 1:2] * 100  # variance explained

ggplot(pca_data, aes(x = PC1, y = PC2, color = SampleType)) +
    geom_point(size = 3) +
    stat_ellipse(level = 0.7, show.legend = FALSE) +
    labs(x = sprintf("PC1 (%.1f%%)", pca_var[1]),
         y = sprintf("PC2 (%.1f%%)", pca_var[2])) +
    scale_color_brewer(palette = "Set1") +
    theme_minimal()

PCA of protein-level data colored by cell condition.

PCA of protein-level data colored by cell condition.

PCA separation by cell condition after normalization suggests that biological differences are recoverable despite technical variation. If PCA instead separated by acquisition run (in a multi-run experiment), that would signal a residual batch effect requiring additional correction. Approaches include using the reference channel for batch normalization or including batch as a covariate in the statistical model (see Chapter 18 for batch-effect concepts and Chapter 20 for mixed-effect modeling).

Dimensional Reduction and Visualization

Beyond PCA, the scater package provides dedicated single-cell visualization methods. These help answer specific questions about data structure:

  • Do cells cluster by condition or by technical factors (run, channel position)?
  • Are there outlier cells with unusual protein profiles?
  • How much of the variance is explained by known biological and technical covariates?
Code
# Create a SingleCellExperiment for scater functions
sce <- SingleCellExperiment(
    assays = list(logcounts = prots_log),
    colData = colData(leduc[["proteins_processed"]])
)

# Run UMAP (via scater which uses scater::runUMAP, itself wrapping uwot)
set.seed(2024)
sce <- runUMAP(sce)

plotUMAP(sce, colour_by = "SampleType") +
    scale_color_brewer(palette = "Set1") +
    labs(title = "UMAP by cell condition")

UMAP embedding of single-cell proteome profiles, colored by cell condition.

UMAP embedding of single-cell proteome profiles, colored by cell condition.
Code
# Variance explained by condition vs other available covariates
# This uses variancePartition-style logic as shown in Chapter 20
if (requireNamespace("variancePartition", quietly = TRUE)) {
    tryCatch({
        library(variancePartition)
        # Build formula with available covariates from colData
        form <- ~ SampleType + Channel
        varPart <- fitExtractVarPartModel(prots_log, form, colData(leduc[["proteins_processed"]]))
        plotVarPart(sortCols(varPart)) +
            labs(title = "Variance decomposition")
    }, error = function(e) {
        cat("Variance decomposition skipped (package compatibility issue):\n  ", conditionMessage(e), "\n")
    })
} else {
    cat("Install variancePartition for variance decomposition analysis.\n")
}
Variance decomposition skipped (package compatibility issue):
   Initial model failed:
the 'findbars' function has moved to the reformulas package. Please update your imports, or ask an upstream package maintainer to do so. 
TipWhen to use scater vs. scp functions

Use scp::scp_qc() and scp::normalizeScp() for SCP-specific operations (QC metrics, normalization within the QFeatures hierarchy). Use scater::plotPCA(), scater::runUMAP(), and scater::plotColData() for exploratory visualization that benefits from single-cell-oriented defaults (point transparency, color scales, feature-name annotation).

Statistical Modeling

Differential abundance analysis in SCP faces two challenges: the high proportion of missing values and the modest number of cells per condition (often 10–50). The principles from the book’s statistical chapters apply with the following adjustments:

  • Design matrices follow the same structure as Chapter 19 (two-group, multi-group, or factorial designs). For the leduc2022 dataset, compare naive vs. primed conditions.
  • Empirical Bayes moderation (limma, Chapter 19) can be applied to the protein-level matrix after filtering out proteins with excessive missingness. Limma’s moderated t-test stabilizes variance estimates when the number of cells is small.
  • Missing values must be handled before limma. A common SCP strategy is to keep only proteins detected in at least 50–70 % of cells in at least one condition, then impute the remaining missing values per condition (e.g., with impute::impute.knn() or minimum-value imputation). Be aware that the choice of imputation method can strongly affect the results; see Chapter 18 for imputation sensitivity analysis.
  • Mixed models (Chapter 20) are appropriate when multiple acquisition runs exist, with SampleType as a fixed effect and Run as a random intercept: ~ SampleType + (1 | Run).
Code
# Filter: keep proteins with at least 50% detection in at least one condition
keep <- apply(prots_log, 1, function(x) {
    by_cond <- split(x, colData(leduc[["proteins_processed"]])$SampleType)
    any(sapply(by_cond, function(y) mean(!is.na(y)) >= 0.5))
})
prots_filt <- prots_log[keep, ]
cat("Proteins retained after filtering:", sum(keep), "/", nrow(prots_log), "\n")
Proteins retained after filtering: 1388 / 1388 
Code
# Simple imputation by condition median
prots_imp <- prots_filt
for (cond in unique(colData(leduc[["proteins_processed"]])$SampleType)) {
    idx <- colData(leduc[["proteins_processed"]])$SampleType == cond
    for (i in seq_len(nrow(prots_imp))) {
        if (is.na(prots_imp[i, idx][1])) {
            cond_vals <- prots_filt[i, idx]
            prots_imp[i, idx][is.na(prots_imp[i, idx])] <-
                median(cond_vals, na.rm = TRUE)
        }
    }
}

# limma differential abundance (Melanoma vs Monocyte)
library(limma)
cd <- colData(leduc[["proteins_processed"]])
design <- model.matrix(~ 0 + cd$SampleType)
colnames(design) <- levels(factor(cd$SampleType))
fit <- lmFit(prots_imp, design)
cont <- makeContrasts(
    melanoma_vs_monocyte = Melanoma - Monocyte,
    levels = design
)
fit2 <- contrasts.fit(fit, cont)
fit2 <- eBayes(fit2)
tt <- topTable(fit2, number = Inf)

# Volcano plot
tt$sig <- ifelse(tt$adj.P.Val < 0.05 & abs(tt$logFC) > 1,
    ifelse(tt$logFC > 0, "Up in melanoma", "Up in monocyte"),
    "Not significant"
)

ggplot(tt, aes(x = logFC, y = -log10(adj.P.Val), color = sig)) +
    geom_point(alpha = 0.6) +
    geom_hline(yintercept = -log10(0.05), linetype = "dashed", alpha = 0.5) +
    geom_vline(xintercept = c(-1, 1), linetype = "dashed", alpha = 0.5) +
    scale_color_manual(values = c("Up in monocyte" = "#2166AC",
                                   "Not significant" = "grey60",
                                   "Up in melanoma" = "#B2182B")) +
    labs(x = "log2 fold change (melanoma / monocyte)",
         y = "-log10(adjusted p-value)",
         title = "Melanoma vs. monocyte differential abundance") +
    theme_minimal() +
    theme(legend.title = element_blank())

Volcano plot comparing melanoma vs. monocyte cells.

Volcano plot comparing melanoma vs. monocyte cells.
Code
cat("Top differentially abundant proteins (melanoma vs. monocyte):\n")
Top differentially abundant proteins (melanoma vs. monocyte):
Code
tt |>
    filter(adj.P.Val < 0.05) |>
    arrange(adj.P.Val) |>
    head(10)
            logFC  AveExpr          t      P.Value    adj.P.Val        B
Q00722  1.1652079 6.088055  13.914156 5.012549e-42 6.957418e-39 84.41068
Q9NPE2 -1.0853291 5.223452 -11.382728 4.160294e-29 2.887244e-26 55.18474
Q9BW85 -0.8490741 6.822443 -10.550152 2.419214e-25 1.119290e-22 46.68323
Q86UK7 -0.9236239 5.933815 -10.461660 5.876803e-25 2.039251e-22 45.81330
Q9BZI7 -0.8270949 5.492459 -10.013529 4.756965e-23 1.320533e-20 41.50869
Q14185 -0.8888405 6.534603  -9.645978 1.539055e-21 3.560347e-19 38.10497
Q8TDI7  0.7266349 8.433314   9.238708 6.333276e-20 1.255798e-17 34.46863
Q5TZA2 -0.7965977 5.090671  -9.197902 9.119082e-20 1.582161e-17 34.11219
O75365 -0.7987679 5.443318  -9.129466 1.675206e-19 2.583540e-17 33.51765
Q99805  0.7684635 6.050195   9.110769 1.976600e-19 2.743521e-17 33.35592
                   sig
Q00722  Up in melanoma
Q9NPE2  Up in monocyte
Q9BW85 Not significant
Q86UK7 Not significant
Q9BZI7 Not significant
Q14185 Not significant
Q8TDI7 Not significant
Q5TZA2 Not significant
O75365 Not significant
Q99805 Not significant
WarningBiological replication matters

The differential analysis above uses cells as observations. These are technical replicates of the cell-culture condition, not independent biological replicates. A finding that holds across cells from one culture may not generalize to cells from an independently cultured population. For publication-level results, plan experiments with multiple biological replicates (independent cultures or tissue samples), with each replicate contributing several cells to a TMT run.

Summary

Single-cell proteomics analysis with scp adapts the familiar QFeatures framework to the unique challenges of low-input proteomics:

  • Data model: scp stores acquisition runs as SingleCellExperiment assays within a QFeatures container, providing access to both QFeatures aggregation methods and scater/scran single-cell visualisation and normalization functions.
  • QC: The scp_qc() function computes per-channel identification counts, total intensity, and missingness proportions, flagging problematic channels (doublets, empty channels, failed labeling).
  • Filtering and aggregation: Standard PSM-level filters (remove reverse hits, contaminants, low PEP score) precede aggregation PSM → peptide → protein via aggregateFeatures().
  • Normalization: Median-based scaling per channel, anchored by a reference channel when available, aligns intensity distributions across cells within and between runs.
  • Visualization: PCA and UMAP (via scater) reveal structure driven by cell condition, run, or channel position.
  • Statistical modeling: The limma framework from Chapter 19 applies after filtering and imputation, with the caveat that cell-level replication does not substitute for biological replication.

Exercises

  1. Subsetting practice. Filter the leduc object to keep only the naive and primed conditions, then repeat the PCA. Does the separation improve?

  2. Normalization comparison. Replace the median normalization with method = "colScale" in normalizeScp(). Compare the PCA before and after this alternative normalization.

  3. Missingness filtering. Vary the detection-rate threshold (used in the statistical modeling section) from 30 % to 70 %. How does the number of retained proteins and the number of significant hits change?

  4. Multi-run workflow (conceptual). If your experiment had three TMTpro-16 runs, which steps would change? Describe how joinAssays() fits into the pipeline and where the reference channel is used for batch normalization.

  5. Cross-reference with Chapter 20. The limma analysis in this appendix compares two groups (naive vs. primed). If the experiment added a third condition (e.g., reset), what design matrix and contrasts would you use? Write the model.matrix() call.

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] variancePartition_1.38.1    BiocParallel_1.42.2        
 [3] limma_3.64.3                patchwork_1.3.2            
 [5] tidyr_1.3.2                 dplyr_1.2.1                
 [7] scater_1.36.0               ggplot2_4.0.3              
 [9] scuttle_1.18.0              SingleCellExperiment_1.30.1
[11] scpdata_1.16.1              ExperimentHub_2.16.1       
[13] AnnotationHub_3.16.1        BiocFileCache_2.16.2       
[15] dbplyr_2.6.0                scp_1.18.0                 
[17] QFeatures_1.18.0            MultiAssayExperiment_1.34.0
[19] SummarizedExperiment_1.38.1 Biobase_2.68.0             
[21] GenomicRanges_1.60.0        GenomeInfoDb_1.44.3        
[23] IRanges_2.42.0              S4Vectors_0.46.0           
[25] BiocGenerics_0.54.1         generics_0.1.4             
[27] MatrixGenerics_1.20.0       matrixStats_1.5.0          

loaded via a namespace (and not attached):
  [1] RColorBrewer_1.1-3      jsonlite_2.0.0          magrittr_2.0.5         
  [4] ggbeeswarm_0.7.3        nloptr_2.2.1            farver_2.1.2           
  [7] rmarkdown_2.31          vctrs_0.7.3             minqa_1.2.8            
 [10] memoise_2.0.1           htmltools_0.5.9         S4Arrays_1.8.1         
 [13] BiocBaseUtils_1.10.0    curl_7.1.0              broom_1.0.13           
 [16] BiocNeighbors_2.2.0     SparseArray_1.8.1       KernSmooth_2.23-26     
 [19] htmlwidgets_1.6.4       pbkrtest_0.5.5          plyr_1.8.9             
 [22] cachem_1.1.0            igraph_2.3.3            iterators_1.0.14       
 [25] mime_0.13               lifecycle_1.0.5         pkgconfig_2.0.3        
 [28] rsvd_1.0.5              Matrix_1.7-3            R6_2.6.1               
 [31] fastmap_1.2.0           GenomeInfoDbData_1.2.14 rbibutils_2.4.1        
 [34] clue_0.3-68             numDeriv_2016.8-1.1     digest_0.6.37          
 [37] fdrtool_1.2.18          AnnotationDbi_1.70.0    RSpectra_0.16-2        
 [40] irlba_2.3.7             lpsymphony_1.36.0       RSQLite_3.53.3         
 [43] beachmat_2.24.0         filelock_1.0.3          labeling_0.4.3         
 [46] httr_1.4.8              abind_1.4-8             compiler_4.5.1         
 [49] aod_1.3.3               bit64_4.8.2             withr_3.0.3            
 [52] backports_1.5.1         S7_0.2.2                viridis_0.6.5          
 [55] DBI_1.3.0               gplots_3.3.0            MASS_7.3-65            
 [58] rappdirs_0.3.4          DelayedArray_0.34.1     corpcor_1.6.10         
 [61] caTools_1.18.3          gtools_3.9.5            tools_4.5.1            
 [64] vipor_0.4.7             otel_0.2.0              beeswarm_0.4.0         
 [67] remaCor_0.0.20          glue_1.8.1              nlme_3.1-168           
 [70] grid_4.5.1              cluster_2.1.8.2         reshape2_1.4.5         
 [73] gtable_0.3.6            BiocSingular_1.24.0     ScaledMatrix_1.16.0    
 [76] metapod_1.16.0          XVector_0.48.0          ggrepel_0.9.8          
 [79] BiocVersion_3.21.1      pillar_1.11.1           stringr_1.6.0          
 [82] splines_4.5.1           lattice_0.22-7          FNN_1.1.4.1            
 [85] bit_4.6.0               tidyselect_1.2.1        Biostrings_2.76.0      
 [88] knitr_1.51              reformulas_0.4.4        gridExtra_2.3.1        
 [91] ProtGenerics_1.40.0     IHW_1.36.0              RhpcBLASctl_0.23-42    
 [94] xfun_0.60               statmod_1.5.2           stringi_1.8.7          
 [97] UCSC.utils_1.4.0        boot_1.3-31             lazyeval_0.2.3         
[100] yaml_2.3.12             evaluate_1.0.5          codetools_0.2-20       
[103] nipals_1.0              MsCoreUtils_1.20.0      tibble_3.3.1           
[106] BiocManager_1.30.27     cli_3.6.5               uwot_0.2.4             
[109] Rdpack_2.6.6            Rcpp_1.1.2              EnvStats_3.1.0         
[112] png_0.1-9               parallel_4.5.1          blob_1.3.0             
[115] AnnotationFilter_1.32.0 bitops_1.0-9            lme4_2.0-6             
[118] mvtnorm_1.4-2           viridisLite_0.4.3       slam_0.1-56            
[121] lmerTest_3.2-1          scales_1.4.0            purrr_1.2.2            
[124] crayon_1.5.3            fANCOVA_0.6-1           rlang_1.3.0            
[127] cowplot_1.2.0           KEGGREST_1.48.1