SEQUEST-like scoring
Identifying the peptide behind an MS2 spectrum runs through five steps: the
measured spectrum is preprocessed (signal detection),
candidate sequences are selected by precursor mass
(peptide search databases), a theoretical
spectrum is predicted for every candidate, the predictions are matched against the
measurement, and the closeness of fit is scored. This page walks steps three to five
for one spectrum, using the SequestLike module.
The scoring question sounds simple: which candidate's predicted peaks line up best with the measured ones? Representing both sides as intensity vectors over 1 Da bins makes a dot product the obvious measure of agreement. On its own, that measure has a flaw. A measured spectrum with many peaks all over the m/z axis gets a decent dot product with almost any candidate, simply because most predictions land near some peak. SEQUEST's cross-correlation score (xcorr) fixes this by asking how much better the prediction correlates with the measured spectrum at zero offset than on average across a range of shifted offsets. That mean off-position correlation is the background, and subtracting it leaves only the agreement that is specific to the candidate's peak pattern.
The SequestLike implementation follows this idea in a form that lets one
spectrum be scored against many candidates cheaply. The measured spectrum is
binned to 1 Da, and its intensities are square-root scaled and normalized to
the maximum within each of 10 windows. The average of the spectrum's shifted
copies over a delay range of 75 is then subtracted once. Algebraically that
folds the whole background subtraction into the measured side, so scoring a
candidate afterwards reduces to a dot product between the candidate's
predicted intensity vector and this preprocessed measured vector. The module is deliberately named SEQUEST-like.
Its background window differs from the symmetric average in the published
SEQUEST algorithm, so scores follow the same idea as the original without
matching its numbers exactly, and they are comparable within this
implementation.
Loading the measured spectrum
The running example of these pages is the MS2 scan of the doubly charged
peptide ANLGMEVMHER in ms2Example.mgf. Its precursor m/z of 643.803548 and
charge 2 were recovered on the
charge state determination
page. PeakArray.zip turns the two raw arrays of the reader into the
PeakArray the scorer consumes, as introduced on the
peaks and peak arrays page.
open BioFSharp
open BioFSharp.FileFormats.MGF
open BioFSharp.IO
open BioFSharp.Mz
let ms2 =
MGF.read (__SOURCE_DIRECTORY__ + "/data/ms2Example.mgf")
|> List.head
let precursorMZ =
match MGFEntry.tryGetPrecursorMZ ms2 with
| Some mz -> mz
| None -> failwith "no precursor m/z in the MS2 header"
let spectrum : PeakArray<Peak> = PeakArray.zip ms2.Mass ms2.Intensity
printfn "peaks: %i covering m/z %.2f to %.2f"
spectrum.Length spectrum.[0].Mz spectrum.[spectrum.Length - 1].Mz
|
Assembling the candidate list
In a production pipeline the candidates come out of a
search database query for all peptides
within a narrow mass window around the measured precursor mass. To keep this
page self-contained we build such a result list by hand with
SearchDB.createLookUpResult, which produces the same LookUpResult records
the database returns. The neutral mass of each candidate is computed in code
as its residue masses summed up plus one water for the termini.
The list holds the true peptide and three pretenders. Two are permutations of ANLGMEVMHER that keep the C-terminal arginine, so they weigh exactly the same. The third replaces the asparagine with two glycines, a substitution that is also exactly isobaric because the asparagine residue and two glycine residues share the elemental composition. A mass window query cannot distinguish any of these by mass. Only the fragment pattern can.
let mono : IBioItem -> float = BioItem.monoisoMass
let neutralMass (peptide: AminoAcids.AminoAcid list) =
(peptide |> List.sumBy mono) + mono ModificationInfo.Table.H2O
// Stand-ins for what a search database mass window query would return;
// built by hand to keep the page self-contained.
let candidate modSeqId pepSeqId (sequence: string) =
let bioSequence = BioList.ofAminoAcidString sequence
let mass = neutralMass bioSequence
let roundedMass = int64 (System.Math.Round(mass * 1000000.))
SearchDB.createLookUpResult modSeqId pepSeqId mass roundedMass sequence bioSequence 0
let candidates =
[ candidate 1 1 "ANLGMEVMHER" // the peptide the scan was recorded from
candidate 2 2 "MANGLEVMHER" // permutation, same mass
candidate 3 3 "EVMANLGMHER" // permutation, same mass
candidate 4 4 "AGGLGMEVMHER" // Asn replaced by Gly-Gly, also isobaric
]
candidates
|> List.iter (fun c -> printfn "%-13s %10.4f Da" c.StringSequence c.Mass)
|
All four candidates sit at 1285.59 Da, within about two millidaltons of the neutral precursor mass determined from the MS1 scan.
Predicting a theoretical spectrum per candidate
Each candidate is paired with its predicted fragment masses.
Fragmentation.Series.fragmentMasses produces them exactly as on the
in silico fragmentation page: the b and y
series of the candidate sequence as TargetMasses, and the same series of
the reversed sequence as DecoyMasses. We use monoisotopic masses
throughout.
SequestLike.getTheoSpecs then converts every pair into a
TheoreticalSpectrum, holding one binned intensity vector for the target and
one for the decoy. The intensity of each predicted peak comes from a simple
model (predictIntensitySimpleModel): main series ions get the full
predicted intensity, loss peaks and minor series a fraction of it,
everything divided by the charge the ion is predicted at. Every fragment is
laid down once per charge from 1 up to the precursor charge, and the vector
is binned to 1 Da like the measured side, so the two can be compared bin by
bin.
The scan limits define the m/z range of that binning. We use 100 to 1300, which covers the recorded peaks starting at m/z 100.67 as well as the heaviest interesting fragment, the singly protonated full-length y ion at m/z 1286.6. The upper border only cuts away a single stray peak at m/z 1337.6, and no peak of the scan rounds exactly onto a border, where the binning would drop it.
let scanlimits = 100., 1300.
let fragmentPairs =
candidates
|> List.map (fun c ->
let fragments =
Fragmentation.Series.fragmentMasses
Fragmentation.Series.bOfBioList
Fragmentation.Series.yOfBioList
mono
c.BioSequence
c, fragments)
let theoSpecs = SequestLike.getTheoSpecs scanlimits 2 fragmentPairs
open FSharp.Stats
let countOccupied v =
v |> Vector.toArray |> Array.filter (fun x -> x > 0.) |> Array.length
printfn "bins per vector: %i" (Vector.length theoSpecs.Head.TheoSpec)
theoSpecs
|> List.iter (fun ts ->
printfn "%-13s target bins occupied: %i decoy bins occupied: %i"
ts.LookUpResult.StringSequence
(countOccupied ts.TheoSpec)
(countOccupied ts.DecoyTheoSpec))
|
Every candidate now owns two binned prediction vectors, each occupying
around a hundred of the 1200 bins. getTheoSpecs builds its result list by
prepending, so the order is reversed relative to the input, which does not
matter because the scorer ranks by score anyway.
Preprocessing the measured spectrum
The scorer performs the background subtraction on the measured side once, through
spectrumToIntensityArrayMinusAutoCorrelation. calcSequestScore calls it
internally, so this block is purely illustrative.
let preprocessed =
SequestLike.spectrumToIntensityArrayMinusAutoCorrelation scanlimits spectrum
let values = preprocessed |> Vector.toArray
printfn "vector length: %i" values.Length
printfn "positive entries: %i" (values |> Array.filter (fun x -> x > 0.) |> Array.length)
printfn "negative entries: %i" (values |> Array.filter (fun x -> x < 0.) |> Array.length)
|
The negative entries are the signature of the background subtraction. A bin whose intensity is lower than the local self-correlation average now penalizes a candidate that predicts a peak there, while a bin that stands out above the background rewards it. The dot product with a prediction vector therefore measures alignment beyond what shifted versions of the spectrum would produce by chance.
Scoring the candidates
SequestLike.calcSequestScore takes the scan limits, the measured spectrum,
the scan time, the precursor charge, the isolation window target m/z (the
precursor m/z from the header), the theoretical spectra and a free-text
spectrum identifier. It scores every target and every decoy vector against
the preprocessed measured vector and returns one SearchEngineResult per
scored spectrum, ranked by descending score.
let results =
SequestLike.calcSequestScore
scanlimits spectrum 20.93 2 precursorMZ theoSpecs "ms2Example"
let printRanked (rs: SearchEngineResult.SearchEngineResult<float> list) =
printfn "%-13s %-6s %8s %12s %8s" "sequence" "target" "score" "dBestToRest" "dNext"
rs
|> List.iter (fun r ->
printfn "%-13s %-6b %8.4f %12.4f %8.4f"
r.StringSequence r.IsTarget r.Score r.NormDeltaBestToRest r.NormDeltaNext)
printRanked results
|
The target of ANLGMEVMHER comes out on top at 13.04. The two permutations score far below it, at 7.94 and 4.06. Both end in the same MHER stretch and therefore predict the true peptide's low y ions, which is why they still beat every decoy, while their remaining predictions land in the wrong bins. MANGLEVMHER even shares the y ladder up to y6 with the true sequence, and that larger overlap is its lead over the other permutation.
The Gly-Gly candidate almost ties the true peptide at 12.87: two glycines weigh
exactly as much as one asparagine, so from b3 onward its b ladder reproduces the
true one bin for bin, and the shared C-terminal nine residues make y1 to y9
identical as well. Only a handful of bins differ, among them the extra b2 of the
glycine pair, and those few bins are the entire margin. Under any mass-based
fragment comparison a sequence whose fragments are isobaric with the true ones is
close to indistinguishable, and the small dNext of the top hit records that
ambiguity.
Reading a SearchEngineResult
Printing the best hit in full shows everything the record carries.
printfn "%A" results.Head
|
SearchEngine names the scorer that produced the record, so results of
different engines can share one result type. SpectrumID is the identifier
string passed into the call, and ScanTime travels along the same way, so a
PSM can be traced back to its scan. ModSequenceID and PepSequenceID are
copied from the LookUpResult and tie the hit back to the
search database rows it came from,
and the GlobalMod labeling flag rides along with them.
IsTarget distinguishes the candidate's own fragment prediction from that
of its reversed decoy sequence. Both records of such a pair share all
identifiers, since they stem from the same database entry.
StringSequence, PrecursorCharge, PrecursorMZ and PeptideLength
describe the match itself. MeasuredMass is the neutral mass computed from
the isolation window target m/z and the charge, while TheoMass is the
candidate's database mass, so the difference between the two is the
precursor mass error of the match. Score is the xcorr-style dot product.
The two delta fields put each score into the context of the whole ranking.
calcNormDeltaBestToRest fills NormDeltaBestToRest with (best score minus this
score) divided by the best score, so the best hit gets 0 and weaker hits approach 1:
MANGLEVMHER's 0.39 means it lost 39 percent of the top score. calcNormDeltaNext
fills NormDeltaNext with the gap to the next-ranked PSM, normalized by the best
score, with the last PSM getting 0: the top hit's 0.0132 is the Gly-Gly near-tie
discussed above. Both functions expect their input ranked by descending score, which
calcSequestScore arranges internally, and a best score of zero or below makes them
return sentinel values instead, 1 and 0 respectively for every PSM. How far a hit
stands out from its competitors is an input to
false discovery rate control.
What the decoys are for
The ranked table contains eight PSMs for four candidates because every candidate was also scored as its reversed decoy, the pairing introduced on the in silico fragmentation page. All four decoy scores sit between 0.05 and 2.40, the level a wrong sequence of the right mass reaches on this spectrum. A single xcorr value has no absolute meaning, and only the comparison against a population of known wrong matches tells whether a 13 is convincing. Collected over a whole run, the decoy scores estimate the score distribution of chance matches, which is what FDR control is built on.
Scoring many spectra
For a whole run with thousands of spectra, calcSequestScoreParallel
distributes the per-candidate scoring with Async.Parallel. It takes the
same arguments and produces the same ranking.
let resultsParallel =
SequestLike.calcSequestScoreParallel
scanlimits spectrum 20.93 2 precursorMZ theoSpecs "ms2Example"
let bestSequential = results.Head
let bestParallel = resultsParallel.Head
printfn "sequential best: %-13s target=%b score=%.4f"
bestSequential.StringSequence bestSequential.IsTarget bestSequential.Score
printfn "parallel best: %-13s target=%b score=%.4f"
bestParallel.StringSequence bestParallel.IsTarget bestParallel.Score
|
Where the scores go next
The library offers further scorers following a different scoring philosophy, covered in Andromeda-like and X!Tandem-like scoring, and the target and decoy scores collected across a run feed false discovery rate control.
<summary> Mgf <http://www.matrixscience.com/help/data_file_help.html>`_ is a simple human-readable format for MS/MS data. It allows storing MS/MS peak lists and exprimental parameters. </summary>
<summary> Reads an mgf file into a collection of MgfEntries </summary>
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
<summary> Represents </summary>
module PeakArray from BioFSharp.Mz
--------------------
type PeakArray<'a (requires 'a :> IPeak)> = 'a array
[<Struct>] type Peak = interface IPeak new: mz: float * intensity: float -> Peak member Equals: Peak * IEqualityComparer -> bool member Intensity: float member Mz: float
--------------------
Peak ()
new: mz: float * intensity: float -> Peak
<summary> Iterates the mz and intensity array and creates a Peak(mz,intensity) for each value pair. Returns a new Peak array. </summary>
<summary>Gets the total number of elements in all the dimensions of the <see cref="T:System.Array" />.</summary>
<exception cref="T:System.OverflowException">The array is multidimensional and contains more than <see cref="F:System.Int32.MaxValue">Int32.MaxValue</see> elements.</exception>
<returns>The total number of elements in all the dimensions of the <see cref="T:System.Array" />; zero if there are no elements in the array.</returns>
<summary> Marker interface for BioItem base. </summary>
val float: value: 'T -> float (requires member op_Explicit)
--------------------
type float = System.Double
--------------------
type float<'Measure> = float
<summary> Basic functions on IBioItems interface </summary>
<summary> Returns the monoisotopic mass of a bio item (without H20) </summary>
<summary> Contains the AminoAcid type and its according functions. The AminoAcid type is a complex presentation of amino acids, allowing modifications </summary>
<summary> Amino acid Codes </summary>
<summary> Functionality for creating formula modifications </summary>
<summary> Contains frequent modifications </summary>
val string: value: 'T -> string
--------------------
type string = System.String
module BioList from BioFSharp.BioCollectionsExtensions
--------------------
module BioList from BioFSharp
<summary> This module contains the BioList type and its according functions. The BioList type is a List of objects using the IBioItem interface </summary>
<summary> Generates amino acid sequence of one-letter-code raw string </summary>
val int64: value: 'T -> int64 (requires member op_Explicit)
--------------------
type int64 = System.Int64
--------------------
type int64<'Measure> = int64
<summary>Provides constants and static methods for trigonometric, logarithmic, and other common mathematical functions.</summary>
System.Math.Round(d: decimal) : decimal
System.Math.Round(value: float, mode: System.MidpointRounding) : float
System.Math.Round(value: float, digits: int) : float
System.Math.Round(d: decimal, mode: System.MidpointRounding) : decimal
System.Math.Round(d: decimal, decimals: int) : decimal
System.Math.Round(value: float, digits: int, mode: System.MidpointRounding) : float
System.Math.Round(d: decimal, decimals: int, mode: System.MidpointRounding) : decimal
<summary> Returns the fragment masses of the amino acid sequence specified by aal. The ionseries are specified by functions "nTerminalSeries" and "cTerminalSeries". The mass accuracy is determined by the massfunction applied. </summary>
<summary> Returns the b series of the given amino acids list. The mass accuracy is determined by the massfunction applied. </summary>
<summary> Returns the y series of the given amino acids list. The mass accuracy is determined by the massfunction applied. </summary>
<summary> Converts the fragment ion ladders to a theoretical Sequestlike spectrum at a given charge state. Subsequently, the spectrum is binned to the nearest mz bin (binwidth = 1 Da). Filters out peaks that are not within the scanLimits. </summary>
namespace FSharp
--------------------
namespace Microsoft.FSharp
module Vector from FSharp.Stats
--------------------
type Vector<'T> = interface IEnumerable interface IEnumerable<'T> interface IStructuralEquatable interface IStructuralComparable interface IComparable new: opsV: INumeric<'T> option * arrV: 'T array -> Vector<'T> override Equals: yobj: obj -> bool override GetHashCode: unit -> int member GetSlice: start: int option * finish: int option -> Vector<'T> member Permute: p: permutation -> Vector<'T> ...
--------------------
new: opsV: INumeric<'T> option * arrV: 'T array -> Vector<'T>
<summary>Creates array with values of vector</summary>
<remarks></remarks>
<param name="vector"></param>
<returns></returns>
<example><code></code></example>
module Array from FSharp.Stats
<summary> Module to compute common statistical measure on array </summary>
--------------------
module Array from Microsoft.FSharp.Collections
--------------------
type Array = new: unit -> Array static member geomspace: start: float * stop: float * num: int * ?IncludeEndpoint: bool -> float array static member linspace: start: float * stop: float * num: int * ?IncludeEndpoint: bool -> float array
--------------------
new: unit -> Array
<summary>Returns length of vector</summary>
<remarks></remarks>
<param name="vector"></param>
<returns></returns>
<example><code></code></example>
module List from FSharp.Stats
<summary> Module to compute common statistical measure on list </summary>
--------------------
module List from Microsoft.FSharp.Collections
--------------------
type List = new: unit -> List static member geomspace: start: float * stop: float * num: int * ?IncludeEndpoint: bool -> float list static member linspace: start: float * stop: float * num: int * ?IncludeEndpoint: bool -> float list
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
--------------------
new: unit -> List
<summary> Measured spectrum to sequest-like normalized intensity array minus auto-correlation (delay 75 -> like in original sequest algorithm) ! Uses 10 as number of windows for window normalization (like in original sequest algorithm) </summary>
<summary> Calculates the SequestLike Scores for all theoretical spectra. </summary>
<summary> Calculates the sequestLike Scores for all theoretical spectra. Implemented using Async parallel. </summary>
BioFSharp.Mz