Header menu logo BioFSharp.Mz

BinderScriptNotebook

Peak quantification

The previous pages end with an identified peptide: the fragment spectrum recorded at 20.93 min matches ANLGMEVMHER, and its precursor sits at m/z 643.80 carrying two charges (see charge state determination). Identification answers which peptide was measured. Quantification asks how much of it was there, and the answer comes from a different slice through the data.

A peptide elutes from the LC column over a stretch of retention time, together with many other peptides that happen to leave the column at the same moment, and every MS1 survey scan taken during that stretch records its isotope cluster once more. All signals of one peptide ion species therefore form a three-dimensional feature along m/z, retention time and intensity. Extracting the intensity at one m/z across consecutive MS1 scans flattens that feature into the extracted ion chromatogram (XIC): a trace that climbs while the peptide elutes and falls back to the noise level once it has passed. The area under that chromatographic peak is proportional to the amount of peptide, and area integration is a more stable and reproducible quantity than the bare apex height.

For the ANLGMEVMHER precursor one would extract the intensities around m/z 643.80 from every MS1 scan of the run. The example data in this repository holds a single MS1 scan, a single time point on such a trace. This page therefore builds a realistic XIC trace synthetically, which has an advantage for a walkthrough: the generating parameters are known exactly, so every result can be checked against ground truth.

Building an XIC trace

XICs approximately follow a Gaussian shape, with one recurring deviation: peak tailing, a positive skew commonly caused by column overloading or unwanted retention mechanisms that keep part of the analyte on the column a little longer. The exponentially modified Gaussian (EMG) describes such tailing peaks well, so the main synthetic peak is generated from exactly that function. A second, smaller feature elutes 0.85 min later, and seeded uniform noise imitates the positive chemical background an instrument records between peaks.

open System
open FSharp.Stats
open BioFSharp.Mz
open BioFSharp.Mz.Quantification

// Exponentially modified Gaussian: a Gaussian of height amplitude, center meanX
// and width sigma, convolved with an exponential decay of relaxation time tau.
// The tau term produces the right-sided tail typical of chromatographic peaks.
let emg amplitude meanX sigma tau x =
    (amplitude * sigma / tau) * sqrt (Math.PI / 2.)
    * exp (0.5 * (sigma / tau) ** 2. - (x - meanX) / tau)
    * SpecialFunctions.Errorfunction.Erfc ((sigma / tau - (x - meanX) / sigma) / sqrt 2.)

let gaussian amplitude meanX sigma x =
    amplitude * exp (-((x - meanX) ** 2.) / (2. * sigma ** 2.))

// Fixed seed: reruns of this page produce identical numbers.
let rnd = Random(1337)

// One MS1 scan every 0.7 s (0.0117 min) for three minutes around the 20.9 min
// elution of the running example. The main peak reaches the tens of thousands
// of counts, a realistic MS1 XIC intensity for a well ionizing peptide, and its
// sigma of 0.055 min (3.3 s) is a typical chromatographic peak width. The
// noise floor of up to 250 counts stays strictly positive, as measured
// intensities do.
let retentionTimes = [| 19.6 .. 0.0117 .. 22.6 |]

let intensities =
    retentionTimes
    |> Array.map (fun rt ->
        emg 42000. 20.9 0.055 0.09 rt
        + gaussian 9000. 21.75 0.05 rt
        + rnd.NextDouble() * 250.)

let apexIndex =
    intensities |> Array.findIndex ((=) (Array.max intensities))

printfn "%i points from %.2f to %.2f min" retentionTimes.Length retentionTimes.[0] (Array.last retentionTimes)
printfn "highest intensity: %.0f at %.3f min" intensities.[apexIndex] retentionTimes.[apexIndex]
257 points from 19.60 to 22.60 min
highest intensity: 27510 at 20.946 min

The apex lands a little right of the EMG center at 20.9 min because the exponential tail shifts the maximum, exactly as it does on a real column. The generating EMG has an analytic area of amplitude times sigma times the square root of two pi, about 5790 intensity·min, a number to keep in mind for the results below.

