Skip to content

fix: collapse I/L for comparison only, never in a reported column - #247

Closed
BioGeek wants to merge 1 commit into
mainfrom
fix/il-collapse-for-matching-not-reporting
Closed

BioGeek wants to merge 1 commit into
mainfrom
fix/il-collapse-for-matching-not-reporting

Conversation

@BioGeek

@BioGeek BioGeek commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Every peptide winnow publishes has its leucines rewritten to isoleucine. Measured over 20,000 human spectra:

isoleucine share of I/L
InstaNovo's predictions going in 30.4%
the ground-truth labels 34.2%
winnow's published prediction and sequence columns 100%

Not one leucine survived either column.

Why that is a bug rather than a convention

Collapsing I and L belongs to comparison. The two residues are isobaric, so a prediction differing from its label only in I/L is still correct, and scoring should say so. Applying the collapse to the columns themselves is a different thing: it puts a residue in the output that was never predicted.

The downstream consequence is not cosmetic. ALAHKYH was published as AIAHKYH, which no longer matches the protein it came from, so the peptide-to-protein step reported it as needing an I/L substitution to map — a substitution the model never made. In a pipeline that reports a mass-equivalent misread as a novel peptide, that is the most consequential direction to be wrong in. On the same 20,000 spectra it put 76% of mapped peptides into an I/L-dependent tier; with this fix the figure is 8.3%, and exact matches go from 662 to 2,932.

It also collapses in the opposite direction to everything around it. InstaNovo and the peptide registry rewrite I into L; winnow rewrote L into I. A key built under one convention matches nothing under the other, and the failure mode is an empty result rather than a wrong one.

What changes

normalize_peptide_cell canonicalises modification notation only and leaves residues as they were given.

The collapse moves to the two row-evaluation functions, which are the only places a prediction is compared against a label. num_matches and correct are therefore unchanged — verified by the existing tests, which pass untouched where they assert on scoring.

_normalize_leucine_tokens becomes replace_isoleucine_with_leucine, named for the residue that disappears. The direction is the whole content of the operation and both directions are in use across these repositories, so a name that does not state it is a trap. It now follows InstaNovo and the registry by collapsing I into L; direction does not affect any comparison here, since both sides are collapsed the same way.

Beam features keep the collapse. _beam_edit_distance compares two beam sequences against each other and never reports them, so it is comparison and stays.

The normalize_leucine parameter of _apply_token_postprocessing goes, since there is no longer a caller that wants residues rewritten on the way in.

Tests

Seven tests asserted the old behaviour and now assert the new one — that a published column keeps the spelling it was given, and that scoring still treats I and L as equal.

tests/datasets/data_loaders/   212 passed
rest of the suite              460 passed

Context

Found while tracing a figure that could not be reconciled: the pipeline reported that 76% of mapped peptides needed an I/L substitution, which is far more than InstaNovo's measured I/L accuracy predicts (92% balanced accuracy over 21,829 forced choices on labelled data). The discrepancy was this rewrite.

Downstream issue: instadeepai/INFlow#132. The same audit found sibling faults in InstaNovo's mapping loader and in InstaNexus's assembled scaffolds, both fixed separately.

Every peptide winnow published had its leucines rewritten to isoleucine.
Measured over 20,000 human spectra: instanovo's predictions going in were
30.4% isoleucine, the ground-truth labels 34.2%, and the published
prediction and sequence columns 100% -- not one leucine survived either.

The collapse belongs to comparison, where the two residues being isobaric
means a prediction differing only in I/L is still correct. Applying it to
the columns themselves put a residue in the output that was never
predicted: ALAHKYH was published as AIAHKYH, no longer matched the protein
it came from, and was reported downstream as a peptide needing an I/L
substitution to map. The model had it right.

normalize_peptide_cell now canonicalises modification notation only, and
leaves residues as they were given. The collapse moves to the two row
evaluation functions, which is the only place a prediction is compared
against a label, so num_matches and correct are unchanged.

