Spectra Merge Algorithm#

OpenMS provides spectra merging and averaging algorithms in SpectraMerger class. Spectra merging is to merge multiple related spectra into a single one - thus, often we end up with a reduced number of spectra. For instance, MS1 spectra within a pre-defined retention time window or MS2 spectra from the same precursor ion. On the other hand, spectra averaging averages neighbouring spectra for each spectrum. Thus, the number of spectra remains the same after spectra averaging. Both merging and averaging attempt to increase the quality of spectrum by increasing its signal to noise ratio.

Spectra merging and averaging are implemented in SpectraMerger in pyOpenMS, which provides two merging (block wise and precursor method - see below) and two averaging methods (gaussian and tophat - see below). For merging, we can use

  • mergeSpectraBlockWise

  • mergeSpectraPrecursors

and for averaging, we use

  • average

with two different options: gaussian and tophat methods. SpectraMerger inherits DefaultParamHandler. Thus you could list the full set of parameters as described in Parameter handling.

Loading the Raw Data#

Let’s take a look at the different algorithms in the examples below. First we download a test MS/MS dataset.

 1# retrieve data
 2from urllib.request import urlretrieve
 3import pyopenms as oms
 4import matplotlib.pyplot as plt
 5
 6gh = "https://raw.githubusercontent.com/OpenMS/pyopenms-docs/master"
 7urlretrieve(gh + "/src/data/small.mzML", "test.mzML")
 8
 9# load MS data and store as MSExperiment object
10exp = oms.MSExperiment()
11oms.MzMLFile().load("test.mzML", exp)

Block wise spectra merging#

Our first example merges MS1 spectra block wise.

 1spectra = exp.getSpectra()
 2
 3# Collecting only MS1 spectra
 4spectra_ms1 = [s for s in spectra if s.getMSLevel() == 1]
 5print(f"Number of MS1 spectra before merge are {len(spectra_ms1)}")
 6
 7# merges blocks of MS1
 8merger = oms.SpectraMerger()
 9merger.mergeSpectraBlockWise(exp)
10
11# Get spectra from the updated (merged) experiment
12spectraMerged = exp.getSpectra()
13spectraMerged_ms1 = [s for s in spectraMerged if s.getMSLevel() == 1]
14print(f"Number of MS1 spectra after merge are {len(spectraMerged_ms1)}")
15# store merged spectra in the disk
16oms.MzMLFile().store("blockwiseMerged.mzML", exp)
17
18# Setting up subplots for five original spectra and merged spectrum
19fig, axs = plt.subplots(6)
20fig.set_figheight(8)
21plt.subplots_adjust(hspace=1)
22
23for i in range(0, 6):
24    s = spectra_ms1[i] if i < 5 else spectraMerged_ms1[0]
25    axs[i].plot(s.get_peaks()[0], s.get_peaks()[1], linewidth=0.2)
26    axs[i].set_yscale("log")
27    axs[i].set_ylim(1e3, 1e7)
28    axs[i].set_xlim(360, 1000)
29    axs[i].title.set_text(
30        "Input MS1 spectrum " + str(i + 1) if i < 5 else "Merged MS1 spectrum"
31    )
32plt.show()
Number of MS1 spectra before merge are 183
Number of MS1 spectra after merge are 37
Cluster sizes:
  size 3: 1x
  size 5: 36x
Number of merged peaks: 87177/360394 (24.19 %) of blocked spectra
Blockwise merging (of 5 MS1 scans)

Above example clearly demonstrates the benefit of spectra merging. The upper rows show the input spectra and the bottom the merged one. The merged spectrum (bottom) has far more signal peaks of higher intensities than the input spectra.

By default, the method mergeSpectraBlockWise of SpectraMerger merges 5 consecutive MS1 spectra into a block. The block size could be adjusted by using block_method:rt_block_size parameter as follow:

 1# again load MS data and store as MSExperiment object
 2exp = oms.MSExperiment()
 3oms.MzMLFile().load("test.mzML", exp)
 4
 5# again load MS data and store as MSExperiment object
 6exp = oms.MSExperiment()
 7oms.MzMLFile().load("test.mzML", exp)
 8
 9# adjust block size to 10 spectra and merge
