antifold
modelsDesign antibody and nanobody CDR sequences on a fixed Fv backbone with AntiFold — per-residue log probabilities, region and IMGT-position constraints, CPU inference. Covers the renamed output columns and how a non-Fv chain still scores.
Antibody inverse folding with AntiFold
AntiFold answers one question: given this antibody variable-domain backbone, what sequence would fold into it? It is ESM-IF1 fine-tuned on solved antibody structures, so unlike a general inverse-folding model it has seen thousands of Fv folds and knows that the framework is nearly invariant while the loops are not.
That is the whole reason to reach for it rather than proteinmpnn. A general model asked to
redesign an Fv will happily rewrite the framework, because from backbone geometry alone there
is nothing special about it. AntiFold scores every position in the IMGT frame, so you can say
"resample CDRH3 and leave the rest alone" in the vocabulary the structure is actually
annotated in, and the constraint means what an antibody engineer thinks it means.
Two things about this page are worth knowing before you start. The output column names in the
project's own README are a release behind the code, so a reader copying them gets a
KeyError; the real ones are below. And AntiFold does not refuse input it was not trained
for — hand it a Fab, or a structure that is not an antibody at all, and it returns a full,
well-formed table of per-residue log probabilities either way. Both of those are documented
here because they are the two ways a run looks like it worked and did not.
What you need before the first run
- Python 3.13.
setup.pydeclarespython_requires=">=3.13"; an older interpreter is refused at install time, not at run time. - ~550 MB of model weights, fetched automatically on the first inference from the project's GitHub release and written next to the installed package. Nothing to sign up for and no token — but the first run is a download, so do not time it as inference.
- No GPU. CUDA is used when present and CPU is the default otherwise; a paired Fv scores
in a few seconds on a laptop core.
--deviceoverrides the choice. - BSD 3-Clause, for the code and the weights alike. No non-commercial clause, no click-through, no registration.
Numbering is handled for you. AntiFold renumbers input chains to IMGT with ANARCII before
inference, so a structure straight out of the PDB works and you do not need to pre-number
anything. Pass --skip_anarcii_numbering only if your file is already IMGT-numbered and you
want it left exactly as it is.
Install
From source, not PyPI. The published antifold wheel is years behind the repository and
predates the switch to ANARCII numbering, so the flags and the output columns in this page do
not apply to it.
git clone https://github.com/oxpig/AntiFold
cd AntiFold
python3.13 -m venv .venv
. .venv/bin/activate
pip install .
On Linux that pulls the CUDA build of PyTorch — requirements.txt asks only for
torch>=2.3, and the default wheel brings the NVIDIA runtime libraries with it, several
gigabytes of them, whether or not a GPU exists. Inference is unaffected and still runs on
CPU. If the download matters to you, install a CPU-only torch into the environment before
pip install .; the project points at PyTorch's own install matrix for picking that wheel.
There is no console script. Invoke the module:
python -m antifold.main --help
Give the structure a path, not a bare filename. AntiFold splits --pdb_file into a
directory and a stem and then re-joins them, so --pdb_file 6y1l.pdb becomes a lookup for
/6y1l.pdb at the filesystem root and dies with FileNotFoundError: Unable to find PDB/CIF file: /6y1l.[pdb|cif]. Write ./6y1l.pdb or an absolute path. This is the first thing that
goes wrong for everybody, and the message does not point at the cause.
Score a backbone
One paired Fv, heavy chain H and light chain L:
curl -sS -o 6y1l.pdb https://files.rcsb.org/download/6Y1L.pdb
python -m antifold.main \
--pdb_file ./6y1l.pdb \
--heavy_chain H \
--light_chain L \
--out_dir out
That writes out/6y1l_HL.csv — one row per scored residue, 27 columns:
| column | what it is |
|---|---|
pdb_posins |
residue number with insertion code, e.g. 112A |
pdb_chain |
chain as given in the input |
pdb_res |
the residue that is there |
top_res |
the model's argmax for the position |
pdb_pos |
IMGT position as an integer |
perplexity |
how tolerant the position is — 1.0 means one residue fits, higher means many do |
assumed_region |
IMGT region label, or empty |
A … Y |
log probability for each of the 20 amino acids, in alphabetical order |
The README names two of these differently and omits a third. It documents aa_orig and
aa_pred and omits assumed_region; the code emits pdb_res, top_res and assumed_region, in a
different column order. Read the header, not the README. The twenty amino-acid columns are a
log-softmax, so exp() across a row sums to 1 and top_res is always the argmax of that
row — both cheap things to assert if you are building on top of the file.
The constant domain is scored, and it should not be
Most antibody structures in the PDB are Fabs, not Fvs, and AntiFold will score the whole
chain you give it. On 6Y1L — a Fab — that is 440 rows: 223 for the heavy chain, 217 for the
light. Only 226 of them are in the variable domain.
assumed_region is the tell. It is populated exactly where pdb_pos is 1-128 and empty
above it, so the CH1 and CL residues come back scored but unlabelled. They are also scored
badly, because the model never saw a constant domain in training:
python - <<'PY'
import pandas as pd
df = pd.read_csv("out/6y1l_HL.csv")
fv = df[df.assumed_region.notna()]
ch = df[df.assumed_region.isna()]
print("rows %d variable %d constant %d" % (len(df), len(fv), len(ch)))
print("recovery whole chain %.4f variable domain only %.4f"
% ((df.top_res == df.pdb_res).mean(), (fv.top_res == fv.pdb_res).mean()))
print("mean perplexity variable %.3f constant %.3f"
% (fv.perplexity.mean(), ch.perplexity.mean()))
PY
Native-sequence recovery over the whole file is 0.689. Over the variable domain alone it is
0.907. Nothing failed; 214 rows the model has no business predicting dragged the headline
number down by twenty points. Any recovery or perplexity figure you report from AntiFold has
to say which rows it was computed over, and the answer should almost always be
assumed_region.notna().
Trimming the structure to the variable domain before you run is the cleaner fix where you have the option. Filtering afterwards is equivalent for scoring, and is what you want when the antigen or the constant domain is load-bearing for the geometry.
A structure that is not an antibody still scores
This is the failure mode to internalise, because nothing about the output announces it. Hand AntiFold ubiquitin:
curl -sS -o 1ubq.pdb https://files.rcsb.org/download/1UBQ.pdb
python -m antifold.main --pdb_file ./1ubq.pdb --heavy_chain A --out_dir ubq
It logs one line — Hchain=A is not an antibody chain, numbered as given — switches itself
into custom-chain mode, and writes ubq/1ubq_A.csv with 76 complete rows of log
probabilities. No error, no non-zero exit.
Worse, the rows carry region labels. assumed_region is assigned from the residue number
against the IMGT ranges and nothing else, so ubiquitin's residues 27-38 are labelled CDRH1
and 56-65 CDRH2. The column is named assumed for exactly this reason, and a pipeline that
filters on assumed_region == "CDRH3" will find positions in any protein you give it.
The numbers are the only signal, and they are soft: mean perplexity 3.96 on ubiquitin against
1.82 on a real Fv, recovery 0.539 against 0.907. Those are a smell, not a test. The test is
the log line. If you are running AntiFold over a set of structures, grep the run log for
is not an antibody chain and treat a hit as a failed input, because the CSV will not tell
you.
Sample sequences in a region
Scoring gives you the landscape; --num_seq_per_target draws from it.
python -m antifold.main \
--pdb_file ./6y1l.pdb \
--heavy_chain H \
--light_chain L \
--num_seq_per_target 5 \
--sampling_temp "0.2" \
--regions "CDRH3" \
--seed 42 \
--out_dir sampled
--regions takes IMGT region names, chain-prefixed position ranges, or a mix of both:
CDRH3,CDRL1,FWH2— one region on one chain.CDR1 CDR2 CDR3— the named region on both chains.CDRH/CDRL— all three CDRs of one chain;allH/allL— a whole chain.H:111-112 L:66-70— explicit positions. TheH:/L:prefix is required, because IMGT numbering restarts on each chain, and a range covers insertion codes, soH:111also selects111Aand111B.CDRH3 L:66-70— regions and positions together.
Everything outside the selection is held fixed, including the constant domain. --seed makes
the draw reproducible; at --sampling_temp 0.2 the samples sit close to the argmax, and
raising it towards 1.0 buys diversity at the cost of likelihood.
Two ways this goes wrong, both of which raise rather than designing nothing. An unrecognised entry is rejected before inference, with the full list of region names and a reminder that positions need a prefix:
Invalid --regions entry '111-112'. Choose an IMGT region name (all, allH, allL, FWH, FWL,
CDRH, CDRL, FW1, ... CDR3, CDRH3, CDRL3, FW4, FWH4, FWL4) or chain-prefixed IMGT positions,
e.g. H:111, L:66-70 or H:10-12,15
And a valid name that matches no position in this structure — CDRL3 on a nanobody, or a
prefix naming a chain you did not run — raises a ValueError naming the structure and the
selection.
Reading score, global_score and seq_recovery
The FASTA headers are where this gets misread, and all three numbers mean something narrower than they look.
>6y1l_HL , score=1.0470, global_score=1.0470, regions=['CDRH3'], model_name=AntiFold, seed=42
>seq1 T=0.20, sample=1, score=0.9329, global_score=1.0062, seq_recovery=0.9795, mutations=H:T108Y,...
Lower is better. Both scores are a mean negative log-likelihood of the sequence under the
model — NLLLoss over the log-softmax, averaged across the masked positions. The README
calls them "average log-odds", which reads as higher-is-better and is the opposite of what the
code computes.
score is region-restricted, global_score is not — except on the first record, where
they are both the global figure. The native row discards its region score and prints the
global one twice, so the native score=1.0470 is not comparable with a sample's
score=0.9329. Compare global_score to global_score: 1.0470 native against 1.0051-1.0074
across the five samples, which is the real statement that low-temperature sampling improved
the sequence's likelihood.
seq_recovery is diluted by every residue you passed in. It is 1 - mutations / length
over the full scored sequence, so on this Fab a CDRH3 redesign that replaced 8 of 12 loop
residues reports 0.9818. That is arithmetic, not agreement: 8 mutations in 440 residues.
Recovery within the designed region is the number you want, and you compute it yourself from
the mutations= field.
Nanobodies and single chains
A VHH is one chain, so name it and leave the light chain out. The repository ships a VHH and
an antibody-antigen complex under data/, so from the clone these run as written:
python -m antifold.main \
--pdb_file ./data/nanobody/8oi2_imgt.pdb \
--nanobody_chain B \
--num_seq_per_target 3 \
--sampling_temp "0.2" \
--regions "CDRH3" \
--seed 42 \
--out_dir nb
Mutations come back prefixed H: even though the chain is B — the nanobody chain is mapped
onto the heavy-chain frame, and the prefix refers to that frame rather than to your chain ID.
Asking for a light-chain region raises rather than designing nothing:
ValueError: 8oi2_imgt_B: --regions CDRL3 selects no positions in this structure. Check the
chain prefixes match the chains being run (a single heavy chain has no L: positions)
For an antibody-antigen complex, name the antigen so its geometry is used as context while only the antibody is designed. This enables custom-chain mode, and the antigen's residues are scored alongside the Fv:
python -m antifold.main \
--pdb_file ./data/antibody_antigen/3hfm.pdb \
--heavy_chain H \
--light_chain L \
--antigen_chain Y \
--out_dir complex
One caution on raw downloads here. ANARCII finds the antibody domain in whatever chain you
name, so a VHH expressed as a fusion comes back renumbered around that domain and the rest of
the construct is carried along — PDB entry 8OI2, whose chain B is a 507-residue fusion,
scores as 466 rows with IMGT positions repeating. Check the row count against the domain you
meant to design before reading anything else out of the file.
What AntiFold does not tell you
It returns sequences that fit a backbone. It does not say whether they express, fold, bind, or
survive a developability screen, and a low global_score is not an affinity prediction. The
model has no notion of the antigen beyond the coordinates you give it, and a CDRH3 resampled
without --antigen_chain is designed against empty space where the epitope used to be.
Three neighbours cover the questions this one does not: thermompnn for whether a
substitution destabilises the fold, immunebuilder for a model when you have sequence but no
structure, and binder-design-filtering for ranking a designed complex. proteinmpnn
remains the right tool for a non-antibody backbone — which is precisely the input AntiFold
accepts and should not.
ESM-IF1, the foundation AntiFold fine-tunes, is archived read-only upstream. AntiFold is the
maintained path for this task, and --esm_if1_mode reaches the original weights if you need
a baseline.
Try it
Data. RCSB PDB entry 6Y1L, the Fab of antibody A.17 with an L47R mutation — chains H
and L, a public anonymous ~650 kB download with no licence to accept and no account.
Confirmed reachable 2026-10-19. The block also pulls the 541 MB AntiFold weights from the
project's GitHub release, which the first inference does on its own, and pip install .
brings the CUDA PyTorch wheels described above — several gigabytes in total, and the bulk of
the runtime. Inference itself is seconds.
Run. In a fresh empty directory, with python3.13 and git on the path. This provisions
everything it uses:
set -e
git clone --quiet https://github.com/oxpig/AntiFold
cd AntiFold
git checkout --quiet 9918344067f34a6b026f2634fdba212fc3e6e986
python3.13 -m venv .venv
. .venv/bin/activate
pip install --quiet .
curl -sS -o 6y1l.pdb https://files.rcsb.org/download/6Y1L.pdb
python -m antifold.main --pdb_file ./6y1l.pdb --heavy_chain H --light_chain L \
--num_seq_per_target 5 --sampling_temp "0.2" --regions "CDRH3" --seed 42 \
--out_dir out >/dev/null 2>&1
python - <<'CHECK'
import hashlib, re, numpy as np, pandas as pd, antifold.antiscripts as A
AAS = list("ACDEFGHIKLMNPQRSTVWY")
df = pd.read_csv("out/6y1l_HL.csv")
fv, ch = df[df.assumed_region.notna()], df[df.assumed_region.isna()]
lp = df[AAS].to_numpy()
heads = [l[1:].strip() for l in open("out/6y1l_HL.fasta") if l.startswith(">")]
num = lambda h, k: float(re.search(rf"{k}=(-?[\d.]+)", h).group(1))
muts = lambda h: re.search(r"mutations=(\S+)", h).group(1).split(",")
native, samples = heads[0], heads[1:]
# invariants — properties of the method and the file format; a failure here is a real problem
assert list(df.columns[:7]) == ["pdb_posins", "pdb_chain", "pdb_res", "top_res",
"pdb_pos", "perplexity", "assumed_region"]
assert np.allclose(np.exp(lp).sum(1), 1.0, atol=1e-3), "the 20 columns are a log-softmax"
assert ([AAS[i] for i in lp.argmax(1)] == df.top_res).all(), "top_res is the argmax"
assert fv.pdb_pos.max() <= 128 and ch.pdb_pos.min() > 128, "assumed_region marks IMGT 1-128"
assert len(ch) > 0, "6Y1L is a Fab, so the constant domain is scored and unlabelled"
assert fv.perplexity.mean() < ch.perplexity.mean(), "the model is out of domain above 128"
for h in samples:
assert all((p := re.match(r"H:[A-Z](\d+)", m)) and 105 <= int(p.group(1)) <= 117
for m in muts(h)), \
"--regions CDRH3 confines every mutation to heavy-chain IMGT 105-117"
assert abs(num(h, "seq_recovery") - (1 - len(muts(h)) / len(df))) < 5e-5, \
"seq_recovery divides by every scored residue, not by the designed region"
assert num(native, "score") == num(native, "global_score"), \
"the native record prints the global score twice"
assert hashlib.sha256(open(A.ANTIFOLD_WEIGHTS_PATH, "rb").read()).hexdigest() \
== A.ANTIFOLD_WEIGHTS_SHA256, "weights match the hash the project pins"
# observed values — AntiFold 9918344 on 2026-10-19; drift to re-record, not failures
# Lower is better, and on this structure and seed every T=0.2 sample beat the native score.
# That is an outcome of the sampling, not a property of the method.
assert all(num(h, "global_score") < num(native, "global_score") for h in samples)
assert (len(df), len(fv), len(ch)) == (440, 226, 214)
assert abs((df.top_res == df.pdb_res).mean() - 0.6886) < 5e-4
assert abs((fv.top_res == fv.pdb_res).mean() - 0.9071) < 5e-4
assert len(samples) == 5 and sorted(len(muts(h)) for h in samples) == [8, 8, 8, 8, 9]
print("rows %d variable %d constant %d" % (len(df), len(fv), len(ch)))
print("recovery whole chain %.4f variable domain %.4f"
% ((df.top_res == df.pdb_res).mean(), (fv.top_res == fv.pdb_res).mean()))
print("mean perplexity variable %.3f constant %.3f"
% (fv.perplexity.mean(), ch.perplexity.mean()))
print("global_score native %.4f samples %.4f-%.4f"
% (num(native, "global_score"),
min(num(h, "global_score") for h in samples),
max(num(h, "global_score") for h in samples)))
print("CDRH3 mutations per sample", sorted(len(muts(h)) for h in samples))
print("OK")
CHECK
Expect. Every assertion passes and the block prints:
rows 440 variable 226 constant 214
recovery whole chain 0.6886 variable domain 0.9071
mean perplexity variable 1.817 constant 4.753
global_score native 1.0470 samples 1.0051-1.0074
CDRH3 mutations per sample [8, 8, 8, 8, 9]
OK
The split is deliberate. The invariants — the column names, the twenty columns being a
log-softmax, top_res as the argmax, assumed_region marking IMGT 1-128, --regions CDRH3
confining mutations to heavy-chain 105-117, the seq_recovery denominator, the native record
printing its global score twice, and the pinned weight hash — are properties of the method and
the file format, so a failure there means something is genuinely wrong. The observed values
below them are dated to AntiFold at commit 9918344 on 2026-10-19; if those move while the
invariants hold, the weights or the structure changed and the numbers want re-recording
rather than the page rewriting.
Provenance
- Code and weights: https://github.com/oxpig/AntiFold, BSD 3-Clause.
- Paper: Høie, Hummer et al., AntiFold: improved antibody structure-based design using inverse folding, Bioinformatics Advances 5(1):vbae202, 2025. https://doi.org/10.1093/bioadv/vbae202
- Numbering: ANARCII, https://github.com/oxpig/ANARCII, BSD 3-Clause.
- Structures: RCSB PDB, https://www.rcsb.org.