_normalize_leucine_tokens becomes replace_isoleucine_with_leucine: named
for the residue that disappears, because the direction is the whole
content of the operation and both directions are in use across these
repositories. It now follows instanovo and the peptide registry, which
collapse I into L. Direction does not affect any comparison here, since
both sides are collapsed the same way.

Beam features keep the collapse. Those sequences are compared against each
other and never reported.

Seven tests asserted the old behaviour and now assert the new one. The
shared loader fixture gained "L", which it had never needed while
everything was being rewritten to "I"; the production residues.yaml has
defined both all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C8sMwNQJTyWwJB5Qn7vjuW
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

Coverage

Coverage Report
FileStmtsMissCoverMissing
__init__.py00100% 
data_types.py40100% 
calibration
   __init__.py00100% 
   calibration_features.py90100% 
   calibrator.py4963293%61, 64, 79, 84–85, 203, 302–303, 305, 308, 353–354, 495–496, 552, 616–617, 621, 626, 692–695, 1069–1070, 1221, 1225, 1251, 1255, 1264, 1281, 1286
   diagnostics.py1384468%71, 156–171, 214–215, 219, 260, 262–277, 288–294
calibration/features
   __init__.py100100% 
   base.py26196%84
   beam.py900100% 
   chimeric.py82198%213
   constants.py90100% 
   fragment_match.py78198%203
   mass_error.py68494%17, 21, 37–38
   retention_time.py206896%111, 114–115, 226, 237, 351, 354, 435
   sequence.py190100% 
   token_score.py37197%82
   utils.py261398%96, 368, 594
compat
   __init__.py00100% 
   instanovo.py10640%12, 14–15, 17, 24–25
datasets
   __init__.py00100% 
   calibration_dataset.py1441589%228, 238, 247, 260, 340, 342–343, 349–352, 354–357
   feature_dataset.py84495%21, 88, 183–184
   interfaces.py30100% 
   psm_dataset.py250100% 
datasets/data_loaders
   __init__.py60100% 
   instanovo.py1051684%90, 93, 119, 142, 168–169, 172–174, 176–177, 179, 182–184, 191
   mztab.py1793083%103, 106, 157, 161, 210–211, 223, 268, 271, 283–284, 317–318, 320–321, 323, 325, 331, 334–335, 342–344, 351, 353–356, 360, 501
   pointnovo.py90100% 
   primenovo.py1211091%121, 124, 211, 242, 254, 261–262, 373, 384, 389
   utils.py184398%25, 175, 459
   winnow.py41587%55–56, 86, 100–101
fdr
   __init__.py00100% 
   base.py581574%81, 85–86, 91, 98–99, 105, 126, 129–130, 135, 137–138, 144, 186
   database_grounded.py37197%100
   nonparametric.py26484%64, 70–71, 74
scripts
   __init__.py00100% 
   main.py47035424%23–26, 59, 61–62, 67–68, 78, 90, 95–97, 99–100, 102, 114, 119–120, 122–123, 125–126, 148, 152, 166–167, 182–183, 186, 195–196, 198, 215–216, 233, 252–253, 255–257, 259–260, 262–264, 266, 271, 296, 298–302, 323–324, 326–327, 330–331, 336, 341–342, 376–378, 380, 382, 387, 389, 393–395, 397, 399–401, 403, 405, 407–408, 411, 416, 418, 425–427, 432–435, 437–438, 440, 442, 457–458, 460–462, 464–469, 471, 485–486, 488–490, 492–493, 495–496, 498, 500–501, 508–511, 525, 527, 529–530, 532, 540–542, 544, 549, 555, 569, 571, 573–574, 576, 584–586, 588–589, 591–595, 597, 633–635, 637–640, 642, 646–649, 727, 748, 762, 780–784, 786–789, 815–817, 819, 821, 826, 828, 832–834, 836, 839, 841–844, 846, 854–856, 858, 860, 862, 865–867, 869–871, 878, 895–897, 900, 903, 908, 910, 913, 917–919, 921, 925–927, 929, 931–932, 937, 944, 952, 954, 956–957, 959, 961, 969–971, 973, 975–976, 978–979, 981–982, 984–985, 992–994, 1000, 1009–1010, 1012–1013, 1020–1022, 1024, 1026, 1031, 1033, 1036, 1040–1042, 1044–1045, 1048, 1051, 1053, 1059–1060, 1062–1063, 1067, 1074, 1082, 1084, 1086–1088, 1095–1097, 1099–1101, 1104, 1109, 1114, 1116–1117, 1119–1121, 1124, 1129, 1139, 1146, 1148, 1152, 1154–1155, 1159–1160, 1162, 1171–1174, 1176–1177, 1179, 1181, 1186, 1188–1190, 1192, 1195, 1197, 1203–1206, 1208–1209, 1211–1212, 1214–1215, 1217, 1223, 1230–1232, 1234, 1273–1274, 1315–1316, 1357–1358, 1388–1389, 1417–1418, 1448–1449, 1476–1477, 1507–1508, 1532–1533, 1557–1558
