From 653720f3203614e46dcfc7e9ce9d7086d680e8e1 Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:46:55 +0100 Subject: [PATCH 1/7] feat: add xcorr feature --- winnow/calibration/features/chimeric.py | 3 + winnow/calibration/features/fragment_match.py | 3 + winnow/calibration/features/utils.py | 175 +++++++++++++++++- 3 files changed, 177 insertions(+), 4 deletions(-) diff --git a/winnow/calibration/features/chimeric.py b/winnow/calibration/features/chimeric.py index eda0b54f..3186767d 100644 --- a/winnow/calibration/features/chimeric.py +++ b/winnow/calibration/features/chimeric.py @@ -123,6 +123,7 @@ def columns(self) -> List[str]: "chimeric_max_ion_gap", "chimeric_b_y_intensity_ratio", "chimeric_spectral_angle", + "chimeric_xcorr", ] if self.learn_from_missing: columns.append("is_missing_chimeric_features") @@ -307,6 +308,7 @@ def compute(self, dataset: CalibrationDataset) -> None: max_ion_gap, b_y_intensity_ratio, spectral_angle, + xcorr, ) = compute_ion_identifications( dataset=dataset.metadata, source_mz_column="runner_up_prosit_mz", @@ -324,3 +326,4 @@ def compute(self, dataset: CalibrationDataset) -> None: dataset.metadata["chimeric_max_ion_gap"] = max_ion_gap dataset.metadata["chimeric_b_y_intensity_ratio"] = b_y_intensity_ratio dataset.metadata["chimeric_spectral_angle"] = spectral_angle + dataset.metadata["chimeric_xcorr"] = xcorr diff --git a/winnow/calibration/features/fragment_match.py b/winnow/calibration/features/fragment_match.py index 899dc9bd..3307da26 100644 --- a/winnow/calibration/features/fragment_match.py +++ b/winnow/calibration/features/fragment_match.py @@ -122,6 +122,7 @@ def columns(self) -> List[str]: "max_ion_gap", "b_y_intensity_ratio", "spectral_angle", + "xcorr", ] if self.learn_from_missing: columns.append("is_missing_fragment_match_features") @@ -289,6 +290,7 @@ def compute(self, dataset: CalibrationDataset) -> None: max_ion_gap, b_y_intensity_ratio, spectral_angle, + xcorr, ) = compute_ion_identifications( dataset=dataset.metadata, source_mz_column="theoretical_mz", @@ -305,3 +307,4 @@ def compute(self, dataset: CalibrationDataset) -> None: dataset.metadata["max_ion_gap"] = max_ion_gap dataset.metadata["b_y_intensity_ratio"] = b_y_intensity_ratio dataset.metadata["spectral_angle"] = spectral_angle + dataset.metadata["xcorr"] = xcorr diff --git a/winnow/calibration/features/utils.py b/winnow/calibration/features/utils.py index 3d7387b7..2cdfb4ab 100644 --- a/winnow/calibration/features/utils.py +++ b/winnow/calibration/features/utils.py @@ -340,8 +340,8 @@ def compute_ion_identifications( source_intensity_column: str, mz_tolerance: float = 0.02, predictions: Optional[List[str]] = None, -) -> Iterator[Tuple[float, float, int, int, int, float, float, float]]: - """Computes the ion match rate, match intensity, longest b-series, longest y-series, complementary ion count, max ion gap, b-y intensity ratio and spectral angle for each spectrum in the dataset. +) -> Iterator[Tuple[float, float, int, int, int, float, float, float, float]]: + """Computes the ion match rate, match intensity, longest b-series, longest y-series, complementary ion count, max ion gap, b-y intensity ratio, spectral angle and xcorr for each spectrum in the dataset. Args: dataset: DataFrame containing the mass spectrum data. @@ -352,10 +352,10 @@ def compute_ion_identifications( predictions: Optional list of tokenised predictions for each spectrum. If not provided, the peptide length will be inferred from the column "predictions" in the metadata. Returns: - Iterator of (ion_match_rate, ion_match_intensity, longest_b_series, longest_y_series, complementary_ion_count, max_ion_gap, b_y_intensity_ratio, spectral_angle) tuples. + Iterator of (ion_match_rate, ion_match_intensity, longest_b_series, longest_y_series, complementary_ion_count, max_ion_gap, b_y_intensity_ratio, spectral_angle, xcorr) tuples. """ per_row_match_results: List[ - Tuple[float, float, int, int, int, float, float, float] + Tuple[float, float, int, int, int, float, float, float, float] ] = [] for _, row in dataset.iterrows(): @@ -392,6 +392,12 @@ def compute_ion_identifications( spectral_angle = compute_spectral_angle( row[source_intensity_column], aligned_m0_intensities ) + # Compute the fast xcorr score + xcorr = compute_xcorr( + observed_mz=row["mz_array"], + observed_intensities=row["intensity_array"], + theoretical_mz=row[source_mz_column], + ) per_row_match_results.append( ( @@ -403,6 +409,7 @@ def compute_ion_identifications( max_ion_gap, b_y_intensity_ratio, spectral_angle, + xcorr, ) ) @@ -617,3 +624,163 @@ def compute_spectral_angle( dot_product = np.clip(dot_product, -1.0, 1.0) return 1 - (2 * np.arccos(dot_product) / np.pi) + + +######################################################## +# Cross-correlation Score (xcorr) +######################################################## + +# Default bin width matching Comet's fragment_bin_tol for near-unit-dalton bins. +XCORR_BIN_SIZE = 1.0005079 +# Default bin offset matching Comet's fragment_bin_offset. +XCORR_BIN_OFFSET = 0.4 +# Number of equal-width m/z windows for intensity normalization (SEQUEST default). +XCORR_NUM_WINDOWS = 10 +# Maximum m/z offset in bins for background subtraction (SEQUEST default ±75). +XCORR_MAX_OFFSET = 75 +# Normalization target for the maximum intensity within each window. +XCORR_WINDOW_NORM_VALUE = 50.0 + + +def _bin_observed_spectrum( + observed_mz: List[float], + observed_intensities: List[float], + num_bins: int, + bin_size: float, + bin_offset: float, +) -> np.ndarray: + """Bin an observed spectrum with sqrt intensity compression. + + Places each peak into a near-unit-dalton bin, taking the square root of + its intensity. When multiple peaks land in the same bin, the maximum + sqrt-intensity is kept. + + Args: + observed_mz: m/z values of the observed spectrum. + observed_intensities: Intensities corresponding to observed_mz. + num_bins: Total number of bins in the output array. + bin_size: Width of each m/z bin in Daltons. + bin_offset: Fractional offset for bin assignment. + + Returns: + Array of length num_bins with sqrt-compressed, max-per-bin intensities. + """ + binned = np.zeros(num_bins) + for mz, intensity in zip(observed_mz, observed_intensities): + bin_idx = int(mz / bin_size + bin_offset) + if 0 <= bin_idx < num_bins: + sqrt_intensity = np.sqrt(max(intensity, 0.0)) + if sqrt_intensity > binned[bin_idx]: + binned[bin_idx] = sqrt_intensity + return binned + + +def _normalize_windows( + spectrum: np.ndarray, + num_windows: int, +) -> None: + """Normalize maximum intensity within equal m/z windows (in-place). + + Divides the spectrum into num_windows equal regions and scales each so + that its maximum intensity equals XCORR_WINDOW_NORM_VALUE. + + Args: + spectrum: Binned spectrum array, modified in-place. + num_windows: Number of equal-width windows. + """ + num_bins = len(spectrum) + window_size = max(num_bins // num_windows, 1) + for w in range(num_windows): + start = w * window_size + end = min(start + window_size, num_bins) if w < num_windows - 1 else num_bins + window = spectrum[start:end] + max_val = window.max() + if max_val > 0: + spectrum[start:end] = window * (XCORR_WINDOW_NORM_VALUE / max_val) + + +def _subtract_background( + spectrum: np.ndarray, + max_xcorr_offset: int, +) -> np.ndarray: + """Subtract local background using the fast xcorr preprocessing. + + For each bin i, computes: + y'[i] = y[i] - (sum of y[i-offset..i+offset] excluding y[i]) / (2 * offset) + + Uses a cumulative sum for O(n) efficiency. + + Args: + spectrum: Window-normalized binned spectrum. + max_xcorr_offset: Number of bins in each direction for background. + + Returns: + Background-subtracted spectrum. + """ + num_bins = len(spectrum) + cumsum = np.concatenate(([0.0], np.cumsum(spectrum))) + lo = np.maximum(0, np.arange(num_bins) - max_xcorr_offset) + hi = np.minimum(num_bins, np.arange(num_bins) + max_xcorr_offset + 1) + window_sums = cumsum[hi] - cumsum[lo] + divisor = 2 * max_xcorr_offset + return spectrum - (window_sums - spectrum) / divisor + + +def compute_xcorr( + observed_mz: List[float], + observed_intensities: List[float], + theoretical_mz: Union[List[float], float], + bin_size: float = XCORR_BIN_SIZE, + bin_offset: float = XCORR_BIN_OFFSET, + num_windows: int = XCORR_NUM_WINDOWS, + max_xcorr_offset: int = XCORR_MAX_OFFSET, +) -> float: + """Compute the SEQUEST fast xcorr score between observed and theoretical spectra. + + Implements the fast cross-correlation score from Eng et al. (2008). The observed + spectrum is preprocessed by binning into near-unit-dalton bins with sqrt intensity + compression, normalizing peak intensities within equal m/z windows, and subtracting + the local background (mean of ±max_xcorr_offset shifted spectra). The xcorr score + is the dot product of the preprocessed observed spectrum with a binary theoretical + spectrum that has 1.0 at each predicted fragment ion bin. + + Reference: + Eng JK, Fischer B, Grossmann J, Maccoss MJ. A fast SEQUEST cross correlation + algorithm. J Proteome Res. 2008 Oct;7(10):4598-602. + + Args: + observed_mz: m/z values of the observed (acquired) spectrum. + observed_intensities: Intensities corresponding to observed_mz. + theoretical_mz: m/z values of theoretical fragment ions from intensity + prediction. May be NaN (float) for missing predictions. + bin_size: Width of each m/z bin in Daltons. Defaults to 1.0005079. + bin_offset: Fractional offset for bin assignment. Defaults to 0.4. + num_windows: Number of equal-width m/z windows for intensity normalization. + Defaults to 10. + max_xcorr_offset: Number of bins in each direction for background estimation. + The background is divided by 2 * max_xcorr_offset. Defaults to 75. + + Returns: + The xcorr score. Returns 0.0 for missing/empty inputs. + """ + if isinstance(theoretical_mz, float): + return 0.0 + if len(observed_mz) == 0 or len(theoretical_mz) == 0: + return 0.0 + + max_mz = max(max(observed_mz), max(theoretical_mz)) + num_bins = int(max_mz / bin_size + bin_offset) + 1 + + observed_binned = _bin_observed_spectrum( + observed_mz, observed_intensities, num_bins, bin_size, bin_offset + ) + _normalize_windows(observed_binned, num_windows) + processed = _subtract_background(observed_binned, max_xcorr_offset) + + theoretical_binned = np.zeros(num_bins) + for mz in theoretical_mz: + bin_idx = int(mz / bin_size + bin_offset) + if 0 <= bin_idx < num_bins: + theoretical_binned[bin_idx] = 1.0 + + return float(np.dot(theoretical_binned, processed)) From 16a8e47551f663539da5ac4c4d600b1da7399e58 Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:47:14 +0100 Subject: [PATCH 2/7] test: add xcorr feature tests --- tests/calibration/features/test_chimeric.py | 4 + .../features/test_fragment_match.py | 4 + tests/calibration/features/test_utils.py | 98 +++++++++++++++++++ 3 files changed, 106 insertions(+) diff --git a/tests/calibration/features/test_chimeric.py b/tests/calibration/features/test_chimeric.py index 2df9bded..bb2ac237 100644 --- a/tests/calibration/features/test_chimeric.py +++ b/tests/calibration/features/test_chimeric.py @@ -78,6 +78,7 @@ def test_properties(self, chimeric_features): "chimeric_max_ion_gap", "chimeric_b_y_intensity_ratio", "chimeric_spectral_angle", + "chimeric_xcorr", # Missing indicator (learn_from_missing=True by default) "is_missing_chimeric_features", ] @@ -139,6 +140,7 @@ def test_learn_from_missing_false_columns_excludes_indicator(self): "chimeric_max_ion_gap", "chimeric_b_y_intensity_ratio", "chimeric_spectral_angle", + "chimeric_xcorr", ] # ------------------------------------------------------------------ @@ -213,6 +215,7 @@ def test_compute_preprocessing_pipeline( mock_max_ion_gap = 1 mock_b_y_intensity_ratio = 0.5 mock_spectral_angle = 0.8 + mock_xcorr = [1.0, 2.0] mock_compute_ions.return_value = ( mock_match_rate, mock_match_intensity, @@ -222,6 +225,7 @@ def test_compute_preprocessing_pipeline( mock_max_ion_gap, mock_b_y_intensity_ratio, mock_spectral_angle, + mock_xcorr, ) # Run the compute method diff --git a/tests/calibration/features/test_fragment_match.py b/tests/calibration/features/test_fragment_match.py index 463f2ae9..d8cfa26a 100644 --- a/tests/calibration/features/test_fragment_match.py +++ b/tests/calibration/features/test_fragment_match.py @@ -64,6 +64,7 @@ def test_properties(self, prosit_features): "max_ion_gap", "b_y_intensity_ratio", "spectral_angle", + "xcorr", # Missing indicator (learn_from_missing=True by default) "is_missing_fragment_match_features", ] @@ -124,6 +125,7 @@ def test_learn_from_missing_false_columns_excludes_indicator(self): "max_ion_gap", "b_y_intensity_ratio", "spectral_angle", + "xcorr", ] def test_learn_from_missing_true_columns_includes_indicator(self): @@ -236,6 +238,7 @@ def test_compute_with_mock( mock_max_ion_gap = 1 mock_b_y_intensity_ratio = 0.5 mock_spectral_angle = 0.8 + mock_xcorr = [1.0, 2.0, 3.0] mock_compute_ions.return_value = ( mock_match_rate, mock_match_intensity, @@ -245,6 +248,7 @@ def test_compute_with_mock( mock_max_ion_gap, mock_b_y_intensity_ratio, mock_spectral_angle, + mock_xcorr, ) prosit_features.compute(sample_dataset_with_spectra) diff --git a/tests/calibration/features/test_utils.py b/tests/calibration/features/test_utils.py index 9e3e8d09..7281c826 100644 --- a/tests/calibration/features/test_utils.py +++ b/tests/calibration/features/test_utils.py @@ -14,6 +14,7 @@ compute_max_ion_gap, compute_b_y_intensity_ratio, compute_spectral_angle, + compute_xcorr, ) from winnow.calibration.features.fragment_match import FragmentMatchFeatures from winnow.calibration.features.chimeric import ChimericFeatures @@ -648,3 +649,100 @@ def test_compute_spectral_angle_length_mismatch_raises(self): observed = [1.0, 2.0] # Different length with pytest.raises(ValueError, match="must align"): compute_spectral_angle(theoretical, observed) + + +class TestXcorr: + """Test the fast xcorr score function.""" + + def test_matching_peaks_positive_score(self): + """Peaks exactly at theoretical positions should produce a positive xcorr.""" + theoretical_mz = [200.0, 400.0, 600.0] + observed_mz = [200.0, 400.0, 600.0] + observed_intensities = [1000.0, 2000.0, 1500.0] + + score = compute_xcorr(observed_mz, observed_intensities, theoretical_mz) + assert score > 0.0 + + def test_no_matching_peaks_low_score(self): + """Peaks far from theoretical positions should yield a low or negative xcorr.""" + theoretical_mz = [200.0, 400.0, 600.0] + observed_mz = [250.0, 450.0, 650.0] + observed_intensities = [1000.0, 2000.0, 1500.0] + + matching_score = compute_xcorr( + [200.0, 400.0, 600.0], observed_intensities, [200.0, 400.0, 600.0] + ) + non_matching_score = compute_xcorr( + observed_mz, observed_intensities, theoretical_mz + ) + assert non_matching_score < matching_score + + def test_nan_theoretical_returns_zero(self): + """NaN theoretical m/z (missing prediction) returns 0.0.""" + import math + + score = compute_xcorr([100.0], [1000.0], math.nan) + assert score == 0.0 + + def test_empty_theoretical_returns_zero(self): + """Empty theoretical m/z list returns 0.0.""" + score = compute_xcorr([100.0], [1000.0], []) + assert score == 0.0 + + def test_empty_observed_returns_zero(self): + """Empty observed spectrum returns 0.0.""" + score = compute_xcorr([], [], [100.0, 200.0]) + assert score == 0.0 + + def test_uniform_spectrum_much_lower_than_matching(self): + """A uniform observed spectrum should score much lower than a matching one. + + When every bin has the same intensity, the background subtraction + largely cancels the signal. Some residual remains due to edge effects + in the background window near spectrum boundaries, but the score + should be much lower than when peaks align with theoretical positions. + """ + observed_mz = [float(i) for i in range(50, 500)] + observed_intensities = [100.0] * len(observed_mz) + theoretical_mz = [100.0, 200.0, 300.0] + + uniform_score = compute_xcorr(observed_mz, observed_intensities, theoretical_mz) + + sparse_mz = [100.0, 200.0, 300.0] + sparse_intensities = [1000.0, 1000.0, 1000.0] + matching_score = compute_xcorr(sparse_mz, sparse_intensities, theoretical_mz) + + assert uniform_score < matching_score * 0.25 + + def test_scale_invariance(self): + """Xcorr should be the same regardless of observed intensity scale. + + Window normalization rescales each region to a fixed value, so + multiplying all intensities by a constant should not change the score. + """ + theoretical_mz = [200.0, 400.0, 600.0] + observed_mz = [200.0, 300.0, 400.0, 500.0, 600.0] + intensities_1x = [1000.0, 500.0, 2000.0, 300.0, 1500.0] + intensities_100x = [i * 100.0 for i in intensities_1x] + + score_1x = compute_xcorr(observed_mz, intensities_1x, theoretical_mz) + score_100x = compute_xcorr(observed_mz, intensities_100x, theoretical_mz) + assert score_1x == pytest.approx(score_100x, rel=1e-10) + + def test_more_matches_higher_score(self): + """More matching peaks should produce a higher xcorr than fewer matches.""" + observed_mz = [100.0, 200.0, 300.0, 400.0, 500.0] + observed_intensities = [1000.0] * 5 + + score_2_matches = compute_xcorr( + observed_mz, observed_intensities, [100.0, 200.0] + ) + score_4_matches = compute_xcorr( + observed_mz, observed_intensities, [100.0, 200.0, 300.0, 400.0] + ) + assert score_4_matches > score_2_matches + + def test_single_peak_positive(self): + """A single observed peak matching a single theoretical ion should score positive.""" + score = compute_xcorr([300.0], [5000.0], [300.0]) + assert score > 0.0 From c52504046361fbae8b4e373f52536cdc42907208 Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:47:41 +0100 Subject: [PATCH 3/7] docs: update feature documentation with xcorr feature --- docs/api/features/chimeric.md | 1 + docs/api/features/fragment_match.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/docs/api/features/chimeric.md b/docs/api/features/chimeric.md index 7f313b42..a5ce8a4b 100644 --- a/docs/api/features/chimeric.md +++ b/docs/api/features/chimeric.md @@ -48,6 +48,7 @@ All columns from `FragmentMatchFeatures` with `chimeric_` prefix: | `chimeric_max_ion_gap` | Daltons (Da) | Largest gap between matched runner-up ions | | `chimeric_b_y_intensity_ratio` | Ratio | Ratio of b-ion to y-ion intensity for runner-up (including isotopic envelopes) | | `chimeric_spectral_angle` | Score (0-1) | Spectral angle similarity between runner-up theoretical and observed intensities | +| `chimeric_xcorr` | Score | SEQUEST fast cross-correlation score for the runner-up peptide. Measures overall agreement between the observed spectrum and the runner-up theoretical spectrum with local background correction. See [FragmentMatchFeatures — Cross-correlation Score](fragment_match.md#cross-correlation-score) for details on the algorithm. | ```python from winnow.calibration.features import ChimericFeatures diff --git a/docs/api/features/fragment_match.md b/docs/api/features/fragment_match.md index 7f50e478..9cc282f1 100644 --- a/docs/api/features/fragment_match.md +++ b/docs/api/features/fragment_match.md @@ -57,6 +57,21 @@ For each theoretical peak, we search for the nearest observed peak using binary | `max_ion_gap` | Daltons (Da) | Largest m/z difference between two consecutive matched theoretical peaks when sorted by m/z. Large gaps may indicate missing fragmentation coverage. | | `b_y_intensity_ratio` | Ratio | Ratio of total matched b-ion intensity to total matched y-ion intensity (including isotopic envelopes). | | `spectral_angle` | Score (0-1) | Normalized spectral angle similarity between theoretical and matched observed intensity vectors. A value of 1 indicates perfect correlation, 0 indicates orthogonal vectors. | +| `xcorr` | Score | SEQUEST fast cross-correlation score. Measures overall agreement between the observed and theoretical spectra with local background correction. Higher values indicate better matches. | + +### Cross-correlation Score + +The `xcorr` column implements the fast cross-correlation score function from SEQUEST (Eng et al., 2008). Unlike the other features which operate on individually matched peaks, xcorr evaluates the overall pattern of the observed spectrum against the theoretical spectrum using a background-corrected dot product. + +The observed spectrum is preprocessed by: + +1. **Binning** into near-unit-dalton bins (~1.0005 Da) with square-root intensity compression +2. **Window normalization** — the maximum intensity within each of 10 equal m/z windows is normalized to a fixed value, making the score robust to different intensity scales +3. **Background subtraction** — the mean intensity from ±75 neighbouring bins is subtracted from each bin, so that only signal specifically at the correct m/z positions contributes positively + +The score is then computed as the dot product of the preprocessed observed spectrum with a binary theoretical spectrum (1.0 at each predicted fragment ion bin position). This background correction naturally penalizes unmatched theoretical ions and discounts noise. + +**Reference:** Eng JK, Fischer B, Grossmann J, Maccoss MJ. A fast SEQUEST cross correlation algorithm. *J Proteome Res.* 2008 Oct;7(10):4598-602. doi: 10.1021/pr800420s. ## Usage From 489cd887a1f91a4a840e950ca75ece8ee37925a3 Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:36:41 +0100 Subject: [PATCH 4/7] chore: move xcorr constants into constants file --- winnow/calibration/features/constants.py | 10 ++++++++++ winnow/calibration/features/utils.py | 20 ++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/winnow/calibration/features/constants.py b/winnow/calibration/features/constants.py index fedcc274..940cb904 100644 --- a/winnow/calibration/features/constants.py +++ b/winnow/calibration/features/constants.py @@ -2,3 +2,13 @@ # Carbon-13 mass shift for isotopic envelope calculation CARBON_ISOTOPE_MASS_SHIFT = 1.00335 +# Default bin width matching Comet's fragment_bin_tol for near-unit-dalton bins. +XCORR_BIN_SIZE = 1.0005079 +# Default bin offset matching Comet's fragment_bin_offset. +XCORR_BIN_OFFSET = 0.4 +# Number of equal-width m/z windows for intensity normalization (SEQUEST default). +XCORR_NUM_WINDOWS = 10 +# Maximum m/z offset in bins for background subtraction (SEQUEST default ±75). +XCORR_MAX_OFFSET = 75 +# Normalization target for the maximum intensity within each window. +XCORR_WINDOW_NORM_VALUE = 50.0 diff --git a/winnow/calibration/features/utils.py b/winnow/calibration/features/utils.py index 2cdfb4ab..5ba8ff65 100644 --- a/winnow/calibration/features/utils.py +++ b/winnow/calibration/features/utils.py @@ -12,7 +12,14 @@ import pandas as pd from winnow.datasets.calibration_dataset import CalibrationDataset -from winnow.calibration.features.constants import CARBON_ISOTOPE_MASS_SHIFT +from winnow.calibration.features.constants import ( + CARBON_ISOTOPE_MASS_SHIFT, + XCORR_BIN_SIZE, + XCORR_BIN_OFFSET, + XCORR_NUM_WINDOWS, + XCORR_MAX_OFFSET, + XCORR_WINDOW_NORM_VALUE, +) ######################################################## # Helper functions @@ -630,17 +637,6 @@ def compute_spectral_angle( # Cross-correlation Score (xcorr) ######################################################## -# Default bin width matching Comet's fragment_bin_tol for near-unit-dalton bins. -XCORR_BIN_SIZE = 1.0005079 -# Default bin offset matching Comet's fragment_bin_offset. -XCORR_BIN_OFFSET = 0.4 -# Number of equal-width m/z windows for intensity normalization (SEQUEST default). -XCORR_NUM_WINDOWS = 10 -# Maximum m/z offset in bins for background subtraction (SEQUEST default ±75). -XCORR_MAX_OFFSET = 75 -# Normalization target for the maximum intensity within each window. -XCORR_WINDOW_NORM_VALUE = 50.0 - def _bin_observed_spectrum( observed_mz: List[float], From cfd38fe340480886bc4edae35b1a1e1cfb5e5172 Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:18:58 +0100 Subject: [PATCH 5/7] fix: correct argument name in feature utils tests --- tests/calibration/features/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/calibration/features/test_utils.py b/tests/calibration/features/test_utils.py index 434ac22f..1ff54a4c 100644 --- a/tests/calibration/features/test_utils.py +++ b/tests/calibration/features/test_utils.py @@ -561,7 +561,7 @@ def test_compute_ion_identifications_uses_per_row_prediction_length(self): _, _, _, _, complementary_counts, _, _ = compute_ion_identifications( dataset, - source_column="prosit_mz", + source_mz_column="prosit_mz", source_annotation_column="annotation", mz_tolerance=0.02, predictions=runner_up_predictions, From a0efccc07bf30cc658822a702e62b3145a3dcc83 Mon Sep 17 00:00:00 2001 From: Jeroen Van Goey Date: Thu, 25 Jun 2026 16:43:34 +0200 Subject: [PATCH 6/7] test: add test_observed_preprocessing_independent_of_out_of_range_theoretical_ions committed --- tests/calibration/features/test_utils.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/calibration/features/test_utils.py b/tests/calibration/features/test_utils.py index cd0e5ab3..ebed5597 100644 --- a/tests/calibration/features/test_utils.py +++ b/tests/calibration/features/test_utils.py @@ -771,6 +771,21 @@ def test_more_matches_higher_score(self): ) assert score_4_matches > score_2_matches + def test_observed_preprocessing_independent_of_out_of_range_theoretical_ions(self): + """Unmatched high-m/z theoretical ions must not change observed normalization.""" + observed_mz = [166.51, 239.26, 280.07, 391.08, 429.99] + observed_intensities = [416.6, 13651.9, 163.4, 806.1, 2155.4] + theoretical_mz = [239.26, 429.99, 391.08] + + score = compute_xcorr(observed_mz, observed_intensities, theoretical_mz) + score_with_unmatched_ion = compute_xcorr( + observed_mz, + observed_intensities, + theoretical_mz + [2000.0], + ) + + assert score_with_unmatched_ion == pytest.approx(score) + def test_single_peak_positive(self): """A single observed peak matching a single theoretical ion should score positive.""" score = compute_xcorr([300.0], [5000.0], [300.0]) From 31a6cddcd645a077ce2765137eecaa9ff97f548d Mon Sep 17 00:00:00 2001 From: Jemma Daniel <134346753+JemmaLDaniel@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:15:14 +0100 Subject: [PATCH 7/7] fix: preprocess observed spectrum equivalently irregardless of candidate spectra --- winnow/calibration/features/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/winnow/calibration/features/utils.py b/winnow/calibration/features/utils.py index db080c1e..042bd4a0 100644 --- a/winnow/calibration/features/utils.py +++ b/winnow/calibration/features/utils.py @@ -937,7 +937,7 @@ def compute_xcorr( if len(observed_mz) == 0 or len(theoretical_mz) == 0: return 0.0 - max_mz = max(max(observed_mz), max(theoretical_mz)) + max_mz = max(observed_mz) num_bins = int(max_mz / bin_size + bin_offset) + 1 observed_binned = _bin_observed_spectrum(