10merger = oms.SpectraMerger()
11param = merger.getParameters()
12param.setValue("block_method:rt_block_size", 10)
13merger.setParameters(param)
14merger.mergeSpectraBlockWise(exp)
15
16spectraMerged = exp.getSpectra()
17spectraMerged_ms1_10scans = [s for s in spectraMerged if s.getMSLevel() == 1]
18
19# store merged spectra in the disk
20oms.MzMLFile().store("blockwiseMerged_10scans.mzML", exp)
21
22fig, axs = plt.subplots(2)
23fig.set_figheight(4)
24plt.subplots_adjust(hspace=1)
25
26for i in range(0, 2):
27    s = spectraMerged_ms1_10scans[0] if i == 0 else spectraMerged_ms1[0]
28    axs[i].plot(s.get_peaks()[0], s.get_peaks()[1], linewidth=0.2)
29    axs[i].set_yscale("log")
30    axs[i].set_ylim(1e3, 1e7)
31    axs[i].set_xlim(360, 1000)
32    axs[i].title.set_text(
33        "Merged MS1 spectrum with 10 scans"
34        if i == 0
35        else "Merged MS1 spectrum with 5 scans"
36    )
37plt.show()
Number of MS1 spectra after merge are 19
Cluster sizes:
  size 3: 1x
  size 10: 18x
Number of merged peaks: 117793/360394 (32.68 %) of blocked spectra
72 spectra and 1 chromatograms stored.
Blockwise merging 10 scans vs. 5 scans

As shown in the above figure, clearer signal peaks are obtained with 10 MS1 scans being merged than 5 MS1 scans. Note that the y-axis is in log scale. But if too many scans are merged, spectra containing too different sets of molecules would be merged, yielding a poor quality spectrum. The users may want to try a few different parameters to produce spectra of optimal quality.

MS2 spectra merging with precursor method#

Next we perform MS2 spectra merging with precursor method by using the mergeSpectraPrecursors method. With this method, the MS2 spectra from the same precursor m/z (subject to tolerance) are merged.

 1# load MS data and store as MSExperiment object
 2exp = oms.MSExperiment()
 3oms.MzMLFile().load("test.mzML", exp)
 4
 5spectra = exp.getSpectra()
 6
 7# spectra with ms_level = 2
 8spectra_ms2 = [s for s in spectra if s.getMSLevel() == 2]
 9print(f"Number of MS2 spectra before merge are {len(spectra_ms2)}")
10
11# merge spectra with similar precursors
12merger = oms.SpectraMerger()
13merger.mergeSpectraPrecursors(exp)
14
15spectraMerged = exp.getSpectra()
16spectraMerged_ms2 = [s for s in spectraMerged if s.getMSLevel() == 2]
17print(f"Number of MS2 spectra after merge are {len(spectraMerged_ms2)}")
Number of MS2 spectra before merge are 53
Number of MS2 spectra after merge are 53
Cluster sizes:
Number of merged peaks: 0/0 (nan %) of blocked spectra

In the above example, no MS2 spectra have been merged because no MS2 spectra had the same precursor m/z values (subject to tolerance) within retention time window. By default, the retention time window size is 5.0 seconds and the precursor m/z tolerance is 1e-4Th. If you opens the test.mzML file, you can see a few MS2 spectra (e.g., scan numbers 2077 and 2099) have quite close precursor m/z values (both have precursor m/z of 432.902Th), but they are apart from each other by about 10 seconds. We adjust both m/z tolerance and retention time so such MS2 spectra are merged together with precursor_method:mz_tolerance and precursor_method:rt_tolerance parameters.

 1# adjust mz and rt tolerances for MS2 spectra grouping for merging
 2param = merger.getParameters()
 3param.setValue("precursor_method:rt_tolerance", 10.0)
 4param.setValue("precursor_method:mz_tolerance", 1e-3)
 5merger.setParameters(param)
 6merger.mergeSpectraPrecursors(exp)
 7
 8# now rerun precursor method merging of MS2 spectra
 9spectraMerged = exp.getSpectra()
10spectraMerged_ms2 = [s for s in spectraMerged if s.getMSLevel() == 2]
11print(f"Number of MS2 spectra after merge are {len(spectraMerged_ms2)}")
12
13# store modified data
14oms.MzMLFile().store("precursorMethodMerged.mzML", exp)
Number of MS2 spectra after merge are 45
Cluster sizes:
size 2: 8x
Number of merged peaks: 488/2262 (21.57 %) of blocked spectra

To check which MS2 spectra are merged together, one can print out the native IDs of the spectra. The native ID of each merged spectrum contains all native IDs of the spectra being merged (comma separated) - this also holds for block wise merging method.

 1# check which input MS2 spectra were merged
 2merged_spectra = dict()
 3for index, s in enumerate(spectraMerged_ms2):
 4    native_IDs = s.getNativeID().split(",")
 5    if len(native_IDs) > 1:  # spectrum is merged
 6        print(native_IDs)
 7        merged_specs = []
 8        for native_ID in native_IDs:
 9            for s2 in spectra_ms2:  # original spectra
