Skip to content

_protein_peptide_pass mis-assigns antigens for structures with >26 chains (list(cpx.id) splits two-character chain ids) - #90

Open
ElainW wants to merge 1 commit into
oxpig:mainfrom
ElainW:fix/antigen-assignment-multichar-chain-ids
Open

_protein_peptide_pass mis-assigns antigens for structures with >26 chains (list(cpx.id) splits two-character chain ids)#90
ElainW wants to merge 1 commit into
oxpig:mainfrom
ElainW:fix/antigen-assignment-multichar-chain-ids

Conversation

@ElainW

@ElainW ElainW commented Aug 27, 2026

Copy link
Copy Markdown

Version: stcrpy 1.0.6, anarci-mhc 0.0.16, Python 3.11

Summary

TCRParser._protein_peptide_pass builds its receptor-chain registry from the string form of
a complex id:

# stcrpy/tcr_processing/TCRParser.py:1195
for cpx in complexes:
    cpx_ch = list(cpx.id)          # "AAZ" -> ['A', 'A', 'Z']
    for c in cpx_ch:
        all_cpx_chains[c] = cpx.id

cpx.id is the concatenation of the complex's chain ids, so list() decomposes it correctly
only while every chain id is a single character. STCRpy itself assigns two-character ids
once a structure has more than 26 chains, so on those structures the registry is wrong: for a
receptor with chains AA and Z, it records 'A' and 'Z', and 'AA' is never
registered
.

Antigen assignment then tests membership in that registry:

(potential_contact not in cpxids) and (p1 in all_cpx_chains) and (p2 not in all_cpx_chains)

so a receptor's own chain passes the "not a receptor chain, therefore antigen" test.

Reproducer

PDB 8rro (40 chains, 8 copies):

import stcrpy
tcrs = stcrpy.load_TCR("8rro.cif")
t = tcrs[5]
print(t.id)                              # 'AAZ'
print(sorted(c.id for c in t.get_chains()))    # ['AA', 'Z']   <- real chain ids
print(sorted(list(t.id)))                      # ['A', 'A', 'Z'] <- what the parser uses
print(sorted(c.id for c in t.get_antigen()))   # ['AA', 'DA', 'E']
#                                                 ^^^^ the receptor's OWN alpha chain

Also emits Crystal Contact Warning: antigen AA has been paired with TCR AAZ.

It is intermittent, and that is a second issue

cpxids and ags in the same function are sets of strings, and the first pairing loop
claims-and-removes (ags.remove(ag)) while resetting model[cpx_id].antigen = []. Iteration
order over str keys is randomized per process by PYTHONHASHSEED, so the result varies
between runs of the same input. Pinning the seed makes it deterministic:

PYTHONHASHSEED 0 1 2 3 4 5 6 7
get_antigen() on copy 5 DA,E AA,DA,E AA,DA,E AA,DA,E DA,E AA,DA,E AA,DA,E AA,DA,E

Impact beyond the spurious chain

On 8i5c and 9iky (50 chains, 10 copies, 10 peptide chains) the symptom is different and
arguably worse: no receptor chain appears as antigen, but copies 0–4 get no antigen at all
while copies 5–9 are handed single-character chains belonging to other copies. The peptides are
not distributed one-per-copy.

Fix

Take the chain objects rather than re-parsing the id string (the change in this PR). With it
applied:

before after
8rro copy 5 antigen AA, DA, E (own chain, + Crystal Contact Warning) DA
8i5c copies 0–4 no antigen antigen assigned
8i5c copies 5–9 antigen BA, H, M, R, C;W (mixed id spaces) BA, GA, LA, QA, VA (one each)
try:
    cpx_ch = [c.id for c in cpx.get_chains()]
except AttributeError:      # a bare TCRchain/MHCchain, not a paired complex
    cpx_ch = [cpx.id]

The try/except preserves the documented contract that complexes may hold either paired
complexes or bare chain objects.

Fixing the registry also removes the observed run-to-run variation on these structures, since
the spurious candidate is what the order-dependent second loop was flip-flopping over. The
set-iteration-order dependence itself remains and may deserve a separate look (an ordered
container, or sorting cpxids/ags before iterating, would make results reproducible by
construction).

One more place multi-character ids are still parsed by concatenation

The patch repairs the registry, but the same function identifies a chain pair the same
concatenating way, a few lines down:

potential_contact  = p1 + p2          # TCRParser.py:1208
potential_contact2 = p2 + p1
...
if (potential_contact not in cpxids) and (p1 in all_cpx_chains) and (p2 not in all_cpx_chains):

and cpxids are themselves concatenations (Entity.__init__(self, c1.id + c2.id),
TCR.py:567). Once chain ids differ in length that mapping is not injective: chains A + AB
give "AAB", which is also the id of a receptor whose chains are AA + B. The pair then
looks like "a receptor, not an antigen contact" and is skipped — a genuine antigen dropped,
this time silently.

This is latent, not observed: with the RCSB id scheme (AZ, then AA, BA, … ZA,
then AB…) an id of the AB form only appears past ~52 chains, and the largest structures we
have are 50 chains — on 8rro/8i5c/9iky the patch alone is sufficient and verified. It
belongs in the report because it is the same root cause: complex ids are a lossy encoding of
their chain set, so any code that recovers chains from the id string is wrong above 26 chains.

The robust version keys contact_freq on the chain-id tuple rather than the concatenation.

… string

list(cpx.id) decomposes the concatenated complex id correctly only while every
chain id is one character. Past 26 chains a two-character chain id never enters
all_cpx_chains and is then mis-classified as antigen (e.g. 8rro copy 5 gets its
own alpha chain AA). Take the chain objects instead.
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.

1 participant