knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.path = "man/figures/README-", out.width = "100%", dev = "ragg_png", dpi = 600, fig.width = 12, fig.height = 8, message = FALSE, warning = FALSE )
The goal of the ScanCentricPeakCharacterization (SCPC) package is to facilitate scan-centric, frequency based, peak characterization of profile level, multi-scan, direct-injection Fourier-transform mass spectrometry data.
You can read more about the merits of this scan-centric method in:
RM Flight, JM Mitchell & HNB Moseley, "Scan-Centric, Frequency-Based Method for Characterizing Peaks from Direct Injection Fourier transform Mass Spectrometry Experiments", Metabolites 2022, 12(6), 515; https://doi.org/10.3390/metabo12060515
This package is licensed with a BSD-like license with a 4th clause: No commercial use.
Academics who want to use it at their institution, please try it.
If you are at a business / for-profit and want to use it, please contact the authors (Robert Flight, rflight79 at gmail; Hunter Moseley, hunter dot moseley at gmail) about licensing. Please contact us even if you aren't sure what would be required for licensing, we do want people to use it.
You can install SCPC from GitHub with:
# install.packages("remotes") remotes::install_github("MoseleyBioinformaticsLab/ScanCentricPeakCharacterization")
You can browse the documentation online here.
library(ScanCentricPeakCharacterization) library(dplyr) library(patchwork) library(ggplot2) #theme_set(cowplot::theme_cowplot())
Outside of the scan-centric nature of this peak-characterization, the second most important feature is the conversion from m/z to frequency. This is done to make evenly spaced data. If you acquire Orbitrap / ICR type mass spectrometry data over any decent range, there is an increasing spacing between individual m/z points.
We will load up an example direct-injection lipidomics sample acquired on a Thermo-Fisher Fusion instrument to demonstrate.
mzml_lipid = SCMzml$new(system.file("extdata/lipid_example.mzML", package = "ScanCentricPeakCharacterization")) mzml_lipid$extract_mzml_data() mzml_lipid$predict_frequency()
mzml_lipid$mzml_df_data[[1]] %>% dplyr::filter(convertable) %>% ggplot(aes(x = mz, y = mean_offset)) + geom_point()
We can see here that the difference or offset of m/z points is increasing with m/z.
In contrast, frequency is defined as the difference over m/z, and therefore is constant.
$$mz_{mean} = mean(mz_{p1}, mz_{p2})$$
$$mz_{diff} = mz_{p2} - mz_{p1}$$
$$frequency = \frac{mz_{mean}}{mz_{diff}}$$
mzml_lipid$mzml_df_data[[1]] %>% dplyr::filter(convertable) %>% ggplot(aes(x = mean_frequency, y = mean_freq_diff)) + geom_point()
However, we can more generally define the conversion of m/z to frequency using a linear model of the form:
$$frequency = a + \frac{x}{mz} + \frac{y}{\sqrt{mz}} + \frac{z}{\sqrt[3]{mz}}$$
And we can verify that with a plot of the m/z vs frequency and their predicted values, in a couple of ways, as well as a plot of the residuals.
mzml_lipid$check_frequency_model()
See the example of SCMzml
below to see how we can change the model being used.
A note about the assumptions baked into SCPC:
MSnbase
and mzR
.We assume mzML
is the most likely mass-spectrometer format, and SCMzml
is an R6 container around the mzML
file (although it should work with any of the formats supported by MSnbase
).
It has basic functionality for loading the mzML
data using MSnbase
, filtering out scans that are not required, and converting the M/Z to frequency space for subsequent peak detection.
It is good to check a few samples using SCMzml
before peak characterization.
This is especially important if you need to define custom scan filtering and choosing a single frequency model before attempting full peak characterization.
Let's go through some of it using the example file included with SCPC. This sample is a lipidomics sample acquired on a Thermo-Fisher tribrid Orbitrap Fusion over 15 minutes, with a combination of MS1 and MS2 scans, acquired in profile mode. It has been filtered to a subset of M/Z and total scans to save space. The first 7.5 minutes are the MS1 primary scans, and the remaining 7.5 minutes are combination of MS1 and MS2 scans of the highest intensity M/Z in the MS1 primary scans.
#| label: load-data lipid_sample = system.file("extdata", "lipid_example.mzML", package = "ScanCentricPeakCharacterization") sc_mzml = SCMzml$new(lipid_sample)
After instantiating the object, we extract the data into a list of data.frames that make it easier to work with. This may change over time to use more efficient data structures.
#| label: extract-mzml sc_mzml$extract_mzml_data()
We can see what is reported at the scan level:
#| label: show-scans head(sc_mzml$scan_info) |> knitr::kable()
And in the data.frame created from the mzML data (here is a single scan):
#| label: show-data head(sc_mzml$mzml_df_data[[1]]) |> knitr::kable()
As discussed above (see Theory), SCPC is based on the idea that we should work in a pseudo-frequency space, which is achieved by calculating an initial frequency based on the M/Z spacing, and then fit that to a model based on the geometry of the Orbitrap or ICR.
For Thermo Orbitrap (Fusion and Fusion Lumos), that our lab primarily uses, this model looks like:
$$frequency = a + \frac{x}{mz} + \frac{y}{\sqrt{mz}} + \frac{z}{\sqrt[3]{mz}}$$
In contrast, for Bruker SolariX, the model has one fewer term:
$$frequency = a + \frac{x}{mz} + \frac{y}{\sqrt{mz}}$$
Note how these models are encoded, the (-) indicates a fraction, and the actual fraction indicates a root (either square or cube in these cases):
#| label: example-models # thermo fusion model: thermo = c("a.freq" = 0, "x.freq" = -1, "y.freq" = -1/2, "z.freq" = -1/3) # bruker bruker = c("a.freq" = 0, "x.freq" = -1, "y.freq" = -1/2)
#| label: predict-frequency sc_mzml$predict_frequency()
Here is the scan information after prediction, it now has model terms related to the above equations:
sc_mzml$scan_info |> head() |> knitr::kable()
And the mz data.frame now has frequency related data as well:
sc_mzml$mzml_df_data[[1]] |> head() |> knitr::kable()
We can see a bunch more information added to the scan level summary data and the M/Z data.frame after we predict frequency in each scan.
We can check the frequency fit using check_frequency
.
#| label: check-frequency sc_mzml$check_frequency_model()
What we've plotted are for a single scan:
For all scans, we have the median and maximum absolute deviation (MAD) of the calculated - predicted residuals.
We can change the frequency model and recheck it easily. Let's use a model where we change the square root to a cube root.
#| label: change-model correct_model = c("a.freq" = 0, "x.freq" = -1, "y.freq" = -1/2, "z.freq" = -1/3) off_model = c("a.freq" = 0, "x.freq" = -1, "y.freq" = -1/3) sc_mzml$frequency_fit_description = off_model sc_mzml$predict_frequency() sc_mzml$check_frequency_model()
From this diagram, we are pretty sure that the model is incorrect for our instrument.
Before proceeding, let's revert it.
#| label: revert-model sc_mzml$frequency_fit_description = correct_model sc_mzml$predict_frequency()
Next we set up our filter scans function. Let's plot the scans first.
#| label: scan-info-plot sc_mzml$scan_info |> ggplot(aes(x = rtime, xend = rtime, y = 0, yend = tic, color = y.freq)) + geom_segment()
We've plotted the scans by their retention time (rtime), and their height is the total intensity chromatogram (tic), and colored by the value of the y frequency term (y.freq).
For this data, we only want those scans below an rtime of 450, and with a y.freq >= 2.9e7.
After using these criteria, the default function also uses boxplot.stats
on the y.freq term to check for possible outlier scans.
#| label: create-filter-scans sc_mzml$generate_filter_scan_function(rtime = c(NA, 450), y.freq = c(2.9e7, NA)) sc_mzml$filter_scans()
Now we can see which scans are being excluded with this filter.
#| label: filter-scans-table sc_mzml$scan_info |> head() |> knitr::kable()
#| label: filter-scans-plot sc_mzml$scan_info |> ggplot(aes(x = rtime, xend = rtime, y = 0, yend = tic, color = keep)) + geom_segment()
Changing the filters here using variations of rtime
and y.freq
should be simple.
If you have more involved needs, you can write your own filtering function.
filter_scans_builtin
is another example of a function you can use as a template.
If you want to use a custom function (named my_custom_filter), you can add it like this:
#| label: filter-scans-custom #| eval: false sc_mzml$generate_filter_scan_function(f_function = my_custom_filter)
After filtering scans, we need to pick a single frequency model. Our publication showed using each scan's own model is a bad idea, and we don't advise using some conglomeration of models either. Instead, the default is to take the model with the y-term closest to the median of all the y-terms.
#| label: choose-model sc_mzml$generate_choose_frequency_model_function() sc_mzml$choose_frequency_model() sc_mzml$frequency_coefficients
Finally, we can convert our M/Z data to frequency for use in peak characterization.
#| label: convert-frequency sc_mzml$convert_to_frequency()
SCCharacterizePeaks
controls the overall interplay between:
SCZip
container that will hold the original and final data;SCMzml
object that loads mzml data, transforms it to frequency space, and filters out scans that don't seem to belong;SCPeakRegion
and SCPeakRegionFinder
that actually do all of the peak characterization.Let's give an example using an example lipid file. This chunk is not evaluated due to peak characterization taking so long.
lipid_sample = system.file("extdata", "lipid_example.mzML", package = "ScanCentricPeakCharacterization") sc_char = SCCharacterizePeaks$new(lipid_sample, out_file = here::here("lipid_sample.zip")) sc_char$load_file()
#| label: run-all #| eval: false sc_char$generate_filter_scan_function(rtime = c(NA, 450), y.freq = c(2.9e7, NA)) sc_char$generate_choose_frequency_model_function() sc_char$prepare_mzml_data() sc_char$find_peaks() sc_char$summarize() sc_char$save_peaks() sc_char$write_zip()
If you have a large number of samples to run, and you know you will be using the same functions for filtering scans and choosing the frequency model, you can use the run_all
instead.
It takes two arguments, the filter_scan_function
, and the choose_frequency_model_function
.
# not run sc_char = SCCharacterizePeaks$new("file.mzML", out_file = "file.zip") sc_char$run_all(filter_scan_function = custom_filter, choose_frequency_model_function = custom_frequency_model)
You should see the vignette on custom functions if you want to go this route.
SCPeakRegionFinder
is similar to SCCharacterizePeaks
in that it is more of a controlling workflow object.
It serves to coordinate all the steps that need to happen for peak characterization outside of the conversion to frequency, which is the purview of the SCMzml
object.
The SCPeakRegionFinder
acts on the SCPeakRegions
object, which has all of the data and methods.
SCPeakRegions
holds the frequency data and the methods.
It is controlled by SCPeakRegionFinder
.
We wanted a fairly generic way to store the original mzML file, any metadata generated about it, the binary output of SCPeakRegionFinder
, and a JSONized peak list that can be used for assignment.
What we decided on was a simple zip file that keeps those objects together.
When we create a new SCZip
, we actually create a temp directory, and move all the data there, and unzip it so that it is easily accessible and pieces can be modified easily.
For example, we can see where the temp data lives for our previously created sc_char
object.
#| label: show-temp sc_char$temp_loc dir(sc_char$temp_loc)
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.