proteinmpnn
modelsDesign amino acid sequences for a fixed protein backbone with ProteinMPNN — inverse folding on CPU, chain and position constraints, homooligomer tying, and the soluble and CA-only checkpoints. Covers what native-sequence recovery does and does not tell you, and the silent ways a backbone stops being designed.
Inverse folding with ProteinMPNN
ProteinMPNN answers one question: given these backbone coordinates, what amino acid sequence would fold into them? It is a message-passing graph network over backbone geometry, not a language model over sequences, and it does not know what the protein is for. Point it at a backbone and it returns sequences; whether those sequences fold, express or function is a separate question this page is careful not to blur into the first one.
Two properties make it the default for this job. It is small — every inference checkpoint is under 7 MB — so it runs on a laptop CPU in seconds, and it takes constraints: fix a chain, fix a position, tie residues across chains so a homooligomer gets one gene instead of three.
The part that costs people real experiments is not the model. It is that a backbone can stop being designed without the tool saying so, and the output looks the same either way. Most of this page is about detecting that.
What a reader must obtain
Nothing gated. The code and every checkpoint live in one public GitHub repository,
dauparas/ProteinMPNN, and clone anonymously — no account, no token, no request form, no
click-through.
On licensing, what is actually in the repository as of 27 Aug 2026: a single LICENSE file
at the root, the MIT License, Copyright (c) 2022 Justas Dauparas, and a byte-identical
copy at training/LICENSE. The weight files sit in vanilla_model_weights/,
soluble_model_weights/ and ca_model_weights/ inside that same repository, and there is
no separate terms file, notice, or licence statement attached to them anywhere in the tree
or in the README.
No GPU is required and none is used by default. The runner selects CUDA only when
torch.cuda.is_available(), so on a machine without an NVIDIA card it runs on CPU without
being asked to, including on Apple silicon — it does not reach for Metal. Every number on
this page was measured that way. See How long this takes on CPU for the scaling.
What you do need: Python 3, PyTorch, NumPy, and about 200 MB of disk for the clone, which is nearly all model weights. The repository states no tighter version bound than that; this page was verified on Python 3.12.8 with torch 2.13.0 and numpy 2.5.2.
Install
The clone is the install — there is no PyPI package for ProteinMPNN, and nothing to build.
python3 -m venv venv
./venv/bin/pip install --disable-pip-version-check torch numpy
git clone --depth 1 https://github.com/dauparas/ProteinMPNN.git
du -sh ProteinMPNN
Everything below assumes that layout — a venv/ and a ProteinMPNN/ beside each other,
commands run from the directory containing both.
The first design
Ubiquitin, one chain, 76 residues. --seed matters: leave it at its default of 0 and the
runner picks a random one, so nothing you do is reproducible.
curl -fsSL -o 1UBQ.pdb https://files.rcsb.org/download/1UBQ.pdb
./venv/bin/python ProteinMPNN/protein_mpnn_run.py \
--pdb_path 1UBQ.pdb --out_folder design \
--num_seq_per_target 4 --sampling_temp 0.1 --seed 37
cat design/seqs/1UBQ.fa
Two things about that invocation are load-bearing.
Use curl -f. RCSB answers a missing entry — or one whose structure is too large for the
PDB format to hold — with an HTML error page under a 404, and without -f curl writes that
page into 1UBQ.pdb. ProteinMPNN then dies inside its featurizer with
ValueError: need at least one array to concatenate, which names nothing useful. It does
exit non-zero, so a pipeline that checks status codes will notice; one that checks only for
an output file will find an empty seqs/ directory.
Name the file after the PDB ID, in the case you intend to use everywhere else. The
output FASTA and every JSON key described under Constraining the design are keyed on the
input file's basename, never on the identifier inside the file. Download to 1ubq.pdb
and the result is design/seqs/1ubq.fa and the fixed-position key is 1ubq; a JSON written
against 1UBQ then fails with a bare KeyError: '1ubq' from inside the featurizer. macOS
hides half of this — its filesystem is case-insensitive, so reading 1ubq.fa as 1UBQ.fa
succeeds locally and the same script fails on Linux. Pick one spelling and keep it.
The output is a FASTA whose first record is the native sequence read off the structure, followed by one record per design:
>1UBQ, score=1.3470, global_score=1.3470, fixed_chains=[], designed_chains=['A'], model_name=v_48_020, git_hash=8907e6671bfbfc92303b5f79c4b5e6ce47cdef57, seed=37
MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG
>T=0.1, sample=1, score=0.7958, global_score=0.7958, seq_recovery=0.5263
MTIKVKFEDGTTLELEVSPDDTIANLKKKIEEKTGIPPEEQVLIYKGEVLEDDKTLADYNIEEGDTIELRLVPKGG
score— mean negative log probability, in nats, of the sampled residue at each designed position. Lower is better. It is a self-assessment, not a measurement.global_score— the same average taken over every residue in every chain, including chains and positions that were held fixed. Identical toscorewhen the whole structure was designed, which is why they match above.seq_recovery— the fraction of designed positions where the sampled residue equals the native one. Its denominator is narrower than it looks; see below.fixed_chains/designed_chains— chains, only. Neither field reflects fixed positions, and neither reflects the positions described under Five ways a backbone stops being designed.
What seq_recovery actually counts
The denominator is not the sequence length. It is the number of positions that were
both designable and structurally resolved: residues in a designed chain, not held
fixed, and carrying a complete N, CA, C, O backbone. Everything else is dropped
from numerator and denominator alike.
This is worth pinning down rather than assuming, because it is the number people quote.
def read_fasta(path):
lines = open(path).read().strip().split("\n")
native = lines[1]
designs = [(float(h.split("seq_recovery=")[1]), s)
for h, s in zip(lines[2::2], lines[3::2])]
return native, designs
native, designs = read_fasta("design/seqs/1UBQ.fa")
for reported, seq in designs:
matches = sum(a == b for a, b in zip(native, seq))
print("reported %.4f recomputed %d/%d = %.4f"
% (reported, matches, len(native), matches / len(native)))
For a fully resolved single-chain structure with nothing fixed, the two agree exactly. The interesting cases are the ones where they do not, and each of those is a case where ProteinMPNN quietly designed less of your protein than you asked it to.
Barnase–barstar (1BRS, six chains, 592 parsed residues) is the small worked example: two
residues in that entry are missing only their backbone O atom, and the reported recovery
is 321/586, not 323/588. Two residues out of 592 does not change a conclusion. Sixteen
does, and the next section is how that happens.
Five ways a backbone stops being designed
Each of these is silent. The header still reads designed_chains=['A'], no warning is
printed, and the FASTA is the length you expected.
1. A residue missing any backbone atom is copied from the native sequence, not
designed. Delete the O atom from residues 10–25 of ubiquitin — change nothing else —
and those sixteen positions come back exactly native in every sample, and vanish from the
recovery denominator. Nothing in the output says so. Incomplete backbones are ordinary in
deposited crystal structures, and a truncated terminus or a poorly resolved loop is enough.
2. Unresolved residues become X in the native sequence and in the design. Where the
residue numbering spans a gap, the parser pads it, so the parsed chain is longer than the
observed one and the missing positions carry X. In the SARS-CoV-2 spike trimer 6VXX
that is 447 of 3363 positions. The model designs none of them, and the output FASTA
contains X characters that are not amino acids — order that sequence and you are ordering
nothing.
3. Only protein backbones are visible. Everything else contributes nothing. Nucleic
acids, ligands, metals, cofactors and waters are not seen — not as context, not at all. In
1TUP, p53 bound to its DNA response element, the two DNA strands are parsed as 21-residue
chains of X, they are listed in designed_chains, and they provide zero structural
context. The DNA-binding surface is designed as though it faced solvent. This is the
limitation with the largest scientific consequence on the page, and it applies to every
binding site defined by a non-protein partner.
4. When you fix chains, the fixed chains are not in the output. Both the native and the designed FASTA records contain the designed chains only. Reassembling the complex is your job, and a script that assumes the record round-trips the whole input will misalign.
5. mmCIF is not parsed. The parser reads fixed-width PDB columns and dies on a .cif
file with ValueError: could not convert string to float. Structures too large for the PDB
format — over 62 chains or 99,999 atoms — are distributed as mmCIF only, and are therefore
out of reach without converting first.
Run this before every design. It reports what the parser will actually see, which is the only thing that matters:
import collections
BACKBONE = {"N", "CA", "C", "O"}
NUCLEIC = {"DA", "DC", "DG", "DT", "DI", "A", "C", "G", "U", "I"}
def preflight(path):
atoms, order, present = collections.defaultdict(set), [], set()
model = 0
for line in open(path):
if line.startswith("MODEL"):
model += 1
if model > 1:
break # only the first model is ever used
if line.startswith(("ATOM", "HETATM")):
key, name = (line[21], line[22:27]), line[17:20].strip()
if key not in atoms:
order.append((key, name))
atoms[key].add(line[12:16].strip())
present.add(name)
# A residue counts as protein only if it has a CA. That filter is what keeps waters,
# ions and ligands out of the report — HOH's single atom is named O, so a naive
# "has some backbone atom" test flags every water in the file as a broken residue.
prot = [(k, r) for k, r in order if "CA" in atoms[k]]
partial = [(k, r) for k, r in prot if not BACKBONE <= atoms[k]]
chains = collections.Counter(k[0] for k, _ in prot)
gaps = []
for ch in chains:
nums = sorted({int(k[1][:4]) for k, _ in prot if k[0] == ch})
gaps += [(ch, a, b) for a, b in zip(nums, nums[1:]) if b - a > 1]
print("protein chains ", dict(chains))
print("partial bbone ", len(partial),
[f"{k[0]}{k[1].strip()} {r} missing {sorted(BACKBONE - atoms[k])}"
for k, r in partial[:4]])
print("numbering gaps ", len(gaps), gaps[:4])
print("nucleic acid ", sorted(present & NUCLEIC))
print("models ", max(model, 1))
return partial, gaps
preflight("1UBQ.pdb")
partial bbone is the line to act on: those residues will be copied from the native
sequence. Either accept that they are fixed and say so when you report recovery, or use the
CA-only checkpoint, which needs nothing but CA and therefore designs them. Checked against
19 RCSB entries spanning 46 to 3363 residues, numbering gaps predicts the number of X
positions ProteinMPNN produces exactly in every case — 447 for 6VXX, 74 for 1IGT, 31 for
1F88, 4 for 1BRS, 0 for the twelve entries with no gaps.
Two things this check also settles. Selenomethionine (MSE) arrives as HETATM and is
read, mapping to M — the parser is not simply ignoring heteroatom records. And a
multi-model NMR entry such as 1D3Z is handled: the first model is used, so a 10-model file
yields one 76-residue chain rather than ten concatenated copies.
Temperature buys diversity, not accuracy
Temperature rescales the per-position distribution before sampling. The usual assumption is that it trades native recovery for diversity. Measured over 32 designs per temperature on ubiquitin at seed 37, that trade barely exists below 0.3:
--sampling_temp |
mean recovery | mean pairwise identity between designs | mean score |
|---|---|---|---|
| 0.0001 | 0.549 | 0.840 | 0.789 |
| 0.05 | 0.551 | 0.837 | 0.791 |
| 0.1 | 0.546 | 0.821 | 0.793 |
| 0.2 | 0.544 | 0.779 | 0.819 |
| 0.3 | 0.544 | 0.708 | 0.880 |
| 0.5 | 0.516 | 0.570 | 1.073 |
| 1.0 | 0.380 | 0.311 | 1.826 |
Recovery is flat from 0.0001 to 0.3 while pairwise identity falls from 84% to 71%. Diversity is close to free in that range, and only above 0.5 does the model start paying for it. The upstream default of 0.1 is conservative; 0.2–0.3 is a reasonable place to generate a library.
for T in 0.0001 0.1 0.3 1.0; do
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1UBQ.pdb \
--out_folder "temp_$T" --num_seq_per_target 32 \
--sampling_temp "$T" --seed 37 > /dev/null
done
import glob, itertools, statistics
for out in ["temp_0.0001", "temp_0.1", "temp_0.3", "temp_1.0"]:
native, designs = read_fasta(glob.glob(out + "/seqs/*.fa")[0])
seqs = [s for _, s in designs]
rec = [sum(a == b for a, b in zip(native, s)) / len(native) for s in seqs]
pw = [sum(a == b for a, b in zip(x, y)) / len(x)
for x, y in itertools.combinations(seqs, 2)]
print("%-12s unique %2d/%d recovery %.3f pairwise identity %.3f"
% (out, len(set(seqs)), len(seqs), statistics.mean(rec), statistics.mean(pw)))
Low temperature is not deterministic, and the reason matters
At --sampling_temp 0.0001 the per-position choice is effectively the argmax, so the
sequences should be identical. They are not: all 32 are distinct, and they average 84%
identity to one another. The diversity is coming from somewhere other than the temperature.
It comes from the decoding order. ProteinMPNN decodes positions in a random order and conditions each choice on the residues already placed, so a different order is a different autoregressive factorisation and gives a different sequence even at zero temperature. That has three practical consequences.
The reported score is a random variable. It depends on the decoding order drawn, not
only on the backbone and the sequence. Scoring the same native ubiquitin ten times gives
values from 1.3246 to 1.3661. --score_only exists to handle this properly — it repeats the
scoring under --num_seq_per_target different decoding orders and reports the spread:
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1UBQ.pdb \
--out_folder scoring --score_only 1 --num_seq_per_target 10 --seed 37 \
| grep "^Score"
That prints mean: 1.3432, std: 0.0127, sample size: 10, and saves the per-order values
alongside the integer-encoded sequence in scoring/score_only/1UBQ_pdb.npz. Never compare
two single scores; compare means, and quote the standard deviation.
--batch_size changes the output at a fixed seed. Four sequences generated one at a
time and four generated in a single batch of four are different sequences from the same
seed, because the batch draws its random state differently. Batching is a throughput choice
on a GPU; on CPU it buys little, and pinning --batch_size 1 is what makes a run
reproducible.
Reproducibility is per machine. With --seed and --batch_size fixed, repeated runs on
one machine are byte-identical. Across architectures, floating-point differences can move a
sampled residue. Record the seed, and treat exact sequences as machine-specific.
Choosing a checkpoint
Three families ship in the repository. Within each, the checkpoint name encodes two numbers:
v_48_020 means 48 neighbours in the geometric graph and backbone coordinates noised during
training with a standard deviation of 0.20 Å. The vanilla and soluble families offer
v_48_002, v_48_010, v_48_020 and v_48_030; the CA-only family stops at v_48_020.
Higher noise is more tolerant of an imprecise backbone; the runner echoes the level it loaded
as Training noise level, and v_48_020 is the default and the right starting point.
| directory | flag | trained on | use for |
|---|---|---|---|
vanilla_model_weights |
(default) | the PDB | anything, unless one of the rows below applies |
soluble_model_weights |
--use_soluble_model |
PDB with membrane proteins excluded | designs that must stay soluble |
ca_model_weights |
--ca_only |
CA traces | backbones with only CA, or with incomplete backbones |
The flag is enough — the runner resolves the weight directory relative to its own location
on disk, so none of these needs a path. The README lists only v_48_010 and v_48_020 for
the soluble family; that is stale, and the directory in fact ships v_48_002, v_48_010,
v_48_020 and v_48_030, all four of which load and run.
Do not also pass --path_to_model_weights. It is checked first and overrides the flags
without saying so. --use_soluble_model --path_to_model_weights ProteinMPNN/vanilla_model_weights
loads the vanilla checkpoint, suppresses the Using ProteinMPNN trained on soluble proteins only! line the runner would otherwise print, and returns output byte-identical to a plain
vanilla run. The header reports model_name=v_48_020 either way and never names the family.
Use the flag; reach for the path only when you keep weights outside the clone.
--ca_only --use_soluble_model is the one combination that is caught — it prints
WARNING: CA-SolubleMPNN is not available yet and exits without designing anything.
The soluble checkpoint is not a cosmetic variant, and the direction of its effect is
checkable. Bovine rhodopsin (1F88) is a seven-transmembrane-helix protein whose native
sequence is largely lipid-facing — precisely what solubleMPNN was trained not to produce:
curl -fsSL -o 1F88.pdb https://files.rcsb.org/download/1F88.pdb
for M in "" "--use_soluble_model"; do
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1F88.pdb \
--out_folder "rhod${M:+_soluble}" --num_seq_per_target 4 \
--sampling_temp 0.1 --seed 37 $M > /dev/null
done
grep -ho "seq_recovery=[0-9.]*" rhod/seqs/1F88.fa rhod_soluble/seqs/1F88.fa
Vanilla recovers 0.3484–0.3717 of the native sequence; soluble recovers 0.2644–0.2784. The soluble checkpoint is actively designing away from the native membrane-facing residues, which is what you want for a solubilised variant and exactly what you do not want if you are reproducing a membrane protein.
The CA-only checkpoint is the answer to trap 1. It conditions on CA positions alone, so
residues missing N, C or O are designed rather than copied — at the cost of a coarser
model of the backbone:
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1UBQ.pdb \
--out_folder ca --num_seq_per_target 2 --sampling_temp 0.1 --seed 37 --ca_only
head -3 ca/seqs/1UBQ.fa
The header confirms which architecture was loaded by reporting CA_model_name rather than
model_name, and the runner prints Using CA-ProteinMPNN! on the way in. Applied to the
O-stripped ubiquitin from trap 1, the CA-only model designs all sixteen of the residues the
default model copied.
On recent PyTorch this path — and only this path, because it is the one computing dihedrals —
emits UserWarning: Using torch.cross without specifying the dim arg is deprecated. It is
upstream's, it is harmless, and the results are unaffected.
Constraining the design
Whole chains. Name the chains to design; every other chain becomes fixed context that the model sees but does not change.
curl -fsSL -o 1BRS.pdb https://files.rcsb.org/download/1BRS.pdb
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1BRS.pdb \
--pdb_path_chains "A" --out_folder barnase --num_seq_per_target 8 \
--sampling_temp 0.1 --seed 37 > /dev/null
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1BRS.pdb \
--out_folder complex --num_seq_per_target 8 \
--sampling_temp 0.1 --seed 37 > /dev/null
head -2 barnase/seqs/1BRS.fa
The header now reads fixed_chains=['B', 'C', 'D', 'E', 'F'], designed_chains=['A'], and
score (0.6610) and global_score (1.2012) differ, because the first covers chain A and the
second covers all six.
Fixed context is real information and the model uses it — but the comparison has to be made over the same residues, because recovery over a different set of positions is simply a different number:
import statistics
def chain_recovery(path, index=0):
lines = open(path).read().strip().split("\n")
native = lines[1].split("/")[index]
observed = [i for i, c in enumerate(native) if c != "X"]
out = []
for design in lines[3::2]:
d = design.split("/")[index]
out.append(sum(native[i] == d[i] for i in observed) / len(observed))
return out
for label, path in [("B-F fixed at native ", "barnase/seqs/1BRS.fa"),
("B-F co-designed ", "complex/seqs/1BRS.fa")]:
r = chain_recovery(path)
print("chain A, %s n=%d mean %.4f sd %.4f"
% (label, len(r), statistics.mean(r), statistics.pstdev(r)))
Chain A recovers 0.6354 ± 0.0217 against a fixed native barstar and 0.5787 ± 0.0324 when all six chains are designed at once. Design a binder against a fixed target, not against the whole complex.
An unknown chain letter is not validated. --pdb_path_chains "Z" raises a bare
KeyError: 'seq_chain_Z' from inside the featurizer, exits 1, and writes no output.
Individual positions. --fixed_positions_jsonl takes a JSON object mapping the PDB
basename to a per-chain list of positions. The indices are 1-based offsets into the parsed
chain sequence, not PDB residue numbers, and the two differ whenever numbering does not
start at 1 — which is most of the time. For a chain whose first observed residue is numbered
f, the index of residue r is r - f + 1.
import json
json.dump({"1UBQ": {"A": list(range(1, 11))}}, open("fixed.jsonl", "w"))
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1UBQ.pdb \
--out_folder fixed --num_seq_per_target 2 --sampling_temp 0.1 --seed 37 \
--fixed_positions_jsonl fixed.jsonl
head -4 fixed/seqs/1UBQ.fa
Every design now begins MQIFVKTLTG, the native first ten. seq_recovery drops to a
denominator of 66, and fixed_chains still reads [] — fixed positions are not reported
anywhere in the header.
Homooligomers. This one is a correctness bug, not a preference. A homodimer designed
without tying gets an independent sequence per chain: on the immunoglobulin light-chain
dimer 1REI, whose two chains are identical natives of 107 residues, none of sixteen
untied designs returned two matching chains, and the two copies averaged only 74.7%
identity to each other. That is two different genes for what has to be one protein.
--tied_positions_jsonl couples positions across chains so that every copy receives the
same residue. Each entry in the list is one tied group — here, position i of chain A with
position i of chain B:
import json
tied = [{"A": [i], "B": [i]} for i in range(1, 108)]
json.dump({"1REI": tied}, open("tied.jsonl", "w"))
curl -fsSL -o 1REI.pdb https://files.rcsb.org/download/1REI.pdb
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1REI.pdb \
--out_folder tied --num_seq_per_target 16 --sampling_temp 0.1 --seed 37 \
--tied_positions_jsonl tied.jsonl > /dev/null
./venv/bin/python ProteinMPNN/protein_mpnn_run.py --pdb_path 1REI.pdb \
--out_folder untied --num_seq_per_target 16 --sampling_temp 0.1 --seed 37 > /dev/null
import statistics
for label, path in [("tied ", "tied/seqs/1REI.fa"), ("untied", "untied/seqs/1REI.fa")]:
lines = open(path).read().strip().split("\n")
designs = [d.split("/") for d in lines[3::2]]
same = sum(d[0] == d[1] for d in designs)
across = statistics.mean(sum(a == b for a, b in zip(d[0], d[1])) / len(d[0])
for d in designs)
rec = [float(h.split("seq_recovery=")[1]) for h in lines[2::2]]
print("%s chains identical %2d/%d chain-to-chain identity %.3f recovery %.4f ± %.4f"
% (label, same, len(designs), across, statistics.mean(rec), statistics.pstdev(rec)))
Tied, all sixteen designs return two identical chains. Recovery is unaffected —
0.6156 ± 0.0175 tied against 0.6113 ± 0.0164 untied, indistinguishable over sixteen
samples — because the two copies of 1REI sit in near-identical environments, so one sequence
satisfies both at no cost. Expect a real cost only where the copies see genuinely different
surroundings. And do not read a single-sample difference as that cost: sample 1 alone reads
0.5981 tied and 0.6168 untied, which looks like a 0.019 penalty and is noise. Cyclic
homooligomers and nanoparticle subunits are the same construction with more chains per tied
group.
How long this takes on CPU
Eight sequences per target, v_48_020, Apple M1 Max on the CPU device, 27 Aug 2026. Wall
time includes about 1.2 s of interpreter start-up and weight loading; generation time is
what the runner reports itself:
| structure | designed residues | generation | wall |
|---|---|---|---|
1CRN |
46 | 1.0 s | 2.4 s |
1UBQ |
76 | 1.6 s | 2.8 s |
1AKI |
129 | 2.2 s | 3.4 s |
1REI |
214 | 3.6 s | 4.9 s |
1BRS |
592 | 8.9 s | 10.7 s |
4KT6 |
830 | 13.4 s | 15.5 s |
1IGT |
1390 | 23.4 s | 26.6 s |
3ZOW |
1449 | 25.2 s | 29.4 s |
6VXX |
3363 | 67.6 s | 74.2 s |
Cost is linear in residues × sequences at roughly 2.5 ms per residue per sequence on this hardware, from a 46-residue peptide to a 3363-residue viral trimer. Treat that constant as hardware-specific and the linearity as the thing to plan with — a thousand designs on a 200-residue target is about eight minutes, not a cluster job. Nothing on this page needed a GPU, and nothing here would be qualitatively different with one.
What a designed sequence is, and is not
ProteinMPNN samples from a learned distribution over sequences given a backbone. That is the whole claim. It contains no model of folding kinetics, stability, expression, solubility, aggregation, or function, and it has not been told what your protein does.
Native-sequence recovery is a similarity measure, not a success criterion. It asks how often the model reproduces one particular natural solution — and many sequences fold to the same backbone, so a design that disagrees with the native everywhere may be perfectly good and a design that agrees may not fold at all. The published figure is 52.4% on native backbones, against 32.9% for Rosetta (Dauparas et al., Science 378:49–56, 2022).
Recovery also varies far more between targets than between settings, which is why a single
number carries almost no information on its own. Across 19 RCSB structures spanning 46 to
3363 residues, all at --sampling_temp 0.1, seed 37, v_48_020, whole structure designed:
| median | 0.483 |
| range | 0.304 (2GB1, 56 residues) to 0.617 (1REI, 214 residues) |
membrane protein 1F88 |
0.372 |
de novo designed 1QYS (Top7) |
0.424 |
A 0.42 on a de novo backbone and a 0.62 on an immunoglobulin domain are not a worse and a better run. Compare a design against other designs on the same backbone, never against a number from a different target.
The in-silico check that means something is a fold-back. Predict a structure for each design with a structure predictor — ESMFold and Boltz-2 are both practical at this scale — and compare it to the input backbone by RMSD, along with the predictor's own confidence. That tests whether the sequence encodes the fold you asked for, which recovery does not. It is still not experimental validation, and this page will not pretend otherwise.
Try it
Data. Ubiquitin, PDB entry 1UBQ — one chain, 76 residues, fully resolved, 78 KB, from
RCSB PDB, which releases its coordinate files into the public domain under CC0 1.0. No
account needed; confirmed reachable 27 Aug 2026. Model weights come from the clone.
Run. Cold in an empty directory. About five and a half minutes end to end, nearly all of it pip downloading PyTorch; the two ProteinMPNN runs take under three seconds combined. The second half deliberately routes through the trap in Five ways a backbone stops being designed — it strips one backbone atom from sixteen residues and checks what comes back.
set -e
python3 -m venv venv
./venv/bin/pip install -q --disable-pip-version-check torch numpy
git clone -q --depth 1 https://github.com/dauparas/ProteinMPNN.git
MPNN=$PWD/ProteinMPNN
# -f matters: RCSB answers a missing or PDB-format-unavailable entry with an HTML
# error page under a 404, and without -f curl writes that page to 1UBQ.pdb.
curl -fsSL -o 1UBQ.pdb https://files.rcsb.org/download/1UBQ.pdb
# A second copy with the backbone O deleted from residues 10-25 — nothing else changed.
./venv/bin/python - <<'PY'
keep = []
for line in open("1UBQ.pdb"):
if line.startswith("ATOM") and 10 <= int(line[22:26]) <= 25 and line[12:16].strip() == "O":
continue
keep.append(line)
open("1UBQ_noO.pdb", "w").writelines(keep)
PY
./venv/bin/python "$MPNN/protein_mpnn_run.py" --pdb_path 1UBQ.pdb \
--out_folder full --num_seq_per_target 8 --sampling_temp 0.1 --seed 37 > /dev/null
./venv/bin/python "$MPNN/protein_mpnn_run.py" --pdb_path 1UBQ_noO.pdb \
--out_folder noO --num_seq_per_target 8 --sampling_temp 0.1 --seed 37 > /dev/null
./venv/bin/python - <<'PY'
def read(path):
lines = open(path).read().strip().split("\n")
native = lines[1]
designs = [(float(h.split("seq_recovery=")[1]), s)
for h, s in zip(lines[2::2], lines[3::2])]
return native, designs
native, full = read("full/seqs/1UBQ.fa")
_, noO = read("noO/seqs/1UBQ_noO.fa")
def identity(a, b, positions):
return sum(a[i] == b[i] for i in positions) / len(positions)
allpos = range(len(native))
stripped = set(range(9, 25)) # residues 10-25, zero-based
designed = [i for i in allpos if i not in stripped]
print("native length ", len(native))
print("native sequence ", native)
print("designs returned ", len(full))
print("all designs same length", all(len(s) == len(native) for _, s in full))
rec_full = [identity(native, s, allpos) for _, s in full]
print("reported == recomputed over all 76 :",
all(abs(r - m) < 5e-5 for (r, _), m in zip(full, rec_full)))
print("mean recovery, intact backbone : %.4f" % (sum(rec_full) / len(rec_full)))
# Strip one backbone atom from 16 residues and ProteinMPNN stops designing them.
carried = [s[9:25] == native[9:25] for _, s in noO]
print("residues 10-25 returned unchanged :", all(carried), "in", sum(carried), "of 8 designs")
rec_noO = [identity(native, s, designed) for _, s in noO]
print("reported == recomputed over the 60 :",
all(abs(r - m) < 5e-5 for (r, _), m in zip(noO, rec_noO)))
print("mean recovery, 60 designed positions: %.4f" % (sum(rec_noO) / len(rec_noO)))
print("mean over all 76, had you assumed it: %.4f"
% (sum(identity(native, s, allpos) for _, s in noO) / len(noO)))
assert len(native) == 76 and len(full) == 8
assert all(len(s) == len(native) for _, s in full)
assert all(abs(r - m) < 5e-5 for (r, _), m in zip(full, rec_full))
assert 0.45 < sum(rec_full) / len(rec_full) < 0.65
assert all(carried) # never designed, never announced
assert all(abs(r - m) < 5e-5 for (r, _), m in zip(noO, rec_noO))
assert not any("X" in s for _, s in full)
print("OK")
PY
Expect.
native length 76
native sequence MQIFVKTLTGKTITLEVEPSDTIENVKAKIQDKEGIPPDQQRLIFAGKQLEDGRTLSDYNIQKESTLHLVLRLRGG
designs returned 8
all designs same length True
reported == recomputed over all 76 : True
mean recovery, intact backbone : 0.5428
residues 10-25 returned unchanged : True in 8 of 8 designs
reported == recomputed over the 60 : True
mean recovery, 60 designed positions: 0.4583
mean over all 76, had you assumed it: 0.5724
OK
Invariants — a failure here means this page is wrong, not that upstream moved:
- The native record is the sequence read off the coordinates. For a fully resolved
1UBQit is 76 residues and matches the deposited sequence exactly. - Every design has the same length as the native, and contains no
Xfor a gapless input. seq_recoveryin the header equals the identity you recompute yourself — but only over the positions ProteinMPNN actually designed. On the intact backbone that is all 76; on the O-stripped copy it is 60, and the reported figure agrees with the narrower denominator in both cases.- Residues 10–25 of the O-stripped structure come back native in all eight designs, with
no warning anywhere in the output. That is the assertion that catches the trap: if a future
version starts designing them,
all(carried)fails and this page needs revising. - The last two printed numbers are the point of the exercise.
0.4583is recovery over the 60 positions that were designed;0.5724is what you get by dividing over all 76, which is higher precisely because the 16 undesigned positions are 100% native. Computing recovery yourself over the full length inflates it.
Observed values, ProteinMPNN commit 8907e66, v_48_020, torch 2.13.0, arm64 CPU,
27 Aug 2026 — a mismatch is drift to investigate, not a bug. The two recovery means, 0.5428
and 0.4583, are stable across repeated cold runs on one machine at seed 37 and
--batch_size 1, and may move on a different architecture; the assertions bracket them
rather than pinning them.
Citing this
Dauparas, J. et al. Robust deep learning-based protein sequence design using ProteinMPNN. Science 378, 49–56 (2022). doi:10.1126/science.add2187
Code and weights: https://github.com/dauparas/ProteinMPNN