Fragment matching, scoring and reporter ions¶
spxtacular provides two functions for peptide-spectrum match (PSM) scoring:
match_fragments() for matching and score() for computing all metrics at once.
match_fragments()¶
from spxtacular import match_fragments
import peptacular as pt
fragments = pt.fragment("PEPTIDE", ion_types=("b", "y"), charges=(1, 2))
matches = match_fragments(
spectrum,
fragments,
tolerance=0.02,
tolerance_unit="da", # "da" or "ppm"
peak_selection="closest", # "closest", "largest", or "all"
)
# matches: list[MatchedFragment], sorted by peak index
Parameters:
| Parameter | Default | Description |
|---|---|---|
spectrum |
Spectrum to search |
|
fragments |
Iterable of fragment objects from peptacular |
|
tolerance |
0.02 |
Matching tolerance |
tolerance_unit |
"da" |
"da" or "ppm" |
peak_selection |
"closest" |
How to resolve multiple peaks within tolerance (see below) |
is_monoisotopic |
True |
Forwarded to the constructed Fragment objects when fragments is a dict[(IonType, charge_state), list[float]] (peptacular's fast_fragment output); otherwise has no effect |
peak_selection modes:
| Mode | Behaviour |
|---|---|
"closest" |
Keep the single peak with smallest m/z error (default) |
"largest" |
Keep the single peak with highest intensity |
"all" |
Keep every peak within tolerance |
Return value:
match_fragments() returns a list[MatchedFragment], sorted by ascending peak_index. Each MatchedFragment carries both the fragment and the matched peak's metadata:
| Field | Description |
|---|---|
fragment |
The matched Fragment object |
peak_index |
Index of the matched peak in spectrum.mz/spectrum.intensity |
peak_mz |
m/z of the matched peak |
peak_intensity |
Intensity of the matched peak |
intensity_pct |
peak_intensity / total_spectrum_intensity * 100 |
ppm_error |
Signed error: (peak_mz - theoretical_mz) / theoretical_mz * 1e6 |
da_error |
Signed error: peak_mz - theoretical_mz |
Example:
matches = match_fragments(spec, fragments, tolerance=10, tolerance_unit="ppm")
for m in matches:
print(f" Peak {m.peak_index} ({m.peak_mz:.4f} m/z) matched {m.fragment} (Δ={m.ppm_error:+.1f} ppm)")
score()¶
Runs match_fragments() internally and returns all scoring metrics as a dict.
from spxtacular import score
result = score(spectrum, fragments, tolerance=10, tolerance_unit="ppm")
Parameters:
| Parameter | Default | Description |
|---|---|---|
spectrum |
Spectrum to score against |
|
fragments |
Iterable of fragment objects from peptacular |
|
tolerance |
0.02 |
Matching tolerance |
tolerance_unit |
"da" |
"da" or "ppm" |
peak_selection |
"closest" |
How to resolve multiple peaks within tolerance — "closest", "largest", or "all" |
predicted_intensities |
None |
Optional predicted intensity for every fragment, in the same order. Enables the literature spectral-angle metric |
Returned metrics:
| Key | Description |
|---|---|
hyperscore |
Base-10 matched-intensity dot product plus log-factorial terms per ion series |
probability_score |
-log10 P(>= k matches by chance) |
total_matched_intensity |
Sum of matched peak intensities |
matched_fraction |
Fraction of theoretical ions matched |
intensity_fraction |
Fraction of total spectrum intensity explained by matches |
mean_ppm_error |
Mean absolute ppm error of matches |
spectral_angle |
Spectral angle vs predicted_intensities (0–1). Without a prediction, a coverage/evenness fallback — see below |
longest_run |
Longest consecutive ion sequence matched |
Neutral-loss and isotope variants of the same fragment share (ion_type, position) and are
collapsed to avoid inflating factorial terms in the hyperscore.
Hyperscore¶
The score uses log10(sum(I_matched)) + sum_s(log10(n_s!)), with unit theoretical
intensities. Each observed peak contributes once to the intensity sum. Each distinct
(ion_type, position) contributes once to its series count, so charge, neutral-loss,
and isotope variants do not inflate the factorial terms. Missing series contribute
0! = 1, and one-sided evidence can have a positive score. No matches or a nonpositive
matched-intensity sum returns zero.
This follows the dot-product/factorial structure described in OpenMS's HyperScore documentation. The base-10 logarithm, unit theoretical intensities, preprocessing, and duplicate handling are explicit spxtacular conventions. Scores are not guaranteed numerically interchangeable with X!Tandem, OpenMS, Comet, or MSFragger outputs.
Warning
Versions through 0.6.0 multiplied the intensity sums of different ion series.
That was a different heuristic, incorrectly described as identical to X!Tandem.
Recompute stored scores and retune thresholds when upgrading. Scaling all
intensities by s now shifts a positive-signal score by log10(s), once rather
than once per ion series. Normalized inputs can produce negative scores.
Spectral angle¶
Supply predicted_intensities — one value per fragment, in the same order — and you get the
spectral angle of the literature (Toprak et al.; the metric Prosit and Spectronaut report):
result = spx.score(
spec, fragments,
tolerance=10, tolerance_unit="ppm",
predicted_intensities=predicted, # aligned with `fragments`
)
It is a cosine, so it is scale-invariant: 1.0 means the observed pattern matches the prediction.
Warning
Without predicted_intensities there is nothing to compare against, and the value falls back
to a cosine against a flat reference — which measures intensity evenness × coverage, not
similarity to a predicted spectrum. A perfect match with realistic intensities [100, 50, 10, 1]
scores 0.509 that way. Do not compare the fallback to published spectral angles.
Example:
import peptacular as pt
from spxtacular import MzmlReader, score
with MzmlReader("run.mzML") as reader:
# reader.ms2 is an iterable lookup object, not an iterator — wrap it in iter()
spec = next(iter(reader.ms2))
fragments = pt.fragment("ACDEFGHIK", ion_types=("b", "y"), charges=(1, 2))
result = score(spec, fragments, tolerance=10, tolerance_unit="ppm")
print(f"Hyperscore: {result['hyperscore']:.3f}")
print(f"Spectral angle: {result['spectral_angle']:.3f}")
print(f"Matched ions: {result['matched_fraction']:.1%}")
Spectrum-to-spectrum similarity¶
score() answers how well does this peptide explain this spectrum. These answer how alike are
these two spectra, which is what spectral library search, replicate comparison and clustering are
built on.
from spxtacular import cosine, modified_cosine, entropy_similarity
cosine(query, reference, tolerance=20, tolerance_unit="ppm") # 0-1
entropy_similarity(query, reference, tolerance=0.02) # 0-1
| Function | What it is |
|---|---|
cosine |
The standard spectral dot product: sqrt-transformed intensities, unit-normalised, peaks matched one-to-one |
modified_cosine |
Cosine that also matches peaks displaced by the precursor mass difference — the GNPS molecular-networking metric |
entropy_similarity |
Unweighted entropy similarity with one-to-one greedy peak alignment |
All three are symmetric, scale-invariant, and bounded in [0, 1]: identical spectra score 1,
spectra with no shared peaks score 0.
Matching is one-to-one — a peak may back at most one match, resolved greedily by descending contribution. Allowing every pair within tolerance instead would let one intense peak match several neighbours and push the score past 1.
Modified cosine¶
Two spectra of the same molecule differing by one modification share many fragments, but every fragment containing the modified site is shifted by the modification's mass. A plain cosine reads those as mismatches:
# same peptide, one +79.966 phospho on the C-terminal half
cosine(a, b, tolerance=0.02) # 0.53 - looks unrelated
modified_cosine(a, b, 500.0, 579.966, tolerance=0.02) # 1.00 - recovered
It reduces exactly to cosine when the two precursors are equal.
entropy_similarity uses the unweighted entropy expression. It does not apply
entropy-dependent intensity weights or automatically remove precursor and noise peaks.
Negative intensities are clipped to zero and each spectrum is normalized to a probability
distribution. Preprocess both inputs consistently. The
MS Entropy reference
distinguishes weighted and unweighted variants and documents its own cleaning defaults.
The tests include independent values for unambiguous peak alignments. They do not establish
that one metric outperforms another for compound identification.
Isobaric reporter ions (TMT, TMTpro, iTRAQ)¶
spxtacular reads the reporter-ion intensities of isobaric-labelled spectra: TMT 0/2/6/10/11,
TMTpro 0/16/18 and iTRAQ 4/8. Channel names and reporter m/z come from tacular's
ISOBARIC_TAG_LOOKUP, which computes each reporter m/z from its isotopic composition, so no
reporter mass is typed into spxtacular.
import spxtacular as spx
spec = next(iter(spx.Reader("run.mzML").ms2))
ions = spec.reporter_ions("TMT10") # or spx.extract_reporter_ions(spec, "TMT10")
ions.channels # ('126', '127N', '127C', ..., '131N')
ions.intensity # one value per channel, 0.0 where no peak was found
ions.ppm_error # observed - theoretical m/z in ppm, NaN where no peak was found
ions["127N"] # one channel's intensity
table = spx.reporter_ion_table(spx.Reader("run.mzML"), "TMTpro18", include_errors=True)
# spectrum_index scan_number native_id ms_level rt 126 127N ... 135N 126_ppm_error ...
Plex names¶
Pass a name or alias from tacular, case-insensitive: TMT0, TMT2, TMT6, TMT10
(TMT10plex), TMT11, TMTpro0, TMT16 (TMTpro16), TMT18 (TMTpro18), iTRAQ4,
iTRAQ8, or a tacular.IsobaricTagInfo. ReporterIons.plex is tacular's name for it
("TMT18" for "TMTpro18"). An unknown name raises SpxtacularError listing the valid
ones.
How peaks are picked¶
| Choice | Behaviour |
|---|---|
| Tolerance | tolerance=20.0, tolerance_unit="ppm" by default ("da" also accepted). The closest channels, the TMT/TMTpro N/C pairs, are 6.32 mDa (about 47-50 ppm) apart, so +/-20 ppm windows never overlap. A tolerance that makes two windows overlap raises SpxtacularError. |
| Several peaks in a window | The most intense peak is used (ties: lowest m/z). |
| No peak in a window | Intensity 0.0; observed_mz and the error are NaN; found is False. Zero keeps sums, ratios and the impurity correction well defined, and NaN in the error columns keeps "not found" visible. |
| Tolerance limit | The overlap error names the largest tolerance that fits, e.g. "Use a tolerance below 24.28 ppm" for TMT10. |
| Input | Centroided spectra in m/z; the m/z array need not be sorted. A decharged spectrum (neutral masses) or NaN/inf intensities raise SpxtacularError. |
Many spectra¶
reporter_ion_table(spectra, plex, ...) takes any iterable of spectra (a list, a generator,
reader.ms2). Any object with an ms2 attribute (every spxtacular reader) is read through
.ms2, even if it is iterable itself, so a reader gives its MS2 spectra. It returns one row per spectrum:
spectrum_index (position in the input), scan_number, native_id, ms_level, rt
(None for a plain Spectrum), one float column per channel, and with
include_errors=True a <channel>_ppm_error column per channel. ms_level= keeps only
spectra of that level; for SPS-MS3 data pass an iterable of the MS3 spectra (e.g.
iter(reader)) with ms_level=3.
Isotope impurity correction¶
Each TMT/iTRAQ reagent lot ships with a sheet of isotope impurities: for every channel, the
percentage of its reporter signal that appears 2 or 1 Da below and 1 or 2 Da above. Pass the
sheet as impurities= and the intensities are corrected:
lot = {
"126": {"-2": 0.0, "-1": 0.0, "+1": 7.0, "+2": 0.2},
"127N": {"-2": 0.0, "-1": 0.4, "+1": 6.5, "+2": 0.0},
# ... one row per channel; channels left out are taken as pure
}
ions = spec.reporter_ions("TMT10", impurities=lot)
ions.raw_intensity # what was measured
ions.intensity # corrected
table = spx.reporter_ion_table(reader, "TMT10", impurities=lot, normalize="sum")
A pandas.DataFrame with channels as the index and shifts as columns works as well. Empty
cells (None, NaN) are 0. Column labels are:
- Numbers (
-2,-1,+1,+2): always that many 13C, never 15N. - Explicit substitutions:
"-13C","+2x13C","-15N","+13C+15N","-18O". - A main-peak column (
0,"0","main","reporter","monoisotopic", any case): ignored, because the main peak is always100 - sum(impurities).
TMT10/11 and TMTpro N channels carry a 15N, and a reagent missing it loses 0.997 Da, not
the 1.003 Da of a 13C. The two land 6.3 mDa apart, on different channels. If your sheet lists
15N and 13C impurities of N channels separately (newer Thermo TMTpro sheets do), label the
15N columns "-15N"/"+15N"; a numeric -1 there is read as 13C and placed on the wrong
m/z.
isotope_correction_matrix(plex, lot) returns the matrix M that is solved,
observed = M @ true. M[i, j] is the fraction of reagent j's signal seen in channel i:
- A reagent keeps
100 - sum(its impurities)percent in its own channel. - Each impurity goes to the channel nearest the shifted reporter m/z, if it is within
min(0.02 Da, half the plex's smallest channel spacing), as in OpenMS. That is 0.02 Da for TMT6 and iTRAQ, whose channels are about 1 Da apart, and about 3.2 mDa for TMT10/11 and TMTpro, whose N/C pairs are 6.32 mDa apart. So the -1 of TMT128Cgoes to127Cand of128Nto127N, since they differ by one 13C. - An impurity that lands on no channel of the plex is lost signal and only lowers the
diagonal. In TMT10 the -1 (13C) of
127Nlands 6.3 mDa below126and the +1 of130Clands on the131Cposition, 6.3 mDa above131N: neither is inside a picking window, so neither is counted. In TMT11 the +1 of130Cgoes to131C.
You can also pass a precomputed square matrix as impurities=, or call
correct_isotope_impurities(intensities, matrix_or_lot, plex=...) (plex is keyword-only) on your own arrays (one
spectrum or a 2-D array of many).
Non-negativity. Each spectrum is first solved exactly. With noise, the exact solution can go slightly negative in a weak channel next to a strong one. Those spectra are re-solved by non-negative least squares (a small Lawson-Hanson NNLS in numpy, since scipy is not a dependency). That gives the exact answer whenever it is already non-negative. Otherwise it gives the closest non-negative fit, which, unlike clipping negatives to zero, passes the remainder on to the neighbouring channels.
Normalization¶
normalize="sum" divides each spectrum's channels by their sum and normalize="max" by the
largest channel, after correction. A spectrum with no reporter signal stays all zero.