From b061a1c7ed48d44423255c79de985a06f24baeae Mon Sep 17 00:00:00 2001 From: RobbinBouwmeester Date: Fri, 28 Aug 2026 10:48:43 +0200 Subject: [PATCH 1/4] feat: deduplicate the calibration reference by peptidoform (4.2.0) Calibration and fine-tuning now fit on one PSM per peptidoform, the first observation in the reference. A reference built from a search result repeats a peptidoform once per spectrum it was identified in, each time with a different observed retention time, so the fit received conflicting targets and weighed peptidoforms by how often they happened to be identified. On the reported MS2Rescore case (201,593 PSMs, single run) the reference chosen by auto-calibration held 6,331 PSMs but only 2,623 peptidoforms, and the repeats disagreed on the observed retention time by up to 236 minutes. Measured on those 2,623 peptidoforms, fitting on the first observations improves the calibration from 9.69 to 4.51 minutes mean absolute error (median 5.98 to 2.09; within five minutes 44.9 % to 81.4 %). Charge states of one peptidoform count as repeats, because retention time does not depend on precursor charge. When repeats disagree by a large fraction of the observed range DeepLC now warns: the reference then mixes runs or contains low-confidence PSMs, which deduplication hides rather than fixes. `deduplicate_reference=False` on calibrate/finetune/predict_and_calibrate/ finetune_and_predict, or `--keep-duplicate-reference-psms` on the command line, restores the previous behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 28 ++++++ deeplc/__main__.py | 29 +++++- deeplc/_reference_selection.py | 100 +++++++++++++++++++++ deeplc/core.py | 41 ++++++++- docs/source/usage.rst | 11 +++ pyproject.toml | 2 +- tests/test_deduplication.py | 160 +++++++++++++++++++++++++++++++++ 7 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 tests/test_deduplication.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee5cef..2741032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.2.0] - 2026-08-28 + +### Changed + +- Calibration and fine-tuning now use **one PSM per peptidoform**, the first observation in + the reference. A reference built from a search result repeats a peptidoform once per + spectrum it was identified in, each time with a different observed retention time, so the + fit was given conflicting targets and weighed peptidoforms by how often they happened to be + identified. On a reported MS2Rescore case (201,593 PSMs, one run) the reference selected by + auto-calibration held 6,331 PSMs but only 2,623 peptidoforms; the repeats disagreed on the + observed retention time by up to 236 minutes. Fitting on the first observations improved the + calibration on those 2,623 peptidoforms from 9.69 to 4.51 minutes mean absolute error + (median 5.98 to 2.09; within 5 minutes 44.9 % to 81.4 %). + + Charge states of one peptidoform count as repeats, since retention time does not depend on + precursor charge. When repeats disagree by a large fraction of the observed range, DeepLC + now says so: that means the reference mixes runs or contains low-confidence PSMs, which + deduplication hides rather than fixes. + + Pass `deduplicate_reference=False` to `calibrate`, `finetune`, `predict_and_calibrate` or + `finetune_and_predict`, or `--keep-duplicate-reference-psms` on the command line, to fit on + every reference PSM as before. + +### Added + +- `deeplc._reference_selection.deduplicate_psms`, which returns the first PSM of every + peptidoform in a `PSMList`. + ## [4.1.1] - 2026-08-26 ### Changed diff --git a/deeplc/__main__.py b/deeplc/__main__.py index 4053618..94212dc 100644 --- a/deeplc/__main__.py +++ b/deeplc/__main__.py @@ -126,6 +126,14 @@ def _validate_finetune(ctx, param, value): expose_value=True, help="Fine-tune the model to the reference before predicting. Requires --reference or --auto-calibrate.", # noqa: E501 ) +@click.option( + "--keep-duplicate-reference-psms", + is_flag=True, + default=False, + help="Fit the calibration (and fine-tuning) on every reference PSM, including repeats of " + "the same peptidoform. By default only the first PSM of each peptidoform is used, because " + "repeats carry conflicting observed retention times for one prediction.", +) @click.option("--output", "-o", type=str, default=None, help="Output file path.") @click.option( "--model", @@ -135,7 +143,15 @@ def _validate_finetune(ctx, param, value): help="Path to a model file. Uses the built-in default model if not provided.", ) def predict( - psms, psm_filetype, reference, reference_filetype, auto_calibrate, finetune, output, model + psms, + psm_filetype, + reference, + reference_filetype, + auto_calibrate, + finetune, + keep_duplicate_reference_psms, + output, + model, ): """Predict retention times for a list of peptide-spectrum matches.""" if auto_calibrate and reference: @@ -143,6 +159,7 @@ def predict( psm_list = _read_psm_file(psms, psm_filetype) output_path = _infer_output_name(psms, output) + dedup = not keep_duplicate_reference_psms if reference: psm_list_reference = _read_psm_file(reference, reference_filetype) @@ -151,18 +168,24 @@ def predict( psm_list=psm_list, psm_list_reference=psm_list_reference, model=model, + deduplicate_reference=dedup, ) else: predictions = deeplc.core.predict_and_calibrate( psm_list=psm_list, psm_list_reference=psm_list_reference, model=model, + deduplicate_reference=dedup, ) elif auto_calibrate: if finetune: - predictions = deeplc.core.finetune_and_predict(psm_list=psm_list, model=model) + predictions = deeplc.core.finetune_and_predict( + psm_list=psm_list, model=model, deduplicate_reference=dedup + ) else: - predictions = deeplc.core.predict_and_calibrate(psm_list=psm_list, model=model) + predictions = deeplc.core.predict_and_calibrate( + psm_list=psm_list, model=model, deduplicate_reference=dedup + ) else: predictions = deeplc.core.predict(psm_list=psm_list, model=model) diff --git a/deeplc/_reference_selection.py b/deeplc/_reference_selection.py index db6e995..1acae24 100644 --- a/deeplc/_reference_selection.py +++ b/deeplc/_reference_selection.py @@ -128,3 +128,103 @@ def _select_by_score(candidates: PSMList) -> PSMList: top_indices = np.argsort(scores)[::-1][:n_select] return candidates[top_indices] + + +def deduplicate_psms(psm_list: PSMList, ignore_charge: bool = True) -> PSMList: + """ + Keep one PSM per peptidoform, the first occurrence in the list. + + A reference set built from a search result usually contains the same peptidoform + identified in many spectra, with a different observed retention time each time. Those + repeats do not add information about the gradient: they give the calibration one x value + with several conflicting y values, and their number is what a spline fit weighs, so a + peptidoform seen 261 times counts 261 times while one seen once counts once. On a + published MS2Rescore case, 6,331 reference PSMs collapsed to 2,623 peptidoforms, and the + retention times of a repeated peptidoform differed by up to 236 minutes. + + Only the first observation is kept, which is what a caller who has already sorted or + filtered its PSMs expects, and it makes the result independent of how many times a + peptidoform happened to be identified. + + Parameters + ---------- + psm_list + PSMs to deduplicate. + ignore_charge + Treat charge states of the same peptidoform as duplicates (default). Retention time + does not depend on precursor charge, so the charge states of one peptidoform are + repeats of the same measurement. Set to False to keep one PSM per peptidoform *and* + charge. + + Returns + ------- + PSMList + The first PSM of every peptidoform, in the original order. + + """ + seen: set[str] = set() + keep = np.zeros(len(psm_list), dtype=bool) + for i, psm in enumerate(psm_list.psm_list): + key = str(psm.peptidoform) + if ignore_charge: + key = key.rsplit("/", 1)[0] + if key not in seen: + seen.add(key) + keep[i] = True + + n_dropped = int((~keep).sum()) + if n_dropped: + LOGGER.info( + "Deduplicated the reference: %d of %d PSMs are repeats of a peptidoform already " + "in the set and were dropped, leaving %d. Pass deduplicate_reference=False to " + "keep them.", + n_dropped, + len(psm_list), + int(keep.sum()), + ) + _warn_on_conflicting_retention_times(psm_list, keep, ignore_charge) + return psm_list[keep] + + +def _warn_on_conflicting_retention_times( + psm_list: PSMList, keep: np.ndarray, ignore_charge: bool +) -> None: + """ + Report the largest retention-time disagreement among the dropped repeats. + + A small spread is ordinary chromatographic jitter; a spread of the order of the gradient + means the repeats are not the same elution event, so the reference was built from PSMs + that a search would normally not put in one calibration set (decoys, low-scoring hits, or + several runs pooled into one file). Worth saying out loud, because deduplication then + hides a data problem rather than solving it. + """ + by_key: dict[str, list[float]] = {} + for psm in psm_list.psm_list: + rt = psm.retention_time + if rt is None or np.isnan(rt): + continue + key = str(psm.peptidoform) + if ignore_charge: + key = key.rsplit("/", 1)[0] + by_key.setdefault(key, []).append(float(rt)) + + spreads = [max(v) - min(v) for v in by_key.values() if len(v) > 1] + if not spreads: + return + worst = max(spreads) + observed = [ + float(psm.retention_time) + for psm in psm_list.psm_list + if psm.retention_time is not None and not np.isnan(psm.retention_time) + ] + span = (max(observed) - min(observed)) if observed else 0.0 + LOGGER.log( + logging.WARNING if span and worst > 0.25 * span else logging.INFO, + "Repeated peptidoforms disagreed on the observed retention time by up to %.2f " + "(median %.2f) against an observed range of %.2f. A large disagreement means the " + "repeats are not the same elution event: check whether the reference mixes runs or " + "includes low-confidence PSMs.", + worst, + float(np.median(spreads)), + span, + ) diff --git a/deeplc/core.py b/deeplc/core.py index f6d58e3..8d791a0 100644 --- a/deeplc/core.py +++ b/deeplc/core.py @@ -12,7 +12,7 @@ from torch.utils.data import DataLoader from deeplc import _model_ops -from deeplc._reference_selection import select_reference_psms +from deeplc._reference_selection import deduplicate_psms, select_reference_psms from deeplc.calibration import ( Calibration, SplineTransformerCalibration, @@ -143,6 +143,7 @@ def calibrate( model: torch.nn.Module | PathLike | str | None = None, calibration: Calibration | None = None, predict_kwargs: dict | None = None, + deduplicate_reference: bool = True, ) -> Calibration: """ Return a `Calibration` instance fitted to the reference dataset. @@ -157,6 +158,12 @@ def calibrate( Calibration instance to use. If None, SplineTransformerCalibration is used. predict_kwargs Additional keyword arguments to pass to the prediction function. + deduplicate_reference + Keep only the first PSM of every peptidoform in the reference (default). A reference + built from a search result repeats a peptidoform once per spectrum it was identified + in, each time with a different observed retention time; those repeats give the fit + conflicting targets and weigh peptidoforms by how often they happened to be + identified. Set to False to fit on every reference PSM as given. Returns ------- @@ -164,6 +171,9 @@ def calibrate( Fitted calibration instance. """ + if deduplicate_reference: + psm_list_reference = deduplicate_psms(psm_list_reference) + # Get calibration if calibration is None: LOGGER.debug("No calibration provided, using SplineTransformerCalibration by default.") @@ -212,6 +222,7 @@ def predict_and_calibrate( model: torch.nn.Module | PathLike | str | None = None, calibration: Calibration | None = None, predict_kwargs: dict | None = None, + deduplicate_reference: bool = True, ) -> np.ndarray: """ Predict retention times and calibrate to a reference. @@ -232,6 +243,13 @@ def predict_and_calibrate( predict_kwargs Additional keyword arguments to pass to the prediction function. + deduplicate_reference + Keep only the first PSM of every peptidoform in the reference (default). A reference + built from a search result repeats a peptidoform once per spectrum it was identified + in, each time with a different observed retention time; those repeats give the fit + conflicting targets and weigh peptidoforms by how often they happened to be + identified. Set to False to use every reference PSM as given. + Returns ------- np.ndarray @@ -266,6 +284,7 @@ def predict_and_calibrate( model=model, calibration=calibration, predict_kwargs=predict_kwargs, + deduplicate_reference=deduplicate_reference, ) else: LOGGER.info("Calibration is already fitted, skipping fitting step.") @@ -293,6 +312,7 @@ def finetune_and_predict( model: torch.nn.Module | PathLike | str | None = None, train_kwargs: dict | None = None, predict_kwargs: dict | None = None, + deduplicate_reference: bool = True, ) -> np.ndarray: """ Fine-tune the model to a reference and predict new retention times. @@ -313,6 +333,13 @@ def finetune_and_predict( predict_kwargs Additional keyword arguments to pass to the prediction function. + deduplicate_reference + Keep only the first PSM of every peptidoform in the reference (default). A reference + built from a search result repeats a peptidoform once per spectrum it was identified + in, each time with a different observed retention time; those repeats give the fit + conflicting targets and weigh peptidoforms by how often they happened to be + identified. Set to False to use every reference PSM as given. + Returns ------- np.ndarray @@ -331,6 +358,7 @@ def finetune_and_predict( psm_list_reference=parsed_psm_list_ref, model=model, train_kwargs=train_kwargs, + deduplicate_reference=deduplicate_reference, ) # Predict retention times with fine-tuned model @@ -347,6 +375,7 @@ def finetune_and_predict( psm_list_reference=parsed_psm_list_ref, model=finetuned_model, predict_kwargs=predict_kwargs, + deduplicate_reference=deduplicate_reference, ) # Apply calibration to predictions @@ -414,6 +443,7 @@ def finetune( validation_split: float = 0.1, model: torch.nn.Module | PathLike | str | None = None, train_kwargs: dict | None = None, + deduplicate_reference: bool = True, ) -> torch.nn.Module: """ Fine-tune an existing model. @@ -434,6 +464,12 @@ def finetune( Trained model or path to model file. train_kwargs Additional keyword arguments to pass to the training function. + deduplicate_reference + Keep only the first PSM of every peptidoform in the reference (default). A reference + built from a search result repeats a peptidoform once per spectrum it was identified + in, each time with a different observed retention time; those repeats give the fit + conflicting targets and weigh peptidoforms by how often they happened to be + identified. Set to False to fit on every reference PSM as given. Returns ------- @@ -443,6 +479,9 @@ def finetune( """ LOGGER.info("Fine-tuning model...") + if deduplicate_reference: + psm_list_reference = deduplicate_psms(psm_list_reference) + # Fine-tuning needs enough reference data to both fit and validate on. The # default validation split leaves too few PSMs to early-stop against on a small # reference set, which is how a fit ends up worse than the model it started diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 3f3a43a..3e19a5f 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -56,6 +56,17 @@ For calibration, pass a reference file with observed retention times using the ``--psm-file-reference`` option. If no reference file is provided, DeepLC attempts automatic calibration from high-confidence PSMs in the input file. +The reference is deduplicated before it is used: only the first PSM of each +peptidoform is kept, charge states included. A search result usually contains +the same peptidoform from many spectra with a different observed retention time +each time, and those repeats give the calibration conflicting targets while +weighing peptidoforms by how often they were identified. Pass +``--keep-duplicate-reference-psms`` (command line) or +``deduplicate_reference=False`` (Python API) to fit on every reference PSM +instead. When the repeats of a peptidoform disagree by a large fraction of the +observed retention-time range, DeepLC reports it: the reference then mixes runs +or contains low-confidence PSMs, and deduplication only hides that. + For a full list of options: .. code-block:: sh diff --git a/pyproject.toml b/pyproject.toml index 1267661..b96964c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deeplc" -version = "4.1.1" +version = "4.2.0" description = "DeepLC: Retention time prediction for (modified) peptides using Deep Learning." readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_deduplication.py b/tests/test_deduplication.py new file mode 100644 index 0000000..558fdd6 --- /dev/null +++ b/tests/test_deduplication.py @@ -0,0 +1,160 @@ +"""Reference deduplication: one PSM per peptidoform, the first observation.""" + +from __future__ import annotations + +import logging + +import numpy as np +from psm_utils import PSM, PSMList + +from deeplc import core +from deeplc._reference_selection import deduplicate_psms + +_PEPTIDES = [ + "AAGPSLSHTSGGTQSK", + "AGFAGDDAPR", + "AIQEYNQDK", + "AAYFGILEK", + "ADTQLDESSEQIDEEELTSK", + "AHQVVEDGYEFFAK", + "ALDQFVNFSEQK", + "AAPFSPAEK", + "VGAHAGEYGAEALER", + "LNLSPLGEEMR", +] + + +def _psms(pairs: list[tuple[str, float | None]], charge: int = 2) -> PSMList: + """Build a PSMList from (peptide, retention time) pairs.""" + return PSMList( + psm_list=[ + PSM(spectrum_id=str(i), peptidoform=f"{seq}/{charge}", retention_time=rt) + for i, (seq, rt) in enumerate(pairs) + ] + ) + + +def test_keeps_the_first_observation_of_each_peptidoform(): + """Repeats are dropped and the retention time of the first one survives.""" + psm_list = _psms([("PEPTIDEK", 10.0), ("PEPTIDEK", 40.0), ("ACDEFGHIK", 20.0)]) + + deduplicated = deduplicate_psms(psm_list) + + assert [str(p.peptidoform) for p in deduplicated] == ["PEPTIDEK/2", "ACDEFGHIK/2"] + assert [p.retention_time for p in deduplicated] == [10.0, 20.0] + + +def test_order_is_preserved(): + """The kept PSMs stay in the order they were given in.""" + psm_list = _psms([(s, float(i)) for i, s in enumerate(_PEPTIDES)]) + assert [str(p.peptidoform) for p in deduplicate_psms(psm_list)] == [ + str(p.peptidoform) for p in psm_list + ] + + +def test_idempotent(): + """Deduplicating an already deduplicated list changes nothing.""" + psm_list = _psms([("PEPTIDEK", 10.0), ("PEPTIDEK", 40.0), ("ACDEFGHIK", 20.0)]) + once = deduplicate_psms(psm_list) + assert len(deduplicate_psms(once)) == len(once) + + +def test_charge_states_are_duplicates_by_default(): + """Retention time does not depend on charge, so charge states are repeats.""" + psm_list = PSMList( + psm_list=[ + PSM(spectrum_id="1", peptidoform="PEPTIDEK/2", retention_time=10.0), + PSM(spectrum_id="2", peptidoform="PEPTIDEK/3", retention_time=11.0), + ] + ) + + assert len(deduplicate_psms(psm_list)) == 1 + assert len(deduplicate_psms(psm_list, ignore_charge=False)) == 2 + + +def test_modified_peptidoforms_are_not_duplicates(): + """A modification makes a different peptidoform, which elutes at a different time.""" + psm_list = PSMList( + psm_list=[ + PSM(spectrum_id="1", peptidoform="PEPTM[Oxidation]IDEK/2", retention_time=10.0), + PSM(spectrum_id="2", peptidoform="PEPTMIDEK/2", retention_time=12.0), + ] + ) + assert len(deduplicate_psms(psm_list)) == 2 + + +def test_warns_when_repeats_disagree_on_the_retention_time(caplog): + """ + A disagreement of the order of the gradient is a data problem, not jitter. + + Deduplication silently fixes the fit, so the case a user needs to hear about is when the + repeated peptidoform was not the same elution event at all. + """ + pairs = [(s, float(i)) for i, s in enumerate(_PEPTIDES)] + pairs.append((_PEPTIDES[0], 500.0)) # same peptidoform, 500 minutes later + + with caplog.at_level(logging.WARNING, logger="deeplc._reference_selection"): + deduplicate_psms(_psms(pairs)) + + assert any("disagreed on the observed retention time" in r.message for r in caplog.records) + + +def test_missing_retention_times_do_not_break_the_report(caplog): + """PSMs without an observed retention time are deduplicated like any other.""" + psm_list = _psms([("PEPTIDEK", None), ("PEPTIDEK", None), ("ACDEFGHIK", 20.0)]) + with caplog.at_level(logging.INFO, logger="deeplc._reference_selection"): + assert len(deduplicate_psms(psm_list)) == 2 + + +def _reference_with_duplicates() -> PSMList: + """Ten peptidoforms on a clean gradient, each repeated once at a wrong retention time.""" + pairs = [(s, 5.0 + 3.0 * i) for i, s in enumerate(_PEPTIDES)] + pairs += [(s, 100.0 - 2.0 * i) for i, s in enumerate(_PEPTIDES)] + return _psms(pairs) + + +def test_calibrate_uses_the_first_observations_by_default(): + """ + ``calibrate`` fits on the deduplicated reference by default. + + The reference holds each peptidoform twice: once on a clean 5 to 32 minute gradient and + once at a contradictory 82 to 100 minutes. A fit on the first observations must reproduce + the clean gradient, and a fit on every PSM must sit well above it, pulled by the repeats. + """ + reference = _reference_with_duplicates() + targets = _psms([(s, None) for s in _PEPTIDES]) + predicted = core.predict(targets, return_matrix=True) + + def fitted_mean(deduplicate: bool) -> float: + calibration = core.calibrate( + reference, predict_kwargs={"device": "cpu"}, deduplicate_reference=deduplicate + ) + head = calibration.selected_model_head or 0 + calibrated = calibration.transform(predicted[:, head]) + assert np.isfinite(calibrated).all() + return float(calibrated.mean()) + + clean_low, clean_high = 5.0, 5.0 + 3.0 * (len(_PEPTIDES) - 1) # the first observations + on, off = fitted_mean(True), fitted_mean(False) + + assert clean_low <= on <= clean_high, f"deduplicated fit {on:.1f} left the clean gradient" + assert off > clean_high, f"fit on all PSMs {off:.1f} was not pulled above the gradient" + + +def test_predict_and_calibrate_forwards_the_parameter(): + """Both settings run end to end and give one prediction per input PSM.""" + psm_list = _psms([(s, None) for s in _PEPTIDES]) + reference = _reference_with_duplicates() + + on = core.predict_and_calibrate( + psm_list, psm_list_reference=reference, predict_kwargs={"device": "cpu"} + ) + off = core.predict_and_calibrate( + psm_list, + psm_list_reference=reference, + predict_kwargs={"device": "cpu"}, + deduplicate_reference=False, + ) + + assert on.shape == off.shape == (len(_PEPTIDES),) + assert not np.allclose(on, off) From c188bf10dd6e48161ccea62e8fce5e912c71083a Mon Sep 17 00:00:00 2001 From: RobbinBouwmeester Date: Fri, 28 Aug 2026 11:23:23 +0200 Subject: [PATCH 2/4] docs: state the reference-duplication numbers in the input's own unit The reported case stores retention times in seconds (the psm_utils convention): identifications run from 243 to 597 s of a roughly ten-minute acquisition, so the worst disagreement between repeats of a peptidoform is 236 s, two thirds of the 354 s range, and the calibration improves from 9.69 to 4.51 s mean absolute error. DeepLC calibrates in whatever unit the input uses and converts nothing. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 ++++++---- deeplc/_reference_selection.py | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2741032..df1cb02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,12 @@ and this project adheres to spectrum it was identified in, each time with a different observed retention time, so the fit was given conflicting targets and weighed peptidoforms by how often they happened to be identified. On a reported MS2Rescore case (201,593 PSMs, one run) the reference selected by - auto-calibration held 6,331 PSMs but only 2,623 peptidoforms; the repeats disagreed on the - observed retention time by up to 236 minutes. Fitting on the first observations improved the - calibration on those 2,623 peptidoforms from 9.69 to 4.51 minutes mean absolute error - (median 5.98 to 2.09; within 5 minutes 44.9 % to 81.4 %). + auto-calibration held 6,331 PSMs but only 2,623 peptidoforms; the repeats of one peptidoform + disagreed on the observed retention time by 236 s, two thirds of the 354 s range covered by + the identifications. Fitting on the first observations improved the calibration on those + 2,623 peptidoforms from 9.69 to 4.51 s mean absolute error (median 5.98 to 2.09 s; within + 5 s 44.9 % to 81.4 %). Retention times are in whatever unit the input uses; DeepLC does not + convert them. Charge states of one peptidoform count as repeats, since retention time does not depend on precursor charge. When repeats disagree by a large fraction of the observed range, DeepLC diff --git a/deeplc/_reference_selection.py b/deeplc/_reference_selection.py index 1acae24..dab4bc7 100644 --- a/deeplc/_reference_selection.py +++ b/deeplc/_reference_selection.py @@ -139,8 +139,9 @@ def deduplicate_psms(psm_list: PSMList, ignore_charge: bool = True) -> PSMList: repeats do not add information about the gradient: they give the calibration one x value with several conflicting y values, and their number is what a spline fit weighs, so a peptidoform seen 261 times counts 261 times while one seen once counts once. On a - published MS2Rescore case, 6,331 reference PSMs collapsed to 2,623 peptidoforms, and the - retention times of a repeated peptidoform differed by up to 236 minutes. + reported MS2Rescore case, 6,331 reference PSMs collapsed to 2,623 peptidoforms, and the + observed retention times of one repeated peptidoform differed by two thirds of the whole + observed range. Only the first observation is kept, which is what a caller who has already sorted or filtered its PSMs expects, and it makes the result independent of how many times a From e077ad8f090139b9ff2a73ae0f0c8c3d460aa6e4 Mon Sep 17 00:00:00 2001 From: RobbinBouwmeester Date: Fri, 28 Aug 2026 14:07:52 +0200 Subject: [PATCH 3/4] refactor: deduplicate the reference unconditionally, without an option Review feedback on #116: keep the public signatures as they were rather than threading a flag through four functions and the command line. Deduplication now happens inside calibrate() and finetune() with no way to turn it off. It has to live there rather than in the reference-selection layer, because a caller that selects its own reference (MS2Rescore builds one from targets with q <= 0.01, or the top N by score) never passes through select_reference_psms, and that caller is exactly the reported case. The rare caller who wants every reference PSM to count still has a path that needs no parameter: fit a Calibration on its own targets and pass it to predict_and_calibrate, which uses an already fitted calibration as given. train() remains available for full control over a training set. Both are covered by tests. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++-- deeplc/__main__.py | 20 ++--------- deeplc/core.py | 45 ++++++------------------- docs/source/usage.rst | 14 ++++---- tests/test_deduplication.py | 67 ++++++++++++++++++++++--------------- 5 files changed, 64 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df1cb02..f8ca042 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,10 @@ and this project adheres to now says so: that means the reference mixes runs or contains low-confidence PSMs, which deduplication hides rather than fixes. - Pass `deduplicate_reference=False` to `calibrate`, `finetune`, `predict_and_calibrate` or - `finetune_and_predict`, or `--keep-duplicate-reference-psms` on the command line, to fit on - every reference PSM as before. + There is no option for it: the public functions keep the signature they had. A caller who + really wants every reference PSM to count fits a `Calibration` on its own targets and passes + it to `predict_and_calibrate`, which uses an already fitted calibration as given; `train` + remains available for full control over a training set. ### Added diff --git a/deeplc/__main__.py b/deeplc/__main__.py index 94212dc..a7f4967 100644 --- a/deeplc/__main__.py +++ b/deeplc/__main__.py @@ -126,14 +126,6 @@ def _validate_finetune(ctx, param, value): expose_value=True, help="Fine-tune the model to the reference before predicting. Requires --reference or --auto-calibrate.", # noqa: E501 ) -@click.option( - "--keep-duplicate-reference-psms", - is_flag=True, - default=False, - help="Fit the calibration (and fine-tuning) on every reference PSM, including repeats of " - "the same peptidoform. By default only the first PSM of each peptidoform is used, because " - "repeats carry conflicting observed retention times for one prediction.", -) @click.option("--output", "-o", type=str, default=None, help="Output file path.") @click.option( "--model", @@ -149,7 +141,6 @@ def predict( reference_filetype, auto_calibrate, finetune, - keep_duplicate_reference_psms, output, model, ): @@ -159,7 +150,6 @@ def predict( psm_list = _read_psm_file(psms, psm_filetype) output_path = _infer_output_name(psms, output) - dedup = not keep_duplicate_reference_psms if reference: psm_list_reference = _read_psm_file(reference, reference_filetype) @@ -168,24 +158,18 @@ def predict( psm_list=psm_list, psm_list_reference=psm_list_reference, model=model, - deduplicate_reference=dedup, ) else: predictions = deeplc.core.predict_and_calibrate( psm_list=psm_list, psm_list_reference=psm_list_reference, model=model, - deduplicate_reference=dedup, ) elif auto_calibrate: if finetune: - predictions = deeplc.core.finetune_and_predict( - psm_list=psm_list, model=model, deduplicate_reference=dedup - ) + predictions = deeplc.core.finetune_and_predict(psm_list=psm_list, model=model) else: - predictions = deeplc.core.predict_and_calibrate( - psm_list=psm_list, model=model, deduplicate_reference=dedup - ) + predictions = deeplc.core.predict_and_calibrate(psm_list=psm_list, model=model) else: predictions = deeplc.core.predict(psm_list=psm_list, model=model) diff --git a/deeplc/core.py b/deeplc/core.py index 8d791a0..af3dd44 100644 --- a/deeplc/core.py +++ b/deeplc/core.py @@ -143,7 +143,6 @@ def calibrate( model: torch.nn.Module | PathLike | str | None = None, calibration: Calibration | None = None, predict_kwargs: dict | None = None, - deduplicate_reference: bool = True, ) -> Calibration: """ Return a `Calibration` instance fitted to the reference dataset. @@ -158,12 +157,6 @@ def calibrate( Calibration instance to use. If None, SplineTransformerCalibration is used. predict_kwargs Additional keyword arguments to pass to the prediction function. - deduplicate_reference - Keep only the first PSM of every peptidoform in the reference (default). A reference - built from a search result repeats a peptidoform once per spectrum it was identified - in, each time with a different observed retention time; those repeats give the fit - conflicting targets and weigh peptidoforms by how often they happened to be - identified. Set to False to fit on every reference PSM as given. Returns ------- @@ -171,8 +164,12 @@ def calibrate( Fitted calibration instance. """ - if deduplicate_reference: - psm_list_reference = deduplicate_psms(psm_list_reference) + # One point per peptidoform: a reference taken from a search result repeats a + # peptidoform once per spectrum it was identified in, each time with a different observed + # retention time, which gives the fit conflicting targets and weighs peptidoforms by how + # often they happened to be identified. A caller who wants the repeats to count fits a + # Calibration itself and passes it in already fitted. + psm_list_reference = deduplicate_psms(psm_list_reference) # Get calibration if calibration is None: @@ -222,7 +219,6 @@ def predict_and_calibrate( model: torch.nn.Module | PathLike | str | None = None, calibration: Calibration | None = None, predict_kwargs: dict | None = None, - deduplicate_reference: bool = True, ) -> np.ndarray: """ Predict retention times and calibrate to a reference. @@ -243,12 +239,6 @@ def predict_and_calibrate( predict_kwargs Additional keyword arguments to pass to the prediction function. - deduplicate_reference - Keep only the first PSM of every peptidoform in the reference (default). A reference - built from a search result repeats a peptidoform once per spectrum it was identified - in, each time with a different observed retention time; those repeats give the fit - conflicting targets and weigh peptidoforms by how often they happened to be - identified. Set to False to use every reference PSM as given. Returns ------- @@ -284,7 +274,6 @@ def predict_and_calibrate( model=model, calibration=calibration, predict_kwargs=predict_kwargs, - deduplicate_reference=deduplicate_reference, ) else: LOGGER.info("Calibration is already fitted, skipping fitting step.") @@ -312,7 +301,6 @@ def finetune_and_predict( model: torch.nn.Module | PathLike | str | None = None, train_kwargs: dict | None = None, predict_kwargs: dict | None = None, - deduplicate_reference: bool = True, ) -> np.ndarray: """ Fine-tune the model to a reference and predict new retention times. @@ -333,12 +321,6 @@ def finetune_and_predict( predict_kwargs Additional keyword arguments to pass to the prediction function. - deduplicate_reference - Keep only the first PSM of every peptidoform in the reference (default). A reference - built from a search result repeats a peptidoform once per spectrum it was identified - in, each time with a different observed retention time; those repeats give the fit - conflicting targets and weigh peptidoforms by how often they happened to be - identified. Set to False to use every reference PSM as given. Returns ------- @@ -358,7 +340,6 @@ def finetune_and_predict( psm_list_reference=parsed_psm_list_ref, model=model, train_kwargs=train_kwargs, - deduplicate_reference=deduplicate_reference, ) # Predict retention times with fine-tuned model @@ -375,7 +356,6 @@ def finetune_and_predict( psm_list_reference=parsed_psm_list_ref, model=finetuned_model, predict_kwargs=predict_kwargs, - deduplicate_reference=deduplicate_reference, ) # Apply calibration to predictions @@ -443,7 +423,6 @@ def finetune( validation_split: float = 0.1, model: torch.nn.Module | PathLike | str | None = None, train_kwargs: dict | None = None, - deduplicate_reference: bool = True, ) -> torch.nn.Module: """ Fine-tune an existing model. @@ -464,12 +443,6 @@ def finetune( Trained model or path to model file. train_kwargs Additional keyword arguments to pass to the training function. - deduplicate_reference - Keep only the first PSM of every peptidoform in the reference (default). A reference - built from a search result repeats a peptidoform once per spectrum it was identified - in, each time with a different observed retention time; those repeats give the fit - conflicting targets and weigh peptidoforms by how often they happened to be - identified. Set to False to fit on every reference PSM as given. Returns ------- @@ -479,8 +452,10 @@ def finetune( """ LOGGER.info("Fine-tuning model...") - if deduplicate_reference: - psm_list_reference = deduplicate_psms(psm_list_reference) + # One point per peptidoform, as in calibrate(): training on the same peptidoform several + # times with contradictory retention times teaches the model the average of a + # disagreement. Use deeplc.train() for full control over the training set. + psm_list_reference = deduplicate_psms(psm_list_reference) # Fine-tuning needs enough reference data to both fit and validate on. The # default validation split leaves too few PSMs to early-stop against on a small diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 3e19a5f..2149d97 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -60,12 +60,14 @@ The reference is deduplicated before it is used: only the first PSM of each peptidoform is kept, charge states included. A search result usually contains the same peptidoform from many spectra with a different observed retention time each time, and those repeats give the calibration conflicting targets while -weighing peptidoforms by how often they were identified. Pass -``--keep-duplicate-reference-psms`` (command line) or -``deduplicate_reference=False`` (Python API) to fit on every reference PSM -instead. When the repeats of a peptidoform disagree by a large fraction of the -observed retention-time range, DeepLC reports it: the reference then mixes runs -or contains low-confidence PSMs, and deduplication only hides that. +weighing peptidoforms by how often they were identified. This is unconditional; +a caller that really wants every reference PSM to count fits a +:class:`~deeplc.calibration.Calibration` on its own targets and passes it to +:func:`~deeplc.core.predict_and_calibrate`, which uses an already fitted +calibration as given. When the repeats of a peptidoform disagree by a large +fraction of the observed retention-time range, DeepLC reports it: the reference +then mixes runs or contains low-confidence PSMs, and deduplication only hides +that. For a full list of options: diff --git a/tests/test_deduplication.py b/tests/test_deduplication.py index 558fdd6..edc204a 100644 --- a/tests/test_deduplication.py +++ b/tests/test_deduplication.py @@ -9,6 +9,7 @@ from deeplc import core from deeplc._reference_selection import deduplicate_psms +from deeplc.calibration import SplineTransformerCalibration _PEPTIDES = [ "AAGPSLSHTSGGTQSK", @@ -113,48 +114,60 @@ def _reference_with_duplicates() -> PSMList: return _psms(pairs) -def test_calibrate_uses_the_first_observations_by_default(): +def test_calibrate_always_uses_the_first_observations(): """ - ``calibrate`` fits on the deduplicated reference by default. + ``calibrate`` fits one point per peptidoform; there is no switch to turn that off. The reference holds each peptidoform twice: once on a clean 5 to 32 minute gradient and - once at a contradictory 82 to 100 minutes. A fit on the first observations must reproduce - the clean gradient, and a fit on every PSM must sit well above it, pulled by the repeats. + once at a contradictory 82 to 100 minutes. The fit must reproduce the clean gradient. """ reference = _reference_with_duplicates() targets = _psms([(s, None) for s in _PEPTIDES]) - predicted = core.predict(targets, return_matrix=True) - def fitted_mean(deduplicate: bool) -> float: - calibration = core.calibrate( - reference, predict_kwargs={"device": "cpu"}, deduplicate_reference=deduplicate - ) - head = calibration.selected_model_head or 0 - calibrated = calibration.transform(predicted[:, head]) - assert np.isfinite(calibrated).all() - return float(calibrated.mean()) + calibration = core.calibrate(reference, predict_kwargs={"device": "cpu"}) + predicted = core.predict(targets, return_matrix=True) + calibrated = calibration.transform(predicted[:, calibration.selected_model_head or 0]) - clean_low, clean_high = 5.0, 5.0 + 3.0 * (len(_PEPTIDES) - 1) # the first observations - on, off = fitted_mean(True), fitted_mean(False) + assert np.isfinite(calibrated).all() + clean_low, clean_high = 5.0, 5.0 + 3.0 * (len(_PEPTIDES) - 1) + mean = float(calibrated.mean()) + assert clean_low <= mean <= clean_high, f"fit at {mean:.1f} left the clean gradient" - assert clean_low <= on <= clean_high, f"deduplicated fit {on:.1f} left the clean gradient" - assert off > clean_high, f"fit on all PSMs {off:.1f} was not pulled above the gradient" +def test_a_prefitted_calibration_is_the_way_to_keep_the_repeats(): + """ + The escape hatch for the rare caller who wants every reference PSM to count. -def test_predict_and_calibrate_forwards_the_parameter(): - """Both settings run end to end and give one prediction per input PSM.""" - psm_list = _psms([(s, None) for s in _PEPTIDES]) + ``calibrate`` deduplicates unconditionally, so a caller who wants the repeats weighed fits + a ``Calibration`` on its own targets and passes it in; ``predict_and_calibrate`` then uses + it as given instead of fitting one. + """ reference = _reference_with_duplicates() + psm_list = _psms([(s, None) for s in _PEPTIDES]) + + source = core.predict(reference, predict_kwargs={"device": "cpu"}, return_matrix=True) + own = SplineTransformerCalibration() + own.selected_model_head = 0 + own.fit(target=np.array(reference["retention_time"], dtype=np.float32), source=source[:, 0]) - on = core.predict_and_calibrate( + kept = core.predict_and_calibrate( + psm_list, psm_list_reference=reference, calibration=own, predict_kwargs={"device": "cpu"} + ) + deduplicated = core.predict_and_calibrate( psm_list, psm_list_reference=reference, predict_kwargs={"device": "cpu"} ) - off = core.predict_and_calibrate( + + assert kept.shape == deduplicated.shape == (len(_PEPTIDES),) + assert not np.allclose(kept, deduplicated) + + +def test_predict_and_calibrate_runs_on_a_duplicated_reference(): + """One prediction per input PSM, in the input order, whatever the reference looks like.""" + psm_list = _psms([(s, None) for s in _PEPTIDES]) + result = core.predict_and_calibrate( psm_list, - psm_list_reference=reference, + psm_list_reference=_reference_with_duplicates(), predict_kwargs={"device": "cpu"}, - deduplicate_reference=False, ) - - assert on.shape == off.shape == (len(_PEPTIDES),) - assert not np.allclose(on, off) + assert result.shape == (len(_PEPTIDES),) + assert np.isfinite(result).all() From 04947a31d0e58bfbc4598b7453fa017dbb99ec9d Mon Sep 17 00:00:00 2001 From: RobbinBouwmeester Date: Fri, 28 Aug 2026 14:11:50 +0200 Subject: [PATCH 4/4] fix: key the deduplication on Peptidoform.modified_sequence Review feedback on #116. modified_sequence is the ProForma string without the charge state, so psm_utils decides what identifies a peptidoform instead of this module cutting the charge off the string. It keeps terminal, global and labile modifications apart, and it cannot be tripped up by a modification label that contains a slash, which the previous rsplit would have truncated. Charge adducts (/2 against /2[+2H]) also collapse correctly now. modified_sequence exists since psm_utils 1.1.0 and DeepLC already requires >= 1.5, so no dependency change is needed. Two tests added: seven peptidoforms that differ only in terminal, global or labile modifications stay distinct, and a charge adduct does not create a second peptidoform. Co-Authored-By: Claude Opus 5 (1M context) --- deeplc/_reference_selection.py | 16 ++++++++------ tests/test_deduplication.py | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/deeplc/_reference_selection.py b/deeplc/_reference_selection.py index dab4bc7..54c710a 100644 --- a/deeplc/_reference_selection.py +++ b/deeplc/_reference_selection.py @@ -166,9 +166,13 @@ def deduplicate_psms(psm_list: PSMList, ignore_charge: bool = True) -> PSMList: seen: set[str] = set() keep = np.zeros(len(psm_list), dtype=bool) for i, psm in enumerate(psm_list.psm_list): - key = str(psm.peptidoform) - if ignore_charge: - key = key.rsplit("/", 1)[0] + # modified_sequence is the ProForma string without the charge state, so it keeps the + # terminal and global modifications that distinguish two peptidoforms while ignoring + # charge. Taking it from psm_utils also avoids cutting the string ourselves, which a + # modification label containing a slash would break. + key = psm.peptidoform.modified_sequence + if not ignore_charge: + key = f"{key}/{psm.peptidoform.precursor_charge}" if key not in seen: seen.add(key) keep[i] = True @@ -204,9 +208,9 @@ def _warn_on_conflicting_retention_times( rt = psm.retention_time if rt is None or np.isnan(rt): continue - key = str(psm.peptidoform) - if ignore_charge: - key = key.rsplit("/", 1)[0] + key = psm.peptidoform.modified_sequence + if not ignore_charge: + key = f"{key}/{psm.peptidoform.precursor_charge}" by_key.setdefault(key, []).append(float(rt)) spreads = [max(v) - min(v) for v in by_key.values() if len(v) > 1] diff --git a/tests/test_deduplication.py b/tests/test_deduplication.py index edc204a..2c1e2fe 100644 --- a/tests/test_deduplication.py +++ b/tests/test_deduplication.py @@ -84,6 +84,44 @@ def test_modified_peptidoforms_are_not_duplicates(): assert len(deduplicate_psms(psm_list)) == 2 +def test_exotic_peptidoforms_keep_their_identity(): + """ + The key is ``Peptidoform.modified_sequence``, so nothing but charge is ignored. + + Terminal, global and labile modifications all distinguish two peptidoforms, and a + modification label may itself contain a slash, which is why the charge is not cut off the + ProForma string by hand. + """ + distinct = [ + "PEPTIDEK/2", + "[Acetyl]-PEPTIDEK/2", + "PEPTIDEK-[Amidated]/2", + "PEPTM[Oxidation]IDEK/2", + "PEPT[Phospho]IDEK/2", + "<[Carbamidomethyl]@C>PEPCTIDEK/2", + "PROT[Phospho|+79.966]EIN/2", + ] + psm_list = PSMList( + psm_list=[ + PSM(spectrum_id=str(i), peptidoform=pf, retention_time=10.0 + i) + for i, pf in enumerate(distinct) + ] + ) + + assert len(deduplicate_psms(psm_list)) == len(distinct) + + +def test_charge_adducts_do_not_create_a_second_peptidoform(): + """``/2`` and ``/2[+2H]`` are the same peptidoform measured twice.""" + psm_list = PSMList( + psm_list=[ + PSM(spectrum_id="1", peptidoform="PEPTIDEK/2", retention_time=10.0), + PSM(spectrum_id="2", peptidoform="PEPTIDEK/2[+2H]", retention_time=11.0), + ] + ) + assert len(deduplicate_psms(psm_list)) == 1 + + def test_warns_when_repeats_disagree_on_the_retention_time(caplog): """ A disagreement of the order of the gradient is a data problem, not jitter.