Lecture notes. Written from the material of StatOmics Sga21, licensed CC BY-NC-SA 4.0. These are notes, not a transcript: the material has been reorganised and rewritten. This adaptation carries the same licence, and the original is linked above.
2. From Peptides to Differential Expression¶
What this covers¶
This chapter works through a complete differential-expression analysis of a proteomics dataset, from the raw output of a peptide search engine to a short list of proteins whose abundance differs between two patient groups. It follows one dataset end to end — eighteen breast-cancer proteomes assessed by mass spectrometry — through import, preprocessing, statistical modelling, and the plots used to report and sanity-check the result. It assumes some familiarity with linear regression and hypothesis testing, and with the basic vocabulary of shotgun proteomics (peptide, protein group, intensity).
The dataset¶
Eighteen estrogen-receptor-positive breast cancer tissues, from patients treated with tamoxifen upon recurrence, were assessed in a proteomics study. Nine patients had a good outcome ("or") and nine had a poor outcome ("pd"). The proteomes were measured on an LTQ-Orbitrap mass spectrometer, and the resulting raw files were searched with MaxQuant (version 1.4.1.2) against the human canonical proteome (FASTA version 2012-09).
The question the rest of the chapter answers is the standard one in this kind of study: which proteins are differentially abundant between the good-outcome and poor-outcome groups?
A MaxQuant search produces a peptides.txt file of peptide-level intensities (by default found in
path_to_raw_files/combined/txt/), one row per peptide and one column per sample. This is where
the analysis starts — not from protein-level intensities directly, because a protein's abundance
has to be reconstructed from the peptides that were actually detected for it, and that
reconstruction is itself part of the pipeline (see Summarizing peptides to proteins, below).
Getting the data into R¶
The QFeatures package represents the experiment as a single object that can hold several linked
versions of the same features — raw, log-transformed, normalized, aggregated to protein — side by
side, together with sample-level metadata. readQFeatures builds this object directly from
peptides.txt; grep on the column names is used first to identify which columns hold intensities
(they are all named Intensity.<something>):
ecols <- grep("Intensity\\.", names(read.delim(peptidesFile)))
pe <- readQFeatures(
table = peptidesFile,
fnames = 1,
ecol = ecols,
name = "peptideRaw", sep = "\t")
The sample outcome (or/pd) is not a separate column of peptides.txt; it is encoded inside the
sample names themselves, so it has to be pulled out with a string operation and attached as a
factor in the object's sample metadata:
Two more quantities are computed before any transformation of the intensities happens, because both are needed later, for filtering:
nNonZero, the number of samples in which a given peptide has a non-zero intensity —rowSums(assay(pe[["peptideRaw"]]) > 0).- the zero intensities themselves are converted to
NA. A recorded intensity of exactly zero is not a peptide that truly has zero abundance; it is a peptide the search engine failed to detect in that sample. Treating it as a genuine measurement of zero would be wrong, so it is recoded as missing:pe <- zeroIsNA(pe, "peptideRaw").
Once this is done, a first look at the data matters as much as any later step: a substantial fraction of all peptide intensities are missing, and for some peptides there is no signal at all, in any sample. A preprocessing pipeline for this kind of data has to deal with that missingness rather than pretend it isn't there — which the filtering step, below, is partly for.
Preprocessing¶
Putting intensities on the log scale¶
The first step is a base-2 log transformation of the raw, peptide-level intensities:
This creates a new, parallel assay (peptideLog) inside the same object rather than overwriting
the raw one — the QFeatures object keeps every stage of the pipeline addressable by name, which
is what later lets the detail plots go back and compare a peptide's raw and processed values side
by side.
Filtering¶
Filtering removes rows of the data that should not be trusted, or should not be counted twice, in three separate passes.
Overlapping protein groups. A peptide can map to more than one protein — its sequence occurs in
several proteins at once — and it is only kept if none of those proteins is already covered by some
other, smaller group of proteins that the peptide is also consistent with. Practically, each
peptide is assigned to the smallest unique group of proteins it is compatible with, via
smallestUniqueGroups, and ambiguous assignments that cannot be resolved this way are dropped:
pe <- filterFeatures(pe, ~ Proteins %in% smallestUniqueGroups(rowData(pe[["peptideLog"]])$Proteins))
Decoys and contaminants. Peptides matching reverse (decoy) sequences, and known contaminant proteins, are removed:
A further filter — dropping proteins that were only identified via peptides carrying a chemical modification — is skipped in this run, because it needs the larger protein-groups file, which was not used here. This is left as a genuine gap in the pipeline as run, not a step judged unnecessary.
Peptides seen in only one sample. A peptide's intensity is only informative about between-sample differences if it was actually detected more than once. Peptides observed in fewer than two samples are dropped:
Normalizing by median centering¶
Even after filtering, samples can differ in overall intensity for reasons that have nothing to do with the biology — how much material was loaded, day-to-day differences in the instrument. Median centering removes this per-sample shift by subtracting, from every peptide intensity in a sample, that sample's own median intensity over all of its observed peptides:
where \(\hat\mu_i\) is the median intensity across peptides in sample \(i\). After this step the per-sample density curves of intensity line up with one another — a rough check that the procedure did what it was meant to — and a multidimensional scaling (MDS) plot can be used to look at the overall structure of the samples, with its first axis showing the leading log fold changes between them.
Summarizing peptides to proteins¶
The last step of preprocessing collapses the (now normalized) peptide-level intensities into one value per protein per sample, by robust aggregation of the peptides belonging to each protein group:
pe <- aggregateFeatures(pe, i = "peptideNorm", fcol = "Proteins", na.rm = TRUE, name = "proteinRobust")
"Robust" here is the default behaviour of aggregateFeatures: the summary for a protein is not
simply an average of its peptides' intensities — a peptide whose intensity is out of line with the
others mapping to the same protein contributes less to the protein-level summary than the rest do.
The same MDS diagnostic used on the peptide-level data is repeated here, on the protein-level
assay.
Modelling and testing for differential abundance¶
Fitting the model¶
msqrob2 fits, for every protein, a regression of log2 protein intensity on the experimental
factor of interest — here, outcome:
By default this regression is also robust: a protein's fit is not thrown off by one peptide-poor sample, or one badly-behaved observation, in the way an ordinary least-squares fit would be.
The contrast that answers the question¶
Before writing down a contrast it is worth checking what the model's parameters actually are.
getCoef(rowData(pe[["proteinRobust"]])$msqrobModels[[1]]) lists them for one protein's fit, and
ExploreModelMatrix::VisualizeDesign(colData(pe), ~outcome) draws the design matrix they belong to.
outcome has two levels, or and pd; the default treatment coding takes the alphabetically
first level, or (the good-outcome group), as the reference. The fitted model therefore has two
parameters: an intercept, which is the mean log2 protein expression in the good-outcome group, and
a parameter outcomePD, added to the intercept to get the mean log2 expression in the poor-outcome
group. outcomePD is exactly the log2 fold change between the poor-outcome and good-outcome
groups — so the biological question "does this protein differ between the two outcomes?" becomes
the statistical question "is outcomePD different from zero?", tested as the contrast
outcomePD = 0:
L <- makeContrast("outcomePD = 0", parameterNames = c("outcomePD"))
pe <- hypothesisTest(object = pe, i = "proteinRobust", contrast = L)
This attaches, to every protein's row of data, an estimated log fold change (logFC), a
\(p\)-value, and a multiple-testing-adjusted \(p\)-value (adjPval).
Reading and checking the result¶
Volcano plot¶
A volcano plot puts the estimated fold change on one axis against statistical significance on the other, so that proteins that are both large in effect and confidently detected stand out from proteins that are merely noisy:
volcano <- ggplot(rowData(pe[["proteinRobust"]])$outcomePD,
aes(x = logFC, y = -log10(pval), color = adjPval < 0.05)) +
geom_point(cex = 2.5) +
scale_color_manual(values = alpha(c("black", "red"), 0.5)) + theme_minimal()
Proteins declared significant at the 5% false discovery rate (adjPval < 0.05) are highlighted in
the plot.
Heatmap¶
The significant proteins' names are extracted, and a heatmap of their protein-level intensities across all eighteen samples gives a second view of the same result — whether the significant proteins actually separate the two outcome groups, rather than merely happening to pass the threshold:
sigNames <- rowData(pe[["proteinRobust"]])$outcomePD %>%
rownames_to_column("proteinRobust") %>%
filter(adjPval < 0.05) %>%
pull(proteinRobust)
heatmap(assay(pe[["proteinRobust"]])[sigNames, ])
Detail plots¶
The most useful check is going back down a level, from the protein-level summary to the individual
peptides that produced it. For each of the first few significant proteins, the peptide-level
(peptideNorm) and protein-level (proteinRobust) values for that protein are pulled out together
and plotted twice: once as a line plot of each peptide's intensity across all eighteen samples,
faceted by level, to see whether the underlying peptides move together or the "significant" call is
driven by a handful of outliers; and once as a boxplot of intensities grouped by outcome, which is
the most direct picture of whether the two outcome groups actually separate for that protein.
Sources¶
docs/omics-statistics/statomics/sga21/cancer2_6x6/01-data.md— dataset description, import viaQFeatures/readQFeatures, theoutcomefactor,nNonZero, andzeroIsNA.docs/omics-statistics/statomics/sga21/cancer2_6x6/02-preprocessing.md— log transformation, the three filtering steps, median-centering normalization, MDS diagnostics, andaggregateFeatures.docs/omics-statistics/statomics/sga21/cancer2_6x6/03-data-analysis.md—msqrobmodel fitting, the design and contrast,hypothesisTest, the volcano plot, heatmap, and detail plots.
All three files convert the same source, cancer2_6x6.Rmd from the statOmics SGA21 course
(licensed CC BY-NC-SA 4.0). Two things the source points to but does not itself contain: reference
[6], cited in 01-data.md for the MaxQuant search, without a full citation; and the
peptides6vs6.txt data file, and the larger protein-groups file needed for the modified-peptide
filtering step, both hosted externally on the statOmics/SGA2020 GitHub data branch rather than
supplied here.
← 1. The MSqRob Statistical Model · Contents · 3. CPTAC Spike-In Case Study →