Detecting peaks on the trace

Quantification starts by deciding where on the trace a peak begins and ends. FSharp.Stats brings a second-derivative peak picker for this, Signal.PeakDetection.SecondDerivative.getPeaks. It smooths the trace with a Savitzky-Golay filter, takes the negative second derivative (which turns every peak into a pronounced maximum), estimates the noise as the mean absolute difference between the raw and the smoothed trace, and keeps candidate peaks that rise above a signal-to-noise cutoff times that estimate. Its arguments are the cutoff followed by the polynomial order and the window width in points of the Savitzky-Golay filter.

let peaks =
    Signal.PeakDetection.SecondDerivative.getPeaks 5. 2 13 retentionTimes intensities

peaks
|> Array.iter (fun p ->
    printfn "apex %.3f min, intensity %.0f, from %.3f to %.3f min, %i points"
        p.Apex.XVal p.Apex.YVal p.LeftEnd.XVal p.RightEnd.XVal p.XData.Length)
apex 20.946 min, intensity 27510, from 20.712 to 21.495 min, 68 points
apex 21.753 min, intensity 9114, from 21.542 to 21.917 min, 33 points

Both synthetic features are found and nothing else is. The cutoff carries that result: with 0.5 instead of 5 the same call reports 15 peaks, most of them noise ripples of around 200 counts. Each detected peak is an IdentifiedPeak holding the apex, the left and right peak ends, the lift-off points where curvature indicates the peak rising out of the baseline, and the slice of the measured data between the ends. Note how the first peak's slice reaches from 20.71 to 21.50 min, well beyond a symmetric window around the apex, because the border search followed the tail. This record is the input type the quantification below consumes.

Detecting peaks with the chromatographic wavelet

This library also ships its own chromatographic peak detector, PeakDetection.Wavelet.identifyPeaks, which correlates the trace with Mexican Hat (Ricker) wavelets of increasing scale. The Mexican Hat is the negative normalized second derivative of a Gaussian, so it matches the shape a chromatographic peak is expected to have, and shape matching lets weak peaks stand out against noise of similar amplitude. We hand the detector the trace on a seconds axis, where the 0.7 s sampling and peak widths of a few seconds give the wavelet a convenient scale range. Two parameters deserve a note. Borderpadding must be generous relative to the wavelet width, and 200 works well on traces of this size. MaxPeakLength caps the widest wavelet scale at one sixth of its value, and 30 s sits comfortably above six times the 3.3 s sigma of the synthetic peak.

let traceInSeconds =
    Array.map2 (fun rt intensity -> rt * 60., intensity) retentionTimes intensities

let waveletParameters : PeakDetection.Wavelet.Parameters =
    { Borderpadding = 200
      BorderPadMethod = Signal.Padding.BorderPaddingMethod.Zero
      InternalPaddingMethod = Signal.Padding.InternalPaddingMethod.LinearInterpolation
      HugeGapPaddingMethod = Signal.Padding.HugeGapPaddingMethod.Zero
      HugeGapPaddingDistance = 100.
      MaxPeakLength = 30.
      NoiseQuantile = 0.5
      MinSNR = 1. }

let peakGroups =
    PeakDetection.Wavelet.identifyPeaks waveletParameters traceInSeconds

let coveredByGroup rtInMinutes =
    peakGroups
    |> List.exists (fun g -> g.Start <= rtInMinutes * 60. && rtInMinutes * 60. <= g.End)

printfn "peak groups found: %i" peakGroups.Length
printfn "a group covers the main apex: %b" (coveredByGroup peaks.[0].Apex.XVal)
printfn "a group covers the second apex: %b" (coveredByGroup peaks.[1].Apex.XVal)
peak groups found: 29
a group covers the main apex: true
a group covers the second apex: true