10                if native_ID == s2.getNativeID():
11                    merged_specs.append(s2)
12                    break
13        merged_spectra[index] = merged_specs
['controllerType=0 controllerNumber=1 scan=1986', 'controllerType=0 controllerNumber=1 scan=2010']
['controllerType=0 controllerNumber=1 scan=1991', 'controllerType=0 controllerNumber=1 scan=2015']
['controllerType=0 controllerNumber=1 scan=1992', 'controllerType=0 controllerNumber=1 scan=2014']
['controllerType=0 controllerNumber=1 scan=2026', 'controllerType=0 controllerNumber=1 scan=2050']
['controllerType=0 controllerNumber=1 scan=2037', 'controllerType=0 controllerNumber=1 scan=2059']
['controllerType=0 controllerNumber=1 scan=2062', 'controllerType=0 controllerNumber=1 scan=2088']
['controllerType=0 controllerNumber=1 scan=2077', 'controllerType=0 controllerNumber=1 scan=2099']
['controllerType=0 controllerNumber=1 scan=2084', 'controllerType=0 controllerNumber=1 scan=2107']

We can confirm that scans 2077 and 2099 have been merged. In addition, we had a few more pairs of MS2 spectra that were merged. We also plot the input and merged spectra below.

 1# plot the merged and merging MS2 spectra
 2
 3fig, axs = plt.subplots(3, min(4, len(merged_spectra)))
 4fig.set_figheight(7)
 5fig.set_figwidth(14)
 6plt.subplots_adjust(hspace=1)
 7
 8for index, item in enumerate(merged_spectra.items()):
 9    if index == 4:  # show 4 examples
10        break
11    specs = item[1]
12    for i in range(0, 3):
13        s = specs[i] if i < 2 else spectraMerged_ms2[item[0]]
14        axs[i, index].bar(s.get_peaks()[0], s.get_peaks()[1], width=1)
15        axs[i, index].set_yscale("log")
16        axs[i, index].set_ylim(1e3, 1e5)
17        axs[i, index].set_xlim(0, 1200)
18        axs[i, index].title.set_text(
19            "Input MS2 spectrum" if i < 2 else "Merged MS2 spectrum"
20        )
21plt.show()
Precursor method merging

Four examples of MS2 spectra before and after merging are provided above. Each column shows an example. The upper rows show the input spectra and the bottom the merged one. The input MS2 spectra selected by the precursor method show quite similar peak distributions, indicating they are indeed from the same molecule ions. Moreover, as in the above block wise merging, we can check that a merged MS2 spectrum has more peaks than input spectra, possibly containing more complete fragmentation ion masses.

Spectra averaging : gaussian and top hat methods#

SpectraMerger presents a method average to average peak intensities over neighbouring spectra for a given spectrum. As mentioned above, apart from spectra merging, the number of spectra after averaging does not change since it is carried out for each individual input spectrum. The two averaging methods (gaussian or tophat) determine how neighbouring spectra are collected and how weights for the averaging are determined. The gaussian method performs weighted average over the neighbouring spectra with weights having the shape of gaussian shape (i.e., sharply decreasing from the center). On the other hand, the tophat method, as the name implies, performs a simple averaging over the neighbouring spectra. Below we perform gaussian averaging method.

 1# load MS data and store as MSExperiment object
 2exp = oms.MSExperiment()
 3oms.MzMLFile().load("test.mzML", exp)
 4spectra = exp.getSpectra()
 5
 6# number of MS1 spectra before averaging
 7spectra_ms1 = [s for s in spectra if s.getMSLevel() == 1]
 8print(f"Number of MS1 spectra before averaging are {len(spectra_ms1)}")
 9
10# average spectra with gaussian
11merger = oms.SpectraMerger()
12merger.average(exp, "gaussian")
13spectraAveraged = exp.getSpectra()
14
15# number of MS1 spectra after averaging
16spectraAveraged_ms1 = [s for s in spectraAveraged if s.getMSLevel() == 1]
17print(f"Number of MS1 spectra after averaging are {len(spectraAveraged_ms1)}")
18
19fig, axs = plt.subplots(2)
20fig.set_figheight(4)
21plt.subplots_adjust(hspace=1)
22
23for i in range(0, 2):
24    s = spectra_ms1[0] if i == 0 else spectraAveraged_ms1[0]
25    axs[i].plot(s.get_peaks()[0], s.get_peaks()[1], linewidth=.2)
26    axs[i].set_yscale("log")
27    axs[i].set_ylim(5e2, 1e6)
28    axs[i].set_xlim(360, 600)
29    axs[i].title.set_text("Before averaging" if i == 0 else "After averaging")
30plt.show()
31
32# store modified data
33oms.MzMLFile().store("averagedData.mzML", exp)
Number of MS1 spectra before averaging are 183
Number of MS1 spectra after averaging are 183
Averging

After averaging has been applied, the the number of spectra does not change as we mentioned above. But the above plots show that the base line intensity has decreased significantly after averaging. The signal peaks are better separated in the averaged spectrum than in the original spectrum as well.