Ethan Troy commited on
Commit ·
20ceb24
1
Parent(s): 15a0cf7
feat: add Pocket Atlas development gate
Browse files- AUDIT.md +6 -0
- recovery/MODEL_EVALUATION_V2.md +15 -7
- recovery/POCKET_ATLAS_GATE.md +189 -0
- recovery/reports/bindingdb-v2-pocket-atlas-development-gate.json +229 -0
- recovery/scripts/extract_pocket_atlas_features.py +136 -0
- recovery/scripts/prepare_pocket_atlas_sources.py +269 -0
- recovery/scripts/run_pocket_atlas_gate.py +289 -0
- recovery/src/mitointeract_recovery/pocket_features.py +216 -0
- recovery/src/mitointeract_recovery/pocket_gate.py +82 -0
- recovery/src/mitointeract_recovery/pocket_sources.py +170 -0
- recovery/tests/test_checked_reports.py +43 -0
- recovery/tests/test_pocket_features.py +112 -0
- recovery/tests/test_pocket_gate.py +110 -0
- recovery/tests/test_pocket_sources.py +140 -0
AUDIT.md
CHANGED
|
@@ -115,6 +115,12 @@ GtoPdb release 2026.2 was pinned by embedded release metadata and source hashes.
|
|
| 115 |
|
| 116 |
All 161 target accessions were pinned to reviewed Swiss-Prot sequences from UniProt 2026_02. Joint 50/80 clustering against the complete 2,061-protein Source Atlas and scaffold filtering against all 40,477 Atlas ligands froze a cold-both external holdout with 103 observations, 96 pairs, 78 proteins, 78 scaffolds, and 77 publications. Affinity labels are isolated from the identifier manifest. No predictions have been generated. The holdout is sealed until one frozen candidate passes all internal development gates. See `recovery/GTOPDB_EXTERNAL_HOLDOUT.md` and `recovery/reports/gtopdb-2026.2-external-holdout.json`.
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
### Mixed-pAffinity forensic track
|
| 119 |
|
| 120 |
The deterministic 10,000-pair diagnostic sample is backed by `jglaser/binding_affinity` revision `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b` and Parquet SHA-256 `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`.
|
|
|
|
| 115 |
|
| 116 |
All 161 target accessions were pinned to reviewed Swiss-Prot sequences from UniProt 2026_02. Joint 50/80 clustering against the complete 2,061-protein Source Atlas and scaffold filtering against all 40,477 Atlas ligands froze a cold-both external holdout with 103 observations, 96 pairs, 78 proteins, 78 scaffolds, and 77 publications. Affinity labels are isolated from the identifier manifest. No predictions have been generated. The holdout is sealed until one frozen candidate passes all internal development gates. See `recovery/GTOPDB_EXTERNAL_HOLDOUT.md` and `recovery/reports/gtopdb-2026.2-external-holdout.json`.
|
| 117 |
|
| 118 |
+
### Pocket Atlas development gate (2026-07-21)
|
| 119 |
+
|
| 120 |
+
Pocket Atlas mapped 148 homology-safe development proteins to exact or at least 80%-coverage reviewed UniProt sequences and downloaded 145 canonical, versioned AlphaFold monomer structures. Pocketeer extracted 64 confidence, shape, contact-density, and top-pocket descriptors without failure. The checked acquisition workflow reproduced the complete mapping and structure manifest byte-for-byte.
|
| 121 |
+
|
| 122 |
+
On 1,765 development observations outside every reserved test homology cluster, ligand LightGBM achieved RMSE `1.6682`, the ESM residual achieved `1.6435`, and the best structure-containing residual achieved `1.6643`. Structure therefore worsened RMSE by `0.0208` relative to ESM; the incremental cluster-bootstrap interval was `[-0.0481, 0.0083]`, improvement probability was `0.0905`, and one fold regressed by `0.3835`. The predeclared structure gate rejected further training. No benchmark-test or external-holdout evaluation, GPU run, checkpoint, or weight publication occurred. See `recovery/POCKET_ATLAS_GATE.md` and `recovery/reports/bindingdb-v2-pocket-atlas-development-gate.json`.
|
| 123 |
+
|
| 124 |
### Mixed-pAffinity forensic track
|
| 125 |
|
| 126 |
The deterministic 10,000-pair diagnostic sample is backed by `jglaser/binding_affinity` revision `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b` and Parquet SHA-256 `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`.
|
recovery/MODEL_EVALUATION_V2.md
CHANGED
|
@@ -117,13 +117,21 @@ manifest and no model predictions have been generated. See
|
|
| 117 |
`GTOPDB_EXTERNAL_HOLDOUT.md` and
|
| 118 |
`reports/gtopdb-2026.2-external-holdout.json`.
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
## Reproduce
|
| 129 |
|
|
|
|
| 117 |
`GTOPDB_EXTERNAL_HOLDOUT.md` and
|
| 118 |
`reports/gtopdb-2026.2-external-holdout.json`.
|
| 119 |
|
| 120 |
+
Pocket Atlas tested versioned AlphaFold confidence and deterministic
|
| 121 |
+
alpha-sphere pocket geometry on the 1,765 homology-safe development rows. The
|
| 122 |
+
best structure-containing residual achieved RMSE `1.6643`, barely improving the
|
| 123 |
+
ligand control's `1.6682` and worsening the ESM-only residual's `1.6435` by
|
| 124 |
+
`0.0208`. Its incremental cluster-bootstrap interval was `[-0.0481, 0.0083]`
|
| 125 |
+
and one fold regressed by `0.3835`, so the predeclared gate rejected further
|
| 126 |
+
structure training without touching either holdout. See `POCKET_ATLAS_GATE.md`
|
| 127 |
+
and `reports/bindingdb-v2-pocket-atlas-development-gate.json`.
|
| 128 |
+
|
| 129 |
+
The next candidate must provide ligand-conditioned interaction information or
|
| 130 |
+
new exact-Kd training observations that survive the reserved homology and
|
| 131 |
+
scaffold manifests. More capacity over global or ligand-independent protein
|
| 132 |
+
features is not justified. Only a frozen candidate passing the RMSE, bootstrap,
|
| 133 |
+
catastrophic-fold, incremental-signal, and protein-macro gates may receive the
|
| 134 |
+
one permitted external-holdout evaluation.
|
| 135 |
|
| 136 |
## Reproduce
|
| 137 |
|
recovery/POCKET_ATLAS_GATE.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Pocket Atlas Development Gate
|
| 2 |
+
|
| 3 |
+
## Decision
|
| 4 |
+
|
| 5 |
+
**Reject the structure candidate. Do not train or publish weights from this
|
| 6 |
+
hypothesis.**
|
| 7 |
+
|
| 8 |
+
Pocket Atlas added versioned AlphaFold confidence and alpha-sphere pocket
|
| 9 |
+
geometry to the strongest ligand-only control. The best structure-containing
|
| 10 |
+
residual did not beat the ESM-only residual, its grouped confidence interval
|
| 11 |
+
crossed zero, and one fold regressed catastrophically.
|
| 12 |
+
|
| 13 |
+
No benchmark-test or sealed GtoPdb external-holdout prediction was generated.
|
| 14 |
+
The experiment cost $0 in cloud compute.
|
| 15 |
+
|
| 16 |
+
## Development population
|
| 17 |
+
|
| 18 |
+
The original cold-protein test was already observed by earlier work and remains
|
| 19 |
+
spent. This gate used development data only:
|
| 20 |
+
|
| 21 |
+
1. Start with the original train and validation observations.
|
| 22 |
+
2. Jointly use the Source Atlas MMseqs2 50% identity / 80% bidirectional
|
| 23 |
+
coverage clusters.
|
| 24 |
+
3. Reserve all 34 clusters containing any benchmark test protein.
|
| 25 |
+
4. Remove 292 homologous train/validation observations.
|
| 26 |
+
5. Evaluate exactly 1,765 observations, 1,717 pairs, 278 proteins, and 187
|
| 27 |
+
clusters with five outer grouped folds.
|
| 28 |
+
|
| 29 |
+
Each fold contained 353 observations. The prediction-sidecar audit found zero
|
| 30 |
+
test pairs and zero reserved test clusters.
|
| 31 |
+
|
| 32 |
+
## Structure source contract
|
| 33 |
+
|
| 34 |
+
BindingDB source provenance linked benchmark proteins to UniProt accessions.
|
| 35 |
+
A structure was eligible only when:
|
| 36 |
+
|
| 37 |
+
- exactly one accession was available;
|
| 38 |
+
- the UniProt entry was reviewed;
|
| 39 |
+
- the benchmark sequence was exact or a contiguous UniProt
|
| 40 |
+
subsequence/supersequence;
|
| 41 |
+
- contiguous sequence coverage was at least 80%; and
|
| 42 |
+
- AlphaFold returned the canonical DeepMind monomer whose model identity was
|
| 43 |
+
exactly `AF-{accession}-F1`.
|
| 44 |
+
|
| 45 |
+
This explicitly rejects AlphaFold isoforms, alternate models, and complex
|
| 46 |
+
predictions returned by the same API endpoint.
|
| 47 |
+
|
| 48 |
+
Source results:
|
| 49 |
+
|
| 50 |
+
| Item | Count |
|
| 51 |
+
|---|---:|
|
| 52 |
+
| Homology-safe proteins | 278 |
|
| 53 |
+
| Sequence-eligible proteins | 148 |
|
| 54 |
+
| Eligible accessions | 146 |
|
| 55 |
+
| Canonical AlphaFold models | 145 |
|
| 56 |
+
| Unavailable canonical models | 1 (`P0DTD1`) |
|
| 57 |
+
| Downloaded PDB bytes | 56,834,558 |
|
| 58 |
+
|
| 59 |
+
All coordinates are versioned AlphaFold URLs and locally hashed. The checked
|
| 60 |
+
source-preparation script reproduced both the protein mapping and complete
|
| 61 |
+
structure manifest byte-for-byte.
|
| 62 |
+
|
| 63 |
+
## Structure features
|
| 64 |
+
|
| 65 |
+
`pocketeer==0.3.1` generated 64 fixed protein-only features:
|
| 66 |
+
|
| 67 |
+
- AlphaFold global confidence and pLDDT distributions;
|
| 68 |
+
- sequence, atom, and C-alpha counts;
|
| 69 |
+
- radius of gyration and principal-axis dimensions;
|
| 70 |
+
- shape eigenvalues and C-alpha contact density;
|
| 71 |
+
- pocket count and aggregate scores/volumes; and
|
| 72 |
+
- top-three pocket score, volume, sphere count, residue count, radius, solvent
|
| 73 |
+
accessibility, pLDDT, and residue chemistry fractions.
|
| 74 |
+
|
| 75 |
+
Two mapping-quality indicators captured UniProt coverage and exact-sequence
|
| 76 |
+
placement. Missing structures received zero descriptors plus an unavailable
|
| 77 |
+
indicator.
|
| 78 |
+
|
| 79 |
+
Extraction results:
|
| 80 |
+
|
| 81 |
+
| Item | Count |
|
| 82 |
+
|---|---:|
|
| 83 |
+
| Requested structures | 145 |
|
| 84 |
+
| Successful structures | 145 |
|
| 85 |
+
| Failed structures | 0 |
|
| 86 |
+
| Minimum detected pockets | 1 |
|
| 87 |
+
| Maximum detected pockets | 47 |
|
| 88 |
+
| Zero-pocket structures | 0 |
|
| 89 |
+
| Development observations with structure | 1,189 / 1,765 |
|
| 90 |
+
| Development proteins with structure | 147 / 278 |
|
| 91 |
+
|
| 92 |
+
A repeated P00338 extraction was exactly equal with maximum absolute feature
|
| 93 |
+
difference `0.0`.
|
| 94 |
+
|
| 95 |
+
## Predeclared gate
|
| 96 |
+
|
| 97 |
+
A structure candidate had to satisfy all of the following:
|
| 98 |
+
|
| 99 |
+
1. improve RMSE over ligand LightGBM by at least `0.05`;
|
| 100 |
+
2. have a positive lower bound in a 2,000-replicate protein-cluster bootstrap;
|
| 101 |
+
3. maintain positive protein-macro Pearson correlation;
|
| 102 |
+
4. avoid a fold regression worse than `0.10` RMSE;
|
| 103 |
+
5. improve at least `0.02` RMSE over the ESM-only residual; and
|
| 104 |
+
6. have a positive incremental bootstrap lower bound versus ESM.
|
| 105 |
+
|
| 106 |
+
The ligand LightGBM was cross-fitted within every outer-training fold before
|
| 107 |
+
residual fitting. Ridge regularization was selected only inside grouped inner
|
| 108 |
+
folds.
|
| 109 |
+
|
| 110 |
+
## Results
|
| 111 |
+
|
| 112 |
+
| Model | RMSE | Change vs ligand control | Gate |
|
| 113 |
+
|---|---:|---:|---|
|
| 114 |
+
| Ligand Morgan + descriptors LightGBM | 1.66824 | reference | control |
|
| 115 |
+
| ESM protein residual | 1.64349 | +0.02475 | fail |
|
| 116 |
+
| Structure residual | 1.70274 | -0.03450 | fail |
|
| 117 |
+
| ESM + structure residual | 1.66427 | +0.00397 | **fail** |
|
| 118 |
+
|
| 119 |
+
For the best structure-containing model:
|
| 120 |
+
|
| 121 |
+
- incremental change versus ESM: `-0.02078` RMSE;
|
| 122 |
+
- incremental 95% cluster-bootstrap interval: `[-0.04808, 0.00835]`;
|
| 123 |
+
- probability of any incremental improvement: `0.0905`;
|
| 124 |
+
- worst fold change versus ligand control: `-0.38354`; and
|
| 125 |
+
- protein-macro Pearson remained positive but was insufficient to pass.
|
| 126 |
+
|
| 127 |
+
Structure-only behavior was also negative:
|
| 128 |
+
|
| 129 |
+
- RMSE change versus control: `-0.03450`;
|
| 130 |
+
- bootstrap interval: `[-0.09739, 0.03359]`; and
|
| 131 |
+
- probability of improvement: `0.1635`.
|
| 132 |
+
|
| 133 |
+
## Interpretation
|
| 134 |
+
|
| 135 |
+
Global fold and ligand-independent top-pocket descriptors do not supply useful
|
| 136 |
+
incremental affinity information here. AlphaFold structure availability alone
|
| 137 |
+
is not the missing model ingredient. A future structure hypothesis would need
|
| 138 |
+
a ligand-conditioned mechanism, such as a defensibly selected binding site,
|
| 139 |
+
co-complex geometry, or docking/interaction features. Repeating this experiment
|
| 140 |
+
with a larger neural head is not justified by the grouped CPU evidence.
|
| 141 |
+
|
| 142 |
+
The independent GtoPdb cold-both holdout remains sealed. It must not be used to
|
| 143 |
+
choose the next model family.
|
| 144 |
+
|
| 145 |
+
## Reproduce
|
| 146 |
+
|
| 147 |
+
Prepare the pinned structure snapshot:
|
| 148 |
+
|
| 149 |
+
```bash
|
| 150 |
+
cd recovery
|
| 151 |
+
PYTHONPATH=src uv run python scripts/prepare_pocket_atlas_sources.py \
|
| 152 |
+
--sample artifacts/bindingdb-202607-benchmark-v2/sample.jsonl \
|
| 153 |
+
--manifest artifacts/bindingdb-202607-benchmark-v2/split-cold_protein_exact.jsonl \
|
| 154 |
+
--source-records artifacts/bindingdb-202607-source-aware-final/source_records.jsonl \
|
| 155 |
+
--clusters /path/to/source-atlas/mmseqs-identity-50-coverage-80/clusters_cluster.tsv \
|
| 156 |
+
--uniprot /path/to/pinned-uniprot-2026_02.tsv \
|
| 157 |
+
--output-dir /path/to/pocket-atlas-sources
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
Create an isolated pocket-tool environment and extract features:
|
| 161 |
+
|
| 162 |
+
```bash
|
| 163 |
+
uv venv --python 3.11 /path/to/pocket-tools
|
| 164 |
+
uv pip install --python /path/to/pocket-tools/bin/python pocketeer==0.3.1
|
| 165 |
+
|
| 166 |
+
PYTHONPATH=src /path/to/pocket-tools/bin/python \
|
| 167 |
+
scripts/extract_pocket_atlas_features.py \
|
| 168 |
+
--structure-manifest /path/to/pocket-atlas-sources/structure_manifest.json \
|
| 169 |
+
--structures-dir /path/to/pocket-atlas-sources/structures \
|
| 170 |
+
--output-dir /path/to/pocket-atlas-features \
|
| 171 |
+
--workers 4
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
Run the development-only gate:
|
| 175 |
+
|
| 176 |
+
```bash
|
| 177 |
+
PYTHONPATH=src uv run python scripts/run_pocket_atlas_gate.py \
|
| 178 |
+
--sample artifacts/bindingdb-202607-benchmark-v2/sample.jsonl \
|
| 179 |
+
--manifest artifacts/bindingdb-202607-benchmark-v2/split-cold_protein_exact.jsonl \
|
| 180 |
+
--embeddings artifacts/bindingdb-202607-benchmark-v2/frozen-embeddings.npz \
|
| 181 |
+
--clusters /path/to/source-atlas/mmseqs-identity-50-coverage-80/clusters_cluster.tsv \
|
| 182 |
+
--protein-mapping /path/to/pocket-atlas-sources/protein_mapping.jsonl \
|
| 183 |
+
--pocket-features /path/to/pocket-atlas-features/features.json \
|
| 184 |
+
--output-dir /path/to/pocket-atlas-gate
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
Aggregate evidence is checked in at
|
| 188 |
+
`reports/bindingdb-v2-pocket-atlas-development-gate.json`. Generated PDB files,
|
| 189 |
+
features, predictions, and labels remain outside Git.
|
recovery/reports/bindingdb-v2-pocket-atlas-development-gate.json
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "mitointeract-pocket-atlas-checked-report/v1",
|
| 3 |
+
"experiment": "pocket_atlas_development_gate",
|
| 4 |
+
"status": "development_only_no_test_or_external_evaluation",
|
| 5 |
+
"decision": "reject_structure_candidate_no_further_training",
|
| 6 |
+
"target": "pKd",
|
| 7 |
+
"seed": 42,
|
| 8 |
+
"benchmark_test_evaluations": 0,
|
| 9 |
+
"external_holdout_evaluations": 0,
|
| 10 |
+
"cost_usd": 0.0,
|
| 11 |
+
"population": {
|
| 12 |
+
"sample_rows": 2327,
|
| 13 |
+
"source_development_rows": 2057,
|
| 14 |
+
"development_rows_excluded_for_test_cluster_homology": 292,
|
| 15 |
+
"safe_development_rows": 1765,
|
| 16 |
+
"safe_development_pairs": 1717,
|
| 17 |
+
"safe_development_proteins": 278,
|
| 18 |
+
"safe_development_clusters": 187,
|
| 19 |
+
"benchmark_test_rows_excluded": 270,
|
| 20 |
+
"benchmark_test_clusters_reserved": 34
|
| 21 |
+
},
|
| 22 |
+
"source_mapping": {
|
| 23 |
+
"uniprot_release": "2026_02",
|
| 24 |
+
"uniprot_release_date": "10-June-2026",
|
| 25 |
+
"uniprot_api_deployment_date": "11-July-2026",
|
| 26 |
+
"uniprot_entries": 229,
|
| 27 |
+
"uniprot_tsv_sha256": "68c3b65d90446de0df13960242e30d7d60f9a60649374a959ff34405996a2c75",
|
| 28 |
+
"eligible_proteins": 148,
|
| 29 |
+
"eligible_accessions": 146,
|
| 30 |
+
"ineligible": {
|
| 31 |
+
"ambiguous_accession": 1,
|
| 32 |
+
"missing_accession": 29,
|
| 33 |
+
"sequence_placement_or_coverage": 99,
|
| 34 |
+
"unreviewed_uniprot_entry": 1
|
| 35 |
+
},
|
| 36 |
+
"minimum_contiguous_sequence_coverage": 0.8,
|
| 37 |
+
"protein_mapping_sha256": "db7b8812dd1face08e590c5a51a19cf6863f9de11bad8ae30f509d9b3e7084b1"
|
| 38 |
+
},
|
| 39 |
+
"alphafold_snapshot": {
|
| 40 |
+
"api": "https://alphafold.ebi.ac.uk/api/prediction/",
|
| 41 |
+
"metadata_accessions": 146,
|
| 42 |
+
"gdm_monomer_entries": 280,
|
| 43 |
+
"canonical_selection": "modelEntityId == AF-{accession}-F1; provider GDM; monomer only",
|
| 44 |
+
"canonical_models": 145,
|
| 45 |
+
"unavailable_accessions": [
|
| 46 |
+
"P0DTD1"
|
| 47 |
+
],
|
| 48 |
+
"downloaded_structures": 145,
|
| 49 |
+
"structure_bytes": 56834558,
|
| 50 |
+
"structure_manifest_sha256": "9f985679d4d17aae8c7a11bed849c4d4062c7d682b0b40905435785e1916f9c4"
|
| 51 |
+
},
|
| 52 |
+
"structure_features": {
|
| 53 |
+
"tool": "pocketeer",
|
| 54 |
+
"version": "0.3.1",
|
| 55 |
+
"feature_count": 64,
|
| 56 |
+
"mapping_quality_feature_count": 2,
|
| 57 |
+
"requested_structures": 145,
|
| 58 |
+
"successful_structures": 145,
|
| 59 |
+
"failed_structures": 0,
|
| 60 |
+
"minimum_pockets": 1,
|
| 61 |
+
"maximum_pockets": 47,
|
| 62 |
+
"zero_pocket_structures": 0,
|
| 63 |
+
"feature_sha256": "200d0d8cd15c0d57cb51096d8f56ef60c84d83493ecac12eed7a0064aa6f930e",
|
| 64 |
+
"determinism_check": {
|
| 65 |
+
"accession": "P00338",
|
| 66 |
+
"exact_equal": true,
|
| 67 |
+
"maximum_absolute_difference": 0.0
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
"structure_coverage": {
|
| 71 |
+
"safe_rows": 1765,
|
| 72 |
+
"available_rows": 1189,
|
| 73 |
+
"missing_rows": 576,
|
| 74 |
+
"safe_proteins": 278,
|
| 75 |
+
"available_proteins": 147,
|
| 76 |
+
"missing_proteins": 131
|
| 77 |
+
},
|
| 78 |
+
"control": {
|
| 79 |
+
"name": "ligand_morgan_descriptors_lightgbm",
|
| 80 |
+
"metrics": {
|
| 81 |
+
"rmse": 1.6682415337174723,
|
| 82 |
+
"mae": 1.3337803693102785,
|
| 83 |
+
"pearson_r": 0.33782116805873186,
|
| 84 |
+
"spearman_r": 0.29689795196092983,
|
| 85 |
+
"r2": 0.08151297017567782
|
| 86 |
+
},
|
| 87 |
+
"protein_macro_mean_pearson_r": 0.5207683213714444
|
| 88 |
+
},
|
| 89 |
+
"variants": {
|
| 90 |
+
"protein_embedding_ridge_residual": {
|
| 91 |
+
"metrics": {
|
| 92 |
+
"rmse": 1.6434931065733804,
|
| 93 |
+
"mae": 1.326762151114221,
|
| 94 |
+
"pearson_r": 0.44720924687855346,
|
| 95 |
+
"spearman_r": 0.4453109776035747,
|
| 96 |
+
"r2": 0.10856241169224723
|
| 97 |
+
},
|
| 98 |
+
"rmse_improvement_vs_control": 0.024748427144091867,
|
| 99 |
+
"cluster_bootstrap": {
|
| 100 |
+
"iterations": 2000,
|
| 101 |
+
"ci_2_5": -0.1226979699222684,
|
| 102 |
+
"ci_97_5": 0.14593144177553413,
|
| 103 |
+
"probability_improvement": 0.6405
|
| 104 |
+
},
|
| 105 |
+
"fold_rmse_improvements": [
|
| 106 |
+
0.19749483894689246,
|
| 107 |
+
0.03203609759448267,
|
| 108 |
+
-0.3950203091840785,
|
| 109 |
+
0.26783291384310304,
|
| 110 |
+
0.013510866159464952
|
| 111 |
+
],
|
| 112 |
+
"protein_macro_mean_pearson_r": 0.5314499173271257,
|
| 113 |
+
"passed": false
|
| 114 |
+
},
|
| 115 |
+
"structure_ridge_residual": {
|
| 116 |
+
"metrics": {
|
| 117 |
+
"rmse": 1.7027422554992393,
|
| 118 |
+
"mae": 1.3740292703037023,
|
| 119 |
+
"pearson_r": 0.32258584622423186,
|
| 120 |
+
"spearman_r": 0.2630759939864411,
|
| 121 |
+
"r2": 0.04312987314367667
|
| 122 |
+
},
|
| 123 |
+
"rmse_improvement_vs_control": -0.034500721781766996,
|
| 124 |
+
"cluster_bootstrap": {
|
| 125 |
+
"iterations": 2000,
|
| 126 |
+
"ci_2_5": -0.09738584672656869,
|
| 127 |
+
"ci_97_5": 0.03358697436359342,
|
| 128 |
+
"probability_improvement": 0.1635
|
| 129 |
+
},
|
| 130 |
+
"fold_rmse_improvements": [
|
| 131 |
+
-0.052726149391103094,
|
| 132 |
+
-0.17034724269050883,
|
| 133 |
+
0.0566650978161638,
|
| 134 |
+
0.011227189056832731,
|
| 135 |
+
-0.019131441509435243
|
| 136 |
+
],
|
| 137 |
+
"protein_macro_mean_pearson_r": 0.4867412848102915,
|
| 138 |
+
"passed": false
|
| 139 |
+
},
|
| 140 |
+
"protein_embedding_plus_structure_ridge_residual": {
|
| 141 |
+
"metrics": {
|
| 142 |
+
"rmse": 1.6642723336109313,
|
| 143 |
+
"mae": 1.3471499523001278,
|
| 144 |
+
"pearson_r": 0.43435936114789336,
|
| 145 |
+
"spearman_r": 0.42805907694460715,
|
| 146 |
+
"r2": 0.08587843133505468
|
| 147 |
+
},
|
| 148 |
+
"rmse_improvement_vs_control": 0.003969200106541004,
|
| 149 |
+
"cluster_bootstrap": {
|
| 150 |
+
"iterations": 2000,
|
| 151 |
+
"ci_2_5": -0.1343544421249894,
|
| 152 |
+
"ci_97_5": 0.11774505669976526,
|
| 153 |
+
"probability_improvement": 0.525
|
| 154 |
+
},
|
| 155 |
+
"fold_rmse_improvements": [
|
| 156 |
+
0.13279607681808803,
|
| 157 |
+
0.015698267195580762,
|
| 158 |
+
-0.3835410243143167,
|
| 159 |
+
0.249759930531807,
|
| 160 |
+
-0.004686872948701071
|
| 161 |
+
],
|
| 162 |
+
"protein_macro_mean_pearson_r": 0.5255208296082974,
|
| 163 |
+
"passed": false
|
| 164 |
+
}
|
| 165 |
+
},
|
| 166 |
+
"predeclared_structure_gate": {
|
| 167 |
+
"best_structure_variant": "protein_embedding_plus_structure_ridge_residual",
|
| 168 |
+
"minimum_rmse_improvement_vs_control": 0.05,
|
| 169 |
+
"actual_rmse_improvement_vs_control": 0.003969200106541004,
|
| 170 |
+
"minimum_incremental_rmse_improvement_vs_esm": 0.02,
|
| 171 |
+
"actual_incremental_rmse_improvement_vs_esm": -0.020779227037550863,
|
| 172 |
+
"incremental_cluster_bootstrap": {
|
| 173 |
+
"iterations": 2000,
|
| 174 |
+
"seed": 43,
|
| 175 |
+
"ci_2_5": -0.048075518247379725,
|
| 176 |
+
"ci_97_5": 0.008345387666166259,
|
| 177 |
+
"probability_improvement": 0.0905
|
| 178 |
+
},
|
| 179 |
+
"bootstrap_lower_bound_positive_vs_control": false,
|
| 180 |
+
"incremental_bootstrap_lower_bound_positive_vs_esm": false,
|
| 181 |
+
"no_catastrophic_fold": false,
|
| 182 |
+
"protein_macro_pearson_positive": true,
|
| 183 |
+
"passed": false
|
| 184 |
+
},
|
| 185 |
+
"verification": {
|
| 186 |
+
"prediction_rows": 1765,
|
| 187 |
+
"unique_observation_ids": 1765,
|
| 188 |
+
"fold_rows": {
|
| 189 |
+
"0": 353,
|
| 190 |
+
"1": 353,
|
| 191 |
+
"2": 353,
|
| 192 |
+
"3": 353,
|
| 193 |
+
"4": 353
|
| 194 |
+
},
|
| 195 |
+
"test_pairs_in_predictions": 0,
|
| 196 |
+
"test_clusters_in_predictions": 0,
|
| 197 |
+
"all_predictions_finite": true,
|
| 198 |
+
"all_reconstructed_metric_differences": 0.0,
|
| 199 |
+
"source_reproduction": {
|
| 200 |
+
"mapping_exact_match": true,
|
| 201 |
+
"structure_manifest_exact_match": true
|
| 202 |
+
}
|
| 203 |
+
},
|
| 204 |
+
"inputs": {
|
| 205 |
+
"sample_sha256": "4b022ee8e3f1a4d00499bcaea218bad730f0181fedd7dda7d6a5a3ea370bb7ea",
|
| 206 |
+
"manifest_sha256": "fb0a4dc63b8074018a1b7f02b863b8fa0cf6117930c3298b7c80133867cb2798",
|
| 207 |
+
"source_records_sha256": "1e9963217b9ed65528ca477adcba66beea1fa341a1c1c6d41cd789332ab85bbb",
|
| 208 |
+
"clusters_sha256": "0162c535ba224b64f328555bb18628d945f5aeb98a4bb7b9f1f832394baddbf6",
|
| 209 |
+
"frozen_embeddings_sha256": "e4c8c3e510b9f28c0b556aa429b79025a07712362e37a2e99a365a8fca844bff"
|
| 210 |
+
},
|
| 211 |
+
"artifacts": {
|
| 212 |
+
"predictions_sha256": "6d8b251deccf2881d9cc8bdf95e6f8a8ec4ecee065c53b56f5ea2b17907bee7f",
|
| 213 |
+
"predictions_in_git": false,
|
| 214 |
+
"structures_in_git": false,
|
| 215 |
+
"checkpoint_created": false,
|
| 216 |
+
"weights_published": false
|
| 217 |
+
},
|
| 218 |
+
"packages": {
|
| 219 |
+
"lightgbm": "4.7.0",
|
| 220 |
+
"numpy_gate": "2.5.1",
|
| 221 |
+
"numpy_pocket_tool": "2.4.6",
|
| 222 |
+
"pocketeer": "0.3.1",
|
| 223 |
+
"biotite": "1.6.0",
|
| 224 |
+
"scipy_pocket_tool": "1.17.1",
|
| 225 |
+
"rdkit": "2026.3.4",
|
| 226 |
+
"scikit_learn": "1.9.0"
|
| 227 |
+
},
|
| 228 |
+
"publication_decision": "publish_negative_report_only"
|
| 229 |
+
}
|
recovery/scripts/extract_pocket_atlas_features.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Extract pinned AlphaFold confidence and pocketeer descriptors."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import hashlib
|
| 8 |
+
import importlib.metadata
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from mitointeract_recovery.pocket_features import (
|
| 16 |
+
FEATURE_NAMES,
|
| 17 |
+
POCKET_TOOL,
|
| 18 |
+
POCKET_TOOL_VERSION,
|
| 19 |
+
extract_structure_features,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
SCHEMA_VERSION = "mitointeract-pocket-atlas-features/v1"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def sha256_file(path: Path) -> str:
|
| 26 |
+
with path.open("rb") as handle:
|
| 27 |
+
return hashlib.file_digest(handle, "sha256").hexdigest()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def worker(accession: str, path_text: str, metadata: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
| 31 |
+
logging.getLogger().setLevel(logging.WARNING)
|
| 32 |
+
vector, diagnostics = extract_structure_features(Path(path_text), metadata)
|
| 33 |
+
return accession, {
|
| 34 |
+
"features": vector.astype(float).tolist(),
|
| 35 |
+
"diagnostics": diagnostics,
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def main() -> None:
|
| 40 |
+
parser = argparse.ArgumentParser()
|
| 41 |
+
parser.add_argument("--structure-manifest", type=Path, required=True)
|
| 42 |
+
parser.add_argument("--structures-dir", type=Path, required=True)
|
| 43 |
+
parser.add_argument("--output-dir", type=Path, required=True)
|
| 44 |
+
parser.add_argument("--workers", type=int, default=4)
|
| 45 |
+
args = parser.parse_args()
|
| 46 |
+
if args.workers <= 0:
|
| 47 |
+
raise ValueError("workers must be positive")
|
| 48 |
+
if args.output_dir.exists() and any(args.output_dir.iterdir()):
|
| 49 |
+
raise FileExistsError(f"output directory is not empty: {args.output_dir}")
|
| 50 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
manifest = json.loads(args.structure_manifest.read_text())
|
| 52 |
+
if manifest.get("schema_version") != "mitointeract-pocket-atlas-structures/v1":
|
| 53 |
+
raise ValueError("unexpected structure manifest schema")
|
| 54 |
+
files = manifest.get("files")
|
| 55 |
+
if not isinstance(files, dict) or not files:
|
| 56 |
+
raise ValueError("structure manifest has no files")
|
| 57 |
+
version = importlib.metadata.version(POCKET_TOOL)
|
| 58 |
+
if version != POCKET_TOOL_VERSION:
|
| 59 |
+
raise ValueError(
|
| 60 |
+
f"unexpected {POCKET_TOOL} version: {version}; expected {POCKET_TOOL_VERSION}"
|
| 61 |
+
)
|
| 62 |
+
jobs: list[tuple[str, Path, dict[str, Any]]] = []
|
| 63 |
+
for accession, metadata in sorted(files.items()):
|
| 64 |
+
path = args.structures_dir / f"{accession}.pdb"
|
| 65 |
+
if not path.is_file():
|
| 66 |
+
raise FileNotFoundError(path)
|
| 67 |
+
actual = sha256_file(path)
|
| 68 |
+
if actual != metadata["sha256"]:
|
| 69 |
+
raise ValueError(f"structure hash mismatch for {accession}")
|
| 70 |
+
jobs.append((accession, path, metadata))
|
| 71 |
+
|
| 72 |
+
results: dict[str, dict[str, Any]] = {}
|
| 73 |
+
failures: dict[str, str] = {}
|
| 74 |
+
with ProcessPoolExecutor(max_workers=args.workers) as executor:
|
| 75 |
+
futures = {
|
| 76 |
+
executor.submit(worker, accession, str(path), metadata): accession
|
| 77 |
+
for accession, path, metadata in jobs
|
| 78 |
+
}
|
| 79 |
+
for future in as_completed(futures):
|
| 80 |
+
accession = futures[future]
|
| 81 |
+
try:
|
| 82 |
+
returned_accession, result = future.result()
|
| 83 |
+
if returned_accession != accession:
|
| 84 |
+
raise RuntimeError("worker accession mismatch")
|
| 85 |
+
if len(result["features"]) != len(FEATURE_NAMES):
|
| 86 |
+
raise RuntimeError("worker feature width mismatch")
|
| 87 |
+
results[accession] = result
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
failures[accession] = f"{type(exc).__name__}: {exc}"
|
| 90 |
+
|
| 91 |
+
features_path = args.output_dir / "features.json"
|
| 92 |
+
features_path.write_text(
|
| 93 |
+
json.dumps(
|
| 94 |
+
{
|
| 95 |
+
"schema_version": SCHEMA_VERSION,
|
| 96 |
+
"feature_names": FEATURE_NAMES,
|
| 97 |
+
"accessions": {key: results[key] for key in sorted(results)},
|
| 98 |
+
},
|
| 99 |
+
indent=2,
|
| 100 |
+
sort_keys=True,
|
| 101 |
+
)
|
| 102 |
+
+ "\n"
|
| 103 |
+
)
|
| 104 |
+
report = {
|
| 105 |
+
"schema_version": SCHEMA_VERSION,
|
| 106 |
+
"tool": {"name": POCKET_TOOL, "version": version},
|
| 107 |
+
"feature_count": len(FEATURE_NAMES),
|
| 108 |
+
"requested_structures": len(jobs),
|
| 109 |
+
"successful_structures": len(results),
|
| 110 |
+
"failed_structures": len(failures),
|
| 111 |
+
"failures": failures,
|
| 112 |
+
"pocket_counts": {
|
| 113 |
+
"minimum": min(result["diagnostics"]["pockets"] for result in results.values()),
|
| 114 |
+
"maximum": max(result["diagnostics"]["pockets"] for result in results.values()),
|
| 115 |
+
"zero": sum(result["diagnostics"]["pockets"] == 0 for result in results.values()),
|
| 116 |
+
}
|
| 117 |
+
if results
|
| 118 |
+
else None,
|
| 119 |
+
"inputs": {
|
| 120 |
+
"structure_manifest": {
|
| 121 |
+
"bytes": args.structure_manifest.stat().st_size,
|
| 122 |
+
"sha256": sha256_file(args.structure_manifest),
|
| 123 |
+
},
|
| 124 |
+
"total_structure_bytes": sum(path.stat().st_size for _, path, _ in jobs),
|
| 125 |
+
},
|
| 126 |
+
"output": {
|
| 127 |
+
"bytes": features_path.stat().st_size,
|
| 128 |
+
"sha256": sha256_file(features_path),
|
| 129 |
+
},
|
| 130 |
+
}
|
| 131 |
+
(args.output_dir / "audit.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
| 132 |
+
print(json.dumps(report, indent=2, sort_keys=True))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
recovery/scripts/prepare_pocket_atlas_sources.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Prepare the pinned development-only Pocket Atlas source snapshot."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import csv
|
| 8 |
+
import hashlib
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
import urllib.error
|
| 12 |
+
import urllib.request
|
| 13 |
+
from collections import Counter
|
| 14 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from mitointeract_recovery.chimera import read_manifest, read_mmseqs_clusters
|
| 19 |
+
from mitointeract_recovery.pocket_sources import (
|
| 20 |
+
build_protein_mapping,
|
| 21 |
+
select_canonical_alphafold_model,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
EXPECTED_SHA256 = {
|
| 25 |
+
"sample": "4b022ee8e3f1a4d00499bcaea218bad730f0181fedd7dda7d6a5a3ea370bb7ea",
|
| 26 |
+
"manifest": "fb0a4dc63b8074018a1b7f02b863b8fa0cf6117930c3298b7c80133867cb2798",
|
| 27 |
+
"source_records": "1e9963217b9ed65528ca477adcba66beea1fa341a1c1c6d41cd789332ab85bbb",
|
| 28 |
+
"clusters": "0162c535ba224b64f328555bb18628d945f5aeb98a4bb7b9f1f832394baddbf6",
|
| 29 |
+
"uniprot": "68c3b65d90446de0df13960242e30d7d60f9a60649374a959ff34405996a2c75",
|
| 30 |
+
}
|
| 31 |
+
UNIPROT_HEADER = (
|
| 32 |
+
"Entry",
|
| 33 |
+
"Entry Name",
|
| 34 |
+
"Reviewed",
|
| 35 |
+
"Organism",
|
| 36 |
+
"Organism (ID)",
|
| 37 |
+
"Protein names",
|
| 38 |
+
"Sequence",
|
| 39 |
+
"Sequence version",
|
| 40 |
+
)
|
| 41 |
+
AFDB_API = "https://alphafold.ebi.ac.uk/api/prediction/"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def sha256_file(path: Path) -> str:
|
| 45 |
+
with path.open("rb") as handle:
|
| 46 |
+
return hashlib.file_digest(handle, "sha256").hexdigest()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 50 |
+
with path.open() as handle:
|
| 51 |
+
return [json.loads(line) for line in handle if line.strip()]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def read_source_accessions(path: Path) -> dict[str, str]:
|
| 55 |
+
output: dict[str, str] = {}
|
| 56 |
+
with path.open() as handle:
|
| 57 |
+
for line_number, line in enumerate(handle, start=1):
|
| 58 |
+
envelope = json.loads(line)
|
| 59 |
+
row = envelope.get("source_record")
|
| 60 |
+
if not isinstance(row, dict):
|
| 61 |
+
raise ValueError(f"source record row {line_number} lacks payload")
|
| 62 |
+
source_id = str(row.get("source_record_id", "")).strip()
|
| 63 |
+
if not source_id or source_id in output:
|
| 64 |
+
raise ValueError(f"invalid or duplicate source record row {line_number}")
|
| 65 |
+
provenance = row.get("provenance")
|
| 66 |
+
if not isinstance(provenance, dict):
|
| 67 |
+
raise ValueError(f"source record row {line_number} lacks provenance")
|
| 68 |
+
output[source_id] = str(provenance.get("swissprot_primary_id") or "").strip()
|
| 69 |
+
return output
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def read_uniprot(path: Path) -> dict[str, dict[str, Any]]:
|
| 73 |
+
output: dict[str, dict[str, Any]] = {}
|
| 74 |
+
with path.open(newline="") as handle:
|
| 75 |
+
reader = csv.DictReader(handle, delimiter="\t")
|
| 76 |
+
if tuple(reader.fieldnames or ()) != UNIPROT_HEADER:
|
| 77 |
+
raise ValueError("unexpected UniProt TSV schema")
|
| 78 |
+
for line_number, row in enumerate(reader, start=2):
|
| 79 |
+
accession = row["Entry"].strip()
|
| 80 |
+
if not accession or accession in output:
|
| 81 |
+
raise ValueError(f"invalid or duplicate UniProt row {line_number}")
|
| 82 |
+
sequence = row["Sequence"].strip()
|
| 83 |
+
if not sequence:
|
| 84 |
+
raise ValueError(f"empty UniProt sequence at row {line_number}")
|
| 85 |
+
output[accession] = {
|
| 86 |
+
"reviewed": row["Reviewed"].strip(),
|
| 87 |
+
"sequence": sequence,
|
| 88 |
+
"sequence_version": int(row["Sequence version"]),
|
| 89 |
+
}
|
| 90 |
+
return output
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def get_metadata(accession: str) -> tuple[str, int, list[dict[str, Any]]]:
|
| 94 |
+
last_error: Exception | None = None
|
| 95 |
+
for attempt in range(4):
|
| 96 |
+
try:
|
| 97 |
+
with urllib.request.urlopen(AFDB_API + accession, timeout=60) as response:
|
| 98 |
+
status = int(response.status)
|
| 99 |
+
data = json.loads(response.read())
|
| 100 |
+
if not isinstance(data, list):
|
| 101 |
+
raise ValueError("AlphaFold API response is not a list")
|
| 102 |
+
selected = [
|
| 103 |
+
model
|
| 104 |
+
for model in data
|
| 105 |
+
if model.get("providerId") == "GDM"
|
| 106 |
+
and not model.get("isComplex", False)
|
| 107 |
+
and model.get("entityType") == "protein"
|
| 108 |
+
]
|
| 109 |
+
return accession, status, selected
|
| 110 |
+
except urllib.error.HTTPError as exc:
|
| 111 |
+
if exc.code == 429:
|
| 112 |
+
time.sleep(2**attempt)
|
| 113 |
+
continue
|
| 114 |
+
last_error = exc
|
| 115 |
+
time.sleep(2**attempt)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
last_error = exc
|
| 118 |
+
time.sleep(2**attempt)
|
| 119 |
+
raise RuntimeError(f"AlphaFold metadata request failed for {accession}: {last_error}")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def download_structure(
|
| 123 |
+
accession: str, model: dict[str, Any], structures_dir: Path
|
| 124 |
+
) -> tuple[str, dict[str, Any]]:
|
| 125 |
+
last_error: Exception | None = None
|
| 126 |
+
url = str(model["pdbUrl"])
|
| 127 |
+
for attempt in range(4):
|
| 128 |
+
try:
|
| 129 |
+
with urllib.request.urlopen(url, timeout=180) as response:
|
| 130 |
+
content = response.read()
|
| 131 |
+
if b"ATOM" not in content[:200_000]:
|
| 132 |
+
raise ValueError("response is not PDB coordinate content")
|
| 133 |
+
path = structures_dir / f"{accession}.pdb"
|
| 134 |
+
path.write_bytes(content)
|
| 135 |
+
return accession, {
|
| 136 |
+
"model_entity_id": model["modelEntityId"],
|
| 137 |
+
"latest_version": model["latestVersion"],
|
| 138 |
+
"model_created_date": model.get("modelCreatedDate"),
|
| 139 |
+
"sequence_checksum": model.get("sequenceChecksum"),
|
| 140 |
+
"sequence_start": model.get("sequenceStart"),
|
| 141 |
+
"sequence_end": model.get("sequenceEnd"),
|
| 142 |
+
"global_metric_value": model.get("globalMetricValue"),
|
| 143 |
+
"fraction_plddt_very_high": model.get("fractionPlddtVeryHigh"),
|
| 144 |
+
"fraction_plddt_confident": model.get("fractionPlddtConfident"),
|
| 145 |
+
"fraction_plddt_low": model.get("fractionPlddtLow"),
|
| 146 |
+
"fraction_plddt_very_low": model.get("fractionPlddtVeryLow"),
|
| 147 |
+
"pdb_url": url,
|
| 148 |
+
"bytes": len(content),
|
| 149 |
+
"sha256": hashlib.sha256(content).hexdigest(),
|
| 150 |
+
}
|
| 151 |
+
except Exception as exc:
|
| 152 |
+
last_error = exc
|
| 153 |
+
time.sleep(2**attempt)
|
| 154 |
+
raise RuntimeError(f"AlphaFold coordinate download failed for {accession}: {last_error}")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def parse_args() -> argparse.Namespace:
|
| 158 |
+
parser = argparse.ArgumentParser()
|
| 159 |
+
parser.add_argument("--sample", type=Path, required=True)
|
| 160 |
+
parser.add_argument("--manifest", type=Path, required=True)
|
| 161 |
+
parser.add_argument("--source-records", type=Path, required=True)
|
| 162 |
+
parser.add_argument("--clusters", type=Path, required=True)
|
| 163 |
+
parser.add_argument("--uniprot", type=Path, required=True)
|
| 164 |
+
parser.add_argument("--output-dir", type=Path, required=True)
|
| 165 |
+
parser.add_argument("--workers", type=int, default=8)
|
| 166 |
+
return parser.parse_args()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main() -> None:
|
| 170 |
+
args = parse_args()
|
| 171 |
+
paths = {
|
| 172 |
+
"sample": args.sample,
|
| 173 |
+
"manifest": args.manifest,
|
| 174 |
+
"source_records": args.source_records,
|
| 175 |
+
"clusters": args.clusters,
|
| 176 |
+
"uniprot": args.uniprot,
|
| 177 |
+
}
|
| 178 |
+
for name, path in paths.items():
|
| 179 |
+
if not path.is_file():
|
| 180 |
+
raise FileNotFoundError(path)
|
| 181 |
+
actual = sha256_file(path)
|
| 182 |
+
if actual != EXPECTED_SHA256[name]:
|
| 183 |
+
raise ValueError(f"{name} hash mismatch: {actual}")
|
| 184 |
+
if args.output_dir.exists():
|
| 185 |
+
raise FileExistsError(args.output_dir)
|
| 186 |
+
metadata_dir = args.output_dir / "afdb_metadata"
|
| 187 |
+
structures_dir = args.output_dir / "structures"
|
| 188 |
+
metadata_dir.mkdir(parents=True)
|
| 189 |
+
structures_dir.mkdir()
|
| 190 |
+
sample_rows = read_jsonl(args.sample)
|
| 191 |
+
mapping_rows, mapping_audit = build_protein_mapping(
|
| 192 |
+
sample_rows=sample_rows,
|
| 193 |
+
manifest=read_manifest(args.manifest),
|
| 194 |
+
source_accessions=read_source_accessions(args.source_records),
|
| 195 |
+
clusters=read_mmseqs_clusters(args.clusters),
|
| 196 |
+
uniprot_entries=read_uniprot(args.uniprot),
|
| 197 |
+
)
|
| 198 |
+
mapping_path = args.output_dir / "protein_mapping.jsonl"
|
| 199 |
+
mapping_path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in mapping_rows))
|
| 200 |
+
eligible_accessions = sorted({str(row["accession"]) for row in mapping_rows if row["eligible"]})
|
| 201 |
+
metadata: dict[str, list[dict[str, Any]]] = {}
|
| 202 |
+
statuses: Counter[int] = Counter()
|
| 203 |
+
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
| 204 |
+
futures = {
|
| 205 |
+
executor.submit(get_metadata, accession): accession for accession in eligible_accessions
|
| 206 |
+
}
|
| 207 |
+
for future in as_completed(futures):
|
| 208 |
+
accession, status, models = future.result()
|
| 209 |
+
metadata[accession] = models
|
| 210 |
+
statuses[status] += 1
|
| 211 |
+
(metadata_dir / f"{accession}.json").write_text(
|
| 212 |
+
json.dumps(models, indent=2, sort_keys=True) + "\n"
|
| 213 |
+
)
|
| 214 |
+
canonical: dict[str, dict[str, Any]] = {}
|
| 215 |
+
unavailable: list[str] = []
|
| 216 |
+
for accession in eligible_accessions:
|
| 217 |
+
model = select_canonical_alphafold_model(accession, metadata[accession])
|
| 218 |
+
if model is None:
|
| 219 |
+
unavailable.append(accession)
|
| 220 |
+
else:
|
| 221 |
+
canonical[accession] = model
|
| 222 |
+
files: dict[str, dict[str, Any]] = {}
|
| 223 |
+
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
| 224 |
+
futures = {
|
| 225 |
+
executor.submit(download_structure, accession, model, structures_dir): accession
|
| 226 |
+
for accession, model in canonical.items()
|
| 227 |
+
}
|
| 228 |
+
for future in as_completed(futures):
|
| 229 |
+
accession, file_metadata = future.result()
|
| 230 |
+
files[accession] = file_metadata
|
| 231 |
+
structure_manifest = {
|
| 232 |
+
"schema_version": "mitointeract-pocket-atlas-structures/v1",
|
| 233 |
+
"canonical_selection": ("modelEntityId == AF-{accession}-F1; provider GDM; monomer only"),
|
| 234 |
+
"eligible_accessions": len(eligible_accessions),
|
| 235 |
+
"available_canonical_models": len(canonical),
|
| 236 |
+
"unavailable_accessions": unavailable,
|
| 237 |
+
"files": {key: files[key] for key in sorted(files)},
|
| 238 |
+
"total_bytes": sum(item["bytes"] for item in files.values()),
|
| 239 |
+
}
|
| 240 |
+
structure_path = args.output_dir / "structure_manifest.json"
|
| 241 |
+
structure_path.write_text(json.dumps(structure_manifest, indent=2, sort_keys=True) + "\n")
|
| 242 |
+
report = {
|
| 243 |
+
"schema_version": "mitointeract-pocket-atlas-sources/v1",
|
| 244 |
+
"inputs": {name: sha256_file(path) for name, path in paths.items()},
|
| 245 |
+
"mapping": {
|
| 246 |
+
**mapping_audit,
|
| 247 |
+
"sha256": sha256_file(mapping_path),
|
| 248 |
+
},
|
| 249 |
+
"alphafold_api": {
|
| 250 |
+
"base_url": AFDB_API,
|
| 251 |
+
"status_counts": {str(key): value for key, value in statuses.items()},
|
| 252 |
+
"metadata_accessions": len(metadata),
|
| 253 |
+
"gdm_monomer_entries": sum(len(models) for models in metadata.values()),
|
| 254 |
+
"canonical_models": len(canonical),
|
| 255 |
+
"unavailable_accessions": unavailable,
|
| 256 |
+
},
|
| 257 |
+
"structures": {
|
| 258 |
+
"files": len(files),
|
| 259 |
+
"total_bytes": structure_manifest["total_bytes"],
|
| 260 |
+
"manifest_sha256": sha256_file(structure_path),
|
| 261 |
+
},
|
| 262 |
+
}
|
| 263 |
+
report_path = args.output_dir / "audit.json"
|
| 264 |
+
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
| 265 |
+
print(json.dumps(report, indent=2, sort_keys=True))
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
main()
|
recovery/scripts/run_pocket_atlas_gate.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run the development-only Pocket Atlas residual gate."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import importlib.metadata
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
from mitointeract_recovery.chimera import (
|
| 15 |
+
LightGBMConfig,
|
| 16 |
+
ResidualVariant,
|
| 17 |
+
cluster_bootstrap_rmse_improvement,
|
| 18 |
+
evaluate_chimera,
|
| 19 |
+
ligand_feature_matrix,
|
| 20 |
+
read_manifest,
|
| 21 |
+
read_mmseqs_clusters,
|
| 22 |
+
sha256_file,
|
| 23 |
+
validate_and_align,
|
| 24 |
+
)
|
| 25 |
+
from mitointeract_recovery.pocket_features import FEATURE_NAMES
|
| 26 |
+
from mitointeract_recovery.pocket_gate import (
|
| 27 |
+
MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT,
|
| 28 |
+
align_structure_features,
|
| 29 |
+
read_mapping,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
| 34 |
+
with path.open() as handle:
|
| 35 |
+
return [json.loads(line) for line in handle if line.strip()]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def parse_args() -> argparse.Namespace:
|
| 39 |
+
parser = argparse.ArgumentParser()
|
| 40 |
+
parser.add_argument("--sample", type=Path, required=True)
|
| 41 |
+
parser.add_argument("--manifest", type=Path, required=True)
|
| 42 |
+
parser.add_argument("--embeddings", type=Path, required=True)
|
| 43 |
+
parser.add_argument("--clusters", type=Path, required=True)
|
| 44 |
+
parser.add_argument("--protein-mapping", type=Path, required=True)
|
| 45 |
+
parser.add_argument("--pocket-features", type=Path, required=True)
|
| 46 |
+
parser.add_argument("--output-dir", type=Path, required=True)
|
| 47 |
+
parser.add_argument("--target-key", default="pkd")
|
| 48 |
+
parser.add_argument("--outer-splits", type=int, default=5)
|
| 49 |
+
parser.add_argument("--control-crossfit-splits", type=int, default=4)
|
| 50 |
+
parser.add_argument("--residual-selection-splits", type=int, default=3)
|
| 51 |
+
parser.add_argument("--bootstrap-iterations", type=int, default=2000)
|
| 52 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 53 |
+
parser.add_argument("--minimum-rmse-improvement", type=float, default=0.05)
|
| 54 |
+
parser.add_argument("--catastrophic-fold-tolerance", type=float, default=0.10)
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--minimum-incremental-rmse-improvement",
|
| 57 |
+
type=float,
|
| 58 |
+
default=MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT,
|
| 59 |
+
)
|
| 60 |
+
return parser.parse_args()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main() -> None:
|
| 64 |
+
args = parse_args()
|
| 65 |
+
inputs = (
|
| 66 |
+
args.sample,
|
| 67 |
+
args.manifest,
|
| 68 |
+
args.embeddings,
|
| 69 |
+
args.clusters,
|
| 70 |
+
args.protein_mapping,
|
| 71 |
+
args.pocket_features,
|
| 72 |
+
)
|
| 73 |
+
for path in inputs:
|
| 74 |
+
if not path.is_file():
|
| 75 |
+
raise FileNotFoundError(path)
|
| 76 |
+
if args.output_dir.exists():
|
| 77 |
+
raise FileExistsError(args.output_dir)
|
| 78 |
+
args.output_dir.mkdir(parents=True)
|
| 79 |
+
started = time.monotonic()
|
| 80 |
+
rows = read_jsonl(args.sample)
|
| 81 |
+
manifest = read_manifest(args.manifest)
|
| 82 |
+
clusters = read_mmseqs_clusters(args.clusters)
|
| 83 |
+
with np.load(args.embeddings) as embedding_arrays:
|
| 84 |
+
aligned = validate_and_align(
|
| 85 |
+
rows,
|
| 86 |
+
embedding_arrays,
|
| 87 |
+
manifest,
|
| 88 |
+
clusters,
|
| 89 |
+
target_key=args.target_key,
|
| 90 |
+
)
|
| 91 |
+
test_clusters = set(aligned["cluster_ids"][aligned["test_indices"]].tolist())
|
| 92 |
+
safe_indices = np.asarray(
|
| 93 |
+
[
|
| 94 |
+
index
|
| 95 |
+
for index in aligned["development_indices"]
|
| 96 |
+
if aligned["cluster_ids"][index] not in test_clusters
|
| 97 |
+
],
|
| 98 |
+
dtype=np.int64,
|
| 99 |
+
)
|
| 100 |
+
excluded_homologous_development = len(aligned["development_indices"]) - len(safe_indices)
|
| 101 |
+
if set(aligned["cluster_ids"][safe_indices]).intersection(test_clusters):
|
| 102 |
+
raise RuntimeError("test-homologous protein cluster survived the safety filter")
|
| 103 |
+
mapping = read_mapping(args.protein_mapping)
|
| 104 |
+
feature_document = json.loads(args.pocket_features.read_text())
|
| 105 |
+
structure_features, structure_coverage = align_structure_features(
|
| 106 |
+
protein_ids=aligned["protein_ids"],
|
| 107 |
+
safe_indices=safe_indices,
|
| 108 |
+
mapping=mapping,
|
| 109 |
+
feature_document=feature_document,
|
| 110 |
+
)
|
| 111 |
+
ligand_control = ligand_feature_matrix(rows)
|
| 112 |
+
variants = [
|
| 113 |
+
ResidualVariant("protein_embedding_ridge_residual", aligned["protein"]),
|
| 114 |
+
ResidualVariant("structure_ridge_residual", structure_features),
|
| 115 |
+
ResidualVariant(
|
| 116 |
+
"protein_embedding_plus_structure_ridge_residual",
|
| 117 |
+
np.concatenate([aligned["protein"], structure_features], axis=1),
|
| 118 |
+
),
|
| 119 |
+
]
|
| 120 |
+
result = evaluate_chimera(
|
| 121 |
+
targets=aligned["targets"],
|
| 122 |
+
ligand_control_features=ligand_control,
|
| 123 |
+
variants=variants,
|
| 124 |
+
development_indices=safe_indices,
|
| 125 |
+
cluster_ids=aligned["cluster_ids"],
|
| 126 |
+
protein_ids=aligned["protein_ids"],
|
| 127 |
+
outer_splits=args.outer_splits,
|
| 128 |
+
control_crossfit_splits=args.control_crossfit_splits,
|
| 129 |
+
residual_selection_splits=args.residual_selection_splits,
|
| 130 |
+
bootstrap_iterations=args.bootstrap_iterations,
|
| 131 |
+
seed=args.seed,
|
| 132 |
+
minimum_rmse_improvement=args.minimum_rmse_improvement,
|
| 133 |
+
catastrophic_fold_tolerance=args.catastrophic_fold_tolerance,
|
| 134 |
+
lightgbm_config=LightGBMConfig(),
|
| 135 |
+
)
|
| 136 |
+
variant_reports = result["variants"]
|
| 137 |
+
esm_name = "protein_embedding_ridge_residual"
|
| 138 |
+
structure_names = (
|
| 139 |
+
"structure_ridge_residual",
|
| 140 |
+
"protein_embedding_plus_structure_ridge_residual",
|
| 141 |
+
)
|
| 142 |
+
best_structure = min(structure_names, key=lambda name: variant_reports[name]["metrics"]["rmse"])
|
| 143 |
+
safe_targets = aligned["targets"][safe_indices]
|
| 144 |
+
safe_clusters = aligned["cluster_ids"][safe_indices]
|
| 145 |
+
esm_predictions = result["variant_predictions"][esm_name][safe_indices]
|
| 146 |
+
structure_predictions = result["variant_predictions"][best_structure][safe_indices]
|
| 147 |
+
incremental_bootstrap = cluster_bootstrap_rmse_improvement(
|
| 148 |
+
safe_targets,
|
| 149 |
+
esm_predictions,
|
| 150 |
+
structure_predictions,
|
| 151 |
+
safe_clusters,
|
| 152 |
+
iterations=args.bootstrap_iterations,
|
| 153 |
+
seed=args.seed + 1,
|
| 154 |
+
)
|
| 155 |
+
incremental_improvement = (
|
| 156 |
+
variant_reports[esm_name]["metrics"]["rmse"]
|
| 157 |
+
- variant_reports[best_structure]["metrics"]["rmse"]
|
| 158 |
+
)
|
| 159 |
+
structure_gate = {
|
| 160 |
+
"best_structure_variant": best_structure,
|
| 161 |
+
"minimum_incremental_rmse_improvement_over_esm": args.minimum_incremental_rmse_improvement,
|
| 162 |
+
"actual_incremental_rmse_improvement_over_esm": incremental_improvement,
|
| 163 |
+
"meets_incremental_improvement": incremental_improvement
|
| 164 |
+
>= args.minimum_incremental_rmse_improvement,
|
| 165 |
+
"incremental_bootstrap": incremental_bootstrap,
|
| 166 |
+
"incremental_bootstrap_lower_bound_positive": incremental_bootstrap["ci_2_5"] > 0,
|
| 167 |
+
"base_gate_passed": variant_reports[best_structure]["gate"]["passed"],
|
| 168 |
+
}
|
| 169 |
+
structure_gate["passed"] = all(
|
| 170 |
+
(
|
| 171 |
+
structure_gate["base_gate_passed"],
|
| 172 |
+
structure_gate["meets_incremental_improvement"],
|
| 173 |
+
structure_gate["incremental_bootstrap_lower_bound_positive"],
|
| 174 |
+
)
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
prediction_path = args.output_dir / "predictions.jsonl"
|
| 178 |
+
with prediction_path.open("w") as handle:
|
| 179 |
+
for index in safe_indices:
|
| 180 |
+
handle.write(
|
| 181 |
+
json.dumps(
|
| 182 |
+
{
|
| 183 |
+
"observation_id": str(aligned["observation_ids"][index]),
|
| 184 |
+
"pair_id": str(aligned["pair_ids"][index]),
|
| 185 |
+
"protein_id": str(aligned["protein_ids"][index]),
|
| 186 |
+
"protein_cluster_id": str(aligned["cluster_ids"][index]),
|
| 187 |
+
"original_split": str(aligned["row_splits"][index]),
|
| 188 |
+
"development_fold": int(result["fold_assignments"][index]),
|
| 189 |
+
"structure_available": bool(structure_features[index, 0]),
|
| 190 |
+
"target_pkd": float(aligned["targets"][index]),
|
| 191 |
+
"ligand_control_prediction": float(result["control_predictions"][index]),
|
| 192 |
+
"variant_predictions": {
|
| 193 |
+
name: float(predictions[index])
|
| 194 |
+
for name, predictions in result["variant_predictions"].items()
|
| 195 |
+
},
|
| 196 |
+
},
|
| 197 |
+
sort_keys=True,
|
| 198 |
+
)
|
| 199 |
+
+ "\n"
|
| 200 |
+
)
|
| 201 |
+
report = {
|
| 202 |
+
"schema_version": "mitointeract-pocket-atlas-gate/v1",
|
| 203 |
+
"experiment": "pocket_atlas_development_gate",
|
| 204 |
+
"status": "development_only_no_test_or_external_evaluation",
|
| 205 |
+
"decision": (
|
| 206 |
+
"advance_structure_candidate"
|
| 207 |
+
if structure_gate["passed"]
|
| 208 |
+
else "reject_structure_candidate"
|
| 209 |
+
),
|
| 210 |
+
"target": "pKd",
|
| 211 |
+
"seed": args.seed,
|
| 212 |
+
"benchmark_test_evaluations": 0,
|
| 213 |
+
"external_holdout_evaluations": 0,
|
| 214 |
+
"population": {
|
| 215 |
+
"sample_rows": len(rows),
|
| 216 |
+
"source_development_rows": len(aligned["development_indices"]),
|
| 217 |
+
"development_rows_excluded_for_test_cluster_homology": excluded_homologous_development,
|
| 218 |
+
"safe_development_rows": len(safe_indices),
|
| 219 |
+
"safe_development_pairs": len(set(aligned["pair_ids"][safe_indices].tolist())),
|
| 220 |
+
"safe_development_proteins": len(set(aligned["protein_ids"][safe_indices].tolist())),
|
| 221 |
+
"safe_development_clusters": result["development_clusters"],
|
| 222 |
+
"benchmark_test_rows_excluded": len(aligned["test_indices"]),
|
| 223 |
+
"benchmark_test_clusters_reserved": len(test_clusters),
|
| 224 |
+
},
|
| 225 |
+
"structure_coverage": structure_coverage,
|
| 226 |
+
"inputs": {path.stem + "_sha256": sha256_file(path) for path in inputs},
|
| 227 |
+
"features": {
|
| 228 |
+
"pocket_feature_count": len(FEATURE_NAMES),
|
| 229 |
+
"mapping_quality_feature_count": 2,
|
| 230 |
+
"total_structure_feature_count": structure_features.shape[1],
|
| 231 |
+
"pocket_feature_names": FEATURE_NAMES,
|
| 232 |
+
"mapping_quality_features": ["uniprot_sequence_coverage", "exact_sequence_mapping"],
|
| 233 |
+
},
|
| 234 |
+
"control": {
|
| 235 |
+
"name": "ligand_morgan_descriptors_lightgbm",
|
| 236 |
+
**result["control"],
|
| 237 |
+
},
|
| 238 |
+
"variants": variant_reports,
|
| 239 |
+
"structure_gate": structure_gate,
|
| 240 |
+
"folds": result["folds"],
|
| 241 |
+
"protocol": {
|
| 242 |
+
"outer_splits": args.outer_splits,
|
| 243 |
+
"outer_group": "Source Atlas joint MMseqs2 50/80 protein cluster",
|
| 244 |
+
"control_crossfit_splits": args.control_crossfit_splits,
|
| 245 |
+
"residual_selection_splits": args.residual_selection_splits,
|
| 246 |
+
"bootstrap_iterations": args.bootstrap_iterations,
|
| 247 |
+
"bootstrap_unit": "Source Atlas MMseqs2 protein cluster",
|
| 248 |
+
"minimum_control_rmse_improvement": args.minimum_rmse_improvement,
|
| 249 |
+
"minimum_incremental_rmse_improvement_over_esm": (
|
| 250 |
+
args.minimum_incremental_rmse_improvement
|
| 251 |
+
),
|
| 252 |
+
"catastrophic_fold_tolerance": args.catastrophic_fold_tolerance,
|
| 253 |
+
"benchmark_test_policy": (
|
| 254 |
+
"masked and excluded with every homologous development cluster"
|
| 255 |
+
),
|
| 256 |
+
"external_holdout_policy": "not loaded, not predicted, and not evaluated",
|
| 257 |
+
},
|
| 258 |
+
"packages": {
|
| 259 |
+
package: importlib.metadata.version(package)
|
| 260 |
+
for package in ("lightgbm", "numpy", "rdkit", "scikit-learn")
|
| 261 |
+
},
|
| 262 |
+
"artifacts": {
|
| 263 |
+
"predictions": prediction_path.name,
|
| 264 |
+
"predictions_sha256": sha256_file(prediction_path),
|
| 265 |
+
},
|
| 266 |
+
"elapsed_seconds": time.monotonic() - started,
|
| 267 |
+
}
|
| 268 |
+
report_path = args.output_dir / "report.json"
|
| 269 |
+
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
| 270 |
+
print(
|
| 271 |
+
json.dumps(
|
| 272 |
+
{
|
| 273 |
+
"decision": report["decision"],
|
| 274 |
+
"control_rmse": report["control"]["metrics"]["rmse"],
|
| 275 |
+
"esm_rmse": report["variants"][esm_name]["metrics"]["rmse"],
|
| 276 |
+
"best_structure_variant": best_structure,
|
| 277 |
+
"best_structure_rmse": report["variants"][best_structure]["metrics"]["rmse"],
|
| 278 |
+
"structure_gate": structure_gate,
|
| 279 |
+
"benchmark_test_evaluations": 0,
|
| 280 |
+
"external_holdout_evaluations": 0,
|
| 281 |
+
"report": str(report_path),
|
| 282 |
+
},
|
| 283 |
+
indent=2,
|
| 284 |
+
)
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
if __name__ == "__main__":
|
| 289 |
+
main()
|
recovery/src/mitointeract_recovery/pocket_features.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic AlphaFold confidence and alpha-sphere pocket descriptors."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from scipy.spatial.distance import pdist # type: ignore[import-not-found]
|
| 11 |
+
|
| 12 |
+
POCKET_TOOL = "pocketeer"
|
| 13 |
+
POCKET_TOOL_VERSION = "0.3.1"
|
| 14 |
+
TOP_POCKETS = 3
|
| 15 |
+
HYDROPHOBIC = frozenset({"ALA", "VAL", "ILE", "LEU", "MET", "PHE", "TRP", "PRO"})
|
| 16 |
+
AROMATIC = frozenset({"PHE", "TYR", "TRP", "HIS"})
|
| 17 |
+
CHARGED = frozenset({"ASP", "GLU", "LYS", "ARG", "HIS"})
|
| 18 |
+
POLAR = frozenset({"SER", "THR", "ASN", "GLN", "CYS", "TYR", "HIS"})
|
| 19 |
+
|
| 20 |
+
GLOBAL_FEATURES = (
|
| 21 |
+
"structure_available",
|
| 22 |
+
"af_global_metric_100",
|
| 23 |
+
"af_fraction_very_high",
|
| 24 |
+
"af_fraction_confident",
|
| 25 |
+
"af_fraction_low",
|
| 26 |
+
"af_fraction_very_low",
|
| 27 |
+
"log_sequence_length",
|
| 28 |
+
"log_atom_count",
|
| 29 |
+
"log_ca_count",
|
| 30 |
+
"radius_gyration_100",
|
| 31 |
+
"bbox_small_100",
|
| 32 |
+
"bbox_middle_100",
|
| 33 |
+
"bbox_large_100",
|
| 34 |
+
"log_bbox_volume",
|
| 35 |
+
"shape_eigenvalue_1",
|
| 36 |
+
"shape_eigenvalue_2",
|
| 37 |
+
"shape_eigenvalue_3",
|
| 38 |
+
"ca_contact_density_8",
|
| 39 |
+
"ca_contact_density_12",
|
| 40 |
+
"plddt_mean_100",
|
| 41 |
+
"plddt_std_100",
|
| 42 |
+
"plddt_q10_100",
|
| 43 |
+
"plddt_q50_100",
|
| 44 |
+
"plddt_q90_100",
|
| 45 |
+
"plddt_fraction_ge70",
|
| 46 |
+
"plddt_fraction_ge90",
|
| 47 |
+
"log_pocket_count",
|
| 48 |
+
"pocket_score_max",
|
| 49 |
+
"pocket_score_mean",
|
| 50 |
+
"log_pocket_volume_sum",
|
| 51 |
+
"log_pocket_residue_sum",
|
| 52 |
+
)
|
| 53 |
+
POCKET_FEATURE_SUFFIXES = (
|
| 54 |
+
"score",
|
| 55 |
+
"log_volume",
|
| 56 |
+
"log_spheres",
|
| 57 |
+
"log_residues",
|
| 58 |
+
"mean_radius_10",
|
| 59 |
+
"mean_sasa_100",
|
| 60 |
+
"plddt_mean_100",
|
| 61 |
+
"hydrophobic_fraction",
|
| 62 |
+
"aromatic_fraction",
|
| 63 |
+
"charged_fraction",
|
| 64 |
+
"polar_fraction",
|
| 65 |
+
)
|
| 66 |
+
FEATURE_NAMES = GLOBAL_FEATURES + tuple(
|
| 67 |
+
f"pocket_{rank}_{suffix}"
|
| 68 |
+
for rank in range(1, TOP_POCKETS + 1)
|
| 69 |
+
for suffix in POCKET_FEATURE_SUFFIXES
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def parse_pdb_plddt(path: Path) -> np.ndarray:
|
| 74 |
+
"""Read AlphaFold per-atom pLDDT from PDB B-factor columns."""
|
| 75 |
+
values: list[float] = []
|
| 76 |
+
with path.open() as handle:
|
| 77 |
+
for line_number, line in enumerate(handle, start=1):
|
| 78 |
+
if not line.startswith(("ATOM ", "HETATM")):
|
| 79 |
+
continue
|
| 80 |
+
if len(line) < 66:
|
| 81 |
+
raise ValueError(f"PDB line {line_number}: missing B-factor columns")
|
| 82 |
+
try:
|
| 83 |
+
value = float(line[60:66])
|
| 84 |
+
except ValueError as exc:
|
| 85 |
+
raise ValueError(f"PDB line {line_number}: invalid B-factor") from exc
|
| 86 |
+
if not math.isfinite(value) or not 0 <= value <= 100:
|
| 87 |
+
raise ValueError(f"PDB line {line_number}: invalid AlphaFold pLDDT")
|
| 88 |
+
values.append(value)
|
| 89 |
+
if not values:
|
| 90 |
+
raise ValueError("PDB contains no coordinate records")
|
| 91 |
+
return np.asarray(values, dtype=np.float64)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _fraction(residues: list[tuple[str, int, str]], group: frozenset[str]) -> float:
|
| 95 |
+
if not residues:
|
| 96 |
+
return 0.0
|
| 97 |
+
return sum(residue_name in group for _, _, residue_name in residues) / len(residues)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _pocket_features(pocket: Any, plddt: np.ndarray) -> list[float]:
|
| 101 |
+
radii = np.asarray([sphere.radius for sphere in pocket.spheres], dtype=np.float64)
|
| 102 |
+
sasa = np.asarray([sphere.mean_sasa for sphere in pocket.spheres], dtype=np.float64)
|
| 103 |
+
pocket_confidence = plddt[np.asarray(pocket.mask, dtype=bool)]
|
| 104 |
+
residues = list(pocket.residues)
|
| 105 |
+
return [
|
| 106 |
+
float(pocket.score),
|
| 107 |
+
math.log1p(max(0.0, float(pocket.volume))),
|
| 108 |
+
math.log1p(int(pocket.n_spheres)),
|
| 109 |
+
math.log1p(len(residues)),
|
| 110 |
+
float(np.mean(radii)) / 10.0,
|
| 111 |
+
float(np.mean(sasa)) / 100.0,
|
| 112 |
+
float(np.mean(pocket_confidence)) / 100.0,
|
| 113 |
+
_fraction(residues, HYDROPHOBIC),
|
| 114 |
+
_fraction(residues, AROMATIC),
|
| 115 |
+
_fraction(residues, CHARGED),
|
| 116 |
+
_fraction(residues, POLAR),
|
| 117 |
+
]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def structure_feature_vector(
|
| 121 |
+
atomarray: Any,
|
| 122 |
+
pockets: list[Any],
|
| 123 |
+
plddt: np.ndarray,
|
| 124 |
+
metadata: dict[str, Any],
|
| 125 |
+
) -> np.ndarray:
|
| 126 |
+
"""Build fixed-width, rotation-invariant global and top-pocket features."""
|
| 127 |
+
coordinates = np.asarray(atomarray.coord, dtype=np.float64)
|
| 128 |
+
if coordinates.ndim != 2 or coordinates.shape[1] != 3:
|
| 129 |
+
raise ValueError("atom coordinates must have shape [N, 3]")
|
| 130 |
+
if plddt.shape != (len(atomarray),):
|
| 131 |
+
raise ValueError(f"pLDDT/atom count mismatch: {len(plddt)} vs {len(atomarray)}")
|
| 132 |
+
ca_mask = np.asarray(atomarray.atom_name == "CA", dtype=bool)
|
| 133 |
+
ca = coordinates[ca_mask]
|
| 134 |
+
if len(ca) < 2:
|
| 135 |
+
raise ValueError("structure has fewer than two CA atoms")
|
| 136 |
+
centered = ca - np.mean(ca, axis=0)
|
| 137 |
+
radius_gyration = float(np.sqrt(np.mean(np.sum(centered**2, axis=1))))
|
| 138 |
+
covariance = np.cov(centered, rowvar=False)
|
| 139 |
+
raw_eigenvalues, eigenvectors = np.linalg.eigh(covariance)
|
| 140 |
+
eigenvalues = np.maximum(raw_eigenvalues, 0.0)[::-1]
|
| 141 |
+
principal_coordinates = centered @ eigenvectors[:, ::-1]
|
| 142 |
+
bounds = np.sort(np.ptp(principal_coordinates, axis=0))
|
| 143 |
+
eigenvalue_total = float(np.sum(eigenvalues))
|
| 144 |
+
normalized_eigenvalues = eigenvalues / eigenvalue_total if eigenvalue_total > 0 else np.zeros(3)
|
| 145 |
+
distances = pdist(ca)
|
| 146 |
+
contact_8 = float(np.mean(distances <= 8.0))
|
| 147 |
+
contact_12 = float(np.mean(distances <= 12.0))
|
| 148 |
+
sorted_pockets = sorted(pockets, key=lambda pocket: pocket.score, reverse=True)
|
| 149 |
+
scores = np.asarray([pocket.score for pocket in sorted_pockets], dtype=np.float64)
|
| 150 |
+
volumes = np.asarray([pocket.volume for pocket in sorted_pockets], dtype=np.float64)
|
| 151 |
+
residue_counts = np.asarray(
|
| 152 |
+
[len(pocket.residues) for pocket in sorted_pockets], dtype=np.float64
|
| 153 |
+
)
|
| 154 |
+
plddt_quantiles = np.quantile(plddt, [0.1, 0.5, 0.9])
|
| 155 |
+
global_values = [
|
| 156 |
+
1.0,
|
| 157 |
+
float(metadata["global_metric_value"]) / 100.0,
|
| 158 |
+
float(metadata["fraction_plddt_very_high"]),
|
| 159 |
+
float(metadata["fraction_plddt_confident"]),
|
| 160 |
+
float(metadata["fraction_plddt_low"]),
|
| 161 |
+
float(metadata["fraction_plddt_very_low"]),
|
| 162 |
+
math.log1p(int(metadata["sequence_end"]) - int(metadata["sequence_start"]) + 1),
|
| 163 |
+
math.log1p(len(atomarray)),
|
| 164 |
+
math.log1p(len(ca)),
|
| 165 |
+
radius_gyration / 100.0,
|
| 166 |
+
*(bounds / 100.0).tolist(),
|
| 167 |
+
math.log1p(float(np.prod(bounds))),
|
| 168 |
+
*normalized_eigenvalues.tolist(),
|
| 169 |
+
contact_8,
|
| 170 |
+
contact_12,
|
| 171 |
+
float(np.mean(plddt)) / 100.0,
|
| 172 |
+
float(np.std(plddt)) / 100.0,
|
| 173 |
+
*(plddt_quantiles / 100.0).tolist(),
|
| 174 |
+
float(np.mean(plddt >= 70.0)),
|
| 175 |
+
float(np.mean(plddt >= 90.0)),
|
| 176 |
+
math.log1p(len(sorted_pockets)),
|
| 177 |
+
float(np.max(scores)) if len(scores) else 0.0,
|
| 178 |
+
float(np.mean(scores)) if len(scores) else 0.0,
|
| 179 |
+
math.log1p(float(np.sum(volumes))) if len(volumes) else 0.0,
|
| 180 |
+
math.log1p(float(np.sum(residue_counts))) if len(residue_counts) else 0.0,
|
| 181 |
+
]
|
| 182 |
+
values = global_values
|
| 183 |
+
for rank in range(TOP_POCKETS):
|
| 184 |
+
if rank < len(sorted_pockets):
|
| 185 |
+
values.extend(_pocket_features(sorted_pockets[rank], plddt))
|
| 186 |
+
else:
|
| 187 |
+
values.extend([0.0] * len(POCKET_FEATURE_SUFFIXES))
|
| 188 |
+
vector = np.asarray(values, dtype=np.float32)
|
| 189 |
+
if vector.shape != (len(FEATURE_NAMES),):
|
| 190 |
+
raise RuntimeError(f"feature width mismatch: {vector.shape} vs {len(FEATURE_NAMES)}")
|
| 191 |
+
if not np.isfinite(vector).all():
|
| 192 |
+
raise ValueError("structure feature vector contains nonfinite values")
|
| 193 |
+
return vector
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def extract_structure_features(
|
| 197 |
+
pdb_path: Path, metadata: dict[str, Any]
|
| 198 |
+
) -> tuple[np.ndarray, dict[str, int]]:
|
| 199 |
+
"""Load one structure and run the pinned optional pocket detector."""
|
| 200 |
+
import importlib.metadata
|
| 201 |
+
|
| 202 |
+
import pocketeer # type: ignore[import-not-found]
|
| 203 |
+
|
| 204 |
+
version = importlib.metadata.version(POCKET_TOOL)
|
| 205 |
+
if version != POCKET_TOOL_VERSION:
|
| 206 |
+
raise ValueError(
|
| 207 |
+
f"unexpected {POCKET_TOOL} version: {version}; expected {POCKET_TOOL_VERSION}"
|
| 208 |
+
)
|
| 209 |
+
atomarray = pocketeer.load_structure(str(pdb_path))
|
| 210 |
+
plddt = parse_pdb_plddt(pdb_path)
|
| 211 |
+
pockets = pocketeer.find_pockets(atomarray)
|
| 212 |
+
return structure_feature_vector(atomarray, pockets, plddt, metadata), {
|
| 213 |
+
"atoms": len(atomarray),
|
| 214 |
+
"ca_atoms": int(np.sum(atomarray.atom_name == "CA")),
|
| 215 |
+
"pockets": len(pockets),
|
| 216 |
+
}
|
recovery/src/mitointeract_recovery/pocket_gate.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validation and alignment helpers for the Pocket Atlas development gate."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from mitointeract_recovery.pocket_features import FEATURE_NAMES
|
| 12 |
+
|
| 13 |
+
FEATURE_SCHEMA = "mitointeract-pocket-atlas-features/v1"
|
| 14 |
+
MINIMUM_INCREMENTAL_RMSE_IMPROVEMENT = 0.02
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def read_mapping(path: Path) -> dict[str, dict[str, Any]]:
|
| 18 |
+
mapping: dict[str, dict[str, Any]] = {}
|
| 19 |
+
with path.open() as handle:
|
| 20 |
+
for row_number, line in enumerate(handle, start=1):
|
| 21 |
+
if not line.strip():
|
| 22 |
+
continue
|
| 23 |
+
row = json.loads(line)
|
| 24 |
+
protein_id = str(row.get("protein_id", "")).strip()
|
| 25 |
+
if not protein_id or protein_id in mapping:
|
| 26 |
+
raise ValueError(f"invalid or duplicate protein mapping row {row_number}")
|
| 27 |
+
mapping[protein_id] = row
|
| 28 |
+
if not mapping:
|
| 29 |
+
raise ValueError("protein mapping is empty")
|
| 30 |
+
return mapping
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def align_structure_features(
|
| 34 |
+
*,
|
| 35 |
+
protein_ids: np.ndarray,
|
| 36 |
+
safe_indices: np.ndarray,
|
| 37 |
+
mapping: dict[str, dict[str, Any]],
|
| 38 |
+
feature_document: dict[str, Any],
|
| 39 |
+
) -> tuple[np.ndarray, dict[str, int]]:
|
| 40 |
+
"""Align accession-level descriptors to observations with fail-closed coverage."""
|
| 41 |
+
if feature_document.get("schema_version") != FEATURE_SCHEMA:
|
| 42 |
+
raise ValueError("unexpected Pocket Atlas feature schema")
|
| 43 |
+
if tuple(feature_document.get("feature_names", ())) != FEATURE_NAMES:
|
| 44 |
+
raise ValueError("Pocket Atlas feature names differ from the code contract")
|
| 45 |
+
accessions = feature_document.get("accessions")
|
| 46 |
+
if not isinstance(accessions, dict):
|
| 47 |
+
raise ValueError("Pocket Atlas feature document has no accession map")
|
| 48 |
+
safe_proteins = set(protein_ids[safe_indices].tolist())
|
| 49 |
+
if set(mapping) != safe_proteins:
|
| 50 |
+
raise ValueError("protein mapping must exactly cover homology-safe proteins")
|
| 51 |
+
matrix = np.zeros((len(protein_ids), len(FEATURE_NAMES) + 2), dtype=np.float32)
|
| 52 |
+
available_proteins: set[str] = set()
|
| 53 |
+
for protein_id in safe_proteins:
|
| 54 |
+
row = mapping[protein_id]
|
| 55 |
+
accession = str(row.get("accession", ""))
|
| 56 |
+
if bool(row.get("eligible")) and accession in accessions:
|
| 57 |
+
vector = np.asarray(accessions[accession].get("features"), dtype=np.float32)
|
| 58 |
+
if vector.shape != (len(FEATURE_NAMES),) or not np.isfinite(vector).all():
|
| 59 |
+
raise ValueError(f"invalid Pocket Atlas vector for {accession}")
|
| 60 |
+
if vector[0] != 1.0:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"available Pocket Atlas vector lacks availability flag: {accession}"
|
| 63 |
+
)
|
| 64 |
+
coverage = float(row.get("coverage", float("nan")))
|
| 65 |
+
if not np.isfinite(coverage) or not 0.8 <= coverage <= 1.0:
|
| 66 |
+
raise ValueError(f"invalid eligible mapping coverage for {protein_id}")
|
| 67 |
+
protein_rows = np.flatnonzero(protein_ids == protein_id)
|
| 68 |
+
matrix[protein_rows, : len(FEATURE_NAMES)] = vector
|
| 69 |
+
matrix[protein_rows, -2] = coverage
|
| 70 |
+
matrix[protein_rows, -1] = float(row.get("placement") == "exact")
|
| 71 |
+
available_proteins.add(protein_id)
|
| 72 |
+
if not np.isfinite(matrix).all():
|
| 73 |
+
raise ValueError("aligned Pocket Atlas matrix contains nonfinite values")
|
| 74 |
+
safe_available = matrix[safe_indices, 0] == 1.0
|
| 75 |
+
return matrix, {
|
| 76 |
+
"safe_rows": len(safe_indices),
|
| 77 |
+
"available_rows": int(np.sum(safe_available)),
|
| 78 |
+
"missing_rows": int(np.sum(~safe_available)),
|
| 79 |
+
"safe_proteins": len(safe_proteins),
|
| 80 |
+
"available_proteins": len(available_proteins),
|
| 81 |
+
"missing_proteins": len(safe_proteins.difference(available_proteins)),
|
| 82 |
+
}
|
recovery/src/mitointeract_recovery/pocket_sources.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the development-only benchmark-to-UniProt structure mapping."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def place_sequence(query: str, reference: str) -> dict[str, Any]:
|
| 10 |
+
"""Place a benchmark sequence in a UniProt sequence without alignment guessing."""
|
| 11 |
+
if not query or not reference:
|
| 12 |
+
return {
|
| 13 |
+
"placement": "unmapped",
|
| 14 |
+
"coverage": 0.0,
|
| 15 |
+
"sequence_start": None,
|
| 16 |
+
"sequence_end": None,
|
| 17 |
+
}
|
| 18 |
+
if query == reference:
|
| 19 |
+
return {
|
| 20 |
+
"placement": "exact",
|
| 21 |
+
"coverage": 1.0,
|
| 22 |
+
"sequence_start": 1,
|
| 23 |
+
"sequence_end": len(reference),
|
| 24 |
+
}
|
| 25 |
+
if query in reference:
|
| 26 |
+
start = reference.index(query) + 1
|
| 27 |
+
return {
|
| 28 |
+
"placement": "subsequence",
|
| 29 |
+
"coverage": len(query) / len(reference),
|
| 30 |
+
"sequence_start": start,
|
| 31 |
+
"sequence_end": start + len(query) - 1,
|
| 32 |
+
}
|
| 33 |
+
if reference in query:
|
| 34 |
+
return {
|
| 35 |
+
"placement": "supersequence",
|
| 36 |
+
"coverage": len(reference) / len(query),
|
| 37 |
+
"sequence_start": 1,
|
| 38 |
+
"sequence_end": len(reference),
|
| 39 |
+
}
|
| 40 |
+
return {
|
| 41 |
+
"placement": "noncontiguous",
|
| 42 |
+
"coverage": 0.0,
|
| 43 |
+
"sequence_start": None,
|
| 44 |
+
"sequence_end": None,
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def select_canonical_alphafold_model(
|
| 49 |
+
accession: str, models: list[dict[str, Any]]
|
| 50 |
+
) -> dict[str, Any] | None:
|
| 51 |
+
"""Select only the canonical DeepMind monomer, never an isoform or complex."""
|
| 52 |
+
expected_id = f"AF-{accession}-F1"
|
| 53 |
+
matches = [
|
| 54 |
+
model
|
| 55 |
+
for model in models
|
| 56 |
+
if model.get("modelEntityId") == expected_id
|
| 57 |
+
and model.get("providerId") == "GDM"
|
| 58 |
+
and model.get("entityType") == "protein"
|
| 59 |
+
and not model.get("isComplex", False)
|
| 60 |
+
]
|
| 61 |
+
if not matches:
|
| 62 |
+
return None
|
| 63 |
+
if len(matches) != 1:
|
| 64 |
+
raise ValueError(f"multiple canonical AlphaFold models for {accession}")
|
| 65 |
+
model = matches[0]
|
| 66 |
+
if not model.get("pdbUrl") or not model.get("latestVersion"):
|
| 67 |
+
raise ValueError(f"canonical AlphaFold model lacks versioned coordinates: {accession}")
|
| 68 |
+
return model
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def build_protein_mapping(
|
| 72 |
+
*,
|
| 73 |
+
sample_rows: list[dict[str, Any]],
|
| 74 |
+
manifest: dict[str, str],
|
| 75 |
+
source_accessions: dict[str, str],
|
| 76 |
+
clusters: dict[str, str],
|
| 77 |
+
uniprot_entries: dict[str, dict[str, Any]],
|
| 78 |
+
minimum_coverage: float = 0.8,
|
| 79 |
+
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
| 80 |
+
"""Map homology-safe development proteins to reviewed UniProt sequences."""
|
| 81 |
+
if not 0 < minimum_coverage <= 1:
|
| 82 |
+
raise ValueError("minimum coverage must be in (0, 1]")
|
| 83 |
+
pair_ids = {str(row.get("pair_id", "")) for row in sample_rows}
|
| 84 |
+
if set(manifest) != pair_ids:
|
| 85 |
+
raise ValueError("manifest pair set must exactly match the sample")
|
| 86 |
+
missing_clusters = {str(row.get("protein_id", "")) for row in sample_rows}.difference(clusters)
|
| 87 |
+
if missing_clusters:
|
| 88 |
+
raise ValueError(
|
| 89 |
+
f"sample proteins missing cluster assignments: {sorted(missing_clusters)[:3]}"
|
| 90 |
+
)
|
| 91 |
+
test_clusters = {
|
| 92 |
+
clusters[str(row["protein_id"])]
|
| 93 |
+
for row in sample_rows
|
| 94 |
+
if manifest[str(row["pair_id"])] == "test"
|
| 95 |
+
}
|
| 96 |
+
safe_rows = [
|
| 97 |
+
row
|
| 98 |
+
for row in sample_rows
|
| 99 |
+
if manifest[str(row["pair_id"])] in {"train", "validation"}
|
| 100 |
+
and clusters[str(row["protein_id"])] not in test_clusters
|
| 101 |
+
]
|
| 102 |
+
if not safe_rows:
|
| 103 |
+
raise ValueError("homology-safe development population is empty")
|
| 104 |
+
accessions_by_protein: dict[str, set[str]] = defaultdict(set)
|
| 105 |
+
sequence_by_protein: dict[str, str] = {}
|
| 106 |
+
for row in safe_rows:
|
| 107 |
+
protein_id = str(row["protein_id"])
|
| 108 |
+
sequence = str(row["sequence"])
|
| 109 |
+
previous = sequence_by_protein.setdefault(protein_id, sequence)
|
| 110 |
+
if previous != sequence:
|
| 111 |
+
raise ValueError(f"inconsistent sequence for protein {protein_id}")
|
| 112 |
+
for source_record_id in row.get("source_record_ids", ()):
|
| 113 |
+
accession = source_accessions.get(str(source_record_id), "").strip()
|
| 114 |
+
if accession:
|
| 115 |
+
accessions_by_protein[protein_id].add(accession)
|
| 116 |
+
output: list[dict[str, Any]] = []
|
| 117 |
+
reason_counts: dict[str, int] = defaultdict(int)
|
| 118 |
+
for protein_id in sorted(sequence_by_protein):
|
| 119 |
+
accessions = sorted(accessions_by_protein[protein_id])
|
| 120 |
+
record: dict[str, Any] = {
|
| 121 |
+
"protein_id": protein_id,
|
| 122 |
+
"sequence": sequence_by_protein[protein_id],
|
| 123 |
+
"cluster": clusters[protein_id],
|
| 124 |
+
"accessions": accessions,
|
| 125 |
+
"eligible": False,
|
| 126 |
+
}
|
| 127 |
+
if len(accessions) != 1:
|
| 128 |
+
reason = "missing_accession" if not accessions else "ambiguous_accession"
|
| 129 |
+
reason_counts[reason] += 1
|
| 130 |
+
output.append(record)
|
| 131 |
+
continue
|
| 132 |
+
accession = accessions[0]
|
| 133 |
+
entry = uniprot_entries.get(accession)
|
| 134 |
+
if entry is None:
|
| 135 |
+
reason_counts["missing_uniprot_entry"] += 1
|
| 136 |
+
output.append(record)
|
| 137 |
+
continue
|
| 138 |
+
if str(entry.get("reviewed")) != "reviewed":
|
| 139 |
+
reason_counts["unreviewed_uniprot_entry"] += 1
|
| 140 |
+
output.append(record)
|
| 141 |
+
continue
|
| 142 |
+
reference = str(entry["sequence"])
|
| 143 |
+
placement = place_sequence(record["sequence"], reference)
|
| 144 |
+
record.update(
|
| 145 |
+
{
|
| 146 |
+
"accession": accession,
|
| 147 |
+
"uniprot_sequence": reference,
|
| 148 |
+
"placement": placement["placement"],
|
| 149 |
+
"coverage": placement["coverage"],
|
| 150 |
+
"sequence_start": placement["sequence_start"],
|
| 151 |
+
"sequence_end": placement["sequence_end"],
|
| 152 |
+
"uniprot_sequence_version": int(entry["sequence_version"]),
|
| 153 |
+
}
|
| 154 |
+
)
|
| 155 |
+
record["eligible"] = (
|
| 156 |
+
placement["placement"] in {"exact", "subsequence", "supersequence"}
|
| 157 |
+
and placement["coverage"] >= minimum_coverage
|
| 158 |
+
)
|
| 159 |
+
if not record["eligible"]:
|
| 160 |
+
reason_counts["sequence_placement_or_coverage"] += 1
|
| 161 |
+
output.append(record)
|
| 162 |
+
audit = {
|
| 163 |
+
"safe_rows": len(safe_rows),
|
| 164 |
+
"safe_proteins": len(output),
|
| 165 |
+
"test_clusters_reserved": len(test_clusters),
|
| 166 |
+
"eligible_proteins": sum(bool(row["eligible"]) for row in output),
|
| 167 |
+
"eligible_accessions": len({str(row["accession"]) for row in output if row["eligible"]}),
|
| 168 |
+
**{f"ineligible_{key}": value for key, value in sorted(reason_counts.items())},
|
| 169 |
+
}
|
| 170 |
+
return output, audit
|
recovery/tests/test_checked_reports.py
CHANGED
|
@@ -211,6 +211,49 @@ def test_gtopdb_external_holdout_is_independent_cold_both_and_sealed():
|
|
| 211 |
assert report["evaluation_policy"]["status"] == "sealed"
|
| 212 |
|
| 213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
def test_primary_kd_audit_and_similarity_split():
|
| 215 |
audit = load("balm-kd-audit.json")
|
| 216 |
clusters = load("balm-kd-protein-clusters.json")
|
|
|
|
| 211 |
assert report["evaluation_policy"]["status"] == "sealed"
|
| 212 |
|
| 213 |
|
| 214 |
+
def test_pocket_atlas_gate_rejects_structure_without_touching_holdouts():
|
| 215 |
+
report = load("bindingdb-v2-pocket-atlas-development-gate.json")
|
| 216 |
+
assert report["decision"] == "reject_structure_candidate_no_further_training"
|
| 217 |
+
assert report["benchmark_test_evaluations"] == 0
|
| 218 |
+
assert report["external_holdout_evaluations"] == 0
|
| 219 |
+
assert report["population"] == {
|
| 220 |
+
"sample_rows": 2327,
|
| 221 |
+
"source_development_rows": 2057,
|
| 222 |
+
"development_rows_excluded_for_test_cluster_homology": 292,
|
| 223 |
+
"safe_development_rows": 1765,
|
| 224 |
+
"safe_development_pairs": 1717,
|
| 225 |
+
"safe_development_proteins": 278,
|
| 226 |
+
"safe_development_clusters": 187,
|
| 227 |
+
"benchmark_test_rows_excluded": 270,
|
| 228 |
+
"benchmark_test_clusters_reserved": 34,
|
| 229 |
+
}
|
| 230 |
+
assert report["alphafold_snapshot"]["canonical_models"] == 145
|
| 231 |
+
assert report["structure_features"]["successful_structures"] == 145
|
| 232 |
+
assert report["structure_features"]["failed_structures"] == 0
|
| 233 |
+
control = report["control"]["metrics"]["rmse"]
|
| 234 |
+
esm = report["variants"]["protein_embedding_ridge_residual"]["metrics"]["rmse"]
|
| 235 |
+
structure = report["variants"]["structure_ridge_residual"]["metrics"]["rmse"]
|
| 236 |
+
combined = report["variants"]["protein_embedding_plus_structure_ridge_residual"]["metrics"][
|
| 237 |
+
"rmse"
|
| 238 |
+
]
|
| 239 |
+
assert esm < combined < control < structure
|
| 240 |
+
gate = report["predeclared_structure_gate"]
|
| 241 |
+
assert gate["actual_incremental_rmse_improvement_vs_esm"] < 0
|
| 242 |
+
assert gate["incremental_cluster_bootstrap"]["ci_2_5"] < 0
|
| 243 |
+
assert gate["incremental_cluster_bootstrap"]["ci_97_5"] > 0
|
| 244 |
+
assert gate["no_catastrophic_fold"] is False
|
| 245 |
+
assert gate["passed"] is False
|
| 246 |
+
verification = report["verification"]
|
| 247 |
+
assert verification["test_pairs_in_predictions"] == 0
|
| 248 |
+
assert verification["test_clusters_in_predictions"] == 0
|
| 249 |
+
assert verification["source_reproduction"] == {
|
| 250 |
+
"mapping_exact_match": True,
|
| 251 |
+
"structure_manifest_exact_match": True,
|
| 252 |
+
}
|
| 253 |
+
assert report["artifacts"]["checkpoint_created"] is False
|
| 254 |
+
assert report["artifacts"]["weights_published"] is False
|
| 255 |
+
|
| 256 |
+
|
| 257 |
def test_primary_kd_audit_and_similarity_split():
|
| 258 |
audit = load("balm-kd-audit.json")
|
| 259 |
clusters = load("balm-kd-protein-clusters.json")
|
recovery/tests/test_pocket_features.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for deterministic Pocket Atlas structural descriptors."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pytest
|
| 10 |
+
from mitointeract_recovery.pocket_features import (
|
| 11 |
+
FEATURE_NAMES,
|
| 12 |
+
parse_pdb_plddt,
|
| 13 |
+
structure_feature_vector,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FakeAtoms:
|
| 18 |
+
def __init__(self, coordinates: np.ndarray, atom_names: list[str]) -> None:
|
| 19 |
+
self.coord = coordinates
|
| 20 |
+
self.atom_name = np.asarray(atom_names)
|
| 21 |
+
|
| 22 |
+
def __len__(self) -> int:
|
| 23 |
+
return len(self.coord)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class FakeSphere:
|
| 28 |
+
radius: float
|
| 29 |
+
mean_sasa: float
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class FakePocket:
|
| 34 |
+
score: float
|
| 35 |
+
volume: float
|
| 36 |
+
spheres: list[FakeSphere]
|
| 37 |
+
residues: list[tuple[str, int, str]]
|
| 38 |
+
mask: np.ndarray
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def n_spheres(self) -> int:
|
| 42 |
+
return len(self.spheres)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def pdb_atom(serial: int, plddt: float) -> str:
|
| 46 |
+
return (
|
| 47 |
+
f"ATOM {serial:5d} CA ALA A{serial:4d} "
|
| 48 |
+
f"{serial:8.3f}{0.0:8.3f}{0.0:8.3f}{1.0:6.2f}{plddt:6.2f} C\n"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def metadata() -> dict:
|
| 53 |
+
return {
|
| 54 |
+
"global_metric_value": 85.0,
|
| 55 |
+
"fraction_plddt_very_high": 0.4,
|
| 56 |
+
"fraction_plddt_confident": 0.4,
|
| 57 |
+
"fraction_plddt_low": 0.1,
|
| 58 |
+
"fraction_plddt_very_low": 0.1,
|
| 59 |
+
"sequence_start": 1,
|
| 60 |
+
"sequence_end": 4,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_parse_pdb_plddt_reads_coordinate_b_factors(tmp_path: Path) -> None:
|
| 65 |
+
path = tmp_path / "model.pdb"
|
| 66 |
+
path.write_text(pdb_atom(1, 91.2) + pdb_atom(2, 67.8) + "END\n")
|
| 67 |
+
assert parse_pdb_plddt(path).tolist() == [91.2, 67.8]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_parse_pdb_plddt_rejects_out_of_range_confidence(tmp_path: Path) -> None:
|
| 71 |
+
path = tmp_path / "model.pdb"
|
| 72 |
+
path.write_text(pdb_atom(1, 101.0))
|
| 73 |
+
with pytest.raises(ValueError, match="invalid AlphaFold pLDDT"):
|
| 74 |
+
parse_pdb_plddt(path)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_structure_features_have_fixed_finite_width() -> None:
|
| 78 |
+
coordinates = np.asarray([[0, 0, 0], [2, 1, 0], [4, 1, 2], [7, 3, 1]], dtype=float)
|
| 79 |
+
atoms = FakeAtoms(coordinates, ["CA"] * 4)
|
| 80 |
+
pocket = FakePocket(
|
| 81 |
+
score=0.8,
|
| 82 |
+
volume=200.0,
|
| 83 |
+
spheres=[FakeSphere(4.0, 12.0), FakeSphere(5.0, 16.0)],
|
| 84 |
+
residues=[("A", 1, "LEU"), ("A", 2, "ASP")],
|
| 85 |
+
mask=np.asarray([True, True, False, False]),
|
| 86 |
+
)
|
| 87 |
+
vector = structure_feature_vector(
|
| 88 |
+
atoms, [pocket], np.asarray([90, 80, 70, 60], dtype=float), metadata()
|
| 89 |
+
)
|
| 90 |
+
assert vector.shape == (len(FEATURE_NAMES),)
|
| 91 |
+
assert len(FEATURE_NAMES) == len(set(FEATURE_NAMES))
|
| 92 |
+
assert np.isfinite(vector).all()
|
| 93 |
+
assert vector[FEATURE_NAMES.index("structure_available")] == 1
|
| 94 |
+
assert vector[FEATURE_NAMES.index("log_pocket_count")] > 0
|
| 95 |
+
assert vector[FEATURE_NAMES.index("pocket_1_hydrophobic_fraction")] == 0.5
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_structure_features_are_rotation_invariant() -> None:
|
| 99 |
+
coordinates = np.asarray([[0, 0, 0], [2, 1, 0], [4, 1, 2], [7, 3, 1], [8, 6, 3]], dtype=float)
|
| 100 |
+
rotation = np.asarray([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]], dtype=float)
|
| 101 |
+
plddt = np.asarray([90, 80, 70, 60, 50], dtype=float)
|
| 102 |
+
first = structure_feature_vector(FakeAtoms(coordinates, ["CA"] * 5), [], plddt, metadata())
|
| 103 |
+
second = structure_feature_vector(
|
| 104 |
+
FakeAtoms(coordinates @ rotation.T, ["CA"] * 5), [], plddt, metadata()
|
| 105 |
+
)
|
| 106 |
+
np.testing.assert_allclose(first, second, rtol=1e-6, atol=1e-6)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def test_structure_features_reject_plddt_atom_mismatch() -> None:
|
| 110 |
+
atoms = FakeAtoms(np.asarray([[0, 0, 0], [1, 1, 1]], dtype=float), ["CA", "CA"])
|
| 111 |
+
with pytest.raises(ValueError, match="count mismatch"):
|
| 112 |
+
structure_feature_vector(atoms, [], np.asarray([90.0]), metadata())
|
recovery/tests/test_pocket_gate.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Pocket Atlas feature alignment and missingness handling."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pytest
|
| 10 |
+
from mitointeract_recovery.pocket_features import FEATURE_NAMES
|
| 11 |
+
from mitointeract_recovery.pocket_gate import (
|
| 12 |
+
FEATURE_SCHEMA,
|
| 13 |
+
align_structure_features,
|
| 14 |
+
read_mapping,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def mapping() -> dict[str, dict]:
|
| 19 |
+
return {
|
| 20 |
+
"p1": {
|
| 21 |
+
"protein_id": "p1",
|
| 22 |
+
"eligible": True,
|
| 23 |
+
"accession": "A1",
|
| 24 |
+
"coverage": 1.0,
|
| 25 |
+
"placement": "exact",
|
| 26 |
+
},
|
| 27 |
+
"p2": {
|
| 28 |
+
"protein_id": "p2",
|
| 29 |
+
"eligible": True,
|
| 30 |
+
"accession": "A2",
|
| 31 |
+
"coverage": 0.9,
|
| 32 |
+
"placement": "subsequence",
|
| 33 |
+
},
|
| 34 |
+
"p3": {"protein_id": "p3", "eligible": False},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def document() -> dict:
|
| 39 |
+
vector = [0.0] * len(FEATURE_NAMES)
|
| 40 |
+
vector[0] = 1.0
|
| 41 |
+
return {
|
| 42 |
+
"schema_version": FEATURE_SCHEMA,
|
| 43 |
+
"feature_names": FEATURE_NAMES,
|
| 44 |
+
"accessions": {"A1": {"features": vector}},
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def test_align_structure_features_tracks_available_and_missing_rows() -> None:
|
| 49 |
+
protein_ids = np.asarray(["p1", "p1", "p2", "p3"])
|
| 50 |
+
matrix, coverage = align_structure_features(
|
| 51 |
+
protein_ids=protein_ids,
|
| 52 |
+
safe_indices=np.arange(4),
|
| 53 |
+
mapping=mapping(),
|
| 54 |
+
feature_document=document(),
|
| 55 |
+
)
|
| 56 |
+
assert matrix.shape == (4, len(FEATURE_NAMES) + 2)
|
| 57 |
+
assert matrix[:, 0].tolist() == [1.0, 1.0, 0.0, 0.0]
|
| 58 |
+
assert matrix[:2, -2].tolist() == [1.0, 1.0]
|
| 59 |
+
assert matrix[:2, -1].tolist() == [1.0, 1.0]
|
| 60 |
+
assert coverage == {
|
| 61 |
+
"safe_rows": 4,
|
| 62 |
+
"available_rows": 2,
|
| 63 |
+
"missing_rows": 2,
|
| 64 |
+
"safe_proteins": 3,
|
| 65 |
+
"available_proteins": 1,
|
| 66 |
+
"missing_proteins": 2,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_align_structure_features_requires_exact_safe_protein_manifest() -> None:
|
| 71 |
+
with pytest.raises(ValueError, match="exactly cover"):
|
| 72 |
+
align_structure_features(
|
| 73 |
+
protein_ids=np.asarray(["p1", "p2"]),
|
| 74 |
+
safe_indices=np.arange(2),
|
| 75 |
+
mapping={"p1": mapping()["p1"]},
|
| 76 |
+
feature_document=document(),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_align_structure_features_rejects_invalid_availability_flag() -> None:
|
| 81 |
+
bad = document()
|
| 82 |
+
bad["accessions"]["A1"]["features"][0] = 0.0
|
| 83 |
+
with pytest.raises(ValueError, match="availability flag"):
|
| 84 |
+
align_structure_features(
|
| 85 |
+
protein_ids=np.asarray(["p1", "p2", "p3"]),
|
| 86 |
+
safe_indices=np.arange(3),
|
| 87 |
+
mapping=mapping(),
|
| 88 |
+
feature_document=bad,
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_align_structure_features_rejects_contract_drift() -> None:
|
| 93 |
+
bad = document()
|
| 94 |
+
bad["feature_names"] = list(FEATURE_NAMES[:-1])
|
| 95 |
+
with pytest.raises(ValueError, match="feature names"):
|
| 96 |
+
align_structure_features(
|
| 97 |
+
protein_ids=np.asarray(["p1", "p2", "p3"]),
|
| 98 |
+
safe_indices=np.arange(3),
|
| 99 |
+
mapping=mapping(),
|
| 100 |
+
feature_document=bad,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_read_mapping_rejects_duplicate_proteins(tmp_path: Path) -> None:
|
| 105 |
+
path = tmp_path / "mapping.jsonl"
|
| 106 |
+
path.write_text(
|
| 107 |
+
json.dumps({"protein_id": "p1"}) + "\n" + json.dumps({"protein_id": "p1"}) + "\n"
|
| 108 |
+
)
|
| 109 |
+
with pytest.raises(ValueError, match="duplicate"):
|
| 110 |
+
read_mapping(path)
|
recovery/tests/test_pocket_sources.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for Pocket Atlas source mapping and canonical model selection."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from mitointeract_recovery.pocket_sources import (
|
| 7 |
+
build_protein_mapping,
|
| 8 |
+
place_sequence,
|
| 9 |
+
select_canonical_alphafold_model,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def test_place_sequence_exact_and_contiguous_constructs() -> None:
|
| 14 |
+
assert place_sequence("ACDE", "ACDE") == {
|
| 15 |
+
"placement": "exact",
|
| 16 |
+
"coverage": 1.0,
|
| 17 |
+
"sequence_start": 1,
|
| 18 |
+
"sequence_end": 4,
|
| 19 |
+
}
|
| 20 |
+
subsequence = place_sequence("CDE", "ACDEF")
|
| 21 |
+
assert subsequence == {
|
| 22 |
+
"placement": "subsequence",
|
| 23 |
+
"coverage": 0.6,
|
| 24 |
+
"sequence_start": 2,
|
| 25 |
+
"sequence_end": 4,
|
| 26 |
+
}
|
| 27 |
+
supersequence = place_sequence("XACDEY", "ACDE")
|
| 28 |
+
assert supersequence["placement"] == "supersequence"
|
| 29 |
+
assert supersequence["coverage"] == pytest.approx(4 / 6)
|
| 30 |
+
assert place_sequence("AAAA", "CCCC")["placement"] == "noncontiguous"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_select_canonical_model_excludes_isoforms_and_complexes() -> None:
|
| 34 |
+
models = [
|
| 35 |
+
{
|
| 36 |
+
"modelEntityId": "AF-P12345-2-F1",
|
| 37 |
+
"providerId": "GDM",
|
| 38 |
+
"entityType": "protein",
|
| 39 |
+
"isComplex": False,
|
| 40 |
+
"pdbUrl": "isoform",
|
| 41 |
+
"latestVersion": 6,
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"modelEntityId": "AF-P12345-F1",
|
| 45 |
+
"providerId": "GDM",
|
| 46 |
+
"entityType": "protein",
|
| 47 |
+
"isComplex": False,
|
| 48 |
+
"pdbUrl": "canonical",
|
| 49 |
+
"latestVersion": 6,
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"modelEntityId": "AF-P12345-F1",
|
| 53 |
+
"providerId": "NVIDIA",
|
| 54 |
+
"entityType": "protein",
|
| 55 |
+
"isComplex": True,
|
| 56 |
+
"pdbUrl": "complex",
|
| 57 |
+
"latestVersion": 1,
|
| 58 |
+
},
|
| 59 |
+
]
|
| 60 |
+
selected = select_canonical_alphafold_model("P12345", models)
|
| 61 |
+
assert selected is not None
|
| 62 |
+
assert selected["pdbUrl"] == "canonical"
|
| 63 |
+
assert select_canonical_alphafold_model("Q99999", models) is None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_build_mapping_excludes_test_and_test_homology_clusters() -> None:
|
| 67 |
+
rows = [
|
| 68 |
+
{
|
| 69 |
+
"pair_id": "pair-1",
|
| 70 |
+
"protein_id": "p1",
|
| 71 |
+
"sequence": "ACDE",
|
| 72 |
+
"source_record_ids": ["s1"],
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"pair_id": "pair-2",
|
| 76 |
+
"protein_id": "p2",
|
| 77 |
+
"sequence": "FGHI",
|
| 78 |
+
"source_record_ids": ["s2"],
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"pair_id": "pair-3",
|
| 82 |
+
"protein_id": "p3",
|
| 83 |
+
"sequence": "KLMN",
|
| 84 |
+
"source_record_ids": ["s3"],
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"pair_id": "pair-4",
|
| 88 |
+
"protein_id": "p4",
|
| 89 |
+
"sequence": "QRST",
|
| 90 |
+
"source_record_ids": ["s4"],
|
| 91 |
+
},
|
| 92 |
+
]
|
| 93 |
+
mapping, audit = build_protein_mapping(
|
| 94 |
+
sample_rows=rows,
|
| 95 |
+
manifest={
|
| 96 |
+
"pair-1": "train",
|
| 97 |
+
"pair-2": "test",
|
| 98 |
+
"pair-3": "validation",
|
| 99 |
+
"pair-4": "validation",
|
| 100 |
+
},
|
| 101 |
+
source_accessions={"s1": "U1", "s2": "U2", "s3": "U3", "s4": "U4"},
|
| 102 |
+
clusters={"p1": "c1", "p2": "ct", "p3": "ct", "p4": "c4"},
|
| 103 |
+
uniprot_entries={
|
| 104 |
+
"U1": {"reviewed": "reviewed", "sequence": "ACDE", "sequence_version": 1},
|
| 105 |
+
"U4": {
|
| 106 |
+
"reviewed": "reviewed",
|
| 107 |
+
"sequence": "QRSTX",
|
| 108 |
+
"sequence_version": 2,
|
| 109 |
+
},
|
| 110 |
+
},
|
| 111 |
+
)
|
| 112 |
+
assert [row["protein_id"] for row in mapping] == ["p1", "p4"]
|
| 113 |
+
assert all(row["eligible"] for row in mapping)
|
| 114 |
+
assert mapping[1]["placement"] == "subsequence"
|
| 115 |
+
assert mapping[1]["coverage"] == 0.8
|
| 116 |
+
assert audit == {
|
| 117 |
+
"safe_rows": 2,
|
| 118 |
+
"safe_proteins": 2,
|
| 119 |
+
"test_clusters_reserved": 1,
|
| 120 |
+
"eligible_proteins": 2,
|
| 121 |
+
"eligible_accessions": 2,
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_build_mapping_rejects_incomplete_manifest() -> None:
|
| 126 |
+
with pytest.raises(ValueError, match="pair set"):
|
| 127 |
+
build_protein_mapping(
|
| 128 |
+
sample_rows=[
|
| 129 |
+
{
|
| 130 |
+
"pair_id": "pair-1",
|
| 131 |
+
"protein_id": "p1",
|
| 132 |
+
"sequence": "ACDE",
|
| 133 |
+
"source_record_ids": [],
|
| 134 |
+
}
|
| 135 |
+
],
|
| 136 |
+
manifest={},
|
| 137 |
+
source_accessions={},
|
| 138 |
+
clusters={"p1": "c1"},
|
| 139 |
+
uniprot_entries={},
|
| 140 |
+
)
|