antibody-numbering
utilityNumber antibody, nanobody and TCR variable domains under IMGT, Chothia, Kabat, Martin or AHo with ANARCII, then slice CDRs and frameworks out of the result — covering the scheme differences that silently move CDR boundaries.
Numbering antibody, nanobody and TCR sequences
Every other antibody task presupposes a numbering. CDR definitions, epitope positions, humanisation, library design and "mutate position 52" all mean nothing until you have said under which scheme. This skill assigns one and slices regions out of it.
The thing that goes wrong
The schemes disagree about where the CDRs are, and they disagree by several residues. The same heavy chain, numbered three ways:
| scheme | CDR1 | CDR2 | CDR3 |
|---|---|---|---|
| IMGT | GFNIKDTY |
IYPTNGYT |
SRWGGDGFYAMDY |
| Chothia | GFNIKDT |
YPTNGY |
WGGDGFYAMDY |
| Kabat | DTYIH |
RIYPTNGYTRYADSVKG |
WGGDGFYAMDY |
That is one molecule, one numbering tool, one call each. Kabat CDR-H2 is 17 residues where IMGT's is 8. IMGT CDR-H3 carries two residues Kabat's does not.
So a CDR sequence is not a property of an antibody — it is a property of an antibody and a scheme. Record which one you used next to every position you report, and never compare a CDR length across two sources without checking that both used the same scheme.
What you need
pip install anarcii. Nothing else:
- No API key, no account, no licence to accept. ANARCII is BSD-3-Clause (University of Oxford).
- No GPU. It is a language model and it pulls in PyTorch, but it runs on CPU — pass
cpu=True. Everything in this skill was verified CPU-only. - No weights download at run time. The six model files ship inside the wheel, about 19 MB in total, so the first call works offline and there is no cache to warm.
ANARCII needs Python 3.11 or newer.
Use ANARCII, not ANARCI
ANARCI is the older tool and much of the literature still names it. Prefer ANARCII: ANARCI builds HMMs from IMGT germline downloads at install time, so its install breaks when that source changes shape or goes down, and it needs HMMER on the machine. ANARCII replaces the HMM alignment with a sequence model, which is why it installs from the package index alone.
Numbering a chain
from anarcii import Anarcii
VH = (
"EVQLVESGGGLVQPGGSLRLSCAASGFNIKDTYIHWVRQAPGKGLEWVARIYPTNGYTRYADSVKGRFTISADTSKNT"
"AYLQMNSLRAEDTAVYYCSRWGGDGFYAMDYWGQGTLVTVSS"
)
numberer = Anarcii(seq_type="antibody", mode="accuracy", cpu=True, ncpu=1)
result = numberer.number([("trastuzumab_H", VH)])
record = result["trastuzumab_H"]
print(record["chain_type"], record["scheme"], len(record["numbering"]))
print(record["query_start"], record["query_end"], record["error"])
number() returns a dict keyed by sequence name, not a list. Pass a list of
(name, sequence) pairs and you get your names back. Pass a bare string and the single
key is the literal "Sequence" — which is the first thing to trip over, because
result[0] and iterating the result both fail or give you keys instead of records.
Each record holds:
| key | what it is |
|---|---|
numbering |
list of ((position, insertion_code), residue) tuples; - marks a gap |
chain_type |
H, K, L, A, B, G, D — or F when numbering failed |
score |
model confidence; below the cut-off the chain is rejected |
query_start, query_end |
0-based inclusive span of the input the domain occupies |
error |
None on success, otherwise why it was rejected |
scheme |
the scheme the numbering is currently in |
query_start and query_end locate the domain
Hand it a whole Fab chain, or a full-length sequence with signal peptide and constant
region attached, and ANARCII numbers only the variable domain and tells you where it
found it. The 220-residue Fab heavy chain of the worked example yields
query_start=0, query_end=119 — the V domain is the first 120 residues and the rest is
ignored rather than mis-numbered. You do not need to trim input to the variable domain
yourself.
Concatenating the non-gap residues reconstructs exactly that slice of the input, which is the cheapest assertion that a numbering is intact:
numbered = "".join(aa for _, aa in record["numbering"] if aa != "-")
assert numbered == VH[record["query_start"]:record["query_end"] + 1]
A kappa light chain is K, not L
chain_type distinguishes the light-chain classes: kappa is K and lambda is L. So
chain_type == "L" is not a test for "is this a light chain" — it is a test for
lambda, and it returns False for every kappa chain, which is most of them. Test
membership instead:
if record["chain_type"] in {"K", "L"}:
... # light chain
TCR chains come back as A, B, G, D — verified A for the alpha chain of PDB
1AO7.
Set seq_type to match the receptor — nothing checks it for you
Anarcii(seq_type=...) selects the model, and the wrong one fails silently rather than
loudly. The alpha chain of 1AO7 put through the default antibody model comes back
chain_type='H', error=None and a full numbering — a confident, wrong answer, with
nothing in the record to suggest it. Through seq_type="tcr" the same chain is A.
# alpha chain of PDB 1AO7, a human TCR bound to HLA-A2
TCR_ALPHA = (
"KEVEQNSGPLSVPEGAIASLNCTYSDRGSQSFFWYRQYSGKSPELIMSIYSNGDKEDGRFTAQLNKASQYVSLLIRDS"
"QPSDSATYLCAVTTDSWGKLQFGAGTQVVVTPDIQNPDPAVYQLRDSKSSDKSVCLFTDFDSQTNVSQSKDSDVYITD"
"KTVLDMRSMDFKSNSAVAWSNKSDFACANAFNNSIIPEDTFFPSPESS"
)
tcr = Anarcii(seq_type="tcr", mode="accuracy", cpu=True, ncpu=1)
print(tcr.number([("1ao7_A", TCR_ALPHA)])["1ao7_A"]["chain_type"])
seq_type takes antibody, tcr and shark (VNAR). Nanobodies and other VHH
single-domain antibodies are not a separate type — they go through antibody and
number as H, as the 1MEL VHH above does.
Switching scheme
chothia = numberer.to_scheme("chothia")
print(chothia["trastuzumab_H"]["numbering"][:5])
to_scheme() converts the most recent number() result held on the numberer — it
takes no numbering argument and returns the whole converted result set. So convert before
you number anything else, and keep a reference if you want two schemes at once. It does
not mutate the records you already hold.
Accepted values are imgt, chothia, kabat, martin and aho.
The frames are not all the same width, and not all fixed
Measured on two heavy chains — a conventional VH with a 13-residue IMGT CDR3, and the
camelid VHH of PDB 1MEL with a 26-residue one:
| scheme | VH slots | VHH slots | highest position | how a long loop is absorbed |
|---|---|---|---|---|
| IMGT | 128 | 141 | 128 | insertion codes beyond the frame |
| AHo | 149 | 149 | 149 | absorbed inside the frame, no insertions |
| Chothia | 120 | 133 | 113 | insertion codes |
The invariant is the highest position, not the slot count. IMGT numbering never goes
past position 128 — but it reaches a 26-residue CDR3 by hanging insertion codes off
positions 111 and 112, and each of those is another entry in the list. So the VHH comes
back as 141 entries, and len(numbering) == 128 is not a valid check on an IMGT
numbering. It passes for most human antibodies and fails on exactly the chains people
care about, because long CDR3s are the norm in camelid single-domain antibodies.
AHo was the only scheme here that took the long CDR3 without insertion codes. Chothia, Kabat and Martin drop the gap slots entirely and carry insertions, so their slot count tracks the chain.
Do not sort the numbering
The list comes back in sequence order, and that order is not the sort order of its own
keys. IMGT fills CDR3 insertions outward from the middle, so the 1MEL nanobody runs
111A, 111B … 111F and then 112G, 112F … 112A — descending. Sorting by
(position, insertion_code) puts 112A before 112G and silently reverses that half
of the CDR3. Read the list as given; the only safe operations are filtering and slicing.
nb = record["numbering"]
assert max(pos for (pos, _ins), _aa in nb) <= 128 # IMGT ceiling holds
insertions = [(p, i) for (p, i), aa in nb if i != " "] # [] for most human chains
print(len(nb), "slots,", len(insertions), "insertion-coded")
Slicing CDRs and frameworks
IMGT's region boundaries are fixed position ranges, which makes region extraction a filter over the numbering rather than anything cleverer:
IMGT_REGIONS = {
"FR1": (1, 26),
"CDR1": (27, 38),
"FR2": (39, 55),
"CDR2": (56, 65),
"FR3": (66, 104),
"CDR3": (105, 117),
"FR4": (118, 128),
}
def imgt_regions(record):
if record["error"] is not None:
raise ValueError(f"{record['chain_type']}: {record['error']}")
out = {}
for name, (lo, hi) in IMGT_REGIONS.items():
out[name] = "".join(
aa for (pos, _ins), aa in record["numbering"]
if lo <= pos <= hi and aa != "-"
)
return out
for name, seq in imgt_regions(record).items():
print(f"{name:5s} {seq}")
This only works on an IMGT numbering. The other schemes put their boundaries at different positions, so either convert to IMGT first or use the region accessors in the next section.
Checking failures, and sanitising input first
Numbering fails in two different ways and they need different handling.
A protein that is not an antigen receptor is rejected cleanly. Feed in a receptor
ectodomain and you get chain_type='F', numbering=None and
error='Score less than cut off.'. So guard on error before touching numbering,
as imgt_regions above does — the failure mode is a None you will otherwise index.
A sequence containing characters that are not amino acids does not fail cleanly. This is the sharp edge. Verified against 2.0.8:
| input | result |
|---|---|
X in the sequence |
numbered normally |
| lowercase sequence | raises IndexError |
* stop codon |
raises IndexError |
| a digit | raises IndexError |
- alignment gap |
raises KeyError on the sequence name |
Lowercase and * are not exotic — soft-masked FASTA is lowercase, and translating an
ORF with its stop gives you *. The exception surfaces from inside the model, not as a
validation error, and IndexError is not something you would think to catch around a
numbering call.
The behaviour is also batch-dependent, which makes it easy to miss in testing: a bad
sequence on its own raises, but the same bad sequence batched alongside a valid one
degrades to chain_type='F', numbering=None and
error='Less than 50 non insertion residues numbered.' instead. Do not rely on either —
sanitise before the call:
AA = set("ACDEFGHIKLMNPQRSTVWYX")
def clean(seq):
seq = seq.strip().upper().replace("-", "").replace("*", "")
bad = sorted(set(seq) - AA)
if bad:
raise ValueError(f"non-amino-acid characters in sequence: {bad}")
return seq
records = numberer.number([(name, clean(s)) for name, s in [("trastuzumab_H", VH)]])
print(records["trastuzumab_H"]["chain_type"], records["trastuzumab_H"]["error"])
Uppercasing and dropping gaps and stops covers the realistic cases; raising on anything else left over means a malformed FASTA stops at your code with a message naming the character, rather than at a tensor index.
Region accessors with abnumber
abnumber (MIT) wraps a numbering in a Chain object with scheme-aware region
accessors, alignment and CDR-graft humanisation. Historically it required ANARCI and
therefore HMMER; as of 0.4.4 it takes use_anarcii=True and then installs and runs from
the package index alone, on any platform ANARCII supports:
from abnumber import Chain
chain = Chain(
VH,
scheme="imgt",
use_anarcii=True,
anarcii_args={"cpu": True, "ncpu": 1},
)
print(chain.chain_type, chain.is_heavy_chain())
print(chain.cdr1_seq, chain.cdr2_seq, chain.cdr3_seq)
print(list(chain.regions.keys()))
Chain(..., scheme="kabat", use_anarcii=True) gives you Kabat's region definitions
directly, which is how the comparison table at the top of this page was produced.
One limit worth knowing before you choose a backend: germline assignment is not
available through ANARCII. Passing assign_germline=True together with
use_anarcii=True warns and silently falls back to ANARCI, so that one feature still
costs you a HMMER install. Everything else works on the ANARCII path.
Exporting a numbering
to_csv() writes the last result as one row per sequence — Name, Chain, Score,
Query start, Query end, then one column per numbered position:
numberer.to_csv("numbering.csv")
Position columns only line up across rows for the fixed-width frames (IMGT, AHo). There
are also to_legacy() for the older ANARCI tuple layout and to_msgpack() for a
compact round-trip.
Try it
Data. The heavy chain of the trastuzumab Fab, read from RCSB PDB entry 1N8Z
(trastuzumab Fab in complex with the HER2 extracellular domain). Entity 2 is the heavy
chain, 220 residues — a Fab chain rather than an isolated variable domain, so it also
exercises domain-boundary detection. RCSB data is public and needs no account; the entry
was confirmed reachable on 2026-10-04.
Run. In a fresh empty directory:
python3 -m venv .venv && . .venv/bin/activate
pip install --quiet anarcii
cat > try_it.py <<'PY'
import json, urllib.request
from anarcii import Anarcii
URL = "https://data.rcsb.org/rest/v1/core/polymer_entity/1N8Z/2"
with urllib.request.urlopen(URL, timeout=60) as fh:
entity = json.load(fh)
seq = entity["entity_poly"]["pdbx_seq_one_letter_code_can"].strip().upper()
print(f"fetched {len(seq)} residues")
numberer = Anarcii(seq_type="antibody", mode="accuracy", cpu=True, ncpu=1)
rec = numberer.number([("1n8z_H", seq)])["1n8z_H"]
assert rec["error"] is None, rec["error"]
print(f"chain_type={rec['chain_type']} scheme={rec['scheme']} "
f"slots={len(rec['numbering'])} span={rec['query_start']}-{rec['query_end']} "
f"score={rec['score']:.2f}")
# non-gap residues must reconstruct the domain slice of the query
numbered = "".join(aa for _, aa in rec["numbering"] if aa != "-")
assert numbered == seq[rec["query_start"]:rec["query_end"] + 1]
IMGT_CDRS = {"CDR1": (27, 38), "CDR2": (56, 65), "CDR3": (105, 117)}
for name, (lo, hi) in IMGT_CDRS.items():
cdr = "".join(aa for (p, _i), aa in rec["numbering"] if lo <= p <= hi and aa != "-")
print(f"IMGT {name} {cdr}")
kabat = numberer.to_scheme("kabat")["1n8z_H"]
kabat_cdr3 = "".join(aa for (p, _i), aa in kabat["numbering"] if 95 <= p <= 102 and aa != "-")
print(f"Kabat CDR3 {kabat_cdr3}")
PY
python3 try_it.py
Expect.
Invariants — a failure here means this page is wrong, not that upstream moved:
chain_typeisHanderrorisNone.- No IMGT position exceeds 128. That ceiling is the invariant — the slot count is 128 only for a chain with no insertion codes, which this one has none of.
- The non-gap residues equal
seq[query_start:query_end + 1]. The assertion in the script covers this. - IMGT CDR3 is
SRWGGDGFYAMDYand Kabat CDR3 isWGGDGFYAMDY— the two-residue difference is the scheme definitions, and it is the point of the table at the top. - IMGT CDR1
GFNIKDTY, IMGT CDR2IYPTNGYT.
Observed on 2026-10-04 with anarcii 2.0.8 — a mismatch here is drift to investigate, not a bug:
fetched 220 residuesfrom entity 2.span=0-119, locating the variable domain in the first 120 residues of the Fab chain.slots=128— this chain carries no insertion codes.score≈30.88. Model confidence moves with the model; only its position above the cut-off is meaningful.