The detector finds both true apexes, and it detects generously: on this two-feature trace it reports 29 peak groups, treating shoulders and noise structures next to the real peaks as separate groups. A consumer therefore selects the group covering the retention time of interest and disregards the rest. A group's Data holds the padded trace region the wavelet worked on, and the fitted Stdev reflects the wavelet scale the group responded to within the configured range. For the rest of this page we stay with the second-derivative peaks, which carry exact slice boundaries and feed directly into the quantification step.

Fitting and integrating the main peak

The HULQ module turns an IdentifiedPeak into a quantity. HULQ.getPeakBy selects from the detected peaks the one whose boundaries contain a target retention time, falling back to the nearest apex when no peak contains it. The natural target is the retention time of the identification, 20.93 min for our MS2 scan.

HULQ.quantifyPeak then runs the model fitting workflow. It estimates starting parameters from the peak slice (by weighted moments, with a Caruana log-parabola fit stepping in when the slice is truncated on one side), fits a Gaussian and, when the moments allow, an EMG via Levenberg-Marquardt, scores both fits by their standard error of prediction, keeps the better one, and integrates the fitted model analytically for the area.

let describeModel (q: HULQ.QuantifiedPeak) =
    match q.Model with
    | Some (HULQ.Gaussian _) -> "Gaussian"
    | Some (HULQ.EMG _) -> "EMG"
    | None -> "none, trapezoid fallback"

let printQuantification (q: HULQ.QuantifiedPeak) =
    printfn "model selected              : %s" (describeModel q)
    match q.Model, q.EstimatedParams with
    | Some (HULQ.Gaussian _), [| amp; meanX; sigma |] ->
        printfn "amplitude                   : %.1f" amp
        printfn "mean retention time         : %.3f min" meanX
        printfn "sigma                       : %.4f min" sigma
    | Some (HULQ.EMG _), [| amp; meanX; sigma; tau |] ->
        printfn "amplitude                   : %.1f" amp
        printfn "mean retention time         : %.3f min" meanX
        printfn "sigma                       : %.4f min" sigma
        printfn "tau                         : %.4f min" tau
    | _ -> ()
    printfn "standard error of prediction: %.1f" q.StandardErrorOfPrediction
    printfn "area under the fitted model : %.1f" q.Area
    printfn "measured apex intensity     : %.1f" q.MeasuredApexIntensity

let mainPeak = HULQ.getPeakBy peaks 20.93

let mainQuantification = HULQ.quantifyPeak mainPeak

printQuantification mainQuantification
model selected              : EMG
amplitude                   : 35762.5
mean retention time         : 20.906 min
sigma                       : 0.0629 min
tau                         : 0.0759 min
standard error of prediction: 601.2
area under the fitted model : 5635.9
measured apex intensity     : 27509.6

The tailed peak is recognized as an EMG. The fitted mean retention time of 20.906 min sits on the generating center of 20.9, and sigma and tau land near the generating 0.055 and 0.09 min. The amplitude is the height parameter of the pre-convolution Gaussian, so it neither matches the measured apex of 27510 counts nor needs to. The MeasuredApexIntensity field preserves that raw apex alongside the model.

How good is the model area? A direct numerical integration of the same slice gives an assumption-free reference.

let mainTrapezoid =
    Integration.trapezEstAreaOf mainPeak.XData mainPeak.YData

printfn "model area    : %.1f" mainQuantification.Area
printfn "trapezoid area: %.1f" mainTrapezoid
model area    : 5635.9
trapezoid area: 5874.7

Both land close to the analytic area of 5790 intensity·min the trace was generated with. The trapezoid integral runs a little above it because it also sums the noise floor riding on the slice, the model area a little below. The model area is the value a pipeline stores: it is anchored to a fitted peak shape, which keeps it comparable when slice boundaries or noise differ between runs.

How the model is chosen

Whether an EMG is attempted at all is decided by the moment estimates. ParameterEstimation.estTau derives the EMG tail parameter from the moments as sigma times the cube root of half the skewness. For a peak with zero or negative skew that fractional power of a non-positive number is NaN, the EMG candidate becomes unavailable, and the Gaussian is used. That is the intended selection path: a peak that is not right-skewed is a Gaussian case, and no tail parameter should be invented for it. The moments of our two peaks show both sides of this rule.

