diff --git a/docs/api/datasets.md b/docs/api/datasets.md index da56a50e..c73e1439 100644 --- a/docs/api/datasets.md +++ b/docs/api/datasets.md @@ -87,11 +87,17 @@ dataset.save(Path("output_directory")) - **Filtering**: Removes invalid tokens and unsupported modifications - **Evaluation**: Computes correctness labels when ground truth available +#### Leucine and isoleucine + +Winnow treats leucine and isoleucine as interchangeable when computing training labels (`correct`, `num_matches`) and beam edit distance because they are structural isomers, so standard mass spectrometry cannot differentiate them by precursor mass alone. + +By default, stored `prediction`, labelled `sequence`, and beam `ScoredSequence.sequence` keep the source residue (`L` or `I`). Set `output_leucine_as_isoleucine=True` on any `DatasetLoader` (or `data_loader.output_leucine_as_isoleucine=true` in the CLI) to restore the legacy behaviour of rewriting stored tokens with `L` → `I`. + ### Data loaders The datasets module provides several data loaders that implement the `DatasetLoader` protocol. -All loaders require `residue_masses` (ProForma residue → mass; see `configs/residues.yaml`). Loaders that ingest tool-specific peptide notation also take `residue_remapping`. +All loaders require `residue_masses` (ProForma residue → mass; see `configs/residues.yaml`). Loaders that ingest tool-specific peptide notation also take `residue_remapping`. Each loader accepts `output_leucine_as_isoleucine` (default `false`; see above). #### InstaNovoDatasetLoader diff --git a/docs/api/features/beam.md b/docs/api/features/beam.md index 35fdc90c..0068ccaf 100644 --- a/docs/api/features/beam.md +++ b/docs/api/features/beam.md @@ -56,6 +56,6 @@ The dataset must have beam predictions available (`dataset.predictions` must not - When beam size is 1, margin and entropy default to 0; `edit_distance` defaults to `1.0` - Entropy is computed on the **normalised** runner-up probabilities (excluding top-1) - Z-score uses the full beam including top-1 for mean/std calculation -- Edit distance collapses I/L via the same leucine normalisation used elsewhere in the package (`L` → `I`) +- Edit distance collapses I/L at comparison time (`L` → `I` on copies of the top-1 and top-2 token lists); stored beam sequences are not rewritten unless `data_loader.output_leucine_as_isoleucine=true` - Both sequences empty is treated as undefined (`1.0`), not as raw Levenshtein zero - All probability values are derived from `exp(sequence_log_probability)` diff --git a/docs/cli.md b/docs/cli.md index b68978a4..8205a907 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -215,7 +215,7 @@ Set `diagnostics.fail_on_warning=true` to exit with code 1 when $|\widehat{\math ### `winnow annotate-proteome-hits` -Load spectra and *de novo* predictions via a `DatasetLoader`, drop peptides shorter than a residue threshold, and add a boolean `proteome_hit` column by matching each prediction (modifications stripped, isoleucine normalised to leucine) as a substring of a reference proteome. The annotated holdout is written as a Winnow dataset directory (`metadata.csv` plus optional `predictions.pkl`). +Load spectra and *de novo* predictions via a `DatasetLoader`, drop peptides shorter than a residue threshold, and add a boolean `proteome_hit` column by substring matching against a reference proteome. Matching collapses leucine and isoleucine to the same residue on both the prediction (after stripping modifications) and each FASTA sequence. Stored peptide sequences in the output keep their original residue identity unless you set `data_loader.output_leucine_as_isoleucine=true`. The annotated holdout is written as a Winnow dataset directory (`metadata.csv` plus optional `predictions.pkl`). This produces the `proteome_hit` column consumed by `diagnose-calibration` with `data_loader=winnow` and `diagnostics.label_source=precomputed diagnostics.label_column=proteome_hit`. Training does not use `proteome_hit`; the column may sit alongside sequence-derived labels without changing train behaviour. Prediction accepts datasets with or without the column. diff --git a/docs/configuration.md b/docs/configuration.md index 54325107..d41a2b28 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -532,7 +532,7 @@ You must choose exactly one labelling mode. The command validates config before ### Main config (`configs/annotate_proteome_hits.yaml`) -Loads a *de novo* prediction dataset, filters short peptides, adds `proteome_hit` via FASTA substring matching, and writes a Winnow dataset directory. See the [CLI reference](cli.md#winnow-annotate-proteome-hits). +Loads a *de novo* prediction dataset, filters short peptides, adds `proteome_hit` via FASTA substring matching, and writes a Winnow dataset directory, stripping post-translational modifications and treating leucine and isoleucine as equivalent. See the [CLI reference](cli.md#winnow-annotate-proteome-hits). ```yaml defaults: @@ -594,6 +594,7 @@ Each data format has a dedicated loader configuration in `configs/data_loader/`: _target_: winnow.datasets.data_loaders.InstaNovoDatasetLoader add_index_cols: false # If true, add experiment_name + spectrum_id for parquet/ipc (InstaNovo-style). MGF always gets these columns. residue_masses: ${residue_masses} +output_leucine_as_isoleucine: false # false: L/I as in source in exports; true: L shown as I in output tokens (legacy) residue_remapping: "M(ox)": "M[UNIMOD:35]" "C(+57.02)": "C[UNIMOD:4]" diff --git a/tests/datasets/data_loaders/test_instanovo.py b/tests/datasets/data_loaders/test_instanovo.py index 54cece4d..c41fb43d 100644 --- a/tests/datasets/data_loaders/test_instanovo.py +++ b/tests/datasets/data_loaders/test_instanovo.py @@ -17,6 +17,7 @@ def _finalize(loader, metadata, *, has_labels: bool = True): loader.metrics, has_labels=has_labels, residue_remapping=loader.metrics.residue_set.residue_remapping, + output_leucine_as_isoleucine=loader.output_leucine_as_isoleucine, ) @@ -542,8 +543,33 @@ def test_process_beams_produces_scored_sequences(self, loader): assert hasattr(beams[0][0], "sequence") assert hasattr(beams[0][0], "sequence_log_probability") - def test_process_beams_replaces_l_with_i_in_sequences(self, loader): - """L amino acid must be replaced with I at token level after split.""" + def test_process_beams_preserves_l_by_default(self, loader): + beam_df = pl.DataFrame( + { + "predictions_beam_0": ["PEPTLDE"], + "predictions_log_probability_beam_0": [-0.5], + "predictions_token_log_probabilities_0": [ + "[-0.1, -0.2, -0.3, -0.4, -0.5, -0.6, -0.7]" + ], + } + ) + beams = loader._process_beams(beam_df) + assert beams[0] is not None + assert "L" in beams[0][0].sequence + + def test_process_beams_replaces_l_with_i_when_legacy_flag( + self, full_residue_masses, standard_remapping + ): + loader = InstaNovoDatasetLoader( + residue_masses=full_residue_masses, + residue_remapping=standard_remapping, + output_leucine_as_isoleucine=True, + beam_columns={ + "sequence": "predictions_beam_", + "log_probability": "predictions_log_probability_beam_", + "token_log_probabilities": "predictions_token_log_probabilities_", + }, + ) beam_df = pl.DataFrame( { "predictions_beam_0": ["PEPTLDE"], @@ -799,7 +825,27 @@ def test_process_predictions_splits_comma_separated_prediction(self, loader): result = loader._process_predictions(preds_df, ["spectrum_id"]) assert result["prediction"].iloc[0] == ["P", "E", "P", "T", "I", "D", "E"] - def test_finalize_replaces_l_with_i_in_prediction_list(self, loader): + def test_finalize_preserves_l_in_prediction_list_by_default(self, loader): + preds_df = pd.DataFrame( + { + "spectrum_id": [1], + "predictions": ["PEPTLDE"], + "predictions_tokenised": ["P, E, P, T, L, D, E"], + "log_probs": [-0.5], + } + ) + processed = loader._process_predictions(preds_df, ["spectrum_id"]) + finalized = _finalize(loader, processed, has_labels=False) + assert "L" in finalized["prediction"].iloc[0] + + def test_finalize_replaces_l_with_i_when_legacy_flag( + self, full_residue_masses, standard_remapping + ): + loader = InstaNovoDatasetLoader( + residue_masses=full_residue_masses, + residue_remapping=standard_remapping, + output_leucine_as_isoleucine=True, + ) preds_df = pd.DataFrame( { "spectrum_id": [1], diff --git a/tests/datasets/data_loaders/test_mztab.py b/tests/datasets/data_loaders/test_mztab.py index 28b627a3..53c42e88 100644 --- a/tests/datasets/data_loaders/test_mztab.py +++ b/tests/datasets/data_loaders/test_mztab.py @@ -18,15 +18,21 @@ def _finalize(loader, metadata, *, has_labels: bool = True): loader.metrics, has_labels=has_labels, residue_remapping=loader.metrics.residue_set.residue_remapping, + output_leucine_as_isoleucine=loader.output_leucine_as_isoleucine, ) -def _normalize_peptide(loader, value: object) -> list[str] | None: +def _normalize_peptide( + loader, value: object, *, output_leucine_as_isoleucine: bool | None = None +) -> list[str] | None: """Normalize a peptide cell using loader metrics (string or token list).""" + if output_leucine_as_isoleucine is None: + output_leucine_as_isoleucine = loader.output_leucine_as_isoleucine return data_utils.normalize_peptide_cell( value, loader.metrics, residue_remapping=loader.metrics.residue_set.residue_remapping, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ) @@ -428,9 +434,16 @@ def test_process_predictions_preserves_modification_names_in_untokenised( ) assert result["prediction_untokenised"][0] == "PEPTC[Carbamidomethyl]DE" - def test_normalize_replaces_leucine_at_token_level(self, loader): + def test_normalize_preserves_leucine_by_default(self, loader): tokens = _normalize_peptide(loader, "PEPTLDE") assert tokens is not None + assert "L" in tokens + + def test_normalize_replaces_leucine_when_legacy_flag(self, loader): + tokens = _normalize_peptide( + loader, "PEPTLDE", output_leucine_as_isoleucine=True + ) + assert tokens is not None assert "L" not in tokens assert "I" in tokens @@ -504,8 +517,7 @@ def test_normalize_remaps_carbamidomethyl_without_corrupting_name(self, loader): tokens = _normalize_peptide(loader, "GEEHC[Carbamidomethyl]GHLLQAHK") assert tokens is not None assert "C[UNIMOD:4]" in tokens - assert "L" not in tokens - assert tokens.count("I") == 2 + assert tokens.count("L") == 2 def test_normalize_unmodified_sequence_passes_through(self, loader): assert _normalize_peptide(loader, "ACGM") == ["A", "C", "G", "M"] @@ -893,6 +905,16 @@ def test_evaluate_correct_flag_false_on_different_sequence(self, loader): result = _finalize(loader, metadata, has_labels=True) assert not result["correct"][0] + def test_evaluate_correct_flag_true_on_l_vs_i_match(self, loader): + metadata = pl.DataFrame( + { + "sequence": [["P", "E", "P", "T", "L", "D", "E"]], + "prediction": [["P", "E", "P", "T", "I", "D", "E"]], + } + ) + result = _finalize(loader, metadata, has_labels=True) + assert result["correct"][0] + def test_evaluate_correct_flag_false_on_length_mismatch(self, loader): metadata = pl.DataFrame( {"sequence": [["P", "E", "P"]], "prediction": [["P", "E"]]} diff --git a/tests/datasets/data_loaders/test_primenovo.py b/tests/datasets/data_loaders/test_primenovo.py index 291fe924..a89cc513 100644 --- a/tests/datasets/data_loaders/test_primenovo.py +++ b/tests/datasets/data_loaders/test_primenovo.py @@ -376,7 +376,24 @@ def test_load_tokenises_and_remaps_via_finalize(self, loader, mgf_path, tsv_path assert "M[UNIMOD:35]" in row["prediction"] assert "M[+15.995]" in row["prediction_untokenised"] - def test_load_applies_token_level_l_to_i(self, loader, mgf_path, tsv_path): + def test_load_preserves_l_in_prediction_by_default( + self, loader, mgf_path, tsv_path + ): + dataset = loader.load(data_path=mgf_path, predictions_path=tsv_path) + meta = dataset.metadata + row = meta.set_index("spectrum_id").loc["spectra:run_X_SCANS_1"] + assert "L" in row["prediction"] + assert "L" in row["prediction_untokenised"] + + def test_load_applies_token_level_l_to_i_when_legacy_flag( + self, full_residue_masses, mgf_path, tsv_path + ): + loader = PrimeNovoDatasetLoader( + residue_masses=full_residue_masses, + residue_remapping=_PRIMENOVO_REMAPPING, + mid_sequence_n_terminal_mods=_MID_SEQUENCE_N_TERMINAL_MODS, + output_leucine_as_isoleucine=True, + ) dataset = loader.load(data_path=mgf_path, predictions_path=tsv_path) meta = dataset.metadata row = meta.set_index("spectrum_id").loc["spectra:run_X_SCANS_1"] diff --git a/tests/datasets/data_loaders/test_utils.py b/tests/datasets/data_loaders/test_utils.py index 0b3cf456..f1b5597d 100644 --- a/tests/datasets/data_loaders/test_utils.py +++ b/tests/datasets/data_loaders/test_utils.py @@ -16,6 +16,7 @@ "P": 97.052764, "E": 129.042593, "L": 113.084064, + "I": 113.084064, } REMAPPING = {"M[Oxidation]": "M[UniMod:35]"} @@ -107,15 +108,24 @@ def test_string_splits_and_remaps(self, metrics: Metrics) -> None: ) assert tokens == ["A", "G"] - def test_leucine_mapped_at_token_level(self, metrics: Metrics) -> None: + def test_leucine_preserved_by_default(self, metrics: Metrics) -> None: tokens = data_utils.normalize_peptide_cell( ["A", "L"], metrics, residue_remapping=REMAPPING, ) + assert tokens == ["A", "L"] + + def test_leucine_mapped_when_legacy_flag_true(self, metrics: Metrics) -> None: + tokens = data_utils.normalize_peptide_cell( + ["A", "L"], + metrics, + residue_remapping=REMAPPING, + output_leucine_as_isoleucine=True, + ) assert tokens == ["A", "I"] - def test_string_leucine_mapped_without_corrupting_mod_names( + def test_string_leucine_legacy_without_corrupting_mod_names( self, metrics: Metrics ) -> None: """Uppercase L inside a modification name must not be rewritten before split.""" @@ -124,6 +134,7 @@ def test_string_leucine_mapped_without_corrupting_mod_names( "AC[Labelled]L", metrics, residue_remapping=remapping, + output_leucine_as_isoleucine=True, ) assert tokens == ["A", "C[UniMod:999]", "I"] @@ -294,6 +305,26 @@ def test_row_num_matches_and_correct(self, metrics: Metrics) -> None: 1, ["A", "G"], ["A", "G"], sequence_valid=True, prediction_valid=True ) + def test_row_num_matches_treats_l_and_i_as_equivalent( + self, metrics: Metrics + ) -> None: + sequence = ["A", "L", "G"] + prediction = ["A", "I", "G"] + assert data_utils.row_num_matches( + sequence, + prediction, + metrics, + sequence_valid=True, + prediction_valid=True, + ) == len(sequence) + assert data_utils.row_is_correct( + len(sequence), + sequence, + prediction, + sequence_valid=True, + prediction_valid=True, + ) + class TestFinalizePeptideMetadata: @pytest.fixture() diff --git a/tests/utils/test_proteome.py b/tests/utils/test_proteome.py index 48979a37..b2a91dec 100644 --- a/tests/utils/test_proteome.py +++ b/tests/utils/test_proteome.py @@ -15,6 +15,7 @@ from winnow.utils.config_path import get_config_dir from winnow.utils.proteome import ( annotate_calibration_dataset, + normalize_sequence, processed_peptide_for_match, residue_set_from_residues_yaml, residue_token_count, @@ -33,16 +34,16 @@ def residue_masses(residue_set: ResidueSet) -> dict[str, float]: def test_processed_peptide_for_match_strips_mods() -> None: cases = [ - ("PEP(+123.45)TIDE[UNIMOD:35]K", "PEPTLDEK"), - ("[UNIMOD:1]-PEP(+123.45)TIDE[UNIMOD:35]K", "PEPTLDEK"), + ("PEP(+123.45)TIDE[UNIMOD:35]K", "PEPTIDEK"), + ("[UNIMOD:1]-PEP(+123.45)TIDE[UNIMOD:35]K", "PEPTIDEK"), ("PEP(foo)TAGDE", "PEPTAGDE"), ("(+47.01)-PEPTAGDE", "PEPTAGDE"), ("PEPTAGDE[Carboxyl]", "PEPTAGDE"), ("[Acetyl]-PEPTAGDE", "PEPTAGDE"), ("(N-term)PEPTAGDE", "PEPTAGDE"), - ("PEP+15.99TIDE", "PEPTLDE"), - ("PEP-15.99TIDE", "PEPTLDE"), - ("PEP15.99TIDE", "PEPTLDE"), + ("PEP+15.99TIDE", "PEPTIDE"), + ("PEP-15.99TIDE", "PEPTIDE"), + ("PEP15.99TIDE", "PEPTIDE"), ("+42.01-PEPTAGDE", "PEPTAGDE"), ] for raw, expected in cases: @@ -52,6 +53,44 @@ def test_processed_peptide_for_match_strips_mods() -> None: assert out == expected, raw +def test_processed_peptide_for_match_maps_leucine_to_isoleucine() -> None: + assert processed_peptide_for_match("PEPTLDE") == "PEPTIDE" + + +def test_processed_peptide_for_match_unimod_stripped_before_leucine_map() -> None: + """Bracketed UNIMOD must be removed before L → I so names are not corrupted.""" + assert processed_peptide_for_match("PEPTC[UNIMOD:4]DE") == "PEPTCDE" + + +def test_normalize_sequence_maps_leucine_to_isoleucine() -> None: + assert normalize_sequence("PEPTLDE") == "PEPTIDE" + assert normalize_sequence("PEPTIDE") == "PEPTIDE" + + +def test_annotate_l_query_hits_fasta_with_isoleucine( + residue_set: ResidueSet, tmp_path: Path +) -> None: + dataset = CalibrationDataset( + metadata=pd.DataFrame( + { + "spectrum_id": ["a"], + "prediction": [["P", "E", "P", "T", "L", "D", "E"]], + "prediction_untokenised": ["PEPTLDE"], + "confidence": [0.9], + "mz_array": [[100.0]], + "intensity_array": [[1.0]], + } + ) + ) + fasta_path = tmp_path / "proteome.fasta" + fasta_path.write_text(">prot1\nXXXXXXMKLLPEPTIDEMKLLYYYY\n") + + annotated, _stats = annotate_calibration_dataset( + dataset, fasta_path, residue_set, min_residue_length=7 + ) + assert annotated.metadata["proteome_hit"].tolist() == [True] + + @pytest.mark.parametrize( ("prediction", "expected"), [ diff --git a/winnow/calibration/features/beam.py b/winnow/calibration/features/beam.py index bfe78bc0..735dbc56 100644 --- a/winnow/calibration/features/beam.py +++ b/winnow/calibration/features/beam.py @@ -8,7 +8,7 @@ from winnow.calibration.features.base import CalibrationFeatures, FeatureDependency from winnow.datasets.calibration_dataset import CalibrationDataset from winnow.calibration.features.utils import require_beam_predictions -from winnow.datasets.data_loaders.utils import _normalize_leucine_tokens +from winnow.datasets.data_loaders.utils import replace_leucine_with_isoleucine def _beam_len(beam: Optional[List]) -> int: @@ -75,8 +75,8 @@ def _beam_edit_distance(beam: Optional[List]) -> float: if not top_seq and not second_seq: return 1.0 return _normalised_levenshtein( - _normalize_leucine_tokens(top_seq), - _normalize_leucine_tokens(second_seq), + replace_leucine_with_isoleucine(top_seq), + replace_leucine_with_isoleucine(second_seq), ) diff --git a/winnow/configs/data_loader/instanovo.yaml b/winnow/configs/data_loader/instanovo.yaml index 9741b238..18799dfd 100644 --- a/winnow/configs/data_loader/instanovo.yaml +++ b/winnow/configs/data_loader/instanovo.yaml @@ -7,6 +7,9 @@ _target_: winnow.datasets.data_loaders.InstaNovoDatasetLoader add_index_cols: false residue_masses: ${residue_masses} +# false: prediction, sequence, and beams keep L and I from the source. +# true: stored tokens show I wherever the source had L (legacy). +output_leucine_as_isoleucine: false residue_remapping: # Used to map InstaNovo legacy notations to UNIMOD tokens. "M(ox)": "M[UNIMOD:35]" # Oxidation "M(+15.99)": "M[UNIMOD:35]" # Oxidation diff --git a/winnow/configs/data_loader/mztab.yaml b/winnow/configs/data_loader/mztab.yaml index 350df58d..61f0c5da 100644 --- a/winnow/configs/data_loader/mztab.yaml +++ b/winnow/configs/data_loader/mztab.yaml @@ -2,6 +2,9 @@ _target_: winnow.datasets.data_loaders.MZTabDatasetLoader residue_masses: ${residue_masses} +# false: prediction, sequence, and beams keep L and I from the source. +# true: stored tokens show I wherever the source had L (legacy). +output_leucine_as_isoleucine: false # Build ScoredSequence beam lists from Casanovo mzTab (multiple PSMs per spectrum). # Set false for traditional database-search mzTab (loader raises if true). diff --git a/winnow/configs/data_loader/pointnovo.yaml b/winnow/configs/data_loader/pointnovo.yaml index 022691a9..16aef011 100644 --- a/winnow/configs/data_loader/pointnovo.yaml +++ b/winnow/configs/data_loader/pointnovo.yaml @@ -3,3 +3,6 @@ _target_: winnow.datasets.data_loaders.PointNovoDatasetLoader residue_masses: ${residue_masses} +# false: prediction and sequence keep L and I from the source. +# true: stored token lists show I wherever the source had L (legacy). +output_leucine_as_isoleucine: false diff --git a/winnow/configs/data_loader/primenovo.yaml b/winnow/configs/data_loader/primenovo.yaml index 603d8fc1..0d64f2fd 100644 --- a/winnow/configs/data_loader/primenovo.yaml +++ b/winnow/configs/data_loader/primenovo.yaml @@ -2,6 +2,9 @@ _target_: winnow.datasets.data_loaders.PrimeNovoDatasetLoader residue_masses: ${residue_masses} +# false: prediction, sequence, and beams keep L and I from the source. +# true: stored tokens show I wherever the source had L (legacy). +output_leucine_as_isoleucine: false residue_remapping: # Used to map pi-PrimeNovo-specific notations to UNIMOD tokens. "M[+15.995]": "M[UNIMOD:35]" # Oxidation diff --git a/winnow/configs/data_loader/winnow.yaml b/winnow/configs/data_loader/winnow.yaml index 8bf7a371..7a0251a9 100644 --- a/winnow/configs/data_loader/winnow.yaml +++ b/winnow/configs/data_loader/winnow.yaml @@ -3,6 +3,9 @@ _target_: winnow.datasets.data_loaders.WinnowDatasetLoader residue_masses: ${residue_masses} +# false: prediction and sequence keep L and I from the source. +# true: stored token lists show I wherever the source had L (legacy). +output_leucine_as_isoleucine: false # The internal Winnow dataset loader does not need a residue remapping # since it uses the UNIMOD tokens directly. residue_remapping: null diff --git a/winnow/datasets/data_loaders/instanovo.py b/winnow/datasets/data_loaders/instanovo.py index 019bc7d9..58e92c65 100644 --- a/winnow/datasets/data_loaders/instanovo.py +++ b/winnow/datasets/data_loaders/instanovo.py @@ -40,6 +40,7 @@ def __init__( beam_columns: Optional[dict[str, str]] = None, add_index_cols: bool = False, column_mapping: Optional[dict[str, str]] = None, + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the InstaNovoDatasetLoader. @@ -55,7 +56,14 @@ def __init__( names produced by the InstaNovo version you are loading. Defaults are ``{"predictions": "predictions", "predictions_tokenised": "predictions_tokenised", "log_probability": "log_probs"}``. + output_leucine_as_isoleucine: When True, rewrite ``L`` → ``I`` in ``prediction``, + ``sequence``, and beam token lists on the returned + ``CalibrationDataset`` (legacy behaviour). When False (default), + those outputs keep the source ``L`` or ``I``. Label matching + (``correct``, ``num_matches``) always treats leucine and + isoleucine as interchangeable regardless of this flag. """ + self.output_leucine_as_isoleucine = output_leucine_as_isoleucine self.metrics = Metrics( residue_set=ResidueSet( residue_masses=residue_masses, residue_remapping=residue_remapping @@ -186,6 +194,7 @@ def load( self.metrics, has_labels=has_labels, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=self.output_leucine_as_isoleucine, ) return CalibrationDataset(metadata=predictions, predictions=beams) @@ -305,9 +314,11 @@ def convert_row_to_scored_sequences( if sequence and log_prob > float("-inf"): tokens = self.metrics._split_peptide(sequence) + if self.output_leucine_as_isoleucine: + tokens = data_utils.replace_leucine_with_isoleucine(tokens) scored_sequences.append( ScoredSequence( - sequence=data_utils._normalize_leucine_tokens(tokens), + sequence=tokens, mass_error=None, sequence_log_probability=log_prob, token_log_probabilities=ast.literal_eval(token_log_prob) diff --git a/winnow/datasets/data_loaders/mztab.py b/winnow/datasets/data_loaders/mztab.py index 9216a8c4..8ebb7f8a 100644 --- a/winnow/datasets/data_loaders/mztab.py +++ b/winnow/datasets/data_loaders/mztab.py @@ -70,6 +70,7 @@ def __init__( isotope_error_range: Tuple[int, int] = (0, 1), load_beams: bool = True, column_mapping: Optional[dict[str, Optional[str]]] = None, + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the MZTabDatasetLoader. @@ -83,7 +84,12 @@ def __init__( column_mapping: Maps logical roles to mzTab column headers. See module docstring and ``_DEFAULT_COLUMN_MAPPING``. Missing mapped columns fail fast with available headers listed. + output_leucine_as_isoleucine: When True, rewrite ``L`` → ``I`` in returned + ``prediction`` / ``sequence`` token lists (legacy). Default + keeps source ``L`` or ``I``; ``correct`` / ``num_matches`` always + treat leucine and isoleucine as interchangeable. """ + self.output_leucine_as_isoleucine = output_leucine_as_isoleucine self.metrics = Metrics( residue_set=ResidueSet( residue_masses=residue_masses, residue_remapping=residue_remapping @@ -337,6 +343,7 @@ def load( self.metrics, has_labels=False, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=self.output_leucine_as_isoleucine, ) top_predictions = self._get_top_predictions(predictions, is_casanovo) @@ -346,6 +353,7 @@ def load( self.metrics, has_labels=has_labels, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=self.output_leucine_as_isoleucine, ) metadata_pd = metadata.to_pandas() diff --git a/winnow/datasets/data_loaders/pointnovo.py b/winnow/datasets/data_loaders/pointnovo.py index c8b00412..adeb5ede 100644 --- a/winnow/datasets/data_loaders/pointnovo.py +++ b/winnow/datasets/data_loaders/pointnovo.py @@ -20,6 +20,7 @@ def __init__( residue_masses: dict[str, float], residue_remapping: dict[str, str] | None = None, isotope_error_range: tuple[int, int] = (0, 1), + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the loader with the common dataset-loader options. @@ -27,7 +28,12 @@ def __init__( but defining the initializer makes this a concrete implementation of the ``DatasetLoader`` protocol on Python 3.10. """ - del residue_masses, residue_remapping, isotope_error_range + del ( + residue_masses, + residue_remapping, + isotope_error_range, + output_leucine_as_isoleucine, + ) def load( self, *, data_path: Path, predictions_path: Path | None = None, **kwargs: Any diff --git a/winnow/datasets/data_loaders/primenovo.py b/winnow/datasets/data_loaders/primenovo.py index 25911175..733a34d7 100644 --- a/winnow/datasets/data_loaders/primenovo.py +++ b/winnow/datasets/data_loaders/primenovo.py @@ -86,6 +86,7 @@ def __init__( mid_sequence_n_terminal_mods: list[str], residue_remapping: dict[str, str], isotope_error_range: Tuple[int, int] = (0, 1), + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the PrimeNovoDatasetLoader. @@ -99,7 +100,12 @@ def __init__( residue_remapping: Optional ProForma mapping from pi-PrimeNovo tokens. isotope_error_range: The range of isotope errors to consider when matching peptides. + output_leucine_as_isoleucine: When True, rewrite ``L`` → ``I`` in returned + ``prediction`` / ``sequence`` token lists (legacy). Default + keeps source ``L`` or ``I``; ``correct`` / ``num_matches`` always + treat leucine and isoleucine as interchangeable. """ + self.output_leucine_as_isoleucine = output_leucine_as_isoleucine self.metrics = Metrics( residue_set=ResidueSet( residue_masses=residue_masses, residue_remapping=residue_remapping @@ -201,6 +207,7 @@ def load( self.metrics, has_labels=has_labels, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=self.output_leucine_as_isoleucine, ) metadata_pd = metadata.to_pandas() diff --git a/winnow/datasets/data_loaders/utils.py b/winnow/datasets/data_loaders/utils.py index ec3131fd..39b50631 100644 --- a/winnow/datasets/data_loaders/utils.py +++ b/winnow/datasets/data_loaders/utils.py @@ -127,8 +127,16 @@ def add_index_cols(df: pl.DataFrame, fp: Path | str) -> pl.DataFrame: _add_index_cols_fn = add_index_cols -def _normalize_leucine_tokens(tokens: list[str]) -> list[str]: - """Map leucine to isoleucine at the token level.""" +def replace_leucine_with_isoleucine(tokens: list[str]) -> list[str]: + """Return a copy of ``tokens`` with leucine rewritten as isoleucine since they are structural isomers that Winnow treats as equivalent when judging peptide correctness. + + Args: + tokens: ProForma residue tokens (e.g. from :func:`normalize_peptide_cell`). + + Returns: + A new list where each exact token ``L`` is ``I``; all other tokens are + unchanged. + """ return ["I" if token == "L" else token for token in tokens] @@ -136,10 +144,10 @@ def _apply_token_postprocessing( tokens: list[str], *, residue_remapping: dict[str, str], - normalize_leucine: bool = True, + output_leucine_as_isoleucine: bool = False, ) -> list[str]: - if normalize_leucine: - tokens = _normalize_leucine_tokens(tokens) + if output_leucine_as_isoleucine: + tokens = replace_leucine_with_isoleucine(tokens) return [residue_remapping.get(token, token) for token in tokens] @@ -148,7 +156,7 @@ def normalize_peptide_cell( metrics: Metrics, *, residue_remapping: dict[str, str], - normalize_leucine: bool = True, + output_leucine_as_isoleucine: bool = False, require_label: bool = False, ) -> list[str] | None: """Normalize one peptide cell to ProForma token list, or ``None`` if absent/empty. @@ -157,7 +165,8 @@ def normalize_peptide_cell( value: Raw or tokenized cell from pandas or polars. metrics: InstaNovo metrics for string tokenization. residue_remapping: Modification token remapping table. - normalize_leucine: When True, map ``L`` → ``I`` at token level. + output_leucine_as_isoleucine: When True, apply + :func:`replace_leucine_with_isoleucine` to stored token lists. require_label: When True, return ``None`` for cells that fail :func:`is_usable_peptide_label` before tokenization (ground truth). """ @@ -176,7 +185,7 @@ def normalize_peptide_cell( tokens = _apply_token_postprocessing( tokens, residue_remapping=residue_remapping, - normalize_leucine=normalize_leucine, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ) return tokens if tokens else None @@ -187,12 +196,14 @@ def _normalize_peptide_column_pandas( *, residue_remapping: dict[str, str], require_label: bool, + output_leucine_as_isoleucine: bool, ) -> pd.Series: return series.apply( lambda value: normalize_peptide_cell( value, metrics, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, require_label=require_label, ) ) @@ -204,12 +215,14 @@ def _normalize_peptide_column_polars( *, residue_remapping: dict[str, str], require_label: bool, + output_leucine_as_isoleucine: bool, ) -> pl.Series: return series.map_elements( lambda value: normalize_peptide_cell( value, metrics, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, require_label=require_label, ), return_dtype=pl.List(pl.Utf8), @@ -308,6 +321,7 @@ def _finalize_peptide_metadata_pandas( *, has_labels: bool, residue_remapping: dict[str, str], + output_leucine_as_isoleucine: bool, sequence_col: str, prediction_col: str, score_col: str, @@ -317,6 +331,7 @@ def _finalize_peptide_metadata_pandas( metrics, residue_remapping=residue_remapping, require_label=False, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ) metadata["valid_prediction"] = metadata[prediction_col].apply( is_valid_peptide_tokens @@ -331,6 +346,7 @@ def _finalize_peptide_metadata_pandas( metrics, residue_remapping=residue_remapping, require_label=True, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ) metadata["valid_sequence"] = metadata[sequence_col].apply(is_valid_peptide_tokens) @@ -356,6 +372,7 @@ def _finalize_peptide_metadata_polars( *, has_labels: bool, residue_remapping: dict[str, str], + output_leucine_as_isoleucine: bool, sequence_col: str, prediction_col: str, score_col: str, @@ -366,6 +383,7 @@ def _finalize_peptide_metadata_polars( metrics, residue_remapping=residue_remapping, require_label=False, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ).alias(prediction_col), ).with_columns( pl.col(prediction_col) @@ -383,6 +401,7 @@ def _finalize_peptide_metadata_polars( metrics, residue_remapping=residue_remapping, require_label=True, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, ).alias(sequence_col), ).with_columns( pl.col(sequence_col) @@ -423,6 +442,7 @@ def finalize_peptide_metadata( *, has_labels: bool, residue_remapping: dict[str, str] | None = None, + output_leucine_as_isoleucine: bool = False, sequence_col: str = "sequence", prediction_col: str = "prediction", score_col: str = "confidence", @@ -436,6 +456,27 @@ def finalize_peptide_metadata( When ``score_col`` is present, rows with ``None``/NaN scores are marked ``valid_prediction=False`` in addition to token-level checks. The score check is skipped if the column is absent. + + Args: + metadata: PSM metadata with peptide columns to normalise. + metrics: InstaNovo metrics for tokenisation and residue matching. + has_labels: When True, normalise ``sequence_col``, set ``valid_sequence``, + and compute ``num_matches`` / ``correct``. When False, only + ``prediction_col`` and ``valid_prediction`` are updated. + residue_remapping: Input notation to ProForma token mapping. Defaults to + ``metrics.residue_set.residue_remapping``. + output_leucine_as_isoleucine: When True, apply + :func:`replace_leucine_with_isoleucine` to stored token lists + (legacy behaviour). Correctness labels always treat ``L`` and ``I`` + as equivalent regardless of this flag. + sequence_col: Ground-truth peptide column name. + prediction_col: Predicted peptide column name. + score_col: Confidence or score column used to mark invalid predictions + when values are missing or NaN. + + Returns: + The input frame (pandas or polars) with normalised peptide columns and + validity / match columns added or updated in place. """ if residue_remapping is None: residue_remapping = metrics.residue_set.residue_remapping @@ -446,6 +487,7 @@ def finalize_peptide_metadata( metrics, has_labels=has_labels, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, sequence_col=sequence_col, prediction_col=prediction_col, score_col=score_col, @@ -455,6 +497,7 @@ def finalize_peptide_metadata( metrics, has_labels=has_labels, residue_remapping=residue_remapping, + output_leucine_as_isoleucine=output_leucine_as_isoleucine, sequence_col=sequence_col, prediction_col=prediction_col, score_col=score_col, @@ -538,10 +581,16 @@ def row_num_matches( sequence_valid: bool, prediction_valid: bool, ) -> int: - """Count residue matches between tokenized sequence and prediction.""" + """Count residue matches between tokenized sequence and prediction. + + Leucine and isoleucine are treated as equivalent (``L`` → ``I`` on copies + only; stored token lists are unchanged). + """ if not sequence_valid or not prediction_valid or not sequence or not prediction: return 0 - return metrics._novor_match(sequence, prediction) + sequence_for_il_match = replace_leucine_with_isoleucine(sequence) + prediction_for_il_match = replace_leucine_with_isoleucine(prediction) + return metrics._novor_match(sequence_for_il_match, prediction_for_il_match) def row_is_correct( diff --git a/winnow/datasets/data_loaders/winnow.py b/winnow/datasets/data_loaders/winnow.py index 5f8009f1..3c070c75 100644 --- a/winnow/datasets/data_loaders/winnow.py +++ b/winnow/datasets/data_loaders/winnow.py @@ -25,8 +25,10 @@ def __init__( residue_masses: dict[str, float], residue_remapping: Optional[dict[str, str]] = None, isotope_error_range: Tuple[int, int] = (0, 1), + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the WinnowDatasetLoader.""" + self.output_leucine_as_isoleucine = output_leucine_as_isoleucine self.metrics = Metrics( residue_set=ResidueSet( residue_masses=residue_masses, residue_remapping=residue_remapping @@ -90,6 +92,7 @@ def load( self.metrics, has_labels=has_labels, residue_remapping=self.metrics.residue_set.residue_remapping, + output_leucine_as_isoleucine=self.output_leucine_as_isoleucine, ) predictions_pkl_path = data_path / Path("predictions.pkl") diff --git a/winnow/datasets/interfaces.py b/winnow/datasets/interfaces.py index 116706f1..a7ba5bd9 100644 --- a/winnow/datasets/interfaces.py +++ b/winnow/datasets/interfaces.py @@ -19,6 +19,7 @@ def __init__( residue_masses: dict[str, float], residue_remapping: dict[str, str] | None = None, isotope_error_range: Tuple[int, int] = (0, 1), + output_leucine_as_isoleucine: bool = False, ) -> None: """Initialise the DatasetLoader. @@ -26,6 +27,11 @@ def __init__( residue_masses: The mapping of residues to their masses (ProForma notation). residue_remapping: Optional mapping of input notations to ProForma notation. Defaults to None. isotope_error_range: The range of isotope errors to consider when matching peptides. Defaults to (0, 1). + output_leucine_as_isoleucine: When True, rewrite ``L`` → ``I`` in ``prediction`` and + ``sequence`` token lists on the returned ``CalibrationDataset`` + (legacy behaviour). When False (default), those outputs keep the + source residue. ``correct`` / ``num_matches`` always treat ``L`` + and ``I`` as interchangeable regardless of this flag. """ ... diff --git a/winnow/utils/proteome.py b/winnow/utils/proteome.py index 9da0d3ad..92842d90 100644 --- a/winnow/utils/proteome.py +++ b/winnow/utils/proteome.py @@ -35,14 +35,18 @@ class ProteomeAnnotationCounts: def normalize_sequence(sequence: str) -> str: - """Normalise a peptide sequence by replacing I with L.""" + """Map leucine to isoleucine for I/L-equivalent substring matching.""" if sequence: - return sequence.replace("I", "L") + return sequence.replace("L", "I") return sequence def load_proteome_haystack(fasta_file: Path | str) -> str: - """Load a FASTA file into a string for substring matching.""" + """Load a FASTA file into a string for substring matching. + + Each record is passed through :func:`normalize_sequence` so ``I`` and ``L`` + match interchangeably against query peptides. + """ path = Path(fasta_file) if not path.is_file(): raise FileNotFoundError(f"FASTA file not found: {path}") @@ -56,7 +60,10 @@ def load_proteome_haystack(fasta_file: Path | str) -> str: def processed_peptide_for_match(prediction: str) -> str: - """Strip mods and normalise I/L for proteome substring matching. + """Strip mods then map leucine to isoleucine for proteome substring matching. + + Modifications are removed before any ``L`` → ``I`` replacement so bracketed + names (e.g. ``UNIMOD``) are not corrupted. Modifications of the following forms are stripped: - Round brackets (e.g. "(+43.006)")