From 04b1033946b2e9dd5f6e849faa00053c18015bff Mon Sep 17 00:00:00 2001 From: Alex Rubinsteyn Date: Thu, 9 Jul 2026 11:15:10 -0400 Subject: [PATCH 1/2] Support un-normalizable exotic alleles (#220) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netMHCpan's -listMHC includes alleles mhcgnomes can't parse: exotic non-human alleles (H-2-Qa1, BoLA-amani.1) and HLA low/null-expression variants (HLA-A30:14L). Requesting one raised AlleleParseError during predictor construction even though netMHCpan supports it. This is the tail left over after #219 (which handled ~2,456 alleles that normalize but to a spelling netMHCpan rejects); these names don't normalize at all. Carry the predictor's own -listMHC spelling as identity for these: * add normalize_allele_name_or_raw(): normalize when possible, else fall back to a canonical raw form (strip whitespace and the '*' gene/allele separator). netMHCpan echoes a requested "HLA-A30:14L" back as "HLA-A*30:14L", so stripping '*' makes both spellings one identity. * BasePredictor.__init__ gains keep_unparseable_alleles (default False); command-line predictors pass True since they validate against the raw supported list. IEDB / in-process predictors still raise as before. * output parser (parsing.py) uses the raw fallback instead of raising on an un-normalizable echoed allele, so requested and echoed forms match in _check_results. 14 of the 21 affected names now round-trip end-to-end against netMHCpan 4.2 (11 BoLA, H-2-Qa1/Qa2, HLA-A30:14L in both spellings). The other 7 (Mamu-B12/B17/B20/B22, BoLA-T2C, H2-Qa1, H2-Qa2) are listed by netMHCpan but rejected on -a by the binary itself, so they fail at predict time with "Missing predictions" — a documented netMHCpan inconsistency mhctools can't work around. Tests: binary-free unit tests for the fallback and keep_unparseable gate (tests/test_unparseable_alleles.py, added to the public CI subset) plus a netMHCpan integration test asserting the round-trip identity. Bump version to 3.19.0. Claude-Session: https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG --- .github/workflows/tests.yml | 1 + mhctools/__init__.py | 2 +- mhctools/allele_normalization.py | 24 ++++++ mhctools/base_commandline_predictor.py | 5 +- mhctools/base_predictor.py | 44 +++++++++-- mhctools/parsing.py | 6 +- tests/test_netmhc_pan.py | 26 ++++++ tests/test_unparseable_alleles.py | 105 +++++++++++++++++++++++++ 8 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 tests/test_unparseable_alleles.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 30f2b73..f9ad91e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,6 +55,7 @@ jobs: tests/test_processing_predictor.py \ tests/test_pepsickle.py \ tests/test_bigmhc.py \ + tests/test_unparseable_alleles.py \ tests/test_random.py integration-netmhc: diff --git a/mhctools/__init__.py b/mhctools/__init__.py index b30aa3d..91aaf39 100644 --- a/mhctools/__init__.py +++ b/mhctools/__init__.py @@ -78,7 +78,7 @@ def __getattr__(name): raise AttributeError( "module %r has no attribute %r" % (__name__, name)) -__version__ = "3.18.0" +__version__ = "3.19.0" __all__ = [ "Prediction", diff --git a/mhctools/allele_normalization.py b/mhctools/allele_normalization.py index 55d666e..d468acb 100644 --- a/mhctools/allele_normalization.py +++ b/mhctools/allele_normalization.py @@ -266,3 +266,27 @@ def normalize_allele_name(raw_allele, omit_dra1=False, infer_class2_pair=True): normalized = "-".join(normalized_parts) _normalized_allele_cache[cache_key] = normalized return normalized + + +def normalize_allele_name_or_raw(name): + """ + Normalize an allele name via mhcgnomes, or fall back to a canonical "raw" + spelling for names it can't parse. + + Some alleles appear in a predictor's own supported list (e.g. netMHCpan's + ``-listMHC``) yet are rejected by mhcgnomes: exotic non-human alleles such + as ``H-2-Qa1`` or ``BoLA-amani.1``, and HLA low/null-expression variants + such as ``HLA-A30:14L``. For these we key on the predictor's own spelling + so that a requested allele and the same allele echoed back in the + predictor's output resolve to a single identity. + + The raw fallback strips surrounding whitespace and the ``*`` gene/allele + separator (which a predictor may add or omit — netMHCpan prints + ``HLA-A*30:14L`` for a requested ``HLA-A30:14L``), preserving case to match + the predictor's ``-listMHC`` / output spelling. + """ + stripped = str(name).strip() + try: + return normalize_allele_name(stripped.upper()) + except AlleleParseError: + return stripped.replace("*", "") diff --git a/mhctools/base_commandline_predictor.py b/mhctools/base_commandline_predictor.py index 1b5f8d0..410cef5 100644 --- a/mhctools/base_commandline_predictor.py +++ b/mhctools/base_commandline_predictor.py @@ -257,7 +257,10 @@ def __init__( valid_alleles=None, default_peptide_lengths=default_peptide_lengths, min_peptide_length=min_peptide_length, - max_peptide_length=max_peptide_length) + max_peptide_length=max_peptide_length, + # Non-human alleles the normalizer can't parse (e.g. H-2-Qa1) are + # kept verbatim and validated against the tool's raw -listMHC list. + keep_unparseable_alleles=True) self._resolve_supported_alleles() diff --git a/mhctools/base_predictor.py b/mhctools/base_predictor.py index 78c4889..ebdd8c5 100644 --- a/mhctools/base_predictor.py +++ b/mhctools/base_predictor.py @@ -15,7 +15,11 @@ from collections import defaultdict, namedtuple from typechecks import require_iterable_of -from .allele_normalization import normalize_allele_name +from .allele_normalization import ( + normalize_allele_name, + normalize_allele_name_or_raw, + AlleleParseError, +) from .unsupported_allele import UnsupportedAllele from .binding_prediction_collection import BindingPredictionCollection @@ -115,7 +119,8 @@ def __init__( min_peptide_length=8, max_peptide_length=None, allow_X_in_peptides=False, - allow_lowercase_in_peptides=False): + allow_lowercase_in_peptides=False, + keep_unparseable_alleles=False): """ Parameters ---------- @@ -143,12 +148,19 @@ def __init__( allow_lowercase_in_peptides : bool Allow lowercase letters in peptide sequences + + keep_unparseable_alleles : bool + If True, keep allele names our normalizer can't parse verbatim + instead of raising (used by command-line predictors that validate + against the tool's own supported-allele list, so that non-human + alleles like H-2-Qa1 or BoLA-amani.1 can still be requested). """ # I find myself often constructing a predictor with just one allele # so as a convenience, allow user to not wrap that allele as a list if type(alleles) is str: alleles = alleles.split(',') - self.alleles = self._check_hla_alleles(alleles, valid_alleles) + self.alleles = self._check_hla_alleles( + alleles, valid_alleles, keep_unparseable=keep_unparseable_alleles) if type(default_peptide_lengths) is int: default_peptide_lengths = [default_peptide_lengths] @@ -515,20 +527,36 @@ def predict_subsequences_dataframe( @staticmethod def _check_hla_alleles( alleles, - valid_alleles=None): + valid_alleles=None, + keep_unparseable=False): """ Given a list of HLA alleles and an optional list of valid HLA alleles, return a set of alleles that we will pass into the MHC binding predictor. + + When keep_unparseable is True, allele names the normalizer can't + parse are kept verbatim (original case) rather than raising, so a + caller that validates against the tool's own supported list can still + accept non-human alleles like H-2-Qa1 or BoLA-amani.1. """ require_iterable_of(alleles, str, "HLA alleles") # Don't run the MHC predictor twice for homozygous alleles, # only run it for unique alleles - alleles = { - normalize_allele_name(allele.strip().upper()) - for allele in alleles - } + normalized = set() + for allele in alleles: + try: + normalized.add(normalize_allele_name(allele.strip().upper())) + except AlleleParseError: + if not keep_unparseable: + raise + # Keep the predictor's own spelling; the tool lists (and + # outputs) these names as-is and validation happens against its + # raw supported-allele list. normalize_allele_name_or_raw + # applies the same canonical fallback the output parser uses so + # a requested allele and its echoed form share one identity. + normalized.add(normalize_allele_name_or_raw(allele)) + alleles = normalized if valid_alleles: # For some reason netMHCpan drops the '*' in names, so # 'HLA-A*03:01' becomes 'HLA-A03:01' diff --git a/mhctools/parsing.py b/mhctools/parsing.py index a41833d..9502bd4 100644 --- a/mhctools/parsing.py +++ b/mhctools/parsing.py @@ -15,7 +15,7 @@ import numpy as np -from .allele_normalization import normalize_allele_name +from .allele_normalization import normalize_allele_name_or_raw from .binding_prediction import BindingPrediction from .pred import Prediction, Kind @@ -223,7 +223,7 @@ def parse_stdout( source_sequence_name=original_key, offset=offset, peptide=peptide, - allele=normalize_allele_name(allele), + allele=normalize_allele_name_or_raw(allele), score=score, affinity=ic50, percentile_rank=rank, @@ -644,7 +644,7 @@ def parse_netmhcpan_to_preds( offset -= 1 peptide = str(fields[peptide_index]) - allele = normalize_allele_name(str(fields[allele_index])) + allele = normalize_allele_name_or_raw(str(fields[allele_index])) key = str(fields[key_index]) if sequence_key_mapping: diff --git a/tests/test_netmhc_pan.py b/tests/test_netmhc_pan.py index 92f7a21..675c3f4 100644 --- a/tests/test_netmhc_pan.py +++ b/tests/test_netmhc_pan.py @@ -94,6 +94,32 @@ def test_netmhc_pan_multiple_alleles(): "Expected both alleles, got %s" % (observed_alleles,) +def test_netmhc_pan_exotic_unnormalizable_alleles(): + """netMHCpan lists non-human alleles (H-2-Qa1, BoLA-amani.1) and HLA + low/null-expression variants (HLA-A30:14L) that mhcgnomes can't normalize + (issue #220). They must round-trip: request them, run, and get predictions + back keyed by the requested identity. netMHCpan echoes HLA-A30:14L back + with a '*' (HLA-A*30:14L), so both spellings must resolve to one identity. + """ + cases = [ + ("H-2-Qa1", "H-2-Qa1"), + ("BoLA-amani.1", "BoLA-amani.1"), + ("HLA-A30:14L", "HLA-A30:14L"), # requested without '*' + ("HLA-A*30:14L", "HLA-A30:14L"), # requested with '*', same identity + ] + peptides = ["SIINFEKLL", "GILGFVFTL"] + for requested, expected_identity in cases: + predictor = NetMHCpan(alleles=[requested]) + binding_predictions = predictor.predict_peptides(peptides) + assert len(binding_predictions) == len(peptides), \ + "Expected %d predictions for %s, got %s" % ( + len(peptides), requested, binding_predictions) + observed = {bp.allele for bp in binding_predictions} + assert observed == {expected_identity}, \ + "Expected identity %r for requested %r, got %s" % ( + expected_identity, requested, observed) + + def test_netmhc_pan_batched_matches_per_allele(): """Batching alleles into one `-a A,B,C` invocation must produce exactly the same scores as running one process per allele. Uses the real binary's diff --git a/tests/test_unparseable_alleles.py b/tests/test_unparseable_alleles.py new file mode 100644 index 0000000..a88a9ee --- /dev/null +++ b/tests/test_unparseable_alleles.py @@ -0,0 +1,105 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Unit tests for supporting alleles that netMHCpan lists but mhcgnomes can't +parse (issue #220): exotic non-human alleles (H-2-Qa1, BoLA-amani.1) and HLA +low/null-expression variants (HLA-A30:14L). + +The key invariant is that a requested allele and the same allele echoed back +in a predictor's output resolve to a single identity, so `_check_results` +matches them. Both go through `normalize_allele_name_or_raw`; when parsing +fails it strips whitespace and the '*' gene/allele separator (which netMHCpan +adds or omits) so e.g. requested "HLA-A30:14L" and echoed "HLA-A*30:14L" agree. + +No predictor binary is required. +""" + +import pytest + +from mhctools.allele_normalization import ( + normalize_allele_name_or_raw, + AlleleParseError, +) +from mhctools.base_predictor import BasePredictor + + +# --------------------------------------------------------------------------- +# normalize_allele_name_or_raw: parse when possible, canonical raw fallback +# --------------------------------------------------------------------------- + +def test_parseable_allele_is_normalized(): + # Case-insensitive, and the canonical form keeps the '*'. + assert normalize_allele_name_or_raw("hla-a*02:01") == "HLA-A*02:01" + assert normalize_allele_name_or_raw("HLA-A02:01") == "HLA-A*02:01" + + +def test_unparseable_non_human_kept_verbatim(): + # These netMHCpan names don't parse at all; keep the tool's own spelling + # (including original case, which the output echoes). + for name in ["H-2-Qa1", "H-2-Qa2", "BoLA-amani.1", "BoLA-JSP.1", + "BoLA-T2a", "BoLA-gb1.7"]: + assert normalize_allele_name_or_raw(name) == name + + +def test_whitespace_is_stripped(): + assert normalize_allele_name_or_raw(" H-2-Qa1 \n") == "H-2-Qa1" + + +def test_star_stripped_from_unparseable_fallback(): + # netMHCpan prints "HLA-A*30:14L" for a requested "HLA-A30:14L"; both + # spellings must collapse to one identity so results match. + bare = normalize_allele_name_or_raw("HLA-A30:14L") + starred = normalize_allele_name_or_raw("HLA-A*30:14L") + assert bare == starred == "HLA-A30:14L" + + +def test_request_and_echo_share_identity(): + # The invariant _check_results relies on: whatever the user typed and + # whatever netMHCpan echoes back reduce to the same string. + for requested, echoed in [ + ("HLA-A30:14L", "HLA-A*30:14L"), # star added by netMHCpan + ("H-2-Qa1", "H-2-Qa1"), # verbatim + ("BoLA-amani.1", "BoLA-amani.1")]: + assert (normalize_allele_name_or_raw(requested) == + normalize_allele_name_or_raw(echoed)) + + +# --------------------------------------------------------------------------- +# _check_hla_alleles: keep_unparseable gate +# --------------------------------------------------------------------------- + +def test_check_hla_alleles_raises_by_default(): + # Default behavior (used by IEDB / in-process predictors) must still + # reject names it can't normalize. + with pytest.raises(AlleleParseError): + BasePredictor._check_hla_alleles(["H-2-Qa1"]) + + +def test_check_hla_alleles_keeps_unparseable_when_requested(): + result = set(BasePredictor._check_hla_alleles( + ["H-2-Qa1", "BoLA-amani.1"], keep_unparseable=True)) + assert result == {"H-2-Qa1", "BoLA-amani.1"} + + +def test_check_hla_alleles_still_normalizes_parseable_when_keeping(): + # Mixing parseable and unparseable: parseable ones are still canonicalized. + result = set(BasePredictor._check_hla_alleles( + ["hla-a*02:01", "H-2-Qa1"], keep_unparseable=True)) + assert result == {"HLA-A*02:01", "H-2-Qa1"} + + +def test_check_hla_alleles_dedupes_star_variants_of_unparseable(): + # Two spellings of the same un-normalizable allele collapse to one entry. + result = BasePredictor._check_hla_alleles( + ["HLA-A30:14L", "HLA-A*30:14L"], keep_unparseable=True) + assert result == ["HLA-A30:14L"] From 04f2b3f5dd8e9715166a83c23b60863459301fa9 Mon Sep 17 00:00:00 2001 From: Alex Rubinsteyn Date: Thu, 9 Jul 2026 12:08:58 -0400 Subject: [PATCH 2/2] Address review feedback on exotic-allele support Simplify and harden the changes from the previous commit (no behavior change): * _check_hla_alleles: replace the try/except in the dedup loop with a branch on keep_unparseable. This drops a redundant second parse of unparseable names (normalize_allele_name_or_raw re-parsed after the try already failed) and removes the now-unused AlleleParseError import. keep_unparseable=True -> normalize_allele_name_or_raw (parse or raw fallback); otherwise normalize_allele_name, which still raises as before. * normalize_allele_name_or_raw: document that un-normalizable exotic alleles are effectively case-sensitive (parseable names are uppercased before normalization, but the raw fallback preserves case to match the predictor's -listMHC / output spelling), so they must be requested with the tool's own casing. * Add a binary-free regression test that runs an exotic allele (H-2-Qa1) through the netMHC output parser, which previously raised on names mhcgnomes can't parse. Full suite: 510 passed, 38 skipped, 2 xfailed (netMHCpan 4.2). Claude-Session: https://claude.ai/code/session_01LZahFhBSCiehXTESCYQ7wG --- mhctools/allele_normalization.py | 6 ++++++ mhctools/base_predictor.py | 28 +++++++++++----------------- tests/test_unparseable_alleles.py | 26 ++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/mhctools/allele_normalization.py b/mhctools/allele_normalization.py index d468acb..397e764 100644 --- a/mhctools/allele_normalization.py +++ b/mhctools/allele_normalization.py @@ -284,6 +284,12 @@ def normalize_allele_name_or_raw(name): separator (which a predictor may add or omit — netMHCpan prints ``HLA-A*30:14L`` for a requested ``HLA-A30:14L``), preserving case to match the predictor's ``-listMHC`` / output spelling. + + Note: because parseable names are uppercased before normalization but the + raw fallback preserves case, un-normalizable exotic alleles are effectively + **case-sensitive** — request them exactly as the predictor's ``-listMHC`` + spells them (e.g. ``H-2-Qa1``, not ``h-2-qa1``), or validation against the + raw supported list won't match. """ stripped = str(name).strip() try: diff --git a/mhctools/base_predictor.py b/mhctools/base_predictor.py index ebdd8c5..a197097 100644 --- a/mhctools/base_predictor.py +++ b/mhctools/base_predictor.py @@ -18,7 +18,6 @@ from .allele_normalization import ( normalize_allele_name, normalize_allele_name_or_raw, - AlleleParseError, ) from .unsupported_allele import UnsupportedAllele @@ -541,22 +540,17 @@ def _check_hla_alleles( """ require_iterable_of(alleles, str, "HLA alleles") - # Don't run the MHC predictor twice for homozygous alleles, - # only run it for unique alleles - normalized = set() - for allele in alleles: - try: - normalized.add(normalize_allele_name(allele.strip().upper())) - except AlleleParseError: - if not keep_unparseable: - raise - # Keep the predictor's own spelling; the tool lists (and - # outputs) these names as-is and validation happens against its - # raw supported-allele list. normalize_allele_name_or_raw - # applies the same canonical fallback the output parser uses so - # a requested allele and its echoed form share one identity. - normalized.add(normalize_allele_name_or_raw(allele)) - alleles = normalized + # Keep only unique alleles (don't run the predictor twice for a + # homozygous genotype). When keep_unparseable is set (command-line + # predictors, which validate against the tool's own -listMHC list), + # normalize_allele_name_or_raw retains names mhcgnomes can't parse + # using the same canonical fallback the output parser applies, so a + # requested allele and its echoed form share one identity. Otherwise + # normalize_allele_name raises on an unparseable name, as before. + if keep_unparseable: + alleles = {normalize_allele_name_or_raw(a) for a in alleles} + else: + alleles = {normalize_allele_name(a.strip().upper()) for a in alleles} if valid_alleles: # For some reason netMHCpan drops the '*' in names, so # 'HLA-A*03:01' becomes 'HLA-A03:01' diff --git a/tests/test_unparseable_alleles.py b/tests/test_unparseable_alleles.py index a88a9ee..6a5ac1e 100644 --- a/tests/test_unparseable_alleles.py +++ b/tests/test_unparseable_alleles.py @@ -31,6 +31,7 @@ AlleleParseError, ) from mhctools.base_predictor import BasePredictor +from mhctools.parsing import parse_netmhc4_stdout # --------------------------------------------------------------------------- @@ -103,3 +104,28 @@ def test_check_hla_alleles_dedupes_star_variants_of_unparseable(): result = BasePredictor._check_hla_alleles( ["HLA-A30:14L", "HLA-A*30:14L"], keep_unparseable=True) assert result == ["HLA-A30:14L"] + + +# --------------------------------------------------------------------------- +# Output parser falls back to the raw name instead of raising (binary-free). +# --------------------------------------------------------------------------- + +def test_parser_keeps_unparseable_output_allele(): + # A netMHCpan/netMHC-style table whose allele column is an exotic name + # mhcgnomes can't parse. Before the fallback this raised in the parser; + # now the raw name is carried through verbatim. + stdout = """ +# NetMHC version 4.0 +----------------------------------------------------------------------------------- + pos HLA peptide Core Offset I_pos I_len D_pos D_len iCore Identity 1-log50k(aff) Affinity(nM) %Rank BindLevel +----------------------------------------------------------------------------------- + 0 H-2-Qa1 SIINFEKLL SIINFEKLL 0 0 0 0 0 SIINFEKLL SEQ_A 0.349 1147.39 4.50 +----------------------------------------------------------------------------------- + +Protein PEPLIST. Allele H-2-Qa1. Number of high binders 0. Number of weak binders 0. Number of peptides 1 +----------------------------------------------------------------------------------- +""" + preds = parse_netmhc4_stdout(stdout) + assert len(preds) == 1 + assert preds[0].allele == "H-2-Qa1" # raw name, not raised on + assert preds[0].peptide == "SIINFEKLL"