let secondPeak = HULQ.getPeakBy peaks 21.75

let printMoments name peak =
    match ParameterEstimation.estimateMoments peak with
    | Some m ->
        printfn "%s: mean %.3f min, sigma %.4f min, skew %.3f, tau %.4f" name m.MeanX m.Std m.Skew m.Tau
    | None -> printfn "%s: no moment estimate" name

printMoments "main peak  " mainPeak
printMoments "second peak" secondPeak
main peak  : mean 20.991 min, sigma 0.1068 min, skew 1.109, tau 0.0878
second peak: mean 21.748 min, sigma 0.0555 min, skew -0.212, tau NaN

The tailed main peak carries a clear positive skew and a defined tau, so both models compete and the output above showed the EMG winning on standard error. The second peak was generated symmetric, its slight negative skew is noise, and its tau is NaN. For it the EMG leg never runs.

Quantifying the second feature

The workflow repeats per peptide feature: select the peak at the feature's retention time, quantify it.

let secondQuantification = HULQ.quantifyPeak secondPeak

printQuantification secondQuantification
model selected              : Gaussian
amplitude                   : 9032.7
mean retention time         : 21.749 min
sigma                       : 0.0516 min
standard error of prediction: 121.0
area under the fitted model : 1167.3
measured apex intensity     : 9113.5

As the moments predicted, the symmetric feature is fitted as a Gaussian, and for a Gaussian the amplitude is the apex height, so 9033 sits right at the generating 9000. Mean and sigma recover the generating 21.75 min and 0.05 min. Should neither model converge on some degenerate slice, quantifyPeak still returns a usable result, the trapezoid area of the slice with Model = None.

From one peak to a pipeline

The error measure behind the model selection is NonLinearRegression'.standardErrorOfPrediction from this library's FSharp.Stats extension, the root of the summed squared residuals normalized by the residual degrees of freedom, and the model with the lower value wins. Around this core a real pipeline adds the comparative layer: for isotopic labeling such as 15N, the light and the heavy XIC of the same peptide are extracted and their fitted areas are ratioed within one run, while label-free workflows compare the areas of matching features across runs.

Before peak areas are aggregated into protein quantities, the identifications they hang on are filtered to a controlled error rate, which is the subject of FDR control. And the m/z every XIC follows comes from the precursor's charge assignment on the charge state determination page.

namespace System
Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.Stats
namespace BioFSharp
namespace BioFSharp.Mz
module Quantification from BioFSharp.Mz
val emg: amplitude: float -> meanX: float -> sigma: float -> tau: float -> x: float -> float
val amplitude: float
val meanX: float
val sigma: float
val tau: float
val x: float
val sqrt: value: 'T -> 'U (requires member Sqrt)
type Math = static member Abs: value: decimal -> decimal + 7 overloads static member Acos: d: float -> float static member Acosh: d: float -> float static member Asin: d: float -> float static member Asinh: d: float -> float static member Atan: d: float -> float static member Atan2: y: float * x: float -> float static member Atanh: d: float -> float static member BigMul: a: int * b: int -> int64 + 5 overloads static member BitDecrement: x: float -> float ...
<summary>Provides constants and static methods for trigonometric, logarithmic, and other common mathematical functions.</summary>
field Math.PI: float = 3.14159265359
val exp: value: 'T -> 'T (requires member Exp)
namespace FSharp.Stats.SpecialFunctions
module Errorfunction from FSharp.Stats.SpecialFunctions
<summary> Error function (erf) and related functions. the error function (also called the Gauss error function), often denoted by erf, is a complex function of a complex variable defined as: erf (z) = 2/√π * \int e^(-t²) dt from 0 to z This integral is a special (non-elementary) sigmoid function that occurs often in probability, statistics, and partial differential equations. In many of these applications, the function argument is a real number. If the function argument is real, then the function value is also real. In statistics, for non-negative values of x, the error function has the following interpretation: for a random variable Y that is normally distributed with mean 0 and standard deviation 1/√2 , erf x is the probability that Y falls in the range [−x, x]. </summary>
val Erfc: x: float -> float
<summary>Computes the complement of the error function. Note that this implementation has only been verified to have a relative error of around 1e-4.</summary>
<remarks></remarks>
<param name="Erfc"></param>
<param name="x"></param>
<returns></returns>
<example><code></code></example>
val gaussian: amplitude: float -> meanX: float -> sigma: float -> x: float -> float
val rnd: Random
Multiple items
type Random = new: unit -> unit + 1 overload member GetHexString: stringLength: int * ?lowercase: bool -> string + 1 overload member GetItems<'T> : choices: ReadOnlySpan<'T> * length: int -> 'T array + 2 overloads member GetString: choices: ReadOnlySpan<char> * length: int -> string member Next: unit -> int + 2 overloads member NextBytes: buffer: byte array -> unit + 1 overload member NextDouble: unit -> float member NextInt64: unit -> int64 + 2 overloads member NextSingle: unit -> float32 member Shuffle<'T> : values: Span<'T> -> unit + 1 overload ...
<summary>Represents a pseudo-random number generator, which is an algorithm that produces a sequence of numbers that meet certain statistical requirements for randomness.</summary>

