Deconvolution
Deconvolution takes a raw centroid spectrum and groups peaks into isotope clusters, assigning each cluster a monoisotopic m/z and a charge state. The output is still a spectrum — same mz and intensity arrays — but now with a charge array and spectrum_type = DECONVOLUTED.
Converting those charged peaks to neutral masses is a separate step: see decharge.
Basic usage
from spxtacular import Spectrum
import numpy as np
# A 2+ cluster of three peaks, then a 1+ cluster of two.
mz = np.array([500.2573, 500.7590, 501.2606, 800.2000, 801.2033], dtype=np.float64)
intensity = np.array([1e5, 5.2e4, 1.1e4, 2e5, 6.5e4], dtype=np.float64)
spec = Spectrum(mz=mz, intensity=intensity)
decon = spec.deconvolute(
charge_range=(1, 5),
tolerance=15,
tolerance_type="ppm",
)
print(decon.mz) # monoisotopic m/z, one entry per cluster (or singleton)
print(decon.charge) # charge state per peak; -1 for singletons
print(decon.intensity) # summed over the cluster
print(decon.iso_score) # isotopic profile fit, 0–1
The output has one peak per identified cluster, not one peak per input peak: five input peaks became two entries. A cluster of three isotope peaks at z=2 collapses to a single output entry at the monoisotopic m/z with charge=2, and its intensity is the sum over the cluster.
How the algorithm works
The implementation lives in src/spxtacular/decon/ — pure NumPy, no graph construction.
1. Seed selection
Pick the most intense unused peak as the seed for the next cluster. High-intensity clusters are therefore assigned first.
2. Apex alignment
The seed is treated as the observed envelope apex. For every charge in charge_range, the selected
isotope model predicts which isotope is most abundant. The algorithm tests the exact theoretical
apex and the contiguous near-apex positions whose predicted abundance is at least 90% of the
maximum. It matches and scores the complete envelope for each alignment. This prevents small
intensity fluctuations across a broad high-mass apex from causing a one-isotope error in the
inferred monoisotopic m/z. The mass and theoretical apex are recalculated until stable.
3. Cluster building
Starting at the apex, the algorithm walks left and right independently in steps of
NEUTRON_MASS / z, where NEUTRON_MASS is the C13 neutron mass (~1.00335 Da). At each position,
all unused peaks inside the m/z window are considered. Candidates outside the abundance fold gate
or ion-mobility window are rejected. The remaining candidates are ranked by the sum of squared,
normalised m/z error, log-abundance error, and ion-mobility error. Ion mobility participates only
when the spectrum contains it. Expansion in that direction stops when the theoretical abundance is
too low, no peak is found beyond the allowed gap count, or every candidate fails a hard gate. A
blocking peak is left unused for a later greedy pass.
Envelope length is adaptive by default. max_isotopes can impose a hard limit when runtime or a
known acquisition range warrants one.
4. Scoring
Each candidate cluster is scored against a theoretical isotope distribution using the
Bhattacharyya coefficient. This comparison is two-sided: both missing predicted intensity and
unexpected intensity among the aligned candidate peaks lower the coefficient. Aligned observed
entries participate even where the corresponding model entry falls below the detectability
cutoffs. Missing peaks that should have been detectable above min_intensity receive an additional
penalty; absent theoretical peaks below that floor do not. The score is in the range 0–1, where 1 is
a perfect match to the theoretical envelope. A cluster of a single peak scores 0.0 — one peak is
no evidence of a charge state.
The theoretical envelope is calculated with a BRAIN-style recurrence and cached at one-Dalton resolution. Built-in average-composition models are available for peptides, glycans, lipids, DNA, and RNA. The peptide model is the default.
5. Charge assignment
Every charge candidate is evaluated without modifying the input state. The candidate with the highest score wins, with ties broken by matched peak count.
6. Rejection
If the winning score is below min_score, the seed is marked as a singleton (charge=-1, iso_score=0.0). The other peaks that were tested as cluster members remain available as seeds for future iterations.
7. Repeat
Only peaks accepted into the winning cluster are marked as used. Missing, abundance-rejected, and
fold-rejected peaks remain available. The cycle restarts from the next most-intense unused peak
until every input peak has been consumed or max_dpeaks is reached.
Parameters
def deconvolute(
self,
tolerance: float = 50,
tolerance_type: Literal["ppm", "da"] = "ppm",
charge_range: tuple[int, int] = (1, 3),
intensity: Literal["base", "total"] = "total",
max_dpeaks: int = 2000,
inplace: bool = False,
min_intensity: float | Literal["min"] = "min",
min_score: float = 0.0,
isotope_model: IsotopeModel | IsotopeModelType | str = "peptide",
min_isotope_abundance: float = 0.01,
max_isotope_fold_error: float = 2.0,
max_isotope_gaps: int = 0,
max_isotopes: int | None = None,
im_tolerance: float = 0.05,
im_tolerance_type: Literal["relative", "absolute"] = "relative",
ionization_model: IonizationModel | str | float | None = None,
) -> Self
Note the positional order: inplace comes before min_intensity and min_score. Pass the
latter two by keyword.
| Parameter | Default | Description |
|---|---|---|
tolerance |
50 |
Peak matching tolerance |
tolerance_type |
"ppm" |
"ppm" or "da" |
charge_range |
(1, 3) |
Min and max charge to try, inclusive. Requires 1 <= min <= max, else ValueError |
intensity |
"total" |
"total" sums matched peaks; "base" uses observed A+0, or zero when A+0 is absent |
max_dpeaks |
2000 |
Upper bound on output peaks |
inplace |
False |
Mutate in place instead of returning a new Spectrum |
min_intensity |
"min" |
Intensity floor for detectability scoring. "min" uses the spectrum minimum |
min_score |
0.0 |
Minimum profile score to accept a cluster; 0.0 accepts everything |
isotope_model |
"peptide" |
"peptide", "glycan", "lipid", "dna", "rna", or a custom IsotopeModel |
min_isotope_abundance |
0.01 |
Stop when theoretical abundance falls below 1% of the apex |
max_isotope_fold_error |
2.0 |
Stop when observed intensity is outside 0.5x to 2x expected |
max_isotope_gaps |
0 |
Missing isotope positions allowed before stopping one direction |
max_isotopes |
None |
Adaptive by default; an integer imposes a hard envelope-length limit |
im_tolerance |
0.05 |
Candidate-to-seed mobility gate when the spectrum contains ion mobility |
im_tolerance_type |
"relative" |
Scale the mobility tolerance by the seed value or use it as an absolute difference |
ionization_model |
None |
Adduct preset/alias, custom model, or signed carrier mass. Defaults from scan polarity, with positive protonation as the fallback |
Calling deconvolute() on an already-DECONVOLUTED spectrum emits a UserWarning and returns it
unchanged.
Isotope models
Select a built-in model by string or enum:
from spxtacular import IsotopeModelType
glycan = spectrum.deconvolute(isotope_model="glycan")
rna = spectrum.deconvolute(isotope_model=IsotopeModelType.RNA)
Define a custom average composition as expected atoms per Dalton. A fixed composition represents atoms that occur once rather than scaling with mass.
from spxtacular import IsotopeModel
model = IsotopeModel(
atoms_per_da={"C": 0.05, "H": 0.08, "N": 0.01, "O": 0.02},
fixed_composition={"H": 2, "O": 1},
)
custom = spectrum.deconvolute(isotope_model=model)
The general lipid preset spans several lipid classes and is necessarily approximate. Prefer an exact formula or a class-specific custom model when that information is available.
Score output
After deconvolution, spectrum.iso_score is a float64 array parallel to mz/intensity. Each assigned cluster carries a score in 0–1 representing how well its observed intensity distribution matches the theoretical isotope envelope. Singletons always have iso_score=0.0.
decon = spec.deconvolute(charge_range=(1, 5), tolerance=10, tolerance_type="ppm")
print(decon.iso_score) # array of float64, same length as decon.mz
# Keep only well-matched clusters (score >= 0.5) and assigned peaks (charge > 0)
confident = decon.filter(min_score=0.5, min_charge=1)
The iso_score array is propagated through .decharge(), so neutral-mass peaks retain their cluster score.
Deconvolution provenance
The output spectrum records the resolved ionization and isotope models together with every
parameter that affects envelope construction and matching. This includes the abundance threshold,
fold-error gate, allowed gaps, envelope-length limit, and ion-mobility tolerance. Custom
IsotopeModel compositions and isotope abundances are stored by value, so native .npz, matchms,
and spectrl round-trips preserve the exact model. Schema-v1 provenance remains readable.
charge_range: Cover the full range you expect. A wider range increases runtime linearly. For tryptic peptides (1, 5) is typical; for intact proteins (5, 50) or wider.
tolerance: The default 50 ppm is conservative. For high-resolution instruments (Orbitrap, timsTOF) use 5–15 ppm.
intensity mode: "total" is recommended for quantification. "base" returns the observed
A+0 intensity and returns zero when the monoisotopic peak was inferred rather than observed.
Charge conventions
charge value |
Meaning |
|---|---|
> 0 |
Assigned isotope cluster with this charge state |
-1 |
Singleton — no isotope neighbours found at any tested charge |
0 |
After .decharge() — neutral mass, charge state no longer tracked |
Charge values are positive magnitudes. Polarity and carrier mass are recorded separately in deconvolution provenance.
Polarity and adducts
negative = spectrum.deconvolute(isotope_model="rna", ionization_model="[M-H]-")
sodium = spectrum.deconvolute(isotope_model="lipid", ionization_model="[M+Na]+")
neutral = sodium.decharge() # reuses the recorded sodium carrier
Built-ins cover [M+H]+, [M-H]-, [M+Na]+, and [M+NH4]+.
IonizationModel accepts custom signed carrier masses.
Worked example
import numpy as np
from spxtacular import Spectrum
# Simulated z=2 peptide of neutral mass 1398.70, with its isotope envelope.
# Isotope spacing at z=2 is 1.00335 / 2 ≈ 0.5017 Da.
mz = np.array([
450.2000, # singleton
700.3573, 700.8590, 701.3606, 701.8623, # z=2 cluster
], dtype=np.float64)
intensity = np.array([1e3, 80000.0, 58614.0, 21276.0, 3828.0], dtype=np.float64)
spec = Spectrum(mz=mz, intensity=intensity)
decon = spec.deconvolute(charge_range=(1, 4), tolerance=10, tolerance_type="ppm")
for mz_val, z, inten, score in zip(decon.mz, decon.charge, decon.intensity, decon.iso_score):
label = f"z={z}" if z != -1 else "singleton"
print(f" mz={mz_val:.4f} {label:<9} intensity={inten:.2e} score={score:.3f}")
Expected output:
Four input peaks collapsed into one cluster entry at the monoisotopic m/z, with the intensities
summed. The singleton is preserved with charge=-1 and iso_score=0.0.
Note that the envelope matters: supply only the first two or three isotope peaks of a large cluster and the missing tail is penalised, which can leave a z=1 reading of the same peaks scoring marginally higher. Deconvolution is most reliable on complete envelopes.
To convert the assigned peaks to neutral masses, chain .decharge():