if-split 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- if_split-0.3.0/CHANGELOG.md +88 -0
- {if_split-0.2.0 → if_split-0.3.0}/CLAUDE.md +14 -6
- {if_split-0.2.0 → if_split-0.3.0}/PKG-INFO +187 -25
- {if_split-0.2.0 → if_split-0.3.0}/PLAN.md +44 -14
- {if_split-0.2.0 → if_split-0.3.0}/README.md +186 -24
- {if_split-0.2.0 → if_split-0.3.0}/config/default.yaml +23 -5
- if_split-0.3.0/config/masterclass.yaml +51 -0
- if_split-0.3.0/examples/IF-Split-2026.07.14/README.md +118 -0
- if_split-0.3.0/examples/IF-Split-2026.07.14/STATS.txt +28 -0
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/config.yaml +8 -4
- if_split-0.3.0/examples/IF-Split-2026.07.14/manifest.json +167 -0
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/metal_test.json +180 -57
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/nucleic_acid_test.json +9 -2
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test/small_molecule_test.json +164 -521
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/test.json +234 -84
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/train.json +1536 -265
- {if_split-0.2.0/examples/IF-Split-2026.05.31 → if_split-0.3.0/examples/IF-Split-2026.07.14}/val.json +214 -103
- {if_split-0.2.0 → if_split-0.3.0}/pyproject.toml +1 -1
- if_split-0.3.0/scripts/audit_nico_histag.py +206 -0
- if_split-0.3.0/scripts/consume_split.py +157 -0
- if_split-0.3.0/scripts/eval_metal_tiering.py +122 -0
- if_split-0.3.0/scripts/eval_sm_tiering.py +73 -0
- if_split-0.3.0/scripts/eval_structural_clustering.py +52 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/__init__.py +1 -1
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/cli.py +204 -52
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/cluster.py +46 -1
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/config.py +99 -0
- if_split-0.3.0/src/ifsplit/dataset.py +200 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/enumerate.py +22 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/hydrate.py +17 -2
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/ligands.py +165 -31
- if_split-0.3.0/src/ifsplit/manifest.py +713 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/parse.py +62 -5
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/rcsb.py +16 -1
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/schema.py +91 -1
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/split.py +116 -6
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_config.py +40 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_download.py +45 -0
- if_split-0.3.0/tests/test_enumerate_lock.py +298 -0
- if_split-0.3.0/tests/test_ligands.py +647 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_loader.py +60 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_pipeline.py +277 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_schema.py +101 -0
- {if_split-0.2.0 → if_split-0.3.0}/uv.lock +1 -1
- if_split-0.2.0/examples/IF-Split-2026.05.31/README.md +0 -84
- if_split-0.2.0/examples/IF-Split-2026.05.31/STATS.txt +0 -20
- if_split-0.2.0/examples/IF-Split-2026.05.31/manifest.json +0 -132
- if_split-0.2.0/src/ifsplit/dataset.py +0 -112
- if_split-0.2.0/src/ifsplit/manifest.py +0 -417
- if_split-0.2.0/tests/test_enumerate_lock.py +0 -101
- if_split-0.2.0/tests/test_ligands.py +0 -342
- {if_split-0.2.0 → if_split-0.3.0}/.github/workflows/ci.yml +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/.github/workflows/publish.yml +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/.gitignore +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/.python-version +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/LICENSE +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/data/cache/.gitkeep +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/data/out/.gitkeep +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/__main__.py +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/src/ifsplit/download.py +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/conftest.py +0 -0
- {if_split-0.2.0 → if_split-0.3.0}/tests/test_integration.py +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to IF-Split are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
|
|
5
|
+
follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
The **split is always computed from metadata + sequences only** — `build` never
|
|
8
|
+
downloads structure coordinates. That invariant holds across every release below.
|
|
9
|
+
|
|
10
|
+
## [0.3.0] — 2026-07-14
|
|
11
|
+
|
|
12
|
+
A large release: fold-honest splitting, split-output certification, a two-corpus
|
|
13
|
+
training model, a metadata-only curation overhaul, and offline re-derivability.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Fold-level structural leakage control** (opt-in `structural_clustering`:
|
|
18
|
+
`off` | `cath` | `ecod` | `scop2`). Same-fold protein chains are union-merged into
|
|
19
|
+
one leakage-safe component in addition to shared sequence clusters, so a fold cannot
|
|
20
|
+
straddle train/test — using RCSB's precomputed CATH/ECOD/SCOP2 classifications
|
|
21
|
+
(metadata only, no coordinates).
|
|
22
|
+
- **Balance-aware split strategy** (`split_strategy: balanced`). Caps dominant folds
|
|
23
|
+
to train and fills val/test to their *entry* targets from the fold tail, restoring
|
|
24
|
+
~80/10/10 by entries with thousands of held-out folds. `config/masterclass.yaml`
|
|
25
|
+
ships the fold-honest recipe (`scop2` + `balanced`).
|
|
26
|
+
- **Split-output certification.** The `@2` `dataset.lock` records `split_sha256` (a
|
|
27
|
+
hash of the entry→split partition); `verify` re-derives Stages 3–6 and certifies the
|
|
28
|
+
split *output* reproduced, not just the Stage-1 candidate set.
|
|
29
|
+
- **Two training corpora from one split**: all kept structures as design *backbones*,
|
|
30
|
+
plus a functional-ligand *conditioning-target* corpus (`targets.jsonl`, one row per
|
|
31
|
+
ligand keyed to entry + split + class + tier). `SplitView` exposes both views.
|
|
32
|
+
- **Offline `resplit`** (`if-split resplit --candidates candidates.jsonl --config X`):
|
|
33
|
+
re-derives Stages 3–7 from a cached snapshot with no RCSB — ablate curation /
|
|
34
|
+
clustering / split settings, or tighten a filter, in seconds instead of
|
|
35
|
+
re-enumerating the PDB. The lock records `source` (`build` | `resplit`).
|
|
36
|
+
- **Offline `verify`** (`verify LOCK --candidates candidates.jsonl`): integrity-check
|
|
37
|
+
a distributed dataset with no network; a corrupt candidates file is reported as an
|
|
38
|
+
integrity failure. A `resplit` lock is steered to offline verification.
|
|
39
|
+
- **Per-method resolution caps** (`resolution_max_A_by_method`) and a **cryo-EM
|
|
40
|
+
map-fit floor** (`min_em_backbone_inclusion`, wiring in the previously-unused
|
|
41
|
+
`em_backbone_inclusion` metric). Resolution is now re-derived in Stage 3, so the cut
|
|
42
|
+
is auditable from `candidates.jsonl` and tightenable offline.
|
|
43
|
+
- **Opt-in sequence-usability floor** (`min_modeled_residues`) and an always-on drop of
|
|
44
|
+
empty / all-`X` (poly-UNK) protein chains, which carry no learnable label.
|
|
45
|
+
- RCSB **metal-binding annotations** (GO/InterPro/Pfam) captured to rescue native
|
|
46
|
+
metalloenzymes; `if-split spec` to emit a portable, self-identifying split spec.
|
|
47
|
+
|
|
48
|
+
### Changed / curation
|
|
49
|
+
|
|
50
|
+
- **Metal tiering**: heavy-atom / lanthanide **phasing derivatives** (Hg/Au/Pt/Pb/Tl/…)
|
|
51
|
+
demoted to `ambiguous` (reported, recoverable) rather than counted as functional
|
|
52
|
+
metal sites; inorganic **Fe-S / metal-oxo / FeMo clusters** (SF4/FES, the OEC) now
|
|
53
|
+
classed `metal`; native Ni/Co (and heavy/lanthanide) sites rescued via annotation,
|
|
54
|
+
affinity, or subject-of-investigation. The lone-Ni/Co His-tag figure was corrected
|
|
55
|
+
(~96% → ~82%).
|
|
56
|
+
- **Glycans** (RCSB CCD `type` = *saccharide*) with no measured affinity are tiered
|
|
57
|
+
`glycan` (decorative / detergent), recoverable via an opt-in tier — not counted as
|
|
58
|
+
small-molecule conditioning targets.
|
|
59
|
+
- **Small molecules**: a measured binding affinity now overrides the additive
|
|
60
|
+
blacklist, so a blacklisted comp that is the real measured ligand stays functional.
|
|
61
|
+
- **Nucleic acids**: `is_nucleic` now recognizes the `NA-hybrid` polymer type; the
|
|
62
|
+
ligand class was renamed `nucleotide` → `nucleic_acid`.
|
|
63
|
+
- The size cap keeps `< 6000` residues correctly (`> max_total_residues`, not `>=`).
|
|
64
|
+
- Adding a resolution-less method (NMR/SAXS) now warns instead of silently returning
|
|
65
|
+
zero entries.
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- `verify` warns (rather than fails) on a version-only lock mismatch.
|
|
70
|
+
- `fetch` reads split id-lists from the manifest directory, not the current directory.
|
|
71
|
+
- `identity_threshold` is validated against RCSB's precomputed cluster levels
|
|
72
|
+
(30/50/70/90/95/100) so an unsupported level can't silently disable clustering.
|
|
73
|
+
- A bound halide is tiered a counterion, not a functional small molecule.
|
|
74
|
+
|
|
75
|
+
## [0.2.0] — 2026
|
|
76
|
+
|
|
77
|
+
- Recover non-covalently bound cofactors (FAD/NAD/FMN/NADP, inhibitors) via RCSB's
|
|
78
|
+
`is_subject_of_investigation` flag.
|
|
79
|
+
- Harden Ni/Co metal curation against His-tags absent from the deposited sequence.
|
|
80
|
+
- Shareable split spec (`if-split spec`) and a self-identifying config header.
|
|
81
|
+
- Rename the ligand class `nucleotide` → `nucleic_acid`; PyPI/CI badges + install docs.
|
|
82
|
+
|
|
83
|
+
## [0.1.0] — 2026
|
|
84
|
+
|
|
85
|
+
- Initial release: a reproducible, date-pinned, ligand-aware train/val/test splitter
|
|
86
|
+
for the PDB. Enumerate → filter → tier ligands → cluster (union-find, leakage-safe)
|
|
87
|
+
→ deterministic split → manifest + lock, all from RCSB Search + Data API metadata
|
|
88
|
+
(no coordinates). Optional `fetch` downloads structures for a built split.
|
|
@@ -10,7 +10,8 @@ usage. This file is the orientation for working in the repo.
|
|
|
10
10
|
**The split is computed from metadata + sequences only — `build` never downloads
|
|
11
11
|
structure coordinates.** Everything needed (resolution, method, release date,
|
|
12
12
|
residue counts, per-entity sequences, ligand chem-comp + bound-component signals,
|
|
13
|
-
RCSB cluster membership
|
|
13
|
+
RCSB sequence-cluster membership, and CATH/ECOD/SCOP2 structural classifications)
|
|
14
|
+
comes from the RCSB Search + Data APIs. Coordinates
|
|
14
15
|
(mmCIF) are large and only needed downstream, so `fetch` (Stage 2) is optional.
|
|
15
16
|
Keep it that way: do not add coordinate access to the build path.
|
|
16
17
|
|
|
@@ -36,6 +37,8 @@ wsl -d ubuntu bash -lc 'cd ~/projects/IF-Split && export PATH="$HOME/.local/bin:
|
|
|
36
37
|
uv run ruff check . # lint (must pass)
|
|
37
38
|
uv run ruff format . # format
|
|
38
39
|
uv run if-split build --limit 50 --out /tmp/ifs # dev build (small, live RCSB)
|
|
40
|
+
uv run if-split build --config config/masterclass.yaml --out /tmp/mc # fold-honest split (scop2 + balanced)
|
|
41
|
+
uv run if-split resplit --candidates data/out/candidates.jsonl --config X.yaml --out /tmp/rs # re-derive Stages 3-7 offline (no RCSB)
|
|
39
42
|
```
|
|
40
43
|
|
|
41
44
|
- `uv sync` sets up the env; `uv sync --extra mlops` adds pyarrow for `fetch`'s
|
|
@@ -47,17 +50,22 @@ uv run if-split build --limit 50 --out /tmp/ifs # dev build (small, live RCSB)
|
|
|
47
50
|
|
|
48
51
|
`enumerate.py`+`rcsb.py` (Stage 1, Search+Data API → candidates.jsonl) →
|
|
49
52
|
`parse.py` (3, metadata filters) → `ligands.py` (4, confidence tiering) →
|
|
50
|
-
`cluster.py` (5, union-find components
|
|
53
|
+
`cluster.py` (5, union-find components: sequence + optional fold-level structural
|
|
54
|
+
clustering) → `split.py` (6, split assignment: `hash` | `balanced`) →
|
|
51
55
|
`manifest.py` (7, lock + manifest + registry, verify/stats) → `dataset.py` (8,
|
|
52
|
-
loader). `download.py`+`hydrate.py` are the optional Stage 2 `fetch`.
|
|
56
|
+
loader). `download.py`+`hydrate.py` are the optional Stage 2 `fetch`. `cli.py`'s
|
|
57
|
+
`resplit` re-runs Stages 3-7 from a cached `candidates.jsonl` (no Stage 1) via the
|
|
58
|
+
shared `_run_pipeline`; `verify --candidates` does the same for offline checking.
|
|
53
59
|
|
|
54
60
|
Invariants that must not regress:
|
|
55
61
|
- **Determinism:** same config → byte-identical `manifest.json` (no wall-clock
|
|
56
62
|
fields). `test_manifest_is_deterministic` guards this.
|
|
57
63
|
- **No cross-split leakage:** sequence clusters joined by a shared multi-chain
|
|
58
|
-
entry
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
entry (and, with `structural_clustering` on, by a shared fold superfamily) are
|
|
65
|
+
union-find–merged into one component; a component maps to exactly one split, so
|
|
66
|
+
overlap is impossible by construction. This holds for both split strategies
|
|
67
|
+
(`hash` and `balanced`, which only chooses *which* split a whole component lands
|
|
68
|
+
in). `check_no_leakage` is a real invariant (not a tautology) — keep it that way.
|
|
61
69
|
- **Growth stability:** a cluster/component's split is `hash(salt + canonical_key)`
|
|
62
70
|
into cumulative fractions, keyed on the global-min member id (not RCSB's volatile
|
|
63
71
|
integer id). A larger snapshot only *adds* components; `splits.registry.json`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: if-split
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Reproducible, date-pinned, ligand-aware train/val/test splitter for the PDB (LigandMPNN-style).
|
|
5
5
|
Author: WSobo
|
|
6
6
|
License: MIT
|
|
@@ -43,9 +43,10 @@ records and sequences. Coordinates are an optional, downstream concern.
|
|
|
43
43
|
| | |
|
|
44
44
|
|---|---|
|
|
45
45
|
| **Fresh** | Builds from the current PDB, not a years-old frozen copy. |
|
|
46
|
-
| **Reproducible** | A `dataset.lock` pins the snapshot
|
|
46
|
+
| **Reproducible** | A `dataset.lock` pins the snapshot **and the split output**; `verify` re-derives both and certifies they reproduced byte-for-byte (or reports exactly what drifted). |
|
|
47
47
|
| **Cheap** | Metadata-only — a split is megabytes of JSON, not a terabyte of mmCIF. |
|
|
48
48
|
| **Honest about quality** | Every ligand is tiered (`functional` / `ambiguous` / `artifact`) with a reason; nothing is silently dropped. |
|
|
49
|
+
| **Fold-aware** | Controls *structural* leakage, not just sequence: same-fold chains can't straddle train/test — the leak that matters most for structure→sequence models. |
|
|
49
50
|
|
|
50
51
|
### Two reproducibility guarantees
|
|
51
52
|
|
|
@@ -60,6 +61,51 @@ records and sequences. Coordinates are an optional, downstream concern.
|
|
|
60
61
|
`splits.registry.json` pins prior assignments to make this exact even across
|
|
61
62
|
re-clustering.
|
|
62
63
|
|
|
64
|
+
### Fold-level leakage control
|
|
65
|
+
|
|
66
|
+
Sequence clustering alone is not enough for inverse folding. A model learns
|
|
67
|
+
**structure → sequence**, so two chains below the 30% identity threshold that
|
|
68
|
+
nonetheless share a **fold** (TIM barrels, Rossmann folds, globins…) leak
|
|
69
|
+
structural information across the split — the model has effectively seen the test
|
|
70
|
+
backbone during training. `structural_clustering` closes this: protein entities
|
|
71
|
+
sharing a structural **(super)family** are union-merged into the same component in
|
|
72
|
+
addition to shared sequence clusters, so a fold cannot straddle train/test.
|
|
73
|
+
|
|
74
|
+
It uses RCSB's precomputed **CATH / ECOD / SCOP2** classifications — still metadata
|
|
75
|
+
only, no coordinates — selectable per build (`off | cath | ecod | scop2`). It is
|
|
76
|
+
**purely additive**: it can only merge components, never split them, and a chain
|
|
77
|
+
with no classification simply contributes no structural edge. `if-split stats`
|
|
78
|
+
reports how many components the structural pass folded together, so the effect is
|
|
79
|
+
always measurable (`scripts/eval_structural_clustering.py` compares the methods).
|
|
80
|
+
|
|
81
|
+
Coverage is partial by nature: CATH ≈ 55%, ECOD ≈ 80%, SCOP2 ≈ 52% of protein
|
|
82
|
+
chains are classified (measured on the full 2026-07-14 snapshot); the rest fall
|
|
83
|
+
back to sequence-only.
|
|
84
|
+
|
|
85
|
+
**Why it needs a balance-aware split.** On its own, fold-merging collapses the
|
|
86
|
+
dominant superfamilies (antibodies, TIM barrels) into mega-components that land
|
|
87
|
+
wholesale in one split, skewing the *entry* balance to ~95/3/2 (the *component*
|
|
88
|
+
split stays ~80/10/10). `split_strategy: "balanced"` fixes this: it **caps the
|
|
89
|
+
dominant folds to train and fills val/test to their entry targets from the tail of
|
|
90
|
+
smaller folds** — restoring ~80/10/10 by entries with thousands of distinct,
|
|
91
|
+
held-out folds per split. It stays leakage-safe (whole components) and
|
|
92
|
+
growth-stable (via the registry), and reports a gap if a method's tail is too thin
|
|
93
|
+
(`cath`/`ecod` starve val; **`scop2` is the sweet spot**). `balanced` also fixes
|
|
94
|
+
the plain sequence-only skew (88/6/6 → 80/10/10) from the antibody mega-cluster.
|
|
95
|
+
|
|
96
|
+
### The masterclass split
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv run if-split build --config config/masterclass.yaml --out data/mc
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`config/masterclass.yaml` = default **+ `structural_clustering: scop2` + `split_strategy: balanced`**: a fold-honest ~80/10/10 split whose val/test are thousands of folds held entirely out of train — the truest generalization measure the tool can produce, still from metadata alone.
|
|
103
|
+
|
|
104
|
+
| split | strategy | structural | entry balance | val/test |
|
|
105
|
+
|---|---|---|--:|---|
|
|
106
|
+
| default | hash | off | 88 / 6 / 6 | sequence-clustered |
|
|
107
|
+
| masterclass | balanced | scop2 | 80 / 10 / 10 | thousands of held-out folds |
|
|
108
|
+
|
|
63
109
|
---
|
|
64
110
|
|
|
65
111
|
## Install
|
|
@@ -94,9 +140,19 @@ uv run if-split build --limit 50 --out /tmp/ifs
|
|
|
94
140
|
# Summarize a build: split sizes, per-class test counts, curation tiers.
|
|
95
141
|
uv run if-split stats data/out/manifest.json
|
|
96
142
|
|
|
97
|
-
# Reproduce-check: re-derive from a lock
|
|
143
|
+
# Reproduce-check: re-derive from a lock, report candidate drift vs the live PDB,
|
|
144
|
+
# and (when candidates reproduce) certify the split output matches its hash.
|
|
98
145
|
uv run if-split verify data/out/dataset.lock
|
|
99
146
|
|
|
147
|
+
# Offline verify: integrity-check a distributed candidates.jsonl + lock, no network.
|
|
148
|
+
uv run if-split verify data/out/dataset.lock --candidates data/out/candidates.jsonl
|
|
149
|
+
|
|
150
|
+
# Re-derive the split from a CACHED candidates.jsonl — no RCSB. Ablate curation,
|
|
151
|
+
# clustering, split strategy, or TIGHTEN a filter (e.g. resolution) on a fixed
|
|
152
|
+
# snapshot in ~seconds instead of re-enumerating the whole PDB.
|
|
153
|
+
uv run if-split resplit --candidates data/out/candidates.jsonl \
|
|
154
|
+
--config config/masterclass.yaml --out data/mc
|
|
155
|
+
|
|
100
156
|
# Growth-stable regeneration: pin prior cluster→split assignments.
|
|
101
157
|
uv run if-split build --registry data/out/splits.registry.json --out data/out2
|
|
102
158
|
|
|
@@ -112,7 +168,7 @@ uv run if-split spec data/out/manifest.json --name my-split --out my-split.ifspl
|
|
|
112
168
|
| File | Purpose |
|
|
113
169
|
|---|---|
|
|
114
170
|
| `candidates.jsonl` | The snapshot definition — one canonical JSON record per entry. Hashed into the lock. |
|
|
115
|
-
| `dataset.lock` | Reproduction anchor: embedded config + candidates SHA-256 + entry list. |
|
|
171
|
+
| `dataset.lock` | Reproduction anchor: embedded config + candidates SHA-256 + entry list + a `split` hash of the entry→split partition (so `verify` certifies the split output, not just the inputs). |
|
|
116
172
|
| `manifest.json` | Human-facing run record: per-split entry lists, ligand classes + tiers, per-class (and ambiguous) counts, drop log, cluster/leakage stats, entry→cluster map. |
|
|
117
173
|
| `splits.registry.json` | `cluster key → split`, for growth-stable regeneration. |
|
|
118
174
|
|
|
@@ -176,10 +232,10 @@ A `build` runs eight stages; none touch coordinates.
|
|
|
176
232
|
| Stage | Module | What it does |
|
|
177
233
|
|---|---|---|
|
|
178
234
|
| 1 — enumerate | `enumerate.py`, `rcsb.py` | RCSB Search → entry IDs; Data API (GraphQL, batched) → sequences, ligands, residue counts, cluster membership → `candidates.jsonl`. |
|
|
179
|
-
| 3 — filter | `parse.py` | Drop no-protein / no-sequence / oversized entries (assembly-1 residue count
|
|
235
|
+
| 3 — filter | `parse.py` | Drop no-protein / no-usable-sequence (empty **or** all-`X` poly-UNK) / too-short (opt-in `min_modeled_residues`) / oversized entries (assembly-1 residue count `> max_total_residues`) / over-resolution (re-derived here so it is auditable; per-method caps via `resolution_max_A_by_method`), plus optional wwPDB validation-report quality caps (clashscore, R-free, Ramachandran/rotamer/RSRZ, cryo-EM map-fit floor) — all from metadata. Every drop is logged with its reason. |
|
|
180
236
|
| 4 — ligands | `ligands.py` | Tier each non-protein component `functional`/`ambiguous`/`artifact`; derive class labels (metal / small-molecule / nucleic-acid). `nucleic_acid` = a protein↔DNA/RNA *complex* (verified assembly interface), **not** a bound mononucleotide. **Annotate, never drop.** |
|
|
181
|
-
| 5 — cluster | `cluster.py` | Group protein entities by RCSB precomputed cluster id at `identity_threshold`; canonical key = smallest member id. |
|
|
182
|
-
| 6 — split | `split.py` |
|
|
237
|
+
| 5 — cluster | `cluster.py` | Group protein entities by RCSB precomputed cluster id at `identity_threshold`; canonical key = smallest member id. Optionally union same-fold entities (CATH/ECOD/SCOP2) for structural-leakage control. |
|
|
238
|
+
| 6 — split | `split.py` | Assign components → train/val/test (`hash`, or `balanced` for entry-balanced fold-tail val/test); assert no cluster spans two splits; audit residual secondary-chain overlap. |
|
|
183
239
|
| 7 — manifest | `manifest.py` | Emit lock + manifest + registry (all deterministic, no wall-clock fields). |
|
|
184
240
|
| 8 — loader | `dataset.py` | Read a manifest into train/val/test views with cluster-balanced sampling. |
|
|
185
241
|
| 2 — fetch *(opt-in)* | `download.py`, `hydrate.py` | Download mmCIF for a built manifest into a sharded, indexed, ML-ready tree. |
|
|
@@ -201,6 +257,7 @@ metrics come straight from the deposited report:
|
|
|
201
257
|
| `max_rotamer_outlier_pct` | % sidechain rotamer outliers | X-ray + cryo-EM |
|
|
202
258
|
| `max_rfree` | R-free (DCC) | X-ray |
|
|
203
259
|
| `max_rsrz_outlier_pct` | % real-space-R Z-score outliers | X-ray |
|
|
260
|
+
| `min_em_backbone_inclusion` | backbone atom-in-density (a **floor** — higher is better) | cryo-EM |
|
|
204
261
|
|
|
205
262
|
Two rules keep it honest: a cap fires **only when the metric is present**, so a
|
|
206
263
|
cryo-EM entry is never dropped for a missing R-free; and every cap is **off by
|
|
@@ -220,8 +277,8 @@ machine-readable reason, from RCSB metadata signals:
|
|
|
220
277
|
|
|
221
278
|
| Tier | Meaning | Example reasons |
|
|
222
279
|
|---|---|---|
|
|
223
|
-
| `functional` | Real ligand/site → gets a class label |
|
|
224
|
-
| `ambiguous` | Present but uncorroborated → reported, **not** labelled | `metal_unbound`, `ligand_unbound` |
|
|
280
|
+
| `functional` | Real ligand/site → gets a class label | `metal_bound`/`ligand_bound` (contacts protein), `*_affinity` (measured), `*_investigated` (RCSB SOI), `metal_annotated` (protein annotated to bind this metal) |
|
|
281
|
+
| `ambiguous` | Present but uncorroborated → reported, **not** labelled | `metal_unbound`, `ligand_unbound`, `metal_site_nonnative`, `glycan`, `purification_metal_uncorroborated` |
|
|
225
282
|
| `artifact` | Buffer / counterion / purification tag → excluded from labels | `additive`, `counterion`, `histag_metal` |
|
|
226
283
|
|
|
227
284
|
**Holo gating (metadata-only).** Presence isn't enough. A small molecule or metal
|
|
@@ -244,13 +301,23 @@ affinity purification. A poly-His run anywhere — or a short run at a chain
|
|
|
244
301
|
terminus (`histag_terminal_min_run`, catching 6×His tags left partial by
|
|
245
302
|
unmodeled or trimmed residues) — flags the entry's Ni/Co as an `artifact`.
|
|
246
303
|
|
|
247
|
-
But
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
304
|
+
But an audit (reproducible via [`scripts/audit_nico_histag.py`](scripts/audit_nico_histag.py))
|
|
305
|
+
showed a subtler issue: **~82% of lone Ni/Co entries carry no detectable His-tag
|
|
306
|
+
in the deposited sequence** — IMAC tags are frequently absent from the SEQRES
|
|
307
|
+
record, not just unmodeled, so a sequence scan can't recover them. So even with no
|
|
308
|
+
detectable tag, a *lone* Ni/Co (the entry's only metal) with no corroboration is
|
|
309
|
+
demoted from `functional` to `ambiguous` — reported, not labelled.
|
|
310
|
+
|
|
311
|
+
To avoid over-firing on genuine bare-Ni/Co enzymes (urease, cobalt methionine
|
|
312
|
+
aminopeptidase, nitrile hydratase, …), a lone Ni/Co is **rescued to `functional`
|
|
313
|
+
(`metal_annotated`)** when the protein's RCSB GO/InterPro/Pfam annotation says it
|
|
314
|
+
binds that metal. A protein that binds a *different* native metal (Ni/Co as an
|
|
315
|
+
isomorphous substitute — e.g. Co in a Mg enzyme) is reported `metal_site_nonnative`
|
|
316
|
+
so a consumer can choose to keep it; one with no metal annotation at all stays
|
|
317
|
+
`purification_metal_uncorroborated`. All from RCSB's own metadata (no extra
|
|
318
|
+
UniProt call). Real metals (Zn, Mg, Fe, …), and Ni/Co with affinity/SOI or beside
|
|
319
|
+
a genuine metal, are untouched. Rerun [`scripts/eval_metal_tiering.py`](scripts/eval_metal_tiering.py)
|
|
320
|
+
to measure the tier distribution over the whole lone-Ni/Co set.
|
|
254
321
|
|
|
255
322
|
Crucially, **the structure always stays in its split** — a protein with a junk
|
|
256
323
|
ion is still a good backbone; we just don't label the junk. A consumer wanting
|
|
@@ -258,11 +325,29 @@ ion is still a good backbone; we just don't label the junk. A consumer wanting
|
|
|
258
325
|
threshold, not the build. The same per-component tier is what a downstream
|
|
259
326
|
featurizer reads to decide what counts as real ligand context.
|
|
260
327
|
|
|
328
|
+
> **Per-instance is a featurizer concern.** These tiers are per *component* — they
|
|
329
|
+
> establish whether a structure contains a real Ni/Co site, not *which* of several
|
|
330
|
+
> same-element ions is it. A deposition can hold both a catalytic Ni and a surface
|
|
331
|
+
> crystallization Ni under one `NI` id, and no metadata separates them (adventitious
|
|
332
|
+
> Ni binds surface His/Asp with the same geometry as a catalytic site). Deciding
|
|
333
|
+
> which individual ion to featurize is left to the coordinate-level featurizer.
|
|
334
|
+
|
|
335
|
+
**Glycans aren't ligand pockets.** A carbohydrate (RCSB CCD type `*saccharide*` —
|
|
336
|
+
NAG/BMA/MAN/…, and sugar-detergents like LMT) is overwhelmingly decorative
|
|
337
|
+
glycosylation or a purification detergent, not a site an inverse-folding model
|
|
338
|
+
conditions on. So a carbohydrate is tiered `glycan` (reported, not a small-molecule
|
|
339
|
+
target) unless it has a *measured binding affinity* — RCSB's `is_subject_of_investigation`
|
|
340
|
+
flag is too noisy for sugars (it flags glycosylation and detergents), so it doesn't
|
|
341
|
+
rescue here. A genuine lectin/glycosidase ligand is recoverable as an opt-in target
|
|
342
|
+
(`include_ambiguous=True`). Real cofactors (ATP, NAD, HEM, FAD) and structural lipids
|
|
343
|
+
(cardiolipin, phosphatidyl-*) are `non-polymer`, not saccharides, so they're untouched.
|
|
344
|
+
|
|
261
345
|
### Test-set representation
|
|
262
346
|
|
|
263
|
-
The split is a
|
|
264
|
-
but not forced by default:
|
|
265
|
-
`functional` counts plus `ambiguous`
|
|
347
|
+
The split is deterministic (a per-component hash, or the `balanced` fold-tail
|
|
348
|
+
fill), so the test set's ligand mix is reported but not forced by default:
|
|
349
|
+
`manifest.json` carries per-split, per-class `functional` counts plus `ambiguous`
|
|
350
|
+
counts, so under-representation is visible.
|
|
266
351
|
An opt-in `--enforce-minimums` top-up (recruit `functional`-only ligand clusters
|
|
267
352
|
into test in deterministic order) is scoped for a future release.
|
|
268
353
|
|
|
@@ -283,6 +368,75 @@ for epoch in range(3):
|
|
|
283
368
|
batch_ids = ds.train.sample_by_cluster(seed=epoch)
|
|
284
369
|
```
|
|
285
370
|
|
|
371
|
+
### Training strategies for inverse folding
|
|
372
|
+
|
|
373
|
+
An inverse-folding model consumes two different things, with opposite scale/quality
|
|
374
|
+
tradeoffs. IF-Split emits **both from the same leakage-safe split**, so you pick a
|
|
375
|
+
strategy without re-deriving the split:
|
|
376
|
+
|
|
377
|
+
| Corpus | What | Use it for |
|
|
378
|
+
|---|---|---|
|
|
379
|
+
| **Backbones** — every kept structure | `ds.train.backbones` | ProteinMPNN-style, ligand-agnostic. The scale lever — a structure with only junk ions or no ligand is still a good backbone. |
|
|
380
|
+
| **Conditioning targets** — the `functional`-tier ligands | `ds.train.conditioning_targets()` | LigandMPNN-style. One row per `(structure, ligand)`; junk is never a target. The quality lever. |
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
ds = load_dataset("data/out/manifest.json")
|
|
384
|
+
|
|
385
|
+
# 1. Backbone-only training (max data): every structure.
|
|
386
|
+
backbones = ds.train.backbones
|
|
387
|
+
|
|
388
|
+
# 2. Ligand-conditioned training: condition on the real ligands only.
|
|
389
|
+
targets = ds.train.conditioning_targets() # metal / small_molecule / nucleic_acid
|
|
390
|
+
metal_targets = ds.train.conditioning_targets(classes=["metal"])
|
|
391
|
+
|
|
392
|
+
# 3. Condition on ALL of a structure's ligands at once (group by entry), or one at a time:
|
|
393
|
+
for entry_id, ligs in ds.train.targets_by_entry().items():
|
|
394
|
+
ctx = [(t.ligand_class, t.comp_id) for t in ligs] # e.g. [("small_molecule","HEM")]
|
|
395
|
+
|
|
396
|
+
# 4. Only structures that actually carry a conditioning target:
|
|
397
|
+
conditioned = ds.train.conditioned_entry_ids() # subset of backbones
|
|
398
|
+
|
|
399
|
+
# 5. Opt in to ambiguous targets — non-native metal pockets (Ni/Co substituting the
|
|
400
|
+
# native metal) and glycans (glycosylation / lectin ligands) — off by default:
|
|
401
|
+
any_site = ds.train.conditioning_targets(include_ambiguous=True)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The full corpus is also written to `targets.jsonl` (one row per target: entry, split,
|
|
405
|
+
cluster, class, comp_id, tier, reason) and mirrored into `index.parquet`'s
|
|
406
|
+
`conditioning_targets` column after `fetch`.
|
|
407
|
+
|
|
408
|
+
#### From a target to its pocket (your featurizer owns this)
|
|
409
|
+
|
|
410
|
+
IF-Split stops at the **labels** — it never parses coordinates. The last mile is
|
|
411
|
+
yours: join a target's `comp_id` to a fetched structure with your own parser and
|
|
412
|
+
pull the ligand atoms + pocket. This is deliberate — featurization is
|
|
413
|
+
model-specific (ligand atoms? SMILES? a pocket mask? all-atom context?), and every
|
|
414
|
+
inverse-folding model already has its own pipeline. `comp_id` is the join key:
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
from pathlib import Path
|
|
418
|
+
|
|
419
|
+
import gemmi # or biotite / Biopython — same pattern
|
|
420
|
+
from ifsplit.dataset import load_dataset
|
|
421
|
+
from ifsplit.download import rel_path_for
|
|
422
|
+
|
|
423
|
+
ds = load_dataset("data/out/manifest.json")
|
|
424
|
+
for entry_id, targets in ds.test.targets_by_entry().items():
|
|
425
|
+
path = Path("data/structures") / rel_path_for(entry_id, "test", assembly=True)
|
|
426
|
+
model = gemmi.read_structure(str(path))[0]
|
|
427
|
+
for t in targets:
|
|
428
|
+
# A structure may hold several copies of one ligand (plus adventitious ions).
|
|
429
|
+
# Surface ALL copies; pick the instance to condition on per your model.
|
|
430
|
+
copies = [r for ch in model for r in ch if r.name == t.comp_id]
|
|
431
|
+
# ... extract residues within config.ligand_context_radius_A of each copy ...
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
When an entry has several functional ligands (e.g. a cofactor *and* an inhibitor,
|
|
435
|
+
or a catalytic metal alongside an adventitious one), that shows up as **multiple
|
|
436
|
+
target rows** — condition on all (group by `entry_id`) or one per example, your
|
|
437
|
+
call. A runnable, copy-and-adapt version with pocket extraction is
|
|
438
|
+
[`scripts/consume_split.py`](scripts/consume_split.py).
|
|
439
|
+
|
|
286
440
|
## Sharing a split spec
|
|
287
441
|
|
|
288
442
|
The config **is** the shareable recipe. Everything that affects the split lives in
|
|
@@ -307,9 +461,9 @@ spec:
|
|
|
307
461
|
ifsplit_spec: ifsplit/config@1 # schema id — the file says what it is
|
|
308
462
|
name: my-split
|
|
309
463
|
author: you
|
|
310
|
-
created_with: if-split 0.
|
|
464
|
+
created_with: if-split 0.3.0
|
|
311
465
|
expected_config_hash: 3b63318286fd2ac4994f34d10936be05
|
|
312
|
-
snapshot_date: '2026-
|
|
466
|
+
snapshot_date: '2026-07-14'
|
|
313
467
|
resolution_max_A: 3.5
|
|
314
468
|
# ... all output-affecting settings ...
|
|
315
469
|
```
|
|
@@ -323,7 +477,7 @@ differ only in their labels produce byte-identical outputs.
|
|
|
323
477
|
|---|---|--:|
|
|
324
478
|
| `*.ifsplit.yaml` (or `config.yaml`) | *"How did you make this split?"* — the recipe | ~KB |
|
|
325
479
|
| `manifest.json` | *"What's in it?"* — counts, provenance, file index | ~KB |
|
|
326
|
-
| `dataset.lock` | *"Reproduce the exact bytes"* — pins entry set + candidates SHA | ~MB |
|
|
480
|
+
| `dataset.lock` | *"Reproduce the exact bytes"* — pins entry set + candidates SHA + split-output hash | ~MB |
|
|
327
481
|
|
|
328
482
|
## Configuration
|
|
329
483
|
|
|
@@ -336,8 +490,10 @@ doubles as a shareable **split spec** — see [Sharing a split spec](#sharing-a-
|
|
|
336
490
|
|---|---|---|
|
|
337
491
|
| `snapshot_date` | `2026-05-30` | `release_date <= this` — the reproducibility anchor. |
|
|
338
492
|
| `experimental_methods` | X-ray, EM | Allowed `exptl.method` values. |
|
|
339
|
-
| `resolution_max_A` | `3.5` | Resolution cutoff. |
|
|
340
|
-
| `
|
|
493
|
+
| `resolution_max_A` | `3.5` | Resolution cutoff (re-derived in Stage 3, so it is auditable from `candidates.jsonl`). |
|
|
494
|
+
| `resolution_max_A_by_method` | `{}` | Optional per-method resolution overrides, e.g. `{ELECTRON MICROSCOPY: 3.0}` — cryo-EM 3.5 Å ≠ X-ray 3.5 Å. Empty = one cap for all. |
|
|
495
|
+
| `max_total_residues` | `5999` | Max residues **kept** (drop if `> this`) — LigandMPNN kept `< 6000`, i.e. `<= 5999`. |
|
|
496
|
+
| `min_modeled_residues` | `0` | Opt-in floor on modeled (non-`X`) residues in a protein chain. `0` = off; only the always-on empty/all-`X` (poly-UNK) drop applies. `~20` also drops tiny/mostly-unknown chains. |
|
|
341
497
|
| `excluded_het` | waters + common ions | Extra components forced to `artifact`. |
|
|
342
498
|
| `use_biological_assembly` | `true` | Count residues from assembly 1, not the deposited asymmetric unit. |
|
|
343
499
|
| `purification_metals` | `[NI, CO]` | Metals treated as IMAC tags; `[]` disables the heuristic. |
|
|
@@ -348,7 +504,7 @@ doubles as a shareable **split spec** — see [Sharing a split spec](#sharing-a-
|
|
|
348
504
|
| `clustering_backend` | `precomputed` | `precomputed` (RCSB clusters) or `mmseqs2` (run your own). |
|
|
349
505
|
| `split_fractions` | 0.80 / 0.10 / 0.10 | train / val / test. |
|
|
350
506
|
| `split_salt` | `snapsplit-v1` | Bump to intentionally reshuffle the split. |
|
|
351
|
-
| `max_clashscore`, `max_rfree`, `max_ramachandran_outlier_pct`, `max_rotamer_outlier_pct`, `max_rsrz_outlier_pct`, `require_validation_report` | off | Optional validation-report quality caps — see [Structure quality](#structure-quality-validation-report). |
|
|
507
|
+
| `max_clashscore`, `max_rfree`, `max_ramachandran_outlier_pct`, `max_rotamer_outlier_pct`, `max_rsrz_outlier_pct`, `min_em_backbone_inclusion`, `require_validation_report` | off | Optional validation-report quality caps — see [Structure quality](#structure-quality-validation-report). |
|
|
352
508
|
| `ligand_context_radius_A`, `max_ligand_atoms` | `8.0`, `25` | Featurization only (not part of the split). |
|
|
353
509
|
|
|
354
510
|
## Develop
|
|
@@ -379,6 +535,12 @@ data/out/ # generated manifests + lock files
|
|
|
379
535
|
tests/
|
|
380
536
|
```
|
|
381
537
|
|
|
538
|
+
## Changelog
|
|
539
|
+
|
|
540
|
+
Release history is in [CHANGELOG.md](CHANGELOG.md). The current release is **0.3.0**
|
|
541
|
+
(fold-honest splitting, split-output certification, the two-corpus training model, a
|
|
542
|
+
metadata-only curation overhaul, and offline `resplit` / `verify`).
|
|
543
|
+
|
|
382
544
|
## License
|
|
383
545
|
|
|
384
546
|
MIT — see [LICENSE](LICENSE).
|
|
@@ -106,8 +106,8 @@ IF-Split/
|
|
|
106
106
|
download.py # Stage 2: OPTIONAL on-demand mmCIF fetch (featurization)
|
|
107
107
|
parse.py # Stage 3: metadata filters + drop log
|
|
108
108
|
ligands.py # Stage 4: classify non-protein entities from metadata
|
|
109
|
-
cluster.py # Stage 5:
|
|
110
|
-
split.py # Stage 6:
|
|
109
|
+
cluster.py # Stage 5: seq clusters (precomputed|mmseqs2) + opt. fold-level structural union
|
|
110
|
+
split.py # Stage 6: split assignment (hash | balanced) + stratification
|
|
111
111
|
manifest.py # Stage 7: emit manifest + lock file
|
|
112
112
|
dataset.py # Stage 8: loader / torch Dataset consuming a manifest
|
|
113
113
|
cli.py # `if-split build`, `if-split verify`, `if-split stats`
|
|
@@ -151,7 +151,8 @@ guaranteed to have used identical settings.
|
|
|
151
151
|
snapshot_date: "2026-05-30" # release_date <= this. The reproducibility anchor.
|
|
152
152
|
experimental_methods: ["X-RAY DIFFRACTION", "ELECTRON MICROSCOPY"]
|
|
153
153
|
resolution_max_A: 3.5
|
|
154
|
-
max_total_residues: 5999 # LigandMPNN
|
|
154
|
+
max_total_residues: 5999 # max residues KEPT (drop if > this); LigandMPNN kept < 6000
|
|
155
|
+
min_modeled_residues: 0 # opt-in floor on modeled (non-X) residues; 0 = off
|
|
155
156
|
excluded_het: ["HOH", "NA", "CL", "K", "BR"] # waters + common crystallization ions
|
|
156
157
|
use_biological_assembly: true # biounits, as in LigandMPNN (assembly 1)
|
|
157
158
|
# purification-artifact curation (Stage 4): His-tag + Ni/Co only -> not a metal site
|
|
@@ -215,10 +216,13 @@ re-featurize from the cleaned structures.
|
|
|
215
216
|
**Stage 3 — Filter (`parse.py`)**
|
|
216
217
|
|
|
217
218
|
- Operate on the metadata in `candidates.jsonl` (no coordinate parsing). Apply
|
|
218
|
-
filters: drop entries with `total_residues
|
|
219
|
-
assembly residue count when `use_biological_assembly`),
|
|
220
|
-
|
|
221
|
-
|
|
219
|
+
filters: drop entries with `total_residues > max_total_residues` (use the
|
|
220
|
+
assembly residue count when `use_biological_assembly`), over the (per-method)
|
|
221
|
+
resolution cap — re-derived here from `resolution_A` so the cut is auditable and
|
|
222
|
+
tightenable offline — drop entries whose only non-protein components are in
|
|
223
|
+
`excluded_het`, drop entries with no protein polymer entity or no usable
|
|
224
|
+
(non-all-`X`) sequence, plus optional validation-report caps (incl. the cryo-EM
|
|
225
|
+
map-fit floor). Record drop reasons + counts.
|
|
222
226
|
- Sequences come from the Data API canonical one-letter code, which already maps
|
|
223
227
|
modified residues to canonical parents (e.g. MSE→MET). Coordinate-level
|
|
224
228
|
re-parsing with `gemmi` belongs to the optional featurization path, not here.
|
|
@@ -311,12 +315,34 @@ entry may touch multiple clusters via different chains; assign the entry to the
|
|
|
311
315
|
cluster of its longest protein chain (record all) so split assignment is
|
|
312
316
|
unambiguous.
|
|
313
317
|
|
|
318
|
+
**Fold-level structural clustering (`structural_clustering`, opt-in ← *implemented***).
|
|
319
|
+
Sequence clustering misses *structural* redundancy: chains below the identity
|
|
320
|
+
threshold can still share a fold, which a structure→sequence model leaks across
|
|
321
|
+
splits. When set (`cath` | `ecod` | `scop2`), protein entities sharing an RCSB
|
|
322
|
+
structural (super)family are union-merged into the same component **in addition
|
|
323
|
+
to** shared sequence clusters — so a fold cannot straddle train/test. The
|
|
324
|
+
classifications ride in the snapshot as metadata (`rcsb_polymer_instance_annotation`,
|
|
325
|
+
Stage 1; **no coordinates**). CATH keys on the superfamily code (`1.10.490.10`),
|
|
326
|
+
ECOD/SCOP2 on the family name. Purely additive (only merges, never splits);
|
|
327
|
+
coverage is partial (CATH ~55%, ECOD ~81%, SCOP2 ~52% of chains) so unclassified
|
|
328
|
+
chains fall back to sequence-only. See README "Fold-level leakage control".
|
|
329
|
+
|
|
314
330
|
**Stage 6 — Split assignment (`split.py`) — the reproducibility core**
|
|
315
331
|
|
|
316
332
|
- Assign each cluster (not each entry) to a split by deterministic hash:
|
|
317
333
|
`bucket = int(blake2b(cluster_repr_id + split_salt)) ...` mapped to cumulative
|
|
318
334
|
`split_fractions`. Same salt + same cluster IDs → same assignment, forever.
|
|
319
335
|
(See the Stage 6 note in §1 on making `cluster_repr_id` input-independent.)
|
|
336
|
+
- **Balance-aware strategy (`split_strategy: "balanced"` ← *implemented***).
|
|
337
|
+
Per-component hashing balances *components*, not *entries*; a dominant fold
|
|
338
|
+
(under structural clustering) or the antibody mega-cluster (even sequence-only)
|
|
339
|
+
balloons one split. `balanced` caps dominant folds (> 0.2% of entries) to train
|
|
340
|
+
and fills val/test to their *entry* targets from the tail of smaller folds in
|
|
341
|
+
hash order — leakage-safe (whole components), growth-stable via the registry,
|
|
342
|
+
and it reports a gap rather than forcing a target it can't reach. The
|
|
343
|
+
"masterclass" recipe `structural_clustering: scop2` + `split_strategy: balanced`
|
|
344
|
+
(`config/masterclass.yaml`) yields ~80/10/10 by entries with thousands of folds
|
|
345
|
+
held entirely out of train.
|
|
320
346
|
- Stratify the test set by ligand class so SM/metal/nucleotide are all
|
|
321
347
|
represented (LigandMPNN's test sets are deliberately ligand-containing).
|
|
322
348
|
Implement as: within the test-bucketed clusters, label structures by ligand
|
|
@@ -333,13 +359,17 @@ Emit two artifacts in `data/out/`:
|
|
|
333
359
|
- `manifest.json` — human-facing: snapshot_date, config hash, tool versions
|
|
334
360
|
(mmseqs2, gemmi), per-split entry lists, per-structure metadata, ligand-class
|
|
335
361
|
tags, per-class test counts, drop log.
|
|
336
|
-
- `dataset.lock` — reproduction-facing: the
|
|
337
|
-
|
|
338
|
-
API by id + `release_date <=
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
362
|
+
- `dataset.lock` — reproduction-facing anchors: (1) the embedded config + the
|
|
363
|
+
**candidate set** — every entry ID and the canonical `candidates.jsonl` SHA-256
|
|
364
|
+
(the candidate set is reproduced from the Data API by id + `release_date <=
|
|
365
|
+
snapshot_date`; clustering rides along as per-entity `cluster_ids`, so there is
|
|
366
|
+
no separate cluster file); (2) a **`split` block** hashing the entry→split
|
|
367
|
+
partition (`@2` locks). `if-split verify dataset.lock` re-enumerates and reports
|
|
368
|
+
candidate drift (added/removed/hash); when the candidate set reproduced
|
|
369
|
+
byte-for-byte it recomputes Stages 3-6 and certifies the split-output hash
|
|
370
|
+
matches (registry-free builds), so a curation/split-logic change is caught even
|
|
371
|
+
with identical inputs. (mmCIF SHA-256s belong to the *optional* featurization
|
|
372
|
+
fetch, not this lock.)
|
|
343
373
|
- Version the dataset as `IF-Split-<snapshot_date>` (e.g. `IF-Split-2026.05.30`).
|
|
344
374
|
|
|
345
375
|
**Stage 8 — Loader (`dataset.py`)**
|