boltzgen
modelsDesign protein and peptide binders against a target with BoltzGen — the design specification YAML, the six protocols, weight provenance, and how to read the design confidence metrics. Generation, not prediction. Needs an NVIDIA GPU to run the model; the specification, install and output checks run on CPU.
BoltzGen
BoltzGen invents binders. You give it a target — a protein, a peptide, a nucleic acid, a small molecule — and it returns protein or peptide sequences and backbones proposed to bind it, ranked. Nothing about the binder exists beforehand: not its sequence, not its fold, not its length.
That is the whole distinction from the other Boltz model in this registry.
| asked | answered | |
|---|---|---|
boltz2-nim |
here are two molecules, how do they sit together and how tightly do they bind? | one structure, one affinity |
| BoltzGen | here is one molecule, invent something that binds it | thousands of candidate binders, ranked |
They are not alternatives. BoltzGen contains Boltz-2: after the generative step proposes a backbone and an inverse-folding step writes a sequence onto it, a Boltz-2 checkpoint re-folds the designed sequence with the target to see whether the proposal survives being predicted independently. Prediction is BoltzGen's scoring oracle, not its output.
Where this page stops: it covers getting a correct design specification in and reading what
comes out. Deciding which of ten thousand designs to order — interface confidence, self
consistency, sequence liabilities, thresholds and their provenance — is the subject of the
binder-design-filtering skill, which starts where this one ends.
What you must have before the first block that runs the model
An NVIDIA GPU, and a linux x86_64 machine to put it in. This is not a preference. Three
of BoltzGen's hard dependencies — cuequivariance_ops_cu12, cuequivariance_ops_torch_cu12
and cuequivariance_torch — publish CUDA-only wheels, so the package does not install at
all on Apple silicon, an ARM server, or Windows-native Python. There is no CPU fallback:
every model step's trainer configuration hard-codes accelerator: gpu, and the pipeline
calls torch.cuda.get_device_capability() while it is still building the configuration, so
boltzgen run and boltzgen configure fail on a CPU-only host before any step starts.
| requirement | figure, measured 2026-08-28 |
|---|---|
| NVIDIA GPU | required for design, inverse_folding, folding, design_folding, affinity |
| linux x86_64 | the CUDA wheels publish no other platform |
| Python | >=3.11 |
| install download | 3.5 GB of wheels, 2.7 GB of it CUDA runtime |
| model weights | 5.6 GB on first run for protein-anything, to ~/.cache unless --cache or $HF_HOME says otherwise; a further 1.9 GB for the affinity checkpoint |
| small-molecule dictionary | 373 MB, needed even by the CPU-only boltzgen check |
No account, no API key, no click-through licence, no request form. Two licences, and they are separate questions:
- Code —
HannesStark/boltzgenships a rootLICENSEreadingMIT License, Copyright (c) 2025 Hannes Stärk. Confirmed 2026-08-28 against the repository, not the README badge. - Weights — the public checkpoint repository
boltzgen/boltzgen-1on Hugging Face declareslicense: mitin its model card and is not gated: the API reportsgated: false, private: false, and an anonymous request for each checkpoint returns 200. Its declared base model,boltz-community/boltz-2, is likewiselicense: mit.
Do not take the README's checkpoint defaults at face value. Its command-line reference
gives --design_checkpoints a default of boltzgen/boltzgen1_diverse and
boltzgen/boltzgen1_adherence, and names three more one-file repositories beside them.
None of the five appears in the organisation's public model listing and all five answer
401 to an anonymous request. The shipped code does not use them: in 0.3.2 every default
artifact resolves to boltzgen/boltzgen-1, the public repository, so no token is involved
and nothing is gated. The README is stale on this point and the code is what runs. The next
section shows how to confirm that for whatever version you install.
Install
Check the tree resolves on your machine before committing to 3.5 GB. This downloads package metadata only.
python3 -m venv .venv
./.venv/bin/pip install --disable-pip-version-check --quiet --upgrade pip
./.venv/bin/pip install --dry-run --disable-pip-version-check boltzgen==0.3.2 \
2>&1 | grep -E "^(ERROR: (Could not find|No matching)|Would install boltzgen)" \
|| echo "resolved: every dependency has a wheel for this platform"
On macOS arm64, 2026-08-28:
ERROR: Could not find a version that satisfies the requirement cuequivariance_ops_cu12>=0.5.0 (from boltzgen) (from versions: none)
ERROR: No matching distribution found for cuequivariance_ops_cu12>=0.5.0
That is the whole story of the platform requirement, told by pip. On linux x86_64 the same command resolves 101 packages. The size of what it resolves is worth seeing before you start it:
docker run --rm --platform linux/amd64 -v "$PWD":/out python:3.12-slim sh -c '
pip install --quiet --root-user-action=ignore --upgrade pip &&
pip install --dry-run --root-user-action=ignore --report /out/resolve.json boltzgen==0.3.2 >/dev/null &&
python - <<EOF
import json, urllib.request, concurrent.futures
pkgs = json.load(open("/out/resolve.json"))["install"]
def size(p):
req = urllib.request.Request(p["download_info"]["url"], method="HEAD")
with urllib.request.urlopen(req, timeout=30) as r:
return p["metadata"]["name"], int(r.headers.get("Content-Length") or 0)
with concurrent.futures.ThreadPoolExecutor(16) as ex:
rows = dict(ex.map(size, pkgs))
cuda = {n: s for n, s in rows.items() if n.startswith(("nvidia-", "cuequivariance")) or n == "triton"}
ver = {p["metadata"]["name"]: p["metadata"]["version"] for p in pkgs}
print("packages :", len(pkgs))
print("boltzgen / torch :", ver["boltzgen"], "/", ver["torch"])
print("total wheels : %.2f GB" % (sum(rows.values()) / 2**30))
print("CUDA-only wheels : %d packages, %.2f GB" % (len(cuda), sum(cuda.values()) / 2**30))
print("largest :", ", ".join("%s %.0f MB" % (n, s / 2**20)
for n, s in sorted(rows.items(), key=lambda t: -t[1])[:4]))
EOF'
Run 2026-08-28:
packages : 101
boltzgen / torch : 0.3.2 / 2.13.0
total wheels : 3.46 GB
CUDA-only wheels : 23 packages, 2.70 GB
largest : nvidia-cublas-cu12 554 MB, torch 502 MB, nvidia-cublas 404 MB, nvidia-cudnn-cu13 349 MB
The install itself, on a machine that resolved:
python3 -m venv .venv
./.venv/bin/pip install --disable-pip-version-check --upgrade pip
./.venv/bin/pip install --disable-pip-version-check boltzgen==0.3.2
./.venv/bin/boltzgen --help | head -3
usage: boltzgen [-h] [-v] {run,configure,execute,download,check,merge} ...
Boltzgen command line interface
Pin the version. boltzgen is pre-1.0 and its CLI surface — those six subcommands, the
protocol names, the step names, the flag spellings — is the thing this page describes. An
unpinned install silently moves it.
Getting the weights yourself
boltzgen run downloads what it needs on first use, to ~/.cache unless --cache or
$HF_HOME says otherwise. Two reasons to look first: to know what is coming, and to check
where the version you install actually points, because the README and the code disagree.
import urllib.request, urllib.error
# What 0.3.2 resolves to. Read your own version's list out of the installed package with
# python -c "from boltzgen.cli.boltzgen import ARTIFACTS; print(ARTIFACTS)"
# — that dict is what `boltzgen run` and `boltzgen download` both use.
ARTIFACTS = {
"design-diverse": ("boltzgen/boltzgen-1", "boltzgen1_diverse.ckpt", "model"),
"design-adherence": ("boltzgen/boltzgen-1", "boltzgen1_adherence.ckpt", "model"),
"inverse-fold": ("boltzgen/boltzgen-1", "boltzgen1_ifold.ckpt", "model"),
"folding": ("boltzgen/boltzgen-1", "boltz2_conf_final.ckpt", "model"),
"affinity": ("boltzgen/boltzgen-1", "boltz2_aff.ckpt", "model"),
"moldir": ("boltzgen/inference-data", "mols.zip", "dataset"),
}
# The repositories the README's CLI reference names for the same files.
README_REPOS = {
"design-diverse": ("boltzgen/boltzgen1_diverse", "boltzgen1_diverse.ckpt"),
"design-adherence": ("boltzgen/boltzgen1_adherence", "boltzgen1_adherence.ckpt"),
"inverse-fold": ("boltzgen/boltzgen1_ifold", "boltzgen1_ifold.ckpt"),
"folding": ("boltzgen/boltz2_conf_final", "boltz2_conf_final.ckpt"),
"affinity": ("boltzgen/boltz2_affinity", "boltz2_aff.ckpt"),
}
def probe(repo, fname, kind="model"):
seg = "" if kind == "model" else "datasets/"
url = f"https://huggingface.co/{seg}{repo}/resolve/main/{fname}"
req = urllib.request.Request(url, method="HEAD",
headers={"User-Agent": "boltzgen-skill-check"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
# Hugging Face reports the real object size out of band; Content-Length is
# the LFS pointer for a large file.
return r.status, int(r.headers.get("x-linked-size")
or r.headers.get("Content-Length") or 0)
except urllib.error.HTTPError as e:
return e.code, 0
total = 0
print(f"{'artifact':16} {'repository the code uses':28} {'anon':>5} {'size':>9} README repo")
for name, (repo, fname, kind) in ARTIFACTS.items():
code, size = probe(repo, fname, kind)
total += size
other = README_REPOS.get(name)
alt = probe(*other)[0] if other else "-"
print(f"{name:16} {repo:28} {code:>5} {size / 2**30:6.2f} GB {alt}")
print(f"\n{total / 2**30:.1f} GB for every artifact, all of it anonymous")
assert all(probe(r, f, k)[0] == 200 for r, f, k in ARTIFACTS.values()), \
"every artifact the code resolves to must be fetchable without credentials"
artifact repository the code uses anon size README repo
design-diverse boltzgen/boltzgen-1 200 1.80 GB 401
design-adherence boltzgen/boltzgen-1 200 1.80 GB 401
inverse-fold boltzgen/boltzgen-1 200 0.01 GB 401
folding boltzgen/boltzgen-1 200 1.94 GB 401
affinity boltzgen/boltzgen-1 200 1.92 GB 401
moldir boltzgen/inference-data 200 0.36 GB -
7.8 GB for every artifact, all of it anonymous
Every artifact 0.3.2 reaches for is anonymously fetchable, so --models_token — which
carries a default value in the released package — is not doing anything for these
repositories. Do not treat that default as a credential you are relying on; if a version
moves the defaults to the repositories in the last column, this block is how you find out.
Stage them yourself with boltzgen download, for an air-gapped machine or to pin what a
run used. It takes any of design-diverse, design-adherence, inverse-fold, folding,
affinity, moldir or all, and writes a Hugging Face cache tree under --cache that
boltzgen run --cache then reads. The two artifacts the CPU-only check path needs:
./.venv/bin/boltzgen download inverse-fold moldir --cache ./weights
du -sh ./weights
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Using model artifact: weights/models--boltzgen--boltzgen-1/snapshots/c1be29e1f82ffcc72264f64b993c43fb4e0d17f0/boltzgen1_ifold.ckpt
Downloading inverse-fold to weights/models--boltzgen--boltzgen-1/snapshots/c1be29e1f82ffcc72264f64b993c43fb4e0d17f0/boltzgen1_ifold.ckpt
Using dataset artifact: weights/datasets--boltzgen--inference-data/snapshots/c3d36fd276e9caf098c75d4113c6d5eb320b1a4c/mols.zip
Downloading moldir to weights/datasets--boltzgen--inference-data/snapshots/c3d36fd276e9caf098c75d4113c6d5eb320b1a4c/mols.zip
397M ./weights
The warning on the first line is the access test answering itself: the download is
unauthenticated and it works. Add design-diverse design-adherence folding for a real run,
which brings the total to about 5.9 GB, and affinity if the target is a small molecule.
To point at loose files instead of a cache tree, pass paths — this is also how you pin a specific checkpoint after downloading it by hand:
./.venv/bin/boltzgen run design.yaml \
--output run1 --protocol protein-anything \
--design_checkpoints weights/boltzgen1_diverse.ckpt weights/boltzgen1_adherence.ckpt \
--inverse_fold_checkpoint weights/boltzgen1_ifold.ckpt \
--folding_checkpoint weights/boltz2_conf_final.ckpt \
--moldir weights/mols.zip
--design_checkpoints takes several paths and splits the design budget equally between
them; the default uses two. They are named for what they emphasise — diversity of the
generated set, and adherence to what the specification asked for — and the project documents
no more than that, so running only one narrows the population you later filter in a way you
cannot easily reason about. --moldir takes the .zip directly; do not unpack it.
Protocols
--protocol is not a style setting. It changes which pipeline steps run and what the
inverse-folding step is allowed to emit.
| protocol | design | what changes |
|---|---|---|
protein-anything |
proteins binding proteins or peptides | the default; adds a design_folding step that re-folds the binder alone |
peptide-anything |
linear or cyclic peptides | no cysteine in inverse folding, no design_folding, no hydrophobic-patch metric |
protein-small_molecule |
proteins binding a small molecule | adds an affinity step using the Boltz-2 affinity checkpoint |
antibody-anything |
antibody CDRs on an existing framework | no cysteine, no design_folding |
nanobody-anything |
nanobody CDRs | same settings as antibody-anything |
protein-redesign |
redesigning or optimising an existing protein | no design_folding; uses the file's design: block rather than a separate binder chain |
protein-redesign also replaces the ranking metrics wholesale: the design-versus-target
columns are dropped and ranking falls back to design_residue_iptm, iptm, ptm and the
re-folding RMSD, because there is no separate binder chain to score against a target.
The cysteine rule is the one that changes results silently. Under peptide-anything,
antibody-anything and nanobody-anything, inverse folding refuses to place cysteine, so a
cyclic peptide you intended to close with a disulfide comes back without one unless you
write the cysteines into the specification yourself and declare the bond. Override the
default with --inverse_fold_avoid ''.
The design specification
One YAML file describes one design job: what to invent, what to invent it against, and
which parts of the target matter. Everything is a list under entities, and each entry is
one of protein, dna, rna, ligand or file.
entities:
# A designed binder. The number is a length range, sampled per design.
- protein:
id: D
sequence: 60..90
# The target, read out of a structure file.
- file:
path: 4zqk.cif # resolved relative to THIS yaml, not your shell's cwd
include:
- chain:
id: A # label_asym_id — see the next section
binding_types:
- chain:
id: A
binding: 37,39,41,96..108
- chain:
id: B
not_binding: all
The sequence: field of a designed chain is a small language, not a sequence. Digits are
counts of residues to invent; letters are residues you fix.
| written | means |
|---|---|
17 |
invent exactly 17 residues |
60..90 |
invent between 60 and 90, resampled for each design |
3..5C6C3 |
invent 3–5, then a fixed Cys, then invent 6, then a fixed Cys, then invent 3 |
15..20AAAAAAVTTTT18PPP |
mixed fixed and designed stretches, in order |
Ranges are inclusive at both ends, and a range resamples per design — so with
--diffusion_batch_size above 1, every design in a batch shares one sampled length. A
batch size close to the total design count therefore samples the length space badly.
Other keys on a file entity, all optional:
| key | effect |
|---|---|
include |
which chains and residues of the file to use at all; default is everything |
binding_types |
binding: / not_binding: — where the binder should and should not sit |
structure_groups |
visibility: 1 gives the model the structure, 0 withholds it, 2 puts a group in its own frame so its position relative to group 1 is unspecified |
design |
residues inside the loaded file to redesign — this is how CDR and protein-redesign jobs are written |
secondary_structure |
helix: / sheet: / loop: on designed positions |
residue_constraints |
allowed: / disallowed: amino acids at a designed position: |
exclude, include_proximity |
drop chains, or keep only what is near something |
reset_res_index |
renumber a chain's residues consecutively after every selector above has been applied — it does not change what res_index means in include or binding |
constraints: sits beside entities, not inside it, and takes bond: entries naming
[chain_id, residue, atom] on each side — a disulfide, or a staple to a ligand. Residue
numbers in a constraint are counted as if every length range had sampled its minimum,
which is the one place in the format where the number you write is not a position in the
finished chain.
Chain and residue selectors: the one that silently designs the wrong site
Every id: in a design specification is an mmCIF label_asym_id, and every residue
number is a 1-based index into that chain's _entity_poly_seq — the deposited sequence,
which is label_seq_id. Neither is what a structure viewer puts in front of you by default,
and neither is what a paper's numbering uses. BoltzGen's own documentation flags this; what
follows is what it costs, measured.
Three separate consequences, and only the first usually produces an error:
- The chain letter can be different. Author chain ids are whatever the depositors
chose;
label_asym_idis assigned in order over every entity in the file, ligands and waters included. A letter that is not alabel_asym_idat all raisesSpecified chain id … not in file. A letter that happens to be one — the ligand or water chain, or a different polymer — is accepted and gives you the wrong molecule. - The residue numbers are almost always different. Author numbering usually follows the parent protein, so it carries the offset of whatever construct was crystallised.
- Unresolved residues still occupy an index.
_entity_poly_seqholds the whole deposited sequence, so a residue with no density is counted. Selecting it is legal, and the model is being told to attend to a position for which the file has no coordinates.
Read the file before you write the specification:
import urllib.request, gemmi
# PD-1 / PD-L1, and an anti-lysozyme Fab. Public RCSB entries, no account.
for pdb in ("4ZQK", "3HFM"):
urllib.request.urlretrieve(
f"https://files.rcsb.org/download/{pdb}.cif", f"{pdb.lower()}.cif")
def chain_table(path):
"""Every polymer chain BoltzGen can see, keyed the way a design spec keys it."""
st = gemmi.read_structure(path)
st.setup_entities()
auth_of = {r.subchain: ch.name for ch in st[0] for r in ch}
rows = []
for sub in st[0].subchains():
if sub.check_polymer_type() == gemmi.PolymerType.Unknown:
continue # ligands and waters are not design targets
label = sub.subchain_id()
offsets = sorted({r.seqid.num - r.label_seq for r in sub if r.label_seq})
rows.append({
"yaml_id": label, # what `include: chain: id:` takes
"auth_asym_id": auth_of[label], # what a structure viewer shows
"observed": len(sub),
"first_label_seq": sub[0].label_seq,
"first_auth_seq": sub[0].seqid.num,
"auth_minus_label": offsets,
})
return rows
for path in ("4zqk.cif", "3hfm.cif"):
print(path)
for r in chain_table(path):
print(f" id: {r['yaml_id']:<3} viewer shows chain {r['auth_asym_id']:<3} "
f"{r['observed']:>4} obs label_seq starts {r['first_label_seq']:>3} "
f"auth_seq starts {r['first_auth_seq']:>4} "
f"auth-label {r['auth_minus_label']}")
4zqk.cif
id: A viewer shows chain A 115 obs label_seq starts 1 auth_seq starts 18 auth-label [17]
id: B viewer shows chain B 106 obs label_seq starts 1 auth_seq starts 33 auth-label [32]
3hfm.cif
id: A viewer shows chain L 214 obs label_seq starts 1 auth_seq starts 1 auth-label [0]
id: B viewer shows chain H 215 obs label_seq starts 1 auth_seq starts 1 auth-label [0]
id: C viewer shows chain Y 129 obs label_seq starts 1 auth_seq starts 1 auth-label [0]
3hfm.cif is the loud failure: its viewer chains are L, H and Y, so id: H for the
antibody heavy chain raises Specified chain id H not in file and you fix it in seconds.
4zqk.cif is the quiet one — the letters agree, the numbers do not, and nothing complains.
Here is what that costs. PD-L1's binding site for PD-1 is a published, checkable set of residues; the block computes it from the structure rather than trusting a citation:
import gemmi, numpy as np
st = gemmi.read_structure("4zqk.cif")
st.setup_entities()
st.remove_ligands_and_waters()
def polymer(label_asym_id):
return next(s for s in st[0].subchains() if s.subchain_id() == label_asym_id)
target, partner = polymer("A"), polymer("B") # PD-L1, PD-1
def coords(span):
xyz, owner = [], []
for res in span:
for atom in res:
xyz.append([atom.pos.x, atom.pos.y, atom.pos.z])
owner.append(res.label_seq)
return np.array(xyz), np.array(owner)
tx, towner = coords(target)
px, _ = coords(partner)
d = np.linalg.norm(tx[:, None, :] - px[None, :, :], axis=-1)
site = sorted(set(towner[(d <= 4.5).any(axis=1)].tolist()))
by_label = {r.label_seq: r for r in target}
print("PD-L1 (label_asym A) residues within 4.5 A of PD-1")
print(" res_index, what the YAML wants :", ",".join(str(i) for i in site))
print(" auth_seq, what a viewer shows :",
",".join(str(by_label[i].seqid.num) for i in site))
print(f"\n{'res_index':>9} {'residue you meant':<20} {'residue auth numbering gives':<28}")
for i in site[:8]:
right = by_label[i]
wrong = by_label.get(right.seqid.num)
got = f"{wrong.name} (res_index {wrong.label_seq})" if wrong else "out of range"
print(f"{i:>9} {right.name + ' auth ' + str(right.seqid.num):<20} {got:<28}")
shifted = {by_label[i].seqid.num for i in site}
overlap = set(site) & shifted
print(f"\n{len(site)} interface residues; pasting the viewer's numbers instead selects "
f"{len(overlap)} of them.")
PD-L1 (label_asym A) residues within 4.5 A of PD-1
res_index, what the YAML wants : 2,6,9,37,39,41,49,59,96,98,100,102,103,104,105,106,107,108
auth_seq, what a viewer shows : 19,23,26,54,56,58,66,76,113,115,117,119,120,121,122,123,124,125
res_index residue you meant residue auth numbering gives
2 PHE auth 19 MET (res_index 19)
6 VAL auth 23 CYS (res_index 23)
9 ASP auth 26 PRO (res_index 26)
37 ILE auth 54 GLU (res_index 54)
39 TYR auth 56 ASP (res_index 56)
41 GLU auth 58 LYS (res_index 58)
49 GLN auth 66 GLN (res_index 66)
59 VAL auth 76 SER (res_index 76)
18 interface residues; pasting the viewer's numbers instead selects 0 of them.
The auth numbers on the right — 54, 56, 66, 113, 115, 117, 121, 123, 125 — are the PD-L1
contact residues as the literature names them. Written into binding: unconverted, they
select none of the site, and the run completes normally: eighteen residues were named,
eighteen residues were used, the model bound the wrong face of the protein. There is no
error, no warning, and nothing in the output that looks wrong.
Validate the specification against the file it points at
import re, gemmi, yaml, pathlib
spec = """
entities:
# The binder BoltzGen will invent: 60-90 residues, sequence and structure both new.
- protein:
id: D
sequence: 60..90
# The target, read out of a real structure file.
- file:
path: 4zqk.cif
include:
- chain:
id: A # label_asym_id, not the chain letter a viewer shows
binding_types:
- chain:
id: A
binding: 37,39,41,96..108
- chain:
id: B
not_binding: all
"""
pathlib.Path("pdl1_binder.yaml").write_text(spec)
def expand(ranges, n):
"""1-based inclusive residue selectors: '5', '5..9', '..9', '9..', comma-joined."""
out = []
for part in str(ranges).split(","):
part = part.strip()
if re.fullmatch(r"\d+", part):
out.append(int(part))
elif re.fullmatch(r"\d+\.\.\d+", part):
a, b = map(int, part.split("..")); out += list(range(a, b + 1))
elif re.fullmatch(r"\.\.\d+", part):
out += list(range(1, int(part[2:]) + 1))
elif re.fullmatch(r"\d+\.\.", part):
out += list(range(int(part[:-2]), n + 1))
elif part == "all":
out += list(range(1, n + 1))
else:
raise ValueError(f"malformed residue range {part!r}")
return out
def chains_of(path):
"""label_asym_id -> (auth chain, SEQRES length, set of label_seq with coordinates)."""
st = gemmi.read_structure(str(path)); st.setup_entities()
auth_of = {r.subchain: ch.name for ch in st[0] for r in ch}
seqres = {}
for ent in st.entities:
for sub in ent.subchains:
seqres[sub] = len(ent.full_sequence)
out = {}
for sub in st[0].subchains():
if sub.check_polymer_type() == gemmi.PolymerType.Unknown:
continue
lab = sub.subchain_id()
out[lab] = (auth_of[lab], seqres.get(lab, len(sub)),
{r.label_seq for r in sub})
return out
def validate(spec_path):
spec_path = pathlib.Path(spec_path)
doc = yaml.safe_load(spec_path.read_text())
problems, notes = [], []
for ent in doc["entities"]:
kind, body = next(iter(ent.items()))
if kind == "protein":
seq = str(body.get("sequence", ""))
if not re.fullmatch(r"(\d+(\.\.\d+)?|[ACDEFGHIKLMNPQRSTVWY]+)+", seq):
problems.append(f"protein {body.get('id')}: malformed sequence {seq!r}")
continue
if kind != "file":
continue
# File paths resolve against the YAML's directory, not the working directory.
target = (spec_path.parent / body["path"]).resolve()
if not target.exists():
problems.append(f"file: {body['path']} not found next to {spec_path.name}")
continue
avail = chains_of(target)
for section in ("include", "binding_types", "design", "structure_groups"):
for item in body.get(section, []) or []:
block = item.get("chain") or item.get("group") or {}
cid = block.get("id")
if cid is None:
problems.append(f"{section}: entry with no id"); continue
if cid not in avail:
problems.append(
f"{section}: id {cid!r} is not a label_asym_id in "
f"{body['path']} — it has {sorted(avail)} "
f"(viewer chains {sorted({v[0] for v in avail.values()})})")
continue
auth, n, resolved = avail[cid]
for key in ("res_index", "binding", "not_binding"):
if key not in block:
continue
picked = expand(block[key], n)
bad = [i for i in picked if not 1 <= i <= n]
if bad:
problems.append(
f"{section} {cid}.{key}: {bad[:5]} outside 1..{n}")
missing = [i for i in picked if 1 <= i <= n and i not in resolved]
if missing:
notes.append(
f"{section} {cid}.{key}: {len(missing)} of {len(picked)} "
f"residues have no coordinates in the file "
f"(res_index {missing[:5]}) — they still occupy an index")
return problems, notes
problems, notes = validate("pdl1_binder.yaml")
print("pdl1_binder.yaml :", problems or "valid")
for n in notes:
print(" note:", n)
BAD = {
"viewer chain letter": {"entities": [{"file": {"path": "3hfm.cif",
"include": [{"chain": {"id": "H"}}]}}]},
"auth residue numbers": {"entities": [{"file": {"path": "4zqk.cif",
"include": [{"chain": {"id": "A", "res_index": "113..125"}}]}}]},
"zero-based range": {"entities": [{"file": {"path": "4zqk.cif",
"include": [{"chain": {"id": "A", "res_index": "0..10"}}]}}]},
}
print()
for label, doc in BAD.items():
pathlib.Path("bad.yaml").write_text(yaml.safe_dump(doc))
p, _ = validate("bad.yaml")
print(f"rejects {label:22}: {p[0] if p else 'NOT CAUGHT'}")
pdl1_binder.yaml : valid
note: binding_types B.not_binding: 12 of 118 residues have no coordinates in the file (res_index [53, 54, 55, 56, 57]) — they still occupy an index
rejects viewer chain letter : include: id 'H' is not a label_asym_id in 3hfm.cif — it has ['A', 'B', 'C'] (viewer chains ['H', 'L', 'Y'])
rejects auth residue numbers : include A.res_index: [116, 117, 118, 119, 120] outside 1..115
rejects zero-based range : include A.res_index: [0] outside 1..115
Read what that third result does and does not prove. The auth-numbered selection was caught only because part of it ran off the end of a 115-residue chain. An offset that stays in range — which is the common case, and exactly the PD-L1 case above — passes every static check there is. Range checking finds typos. Only converting the numbers finds this.
Two more things the block shows. not_binding: all on chain B silently covers 12 residues
that have no coordinates in the file, because the selector counts the deposited sequence.
And the SEQRES length, not the observed residue count, is the upper bound — chain B has 106
observed residues and 118 valid indices.
How often this actually bites
Run over eighteen public entries chosen to span what people design against — checkpoint complexes, antibody-antigen pairs, a TCR-pMHC, viral spikes, and small single-chain controls:
import os, urllib.request, gemmi
# Eighteen entries spanning what people actually design against: an immune checkpoint
# complex, three antibody-antigen complexes, a TCR-pMHC, two viral spikes, an enzyme
# with eight chains, and small single-chain controls.
IDS = ["4ZQK", "3HFM", "2DD8", "1YY9", "1AO7", "6VXX", "6XR8", "7KMG", "8R3A",
"6M1U", "7RPZ", "1G13", "1BRS", "5O45", "1UBQ", "1HHP", "2HHB", "1CRN"]
rows = []
for pdb in IDS:
fn = f"{pdb.lower()}.cif"
if not os.path.exists(fn):
urllib.request.urlretrieve(f"https://files.rcsb.org/download/{pdb}.cif", fn)
st = gemmi.read_structure(fn); st.setup_entities()
auth_of = {r.subchain: ch.name for ch in st[0] for r in ch}
seqres = {s: len(e.full_sequence) for e in st.entities for s in e.subchains}
for sub in st[0].subchains():
if sub.check_polymer_type() == gemmi.PolymerType.Unknown:
continue
lab = sub.subchain_id()
rows.append((pdb, lab, auth_of[lab], seqres.get(lab, len(sub)), len(sub),
sorted({r.seqid.num - r.label_seq for r in sub if r.label_seq})))
chain = [r for r in rows if r[1] != r[2]]
num = [r for r in rows if r[5] != [0]]
multi = [r for r in num if len(r[5]) > 1]
gaps = [r for r in rows if r[3] != r[4]]
print(f"{len(rows)} polymer chains over {len(IDS)} entries")
print(f" design id differs from the viewer's chain letter : {len(chain):>2}"
f" ({100*len(chain)//len(rows)}%)")
print(f" auth_seq_id differs from res_index : {len(num):>2}"
f" ({100*len(num)//len(rows)}%)")
print(f" ...by a non-constant offset : {len(multi):>2}")
print(f" SEQRES longer than the observed residues : {len(gaps):>2}"
f" ({100*len(gaps)//len(rows)}%)")
print("\nworst cases:")
for r in sorted(rows, key=lambda r: -(len(r[5]) + (r[1] != r[2]) + (r[3] - r[4]) / 500)):
if (r[1] != r[2]) + (r[5] != [0]) + (r[3] != r[4]) < 3:
continue
print(f" {r[0]} id={r[1]:<2} viewer={r[2]:<2} indices={r[3]:<5} with coords={r[4]:<5} "
f"auth-label offsets={r[5]}")
56 polymer chains over 18 entries
design id differs from the viewer's chain letter : 8 (14%)
auth_seq_id differs from res_index : 16 (28%)
...by a non-constant offset : 6
SEQRES longer than the observed residues : 34 (60%)
worst cases:
2DD8 id=A viewer=H indices=245 with coords=220 auth-label offsets=[-5, -4, -3, -2, -1, 0]
2DD8 id=B viewer=L indices=213 with coords=212 auth-label offsets=[0, 1]
2DD8 id=C viewer=S indices=202 with coords=192 auth-label offsets=[316]
Three findings worth carrying:
- Six of the sixteen renumbered chains carry a non-constant offset, so "subtract a
constant" is not a workaround.
2DD8— an antibody bound to a coronavirus receptor binding domain, which is the exact shape of a real design job — needs six different offsets on one chain, has viewer chainsH/L/Sagainst design idsA/B/C, and hides 25 residues with no coordinates. All three problems, one file. - Sixty percent of chains have indices with no coordinates. On
6VXX, the SARS-CoV-2 spike, that is 309 of 1281 positions. Abinding:range crossing one of those regions is legal and is asking the model to attend to a position the file does not describe. 7RPZ, which ships in BoltzGen's own example directory, carries offsets of -2 and -1. The trap is not confined to exotic entries.
Structures predicted by AlphaFold and deposited in AlphaFold DB have label_seq_id equal to
auth_seq_id and no unresolved residues, so a design job against a predicted target avoids
all three. Convert anyway — the habit is what protects the experimental case.
boltzgen check — the only part of the pipeline that runs without a GPU
check parses a specification, resolves it against the target file, reports how many
residues will be designed and how many are unresolved, and writes an annotated mmCIF you can
open in a viewer to see the binding site you actually selected. It runs the parser, not the
model, so it needs no GPU and no checkpoint — only the 373 MB molecule dictionary.
./.venv/bin/boltzgen check pdl1_binder.yaml --output checked --cache ./weights
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Using dataset artifact: weights/datasets--boltzgen--inference-data/snapshots/c3d36fd276e9caf098c75d4113c6d5eb320b1a4c/mols.zip
Creating output directory: checked
************** Checking design spec: pdl1_binder.yaml **************
Total designed residues: 82
There are 0 unresolved residues and 47 unresolved atoms in the target.
Design specification visualization is written to checked/pdl1_binder.cif
********************************************************************
Run it on every specification before spending GPU time. It catches the wrong chain id, an out-of-range residue index and a malformed range immediately, and its output file is the only way to see a wrong-but-in-range selection before the run.
What the checked file actually contains
Two things about the output are not what the command's description implies, and both are worth seeing before you open it in a viewer.
import gemmi
from collections import Counter
doc = gemmi.cif.read("checked/pdl1_binder.cif")
block = doc.sole_block()
chains = Counter(block.find_loop("_atom_site.label_asym_id"))
print("chains written :", dict(chains))
b = Counter(float(v) for v in block.find_loop("_atom_site.B_iso_or_equiv"))
print("B_iso_or_equiv values :", dict(sorted(b.items())))
qa = block.find("_ma_qa_metric.", ["name", "type"])
print("_ma_qa_metric declares:", [(r[0], r[1]) for r in qa])
vals = Counter(float(v) for v in block.find_loop("_ma_qa_metric_local.metric_value"))
print("its metric_value :", dict(sorted(vals.items())))
# The binder chain the spec asked for is not in this file: check writes only residues
# that already have coordinates, and nothing has been designed yet.
assert "D" not in chains, "the designed chain should not be in a check output"
# B-factor is 100 per designed residue plus 80 per declared binding-site residue.
assert set(b) <= {0.0, 80.0, 100.0, 180.0}, "B-factor is a design/binding code"
# The table is declared as pLDDT and holds the design colour weight times 100.
assert set(vals) <= {80.0, 100.0}, "the pLDDT column carries a colour flag"
assert vals[100.0] == 16, "one entry per residue named in binding:"
print("\nOnly the target is written — the designed chain has no coordinates yet.")
print("B_iso_or_equiv: 100 per designed residue + 80 per declared binding-site residue.")
print("_ma_qa_metric_local is declared pLDDT and holds 100 for binding-site residues,")
print("80 for the rest. Nothing in this file was predicted.")
chains written : {'A': 874}
B_iso_or_equiv values : {0.0: 745, 80.0: 129}
_ma_qa_metric declares: [('pLDDT', 'pLDDT')]
its metric_value : {80.0: 99, 100.0: 16}
Only the target is written — the designed chain has no coordinates yet.
B_iso_or_equiv: 100 per designed residue + 80 per declared binding-site residue.
_ma_qa_metric_local is declared pLDDT and holds 100 for binding-site residues,
80 for the rest. Nothing in this file was predicted.
The binder is not in the file. check writes only residues that already have
coordinates, and nothing has been designed yet — so the visualisation is the target, with
your binding-site selection marked on it. That is still the check you want (it is the only
way to see a wrong-but-in-range selection before the run), but do not go looking for the
designed chain.
Both annotation columns are named after confidence and carry neither.
_atom_site.B_iso_or_equiv is 100 per designed residue plus 80 per declared
binding-site residue, so it takes one of four values and is 0 or 80 in a target-only
file like this one. _ma_qa_metric_local is declared as pLDDT in _ma_qa_metric and
holds the design colour weight times 100 — 100 for a binding-site residue, 80 for the
rest. Colouring by either is what makes the selection visible, which is the point; reading
either as model confidence would be reading a prediction that has not happened yet.
Total designed residues is resampled every time you run check. The specification
above asks for 60..90, and three consecutive runs reported 86, 63 and 76. The number is a
draw from the range, not a property of the specification, so do not treat a change in it as
a change in what you asked for.
Running the pipeline
./.venv/bin/boltzgen run pdl1_binder.yaml \
--output run1 \
--protocol protein-anything \
--num_designs 50 \
--budget 30
Seven steps run in order, and --steps restricts to any subset of them:
| step | what it does | needs |
|---|---|---|
design |
the generative model proposes backbones for the binder | GPU |
inverse_folding |
writes a sequence onto each backbone | GPU |
folding |
re-folds the designed sequence with the target, using Boltz-2 | GPU |
design_folding |
re-folds the binder alone, to see whether it holds its shape unaided | GPU |
affinity |
Boltz-2 affinity prediction, small-molecule targets only | GPU |
analysis |
computes interface and structure metrics over the refolded complexes | CPU-bound |
filtering |
applies hard filters, ranks, and picks a diverse subset | seconds |
--num_designs is the count of intermediate designs generated; --budget is how many
survive into the final diversity-optimised set, and defaults to 30. Start at 50 to confirm
the specification behaves, then raise it — the project's own guidance is 10,000 to 60,000
intermediate designs for a real campaign. That ratio is the point of the pipeline rather
than an inefficiency in it: what you are buying with GPU time is a population large enough
that filtering has something to choose from. Cost is roughly linear in --num_designs and
grows with target size; a 50-design smoke test is minutes on a datacentre GPU and a full
campaign is GPU-days. Those are orders of magnitude, not measurements — no timing on this
page was measured, because the validating host has no GPU.
Two flags that save whole runs:
--reuserestarts an interrupted run without losing finished designs, and generates only the shortfall. There is no reason not to pass it.--steps filteringre-runs only the last step against results you already have, in seconds. Filtering is meant to be re-run with different thresholds; regenerating designs to change a cut-off is wasted GPU time.boltzgen mergecombines several output directories first, so parallel runs across machines can be filtered as one population.
What comes out
run1/
config/, steps.yaml what was actually run
intermediate_designs/ backbones from the design step (.cif + .npz)
intermediate_designs_inverse_folded/ backbones with sequences on them
refold_cif/ the designed binder re-folded WITH the target
refold_design_cif/ the binder re-folded alone
aggregate_metrics_analyze.csv
per_target_metrics_analyze.csv
final_ranked_designs/
intermediate_ranked_<N>_designs/ top N on quality alone
final_<budget>_designs/ quality plus diversity — the set to order from
all_designs_metrics.csv every design filtering considered
final_designs_metrics_<budget>.csv the selected set
results_overview.pdf
Two things about that tree matter more than the rest.
The structures in final_ranked_designs/ are copies of refold_cif/, not of the
generated backbones. What you order is the re-prediction, which is the only structure that
was produced without knowing the answer.
In intermediate_designs_inverse_folded/, designed residues have backbone coordinates
only — every side-chain atom is at (0, 0, 0). Those files are an intermediate, not a
model. Anything that computes packing, contacts or SASA over them will produce numbers
without complaining. Use refold_cif/.
Reading the design confidence metrics
The metric columns in all_designs_metrics.csv are not the fields you know from a folding
model, and three of them have names that invite the wrong reading. The definitions below
come from the shipped scoring code, and the block after them runs that code to show what the
names mean.
| column | what it actually measures |
|---|---|
ptm |
pTM over the whole complex — the familiar one |
iptm |
pTM over all cross-chain token pairs |
design_ptm |
pTM restricted to pairs inside the designed chain — the binder's own fold, and nothing about the target |
target_ptm |
the same, restricted to the target |
design_iptm |
pTM over pairs between the designed chain and the target |
design_to_target_iptm |
pTM over pairs between the designed residues and the target |
design_residue_iptm |
pTM over cross-chain pairs where either side is a designed residue — the one that still works when every chain contains designed residues |
design_iiptm |
as design_to_target_iptm, restricted to designed residues with an atom within 8 Å of the target |
design_to_target_ipsae / target_to_design_ipsae |
ipSAE between the designed chain and the target, computed in each direction over pairs whose PAE is under 15 Å |
design_ipsae_min |
the smaller of those two |
min_design_to_target_pae |
the single best predicted aligned error across the interface |
delta_sasa_original / _refolded |
buried surface area, on the generated and the re-folded structure |
plip_hbonds / plip_saltbridge |
interface hydrogen bonds and salt bridges |
filter_rmsd, filter_rmsd_design |
how far the re-fold moved from the design, with and without the target |
affinity_probability_binary1 |
Boltz-2 binder probability, protein-small_molecule only |
design_ptm is not the complex pTM. Everywhere else in this field a ptm is global.
Here it is masked to the designed chain, so a binder that folds beautifully and misses the
target entirely scores as well as one that binds. It is a useful number — it is the closest
thing to "is this a real protein" — but it is not a binding score, and ranking on it ranks
foldability.
min_design_to_target_pae is a minimum over the whole interface block, not a mean. One
confidently placed pair sets it, so it is the most optimistic number in the row and moves
independently of how much of the interface is real. The default ranking uses it as
neg_min_design_to_target_pae — negated, because ranking maximises — alongside the ipTM
columns rather than instead of them.
design_iptm and design_to_target_iptm differ only when part of a chain is designed.
For a de novo binder the whole chain is new, the two masks coincide, and the numbers are
identical. For CDR grafting or protein-redesign, design_mask is a handful of residues
inside a chain that is otherwise fixed, and the two diverge — design_iptm scores the whole
scaffold against the target while design_to_target_iptm scores only what you changed. The
default ranking uses design_to_target_iptm.
Under protein-redesign it uses neither, and the reason is worth knowing: "target" is
defined as everything outside the designed chains, so when every chain carries designed
residues there is no target left and both columns collapse to zero. That protocol overrides
the ranking to design_residue_iptm, iptm, ptm and the re-folding RMSD instead. If you
ever see design_to_target_iptm at exactly 0 across a whole run, this is why.
BoltzGen's scoring functions import and run without weights or a GPU, so this is checkable rather than something to take on trust:
import torch
from boltzgen.model.layers.confidence_utils import compute_ptms
from boltzgen.data import const
BINS = 64
CENTERS = torch.arange(0.5 * 32 / BINS, 32.0, 32 / BINS) # the PAE head's bins
def pae_logits(target_pae, sigma=1.5):
"""Logits whose softmax peaks at the requested PAE for every token pair."""
return -((CENTERS.view(1, 1, 1, -1) - target_pae.unsqueeze(-1)) ** 2) / (2 * sigma**2)
def scene(n_target, n_binder, designed, intra=1.0, designed_iface=1.0,
scaffold_iface=1.0, sep=6.0):
"""A two-chain complex; `designed` is the slice of the binder chain being designed.
`designed_iface` is the PAE between the designed residues and the target;
`scaffold_iface` is the PAE between the rest of the binder chain and the target.
They differ only for a partial redesign — a CDR graft on a fixed framework.
"""
n = n_target + n_binder
asym = torch.tensor([0] * n_target + [1] * n_binder)
# Both masks are boolean: the interface-contact step scatters a bool into
# zeros_like(design_mask), so a float mask raises inside compute_ptms.
chain_design = torch.zeros(n, dtype=torch.bool)
chain_design[n_target:] = True # the whole binder chain
design = torch.zeros(n, dtype=torch.bool)
design[designed] = True # the residues actually being designed
same = asym[:, None] == asym[None, :]
is_design = design[:, None] | design[None, :]
pae = torch.where(same, torch.tensor(intra),
torch.where(is_design, torch.tensor(designed_iface),
torch.tensor(scaffold_iface)))
# Backbone-ish coordinates: two parallel rods `sep` angstrom apart. The N-CA-C angle
# has to be a real one — compute_ptms discards any frame whose |cos| exceeds 0.9063
# as collinear, and a flat or folded-back triple silently zeroes every pTM.
idx = torch.arange(n).float()
ca = torch.stack([idx * 3.8 % 60, torch.where(asym == 0, 0.0, sep), idx * 0.1], -1)
xyz = torch.stack([ca + torch.tensor([-1.0, 0.9, 0.0]),
ca,
ca + torch.tensor([1.0, 0.9, 0.0])], 1).reshape(1, -1, 3)
a2t = torch.zeros(1, 3 * n, n)
a2t[0, torch.arange(3 * n), torch.arange(n).repeat_interleave(3)] = 1
feats = {
"frames_idx": torch.arange(3 * n).reshape(1, n, 3),
"token_pad_mask": torch.ones(1, n),
"atom_pad_mask": torch.ones(1, 3 * n),
"asym_id": asym.unsqueeze(0),
"mol_type": torch.full((1, n), const.chain_type_ids["PROTEIN"]),
"atom_to_token": a2t,
"design_mask": design.unsqueeze(0),
"chain_design_mask": chain_design.unsqueeze(0),
}
return pae_logits(pae.unsqueeze(0)), xyz, feats
NAMES = ("ptm iptm ligand_iptm protein_iptm chain_pair_iptm design_to_target_iptm "
"design_residue_iptm design_iptm design_iiptm target_ptm design_ptm "
"design_ipsae_min design_to_target_ipsae target_to_design_ipsae "
"chain_pair_ipsae").split()
def report(label, **kw):
logits, xyz, feats = scene(**kw)
out = compute_ptms(logits, xyz, feats, multiplicity=1)
d = {k: v for k, v in zip(NAMES, out) if torch.is_tensor(v)}
print(f"{label:36} " + " ".join(
f"{k}={d[k].item():.3f}" for k in
("ptm", "design_ptm", "design_iptm", "design_to_target_iptm")))
return d
T, B = 100, 60
whole = slice(T, T + B)
print("100-residue target, 60-residue binder chain\n")
a = report("de novo binder, docked", n_target=T, n_binder=B, designed=whole)
b = report("de novo binder, interface vague", n_target=T, n_binder=B, designed=whole,
designed_iface=22.0, scaffold_iface=22.0)
c = report("12 CDR residues on a docked scaffold", n_target=T, n_binder=B,
designed=slice(T + 20, T + 32), designed_iface=22.0, scaffold_iface=1.0)
print(f"\ndesign_ptm survives the interface collapsing: {a['design_ptm'].item():.3f} -> "
f"{b['design_ptm'].item():.3f}, while design_iptm goes "
f"{a['design_iptm'].item():.3f} -> {b['design_iptm'].item():.3f}")
assert b["design_ptm"] > 0.8, "design_ptm must not depend on the interface"
assert b["design_iptm"] < 0.2, "design_iptm must collapse with the interface"
print(f"design_iptm and design_to_target_iptm agree on a fully designed chain "
f"({a['design_iptm'].item():.3f} / {a['design_to_target_iptm'].item():.3f}) and "
f"diverge on a partly designed one "
f"({c['design_iptm'].item():.3f} / {c['design_to_target_iptm'].item():.3f})")
assert torch.isclose(a["design_iptm"], a["design_to_target_iptm"], atol=1e-4)
assert not torch.isclose(c["design_iptm"], c["design_to_target_iptm"], atol=1e-2)
print(f"ipSAE is directional: design->target {c['design_to_target_ipsae'].item():.3f}, "
f"target->design {c['target_to_design_ipsae'].item():.3f}, "
f"design_ipsae_min {c['design_ipsae_min'].item():.3f}")
100-residue target, 60-residue binder chain
de novo binder, docked ptm=0.873 design_ptm=0.873 design_iptm=0.873 design_to_target_iptm=0.873
de novo binder, interface vague ptm=0.562 design_ptm=0.873 design_iptm=0.044 design_to_target_iptm=0.044
12 CDR residues on a docked scaffold ptm=0.873 design_ptm=0.873 design_iptm=0.873 design_to_target_iptm=0.044
design_ptm survives the interface collapsing: 0.873 -> 0.873, while design_iptm goes 0.873 -> 0.044
design_iptm and design_to_target_iptm agree on a fully designed chain (0.873 / 0.873) and diverge on a partly designed one (0.873 / 0.044)
ipSAE is directional: design->target 0.832, target->design 0.638, design_ipsae_min 0.638
The first two rows are the same de novo binder differing only in interface confidence.
ptm moves, design_iptm collapses, and design_ptm does not move at all — because it
never looked at the interface. Ranking on design_ptm would put those two designs level.
The third row is the one to remember for antibody and redesign work. The scaffold is
confidently docked and the twelve residues you actually designed are not, and the two
interface columns say opposite things: design_iptm 0.873, design_to_target_iptm 0.044.
Read the first and you ship a design whose new residues are placed at random.
ipSAE is directional, and by enough to matter — 0.832 one way and 0.638 the other on the
third row. It counts only pairs whose PAE is under 15 Å, so the two directions divide by
different residue counts. design_ipsae_min takes the pessimistic one, which is the number
to prefer.
Choosing what to order
Stop here and change tools. BoltzGen's own filtering step gives you a ranked, diversity-
optimised set, and it is the right first cut — but the decision of what to synthesise wants
thresholds with provenance, an independent re-prediction, sequence liability screening and
a realistic view of hit rates. The binder-design-filtering skill covers exactly that, and
consumes what refold_cif/ and all_designs_metrics.csv contain.
Three things the filtering step does before you see a number, which change what the counts
in all_designs_metrics.csv mean:
- Designs with an identical designed sequence are dropped, keeping the first. Two backbones that inverse-fold to the same sequence are one row, not two.
has_xremoves any design whose sequence containsX. An unresolved token is a failed design, not a low-scoring one.--filter_biased, on by default, removes amino-acid composition outliers — caps on the alanine, glycine, glutamate, leucine and valine fractions. Turning it off with--filter_biased=falserecovers designs that are real but compositionally odd, and also the poly-alanine ones.
Two knobs worth setting before you leave BoltzGen's own filter, because they are cheap and the defaults are conservative:
--alphatrades quality against sequence diversity in the final set —0.0picks the best-scoring designs and nothing else,1.0picks the most different ones. Defaults are0.001, or0.01underpeptide-anything. Nearly pure quality. Raise it when the top of your ranking is one design and fifty near-copies.--additional_filterstakes hard cuts as'feature>threshold'or'feature<threshold'—>when higher is better. Quote them, or the shell reads the angle brackets as redirection.
Try it
A cold check of the two claims this page rests on: that a design specification's chain and residue selectors are not the ones a viewer shows, and that the weights are still obtainable without credentials. No GPU, no BoltzGen install — the checks run against the file formats and the hosts.
Data — two RCSB entries, both open, no account:
https://files.rcsb.org/download/4ZQK.cif PD-1 / PD-L1 complex
https://files.rcsb.org/download/3HFM.cif anti-lysozyme Fab with lysozyme
wwPDB releases coordinate data into the public domain under CC0, so neither file needs an
account or carries a use restriction. Both were confirmed reachable 2026-08-28, as was
boltzgen/boltzgen-1 on Hugging Face, whose model card declares MIT and whose files need no
token.
python3 -m venv .venv
./.venv/bin/pip install --disable-pip-version-check --quiet "gemmi>=0.6.5" numpy pyyaml
curl -fsSL -o 4zqk.cif https://files.rcsb.org/download/4ZQK.cif
curl -fsSL -o 3hfm.cif https://files.rcsb.org/download/3HFM.cif
cat > check_spec.py <<'PY'
import re, sys, urllib.request, urllib.error, gemmi, numpy as np, yaml, pathlib
# ---- 1. what a design spec's `id:` and residue numbers actually mean ---------------
def chains_of(path):
"""label_asym_id -> (viewer chain, SEQRES length, {label_seq with coordinates})."""
st = gemmi.read_structure(path); st.setup_entities()
auth_of = {r.subchain: ch.name for ch in st[0] for r in ch}
seqres = {s: len(e.full_sequence) for e in st.entities for s in e.subchains}
return {s.subchain_id(): (auth_of[s.subchain_id()],
seqres.get(s.subchain_id(), len(s)),
{r.label_seq for r in s})
for s in st[0].subchains()
if s.check_polymer_type() != gemmi.PolymerType.Unknown}
for path in ("4zqk.cif", "3hfm.cif"):
for cid, (auth, n, resolved) in chains_of(path).items():
print(f"{path} id: {cid:<3} viewer chain {auth:<3} "
f"{n:>4} indices, {len(resolved):>4} with coordinates")
assert set(chains_of("3hfm.cif")) == {"A", "B", "C"}, "3HFM design ids are A/B/C"
assert {v[0] for v in chains_of("3hfm.cif").values()} == {"H", "L", "Y"}, \
"3HFM viewer chains are H/L/Y — a spec written with those raises"
# ---- 2. what pasting a viewer's residue numbers costs ------------------------------
st = gemmi.read_structure("4zqk.cif"); st.setup_entities(); st.remove_ligands_and_waters()
pol = {s.subchain_id(): s for s in st[0].subchains()}
def coords(span):
xyz = [[a.pos.x, a.pos.y, a.pos.z] for r in span for a in r]
owner = [r.label_seq for r in span for _ in r]
return np.array(xyz), np.array(owner)
tx, towner = coords(pol["A"]); px, _ = coords(pol["B"])
d = np.linalg.norm(tx[:, None, :] - px[None, :, :], axis=-1)
site = sorted(set(towner[(d <= 4.5).any(axis=1)].tolist()))
by_label = {r.label_seq: r for r in pol["A"]}
auth = [by_label[i].seqid.num for i in site]
print(f"\nPD-L1 interface, res_index : {','.join(map(str, site))}")
print(f"PD-L1 interface, auth_seq : {','.join(map(str, auth))}")
overlap = set(site) & set(auth)
print(f"pasting auth numbers selects {len(overlap)} of {len(site)} interface residues")
assert len(overlap) == 0, "the two numberings must not be interchangeable here"
# ---- 3. the spec validator, on a good spec and a bad one ---------------------------
def expand(spec, n):
out = []
for part in str(spec).split(","):
part = part.strip()
if part == "all": out += list(range(1, n + 1))
elif re.fullmatch(r"\d+", part): out.append(int(part))
elif re.fullmatch(r"\d+\.\.\d+", part):
a, b = map(int, part.split("..")); out += list(range(a, b + 1))
elif re.fullmatch(r"\.\.\d+", part): out += list(range(1, int(part[2:]) + 1))
elif re.fullmatch(r"\d+\.\.", part): out += list(range(int(part[:-2]), n + 1))
else: raise ValueError(f"malformed range {part!r}")
return out
def validate(doc, base=pathlib.Path(".")):
problems = []
for ent in doc["entities"]:
kind, body = next(iter(ent.items()))
if kind != "file":
continue
avail = chains_of(str(base / body["path"]))
for section in ("include", "binding_types"):
for item in body.get(section, []) or []:
block = item["chain"]
cid = block["id"]
if cid not in avail:
problems.append(f"{section}: id {cid!r} is not a label_asym_id — "
f"the file has {sorted(avail)} "
f"(viewer chains {sorted({v[0] for v in avail.values()})})")
continue
n = avail[cid][1]
for key in ("res_index", "binding", "not_binding"):
if key in block:
bad = [i for i in expand(block[key], n) if not 1 <= i <= n]
if bad:
problems.append(f"{section} {cid}.{key}: {bad[:3]} outside 1..{n}")
return problems
good = yaml.safe_load(f"""
entities:
- protein: {{id: D, sequence: 60..90}}
- file:
path: 4zqk.cif
include: [{{chain: {{id: A}}}}]
binding_types: [{{chain: {{id: A, binding: "{','.join(map(str, site))}"}}}}]
""")
bad = yaml.safe_load("""
entities:
- file:
path: 3hfm.cif
include: [{chain: {id: H}}]
""")
print("\ngood spec :", validate(good) or "valid")
print("bad spec :", validate(bad)[0])
assert not validate(good) and validate(bad)
# ---- 4. are the weights still obtainable without credentials? ----------------------
def probe(url):
try:
with urllib.request.urlopen(urllib.request.Request(
url, method="HEAD", headers={"User-Agent": "boltzgen-skill-check"}),
timeout=30) as r:
return r.status, int(r.headers.get("x-linked-size")
or r.headers.get("Content-Length") or 0)
except urllib.error.HTTPError as e:
return e.code, 0
print()
for name in ("boltzgen1_ifold.ckpt", "boltzgen1_diverse.ckpt", "boltz2_conf_final.ckpt"):
code, size = probe(f"https://huggingface.co/boltzgen/boltzgen-1/resolve/main/{name}")
print(f"boltzgen-1/{name:24} what the code resolves to {code} {size / 2**30:5.2f} GB")
code, _ = probe("https://huggingface.co/boltzgen/boltzgen1_ifold/resolve/main/"
"boltzgen1_ifold.ckpt")
print(f"boltzgen1_ifold what the README names {code}")
assert probe("https://huggingface.co/boltzgen/boltzgen-1/resolve/main/"
"boltzgen1_ifold.ckpt")[0] == 200, "the public checkpoints must be reachable"
print("\nall assertions passed")
PY
./.venv/bin/python check_spec.py
Expect
Invariants — these hold regardless of release, and a failure means this page is wrong:
3HFM's design ids areA,B,Cwhile a viewer showsH,L,Y, so a specification written from the viewer raises rather than misbehaving.4ZQKchainBhas more valid indices (118) than residues with coordinates (106): the selector counts the deposited sequence, not the modelled one.- The eighteen PD-L1 interface residues, expressed as
res_indexand asauth_seq_id, are disjoint sets — the assertion that pasting one for the other selects none of the site. - The validator accepts the converted specification and rejects the viewer-chain one.
- The checkpoints answer 200 without credentials — the last assertion. If that starts failing, the model is no longer obtainable on the route this page documents and the skill needs a human decision, not a patch.
Observed 2026-08-28 — checkpoint sizes move when the weights are rebuilt, and the 401 on
the repository the README names becomes a 200 if it is ever published. Treat a mismatch on
these as drift to investigate:
4zqk.cif id: A viewer chain A 115 indices, 115 with coordinates
4zqk.cif id: B viewer chain B 118 indices, 106 with coordinates
3hfm.cif id: A viewer chain L 214 indices, 214 with coordinates
3hfm.cif id: B viewer chain H 215 indices, 215 with coordinates
3hfm.cif id: C viewer chain Y 129 indices, 129 with coordinates
PD-L1 interface, res_index : 2,6,9,37,39,41,49,59,96,98,100,102,103,104,105,106,107,108
PD-L1 interface, auth_seq : 19,23,26,54,56,58,66,76,113,115,117,119,120,121,122,123,124,125
pasting auth numbers selects 0 of 18 interface residues
good spec : valid
bad spec : include: id 'H' is not a label_asym_id — the file has ['A', 'B', 'C'] (viewer chains ['H', 'L', 'Y'])
boltzgen-1/boltzgen1_ifold.ckpt what the code resolves to 200 0.01 GB
boltzgen-1/boltzgen1_diverse.ckpt what the code resolves to 200 1.80 GB
boltzgen-1/boltz2_conf_final.ckpt what the code resolves to 200 1.94 GB
boltzgen1_ifold what the README names 401
all assertions passed
Citing
Stark, H. et al. BoltzGen: Toward universal binder design. bioRxiv (2025). doi:10.1101/2025.11.20.689494