Concepts and FAQ
How immunum picks a chain, numbers it under each scheme, scores the result and handles input. The examples use Python; every interface behaves the same way.
Chains
immunum numbers seven chains. Results report a chain by its one-letter code. Every name in the table is accepted, in any case; spaces around a name are ignored, and a chain named twice is kept once.
| Chain | Code | Also accepted |
|---|---|---|
| Antibody heavy | H |
IGH, heavy |
| Antibody kappa | K |
IGK, kappa |
| Antibody lambda | L |
IGL, lambda |
| T-cell receptor α | A |
TRA, alpha |
| T-cell receptor β | B |
TRB, beta |
| T-cell receptor γ | G |
TRG, gamma |
| T-cell receptor δ | D |
TRD, delta |
Wherever a list of chains is expected, a group can stand in for several chains:
| Group | Chains |
|---|---|
ig |
H, K, L |
tcr |
A, B, G, D |
all |
H, K, L, A, B, G, D |
Each list entry is one name: ["ig", "B"] asks for H, K, L and B, while "igh,igk" is rejected
as an unknown chain. (The command line splits its --chain value on commas itself; see
Command line.) Groups are only accepted where several chains are, not where one
chain is expected, such as regions_for.
Chain detection
The annotator aligns the sequence to the consensus profile of every chain you asked for and keeps
the chain whose alignment scores highest. The confidence is then computed for that
alignment. Each requested chain is one more alignment per sequence, so time grows roughly in
proportion to the number of chains: ["all"] takes about five times as long as ["H"]. Pass only the
chains you expect in your data.
Numbering schemes
| Scheme | Accepted names (any case) | Reported as | Chains |
|---|---|---|---|
| IMGT | imgt, i |
IMGT |
all seven |
| Kabat | kabat, k |
Kabat |
H, K, L |
| Chothia | chothia, c |
Chothia |
H, K, L |
| Martin | martin, m |
Martin |
H, K, L |
| AHo | aho, a |
Aho |
H, K, L |
Only IMGT numbers T-cell receptor chains. Asking another scheme for one raises an
unsupported_chain error when the annotator is built; scheme_supports_chain(scheme, chain)
tells you beforehand.
Every sequence is aligned once against IMGT-numbered consensus profiles (IMGT positions 1–128). The other schemes are derived from that alignment by per-scheme rules that map IMGT positions to the scheme's positions. The detected chain, the confidence and the residues numbered are therefore the same under every scheme; only the position labels and region boundaries change.
Region boundaries
CDR boundaries per scheme, inclusive, from immunum.regions_for(scheme, chain). The framework
regions fill the gaps: FR1 runs from 1 to just before CDR1, FR4 from just after CDR3 to the last
position.
| Scheme | Chain | CDR1 | CDR2 | CDR3 | Positions |
|---|---|---|---|---|---|
| IMGT | any | 27–38 | 56–65 | 105–117 | 1–128 |
| AHo | H, K, L | 25–42 | 57–77 | 109–138 | 1–149 |
| Kabat | H | 31–35 | 50–65 | 95–102 | 1–113 |
| Kabat | K, L | 24–34 | 50–56 | 89–97 | 1–107 |
| Chothia | H | 26–32 | 52–56 | 96–101 | 1–113 |
| Chothia | K, L | 26–32 | 50–52 | 91–96 | 1–107 |
| Martin | H | 26–35 | 50–58 | 95–102 | 1–113 |
| Martin | K, L | 24–34 | 50–56 | 89–97 | 1–107 |
IMGT and AHo use the same positions for every chain, so one number means the same place on a heavy and a light chain. Kabat, Chothia and Martin define heavy and light CDRs differently. Martin's CDRs follow the AbM definition.
The same sequence therefore gets different CDRs. For this heavy chain:
| Scheme | CDR-H1 | CDR-H2 | CDR-H3 |
|---|---|---|---|
| IMGT | GGSFSTYA |
VIPLLTIT |
AREGTTGKPIGAFAH |
| Kabat | TYALS |
GVIPLLTITNYAPRFQG |
EGTTGKPIGAFAH |
| Chothia | GGSFSTY |
IPLLTI |
GTTGKPIGAFA |
| Martin | GGSFSTYALS |
GVIPLLTITN |
EGTTGKPIGAFAH |
| AHo | ASGGSFSTYALS |
GVIPLLTITNYAPRFQGR |
EGTTGKPIGAFAH |
from immunum import Annotator, regions_for
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
assert regions_for("kabat", "H")["cdr1"] == (31, 35)
assert regions_for("chothia", "H")["cdr1"] == (26, 32)
assert regions_for("kabat", "K")["cdr1"] == (24, 34)
assert regions_for("imgt", "H") == regions_for("imgt", "L")
kabat = Annotator(chains=["H"], scheme="kabat").segment(heavy)
chothia = Annotator(chains=["H"], scheme="chothia").segment(heavy)
assert kabat.cdr1 == "TYALS"
assert chothia.cdr1 == "GGSFSTY"
Insertions and deletions
A loop longer than a scheme's positions gets insertion letters; positions are strings for that
reason, such as "111A". Results list positions in sequence order.
- IMGT splits insertions between the two centre positions of a loop: 32/33 in CDR1, 60/61 in
CDR2 and 111/112 in CDR3. The heavy chain above has a 15-residue CDR3, numbered with
111Aand112A; four residues more give111A,111B,111C,112C,112B,112A. A loop shorter than its positions leaves out the centre ones first: the 6-residue CDR1QDVNTAof the kappa chain below is numbered 27, 28, 29, 36, 37, 38. - Kabat and Chothia put every insertion after one position. For the heavy chain above
both give
52A,82A–82Cand100A–100E. - Martin heavy chains are numbered like Chothia, except that framework 3 insertions go on 72
instead of 82:
72A–72Cfor the heavy chain above. - AHo has positions for longer loops (1–149); none of the sequences on this page get an insertion letter under AHo.
from immunum import Annotator
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
kappa = "DIQMTQSPSSLSASVGDRVTITCRASQDVNTAVAWYQQKPGKAPKLLIYSASFLYSGVPSRFSGSRSGTDFTLTISSLQPEDFATYYCQQHYTTPPTFGQGTKVEIK"
def insertions(scheme, sequence):
numbering = Annotator(chains=["ig"], scheme=scheme).number(sequence).numbering
return [position for position in numbering if position[-1].isalpha()]
assert insertions("imgt", heavy) == ["111A", "112A"]
assert insertions("kabat", heavy) == [
"52A", "82A", "82B", "82C", "100A", "100B", "100C", "100D", "100E"
]
assert insertions("martin", heavy) == [
"52A", "72A", "72B", "72C", "100A", "100B", "100C", "100D", "100E"
]
kappa_numbering = Annotator(chains=["ig"], scheme="imgt").number(kappa).numbering
cdr1 = [p for p in kappa_numbering if 27 <= int(p) <= 38]
assert cdr1 == ["27", "28", "29", "36", "37", "38"]
Confidence
Every result carries a confidence between 0 and 1: how well the sequence matches the consensus
profile of the detected chain. It is computed from the alignment:
- Each consensus position scores every amino acid by BLOSUM62, averaged over the residues seen at that position in the chain's reference sequences and weighted by their frequency. Letters other than the 20 standard amino acids score −4.
- Only positions occupied in more than 90% of the reference sequences count. In the heavy-chain profile that is every framework position but only 21 of the 35 CDR positions: the centre of each loop, where shorter loops have no residue, doesn't count.
- Over the counted positions between the first and last consensus position the alignment reaches, immunum sums the score of the residue aligned there, or the position's gap penalty when the alignment skips it.
- That sum is divided by the best possible sum over the same positions and clamped to 0–1.
Inserted residues, and residues before or after the domain, don't enter the score. Because only the reached positions count, a fragment can score as high as a full domain: the first 30 residues of the heavy chain above score 0.77, the whole domain 0.78. Changing CDR3 residues barely moves the score; changing a conserved framework residue does.
Minimum confidence
An annotator has a min_confidence, 0.5 unless you pass another value in [0, 1]. When the best
alignment of a sequence scores below it, the sequence isn't numbered: its result has error_kind
low_confidence and the score in error. In the domain search it also
decides when to stop looking for further domains.
from immunum import Annotator
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
gfp = "MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK"
annotator = Annotator(chains=["ig"], scheme="imgt")
print(round(annotator.number(heavy).confidence, 3)) # 0.785
result = annotator.number(gfp)
print(result.error_kind) # low_confidence
print(result.error) # alignment confidence 0.1525 is below min_confidence 0.5000
unfiltered = Annotator(chains=["ig"], scheme="imgt", min_confidence=0.0)
print(round(unfiltered.number(gfp).confidence, 3)) # 0.153
Choosing a value
Scores of the bundled validation sequences (fixtures/validation), numbered with
chains=["all"]:
| Chain | Sequences | Lowest | Median | Below 0.5 |
|---|---|---|---|---|
| H | 2452 | 0.550 | 0.803 | 0 |
| K | 1485 | 0.583 | 0.850 | 0 |
| L | 371 | 0.553 | 0.821 | 0 |
| A | 865 | 0.285 | 0.645 | 77 |
| B | 934 | 0.269 | 0.765 | 57 |
| G | 25 | 0.567 | 0.849 | 0 |
| D | 23 | 0.766 | 0.825 | 0 |
Green fluorescent protein scores 0.153 and hen egg-white lysozyme 0.082 against the antibody chains.
- Keep 0.5 for antibodies: it kept every antibody in the validation sets.
- Lower it for TCR α and β chains, where the default rejects some real sequences: at 0.25 every TRA and TRB validation sequence passes.
- Raise it to reject sequences that align poorly, such as heavily mutated or engineered domains, at the cost of losing some real ones.
0.0disables the filter. Every valid sequence then gets numbered, even one that isn't an antibody: GFP gets four residues numbered as a kappa chain.
Prefix and postfix
Segmentation accounts for every residue: joining prefix, fr1 … fr4 and postfix in order
gives back the input sequence.
prefixholds the residues before FR1, such as a signal peptide, or the linker after a previous domain.postfixholds the residues after FR4, such as the start of a constant region.
Both are often empty. number leaves these residues out of numbering; query_start and
query_end (0-based, inclusive) give the span it numbered.
from immunum import Annotator
leader = "MEFGLSWVFLVALLRGVQC"
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
constant = "ASTKGPSVFPLAPSSKSTSGGTAALGCLVKDYFPEPVTVSWNSGALTSGVHTFPAVLQSSGLYSLSSVVTVPSSSLGTQTYICNVNHKPSNTKVDKKV"
sequence = leader + heavy + constant
annotator = Annotator(chains=["ig"], scheme="imgt")
regions = annotator.segment(sequence)
assert regions.prefix == leader
assert regions.fr1 == "QVQLVQSGAEVKRPGSSVTVSCKAS"
assert regions.postfix == constant
assert "".join(regions.as_dict().values()) == sequence
result = annotator.number(sequence)
assert (result.query_start, result.query_end) == (19, 140)
assert sequence[result.query_start : result.query_end + 1] == heavy
With several domains (segment_domains), every residue still lands in exactly one domain's
regions: a domain's prefix holds the residues since the previous domain (or the start of the
sequence), and only the last domain has a postfix. The domains' regions in order rebuild the
sequence.
Multiple domains
number and segment return one domain: the best-scoring alignment of the whole sequence. For
an scFv with a heavy and a kappa domain, number returns the kappa domain, because it scores
higher. number_domains and segment_domains return every domain, ordered by position. On the
command line, --all-domains does the same.
The domain search:
- Align the whole sequence and keep the best alignment as a domain. If it scores below
min_confidence, the result islow_confidence, as fornumber. - Search the residues before that domain and the residues after it the same way, each on its
own, and keep each part's best alignment if it reaches
min_confidence. - Repeat on the residues around every domain found. A part shorter than 30 residues isn't searched.
- A domain shorter than 30 residues isn't reported, but the residues around it are still searched.
The lists are never empty. When no domain is found, the list holds a single result with error
set: low_confidence when the best alignment scores too low, or domain_too_short when it is
confident but shorter than 30 residues. An invalid sequence gives a single invalid_sequence
result. number has no length minimum for the domain it returns, so a fragment can be numbered
by number and still give domain_too_short from number_domains.
from immunum import Annotator
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
kappa = "DIQMTQSPSSLSASVGDRVTITCRASQDVNTAVAWYQQKPGKAPKLLIYSASFLYSGVPSRFSGSRSGTDFTLTISSLQPEDFATYYCQQHYTTPPTFGQGTKVEIK"
linker = "GGGGSGGGGSGGGGS"
scfv = heavy + linker + kappa
annotator = Annotator(chains=["ig"], scheme="imgt")
assert annotator.number(scfv).chain == "K"
domains = annotator.number_domains(scfv)
assert [d.chain for d in domains] == ["H", "K"]
assert domains[1].query_start == len(heavy + linker)
heavy_regions, kappa_regions = annotator.segment_domains(scfv)
assert heavy_regions.postfix == ""
assert kappa_regions.prefix == linker
# The last 28 residues of the heavy chain, then 40 glycines
fragment = heavy[-28:] + "G" * 40
assert annotator.number(fragment).error is None
(result,) = annotator.number_domains(fragment)
assert result.error_kind == "domain_too_short"
print(result.error) # domain length 28 is below minimum 30
Input rules
- Length: 30 to 10,000 characters. Shorter or longer sequences get an
invalid_sequenceerror. - Characters: every character must be an ASCII letter,
A–Zora–z. Digits, spaces, newlines,*,-,.and non-ASCII characters give aninvalid_sequenceerror that names the character and its 0-based position. - Non-standard letters such as
X,BorZare accepted and numbered, but score as a mismatch (−4), so they lower the confidence at counted positions: oneXat IMGT position 6 of the heavy chain above lowers it from 0.785 to 0.767. - Case: lower and upper case score the same, and residues come back as you gave them.
- Whitespace: the Python, JavaScript and Rust APIs take the sequence as given, so a trailing newline or a space inside the sequence is an invalid character; strip it first. The command line trims each input line and joins the lines of a FASTA record; whitespace inside a line is still an error. The demo removes all whitespace before numbering.
from immunum import Annotator
heavy = "QVQLVQSGAEVKRPGSSVTVSCKASGGSFSTYALSWVRQAPGRGLEWMGGVIPLLTITNYAPRFQGRITITADRSTSTAYLELNSLRPEDTAVYYCAREGTTGKPIGAFAHWGQGTLVTVSS"
annotator = Annotator(chains=["H"], scheme="imgt")
assert annotator.number(heavy.lower()).numbering["1"] == "q"
result = annotator.number(heavy + "\n")
print(result.error_kind) # invalid_sequence
print(result.error) # invalid character '\n' at position 122
assert annotator.number("".join((heavy + "\n").split())).error is None
with_x = annotator.number(heavy[:5] + "X" + heavy[6:])
assert with_x.numbering["6"] == "X"
print(annotator.number(heavy[:29]).error) # sequence length 29 is below minimum 30
See Errors for every error kind.
FAQ
Why is my sequence low_confidence?
Its best alignment scored below min_confidence (0.5 by default). The error message gives the
score. Common causes:
- It isn't an antibody or TCR variable domain.
- You didn't ask for its chain. The TCR β chain 1AO7_E from the validation set scores 0.30
against
["ig"]and 0.76 against["tcr"]. - It is a TCR α or β chain that scores low; see Choosing a value.
- It is a nucleotide sequence; see below.
- It holds many non-standard letters such as
Xat conserved positions.
To see the score of a rejected sequence, number it with min_confidence=0.0.
Which scheme should I use?
Use the scheme your downstream tools or data expect. Otherwise:
- IMGT is the only one that numbers TCR chains, uses the same positions for every chain, and is what immunum aligns to internally.
- AHo also uses the same positions for every chain, with room for longer loops (1–149).
- Kabat, Chothia and Martin have separate heavy and light layouts and differ mostly in where the CDRs start and end; see Region boundaries.
The numbered residues and the confidence don't depend on the scheme, so you can number a sequence under several schemes and compare.
Why does my numbering differ from ANARCI for some sequences?
immunum doesn't run ANARCI. It aligns to its own consensus profiles and derives every scheme from that IMGT alignment, so where a numbering is ambiguous it can choose differently. The benchmarks measure per-region agreement with the validation data, which for antibodies is where ANARCI and antpack agree.
Is the Annotator thread-safe? Can I pickle it?
Yes. In Python an Annotator can be shared between threads and pickled with
its settings, so it works with thread and process pools; see
Batches, threads and processes. In Rust it is
Send, Sync and Clone; see Sharing an annotator.
How do I number nucleotide sequences?
Translate them to amino acids first, in the right frame; immunum numbers protein sequences only.
A DNA sequence is all letters, so it isn't rejected as invalid, but it is scored as amino acids
and comes back low_confidence. Remove stop codons (*) from the translation: * is an invalid
character.
from immunum import Annotator
# The first 147 nucleotides of a human IGHV1 gene
dna = "CAGGTGCAGCTGGTGCAGTCTGGGGCTGAGGTGAAGAAGCCTGGGTCCTCGGTGAAGGTCTCCTGCAAGGCTTCTGGAGGCACCTTCAGCAGCTATGCTATCAGCTGGGTGCGACAGGCCCCTGGACAAGGGCTTGAGTGGATGGGA"
result = Annotator(chains=["ig"], scheme="imgt").number(dna)
assert result.error_kind == "low_confidence"