--------------------
Random() : Random
Random(Seed: int) : Random
val retentionTimes: float array
val intensities: float array
Multiple items
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
val map: mapping: ('T -> 'U) -> array: 'T array -> 'U array
val rt: float
Random.NextDouble() : float
val apexIndex: int
val findIndex: predicate: ('T -> bool) -> array: 'T array -> int
val max: array: 'T array -> 'T (requires comparison)
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
property Array.Length: int with get
<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>
val last: array: 'T array -> 'T
val peaks: Signal.PeakDetection.IdentifiedPeak array
namespace FSharp.Stats.Signal
module PeakDetection from FSharp.Stats.Signal
module SecondDerivative from FSharp.Stats.Signal.PeakDetection
val getPeaks: snr: float -> polOrder: int -> ws: int -> xData: float array -> yData: float array -> Signal.PeakDetection.IdentifiedPeak array
val iter: action: ('T -> unit) -> array: 'T array -> unit
val p: Signal.PeakDetection.IdentifiedPeak
Signal.PeakDetection.IdentifiedPeak.Apex: Signal.PeakDetection.PeakFeature
Signal.PeakDetection.PeakFeature.XVal: float
Signal.PeakDetection.PeakFeature.YVal: float
Signal.PeakDetection.IdentifiedPeak.LeftEnd: Signal.PeakDetection.PeakFeature
Signal.PeakDetection.IdentifiedPeak.RightEnd: Signal.PeakDetection.PeakFeature
Signal.PeakDetection.IdentifiedPeak.XData: float array
val traceInSeconds: (float * float) array
val map2: mapping: ('T1 -> 'T2 -> 'U) -> array1: 'T1 array -> array2: 'T2 array -> 'U array
val intensity: float
val waveletParameters: PeakDetection.Wavelet.Parameters
module PeakDetection from BioFSharp.Mz
module Wavelet from BioFSharp.Mz.PeakDetection
type Parameters = { Borderpadding: int BorderPadMethod: BorderPaddingMethod InternalPaddingMethod: InternalPaddingMethod HugeGapPaddingMethod: HugeGapPaddingMethod HugeGapPaddingDistance: float MaxPeakLength: float NoiseQuantile: float MinSNR: float } member Equals: Parameters * IEqualityComparer -> bool
module Padding from FSharp.Stats.Signal
<summary> padds data points to the beginning, the end and on internal intervals of the data </summary>
type BorderPaddingMethod = | Random | Zero
<summary> padds data point at signals start and end </summary>
union case Signal.Padding.BorderPaddingMethod.Zero: Signal.Padding.BorderPaddingMethod
<summary> inserts 0.0 as y_Value </summary>
type InternalPaddingMethod = | Random | NaN | Delete | Zero | LinearInterpolation
<summary> padds data point in small gaps (e.g. a missing data point or small ranges with no data) </summary>
union case Signal.Padding.InternalPaddingMethod.LinearInterpolation: Signal.Padding.InternalPaddingMethod
<summary> inserts points lying on the linear interpolation of the two adjacent knots </summary>
type HugeGapPaddingMethod = | Random | NaN | Delete | Zero | LinearInterpolation
<summary> padds data point in huge gaps (e.g. big ranges with no data) </summary>
union case Signal.Padding.HugeGapPaddingMethod.Zero: Signal.Padding.HugeGapPaddingMethod
<summary> inserts 0.0 as y_Value </summary>
val peakGroups: PeakDetection.Wavelet.PeakGroup list
val identifyPeaks: parameters: PeakDetection.Wavelet.Parameters -> trace: (float * float) array -> PeakDetection.Wavelet.PeakGroup list
val coveredByGroup: rtInMinutes: float -> bool
val rtInMinutes: float
Multiple items
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
val exists: predicate: ('T -> bool) -> list: 'T list -> bool
val g: PeakDetection.Wavelet.PeakGroup
PeakDetection.Wavelet.PeakGroup.Start: float
PeakDetection.Wavelet.PeakGroup.End: float
property List.Length: int with get
val describeModel: q: HULQ.QuantifiedPeak -> string
val q: HULQ.QuantifiedPeak
module HULQ from BioFSharp.Mz.Quantification
type QuantifiedPeak = { Model: PeakModel option YPredicted: float array EstimatedParams: float array StandardErrorOfPrediction: float Area: float MeasuredApexIntensity: float }
HULQ.QuantifiedPeak.Model: HULQ.PeakModel option
union case Option.Some: Value: 'T -> Option<'T>
union case HULQ.PeakModel.Gaussian: Fitting.NonLinearRegression.Model -> HULQ.PeakModel
union case HULQ.PeakModel.EMG: Fitting.NonLinearRegression.Model -> HULQ.PeakModel
union case Option.None: Option<'T>
val printQuantification: q: HULQ.QuantifiedPeak -> unit
HULQ.QuantifiedPeak.EstimatedParams: float array
val amp: float
HULQ.QuantifiedPeak.StandardErrorOfPrediction: float
HULQ.QuantifiedPeak.Area: float
HULQ.QuantifiedPeak.MeasuredApexIntensity: float
val mainPeak: Signal.PeakDetection.IdentifiedPeak
val getPeakBy: peaks: Signal.PeakDetection.IdentifiedPeak array -> x: float -> Signal.PeakDetection.IdentifiedPeak
<summary> Return Option </summary>
val mainQuantification: HULQ.QuantifiedPeak
val quantifyPeak: p: Signal.PeakDetection.IdentifiedPeak -> HULQ.QuantifiedPeak
val mainTrapezoid: float
Multiple items
module Integration from BioFSharp.Mz.Quantification

--------------------
namespace FSharp.Stats.Integration
val trapezEstAreaOf: xData: float array -> yData: float array -> float
<summary> Returns the estimated area beneath the data using the trapezoidal rule. </summary>
Signal.PeakDetection.IdentifiedPeak.YData: float array
val secondPeak: Signal.PeakDetection.IdentifiedPeak
val printMoments: name: string -> peak: Signal.PeakDetection.IdentifiedPeak -> unit
val name: string
val peak: Signal.PeakDetection.IdentifiedPeak
module ParameterEstimation from BioFSharp.Mz.Quantification
val estimateMoments: p: Signal.PeakDetection.IdentifiedPeak -> ParameterEstimation.EstimatedMoments option
val m: ParameterEstimation.EstimatedMoments
ParameterEstimation.EstimatedMoments.MeanX: float
ParameterEstimation.EstimatedMoments.Std: float
ParameterEstimation.EstimatedMoments.Skew: float
ParameterEstimation.EstimatedMoments.Tau: float
val secondQuantification: HULQ.QuantifiedPeak

Type something to start searching.