Data: assays, numbering, and folds

What the three rungs are computed from, where it came from, and the two things about it that fail silently if you get them wrong. See Introduction for the question and Method for what the experiment does with these inputs.

Source and provenance

ProteinGym v1.3, taken from its Zenodo deposit, DOI 10.5281/zenodo.15293562.

Two files are needed and both come from that record:

file what it carries
DMS_substitutions.csv the assay-level reference: taxon, target sequence, length, mutant counts
cv_folds_singles_substitutions.zip the per-variant tables including fold assignments

Zenodo rather than the lab web server, for two reasons. A DOI names an immutable record, so "which inputs produced this table" has an answer that outlives a URL; the lab host also publishes unversioned paths that resolve, which would let the inputs move under committed results with nothing to see in a diff. Separately, the lab host presents its certificate chain out of order, which Python's OpenSSL declines to build a path through even with certifi's bundle passed explicitly, while curl's LibreSSL accepts it. Working around that would have meant either weakening verification or depending on whichever TLS stack the machine happens to have. Both files were checked byte-for-byte against the lab host before the switch.

The fold archive is a strict superset of the main substitutions archive, so it is the only download needed for the variant tables.

Which assays, and why those

The filter was pre-registered on issue #11 before any assay was downloaded. These are the experiment's terms, not tuning knobs: changing one after seeing a result is precisely the selection bias pre-registration exists to prevent.

target length          <= 400 residues
single mutants         >= 2000 and <= 8000
taxa                   Virus, Prokaryote, Human
within each taxon      the alphabetically-first DMS_id

Length bounds cost as well as scope, since attention is quadratic in it.

One amendment, on 2026-07-31, before any outcome data existed. The original rule was "one viral plus two non-viral", which returned two Pseudomonas enzymes out of three and made a generality claim thin for no gain. It became one assay per taxon across three taxa. The amendment is recorded on issue #11; it is stated here because a reader assessing whether the cohort was chosen to flatter a result should not have to go find it.

The three assays

assay taxon organism length variants positions
R1AB_SARS2_Flynn_2022 Virus SARS-CoV-2 306 5,725 303
A4GRB6_PSEAI_Chen_2020 Prokaryote Pseudomonas aeruginosa 266 5,004 266
CCR5_HUMAN_Gill_2023 Human Homo sapiens 352 6,137 323

16,866 variants in total. Three assays is enough to ask whether an effect holds across taxa and nowhere near enough to rank methods in general, which is why Introduction frames the deliverable as a crossover point rather than a leaderboard.

The numbering hazard

ProteinGym mutant strings such as A24G are 1-based against the target sequence that assay shipped with, which is not always the UniProt canonical sequence for that protein.

Pairing the numbering with a different sequence shifts every position by a constant. Nothing raises: every score stays finite, every Spearman stays plausible, and the model is scored on the wrong residue throughout. This is the failure mode the whole pipeline is shaped around avoiding.

Two things close it. The reference file carries target_seq, so the sequence is taken from there and never fetched separately. And prepare_data.py asserts the agreement between each mutant's stated wild-type residue and the target sequence at that position, at preparation time rather than 40 minutes into a run.

The prepared table therefore carries position as a zero-based index, along with the parsed wildtype_aa and mutant_aa, so nothing downstream re-derives them from the string and nothing has to remember which convention it is in.

Folds, and why two of the three schemes matter more

ProteinGym ships three five-fold cross-validation schemes, and all three are reported:

scheme how folds are formed what it tests
fold_random_5 rows at random performance when the test sites were seen in training
fold_modulo_5 by residue position, modulo generalisation to unseen positions
fold_contiguous_5 by contiguous position blocks the same, with contiguous held-out regions

modulo and contiguous are position-disjoint by construction: no residue position appears in more than one fold. That is the leakage-aware default this repo uses everywhere, and here it is what separates a model that learned protein constraints from one that memorised which sites of this particular protein tolerate mutation. Under random, folds share almost every position, so a model can score well without transferring anything.

run_arms.py asserts the disjointness rather than trusting it, because the scheme name is a string in a CSV column and a mislabelled one would produce a number that looks like generalisation and is not.

Using ProteinGym's own folds rather than deriving equivalents keeps the numbers comparable to published supervised baselines. Fold sizes are close to even; for R1AB_SARS2_Flynn_2022 under modulo they run 1,134 to 1,154 variants.

How the folds are assigned to roles (test, validation, training pool) is a Method question and lives in Method.

Output table

data/processed/proteingym_variants.parquet, one row per variant:

column meaning
dms_id which assay
mutant ProteinGym's mutant string, 1-based
mutated_sequence the full variant sequence
DMS_score the measured fitness, the regression target
DMS_score_bin ProteinGym's binarised version, unused here
fold_random_5, fold_modulo_5, fold_contiguous_5 fold index per scheme
position zero-based residue index, parsed
wildtype_aa, mutant_aa parsed from the mutant string, checked against the target

Alongside it, data/processed/proteingym_assays.json carries the per-assay metadata: taxon, target sequence, length, variant and position counts, source organism, plus the ProteinGym version and DOI and the filter that selected the cohort. That sidecar is what makes a committed result traceable to the inputs that produced it without re-running anything.

Both are gitignored, as regenerable artifacts. Raw downloads land in data/raw/, also gitignored.

Reproducing

python projects/dms-benchmark/scripts/prepare_data.py

Re-running skips the download when the raw file is already present; pass --force-download to refetch. The run writes a log and a manifest under logs/ recording the inputs, the filter, the selected assays and the package versions, per the run logging convention.

Rung 3 needs only these two prepared files, about 600 KB together: it calls load_esm2 directly and never touches the embedding cache, which is what rungs 1 and 2 use. That matters when staging to a cluster; see slurm/README.md.