Skip to content

Fragment matching and scoring

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_type="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_type "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_type="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_type="ppm")

Parameters:

Parameter Default Description
spectrum Spectrum to score against
fragments Iterable of fragment objects from peptacular
tolerance 0.02 Matching tolerance
tolerance_type "da" "da" or "ppm"
peak_selection "closest" How to resolve multiple peaks within tolerance — "closest", "largest", or "all"

Returned metrics:

Key Description
hyperscore X!Tandem hyperscore: log10(∏ₛ ΣIₛ) + Σₛ log10(nₛ!) over the searched 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

For a b/y search this is numerically identical to the X!Tandem hyperscore, so values are comparable with X!Tandem, Comet and MSFragger. The product runs over whichever series you searched rather than a hardcoded b/y, so an ETD c/z search is scored the same way.

The product is what makes it discriminating: a searched series with no signal collapses the whole score to zero, so a PSM supported only by b ions cannot look as convincing as one corroborated from both directions.

Warning

The intensity term uses raw intensities, so the score shifts by log10(s) per series if the spectrum is scaled by s, and can go negative on a normalised spectrum. Only compare hyperscores computed on identically scaled spectra.

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_type="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_type="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_type="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 Entropy similarity (Li et al. 2021), which discriminates more sharply and has largely displaced cosine for library search

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.