utils
   __init__.py40100% 
   config_formatter.py534024%29, 37–38, 40–42, 44, 55, 58–60, 62–63, 66–69, 72–74, 77–78, 80, 91, 102, 113, 127–128, 130–132, 145–147, 150, 153–154, 157–158, 160
   config_path.py76593%24–26, 117–118
   hydra_overrides.py12283%12, 15
   irt_calibration_config.py895735%13–14, 21–24, 32–39, 43–44, 52–55, 57–60, 62, 64–65, 77–78, 96–103, 114–117, 124–125, 140–143, 146, 148–149, 153, 155–156, 163, 165–166, 197
   koina_intensity_config.py903363%42, 68, 76, 78, 108, 111, 131, 160–172, 177–178, 182–183, 189–192, 201–202, 204–205, 209
   paths.py210100% 
   peptide.py69395%37–38, 94
   proteome.py1151289%41, 48, 68, 79, 84, 88, 119, 144–146, 174, 180
   rich_console.py70100% 
TOTAL357171080% 

Tests Skipped Failures Errors Time
672 0 💤 0 ❌ 0 🔥 31.157s ⏱️

@BioGeek BioGeek mentioned this pull request Oct 9, 2026
5 tasks
@BioGeek

BioGeek commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Checked against the paper reproduction scripts (#240)

#240 adds 28 scripts under paper_scripts/ that read winnow's output, so they are the largest consumer of the columns this PR changes. Checked all of them: none needs changing, and the reason is structural rather than luck, so it should hold as those scripts evolve.

No API breakage. _normalize_leucine_tokens, the normalize_leucine parameter, and normalize_peptide_cell have zero occurrences in paper_scripts/ or Makefile.paper.

Seven scripts already collapse I→L on their own side before comparing — analyze_fdr_overlap, analyze_novelty, analyze_upscored_fps, annotate_preds_proteome_hits, evaluate_calibrator_generalisation, fdr_tool_comparison_preprocess, plot_fdr_method_comparison. .replace("I", "L") maps both residues to L, so today's all-isoleucine columns and tomorrow's mixed ones produce the same key, and both sides of each comparison — including the proteome haystacks — get the same treatment.

The one script comparing the columns directly is mass-based. run_external_peptide_holdout_benchmark calls novor_correctness_mask(sequence, prediction) without normalising, which is where this would have broken. It routes through InstaNovo's Metrics._novor_match, which compares cumulative residue masses rather than letters, and I and L are isobaric — so an I-against-L difference cannot move the match count.

One real consequence: plot_feature_investigation.py prints the prediction as a figure label, so a peptide shown as AIAHKYH becomes ALAHKYH. The numbers behind the panel do not change. That is this PR working — the figure currently shows a residue the model never predicted — but worth flagging if that panel is already in a draft.

Noted on #240 as well.

@JemmaLDaniel

JemmaLDaniel commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

This is a valuable addition to Winnow, but I have some issues with the design of the fix.

  • Firstly, this fix is not backwards compatible. Without this, previous results may not be reproducible in the current codebase because peptides that were remapping on loading before feature computation are now retained unchanged. When passing peptides to external models as part of feature computation (I am thinking in particular about indexed retention time prediction), this can alter the returned predictions and therefore Winnow's ultimate prediction. I would prefer a user-controllable toggle for L -> I remapping that preserves historical behaviour if needed.
  • The handling of beams is inconsistent with the top-1 peptide in metadata, and also inconsistent across data loaders. InstaNovo beams are remapped to leucine, but predictions stored in metadata are preserved unchanged. Casanovo avoids this by creating beams from metadata, so both are preserved as the source file wrote them. Depending on what a feature uses (metadata or beams), this introduces discrepancies that I would prefer to prevent at the data loading level.

)
sequence_valid = bool(row["valid_sequence"])
prediction_valid = bool(row["valid_prediction"])
num_matches = row_num_matches(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put the I/L remapping inside row_num_matches because that metric is really why we are doing it, so imo they should always go hand-in-hand.

"T": 101.047670,
"C": 103.009185,
"I": 113.084064,
"L": 113.084064, # isobaric with I; the production config defines both

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unnecessary comment

"""Beam sequences are compared against each other, so I is collapsed into L.

These are feature inputs, never reported, which is why collapsing them is
safe. The direction follows InstaNovo and the peptide registry.

@JemmaLDaniel JemmaLDaniel Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is a bit vague, what is "the peptide registry" to a future contributor? I am also not sure beams are never reported, they can be saved as part of a CalibrationDataset. See my general comment on the PR, but continuing to always collapse only the InstaNovo beams is not safe.


This column is reported. Collapsing I/L here put a residue in the output that
was never predicted, and a peptide correct as written then failed to match a
reference spelling it the other way.

@JemmaLDaniel JemmaLDaniel Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment implies that Winnow had an internal bug with its own peptide correctness metric, which is not the case. Winnow always judges a peptide to be correct treating leucine and isoleucine as equivalent. The comment likely comes from an error experienced downstream of Winnow's use.

self, metrics: Metrics
) -> None:
"""Uppercase L inside a modification name must not be rewritten before split."""
"""Uppercase L inside a modification name must survive, as must the residue."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit but I find this personification too flowery and generally prefer more technical terminology

despite the two residues being isobaric. Applying it to a reported column
rewrote predictions the model had right -- `ALAHKYH` was published as
`AIAHKYH`, stopped matching the protein it came from, and was reported as
needing an I/L substitution.

@JemmaLDaniel JemmaLDaniel Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another comment that is bringing in unneeded context from outside of Winnow's purview. I would cut the whole thing. The test only needs a single sentence docstring, and even then only if the name does not sufficiently cover its purpose. To that end, I think this method could be named more clearly for its purpose.

reported = data_utils.normalize_peptide_cell(
predicted, metrics, residue_remapping=REMAPPING
)
assert reported == predicted

@JemmaLDaniel JemmaLDaniel Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this being tested in here?

def _normalize_leucine_tokens(tokens: list[str]) -> list[str]:
"""Map leucine to isoleucine at the token level."""
return ["I" if token == "L" else token for token in tokens]
def replace_isoleucine_with_leucine(tokens: list[str]) -> list[str]:

@JemmaLDaniel JemmaLDaniel Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a good rename ty

def replace_isoleucine_with_leucine(tokens: list[str]) -> list[str]:
"""Rewrite every isoleucine token as leucine.

Named for the residue that disappears, because the direction is the whole content

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Way too much docstring bloat for such a short method. The first sentence says it all, just add Google-style args and returns since it's public now.

) -> tuple[int, bool]:
sequence = as_token_list(row.get(sequence_col)) or []
prediction = as_token_list(row.get(prediction_col)) or []
# Collapsed here and nowhere else: isoleucine and leucine are isobaric, so a

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment below about placement of the remapping inside the matching method. This would remove the need for these duplicated comments here (just put in the docstring).

@JemmaLDaniel

Copy link
Copy Markdown
Collaborator

Before I knew you were working on this PR @BioGeek I also made my own fix that accounts for the design discrepancies that I have with this approach - #248 . I reviewed this PR to justify why I think it needs further thought, but I think the other version may be the more rounded-out choice.

@BioGeek

BioGeek commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

Closed in favour of #248

@BioGeek BioGeek closed this Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants