ethanolivertroy commited on
Commit
6019d52
·
1 Parent(s): 7bf19c3

feat: build leakage-aware MitoInteract recovery pipeline

Browse files
AUDIT.md CHANGED
@@ -13,10 +13,11 @@ The published checkpoint is a failed proof of concept, not a scientifically usab
13
  | Training set was only 32 pairs | `config.json`: `max_train_samples: 32` | Cannot support the claim that the model learned from the 1.9M-pair dataset |
14
  | Validation set was only 16 pairs | `max_val_samples: 16` | Correlation metrics are unstable and not representative |
15
  | Reported correlation is negative | Checkpoint: Pearson `-0.9107416`, Spearman `-0.5` | Predictions are directionally wrong on the tiny validation set |
16
- | Regression error is enormous | Checkpoint: RMSE `7.0257`, MAE `6.8183` | Error is roughly the scale of the entire useful pKd range |
17
  | Training barely began | 32 samples / batch 4 × 2 epochs = 16 steps, but warmup is 500 steps | The run completed only 3.2% of warmup; approximate final LR was `3.2e-05`, not the configured `1e-3` |
18
  | Model card contradicts checkpoint | README says ESM-2 650M; config/checkpoint use ESM-2 35M | Architecture and reproducibility claims are incorrect |
19
  | Target semantics are undocumented | Dataset has `affinity_uM`, `neg_log10_affinity_M`, and normalized `affinity`; config names none | Published inference labels raw output as pKd without proving it was trained on pKd |
 
20
  | Evaluation split is undocumented | No training/evaluation code is published | Protein/ligand leakage cannot be ruled out |
21
  | Reproduction assets are missing | No training script, requirements, seed, split manifest, predictions, or metric calculation | Results cannot be independently reproduced |
22
  | Serialization is unsafe and oversized | `full_model.pt` is a 315.7 MB pickle checkpoint loaded with `weights_only=False` | Avoidable code-execution risk and duplicated frozen encoders |
@@ -27,7 +28,7 @@ The separate `mitointeract_weights.pt` file contains 1,278,980 trainable head pa
27
 
28
  MitoInteract v2 does not earn a model-card performance claim until all gates pass:
29
 
30
- 1. **Unit semantics:** pKd conversion tests pass and the exact target column is fixed to `neg_log10_affinity_M`.
31
  2. **Overfit sanity check:** the trainable head can overfit a fixed 32-example set to near-zero training error.
32
  3. **Real validation:** at least 1,000 validation examples, with a published split manifest.
33
  4. **Leakage-aware evaluation:** report random-pair, cold-protein, and cold-ligand/scaffold results separately.
@@ -40,7 +41,7 @@ MitoInteract v2 does not earn a model-card performance claim until all gates pas
40
  For the first bounded experiment, use ESM-2 35M plus ChemBERTa with frozen encoders and a simpler regression head. On a fixed 10,000-pair development sample:
41
 
42
  - validation/test size: at least 1,000 each
43
- - primary target: pKd (`neg_log10_affinity_M`)
44
  - primary split: cold-protein
45
  - RMSE: lower than training-mean baseline
46
  - Pearson: `> 0.30`
@@ -48,3 +49,21 @@ For the first bounded experiment, use ESM-2 35M plus ChemBERTa with frozen encod
48
  - no NaNs or unit-conversion failures
49
 
50
  These are recovery gates, not publishable state-of-the-art claims.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  | Training set was only 32 pairs | `config.json`: `max_train_samples: 32` | Cannot support the claim that the model learned from the 1.9M-pair dataset |
14
  | Validation set was only 16 pairs | `max_val_samples: 16` | Correlation metrics are unstable and not representative |
15
  | Reported correlation is negative | Checkpoint: Pearson `-0.9107416`, Spearman `-0.5` | Predictions are directionally wrong on the tiny validation set |
16
+ | Regression error is enormous | Checkpoint: RMSE `7.0257`, MAE `6.8183` | Error is roughly the scale of the entire reported target range |
17
  | Training barely began | 32 samples / batch 4 × 2 epochs = 16 steps, but warmup is 500 steps | The run completed only 3.2% of warmup; approximate final LR was `3.2e-05`, not the configured `1e-3` |
18
  | Model card contradicts checkpoint | README says ESM-2 650M; config/checkpoint use ESM-2 35M | Architecture and reproducibility claims are incorrect |
19
  | Target semantics are undocumented | Dataset has `affinity_uM`, `neg_log10_affinity_M`, and normalized `affinity`; config names none | Published inference labels raw output as pKd without proving it was trained on pKd |
20
+ | Source target conflates measurement types | The pinned BindingDB notebook combines available `Ki`, `Kd`, `IC50`, and `EC50` values and averages them in linear µM | The dataset target is generic pAffinity, not pKd; assay provenance must be retained in any scientific successor |
21
  | Evaluation split is undocumented | No training/evaluation code is published | Protein/ligand leakage cannot be ruled out |
22
  | Reproduction assets are missing | No training script, requirements, seed, split manifest, predictions, or metric calculation | Results cannot be independently reproduced |
23
  | Serialization is unsafe and oversized | `full_model.pt` is a 315.7 MB pickle checkpoint loaded with `weights_only=False` | Avoidable code-execution risk and duplicated frozen encoders |
 
28
 
29
  MitoInteract v2 does not earn a model-card performance claim until all gates pass:
30
 
31
+ 1. **Unit semantics:** pAffinity conversion tests pass, the exact target column is fixed to `neg_log10_affinity_M`, and the model card does not relabel mixed measurements as pKd.
32
  2. **Overfit sanity check:** the trainable head can overfit a fixed 32-example set to near-zero training error.
33
  3. **Real validation:** at least 1,000 validation examples, with a published split manifest.
34
  4. **Leakage-aware evaluation:** report random-pair, cold-protein, and cold-ligand/scaffold results separately.
 
41
  For the first bounded experiment, use ESM-2 35M plus ChemBERTa with frozen encoders and a simpler regression head. On a fixed 10,000-pair development sample:
42
 
43
  - validation/test size: at least 1,000 each
44
+ - primary target: generic pAffinity (`neg_log10_affinity_M`), not pKd
45
  - primary split: cold-protein
46
  - RMSE: lower than training-mean baseline
47
  - Pearson: `> 0.30`
 
49
  - no NaNs or unit-conversion failures
50
 
51
  These are recovery gates, not publishable state-of-the-art claims.
52
+
53
+ ## Recovery evidence as of 2026-07-19
54
+
55
+ The deterministic 10,000-pair development sample is now backed by the immutable dataset revision `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b`. The original Parquet file was verified against SHA-256 `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`.
56
+
57
+ | Gate | Result |
58
+ |---|---|
59
+ | pAffinity/µM numeric conversion | passed, while preserving the mixed-measurement warning |
60
+ | Python test suite | passed, 33 tests with the optional model dependencies installed |
61
+ | 32-pair synthetic-embedding overfit | passed |
62
+ | 32-pair pinned-encoder overfit | passed, RMSE below `1e-6` pAffinity |
63
+ | Random-pair combined ridge | RMSE `1.3358`, Pearson `0.5371`, Spearman `0.5187` |
64
+ | Exact protein-disjoint combined ridge | RMSE `1.3462`, Pearson `0.4384`, Spearman `0.4283` |
65
+ | MMseqs2 50% similarity-disjoint combined ridge | RMSE `1.4349`, Pearson `0.3625`, Spearman `0.3652` |
66
+ | Scaffold-disjoint combined ridge | RMSE `1.3649`, Pearson `0.5198`, Spearman `0.5354` |
67
+ | Full 10,000-pair neural head | pending GPU embedding run |
68
+
69
+ The MMseqs2 split clustered 2,255 unique proteins into 1,421 groups at 50% sequence identity and 80% bidirectional coverage, then held whole clusters out. The combined ridge model beat its train-mean RMSE of `1.5336`, but the ligand-only ridge slightly outperformed the combined model on this split. This is evidence of dataset shortcut risk, not evidence that the recovery model has learned general protein-ligand interaction biology.
README.md CHANGED
@@ -4,91 +4,138 @@ tags:
4
  - biology
5
  - protein
6
  - drug-target-interaction
7
- - mitochondria
8
- - apoptosis
9
  - binding-affinity
10
  - esm2
11
  - chemberta
12
  datasets:
13
  - jglaser/binding_affinity
14
- metrics:
15
- - pearsonr
16
- - spearmanr
17
- - rmse
18
- - mae
19
  ---
20
 
21
- # MitoInteract: Protein-Molecule Binding Affinity Prediction for Mitochondrial Apoptosis Research
22
 
23
- ## Overview
24
 
25
- MitoInteract is a dual-encoder model that predicts **binding affinity (pKd)** between any protein and any molecule.
26
- It combines:
27
- - **ESM-2 650M** (protein encoder) for protein sequence understanding
28
- - **ChemBERTa** (molecule encoder) for SMILES-based molecular representation
29
- - **Bidirectional cross-attention** fusion layer
30
- - **4-layer MLP** regression head
31
 
32
- ## Intended Use
33
 
34
- This model is designed for **mitochondrial apoptosis research**, enabling researchers to:
35
- - Predict how ceramides interact with mitochondrial membrane proteins (VDAC1, VDAC2)
36
- - Screen BCL-2 family protein interactions with BH3 mimetic drugs (venetoclax, navitoclax, ABT-737)
37
- - Explore protein-lipid interactions in the apoptosis pathway
38
- - Run in-silico binding experiments before wet-lab validation
39
 
40
- ## Quick Start
41
 
42
- ```python
43
- from model import load_model, predict_binding
44
 
45
- # Load model
46
- model, config = load_model("full_model.pt", device="cuda")
 
 
 
 
 
 
 
 
47
 
48
- # Predict ceramide C16 binding to VDAC1
49
- result = predict_binding(
50
- model,
51
- protein_seq="MPPYLTFGLKAGALLPLTLPYVRAEAVTKLKLTLNAFEGASK...", # VDAC1
52
- smiles="CCCCCCCCCCCCCCCC(=O)N[C@@H](CO)[C@H](O)/C=C/CCCCCCCCCCCCC", # Ceramide C16
53
- device="cuda"
54
- )
55
- print(f"Predicted pKd: {result['pKd']:.3f}")
56
- print(f"Predicted Kd: {result['Kd_uM']:.3f} µM")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  ```
58
 
59
- ## Key Apoptosis Targets
60
-
61
- | Protein | Role in Apoptosis |
62
- |---------|-------------------|
63
- | BCL-2 | Anti-apoptotic, prevents MOMP |
64
- | BCL-XL | Anti-apoptotic, sequesters BAX/BAK |
65
- | BAX | Pro-apoptotic, forms pores in outer membrane |
66
- | BAK | Pro-apoptotic, oligomerizes in membrane |
67
- | VDAC1 | Voltage-dependent anion channel, ceramide target |
68
- | Cytochrome c | Released during MOMP, activates caspase cascade |
69
-
70
- ## Key Molecules
71
-
72
- | Molecule | Role |
73
- |----------|------|
74
- | Ceramide C16 | Lipid mediator, promotes MOMP via VDAC |
75
- | Ceramide C2 | Short-chain ceramide analog |
76
- | Venetoclax | BCL-2 inhibitor (FDA-approved) |
77
- | Navitoclax | BCL-2/BCL-XL dual inhibitor |
78
- | ABT-737 | BCL-2/BCL-XL/BCL-w inhibitor |
79
- | Cardiolipin | Mitochondrial inner membrane lipid |
80
-
81
- ## Training Details
82
-
83
- - **Dataset**: jglaser/binding_affinity (1.9M protein-ligand pairs)
84
- - **Architecture**: ESM-2 650M (frozen) + ChemBERTa (frozen) + Cross-Attention + MLP
85
- - **Training**: AdamW, lr=1e-3, cosine schedule, early stopping
86
- - **Best Validation Pearson R**: -0.9107
87
-
88
- ## Citation
89
-
90
- Based on:
91
- - BAPULM (arxiv:2411.04150) - frozen encoder + MLP pattern
92
- - SSM-DTA (arxiv:2206.09818) - CLS cross-attention fusion
93
- - ESM-2 (arxiv:2202.03555) - protein language model
94
- - ChemBERTa (arxiv:2010.09885) - molecular language model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  - biology
5
  - protein
6
  - drug-target-interaction
 
 
7
  - binding-affinity
8
  - esm2
9
  - chemberta
10
  datasets:
11
  - jglaser/binding_affinity
 
 
 
 
 
12
  ---
13
 
14
+ # MitoInteract
15
 
16
+ > **Research recovery in progress. The published v1 checkpoint is deprecated and must not be used to rank compounds or guide experiments.**
17
 
18
+ MitoInteract is an experimental protein-ligand binding-affinity project. The recovery target is **pAffinity**, defined as `-log10(the dataset's affinity value in molar units)`. Higher values indicate a numerically stronger reported affinity.
 
 
 
 
 
19
 
20
+ This target is **not pKd**. The pinned dataset's BindingDB preprocessing combines available `Ki`, `Kd`, `IC50`, and `EC50` values and, when more than one exists, averages them in linear micromolar units. Those measurements are not scientifically interchangeable. The generic `pAffinity` name preserves the source semantics without pretending every row is a dissociation constant.
21
 
22
+ The first published checkpoint did not complete a meaningful training run. Its validation correlations were negative, its evaluation set contained only 16 pairs, and its model card overstated the architecture and intended use. The weights remain in this repository for provenance only. See [`AUDIT.md`](AUDIT.md) for the artifact-level evidence.
 
 
 
 
23
 
24
+ ## Current status
25
 
26
+ The recovery branch now provides:
 
27
 
28
+ - explicit and tested pAffinity/µM conversions;
29
+ - a dataset revision pinned to `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b`;
30
+ - deterministic sampling, pair deduplication, and split manifests;
31
+ - Bemis-Murcko scaffold grouping with an exact-ligand fallback for acyclic molecules;
32
+ - MMseqs2 protein clustering at 50% identity and 80% bidirectional coverage;
33
+ - train-mean, protein-only, ligand-only, and combined character n-gram ridge baselines;
34
+ - a corrected LayerNorm-based v2 head with standardized targets;
35
+ - pinned ESM-2 and ChemBERTa revisions, long-protein chunking, and cached frozen embeddings;
36
+ - safe `safetensors` output for new trainable weights;
37
+ - unit tests and a successful 32-pair end-to-end overfit sanity check.
38
 
39
+ No v2 neural checkpoint has earned a performance claim yet.
40
+
41
+ ## Reproducible development baseline
42
+
43
+ These are **development results**, not an external benchmark. They use a deterministic 10,000-pair sample from `jglaser/binding_affinity`. Each test split contains 1,000 pairs.
44
+
45
+ | Split | Model | Test RMSE ↓ | Pearson ↑ | Spearman ↑ |
46
+ |---|---|---:|---:|---:|
47
+ | Random pair | train mean | 1.5650 | undefined | undefined |
48
+ | Random pair | combined character ridge | **1.3358** | **0.5371** | **0.5187** |
49
+ | Exact protein-disjoint | train mean | 1.4951 | undefined | undefined |
50
+ | Exact protein-disjoint | combined character ridge | **1.3462** | **0.4384** | **0.4283** |
51
+ | Protein-similarity-disjoint, MMseqs2 50% | train mean | 1.5336 | undefined | undefined |
52
+ | Protein-similarity-disjoint, MMseqs2 50% | combined character ridge | **1.4349** | **0.3625** | **0.3652** |
53
+ | Ligand scaffold-disjoint | train mean | 1.5834 | undefined | undefined |
54
+ | Ligand scaffold-disjoint | combined character ridge | **1.3649** | **0.5198** | **0.5354** |
55
+
56
+ The similarity-disjoint result is the most important current recovery signal. It passes the initial gate of beating the train mean with positive Pearson and Spearman correlation. It also shows that ligand features carry more signal than protein character features on this sample, which is a warning against interpreting random-split performance as learned protein-ligand interaction biology.
57
+
58
+ ## Run the recovery checks
59
+
60
+ ```bash
61
+ cd recovery
62
+ uv sync --dev
63
+ uv run pytest -q
64
+
65
+ uv run python scripts/dataset_smoke.py \
66
+ --rows 100 \
67
+ --output artifacts/dataset-smoke.json
68
  ```
69
 
70
+ To rebuild the 10,000-pair development data from the converted Parquet file:
71
+
72
+ ```bash
73
+ uv run python scripts/prepare_sample.py \
74
+ --parquet /path/to/train.parquet \
75
+ --sample-size 10000 \
76
+ --seed 42 \
77
+ --output-dir artifacts/dev-10k
78
+
79
+ uv run python scripts/cluster_proteins.py \
80
+ --data-dir artifacts/dev-10k \
81
+ --min-seq-id 0.5 \
82
+ --coverage 0.8
83
+
84
+ uv run python scripts/run_baselines.py \
85
+ --data-dir artifacts/dev-10k \
86
+ --output artifacts/dev-10k/baselines.json
87
+ ```
88
+
89
+ MMseqs2 is required for the sequence-similarity split. Generated data, embeddings, predictions, and weights are intentionally excluded from Git.
90
+
91
+ ## v2 architecture under evaluation
92
+
93
+ The bounded neural recovery experiment uses frozen, revision-pinned encoders:
94
+
95
+ - protein: `facebook/esm2_t12_35M_UR50D`;
96
+ - ligand: `DeepChem/ChemBERTa-77M-MLM`;
97
+ - fusion: projected protein and ligand vectors plus elementwise product and absolute difference;
98
+ - regression: LayerNorm-based MLP over a standardized pAffinity target;
99
+ - serialization: trainable head in `safetensors`, with encoder names, revisions, and target scaler stored separately.
100
+
101
+ Proteins longer than ESM-2's context are split into residue chunks and pooled with length weighting instead of silently truncating the sequence. This is a reproducible baseline strategy, not proof that full-sequence pooling captures a binding site.
102
+
103
+ ## Intended use
104
+
105
+ Current acceptable use:
106
+
107
+ - reproducing the failure analysis;
108
+ - testing protein-ligand affinity data pipelines;
109
+ - comparing leakage-aware splitting strategies;
110
+ - developing and auditing computational baselines.
111
+
112
+ Current unacceptable use:
113
+
114
+ - ranking compounds for laboratory work;
115
+ - estimating clinical activity, safety, dosage, or efficacy;
116
+ - claiming specificity for mitochondrial apoptosis proteins;
117
+ - treating a predicted pAffinity as experimental evidence;
118
+ - loading `full_model.pt` from an untrusted source.
119
+
120
+ The source dataset combines BindingDB, PDBbind-cn, BioLIP, and BindingMOAD. Its mixed assays, measurement types, source-specific preprocessing, duplicates, and possible censoring limit what a single regression target can support. See [`recovery/DATA_PROVENANCE.md`](recovery/DATA_PROVENANCE.md) for the pinned preprocessing evidence. A publishable model requires source-aware deduplication, stronger external evaluation, uncertainty estimates, and wet-lab validation.
121
+
122
+ ## Legacy v1 facts
123
+
124
+ | Item | Recovered value |
125
+ |---|---:|
126
+ | Training pairs | 32 |
127
+ | Validation pairs | 16 |
128
+ | Optimizer steps | approximately 16 |
129
+ | Configured warmup steps | 500 |
130
+ | Pearson | -0.9107 |
131
+ | Spearman | -0.5000 |
132
+ | RMSE | 7.0257 in the undocumented legacy target scale |
133
+ | MAE | 6.8183 in the undocumented legacy target scale |
134
+
135
+ The original README claimed ESM-2 650M, while the checkpoint used ESM-2 35M. The original `full_model.pt` is a Python pickle loaded with `weights_only=False`; it is retained only as a legacy artifact.
136
+
137
+ ## References
138
+
139
+ - Lin et al., [Language models of protein sequences at the scale of evolution](https://doi.org/10.1101/2022.07.20.500902)
140
+ - Chithrananda et al., [ChemBERTa](https://arxiv.org/abs/2010.09885)
141
+ - Steinegger and Söding, [MMseqs2](https://doi.org/10.1038/nbt.3988)
model.py CHANGED
@@ -1,10 +1,15 @@
 
 
 
 
 
 
1
  """
2
- MitoInteract Model Class Definition
3
- Copy this file to load the model for inference.
4
- """
5
  import torch
6
  import torch.nn as nn
7
- from transformers import EsmModel, EsmTokenizer, AutoModel, AutoTokenizer
 
8
 
9
  class MitoInteract(nn.Module):
10
  def __init__(
@@ -25,23 +30,47 @@ class MitoInteract(nn.Module):
25
  self.mol_encoder = AutoModel.from_pretrained(mol_model_name)
26
  self.mol_dim = mol_dim
27
  if freeze_encoders:
28
- for p in self.esm.parameters(): p.requires_grad = False
29
- for p in self.mol_encoder.parameters(): p.requires_grad = False
 
 
30
  self.prot_proj = nn.Sequential(
31
- nn.Linear(protein_dim, proj_dim), nn.LayerNorm(proj_dim), nn.ReLU(), nn.Dropout(dropout))
 
 
 
 
32
  self.mol_proj = nn.Sequential(
33
- nn.Linear(mol_dim, proj_dim), nn.LayerNorm(proj_dim), nn.ReLU(), nn.Dropout(dropout))
34
- self.cross_attn_mol2prot = nn.MultiheadAttention(proj_dim, n_heads, dropout=dropout, batch_first=True)
35
- self.cross_attn_prot2mol = nn.MultiheadAttention(proj_dim, n_heads, dropout=dropout, batch_first=True)
 
 
 
 
 
 
 
 
36
  self.ln_mol2prot = nn.LayerNorm(proj_dim)
37
  self.ln_prot2mol = nn.LayerNorm(proj_dim)
38
  fused_dim = proj_dim * 2
39
  self.mlp = nn.Sequential(
40
- nn.Linear(fused_dim, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Dropout(dropout),
41
- nn.Linear(512, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(dropout),
42
- nn.Linear(256, 128), nn.BatchNorm1d(128), nn.ReLU(), nn.Dropout(dropout),
43
- nn.Linear(128, 1))
44
-
 
 
 
 
 
 
 
 
 
 
45
  def encode_protein(self, input_ids, attention_mask):
46
  ctx = torch.no_grad() if self.freeze_encoders else torch.enable_grad()
47
  with ctx:
@@ -49,32 +78,44 @@ class MitoInteract(nn.Module):
49
  mask = attention_mask.unsqueeze(-1).float()
50
  pooled = (out.last_hidden_state * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
51
  return pooled, out.last_hidden_state
52
-
53
  def encode_molecule(self, input_ids, attention_mask):
54
  ctx = torch.no_grad() if self.freeze_encoders else torch.enable_grad()
55
  with ctx:
56
  out = self.mol_encoder(input_ids=input_ids, attention_mask=attention_mask)
57
  return out.pooler_output, out.last_hidden_state
58
-
59
- def forward(self, prot_input_ids, prot_attention_mask, mol_input_ids, mol_attention_mask):
 
 
60
  prot_pooled, prot_seq = self.encode_protein(prot_input_ids, prot_attention_mask)
61
  mol_pooled, mol_seq = self.encode_molecule(mol_input_ids, mol_attention_mask)
62
  prot_seq_proj = self.prot_proj(prot_seq)
63
  mol_seq_proj = self.mol_proj(mol_seq)
64
  prot_q = self.prot_proj(prot_pooled).unsqueeze(1)
65
  mol_q = self.mol_proj(mol_pooled).unsqueeze(1)
66
- prot_pad_mask = (prot_attention_mask == 0)
67
- mol_pad_mask = (mol_attention_mask == 0)
68
- h_prot2mol, _ = self.cross_attn_prot2mol(prot_q, mol_seq_proj, mol_seq_proj, key_padding_mask=mol_pad_mask)
69
- h_mol2prot, _ = self.cross_attn_mol2prot(mol_q, prot_seq_proj, prot_seq_proj, key_padding_mask=prot_pad_mask)
 
 
 
 
70
  h_prot2mol = self.ln_prot2mol(h_prot2mol.squeeze(1))
71
  h_mol2prot = self.ln_mol2prot(h_mol2prot.squeeze(1))
72
  fused = torch.cat([h_prot2mol, h_mol2prot], dim=-1)
73
  return self.mlp(fused).squeeze(-1)
74
 
75
 
76
- def load_model(checkpoint_path, device="cpu"):
77
- """Load trained MitoInteract model."""
 
 
 
 
 
 
78
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
79
  config = checkpoint["config"]
80
  model = MitoInteract(
@@ -92,21 +133,35 @@ def load_model(checkpoint_path, device="cpu"):
92
  return model, config
93
 
94
 
95
- def predict_binding(model, protein_seq, smiles, device="cpu"):
96
- """Predict binding affinity (pKd) for a protein-molecule pair."""
97
- prot_tokenizer = EsmTokenizer.from_pretrained("facebook/esm2_t33_650M_UR50D")
98
- mol_tokenizer = AutoTokenizer.from_pretrained("seyonec/ChemBERTa-zinc-base-v1")
99
-
100
- prot_enc = prot_tokenizer(protein_seq, return_tensors="pt", padding=True, truncation=True, max_length=512)
101
- mol_enc = mol_tokenizer(smiles, return_tensors="pt", padding=True, truncation=True, max_length=200)
102
-
 
 
 
 
 
 
 
 
 
 
 
103
  model = model.to(device)
104
  with torch.no_grad():
105
- pKd = model(
106
- prot_enc["input_ids"].to(device), prot_enc["attention_mask"].to(device),
107
- mol_enc["input_ids"].to(device), mol_enc["attention_mask"].to(device),
 
 
108
  )
109
-
110
- pKd_val = pKd.item()
111
- Kd_uM = 10 ** (-pKd_val) * 1e6
112
- return {"pKd": pKd_val, "Kd_uM": Kd_uM}
 
 
1
+ """Deprecated MitoInteract v1 architecture retained for artifact forensics.
2
+
3
+ The v1 checkpoint failed its evaluation gates and uses unsafe pickle
4
+ serialization. New work lives under ``recovery/``. Loading and inference are
5
+ disabled by default so this module cannot silently present an undocumented
6
+ legacy score as pKd.
7
  """
8
+
 
 
9
  import torch
10
  import torch.nn as nn
11
+ from transformers import EsmModel, AutoModel, AutoTokenizer
12
+
13
 
14
  class MitoInteract(nn.Module):
15
  def __init__(
 
30
  self.mol_encoder = AutoModel.from_pretrained(mol_model_name)
31
  self.mol_dim = mol_dim
32
  if freeze_encoders:
33
+ for p in self.esm.parameters():
34
+ p.requires_grad = False
35
+ for p in self.mol_encoder.parameters():
36
+ p.requires_grad = False
37
  self.prot_proj = nn.Sequential(
38
+ nn.Linear(protein_dim, proj_dim),
39
+ nn.LayerNorm(proj_dim),
40
+ nn.ReLU(),
41
+ nn.Dropout(dropout),
42
+ )
43
  self.mol_proj = nn.Sequential(
44
+ nn.Linear(mol_dim, proj_dim),
45
+ nn.LayerNorm(proj_dim),
46
+ nn.ReLU(),
47
+ nn.Dropout(dropout),
48
+ )
49
+ self.cross_attn_mol2prot = nn.MultiheadAttention(
50
+ proj_dim, n_heads, dropout=dropout, batch_first=True
51
+ )
52
+ self.cross_attn_prot2mol = nn.MultiheadAttention(
53
+ proj_dim, n_heads, dropout=dropout, batch_first=True
54
+ )
55
  self.ln_mol2prot = nn.LayerNorm(proj_dim)
56
  self.ln_prot2mol = nn.LayerNorm(proj_dim)
57
  fused_dim = proj_dim * 2
58
  self.mlp = nn.Sequential(
59
+ nn.Linear(fused_dim, 512),
60
+ nn.BatchNorm1d(512),
61
+ nn.ReLU(),
62
+ nn.Dropout(dropout),
63
+ nn.Linear(512, 256),
64
+ nn.BatchNorm1d(256),
65
+ nn.ReLU(),
66
+ nn.Dropout(dropout),
67
+ nn.Linear(256, 128),
68
+ nn.BatchNorm1d(128),
69
+ nn.ReLU(),
70
+ nn.Dropout(dropout),
71
+ nn.Linear(128, 1),
72
+ )
73
+
74
  def encode_protein(self, input_ids, attention_mask):
75
  ctx = torch.no_grad() if self.freeze_encoders else torch.enable_grad()
76
  with ctx:
 
78
  mask = attention_mask.unsqueeze(-1).float()
79
  pooled = (out.last_hidden_state * mask).sum(1) / mask.sum(1).clamp(min=1e-9)
80
  return pooled, out.last_hidden_state
81
+
82
  def encode_molecule(self, input_ids, attention_mask):
83
  ctx = torch.no_grad() if self.freeze_encoders else torch.enable_grad()
84
  with ctx:
85
  out = self.mol_encoder(input_ids=input_ids, attention_mask=attention_mask)
86
  return out.pooler_output, out.last_hidden_state
87
+
88
+ def forward(
89
+ self, prot_input_ids, prot_attention_mask, mol_input_ids, mol_attention_mask
90
+ ):
91
  prot_pooled, prot_seq = self.encode_protein(prot_input_ids, prot_attention_mask)
92
  mol_pooled, mol_seq = self.encode_molecule(mol_input_ids, mol_attention_mask)
93
  prot_seq_proj = self.prot_proj(prot_seq)
94
  mol_seq_proj = self.mol_proj(mol_seq)
95
  prot_q = self.prot_proj(prot_pooled).unsqueeze(1)
96
  mol_q = self.mol_proj(mol_pooled).unsqueeze(1)
97
+ prot_pad_mask = prot_attention_mask == 0
98
+ mol_pad_mask = mol_attention_mask == 0
99
+ h_prot2mol, _ = self.cross_attn_prot2mol(
100
+ prot_q, mol_seq_proj, mol_seq_proj, key_padding_mask=mol_pad_mask
101
+ )
102
+ h_mol2prot, _ = self.cross_attn_mol2prot(
103
+ mol_q, prot_seq_proj, prot_seq_proj, key_padding_mask=prot_pad_mask
104
+ )
105
  h_prot2mol = self.ln_prot2mol(h_prot2mol.squeeze(1))
106
  h_mol2prot = self.ln_mol2prot(h_mol2prot.squeeze(1))
107
  fused = torch.cat([h_prot2mol, h_mol2prot], dim=-1)
108
  return self.mlp(fused).squeeze(-1)
109
 
110
 
111
+ def load_model(checkpoint_path, device="cpu", *, allow_unsafe_legacy=False):
112
+ """Load the failed v1 pickle only after explicit risk acknowledgement."""
113
+ if not allow_unsafe_legacy:
114
+ raise RuntimeError(
115
+ "MitoInteract v1 is deprecated and full_model.pt requires unsafe pickle "
116
+ "deserialization. See AUDIT.md. Pass allow_unsafe_legacy=True only for "
117
+ "controlled artifact forensics, never for untrusted files."
118
+ )
119
  checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
120
  config = checkpoint["config"]
121
  model = MitoInteract(
 
133
  return model, config
134
 
135
 
136
+ def predict_binding(
137
+ model, protein_seq, smiles, device="cpu", *, allow_invalid_legacy_output=False
138
+ ):
139
+ """Return the undocumented v1 score only after explicit acknowledgement."""
140
+ if not allow_invalid_legacy_output:
141
+ raise RuntimeError(
142
+ "v1 output has undocumented target semantics and failed validation. "
143
+ "It cannot be interpreted as pKd or converted to Kd. See AUDIT.md."
144
+ )
145
+ prot_tokenizer = AutoTokenizer.from_pretrained(model.esm.name_or_path)
146
+ mol_tokenizer = AutoTokenizer.from_pretrained(model.mol_encoder.name_or_path)
147
+
148
+ prot_enc = prot_tokenizer(
149
+ protein_seq, return_tensors="pt", padding=True, truncation=True, max_length=512
150
+ )
151
+ mol_enc = mol_tokenizer(
152
+ smiles, return_tensors="pt", padding=True, truncation=True, max_length=200
153
+ )
154
+
155
  model = model.to(device)
156
  with torch.no_grad():
157
+ legacy_score = model(
158
+ prot_enc["input_ids"].to(device),
159
+ prot_enc["attention_mask"].to(device),
160
+ mol_enc["input_ids"].to(device),
161
+ mol_enc["attention_mask"].to(device),
162
  )
163
+
164
+ return {
165
+ "legacy_score": legacy_score.item(),
166
+ "warning": "Do not interpret this value as pKd, Kd, or validated affinity.",
167
+ }
recovery/DATA_PROVENANCE.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dataset target provenance
2
+
3
+ ## Pinned source
4
+
5
+ - Dataset: `jglaser/binding_affinity`
6
+ - Revision: `11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b`
7
+ - File: `data/all.parquet`
8
+ - SHA-256: `d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8`
9
+
10
+ ## Why the target is pAffinity, not pKd
11
+
12
+ The combined Parquet exposes `affinity_uM` and `neg_log10_affinity_M`, but it does not retain the measurement type or source row.
13
+
14
+ The pinned `bindingdb_single.ipynb` selects:
15
+
16
+ ```python
17
+ ['IC50 (nM)', 'Ki (nM)', 'Kd (nM)', 'EC50 (nM)']
18
+ ```
19
+
20
+ Its `to_uM` function converts every available field to micromolar, appends the values to one list, and returns:
21
+
22
+ ```python
23
+ np.mean(vals[~np.isnan(vals)])
24
+ ```
25
+
26
+ It stores the result in one column:
27
+
28
+ ```python
29
+ df_affinity['affinity_uM'] = df_affinity[
30
+ ['IC50 (nM)', 'Ki (nM)', 'Kd (nM)', 'EC50 (nM)']
31
+ ].parallel_apply(to_uM, axis=1)
32
+ ```
33
+
34
+ The pinned `combine_dbs.ipynb` then keeps only `seq`, `smiles`, and `affinity_uM` from each source and computes:
35
+
36
+ ```python
37
+ df_nr['neg_log10_affinity_M'] = 6 - np.log(df_nr['affinity_uM']) / np.log(10)
38
+ ```
39
+
40
+ Therefore:
41
+
42
+ - the numeric transform is `-log10(affinity in M)`;
43
+ - not every row is a dissociation constant;
44
+ - some BindingDB rows may average heterogeneous measurement types before the logarithm;
45
+ - the final file cannot separate `Ki`, `Kd`, `IC50`, or `EC50`;
46
+ - the final file cannot reconstruct assay conditions, censoring operators, or original source provenance.
47
+
48
+ MitoInteract recovery calls this value **pAffinity**. It must not be presented as pKd or converted to a physical Kd.
49
+
50
+ ## Consequence for v2
51
+
52
+ The current 10,000-pair experiment is useful for pipeline recovery and leakage analysis. It is not sufficient for a scientifically defensible affinity predictor. A successor dataset should retain at least:
53
+
54
+ - measurement type;
55
+ - numeric value, unit, and relation operator (`=`, `<`, `>`, etc.);
56
+ - assay and source identifiers;
57
+ - protein and ligand identifiers;
58
+ - replicate measurements before aggregation;
59
+ - source database and version.
60
+
61
+ A clean v2 training target should either model one measurement type at a time or use a multi-task/censoring-aware objective that keeps these distinctions explicit.
recovery/README.md CHANGED
@@ -1,24 +1,171 @@
1
- # MitoInteract v2 Recovery Spike
2
 
3
- This directory contains the smallest reproducible checks needed before retraining the model.
4
 
5
- ## Run locally
6
 
7
  ```bash
8
- cd /root/repos/MitoInteract/recovery
9
  uv sync --dev
10
  uv run pytest -q
11
- uv run python scripts/dataset_smoke.py --rows 100 --output artifacts/dataset-smoke.json
 
 
12
  ```
13
 
14
- The smoke script reads public rows from the Hugging Face Dataset Viewer API, verifies that `neg_log10_affinity_M` agrees with `affinity_uM`, and reports a deterministic train-mean baseline. It is a plumbing check, not the final scientific evaluation.
15
 
16
- ## Next experiment
17
 
18
- Use `config.json` to build a fixed 10,000-pair development sample and report three splits separately:
19
 
20
- 1. random pair
21
- 2. cold protein
22
- 3. cold ligand/scaffold
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- The first model must beat the train-mean baseline and produce positive Pearson/Spearman correlation before any larger GPU run.
 
 
 
 
 
 
 
 
1
+ # MitoInteract v2 Recovery
2
 
3
+ This package contains the reproducible data, baseline, embedding, and training pipeline used to recover MitoInteract from the failed v1 checkpoint.
4
 
5
+ ## 1. Install and test
6
 
7
  ```bash
8
+ cd recovery
9
  uv sync --dev
10
  uv run pytest -q
11
+ uv run python scripts/dataset_smoke.py \
12
+ --rows 100 \
13
+ --output artifacts/dataset-smoke.json
14
  ```
15
 
16
+ The Dataset Viewer smoke test verifies that `neg_log10_affinity_M` agrees numerically with `affinity_uM`. It is a plumbing check, not a performance result.
17
 
18
+ ## Target semantics
19
 
20
+ The target is generic **pAffinity**, not pKd:
21
 
22
+ ```text
23
+ pAffinity = -log10(dataset affinity in molar units)
24
+ ```
25
+
26
+ The pinned source preprocessing combines heterogeneous measurements. In particular, its BindingDB notebook collects `Ki`, `Kd`, `IC50`, and `EC50` and averages the available values in linear micromolar units. These quantities are not interchangeable. The recovery pipeline preserves the dataset's numeric target so the failed run can be audited, but it does not relabel the target as a dissociation constant. See [`DATA_PROVENANCE.md`](DATA_PROVENANCE.md) for the pinned code evidence. A scientifically stronger successor dataset must retain measurement type, assay provenance, relation operators/censoring, and source identifiers.
27
+
28
+ ## 2. Download the pinned source data
29
+
30
+ The recovery configuration pins `jglaser/binding_affinity` to commit:
31
+
32
+ ```text
33
+ 11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b
34
+ ```
35
+
36
+ Download `data/all.parquet` at that revision and verify its SHA-256:
37
+
38
+ ```text
39
+ d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8
40
+ ```
41
+
42
+ For example:
43
+
44
+ ```bash
45
+ hf download jglaser/binding_affinity data/all.parquet \
46
+ --repo-type dataset \
47
+ --revision 11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b \
48
+ --local-dir .cache/binding-affinity
49
+
50
+ sha256sum .cache/binding-affinity/data/all.parquet
51
+ ```
52
+
53
+ ## 3. Build the fixed development sample
54
+
55
+ ```bash
56
+ uv run python scripts/prepare_sample.py \
57
+ --parquet .cache/binding-affinity/data/all.parquet \
58
+ --dataset-revision 11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b \
59
+ --sample-size 10000 \
60
+ --candidate-multiplier 3 \
61
+ --seed 42 \
62
+ --output-dir artifacts/dev-10k
63
+ ```
64
+
65
+ The script:
66
+
67
+ - samples source row indexes deterministically;
68
+ - validates the pAffinity/µM conversion;
69
+ - canonicalizes SMILES;
70
+ - aggregates duplicate protein-ligand measurements by median pAffinity;
71
+ - emits pair IDs and entity IDs derived from content hashes;
72
+ - writes 8,000/1,000/1,000 manifests for random-pair, exact-protein, and scaffold-disjoint splits;
73
+ - records row counts, target distributions, sequence lengths, and overlap checks.
74
+
75
+ Generated data stays under `artifacts/` and is ignored by Git.
76
+
77
+ ## 4. Add the protein-similarity split
78
+
79
+ Install MMseqs2 through the operating system package manager, then run:
80
+
81
+ ```bash
82
+ uv run python scripts/cluster_proteins.py \
83
+ --data-dir artifacts/dev-10k \
84
+ --min-seq-id 0.5 \
85
+ --coverage 0.8 \
86
+ --seed 42
87
+ ```
88
+
89
+ The resulting split keeps MMseqs2 clusters disjoint across train, validation, and test. The clustering requires at least 50% actual sequence identity over 80% of both sequences.
90
+
91
+ ## 5. Run non-neural baselines
92
+
93
+ ```bash
94
+ uv run python scripts/run_baselines.py \
95
+ --data-dir artifacts/dev-10k \
96
+ --output artifacts/dev-10k/baselines.json
97
+ ```
98
+
99
+ The benchmark reports train-mean, protein-only, ligand-only, and combined character n-gram ridge models. It selects ridge regularization on validation data and reports RMSE, MAE, Pearson, Spearman, and R² on the untouched test split.
100
+
101
+ These shortcut-sensitive baselines must be run before a transformer model. A neural model that cannot beat them has not justified its cost.
102
+
103
+ ## 6. Verify the v2 trainable head
104
+
105
+ The default environment keeps PyTorch optional. Install a CPU build for local checks:
106
+
107
+ ```bash
108
+ uv pip install --python .venv/bin/python torch \
109
+ --index-url https://download.pytorch.org/whl/cpu
110
+ uv pip install --python .venv/bin/python transformers safetensors
111
+
112
+ .venv/bin/python -m pytest -q
113
+ .venv/bin/python scripts/overfit_sanity.py \
114
+ --sample artifacts/dev-10k/sample.jsonl \
115
+ --examples 32 \
116
+ --max-rmse 0.05 \
117
+ --output artifacts/overfit-sanity.json
118
+ ```
119
+
120
+ The overfit check isolates the trainable fusion/head path. Passing it does not establish generalization.
121
+
122
+ ## 7. Cache pinned encoder embeddings
123
+
124
+ ```bash
125
+ .venv/bin/python scripts/embed_sample.py \
126
+ --sample artifacts/dev-10k/sample.jsonl \
127
+ --batch-size 8 \
128
+ --device auto \
129
+ --output artifacts/embeddings-10k.npz
130
+ ```
131
+
132
+ Defaults are pinned to:
133
+
134
+ - `facebook/esm2_t12_35M_UR50D@6fbf070e65b0b7291e7bbcd451118c216cff79d8`
135
+ - `DeepChem/ChemBERTa-77M-MLM@ed8a5374f2024ec8da53760af91a33fb8f6a15ff`
136
+
137
+ Protein sequences longer than 1,022 residues are chunked, mean-pooled per chunk, and combined with length weighting. Embeddings are cached so the frozen encoders do not run again for each split or head experiment.
138
+
139
+ ## 8. Train the v2 head
140
+
141
+ ```bash
142
+ .venv/bin/python scripts/train_head.py \
143
+ --embeddings artifacts/embeddings-10k.npz \
144
+ --manifest artifacts/dev-10k/split-protein_similarity_50.jsonl \
145
+ --output-dir artifacts/v2-head-protein-similarity \
146
+ --epochs 100 \
147
+ --batch-size 256 \
148
+ --patience 10 \
149
+ --device auto
150
+ ```
151
+
152
+ The trainer:
153
+
154
+ - standardizes pAffinity using training data only;
155
+ - uses AdamW, gradient clipping, 5% warmup, and cosine decay;
156
+ - selects the checkpoint by validation RMSE;
157
+ - saves only trainable weights in `model.safetensors`;
158
+ - records target scaling, encoder revisions, split counts, optimizer settings, history, and test metrics.
159
+
160
+ ## Acceptance gates
161
+
162
+ A v2 checkpoint is not releasable unless all of these pass:
163
 
164
+ 1. Tests and pAffinity conversion checks pass.
165
+ 2. The 32-pair overfit sanity reaches RMSE ≤ 0.05 pAffinity.
166
+ 3. Validation and test each contain at least 1,000 pairs.
167
+ 4. The published primary result uses the MMseqs2 protein-similarity split, not only a random split.
168
+ 5. Test RMSE beats the train mean and both Pearson and Spearman are positive.
169
+ 6. The neural model beats or materially complements the combined character ridge baseline.
170
+ 7. Dataset, encoder, split, seed, predictions, metrics, and weights are reproducible.
171
+ 8. Model-card language remains computational and does not imply experimental or clinical validity.
recovery/cloud/modal_embed.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the 10k frozen-encoder pass on a temporary Modal L4.
2
+
3
+ This script is intentionally not run by tests. Build the sample locally first,
4
+ review the current Modal price, then invoke it with:
5
+
6
+ modal run cloud/modal_embed.py
7
+
8
+ Retrieve the result with the `modal volume get` command printed on completion.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import modal
19
+
20
+ app = modal.App("mitointeract-v2-embeddings")
21
+ output_volume = modal.Volume.from_name(
22
+ "mitointeract-v2-artifacts", create_if_missing=True
23
+ )
24
+ cache_volume = modal.Volume.from_name("mitointeract-hf-cache", create_if_missing=True)
25
+
26
+ recovery_dir = Path(__file__).parents[1]
27
+ image = (
28
+ modal.Image.debian_slim(python_version="3.12")
29
+ .pip_install(
30
+ "numpy>=2.0",
31
+ "safetensors>=0.5",
32
+ "torch>=2.4",
33
+ "transformers>=4.45",
34
+ )
35
+ .add_local_dir(recovery_dir / "src", remote_path="/app/src")
36
+ .add_local_file(
37
+ recovery_dir / "scripts/embed_sample.py", remote_path="/app/embed_sample.py"
38
+ )
39
+ .add_local_file(
40
+ recovery_dir / "artifacts/dev-10k/sample.jsonl",
41
+ remote_path="/input/sample.jsonl",
42
+ )
43
+ )
44
+
45
+
46
+ @app.function(
47
+ image=image,
48
+ gpu="L4",
49
+ cpu=4,
50
+ memory=16_384,
51
+ timeout=3_600,
52
+ volumes={"/output": output_volume, "/cache": cache_volume},
53
+ )
54
+ def embed() -> dict:
55
+ env = os.environ.copy()
56
+ env["PYTHONPATH"] = "/app/src"
57
+ env["HF_HOME"] = "/cache/huggingface"
58
+ subprocess.run(
59
+ [
60
+ "python",
61
+ "/app/embed_sample.py",
62
+ "--sample",
63
+ "/input/sample.jsonl",
64
+ "--batch-size",
65
+ "32",
66
+ "--device",
67
+ "cuda",
68
+ "--output",
69
+ "/output/embeddings-10k.npz",
70
+ ],
71
+ check=True,
72
+ env=env,
73
+ )
74
+ output_volume.commit()
75
+ cache_volume.commit()
76
+ metadata = json.loads(Path("/output/embeddings-10k.json").read_text())
77
+ metadata["output_bytes"] = Path("/output/embeddings-10k.npz").stat().st_size
78
+ return metadata
79
+
80
+
81
+ @app.local_entrypoint()
82
+ def main() -> None:
83
+ result = embed.remote()
84
+ print(json.dumps(result, indent=2))
85
+ print(
86
+ "modal volume get mitointeract-v2-artifacts "
87
+ "embeddings-10k.npz artifacts/embeddings-10k.npz"
88
+ )
89
+ print(
90
+ "modal volume get mitointeract-v2-artifacts "
91
+ "embeddings-10k.json artifacts/embeddings-10k.json"
92
+ )
recovery/config.json CHANGED
@@ -1,8 +1,8 @@
1
  {
2
  "dataset": "jglaser/binding_affinity",
3
- "dataset_revision": "main",
4
  "target_column": "neg_log10_affinity_M",
5
- "target_unit": "pKd",
6
  "sample_size": 10000,
7
  "seed": 42,
8
  "splits": ["random_pair", "cold_protein", "cold_ligand_scaffold"],
@@ -11,8 +11,12 @@
11
  "minimum_test_rows": 1000,
12
  "model": {
13
  "protein_encoder": "facebook/esm2_t12_35M_UR50D",
14
- "molecule_encoder": "seyonec/ChemBERTa-zinc-base-v1",
15
- "freeze_encoders": true
 
 
 
 
16
  },
17
  "acceptance": {
18
  "beat_train_mean_rmse": true,
 
1
  {
2
  "dataset": "jglaser/binding_affinity",
3
+ "dataset_revision": "11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b",
4
  "target_column": "neg_log10_affinity_M",
5
+ "target_unit": "pAffinity",
6
  "sample_size": 10000,
7
  "seed": 42,
8
  "splits": ["random_pair", "cold_protein", "cold_ligand_scaffold"],
 
11
  "minimum_test_rows": 1000,
12
  "model": {
13
  "protein_encoder": "facebook/esm2_t12_35M_UR50D",
14
+ "protein_revision": "6fbf070e65b0b7291e7bbcd451118c216cff79d8",
15
+ "molecule_encoder": "DeepChem/ChemBERTa-77M-MLM",
16
+ "molecule_revision": "ed8a5374f2024ec8da53760af91a33fb8f6a15ff",
17
+ "freeze_encoders": true,
18
+ "protein_long_sequence_strategy": "length_weighted_chunk_mean",
19
+ "target": "standardized_pAffinity"
20
  },
21
  "acceptance": {
22
  "beat_train_mean_rmse": true,
recovery/pyproject.toml CHANGED
@@ -3,7 +3,20 @@ name = "mitointeract-recovery"
3
  version = "0.1.0"
4
  description = "Reproducibility and target-semantics checks for MitoInteract v2"
5
  requires-python = ">=3.11"
6
- dependencies = []
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  [build-system]
9
  requires = ["setuptools>=64"]
@@ -16,7 +29,10 @@ where = ["src"]
16
  package = true
17
 
18
  [dependency-groups]
19
- dev = ["pytest>=9"]
 
 
 
20
 
21
  [tool.pytest.ini_options]
22
  pythonpath = ["src"]
 
3
  version = "0.1.0"
4
  description = "Reproducibility and target-semantics checks for MitoInteract v2"
5
  requires-python = ">=3.11"
6
+ dependencies = [
7
+ "numpy>=2.0",
8
+ "pyarrow>=18",
9
+ "rdkit>=2025.3",
10
+ "scikit-learn>=1.6",
11
+ "scipy>=1.14",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ model = [
16
+ "safetensors>=0.5",
17
+ "torch>=2.4",
18
+ "transformers>=4.45",
19
+ ]
20
 
21
  [build-system]
22
  requires = ["setuptools>=64"]
 
29
  package = true
30
 
31
  [dependency-groups]
32
+ dev = [
33
+ "pytest>=9",
34
+ "ruff>=0.14",
35
+ ]
36
 
37
  [tool.pytest.ini_options]
38
  pythonpath = ["src"]
recovery/reports/README.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Checked recovery reports
2
+
3
+ These JSON files are small, reviewable outputs from the deterministic recovery pipeline. Large source data, split manifests, embeddings, predictions, and model weights remain under the ignored `artifacts/` directory.
4
+
5
+ - `dev-10k-audit.json`: immutable source revision and hash, sample statistics, split audits, and hashes for reproducing the sample and manifests.
6
+ - `dev-10k-baselines.json`: validation selection and untouched test metrics for every cheap baseline and split.
7
+ - `overfit-actual-embeddings.json`: 32-pair optimization sanity check using the pinned frozen encoders.
8
+
9
+ The checked reports support model-card statements but are not an external benchmark or a releasable neural checkpoint.
recovery/reports/dev-10k-audit.json ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source_parquet": "data/all.parquet",
3
+ "source_sha256": "d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8",
4
+ "dataset": "jglaser/binding_affinity",
5
+ "dataset_revision": "11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b",
6
+ "source_rows": 1836729,
7
+ "candidate_rows": 30000,
8
+ "sample_rows": 10000,
9
+ "seed": 42,
10
+ "target": {
11
+ "column": "neg_log10_affinity_M",
12
+ "unit": "pAffinity"
13
+ },
14
+ "rejected": {},
15
+ "unique_proteins": 2255,
16
+ "unique_ligands": 9897,
17
+ "unique_scaffold_groups": 8463,
18
+ "duplicate_measurements_in_sample": 51,
19
+ "protein_length": {
20
+ "min": 19,
21
+ "median": 479.0,
22
+ "max": 7096,
23
+ "over_512": 4318
24
+ },
25
+ "splits": {
26
+ "random_pair": {
27
+ "train": {
28
+ "rows": 8000,
29
+ "mean_paffinity": 6.5244491582587365,
30
+ "std_paffinity": 1.5626149739941089,
31
+ "min_paffinity": 8.881784197001252e-16,
32
+ "max_paffinity": 14.0
33
+ },
34
+ "validation": {
35
+ "rows": 1000,
36
+ "mean_paffinity": 6.507777023136616,
37
+ "std_paffinity": 1.5282972208815573,
38
+ "min_paffinity": 0.7708500027656555,
39
+ "max_paffinity": 10.74472713470459
40
+ },
41
+ "test": {
42
+ "rows": 1000,
43
+ "mean_paffinity": 6.420703814268112,
44
+ "std_paffinity": 1.5623837036360122,
45
+ "min_paffinity": 1.2039999961853027,
46
+ "max_paffinity": 10.95860767364502
47
+ }
48
+ },
49
+ "cold_protein_exact": {
50
+ "train": {
51
+ "rows": 8000,
52
+ "mean_paffinity": 6.476698521107435,
53
+ "std_paffinity": 1.5651385137888398,
54
+ "min_paffinity": 0.6000000238418579,
55
+ "max_paffinity": 14.0
56
+ },
57
+ "validation": {
58
+ "rows": 1000,
59
+ "mean_paffinity": 6.65395347070694,
60
+ "std_paffinity": 1.5705924306056336,
61
+ "min_paffinity": 2.3372421264648438,
62
+ "max_paffinity": 13.0
63
+ },
64
+ "test": {
65
+ "rows": 1000,
66
+ "mean_paffinity": 6.656532463908196,
67
+ "std_paffinity": 1.4850308092775097,
68
+ "min_paffinity": 8.881784197001252e-16,
69
+ "max_paffinity": 12.698969841003418
70
+ }
71
+ },
72
+ "cold_ligand_scaffold": {
73
+ "train": {
74
+ "rows": 8000,
75
+ "mean_paffinity": 6.537638315707445,
76
+ "std_paffinity": 1.5519537903490064,
77
+ "min_paffinity": 0.6000000238418579,
78
+ "max_paffinity": 13.214670181274414
79
+ },
80
+ "validation": {
81
+ "rows": 1000,
82
+ "mean_paffinity": 6.312392803311348,
83
+ "std_paffinity": 1.5805480574681217,
84
+ "min_paffinity": 8.881784197001252e-16,
85
+ "max_paffinity": 13.0
86
+ },
87
+ "test": {
88
+ "rows": 1000,
89
+ "mean_paffinity": 6.5105747745037075,
90
+ "std_paffinity": 1.583959954584063,
91
+ "min_paffinity": 2.0315170288085938,
92
+ "max_paffinity": 14.0
93
+ }
94
+ }
95
+ },
96
+ "limitations": [
97
+ "the source target can combine Ki, Kd, IC50, and EC50; pAffinity is not pKd",
98
+ "assay type, relation operator, and source provenance are absent from the combined Parquet",
99
+ "cold_protein_exact is entity-disjoint but not sequence-similarity-clustered",
100
+ "scaffold groups use Bemis-Murcko with exact-molecule fallback for acyclic ligands",
101
+ "this development sample is not an external biological benchmark"
102
+ ],
103
+ "split_manifests": {
104
+ "protein_similarity_50": "split-protein_similarity_50.jsonl"
105
+ },
106
+ "external_split_audits": {
107
+ "protein_similarity_50": {
108
+ "tool": "MMseqs2",
109
+ "command": [
110
+ "mmseqs",
111
+ "easy-cluster",
112
+ "artifacts/dev-10k/mmseqs-identity-50-coverage-80/proteins.fasta",
113
+ "artifacts/dev-10k/mmseqs-identity-50-coverage-80/clusters",
114
+ "artifacts/dev-10k/mmseqs-identity-50-coverage-80/tmp",
115
+ "--min-seq-id",
116
+ "0.5",
117
+ "-c",
118
+ "0.8",
119
+ "--cov-mode",
120
+ "0",
121
+ "--alignment-mode",
122
+ "3",
123
+ "--threads",
124
+ "8"
125
+ ],
126
+ "min_sequence_identity": 0.5,
127
+ "minimum_alignment_coverage": 0.8,
128
+ "coverage_mode": "both query and target",
129
+ "alignment_mode": "actual identical residues / aligned columns",
130
+ "unique_proteins": 2255,
131
+ "clusters": 1421,
132
+ "largest_cluster_proteins": 15,
133
+ "split_rows": {
134
+ "validation": 1000,
135
+ "train": 8000,
136
+ "test": 1000
137
+ },
138
+ "manifest": "split-protein_similarity_50.jsonl",
139
+ "protein_cluster_overlap": 0
140
+ }
141
+ },
142
+ "sample_sha256": "b3f10e03a3882a6b57fc38de5384a639e88494a738dcca45a04196f87790186b",
143
+ "manifest_sha256": {
144
+ "split-random_pair.jsonl": "b316172efa0832e9a9677e33c91f999c32b0c2496c1d1ebcf65887a8ff919635",
145
+ "split-cold_protein_exact.jsonl": "5c71713f1fe2fdf7091ac57a1d5f4027b8e1c90f50af379a45074467a3f1ba53",
146
+ "split-protein_similarity_50.jsonl": "42edbf243966f7f9a1c6a16d86d394a94a5386ee4a96defbb9be7f364deaab78",
147
+ "split-cold_ligand_scaffold.jsonl": "b38a49046902eba950c91f97a5781ace3e95cc9f6252295f026cc895f38557e1"
148
+ }
149
+ }
recovery/reports/dev-10k-baselines.json ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "sample_rows": 10000,
3
+ "target": "pAffinity",
4
+ "packages": {
5
+ "numpy": "2.5.1",
6
+ "scipy": "1.18.0",
7
+ "scikit-learn": "1.9.0"
8
+ },
9
+ "splits": {
10
+ "cold_ligand_scaffold": {
11
+ "rows": {
12
+ "train": 8000,
13
+ "validation": 1000,
14
+ "test": 1000
15
+ },
16
+ "mean_baseline": {
17
+ "prediction": 6.537638315707445,
18
+ "validation": {
19
+ "rmse": 1.5957348058680887,
20
+ "mae": 1.2895477378728986,
21
+ "pearson_r": null,
22
+ "spearman_r": null,
23
+ "r2": -0.020329719922339562
24
+ },
25
+ "test": {
26
+ "rmse": 1.5833990791492547,
27
+ "mae": 1.3026160468125345,
28
+ "pearson_r": null,
29
+ "spearman_r": null,
30
+ "r2": -0.00029222365027892216
31
+ }
32
+ },
33
+ "feature_dimensions": {
34
+ "protein": 4096,
35
+ "ligand": 4096
36
+ },
37
+ "models": [
38
+ {
39
+ "name": "protein_char3_ridge",
40
+ "selected_alpha": 1.0,
41
+ "validation_trials": [
42
+ {
43
+ "alpha": 0.1,
44
+ "metrics": {
45
+ "rmse": 1.527804068122877,
46
+ "mae": 1.1779963380098344,
47
+ "pearson_r": 0.3896740635545639,
48
+ "spearman_r": 0.3792641691445764,
49
+ "r2": 0.06469248187168464
50
+ }
51
+ },
52
+ {
53
+ "alpha": 1.0,
54
+ "metrics": {
55
+ "rmse": 1.4578139871351854,
56
+ "mae": 1.139968386530876,
57
+ "pearson_r": 0.4151059016140626,
58
+ "spearman_r": 0.3969308305062462,
59
+ "r2": 0.14842416968739125
60
+ }
61
+ },
62
+ {
63
+ "alpha": 10.0,
64
+ "metrics": {
65
+ "rmse": 1.4723908858399237,
66
+ "mae": 1.1793289355039596,
67
+ "pearson_r": 0.39965520038704133,
68
+ "spearman_r": 0.39853528582342707,
69
+ "r2": 0.1313089600423143
70
+ }
71
+ },
72
+ {
73
+ "alpha": 100.0,
74
+ "metrics": {
75
+ "rmse": 1.554020183734794,
76
+ "mae": 1.257076021552086,
77
+ "pearson_r": 0.3325547399758985,
78
+ "spearman_r": 0.3505718847248242,
79
+ "r2": 0.032318560087709836
80
+ }
81
+ }
82
+ ],
83
+ "validation": {
84
+ "rmse": 1.4578139871351854,
85
+ "mae": 1.139968386530876,
86
+ "pearson_r": 0.4151059016140626,
87
+ "spearman_r": 0.3969308305062462,
88
+ "r2": 0.14842416968739125
89
+ },
90
+ "test": {
91
+ "rmse": 1.39215070131138,
92
+ "mae": 1.102342973947525,
93
+ "pearson_r": 0.47756240225675844,
94
+ "spearman_r": 0.4740036884171099,
95
+ "r2": 0.22675233445655052
96
+ },
97
+ "fit_and_eval_seconds": 0.9593746850005118
98
+ },
99
+ {
100
+ "name": "ligand_char2_5_ridge",
101
+ "selected_alpha": 10.0,
102
+ "validation_trials": [
103
+ {
104
+ "alpha": 0.1,
105
+ "metrics": {
106
+ "rmse": 1.5653168764143357,
107
+ "mae": 1.2302509154081345,
108
+ "pearson_r": 0.35459474446304934,
109
+ "spearman_r": 0.32893305055591643,
110
+ "r2": 0.018198624347302683
111
+ }
112
+ },
113
+ {
114
+ "alpha": 1.0,
115
+ "metrics": {
116
+ "rmse": 1.4623783005609468,
117
+ "mae": 1.1591383439302445,
118
+ "pearson_r": 0.4035976657359315,
119
+ "spearman_r": 0.37928879379063246,
120
+ "r2": 0.1430833734386795
121
+ }
122
+ },
123
+ {
124
+ "alpha": 10.0,
125
+ "metrics": {
126
+ "rmse": 1.4539723529408441,
127
+ "mae": 1.157127993941307,
128
+ "pearson_r": 0.4061425224793911,
129
+ "spearman_r": 0.39141890069825885,
130
+ "r2": 0.15290640459726268
131
+ }
132
+ },
133
+ {
134
+ "alpha": 100.0,
135
+ "metrics": {
136
+ "rmse": 1.5244228028633535,
137
+ "mae": 1.22633742749691,
138
+ "pearson_r": 0.3536892569060556,
139
+ "spearman_r": 0.3496830979478171,
140
+ "r2": 0.06882785931877777
141
+ }
142
+ }
143
+ ],
144
+ "validation": {
145
+ "rmse": 1.4539723529408441,
146
+ "mae": 1.157127993941307,
147
+ "pearson_r": 0.4061425224793911,
148
+ "spearman_r": 0.39141890069825885,
149
+ "r2": 0.15290640459726268
150
+ },
151
+ "test": {
152
+ "rmse": 1.4494043209545224,
153
+ "mae": 1.1682950427532197,
154
+ "pearson_r": 0.4147866313943454,
155
+ "spearman_r": 0.42730966425629635,
156
+ "r2": 0.16184330209141884
157
+ },
158
+ "fit_and_eval_seconds": 0.33016813500034914
159
+ },
160
+ {
161
+ "name": "combined_char_ridge",
162
+ "selected_alpha": 10.0,
163
+ "validation_trials": [
164
+ {
165
+ "alpha": 0.1,
166
+ "metrics": {
167
+ "rmse": 1.6081591147564207,
168
+ "mae": 1.2413834615945816,
169
+ "pearson_r": 0.4169937363739051,
170
+ "spearman_r": 0.40429283856812304,
171
+ "r2": -0.03628004246158212
172
+ }
173
+ },
174
+ {
175
+ "alpha": 1.0,
176
+ "metrics": {
177
+ "rmse": 1.4107695549582286,
178
+ "mae": 1.1013368602991105,
179
+ "pearson_r": 0.4874112220852569,
180
+ "spearman_r": 0.47383417558330937,
181
+ "r2": 0.20249895881043067
182
+ }
183
+ },
184
+ {
185
+ "alpha": 10.0,
186
+ "metrics": {
187
+ "rmse": 1.383549482454833,
188
+ "mae": 1.0967753337621688,
189
+ "pearson_r": 0.49859659893827324,
190
+ "spearman_r": 0.4945176557261553,
191
+ "r2": 0.23297681168774853
192
+ }
193
+ },
194
+ {
195
+ "alpha": 100.0,
196
+ "metrics": {
197
+ "rmse": 1.4929408447274506,
198
+ "mae": 1.200959615111351,
199
+ "pearson_r": 0.43016081195245404,
200
+ "spearman_r": 0.4306665031943897,
201
+ "r2": 0.10689133934020467
202
+ }
203
+ }
204
+ ],
205
+ "validation": {
206
+ "rmse": 1.383549482454833,
207
+ "mae": 1.0967753337621688,
208
+ "pearson_r": 0.49859659893827324,
209
+ "spearman_r": 0.4945176557261553,
210
+ "r2": 0.23297681168774853
211
+ },
212
+ "test": {
213
+ "rmse": 1.3649470834344057,
214
+ "mae": 1.094587422132492,
215
+ "pearson_r": 0.5198256551098647,
216
+ "spearman_r": 0.5354444265178329,
217
+ "r2": 0.2566767000232979
218
+ },
219
+ "fit_and_eval_seconds": 1.7034703729987086
220
+ }
221
+ ]
222
+ },
223
+ "cold_protein_exact": {
224
+ "rows": {
225
+ "train": 8000,
226
+ "validation": 1000,
227
+ "test": 1000
228
+ },
229
+ "mean_baseline": {
230
+ "prediction": 6.476698521107435,
231
+ "validation": {
232
+ "rmse": 1.579782624176554,
233
+ "mae": 1.3174895944134593,
234
+ "pearson_r": null,
235
+ "spearman_r": null,
236
+ "r2": -0.01274982588145357
237
+ },
238
+ "test": {
239
+ "rmse": 1.4951426135931578,
240
+ "mae": 1.2163382358753083,
241
+ "pearson_r": null,
242
+ "spearman_r": null,
243
+ "r2": -0.014679353070982115
244
+ }
245
+ },
246
+ "feature_dimensions": {
247
+ "protein": 4096,
248
+ "ligand": 4096
249
+ },
250
+ "models": [
251
+ {
252
+ "name": "protein_char3_ridge",
253
+ "selected_alpha": 1.0,
254
+ "validation_trials": [
255
+ {
256
+ "alpha": 0.1,
257
+ "metrics": {
258
+ "rmse": 1.5913619001081973,
259
+ "mae": 1.2860890839099883,
260
+ "pearson_r": 0.24087607131254302,
261
+ "spearman_r": 0.24239051227885716,
262
+ "r2": -0.02765046684019734
263
+ }
264
+ },
265
+ {
266
+ "alpha": 1.0,
267
+ "metrics": {
268
+ "rmse": 1.4859462663648586,
269
+ "mae": 1.2179750628471375,
270
+ "pearson_r": 0.3474792859155311,
271
+ "spearman_r": 0.33699637680490147,
272
+ "r2": 0.10398821505374234
273
+ }
274
+ },
275
+ {
276
+ "alpha": 10.0,
277
+ "metrics": {
278
+ "rmse": 1.5008105727933332,
279
+ "mae": 1.2442660155296326,
280
+ "pearson_r": 0.3831242209268426,
281
+ "spearman_r": 0.3726678732274564,
282
+ "r2": 0.08597247826157572
283
+ }
284
+ },
285
+ {
286
+ "alpha": 100.0,
287
+ "metrics": {
288
+ "rmse": 1.553986133423702,
289
+ "mae": 1.2952312564849853,
290
+ "pearson_r": 0.3282098020500765,
291
+ "spearman_r": 0.30711899038156937,
292
+ "r2": 0.02005479994650128
293
+ }
294
+ }
295
+ ],
296
+ "validation": {
297
+ "rmse": 1.4859462663648586,
298
+ "mae": 1.2179750628471375,
299
+ "pearson_r": 0.3474792859155311,
300
+ "spearman_r": 0.33699637680490147,
301
+ "r2": 0.10398821505374234
302
+ },
303
+ "test": {
304
+ "rmse": 1.4585936156876136,
305
+ "mae": 1.1720177594423293,
306
+ "pearson_r": 0.2545678403741694,
307
+ "spearman_r": 0.2484803560755494,
308
+ "r2": 0.03432230415157966
309
+ },
310
+ "fit_and_eval_seconds": 0.9749435680005263
311
+ },
312
+ {
313
+ "name": "ligand_char2_5_ridge",
314
+ "selected_alpha": 10.0,
315
+ "validation_trials": [
316
+ {
317
+ "alpha": 0.1,
318
+ "metrics": {
319
+ "rmse": 1.6018811256320094,
320
+ "mae": 1.2999265880584716,
321
+ "pearson_r": 0.30216596804115387,
322
+ "spearman_r": 0.29339634813312904,
323
+ "r2": -0.04128132661572703
324
+ }
325
+ },
326
+ {
327
+ "alpha": 1.0,
328
+ "metrics": {
329
+ "rmse": 1.4768323814132662,
330
+ "mae": 1.2094637866020204,
331
+ "pearson_r": 0.37716980445129944,
332
+ "spearman_r": 0.36773099907324625,
333
+ "r2": 0.11494568428343488
334
+ }
335
+ },
336
+ {
337
+ "alpha": 10.0,
338
+ "metrics": {
339
+ "rmse": 1.4613319935586893,
340
+ "mae": 1.2048628535270691,
341
+ "pearson_r": 0.3911678449191863,
342
+ "spearman_r": 0.3873732410005544,
343
+ "r2": 0.1334267140116593
344
+ }
345
+ },
346
+ {
347
+ "alpha": 100.0,
348
+ "metrics": {
349
+ "rmse": 1.5197247823028681,
350
+ "mae": 1.2624011125564576,
351
+ "pearson_r": 0.3423555617155642,
352
+ "spearman_r": 0.3437308713625526,
353
+ "r2": 0.06278894470085006
354
+ }
355
+ }
356
+ ],
357
+ "validation": {
358
+ "rmse": 1.4613319935586893,
359
+ "mae": 1.2048628535270691,
360
+ "pearson_r": 0.3911678449191863,
361
+ "spearman_r": 0.3873732410005544,
362
+ "r2": 0.1334267140116593
363
+ },
364
+ "test": {
365
+ "rmse": 1.3526643485350685,
366
+ "mae": 1.0842266837358474,
367
+ "pearson_r": 0.4362727683300783,
368
+ "spearman_r": 0.4191402824529675,
369
+ "r2": 0.16949228447261422
370
+ },
371
+ "fit_and_eval_seconds": 0.37831321699923137
372
+ },
373
+ {
374
+ "name": "combined_char_ridge",
375
+ "selected_alpha": 1.0,
376
+ "validation_trials": [
377
+ {
378
+ "alpha": 0.1,
379
+ "metrics": {
380
+ "rmse": 1.609541967480026,
381
+ "mae": 1.2925864708423616,
382
+ "pearson_r": 0.3470317009211976,
383
+ "spearman_r": 0.3383021776125508,
384
+ "r2": -0.05126479701152342
385
+ }
386
+ },
387
+ {
388
+ "alpha": 1.0,
389
+ "metrics": {
390
+ "rmse": 1.4096703358753107,
391
+ "mae": 1.149479679107666,
392
+ "pearson_r": 0.4577114457735785,
393
+ "spearman_r": 0.4511058044444574,
394
+ "r2": 0.1936146421708813
395
+ }
396
+ },
397
+ {
398
+ "alpha": 10.0,
399
+ "metrics": {
400
+ "rmse": 1.4111861303751425,
401
+ "mae": 1.165886058807373,
402
+ "pearson_r": 0.48119186255022445,
403
+ "spearman_r": 0.481658449066694,
404
+ "r2": 0.19187952492255234
405
+ }
406
+ },
407
+ {
408
+ "alpha": 100.0,
409
+ "metrics": {
410
+ "rmse": 1.500300406119371,
411
+ "mae": 1.2459029321670532,
412
+ "pearson_r": 0.41484717418396205,
413
+ "spearman_r": 0.4187622144178708,
414
+ "r2": 0.08659377868944229
415
+ }
416
+ }
417
+ ],
418
+ "validation": {
419
+ "rmse": 1.4096703358753107,
420
+ "mae": 1.149479679107666,
421
+ "pearson_r": 0.4577114457735785,
422
+ "spearman_r": 0.4511058044444574,
423
+ "r2": 0.1936146421708813
424
+ },
425
+ "test": {
426
+ "rmse": 1.3462287870584813,
427
+ "mae": 1.0676061400175094,
428
+ "pearson_r": 0.4384364045433516,
429
+ "spearman_r": 0.42825367206750203,
430
+ "r2": 0.17737608661000348
431
+ },
432
+ "fit_and_eval_seconds": 1.6212121779990412
433
+ }
434
+ ]
435
+ },
436
+ "protein_similarity_50": {
437
+ "rows": {
438
+ "train": 8000,
439
+ "validation": 1000,
440
+ "test": 1000
441
+ },
442
+ "mean_baseline": {
443
+ "prediction": 6.4936330486238,
444
+ "validation": {
445
+ "rmse": 1.5513375597083523,
446
+ "mae": 1.2790881541401746,
447
+ "pearson_r": null,
448
+ "spearman_r": null,
449
+ "r2": -0.013098447070553743
450
+ },
451
+ "test": {
452
+ "rmse": 1.533614785804936,
453
+ "mae": 1.2540174638183117,
454
+ "pearson_r": null,
455
+ "spearman_r": null,
456
+ "r2": -5.474558686380249e-05
457
+ }
458
+ },
459
+ "feature_dimensions": {
460
+ "protein": 4096,
461
+ "ligand": 4096
462
+ },
463
+ "models": [
464
+ {
465
+ "name": "protein_char3_ridge",
466
+ "selected_alpha": 10.0,
467
+ "validation_trials": [
468
+ {
469
+ "alpha": 0.1,
470
+ "metrics": {
471
+ "rmse": 1.7552403417237736,
472
+ "mae": 1.401314944624901,
473
+ "pearson_r": 0.18824801515316072,
474
+ "spearman_r": 0.18074336545364095,
475
+ "r2": -0.29691710691928597
476
+ }
477
+ },
478
+ {
479
+ "alpha": 1.0,
480
+ "metrics": {
481
+ "rmse": 1.5601924885212413,
482
+ "mae": 1.2610043984651567,
483
+ "pearson_r": 0.2319965337290784,
484
+ "spearman_r": 0.23655822614618757,
485
+ "r2": -0.024696847762769814
486
+ }
487
+ },
488
+ {
489
+ "alpha": 10.0,
490
+ "metrics": {
491
+ "rmse": 1.5356435415785854,
492
+ "mae": 1.2523401461839676,
493
+ "pearson_r": 0.21912642622037123,
494
+ "spearman_r": 0.2117505257188302,
495
+ "r2": 0.0072957755423069814
496
+ }
497
+ },
498
+ {
499
+ "alpha": 100.0,
500
+ "metrics": {
501
+ "rmse": 1.5432243885780599,
502
+ "mae": 1.2689961529970168,
503
+ "pearson_r": 0.19756629969633466,
504
+ "spearman_r": 0.19839572987049425,
505
+ "r2": -0.0025295698557341595
506
+ }
507
+ }
508
+ ],
509
+ "validation": {
510
+ "rmse": 1.5356435415785854,
511
+ "mae": 1.2523401461839676,
512
+ "pearson_r": 0.21912642622037123,
513
+ "spearman_r": 0.2117505257188302,
514
+ "r2": 0.0072957755423069814
515
+ },
516
+ "test": {
517
+ "rmse": 1.5253163396597564,
518
+ "mae": 1.2421429891586304,
519
+ "pearson_r": 0.12107755752116321,
520
+ "spearman_r": 0.11902515580955182,
521
+ "r2": 0.010738639684262585
522
+ },
523
+ "fit_and_eval_seconds": 0.8420703150004556
524
+ },
525
+ {
526
+ "name": "ligand_char2_5_ridge",
527
+ "selected_alpha": 10.0,
528
+ "validation_trials": [
529
+ {
530
+ "alpha": 0.1,
531
+ "metrics": {
532
+ "rmse": 1.5799239713380486,
533
+ "mae": 1.271127613902092,
534
+ "pearson_r": 0.29265036816534756,
535
+ "spearman_r": 0.2854553015351282,
536
+ "r2": -0.050779065362796194
537
+ }
538
+ },
539
+ {
540
+ "alpha": 1.0,
541
+ "metrics": {
542
+ "rmse": 1.4859846732161874,
543
+ "mae": 1.2077869259119034,
544
+ "pearson_r": 0.33129654600169606,
545
+ "spearman_r": 0.3199643083980807,
546
+ "r2": 0.07046083081253718
547
+ }
548
+ },
549
+ {
550
+ "alpha": 10.0,
551
+ "metrics": {
552
+ "rmse": 1.4720109593236679,
553
+ "mae": 1.1972612363100053,
554
+ "pearson_r": 0.3227538249245248,
555
+ "spearman_r": 0.316034036664011,
556
+ "r2": 0.08786079723640017
557
+ }
558
+ },
559
+ {
560
+ "alpha": 100.0,
561
+ "metrics": {
562
+ "rmse": 1.5116912475349742,
563
+ "mae": 1.23919963991642,
564
+ "pearson_r": 0.25043786181937333,
565
+ "spearman_r": 0.24831507291908664,
566
+ "r2": 0.03802179626202029
567
+ }
568
+ }
569
+ ],
570
+ "validation": {
571
+ "rmse": 1.4720109593236679,
572
+ "mae": 1.1972612363100053,
573
+ "pearson_r": 0.3227538249245248,
574
+ "spearman_r": 0.316034036664011,
575
+ "r2": 0.08786079723640017
576
+ },
577
+ "test": {
578
+ "rmse": 1.429188581726333,
579
+ "mae": 1.1390189518928528,
580
+ "pearson_r": 0.36960662009993567,
581
+ "spearman_r": 0.3651127131105154,
582
+ "r2": 0.1314990922237853
583
+ },
584
+ "fit_and_eval_seconds": 0.33130267600063235
585
+ },
586
+ {
587
+ "name": "combined_char_ridge",
588
+ "selected_alpha": 10.0,
589
+ "validation_trials": [
590
+ {
591
+ "alpha": 0.1,
592
+ "metrics": {
593
+ "rmse": 1.695446275393881,
594
+ "mae": 1.3751192058324815,
595
+ "pearson_r": 0.28274147950808365,
596
+ "spearman_r": 0.2880691599027914,
597
+ "r2": -0.21006054325174417
598
+ }
599
+ },
600
+ {
601
+ "alpha": 1.0,
602
+ "metrics": {
603
+ "rmse": 1.4883475906405312,
604
+ "mae": 1.2132670460939408,
605
+ "pearson_r": 0.35310402171271504,
606
+ "spearman_r": 0.36051834925201537,
607
+ "r2": 0.06750229342577019
608
+ }
609
+ },
610
+ {
611
+ "alpha": 10.0,
612
+ "metrics": {
613
+ "rmse": 1.4627022031596522,
614
+ "mae": 1.1911575428247452,
615
+ "pearson_r": 0.36076986449064835,
616
+ "spearman_r": 0.3723077063029598,
617
+ "r2": 0.09936075775091613
618
+ }
619
+ },
620
+ {
621
+ "alpha": 100.0,
622
+ "metrics": {
623
+ "rmse": 1.5056113027663196,
624
+ "mae": 1.2321353279352187,
625
+ "pearson_r": 0.2885589685976343,
626
+ "spearman_r": 0.29454935455844794,
627
+ "r2": 0.04574428935903241
628
+ }
629
+ }
630
+ ],
631
+ "validation": {
632
+ "rmse": 1.4627022031596522,
633
+ "mae": 1.1911575428247452,
634
+ "pearson_r": 0.36076986449064835,
635
+ "spearman_r": 0.3723077063029598,
636
+ "r2": 0.09936075775091613
637
+ },
638
+ "test": {
639
+ "rmse": 1.4348684777145742,
640
+ "mae": 1.1439314622879029,
641
+ "pearson_r": 0.36247186743462856,
642
+ "spearman_r": 0.3652137611435173,
643
+ "r2": 0.12458216430470337
644
+ },
645
+ "fit_and_eval_seconds": 1.6895546520008793
646
+ }
647
+ ]
648
+ },
649
+ "random_pair": {
650
+ "rows": {
651
+ "train": 8000,
652
+ "validation": 1000,
653
+ "test": 1000
654
+ },
655
+ "mean_baseline": {
656
+ "prediction": 6.5244491582587365,
657
+ "validation": {
658
+ "rmse": 1.5276238617697966,
659
+ "mae": 1.2404643696633129,
660
+ "pearson_r": null,
661
+ "spearman_r": null,
662
+ "r2": -0.00011912455955709333
663
+ },
664
+ "test": {
665
+ "rmse": 1.565044692955996,
666
+ "mae": 1.2979176334988474,
667
+ "pearson_r": null,
668
+ "spearman_r": null,
669
+ "r2": -0.004413634248849885
670
+ }
671
+ },
672
+ "feature_dimensions": {
673
+ "protein": 4096,
674
+ "ligand": 4096
675
+ },
676
+ "models": [
677
+ {
678
+ "name": "protein_char3_ridge",
679
+ "selected_alpha": 1.0,
680
+ "validation_trials": [
681
+ {
682
+ "alpha": 0.1,
683
+ "metrics": {
684
+ "rmse": 1.4410565980112502,
685
+ "mae": 1.1288913173079491,
686
+ "pearson_r": 0.4079221480849496,
687
+ "spearman_r": 0.3893575689504746,
688
+ "r2": 0.1100185770832387
689
+ }
690
+ },
691
+ {
692
+ "alpha": 1.0,
693
+ "metrics": {
694
+ "rmse": 1.3745768065170116,
695
+ "mae": 1.100080373942852,
696
+ "pearson_r": 0.44118951305358517,
697
+ "spearman_r": 0.41362525923337845,
698
+ "r2": 0.1902389400564115
699
+ }
700
+ },
701
+ {
702
+ "alpha": 10.0,
703
+ "metrics": {
704
+ "rmse": 1.3955357347301767,
705
+ "mae": 1.1366457569003106,
706
+ "pearson_r": 0.4230883826225867,
707
+ "spearman_r": 0.4074744496313428,
708
+ "r2": 0.16535693645157734
709
+ }
710
+ },
711
+ {
712
+ "alpha": 100.0,
713
+ "metrics": {
714
+ "rmse": 1.4814185114679912,
715
+ "mae": 1.2066325885653495,
716
+ "pearson_r": 0.3582189512625387,
717
+ "spearman_r": 0.36664589962084587,
718
+ "r2": 0.059466217343855376
719
+ }
720
+ }
721
+ ],
722
+ "validation": {
723
+ "rmse": 1.3745768065170116,
724
+ "mae": 1.100080373942852,
725
+ "pearson_r": 0.44118951305358517,
726
+ "spearman_r": 0.41362525923337845,
727
+ "r2": 0.1902389400564115
728
+ },
729
+ "test": {
730
+ "rmse": 1.3880651061666627,
731
+ "mae": 1.1175376932621002,
732
+ "pearson_r": 0.4640112366064133,
733
+ "spearman_r": 0.4357397752396537,
734
+ "r2": 0.20990596198875733
735
+ },
736
+ "fit_and_eval_seconds": 0.894843939000566
737
+ },
738
+ {
739
+ "name": "ligand_char2_5_ridge",
740
+ "selected_alpha": 10.0,
741
+ "validation_trials": [
742
+ {
743
+ "alpha": 0.1,
744
+ "metrics": {
745
+ "rmse": 1.4856668026573707,
746
+ "mae": 1.1865731849074364,
747
+ "pearson_r": 0.3688529817391914,
748
+ "spearman_r": 0.37857178194968666,
749
+ "r2": 0.05406411042117554
750
+ }
751
+ },
752
+ {
753
+ "alpha": 1.0,
754
+ "metrics": {
755
+ "rmse": 1.3916760241468447,
756
+ "mae": 1.1180368124842643,
757
+ "pearson_r": 0.42160711722938904,
758
+ "spearman_r": 0.42128867139651427,
759
+ "r2": 0.16996738915693665
760
+ }
761
+ },
762
+ {
763
+ "alpha": 10.0,
764
+ "metrics": {
765
+ "rmse": 1.3915522045386022,
766
+ "mae": 1.1223492309451104,
767
+ "pearson_r": 0.42089896113032044,
768
+ "spearman_r": 0.41150350807895536,
769
+ "r2": 0.17011508120440122
770
+ }
771
+ },
772
+ {
773
+ "alpha": 100.0,
774
+ "metrics": {
775
+ "rmse": 1.462418544451856,
776
+ "mae": 1.1855577809214592,
777
+ "pearson_r": 0.35716066077001357,
778
+ "spearman_r": 0.34302021119123416,
779
+ "r2": 0.08343718024568059
780
+ }
781
+ }
782
+ ],
783
+ "validation": {
784
+ "rmse": 1.3915522045386022,
785
+ "mae": 1.1223492309451104,
786
+ "pearson_r": 0.42089896113032044,
787
+ "spearman_r": 0.41150350807895536,
788
+ "r2": 0.17011508120440122
789
+ },
790
+ "test": {
791
+ "rmse": 1.4268869133537447,
792
+ "mae": 1.175576719045639,
793
+ "pearson_r": 0.4211078957570381,
794
+ "spearman_r": 0.40335385177971433,
795
+ "r2": 0.1650927724032577
796
+ },
797
+ "fit_and_eval_seconds": 0.4427177519992256
798
+ },
799
+ {
800
+ "name": "combined_char_ridge",
801
+ "selected_alpha": 10.0,
802
+ "validation_trials": [
803
+ {
804
+ "alpha": 0.1,
805
+ "metrics": {
806
+ "rmse": 1.4956017228690686,
807
+ "mae": 1.1701721274256707,
808
+ "pearson_r": 0.4361198131829029,
809
+ "spearman_r": 0.4281298523300441,
810
+ "r2": 0.0413705240127229
811
+ }
812
+ },
813
+ {
814
+ "alpha": 1.0,
815
+ "metrics": {
816
+ "rmse": 1.3140696311066697,
817
+ "mae": 1.0519584881663322,
818
+ "pearson_r": 0.5201378315237909,
819
+ "spearman_r": 0.5029682145871107,
820
+ "r2": 0.25995927103340644
821
+ }
822
+ },
823
+ {
824
+ "alpha": 10.0,
825
+ "metrics": {
826
+ "rmse": 1.307333278135575,
827
+ "mae": 1.0559683529734611,
828
+ "pearson_r": 0.5300812334907434,
829
+ "spearman_r": 0.5194865614899057,
830
+ "r2": 0.26752720749752146
831
+ }
832
+ },
833
+ {
834
+ "alpha": 100.0,
835
+ "metrics": {
836
+ "rmse": 1.4257502567100055,
837
+ "mae": 1.15831251257658,
838
+ "pearson_r": 0.45176846530905573,
839
+ "spearman_r": 0.44875210828870205,
840
+ "r2": 0.1288242419468516
841
+ }
842
+ }
843
+ ],
844
+ "validation": {
845
+ "rmse": 1.307333278135575,
846
+ "mae": 1.0559683529734611,
847
+ "pearson_r": 0.5300812334907434,
848
+ "spearman_r": 0.5194865614899057,
849
+ "r2": 0.26752720749752146
850
+ },
851
+ "test": {
852
+ "rmse": 1.335818862102712,
853
+ "mae": 1.0964913694858551,
854
+ "pearson_r": 0.5371481422264178,
855
+ "spearman_r": 0.5186748222826298,
856
+ "r2": 0.2682642831079074
857
+ },
858
+ "fit_and_eval_seconds": 1.8038349809994543
859
+ }
860
+ ]
861
+ }
862
+ },
863
+ "total_seconds": 26.8222462800004
864
+ }
recovery/reports/overfit-actual-embeddings.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "examples": 32,
3
+ "steps": 1000,
4
+ "seed": 42,
5
+ "rmse_paffinity": 2.6320698458933376e-07,
6
+ "mae_paffinity": 9.685754776000977e-08,
7
+ "acceptance_max_rmse_paffinity": 0.05,
8
+ "passed": true,
9
+ "target_scaler": {
10
+ "mean": 6.446505546569824,
11
+ "std": 1.7902863025665283
12
+ },
13
+ "history": [
14
+ {
15
+ "step": 1,
16
+ "standardized_mse": 1.0336081981658936
17
+ },
18
+ {
19
+ "step": 100,
20
+ "standardized_mse": 2.772125753836008e-06
21
+ },
22
+ {
23
+ "step": 200,
24
+ "standardized_mse": 1.03808101004077e-10
25
+ },
26
+ {
27
+ "step": 300,
28
+ "standardized_mse": 1.9242752194188056e-14
29
+ },
30
+ {
31
+ "step": 400,
32
+ "standardized_mse": 1.2282174246831118e-14
33
+ },
34
+ {
35
+ "step": 500,
36
+ "standardized_mse": 1.1517161193925346e-14
37
+ },
38
+ {
39
+ "step": 600,
40
+ "standardized_mse": 5.5680982768535614e-05
41
+ },
42
+ {
43
+ "step": 700,
44
+ "standardized_mse": 3.629010425498791e-09
45
+ },
46
+ {
47
+ "step": 800,
48
+ "standardized_mse": 6.048471863336416e-14
49
+ },
50
+ {
51
+ "step": 900,
52
+ "standardized_mse": 1.406742154404575e-14
53
+ },
54
+ {
55
+ "step": 1000,
56
+ "standardized_mse": 1.5145552184365335e-14
57
+ }
58
+ ],
59
+ "scope": "trainable head over 32 embeddings from the pinned frozen encoders recorded in recovery/config.json"
60
+ }
recovery/scripts/cluster_proteins.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a sequence-similarity-disjoint protein split using MMseqs2."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ from collections import Counter
11
+ from pathlib import Path
12
+
13
+ from mitointeract_recovery.splits import assign_grouped_splits, assert_group_disjoint
14
+
15
+
16
+ def read_rows(path: Path) -> list[dict]:
17
+ with path.open() as handle:
18
+ return [json.loads(line) for line in handle if line.strip()]
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--data-dir", type=Path, default=Path("artifacts/dev-10k"))
24
+ parser.add_argument("--min-seq-id", type=float, default=0.5)
25
+ parser.add_argument("--coverage", type=float, default=0.8)
26
+ parser.add_argument("--seed", type=int, default=42)
27
+ parser.add_argument("--threads", type=int, default=8)
28
+ args = parser.parse_args()
29
+
30
+ if not shutil.which("mmseqs"):
31
+ raise RuntimeError("MMseqs2 is required; install the mmseqs2 package")
32
+ rows = read_rows(args.data_dir / "sample.jsonl")
33
+ proteins = sorted({row["protein_id"]: row["sequence"] for row in rows}.items())
34
+ suffix = (
35
+ f"identity-{int(args.min_seq_id * 100)}-coverage-{int(args.coverage * 100)}"
36
+ )
37
+ work = args.data_dir / f"mmseqs-{suffix}"
38
+ work.mkdir(parents=True, exist_ok=True)
39
+ fasta = work / "proteins.fasta"
40
+ with fasta.open("w") as handle:
41
+ for protein_id, sequence in proteins:
42
+ handle.write(f">{protein_id}\n{sequence}\n")
43
+
44
+ prefix = work / "clusters"
45
+ tmp = work / "tmp"
46
+ command = [
47
+ "mmseqs",
48
+ "easy-cluster",
49
+ str(fasta),
50
+ str(prefix),
51
+ str(tmp),
52
+ "--min-seq-id",
53
+ str(args.min_seq_id),
54
+ "-c",
55
+ str(args.coverage),
56
+ "--cov-mode",
57
+ "0",
58
+ "--alignment-mode",
59
+ "3",
60
+ "--threads",
61
+ str(args.threads),
62
+ ]
63
+ cluster_tsv = prefix.with_name(prefix.name + "_cluster.tsv")
64
+ if not cluster_tsv.exists():
65
+ subprocess.run(command, check=True)
66
+ protein_to_cluster: dict[str, str] = {}
67
+ with cluster_tsv.open() as handle:
68
+ for line in handle:
69
+ representative, member = line.rstrip("\n").split("\t")
70
+ protein_to_cluster[member] = representative
71
+ missing = {protein_id for protein_id, _ in proteins} - protein_to_cluster.keys()
72
+ if missing:
73
+ raise AssertionError(f"MMseqs2 omitted {len(missing)} proteins")
74
+
75
+ enriched = [
76
+ dict(row, protein_similarity_cluster=protein_to_cluster[row["protein_id"]])
77
+ for row in rows
78
+ ]
79
+ assignments = assign_grouped_splits(
80
+ enriched,
81
+ group_key="protein_similarity_cluster",
82
+ seed=args.seed,
83
+ )
84
+ assert_group_disjoint(enriched, assignments, group_key="protein_similarity_cluster")
85
+ manifest_name = f"split-protein_similarity_{int(args.min_seq_id * 100)}.jsonl"
86
+ manifest = args.data_dir / manifest_name
87
+ with manifest.open("w") as handle:
88
+ for row in rows:
89
+ handle.write(
90
+ json.dumps(
91
+ {"pair_id": row["pair_id"], "split": assignments[row["pair_id"]]}
92
+ )
93
+ + "\n"
94
+ )
95
+
96
+ cluster_sizes = Counter(protein_to_cluster.values())
97
+ row_counts = Counter(assignments.values())
98
+ report = {
99
+ "tool": "MMseqs2",
100
+ "command": command,
101
+ "min_sequence_identity": args.min_seq_id,
102
+ "minimum_alignment_coverage": args.coverage,
103
+ "coverage_mode": "both query and target",
104
+ "alignment_mode": "actual identical residues / aligned columns",
105
+ "unique_proteins": len(proteins),
106
+ "clusters": len(cluster_sizes),
107
+ "largest_cluster_proteins": max(cluster_sizes.values()),
108
+ "split_rows": dict(row_counts),
109
+ "manifest": manifest_name,
110
+ "protein_cluster_overlap": 0,
111
+ }
112
+ (args.data_dir / f"protein-clusters-{suffix}.json").write_text(
113
+ json.dumps(report, indent=2) + "\n"
114
+ )
115
+ audit_path = args.data_dir / "audit.json"
116
+ audit = json.loads(audit_path.read_text())
117
+ audit.setdefault("split_manifests", {})[
118
+ f"protein_similarity_{int(args.min_seq_id * 100)}"
119
+ ] = manifest_name
120
+ audit.setdefault("external_split_audits", {})[
121
+ f"protein_similarity_{int(args.min_seq_id * 100)}"
122
+ ] = report
123
+ audit_path.write_text(json.dumps(audit, indent=2) + "\n")
124
+ print(json.dumps(report, indent=2))
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
recovery/scripts/dataset_smoke.py CHANGED
@@ -1,5 +1,6 @@
1
  #!/usr/bin/env python3
2
  """Validate dataset target semantics and compute a deterministic mean baseline."""
 
3
  from __future__ import annotations
4
 
5
  import argparse
@@ -11,14 +12,20 @@ import urllib.parse
11
  import urllib.request
12
  from pathlib import Path
13
 
14
- from mitointeract_recovery import micromolar_to_pkd
15
 
16
 
17
  def fetch_rows(dataset: str, rows: int) -> list[dict]:
18
  if not 2 <= rows <= 100:
19
  raise ValueError("Dataset Viewer smoke checks support 2-100 rows")
20
  query = urllib.parse.urlencode(
21
- {"dataset": dataset, "config": "default", "split": "train", "offset": 0, "length": rows}
 
 
 
 
 
 
22
  )
23
  url = f"https://datasets-server.huggingface.co/rows?{query}"
24
  with urllib.request.urlopen(url, timeout=60) as response:
@@ -31,7 +38,9 @@ def pearson(xs: list[float], ys: list[float]) -> float | None:
31
  return None
32
  xm, ym = statistics.mean(xs), statistics.mean(ys)
33
  numerator = sum((x - xm) * (y - ym) for x, y in zip(xs, ys))
34
- denominator = math.sqrt(sum((x - xm) ** 2 for x in xs) * sum((y - ym) ** 2 for y in ys))
 
 
35
  return numerator / denominator if denominator else None
36
 
37
 
@@ -40,17 +49,26 @@ def main() -> None:
40
  parser.add_argument("--dataset", default="jglaser/binding_affinity")
41
  parser.add_argument("--rows", type=int, default=100)
42
  parser.add_argument("--seed", type=int, default=42)
43
- parser.add_argument("--output", type=Path, default=Path("artifacts/dataset-smoke.json"))
 
 
44
  args = parser.parse_args()
45
 
46
  rows = fetch_rows(args.dataset, args.rows)
47
  errors = []
48
  for index, row in enumerate(rows):
49
- calculated = micromolar_to_pkd(float(row["affinity_uM"]))
50
  published = float(row["neg_log10_affinity_M"])
51
  delta = abs(calculated - published)
52
  if delta > 1e-5:
53
- errors.append({"row": index, "calculated": calculated, "published": published, "delta": delta})
 
 
 
 
 
 
 
54
 
55
  shuffled = list(rows)
56
  random.Random(args.seed).shuffle(shuffled)
@@ -60,16 +78,27 @@ def main() -> None:
60
  test_targets = [float(row["neg_log10_affinity_M"]) for row in test]
61
  mean_prediction = statistics.mean(train_targets)
62
  predictions = [mean_prediction] * len(test_targets)
63
- rmse = math.sqrt(statistics.mean((pred - target) ** 2 for pred, target in zip(predictions, test_targets)))
64
- mae = statistics.mean(abs(pred - target) for pred, target in zip(predictions, test_targets))
 
 
 
 
 
 
65
 
66
  report = {
67
  "dataset": args.dataset,
68
  "rows": len(rows),
69
  "seed": args.seed,
70
  "target_column": "neg_log10_affinity_M",
 
71
  "unit_check": {"passed": not errors, "errors": errors},
72
- "split": {"train_rows": len(train), "test_rows": len(test), "kind": "seeded smoke only"},
 
 
 
 
73
  "train_mean_baseline": {
74
  "prediction": mean_prediction,
75
  "rmse": rmse,
 
1
  #!/usr/bin/env python3
2
  """Validate dataset target semantics and compute a deterministic mean baseline."""
3
+
4
  from __future__ import annotations
5
 
6
  import argparse
 
12
  import urllib.request
13
  from pathlib import Path
14
 
15
+ from mitointeract_recovery import micromolar_to_paffinity
16
 
17
 
18
  def fetch_rows(dataset: str, rows: int) -> list[dict]:
19
  if not 2 <= rows <= 100:
20
  raise ValueError("Dataset Viewer smoke checks support 2-100 rows")
21
  query = urllib.parse.urlencode(
22
+ {
23
+ "dataset": dataset,
24
+ "config": "default",
25
+ "split": "train",
26
+ "offset": 0,
27
+ "length": rows,
28
+ }
29
  )
30
  url = f"https://datasets-server.huggingface.co/rows?{query}"
31
  with urllib.request.urlopen(url, timeout=60) as response:
 
38
  return None
39
  xm, ym = statistics.mean(xs), statistics.mean(ys)
40
  numerator = sum((x - xm) * (y - ym) for x, y in zip(xs, ys))
41
+ denominator = math.sqrt(
42
+ sum((x - xm) ** 2 for x in xs) * sum((y - ym) ** 2 for y in ys)
43
+ )
44
  return numerator / denominator if denominator else None
45
 
46
 
 
49
  parser.add_argument("--dataset", default="jglaser/binding_affinity")
50
  parser.add_argument("--rows", type=int, default=100)
51
  parser.add_argument("--seed", type=int, default=42)
52
+ parser.add_argument(
53
+ "--output", type=Path, default=Path("artifacts/dataset-smoke.json")
54
+ )
55
  args = parser.parse_args()
56
 
57
  rows = fetch_rows(args.dataset, args.rows)
58
  errors = []
59
  for index, row in enumerate(rows):
60
+ calculated = micromolar_to_paffinity(float(row["affinity_uM"]))
61
  published = float(row["neg_log10_affinity_M"])
62
  delta = abs(calculated - published)
63
  if delta > 1e-5:
64
+ errors.append(
65
+ {
66
+ "row": index,
67
+ "calculated": calculated,
68
+ "published": published,
69
+ "delta": delta,
70
+ }
71
+ )
72
 
73
  shuffled = list(rows)
74
  random.Random(args.seed).shuffle(shuffled)
 
78
  test_targets = [float(row["neg_log10_affinity_M"]) for row in test]
79
  mean_prediction = statistics.mean(train_targets)
80
  predictions = [mean_prediction] * len(test_targets)
81
+ rmse = math.sqrt(
82
+ statistics.mean(
83
+ (pred - target) ** 2 for pred, target in zip(predictions, test_targets)
84
+ )
85
+ )
86
+ mae = statistics.mean(
87
+ abs(pred - target) for pred, target in zip(predictions, test_targets)
88
+ )
89
 
90
  report = {
91
  "dataset": args.dataset,
92
  "rows": len(rows),
93
  "seed": args.seed,
94
  "target_column": "neg_log10_affinity_M",
95
+ "target_unit": "pAffinity (-log10 of the dataset's mixed affinity value in M)",
96
  "unit_check": {"passed": not errors, "errors": errors},
97
+ "split": {
98
+ "train_rows": len(train),
99
+ "test_rows": len(test),
100
+ "kind": "seeded smoke only",
101
+ },
102
  "train_mean_baseline": {
103
  "prediction": mean_prediction,
104
  "rmse": rmse,
recovery/scripts/embed_sample.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Cache pinned frozen-encoder embeddings for a MitoInteract sample."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import time
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ from transformers import AutoModel, AutoTokenizer
15
+
16
+ DEFAULT_PROTEIN_MODEL = "facebook/esm2_t12_35M_UR50D"
17
+ DEFAULT_PROTEIN_REVISION = "6fbf070e65b0b7291e7bbcd451118c216cff79d8"
18
+ DEFAULT_LIGAND_MODEL = "DeepChem/ChemBERTa-77M-MLM"
19
+ DEFAULT_LIGAND_REVISION = "ed8a5374f2024ec8da53760af91a33fb8f6a15ff"
20
+
21
+
22
+ def read_rows(path: Path, limit: int | None) -> list[dict]:
23
+ rows = []
24
+ with path.open() as handle:
25
+ for line in handle:
26
+ if line.strip():
27
+ rows.append(json.loads(line))
28
+ if limit and len(rows) >= limit:
29
+ break
30
+ return rows
31
+
32
+
33
+ def masked_mean(
34
+ last_hidden: torch.Tensor, attention: torch.Tensor, special: torch.Tensor
35
+ ) -> torch.Tensor:
36
+ mask = attention.bool() & ~special.bool()
37
+ weights = mask.unsqueeze(-1).to(last_hidden.dtype)
38
+ return (last_hidden * weights).sum(dim=1) / weights.sum(dim=1).clamp_min(1)
39
+
40
+
41
+ def encode_texts(
42
+ model, tokenizer, texts: list[str], batch_size: int, device: torch.device
43
+ ) -> np.ndarray:
44
+ outputs = []
45
+ for start in range(0, len(texts), batch_size):
46
+ batch = texts[start : start + batch_size]
47
+ encoded = tokenizer(
48
+ batch,
49
+ padding=True,
50
+ truncation=True,
51
+ max_length=min(getattr(tokenizer, "model_max_length", 512), 512),
52
+ return_special_tokens_mask=True,
53
+ return_tensors="pt",
54
+ )
55
+ special = encoded.pop("special_tokens_mask")
56
+ encoded = {key: value.to(device) for key, value in encoded.items()}
57
+ with torch.inference_mode():
58
+ hidden = model(**encoded).last_hidden_state
59
+ pooled = masked_mean(hidden, encoded["attention_mask"], special.to(device))
60
+ outputs.append(pooled.float().cpu().numpy())
61
+ return np.concatenate(outputs, axis=0)
62
+
63
+
64
+ def encode_proteins(
65
+ model,
66
+ tokenizer,
67
+ entities: list[tuple[str, str]],
68
+ batch_size: int,
69
+ device: torch.device,
70
+ chunk_residues: int,
71
+ ) -> dict[str, np.ndarray]:
72
+ chunks: list[str] = []
73
+ owners: list[str] = []
74
+ weights: list[int] = []
75
+ for entity_id, sequence in entities:
76
+ for start in range(0, len(sequence), chunk_residues):
77
+ chunk = sequence[start : start + chunk_residues]
78
+ chunks.append(chunk)
79
+ owners.append(entity_id)
80
+ weights.append(len(chunk))
81
+
82
+ chunk_embeddings = []
83
+ for start in range(0, len(chunks), batch_size):
84
+ batch = chunks[start : start + batch_size]
85
+ encoded = tokenizer(
86
+ batch,
87
+ padding=True,
88
+ truncation=True,
89
+ max_length=chunk_residues + 2,
90
+ return_special_tokens_mask=True,
91
+ return_tensors="pt",
92
+ )
93
+ special = encoded.pop("special_tokens_mask")
94
+ encoded = {key: value.to(device) for key, value in encoded.items()}
95
+ with torch.inference_mode():
96
+ hidden = model(**encoded).last_hidden_state
97
+ pooled = masked_mean(hidden, encoded["attention_mask"], special.to(device))
98
+ chunk_embeddings.extend(pooled.float().cpu().numpy())
99
+
100
+ accum: dict[str, list[tuple[np.ndarray, int]]] = defaultdict(list)
101
+ for owner, embedding, weight in zip(owners, chunk_embeddings, weights):
102
+ accum[owner].append((embedding, weight))
103
+ result = {}
104
+ for owner, values in accum.items():
105
+ matrix = np.stack([value for value, _ in values])
106
+ entity_weights = np.asarray([weight for _, weight in values], dtype=np.float32)
107
+ result[owner] = np.average(matrix, axis=0, weights=entity_weights)
108
+ return result
109
+
110
+
111
+ def main() -> None:
112
+ parser = argparse.ArgumentParser()
113
+ parser.add_argument(
114
+ "--sample", type=Path, default=Path("artifacts/dev-10k/sample.jsonl")
115
+ )
116
+ parser.add_argument("--limit", type=int)
117
+ parser.add_argument("--batch-size", type=int, default=8)
118
+ parser.add_argument("--protein-chunk-residues", type=int, default=1022)
119
+ parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
120
+ parser.add_argument("--protein-model", default=DEFAULT_PROTEIN_MODEL)
121
+ parser.add_argument("--protein-revision", default=DEFAULT_PROTEIN_REVISION)
122
+ parser.add_argument("--ligand-model", default=DEFAULT_LIGAND_MODEL)
123
+ parser.add_argument("--ligand-revision", default=DEFAULT_LIGAND_REVISION)
124
+ parser.add_argument("--output", type=Path, default=Path("artifacts/embeddings.npz"))
125
+ args = parser.parse_args()
126
+
127
+ device_name = (
128
+ "cuda" if args.device == "auto" and torch.cuda.is_available() else args.device
129
+ )
130
+ if device_name == "auto":
131
+ device_name = "cpu"
132
+ device = torch.device(device_name)
133
+ rows = read_rows(args.sample, args.limit)
134
+ proteins = sorted({row["protein_id"]: row["sequence"] for row in rows}.items())
135
+ ligands = sorted({row["ligand_id"]: row["smiles"] for row in rows}.items())
136
+
137
+ started = time.monotonic()
138
+ protein_tokenizer = AutoTokenizer.from_pretrained(
139
+ args.protein_model, revision=args.protein_revision
140
+ )
141
+ protein_model = (
142
+ AutoModel.from_pretrained(args.protein_model, revision=args.protein_revision)
143
+ .eval()
144
+ .to(device)
145
+ )
146
+ protein_embeddings = encode_proteins(
147
+ protein_model,
148
+ protein_tokenizer,
149
+ proteins,
150
+ args.batch_size,
151
+ device,
152
+ args.protein_chunk_residues,
153
+ )
154
+ del protein_model
155
+
156
+ ligand_tokenizer = AutoTokenizer.from_pretrained(
157
+ args.ligand_model, revision=args.ligand_revision
158
+ )
159
+ ligand_model = (
160
+ AutoModel.from_pretrained(args.ligand_model, revision=args.ligand_revision)
161
+ .eval()
162
+ .to(device)
163
+ )
164
+ ligand_matrix = encode_texts(
165
+ ligand_model,
166
+ ligand_tokenizer,
167
+ [smiles for _, smiles in ligands],
168
+ args.batch_size,
169
+ device,
170
+ )
171
+ ligand_embeddings = {
172
+ entity_id: embedding
173
+ for (entity_id, _), embedding in zip(ligands, ligand_matrix)
174
+ }
175
+
176
+ pair_protein = np.stack(
177
+ [protein_embeddings[row["protein_id"]] for row in rows]
178
+ ).astype(np.float32)
179
+ pair_ligand = np.stack(
180
+ [ligand_embeddings[row["ligand_id"]] for row in rows]
181
+ ).astype(np.float32)
182
+ args.output.parent.mkdir(parents=True, exist_ok=True)
183
+ np.savez_compressed(
184
+ args.output,
185
+ pair_ids=np.asarray([row["pair_id"] for row in rows]),
186
+ paffinity=np.asarray([row["paffinity"] for row in rows], dtype=np.float32),
187
+ protein=pair_protein,
188
+ ligand=pair_ligand,
189
+ )
190
+ metadata = {
191
+ "rows": len(rows),
192
+ "unique_proteins": len(proteins),
193
+ "unique_ligands": len(ligands),
194
+ "protein_dim": int(pair_protein.shape[1]),
195
+ "ligand_dim": int(pair_ligand.shape[1]),
196
+ "protein_model": args.protein_model,
197
+ "protein_revision": args.protein_revision,
198
+ "ligand_model": args.ligand_model,
199
+ "ligand_revision": args.ligand_revision,
200
+ "protein_chunk_residues": args.protein_chunk_residues,
201
+ "device": str(device),
202
+ "seconds": time.monotonic() - started,
203
+ }
204
+ args.output.with_suffix(".json").write_text(json.dumps(metadata, indent=2) + "\n")
205
+ print(json.dumps(metadata, indent=2))
206
+
207
+
208
+ if __name__ == "__main__":
209
+ main()
recovery/scripts/overfit_sanity.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Prove the v2 trainable head can memorize a fixed 32-example set."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+ from mitointeract_recovery.model import MitoInteractHead, TargetScaler
15
+
16
+
17
+ def read_targets(path: Path, count: int) -> torch.Tensor:
18
+ values = []
19
+ with path.open() as handle:
20
+ for line in handle:
21
+ if line.strip():
22
+ values.append(float(json.loads(line)["paffinity"]))
23
+ if len(values) == count:
24
+ break
25
+ if len(values) != count:
26
+ raise ValueError(f"needed {count} examples, found {len(values)}")
27
+ return torch.tensor(values, dtype=torch.float32)
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser()
32
+ parser.add_argument(
33
+ "--sample", type=Path, default=Path("artifacts/dev-10k/sample.jsonl")
34
+ )
35
+ parser.add_argument(
36
+ "--embeddings", type=Path, help="Optional NPZ from embed_sample.py"
37
+ )
38
+ parser.add_argument("--examples", type=int, default=32)
39
+ parser.add_argument("--steps", type=int, default=2000)
40
+ parser.add_argument("--seed", type=int, default=42)
41
+ parser.add_argument("--max-rmse", type=float, default=0.05)
42
+ parser.add_argument(
43
+ "--output", type=Path, default=Path("artifacts/overfit-sanity.json")
44
+ )
45
+ args = parser.parse_args()
46
+
47
+ torch.manual_seed(args.seed)
48
+ torch.set_num_threads(min(torch.get_num_threads(), 8))
49
+ embedding_scope = "synthetic fixed embeddings"
50
+ if args.embeddings:
51
+ arrays = np.load(args.embeddings)
52
+ available = len(arrays["paffinity"])
53
+ if available < args.examples:
54
+ raise ValueError(f"needed {args.examples} embeddings, found {available}")
55
+ targets = torch.from_numpy(arrays["paffinity"][: args.examples]).float()
56
+ protein = torch.from_numpy(arrays["protein"][: args.examples]).float()
57
+ ligand = torch.from_numpy(arrays["ligand"][: args.examples]).float()
58
+ embedding_scope = f"actual pinned frozen encoders from {args.embeddings}"
59
+ else:
60
+ targets = read_targets(args.sample, args.examples)
61
+ # Fixed synthetic embeddings isolate the trainable fusion/head path from
62
+ # encoder downloads and prove optimization can fit a tiny set exactly.
63
+ protein = torch.randn(args.examples, 32)
64
+ ligand = torch.randn(args.examples, 24)
65
+
66
+ scaler = TargetScaler.fit(targets)
67
+ encoded_targets = scaler.encode(targets)
68
+ model = MitoInteractHead(
69
+ protein.shape[1],
70
+ ligand.shape[1],
71
+ projection_dim=64,
72
+ hidden_dim=128,
73
+ dropout=0.0,
74
+ )
75
+ optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3, weight_decay=0.0)
76
+
77
+ history = []
78
+ for step in range(1, args.steps + 1):
79
+ model.train()
80
+ optimizer.zero_grad(set_to_none=True)
81
+ prediction = model(protein, ligand)
82
+ loss = torch.nn.functional.mse_loss(prediction, encoded_targets)
83
+ loss.backward()
84
+ optimizer.step()
85
+ if step == 1 or step % 100 == 0 or step == args.steps:
86
+ history.append({"step": step, "standardized_mse": float(loss.detach())})
87
+
88
+ model.eval()
89
+ with torch.no_grad():
90
+ decoded = scaler.decode(model(protein, ligand))
91
+ rmse = math.sqrt(float(torch.mean((decoded - targets) ** 2)))
92
+ mae = float(torch.mean(torch.abs(decoded - targets)))
93
+ report = {
94
+ "examples": args.examples,
95
+ "steps": args.steps,
96
+ "seed": args.seed,
97
+ "rmse_paffinity": rmse,
98
+ "mae_paffinity": mae,
99
+ "acceptance_max_rmse_paffinity": args.max_rmse,
100
+ "passed": rmse <= args.max_rmse,
101
+ "target_scaler": {"mean": scaler.mean, "std": scaler.std},
102
+ "history": history,
103
+ "scope": f"trainable head over {embedding_scope}",
104
+ }
105
+ args.output.parent.mkdir(parents=True, exist_ok=True)
106
+ args.output.write_text(json.dumps(report, indent=2) + "\n")
107
+ print(json.dumps(report, indent=2))
108
+ if not report["passed"]:
109
+ raise SystemExit(1)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
recovery/scripts/prepare_sample.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Create a deterministic, deduplicated DTA development sample and split manifests."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import hashlib
8
+ import json
9
+ import math
10
+ import random
11
+ import statistics
12
+ from collections import Counter, defaultdict
13
+ from pathlib import Path
14
+
15
+ import pyarrow.parquet as pq
16
+
17
+ from mitointeract_recovery import micromolar_to_paffinity
18
+ from mitointeract_recovery.chemistry import canonicalize_smiles, scaffold_id, stable_id
19
+ from mitointeract_recovery.splits import (
20
+ assign_grouped_splits,
21
+ assign_random_splits,
22
+ assert_group_disjoint,
23
+ )
24
+
25
+ COLUMNS = ["seq", "smiles", "smiles_can", "affinity_uM", "neg_log10_affinity_M"]
26
+
27
+
28
+ def stable_pair_id(sequence: str, smiles: str) -> str:
29
+ return stable_id("pair", f"{sequence}\0{smiles}")
30
+
31
+
32
+ def sample_raw_rows(
33
+ parquet_path: Path, candidate_count: int, seed: int
34
+ ) -> tuple[list[dict], int]:
35
+ parquet = pq.ParquetFile(parquet_path)
36
+ total_rows = parquet.metadata.num_rows
37
+ if candidate_count > total_rows:
38
+ candidate_count = total_rows
39
+ wanted = sorted(random.Random(seed).sample(range(total_rows), candidate_count))
40
+ selected: list[dict] = []
41
+ pointer = 0
42
+ offset = 0
43
+
44
+ for batch in parquet.iter_batches(batch_size=8192, columns=COLUMNS):
45
+ batch_end = offset + batch.num_rows
46
+ local_indices: list[int] = []
47
+ while pointer < len(wanted) and wanted[pointer] < batch_end:
48
+ local_indices.append(wanted[pointer] - offset)
49
+ pointer += 1
50
+ if local_indices:
51
+ data = batch.to_pydict()
52
+ for local in local_indices:
53
+ selected.append({column: data[column][local] for column in COLUMNS})
54
+ offset = batch_end
55
+ if pointer == len(wanted):
56
+ break
57
+
58
+ if len(selected) != candidate_count:
59
+ raise RuntimeError(
60
+ f"requested {candidate_count} rows but selected {len(selected)}"
61
+ )
62
+ return selected, total_rows
63
+
64
+
65
+ def normalize_and_deduplicate(
66
+ raw_rows: list[dict], sample_size: int, seed: int
67
+ ) -> tuple[list[dict], Counter]:
68
+ rejected = Counter()
69
+ pairs: dict[str, dict] = {}
70
+ targets: dict[str, list[float]] = defaultdict(list)
71
+
72
+ for raw in raw_rows:
73
+ sequence = "".join(str(raw.get("seq") or "").split()).upper()
74
+ source_smiles = str(raw.get("smiles_can") or raw.get("smiles") or "").strip()
75
+ if not sequence:
76
+ rejected["empty_sequence"] += 1
77
+ continue
78
+ try:
79
+ canonical = canonicalize_smiles(source_smiles)
80
+ except ValueError:
81
+ rejected["invalid_smiles"] += 1
82
+ continue
83
+ try:
84
+ affinity_um = float(raw["affinity_uM"])
85
+ published_paffinity = float(raw["neg_log10_affinity_M"])
86
+ calculated_paffinity = micromolar_to_paffinity(affinity_um)
87
+ except (TypeError, ValueError, OverflowError):
88
+ rejected["invalid_affinity"] += 1
89
+ continue
90
+ if (
91
+ not math.isfinite(published_paffinity)
92
+ or abs(calculated_paffinity - published_paffinity) > 1e-4
93
+ ):
94
+ rejected["unit_mismatch"] += 1
95
+ continue
96
+
97
+ pair_id = stable_pair_id(sequence, canonical)
98
+ if pair_id not in pairs:
99
+ pairs[pair_id] = {
100
+ "pair_id": pair_id,
101
+ "protein_id": stable_id("protein", sequence),
102
+ "ligand_id": stable_id("ligand", canonical),
103
+ "scaffold_id": scaffold_id(canonical),
104
+ "sequence": sequence,
105
+ "smiles": canonical,
106
+ "protein_length": len(sequence),
107
+ "smiles_length": len(canonical),
108
+ }
109
+ targets[pair_id].append(published_paffinity)
110
+
111
+ normalized: list[dict] = []
112
+ for pair_id, row in pairs.items():
113
+ values = targets[pair_id]
114
+ normalized.append(
115
+ {
116
+ **row,
117
+ "paffinity": statistics.median(values),
118
+ "replicate_count": len(values),
119
+ "replicate_paffinity_range": max(values) - min(values),
120
+ }
121
+ )
122
+
123
+ normalized.sort(
124
+ key=lambda row: hashlib.sha256(f"{seed}:{row['pair_id']}".encode()).hexdigest()
125
+ )
126
+ if len(normalized) < sample_size:
127
+ raise RuntimeError(
128
+ f"only {len(normalized)} valid unique pairs remained; increase --candidate-multiplier"
129
+ )
130
+ return normalized[:sample_size], rejected
131
+
132
+
133
+ def write_jsonl(path: Path, rows: list[dict]) -> None:
134
+ path.parent.mkdir(parents=True, exist_ok=True)
135
+ with path.open("w") as handle:
136
+ for row in rows:
137
+ handle.write(json.dumps(row, sort_keys=True) + "\n")
138
+
139
+
140
+ def target_stats(rows: list[dict], assignments: dict[str, str]) -> dict:
141
+ result = {}
142
+ for split in ("train", "validation", "test"):
143
+ values = [
144
+ row["paffinity"] for row in rows if assignments[row["pair_id"]] == split
145
+ ]
146
+ result[split] = {
147
+ "rows": len(values),
148
+ "mean_paffinity": statistics.mean(values),
149
+ "std_paffinity": statistics.stdev(values) if len(values) > 1 else 0.0,
150
+ "min_paffinity": min(values),
151
+ "max_paffinity": max(values),
152
+ }
153
+ return result
154
+
155
+
156
+ def main() -> None:
157
+ parser = argparse.ArgumentParser()
158
+ parser.add_argument("--parquet", type=Path, required=True)
159
+ parser.add_argument(
160
+ "--dataset-revision",
161
+ default="11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b",
162
+ )
163
+ parser.add_argument("--sample-size", type=int, default=10_000)
164
+ parser.add_argument("--candidate-multiplier", type=int, default=3)
165
+ parser.add_argument("--seed", type=int, default=42)
166
+ parser.add_argument("--output-dir", type=Path, default=Path("artifacts/dev-10k"))
167
+ args = parser.parse_args()
168
+ if args.sample_size < 100:
169
+ raise ValueError("sample size must be at least 100")
170
+
171
+ raw_rows, source_rows = sample_raw_rows(
172
+ args.parquet,
173
+ args.sample_size * args.candidate_multiplier,
174
+ args.seed,
175
+ )
176
+ rows, rejected = normalize_and_deduplicate(raw_rows, args.sample_size, args.seed)
177
+
178
+ manifests = {
179
+ "random_pair": assign_random_splits(rows, seed=args.seed),
180
+ "cold_protein_exact": assign_grouped_splits(rows, "protein_id", seed=args.seed),
181
+ "cold_ligand_scaffold": assign_grouped_splits(
182
+ rows, "scaffold_id", seed=args.seed
183
+ ),
184
+ }
185
+ assert_group_disjoint(rows, manifests["cold_protein_exact"], "protein_id")
186
+ assert_group_disjoint(rows, manifests["cold_ligand_scaffold"], "scaffold_id")
187
+
188
+ args.output_dir.mkdir(parents=True, exist_ok=True)
189
+ write_jsonl(args.output_dir / "sample.jsonl", rows)
190
+ for name, assignments in manifests.items():
191
+ write_jsonl(
192
+ args.output_dir / f"split-{name}.jsonl",
193
+ [
194
+ {"pair_id": pair_id, "split": split}
195
+ for pair_id, split in sorted(assignments.items())
196
+ ],
197
+ )
198
+
199
+ with args.parquet.open("rb") as handle:
200
+ source_sha256 = hashlib.file_digest(handle, "sha256").hexdigest()
201
+ audit = {
202
+ "source_parquet": str(args.parquet),
203
+ "source_sha256": source_sha256,
204
+ "dataset": "jglaser/binding_affinity",
205
+ "dataset_revision": args.dataset_revision,
206
+ "source_rows": source_rows,
207
+ "candidate_rows": len(raw_rows),
208
+ "sample_rows": len(rows),
209
+ "seed": args.seed,
210
+ "target": {"column": "neg_log10_affinity_M", "unit": "pAffinity"},
211
+ "rejected": dict(rejected),
212
+ "unique_proteins": len({row["protein_id"] for row in rows}),
213
+ "unique_ligands": len({row["ligand_id"] for row in rows}),
214
+ "unique_scaffold_groups": len({row["scaffold_id"] for row in rows}),
215
+ "duplicate_measurements_in_sample": sum(
216
+ row["replicate_count"] - 1 for row in rows
217
+ ),
218
+ "protein_length": {
219
+ "min": min(row["protein_length"] for row in rows),
220
+ "median": statistics.median(row["protein_length"] for row in rows),
221
+ "max": max(row["protein_length"] for row in rows),
222
+ "over_512": sum(row["protein_length"] > 512 for row in rows),
223
+ },
224
+ "splits": {
225
+ name: target_stats(rows, assignments)
226
+ for name, assignments in manifests.items()
227
+ },
228
+ "limitations": [
229
+ "the source target can combine Ki, Kd, IC50, and EC50; pAffinity is not pKd",
230
+ "assay type, relation operator, and source provenance are absent from the combined Parquet",
231
+ "cold_protein_exact is entity-disjoint but not sequence-similarity-clustered",
232
+ "scaffold groups use Bemis-Murcko with exact-molecule fallback for acyclic ligands",
233
+ "this development sample is not an external biological benchmark",
234
+ ],
235
+ }
236
+ (args.output_dir / "audit.json").write_text(json.dumps(audit, indent=2) + "\n")
237
+ print(json.dumps(audit, indent=2))
238
+
239
+
240
+ if __name__ == "__main__":
241
+ main()
recovery/scripts/run_baselines.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Benchmark leakage-auditing character n-gram ridge baselines."""
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
+
12
+ import numpy as np
13
+ from scipy.sparse import hstack
14
+ from sklearn.feature_extraction.text import TfidfVectorizer
15
+ from sklearn.linear_model import Ridge
16
+
17
+ from mitointeract_recovery.metrics import regression_metrics
18
+
19
+ ALPHAS = (0.1, 1.0, 10.0, 100.0)
20
+
21
+
22
+ def read_jsonl(path: Path) -> list[dict]:
23
+ with path.open() as handle:
24
+ return [json.loads(line) for line in handle if line.strip()]
25
+
26
+
27
+ def read_manifest(path: Path) -> dict[str, str]:
28
+ return {row["pair_id"]: row["split"] for row in read_jsonl(path)}
29
+
30
+
31
+ def partition(rows: list[dict], manifest: dict[str, str]) -> dict[str, list[dict]]:
32
+ result = {"train": [], "validation": [], "test": []}
33
+ for row in rows:
34
+ result[manifest[row["pair_id"]]].append(row)
35
+ return result
36
+
37
+
38
+ def targets(rows: list[dict]) -> np.ndarray:
39
+ return np.asarray([row["paffinity"] for row in rows], dtype=np.float64)
40
+
41
+
42
+ def select_ridge(
43
+ train_x,
44
+ train_y: np.ndarray,
45
+ validation_x,
46
+ validation_y: np.ndarray,
47
+ ) -> tuple[Ridge, float, list[dict]]:
48
+ trials = []
49
+ best = None
50
+ for alpha in ALPHAS:
51
+ model = Ridge(alpha=alpha, solver="lsqr", tol=1e-4)
52
+ model.fit(train_x, train_y)
53
+ predictions = model.predict(validation_x)
54
+ metrics = regression_metrics(validation_y, predictions)
55
+ trials.append({"alpha": alpha, "metrics": metrics})
56
+ if best is None or metrics["rmse"] < best[0]:
57
+ best = (metrics["rmse"], model, alpha)
58
+ return best[1], best[2], trials
59
+
60
+
61
+ def evaluate_feature_set(
62
+ name: str,
63
+ train_x,
64
+ validation_x,
65
+ test_x,
66
+ train_y: np.ndarray,
67
+ validation_y: np.ndarray,
68
+ test_y: np.ndarray,
69
+ ) -> dict:
70
+ started = time.monotonic()
71
+ model, alpha, trials = select_ridge(train_x, train_y, validation_x, validation_y)
72
+ return {
73
+ "name": name,
74
+ "selected_alpha": alpha,
75
+ "validation_trials": trials,
76
+ "validation": regression_metrics(validation_y, model.predict(validation_x)),
77
+ "test": regression_metrics(test_y, model.predict(test_x)),
78
+ "fit_and_eval_seconds": time.monotonic() - started,
79
+ }
80
+
81
+
82
+ def benchmark_split(rows: list[dict], manifest_path: Path) -> dict:
83
+ manifest = read_manifest(manifest_path)
84
+ splits = partition(rows, manifest)
85
+ train_y = targets(splits["train"])
86
+ validation_y = targets(splits["validation"])
87
+ test_y = targets(splits["test"])
88
+
89
+ mean = float(train_y.mean())
90
+ result = {
91
+ "rows": {name: len(values) for name, values in splits.items()},
92
+ "mean_baseline": {
93
+ "prediction": mean,
94
+ "validation": regression_metrics(
95
+ validation_y, np.full_like(validation_y, mean)
96
+ ),
97
+ "test": regression_metrics(test_y, np.full_like(test_y, mean)),
98
+ },
99
+ }
100
+
101
+ protein_vectorizer = TfidfVectorizer(
102
+ analyzer="char",
103
+ ngram_range=(3, 3),
104
+ lowercase=False,
105
+ min_df=2,
106
+ max_features=4096,
107
+ sublinear_tf=True,
108
+ dtype=np.float32,
109
+ )
110
+ ligand_vectorizer = TfidfVectorizer(
111
+ analyzer="char",
112
+ ngram_range=(2, 5),
113
+ lowercase=False,
114
+ min_df=2,
115
+ max_features=4096,
116
+ sublinear_tf=True,
117
+ dtype=np.float32,
118
+ )
119
+
120
+ protein_train = protein_vectorizer.fit_transform(
121
+ [row["sequence"] for row in splits["train"]]
122
+ )
123
+ protein_validation = protein_vectorizer.transform(
124
+ [row["sequence"] for row in splits["validation"]]
125
+ )
126
+ protein_test = protein_vectorizer.transform(
127
+ [row["sequence"] for row in splits["test"]]
128
+ )
129
+ ligand_train = ligand_vectorizer.fit_transform(
130
+ [row["smiles"] for row in splits["train"]]
131
+ )
132
+ ligand_validation = ligand_vectorizer.transform(
133
+ [row["smiles"] for row in splits["validation"]]
134
+ )
135
+ ligand_test = ligand_vectorizer.transform([row["smiles"] for row in splits["test"]])
136
+
137
+ result["feature_dimensions"] = {
138
+ "protein": protein_train.shape[1],
139
+ "ligand": ligand_train.shape[1],
140
+ }
141
+ result["models"] = [
142
+ evaluate_feature_set(
143
+ "protein_char3_ridge",
144
+ protein_train,
145
+ protein_validation,
146
+ protein_test,
147
+ train_y,
148
+ validation_y,
149
+ test_y,
150
+ ),
151
+ evaluate_feature_set(
152
+ "ligand_char2_5_ridge",
153
+ ligand_train,
154
+ ligand_validation,
155
+ ligand_test,
156
+ train_y,
157
+ validation_y,
158
+ test_y,
159
+ ),
160
+ evaluate_feature_set(
161
+ "combined_char_ridge",
162
+ hstack([protein_train, ligand_train], format="csr"),
163
+ hstack([protein_validation, ligand_validation], format="csr"),
164
+ hstack([protein_test, ligand_test], format="csr"),
165
+ train_y,
166
+ validation_y,
167
+ test_y,
168
+ ),
169
+ ]
170
+ return result
171
+
172
+
173
+ def main() -> None:
174
+ parser = argparse.ArgumentParser()
175
+ parser.add_argument("--data-dir", type=Path, default=Path("artifacts/dev-10k"))
176
+ parser.add_argument(
177
+ "--output", type=Path, default=Path("artifacts/dev-10k/baselines.json")
178
+ )
179
+ args = parser.parse_args()
180
+ rows = read_jsonl(args.data_dir / "sample.jsonl")
181
+
182
+ started = time.monotonic()
183
+ report = {
184
+ "sample_rows": len(rows),
185
+ "target": "pAffinity",
186
+ "packages": {
187
+ package: importlib.metadata.version(package)
188
+ for package in ("numpy", "scipy", "scikit-learn")
189
+ },
190
+ "splits": {},
191
+ }
192
+ for manifest_path in sorted(args.data_dir.glob("split-*.jsonl")):
193
+ split_name = manifest_path.stem.removeprefix("split-")
194
+ report["splits"][split_name] = benchmark_split(rows, manifest_path)
195
+ report["total_seconds"] = time.monotonic() - started
196
+
197
+ args.output.parent.mkdir(parents=True, exist_ok=True)
198
+ args.output.write_text(json.dumps(report, indent=2) + "\n")
199
+ print(json.dumps(report, indent=2))
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
recovery/scripts/train_head.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train and evaluate the MitoInteract v2 head over cached frozen embeddings."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import copy
8
+ import json
9
+ import math
10
+ import random
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import torch
15
+ from safetensors.torch import save_file
16
+ from torch.utils.data import DataLoader, TensorDataset
17
+
18
+ from mitointeract_recovery.metrics import regression_metrics
19
+ from mitointeract_recovery.model import MitoInteractHead, TargetScaler
20
+
21
+
22
+ def read_manifest(path: Path) -> dict[str, str]:
23
+ with path.open() as handle:
24
+ return {
25
+ row["pair_id"]: row["split"]
26
+ for row in (json.loads(line) for line in handle if line.strip())
27
+ }
28
+
29
+
30
+ def evaluate(
31
+ model: MitoInteractHead,
32
+ protein: torch.Tensor,
33
+ ligand: torch.Tensor,
34
+ targets: torch.Tensor,
35
+ indices: np.ndarray,
36
+ scaler: TargetScaler,
37
+ ) -> dict:
38
+ model.eval()
39
+ with torch.inference_mode():
40
+ prediction = (
41
+ scaler.decode(model(protein[indices], ligand[indices])).cpu().numpy()
42
+ )
43
+ return regression_metrics(targets[indices].cpu().numpy(), prediction)
44
+
45
+
46
+ def main() -> None:
47
+ parser = argparse.ArgumentParser()
48
+ parser.add_argument("--embeddings", type=Path, required=True)
49
+ parser.add_argument("--manifest", type=Path, required=True)
50
+ parser.add_argument("--output-dir", type=Path, default=Path("artifacts/v2-head"))
51
+ parser.add_argument("--epochs", type=int, default=100)
52
+ parser.add_argument("--batch-size", type=int, default=256)
53
+ parser.add_argument("--learning-rate", type=float, default=1e-3)
54
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
55
+ parser.add_argument("--warmup-ratio", type=float, default=0.05)
56
+ parser.add_argument("--patience", type=int, default=10)
57
+ parser.add_argument("--seed", type=int, default=42)
58
+ parser.add_argument("--projection-dim", type=int, default=256)
59
+ parser.add_argument("--hidden-dim", type=int, default=512)
60
+ parser.add_argument("--dropout", type=float, default=0.1)
61
+ parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
62
+ args = parser.parse_args()
63
+
64
+ random.seed(args.seed)
65
+ np.random.seed(args.seed)
66
+ torch.manual_seed(args.seed)
67
+ device_name = (
68
+ "cuda" if args.device == "auto" and torch.cuda.is_available() else args.device
69
+ )
70
+ if device_name == "auto":
71
+ device_name = "cpu"
72
+ device = torch.device(device_name)
73
+
74
+ arrays = np.load(args.embeddings)
75
+ pair_ids = arrays["pair_ids"].astype(str)
76
+ protein = torch.from_numpy(arrays["protein"]).float().to(device)
77
+ ligand = torch.from_numpy(arrays["ligand"]).float().to(device)
78
+ targets = torch.from_numpy(arrays["paffinity"]).float().to(device)
79
+ manifest = read_manifest(args.manifest)
80
+ split_indices = {
81
+ split: np.asarray(
82
+ [
83
+ index
84
+ for index, pair_id in enumerate(pair_ids)
85
+ if manifest.get(pair_id) == split
86
+ ]
87
+ )
88
+ for split in ("train", "validation", "test")
89
+ }
90
+ if any(not len(indices) for indices in split_indices.values()):
91
+ raise ValueError(
92
+ f"all splits must be non-empty: { {k: len(v) for k, v in split_indices.items()} }"
93
+ )
94
+
95
+ scaler = TargetScaler.fit(targets[split_indices["train"]])
96
+ encoded_targets = scaler.encode(targets)
97
+ train_dataset = TensorDataset(
98
+ protein[split_indices["train"]],
99
+ ligand[split_indices["train"]],
100
+ encoded_targets[split_indices["train"]],
101
+ )
102
+ generator = torch.Generator().manual_seed(args.seed)
103
+ loader = DataLoader(
104
+ train_dataset,
105
+ batch_size=min(args.batch_size, len(train_dataset)),
106
+ shuffle=True,
107
+ generator=generator,
108
+ )
109
+
110
+ model = MitoInteractHead(
111
+ protein.shape[1],
112
+ ligand.shape[1],
113
+ projection_dim=args.projection_dim,
114
+ hidden_dim=args.hidden_dim,
115
+ dropout=args.dropout,
116
+ ).to(device)
117
+ optimizer = torch.optim.AdamW(
118
+ model.parameters(),
119
+ lr=args.learning_rate,
120
+ weight_decay=args.weight_decay,
121
+ )
122
+ total_steps = max(1, args.epochs * len(loader))
123
+ warmup_steps = max(1, round(total_steps * args.warmup_ratio))
124
+
125
+ def lr_factor(step: int) -> float:
126
+ if step < warmup_steps:
127
+ return (step + 1) / warmup_steps
128
+ progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
129
+ return 0.5 * (1 + math.cos(math.pi * progress))
130
+
131
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_factor)
132
+ best_state = None
133
+ best_validation = math.inf
134
+ best_epoch = 0
135
+ remaining_patience = args.patience
136
+ history = []
137
+
138
+ for epoch in range(1, args.epochs + 1):
139
+ model.train()
140
+ losses = []
141
+ for batch_protein, batch_ligand, batch_target in loader:
142
+ optimizer.zero_grad(set_to_none=True)
143
+ prediction = model(batch_protein, batch_ligand)
144
+ loss = torch.nn.functional.mse_loss(prediction, batch_target)
145
+ loss.backward()
146
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
147
+ optimizer.step()
148
+ scheduler.step()
149
+ losses.append(float(loss.detach()))
150
+
151
+ validation = evaluate(
152
+ model,
153
+ protein,
154
+ ligand,
155
+ targets,
156
+ split_indices["validation"],
157
+ scaler,
158
+ )
159
+ history.append(
160
+ {
161
+ "epoch": epoch,
162
+ "train_standardized_mse": float(np.mean(losses)),
163
+ "validation": validation,
164
+ "learning_rate": optimizer.param_groups[0]["lr"],
165
+ }
166
+ )
167
+ if validation["rmse"] < best_validation:
168
+ best_validation = validation["rmse"]
169
+ best_epoch = epoch
170
+ best_state = copy.deepcopy(
171
+ {key: value.detach().cpu() for key, value in model.state_dict().items()}
172
+ )
173
+ remaining_patience = args.patience
174
+ else:
175
+ remaining_patience -= 1
176
+ if remaining_patience <= 0:
177
+ break
178
+
179
+ model.load_state_dict(best_state)
180
+ test_metrics = evaluate(
181
+ model, protein, ligand, targets, split_indices["test"], scaler
182
+ )
183
+ validation_metrics = evaluate(
184
+ model, protein, ligand, targets, split_indices["validation"], scaler
185
+ )
186
+ args.output_dir.mkdir(parents=True, exist_ok=True)
187
+ save_file(best_state, args.output_dir / "model.safetensors")
188
+
189
+ encoder_metadata_path = args.embeddings.with_suffix(".json")
190
+ encoder_metadata = (
191
+ json.loads(encoder_metadata_path.read_text())
192
+ if encoder_metadata_path.exists()
193
+ else None
194
+ )
195
+ report = {
196
+ "embedding_file": str(args.embeddings),
197
+ "manifest": str(args.manifest),
198
+ "rows": {name: len(indices) for name, indices in split_indices.items()},
199
+ "seed": args.seed,
200
+ "device": str(device),
201
+ "best_epoch": best_epoch,
202
+ "target_scaler": {"mean": scaler.mean, "std": scaler.std},
203
+ "head": {
204
+ "protein_dim": int(protein.shape[1]),
205
+ "ligand_dim": int(ligand.shape[1]),
206
+ "projection_dim": args.projection_dim,
207
+ "hidden_dim": args.hidden_dim,
208
+ "dropout": args.dropout,
209
+ },
210
+ "optimizer": {
211
+ "name": "AdamW",
212
+ "learning_rate": args.learning_rate,
213
+ "weight_decay": args.weight_decay,
214
+ "warmup_ratio": args.warmup_ratio,
215
+ "warmup_steps": warmup_steps,
216
+ "total_steps": total_steps,
217
+ },
218
+ "validation": validation_metrics,
219
+ "test": test_metrics,
220
+ "encoders": encoder_metadata,
221
+ "history": history,
222
+ }
223
+ (args.output_dir / "report.json").write_text(json.dumps(report, indent=2) + "\n")
224
+ (args.output_dir / "config.json").write_text(
225
+ json.dumps(
226
+ {
227
+ "format": "MitoInteract-v2-head",
228
+ "target": "pAffinity",
229
+ "target_scaler": report["target_scaler"],
230
+ "head": report["head"],
231
+ "encoders": encoder_metadata,
232
+ },
233
+ indent=2,
234
+ )
235
+ + "\n"
236
+ )
237
+ print(
238
+ json.dumps(
239
+ {key: value for key, value in report.items() if key != "history"}, indent=2
240
+ )
241
+ )
242
+
243
+
244
+ if __name__ == "__main__":
245
+ main()
recovery/src/mitointeract_recovery/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
- from .affinity import micromolar_to_pkd, pkd_to_micromolar
2
 
3
- __all__ = ["micromolar_to_pkd", "pkd_to_micromolar"]
 
1
+ from .affinity import micromolar_to_paffinity, paffinity_to_micromolar
2
 
3
+ __all__ = ["micromolar_to_paffinity", "paffinity_to_micromolar"]
recovery/src/mitointeract_recovery/affinity.py CHANGED
@@ -1,18 +1,17 @@
1
- """Binding-affinity unit conversions used by MitoInteract."""
2
- from __future__ import annotations
3
 
4
  import math
5
 
6
 
7
- def micromolar_to_pkd(affinity_um: float) -> float:
8
- """Convert dissociation constant in micromolar to pKd."""
9
  if not math.isfinite(affinity_um) or affinity_um <= 0:
10
- raise ValueError("affinity_um must be a positive finite number")
11
  return -math.log10(affinity_um * 1e-6)
12
 
13
 
14
- def pkd_to_micromolar(pkd: float) -> float:
15
- """Convert pKd to dissociation constant in micromolar."""
16
- if not math.isfinite(pkd):
17
- raise ValueError("pkd must be finite")
18
- return (10 ** (-pkd)) * 1e6
 
1
+ """Conversions for the dataset's generic molar affinity scale."""
 
2
 
3
  import math
4
 
5
 
6
+ def micromolar_to_paffinity(affinity_um: float) -> float:
7
+ """Convert a positive micromolar affinity value to -log10(value in M)."""
8
  if not math.isfinite(affinity_um) or affinity_um <= 0:
9
+ raise ValueError("affinity_um must be finite and greater than zero")
10
  return -math.log10(affinity_um * 1e-6)
11
 
12
 
13
+ def paffinity_to_micromolar(paffinity: float) -> float:
14
+ """Convert generic pAffinity to a micromolar affinity value."""
15
+ if not math.isfinite(paffinity):
16
+ raise ValueError("paffinity must be finite")
17
+ return (10 ** (-paffinity)) * 1e6
recovery/src/mitointeract_recovery/chemistry.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ligand canonicalization and grouping helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from rdkit import Chem
8
+ from rdkit.Chem.Scaffolds import MurckoScaffold
9
+
10
+
11
+ def canonicalize_smiles(smiles: str) -> str:
12
+ """Return isomeric canonical SMILES or raise for an invalid molecule."""
13
+ molecule = Chem.MolFromSmiles(smiles)
14
+ if molecule is None:
15
+ raise ValueError(f"invalid SMILES: {smiles!r}")
16
+ return Chem.MolToSmiles(molecule, canonical=True, isomericSmiles=True)
17
+
18
+
19
+ def stable_id(namespace: str, value: str) -> str:
20
+ digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:20]
21
+ return f"{namespace}_{digest}"
22
+
23
+
24
+ def scaffold_id(smiles: str) -> str:
25
+ """Return a stable Bemis-Murcko scaffold group, preserving acyclic diversity."""
26
+ canonical = canonicalize_smiles(smiles)
27
+ molecule = Chem.MolFromSmiles(canonical)
28
+ scaffold = MurckoScaffold.GetScaffoldForMol(molecule)
29
+ scaffold_smiles = Chem.MolToSmiles(scaffold, canonical=True, isomericSmiles=True)
30
+ if scaffold_smiles:
31
+ return stable_id("scaffold", scaffold_smiles)
32
+ # Empty Murcko scaffolds are common for acyclic molecules. Treat each
33
+ # canonical molecule as its own group instead of collapsing all of them.
34
+ return stable_id("acyclic", canonical)
recovery/src/mitointeract_recovery/metrics.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression metrics with JSON-safe handling for undefined correlations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Sequence
7
+
8
+ import numpy as np
9
+ from scipy.stats import pearsonr, spearmanr
10
+ from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score
11
+
12
+
13
+ def _correlation(function, y_true: np.ndarray, y_pred: np.ndarray) -> float | None:
14
+ if len(y_true) < 2 or np.ptp(y_true) == 0 or np.ptp(y_pred) == 0:
15
+ return None
16
+ value = float(function(y_true, y_pred).statistic)
17
+ return value if math.isfinite(value) else None
18
+
19
+
20
+ def regression_metrics(
21
+ y_true: Sequence[float], y_pred: Sequence[float]
22
+ ) -> dict[str, float | None]:
23
+ truth = np.asarray(y_true, dtype=np.float64)
24
+ predicted = np.asarray(y_pred, dtype=np.float64)
25
+ if truth.shape != predicted.shape or truth.ndim != 1 or not len(truth):
26
+ raise ValueError(
27
+ "y_true and y_pred must be non-empty one-dimensional sequences of equal length"
28
+ )
29
+ if not np.isfinite(truth).all() or not np.isfinite(predicted).all():
30
+ raise ValueError("metrics inputs must be finite")
31
+ return {
32
+ "rmse": float(math.sqrt(mean_squared_error(truth, predicted))),
33
+ "mae": float(mean_absolute_error(truth, predicted)),
34
+ "pearson_r": _correlation(pearsonr, truth, predicted),
35
+ "spearman_r": _correlation(spearmanr, truth, predicted),
36
+ "r2": float(r2_score(truth, predicted)) if len(truth) >= 2 else None,
37
+ }
recovery/src/mitointeract_recovery/model.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Corrected trainable MitoInteract v2 regression head.
2
+
3
+ The frozen protein and molecule encoders are intentionally kept outside this
4
+ module. Their pooled embeddings can be cached, versioned, and reused across
5
+ head experiments without serializing hundreds of megabytes of frozen weights.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import torch
13
+ from torch import nn
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class TargetScaler:
18
+ mean: float
19
+ std: float
20
+
21
+ @classmethod
22
+ def fit(cls, values: torch.Tensor) -> "TargetScaler":
23
+ mean = float(values.mean())
24
+ std = float(values.std(unbiased=False))
25
+ if not std > 0:
26
+ raise ValueError("target standard deviation must be positive")
27
+ return cls(mean=mean, std=std)
28
+
29
+ def encode(self, values: torch.Tensor) -> torch.Tensor:
30
+ return (values - self.mean) / self.std
31
+
32
+ def decode(self, values: torch.Tensor) -> torch.Tensor:
33
+ return values * self.std + self.mean
34
+
35
+
36
+ class MitoInteractHead(nn.Module):
37
+ """Fuse pooled protein/ligand embeddings and regress standardized pAffinity."""
38
+
39
+ def __init__(
40
+ self,
41
+ protein_dim: int,
42
+ ligand_dim: int,
43
+ *,
44
+ projection_dim: int = 256,
45
+ hidden_dim: int = 512,
46
+ dropout: float = 0.1,
47
+ ) -> None:
48
+ super().__init__()
49
+ self.protein_projection = nn.Sequential(
50
+ nn.LayerNorm(protein_dim),
51
+ nn.Linear(protein_dim, projection_dim),
52
+ nn.GELU(),
53
+ )
54
+ self.ligand_projection = nn.Sequential(
55
+ nn.LayerNorm(ligand_dim),
56
+ nn.Linear(ligand_dim, projection_dim),
57
+ nn.GELU(),
58
+ )
59
+ interaction_dim = projection_dim * 4
60
+ self.regressor = nn.Sequential(
61
+ nn.LayerNorm(interaction_dim),
62
+ nn.Linear(interaction_dim, hidden_dim),
63
+ nn.GELU(),
64
+ nn.Dropout(dropout),
65
+ nn.Linear(hidden_dim, hidden_dim // 2),
66
+ nn.GELU(),
67
+ nn.Dropout(dropout),
68
+ nn.Linear(hidden_dim // 2, 1),
69
+ )
70
+
71
+ def forward(
72
+ self, protein_embedding: torch.Tensor, ligand_embedding: torch.Tensor
73
+ ) -> torch.Tensor:
74
+ if protein_embedding.ndim != 2 or ligand_embedding.ndim != 2:
75
+ raise ValueError("encoder embeddings must be rank-2 [batch, features]")
76
+ if protein_embedding.shape[0] != ligand_embedding.shape[0]:
77
+ raise ValueError("protein and ligand batch sizes must match")
78
+ protein = self.protein_projection(protein_embedding)
79
+ ligand = self.ligand_projection(ligand_embedding)
80
+ interaction = torch.cat(
81
+ [protein, ligand, protein * ligand, torch.abs(protein - ligand)],
82
+ dim=-1,
83
+ )
84
+ return self.regressor(interaction).squeeze(-1)
recovery/src/mitointeract_recovery/splits.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic entity-disjoint split assignment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections import defaultdict
7
+ from collections.abc import Mapping, Sequence
8
+
9
+ SPLIT_RATIOS = {"train": 0.8, "validation": 0.1, "test": 0.1}
10
+
11
+
12
+ def _tie_breaker(seed: int, group: str) -> str:
13
+ return hashlib.sha256(f"{seed}:{group}".encode()).hexdigest()
14
+
15
+
16
+ def assign_random_splits(
17
+ rows: Sequence[Mapping[str, object]],
18
+ *,
19
+ id_key: str = "pair_id",
20
+ seed: int = 42,
21
+ ratios: Mapping[str, float] = SPLIT_RATIOS,
22
+ ) -> dict[str, str]:
23
+ """Assign individual rows deterministically with exact approximate counts."""
24
+ if abs(sum(ratios.values()) - 1.0) > 1e-9 or any(
25
+ value <= 0 for value in ratios.values()
26
+ ):
27
+ raise ValueError("split ratios must be positive and sum to 1")
28
+ ordered = sorted(
29
+ (str(row[id_key]) for row in rows),
30
+ key=lambda pair_id: _tie_breaker(seed, pair_id),
31
+ )
32
+ assignments: dict[str, str] = {}
33
+ cursor = 0
34
+ names = list(ratios)
35
+ for index, name in enumerate(names):
36
+ if index == len(names) - 1:
37
+ stop = len(ordered)
38
+ else:
39
+ stop = cursor + round(len(ordered) * ratios[name])
40
+ for pair_id in ordered[cursor:stop]:
41
+ assignments[pair_id] = name
42
+ cursor = stop
43
+ return assignments
44
+
45
+
46
+ def assign_grouped_splits(
47
+ rows: Sequence[Mapping[str, object]],
48
+ group_key: str,
49
+ *,
50
+ id_key: str = "pair_id",
51
+ seed: int = 42,
52
+ ratios: Mapping[str, float] = SPLIT_RATIOS,
53
+ ) -> dict[str, str]:
54
+ """Assign whole groups to train/validation/test with approximate row balance."""
55
+ if not rows:
56
+ return {}
57
+ if abs(sum(ratios.values()) - 1.0) > 1e-9 or any(
58
+ value <= 0 for value in ratios.values()
59
+ ):
60
+ raise ValueError("split ratios must be positive and sum to 1")
61
+
62
+ groups: dict[str, list[str]] = defaultdict(list)
63
+ for row in rows:
64
+ pair_id = str(row[id_key])
65
+ group = str(row[group_key])
66
+ groups[group].append(pair_id)
67
+
68
+ targets = {name: len(rows) * ratio for name, ratio in ratios.items()}
69
+ counts = {name: 0 for name in ratios}
70
+ assignments: dict[str, str] = {}
71
+ ordered = sorted(
72
+ groups.items(), key=lambda item: (-len(item[1]), _tie_breaker(seed, item[0]))
73
+ )
74
+
75
+ for _, pair_ids in ordered:
76
+ # Choose the split with the largest normalized deficit. Once all are
77
+ # over target, choose the least-filled split relative to its target.
78
+ def priority(name: str) -> tuple[float, float, str]:
79
+ deficit = (targets[name] - counts[name]) / targets[name]
80
+ fill = counts[name] / targets[name]
81
+ return deficit, -fill, name
82
+
83
+ selected = max(ratios, key=priority)
84
+ for pair_id in pair_ids:
85
+ assignments[pair_id] = selected
86
+ counts[selected] += len(pair_ids)
87
+
88
+ return assignments
89
+
90
+
91
+ def assert_group_disjoint(
92
+ rows: Sequence[Mapping[str, object]],
93
+ assignments: Mapping[str, str],
94
+ group_key: str,
95
+ *,
96
+ id_key: str = "pair_id",
97
+ ) -> None:
98
+ """Raise when an entity group occurs in more than one split."""
99
+ seen: dict[str, str] = {}
100
+ for row in rows:
101
+ pair_id = str(row[id_key])
102
+ group = str(row[group_key])
103
+ split = assignments[pair_id]
104
+ previous = seen.setdefault(group, split)
105
+ if previous != split:
106
+ raise AssertionError(
107
+ f"group {group!r} appears in both {previous!r} and {split!r}"
108
+ )
recovery/tests/test_affinity.py CHANGED
@@ -1,28 +1,31 @@
1
  import math
 
2
  import pytest
3
 
4
- from mitointeract_recovery import micromolar_to_pkd, pkd_to_micromolar
5
 
6
 
7
- def test_one_micromolar_is_pkd_six():
8
- assert micromolar_to_pkd(1.0) == pytest.approx(6.0)
9
 
10
 
11
  def test_dataset_example_500_micromolar():
12
- assert micromolar_to_pkd(500.0) == pytest.approx(3.3010299957)
13
 
14
 
15
- @pytest.mark.parametrize("pkd", [2.0, 3.3010299957, 6.0, 7.5850267, 9.0])
16
- def test_round_trip(pkd):
17
- assert micromolar_to_pkd(pkd_to_micromolar(pkd)) == pytest.approx(pkd)
 
 
18
 
19
 
20
- @pytest.mark.parametrize("bad", [0.0, -1.0, math.inf, -math.inf, math.nan])
21
- def test_invalid_micromolar_rejected(bad):
22
  with pytest.raises(ValueError):
23
- micromolar_to_pkd(bad)
24
 
25
 
26
- def test_nonfinite_pkd_rejected():
27
  with pytest.raises(ValueError):
28
- pkd_to_micromolar(math.nan)
 
1
  import math
2
+
3
  import pytest
4
 
5
+ from mitointeract_recovery import micromolar_to_paffinity, paffinity_to_micromolar
6
 
7
 
8
+ def test_one_micromolar_is_paffinity_six():
9
+ assert micromolar_to_paffinity(1.0) == pytest.approx(6.0)
10
 
11
 
12
  def test_dataset_example_500_micromolar():
13
+ assert micromolar_to_paffinity(500.0) == pytest.approx(3.3010299957)
14
 
15
 
16
+ @pytest.mark.parametrize("paffinity", [2.0, 3.3010299957, 6.0, 7.5850267, 9.0])
17
+ def test_round_trip(paffinity):
18
+ assert micromolar_to_paffinity(paffinity_to_micromolar(paffinity)) == pytest.approx(
19
+ paffinity
20
+ )
21
 
22
 
23
+ @pytest.mark.parametrize("bad", [0.0, -1.0, math.inf, math.nan])
24
+ def test_invalid_affinity_rejected(bad):
25
  with pytest.raises(ValueError):
26
+ micromolar_to_paffinity(bad)
27
 
28
 
29
+ def test_nonfinite_paffinity_rejected():
30
  with pytest.raises(ValueError):
31
+ paffinity_to_micromolar(math.nan)
recovery/tests/test_checked_reports.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ REPORTS = Path(__file__).parents[1] / "reports"
6
+
7
+
8
+ def load(name: str) -> dict:
9
+ return json.loads((REPORTS / name).read_text())
10
+
11
+
12
+ def model(report: dict, split: str, name: str) -> dict:
13
+ return next(
14
+ item for item in report["splits"][split]["models"] if item["name"] == name
15
+ )
16
+
17
+
18
+ def test_checked_sample_and_manifests():
19
+ audit = load("dev-10k-audit.json")
20
+ assert audit["dataset_revision"] == "11e49b7ece33d62afd7f65bc05ce60ad37f9ba7b"
21
+ assert (
22
+ audit["source_sha256"]
23
+ == "d50f4cae794298f32c39f75a9e8603528d20e50e4fa757025def412f5b2a78d8"
24
+ )
25
+ assert audit["sample_rows"] == 10_000
26
+ assert (
27
+ audit["external_split_audits"]["protein_similarity_50"][
28
+ "protein_cluster_overlap"
29
+ ]
30
+ == 0
31
+ )
32
+
33
+
34
+ def test_combined_baseline_beats_train_mean_on_every_checked_split():
35
+ report = load("dev-10k-baselines.json")
36
+ assert report["target"] == "pAffinity"
37
+ for split_name, split in report["splits"].items():
38
+ combined = model(report, split_name, "combined_char_ridge")["test"]
39
+ mean = split["mean_baseline"]["test"]
40
+ assert combined["rmse"] < mean["rmse"]
41
+ assert combined["pearson_r"] > 0
42
+ assert combined["spearman_r"] > 0
43
+
44
+
45
+ def test_similarity_split_passes_initial_recovery_gate():
46
+ report = load("dev-10k-baselines.json")
47
+ result = model(report, "protein_similarity_50", "combined_char_ridge")["test"]
48
+ assert result["pearson_r"] > 0.30
49
+ assert result["spearman_r"] > 0.30
50
+
51
+
52
+ def test_actual_encoder_overfit_sanity_passed():
53
+ report = load("overfit-actual-embeddings.json")
54
+ assert report["passed"] is True
55
+ assert report["rmse_paffinity"] <= report["acceptance_max_rmse_paffinity"]
recovery/tests/test_chemistry.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from mitointeract_recovery.chemistry import canonicalize_smiles, scaffold_id
4
+
5
+
6
+ def test_canonical_smiles_normalizes_equivalent_strings():
7
+ assert canonicalize_smiles("C(C)O") == canonicalize_smiles("CCO")
8
+
9
+
10
+ def test_bemis_murcko_scaffold_groups_ring_substitutions():
11
+ assert scaffold_id("Cc1ccccc1") == scaffold_id("Oc1ccccc1")
12
+
13
+
14
+ def test_acyclic_scaffolds_do_not_collapse_into_one_group():
15
+ assert scaffold_id("CCO") != scaffold_id("CCCC")
16
+
17
+
18
+ def test_invalid_smiles_rejected():
19
+ with pytest.raises(ValueError):
20
+ canonicalize_smiles("not-a-smiles")
recovery/tests/test_legacy_guard.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib.util
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ pytest.importorskip("torch")
7
+ pytest.importorskip("transformers")
8
+
9
+
10
+ def load_legacy_module():
11
+ path = Path(__file__).parents[2] / "model.py"
12
+ spec = importlib.util.spec_from_file_location("mitointeract_legacy_model", path)
13
+ assert spec is not None
14
+ assert spec.loader is not None
15
+ module = importlib.util.module_from_spec(spec)
16
+ spec.loader.exec_module(module)
17
+ return module
18
+
19
+
20
+ def test_legacy_pickle_loading_is_blocked_by_default():
21
+ legacy = load_legacy_module()
22
+ with pytest.raises(RuntimeError, match="unsafe pickle"):
23
+ legacy.load_model("not-opened.pt")
24
+
25
+
26
+ def test_legacy_output_cannot_be_called_pkd_by_default():
27
+ legacy = load_legacy_module()
28
+ with pytest.raises(RuntimeError, match="cannot be interpreted as pKd"):
29
+ legacy.predict_binding(None, "PROTEIN", "CC")
recovery/tests/test_metrics.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ from mitointeract_recovery.metrics import regression_metrics
4
+
5
+
6
+ def test_perfect_predictions():
7
+ metrics = regression_metrics([1.0, 2.0, 3.0], [1.0, 2.0, 3.0])
8
+ assert metrics["rmse"] == pytest.approx(0.0)
9
+ assert metrics["mae"] == pytest.approx(0.0)
10
+ assert metrics["pearson_r"] == pytest.approx(1.0)
11
+ assert metrics["spearman_r"] == pytest.approx(1.0)
12
+ assert metrics["r2"] == pytest.approx(1.0)
13
+
14
+
15
+ def test_constant_predictions_have_null_correlations():
16
+ metrics = regression_metrics([1.0, 2.0, 3.0], [2.0, 2.0, 2.0])
17
+ assert metrics["pearson_r"] is None
18
+ assert metrics["spearman_r"] is None
recovery/tests/test_model.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+
3
+ torch = pytest.importorskip("torch")
4
+ from torch import nn # noqa: E402
5
+
6
+ from mitointeract_recovery.model import MitoInteractHead, TargetScaler # noqa: E402
7
+
8
+
9
+ def test_head_output_shape():
10
+ model = MitoInteractHead(16, 12, projection_dim=8, hidden_dim=16, dropout=0.0)
11
+ output = model(torch.randn(4, 16), torch.randn(4, 12))
12
+ assert output.shape == (4,)
13
+
14
+
15
+ def test_head_uses_no_batch_norm():
16
+ model = MitoInteractHead(16, 12, projection_dim=8, hidden_dim=16)
17
+ assert not any(
18
+ isinstance(module, nn.modules.batchnorm._BatchNorm)
19
+ for module in model.modules()
20
+ )
21
+
22
+
23
+ def test_target_scaler_round_trip():
24
+ values = torch.tensor([3.0, 5.0, 7.0, 9.0])
25
+ scaler = TargetScaler.fit(values)
26
+ assert torch.allclose(scaler.decode(scaler.encode(values)), values)
27
+
28
+
29
+ def test_mismatched_batches_rejected():
30
+ model = MitoInteractHead(16, 12, projection_dim=8, hidden_dim=16)
31
+ with pytest.raises(ValueError):
32
+ model(torch.randn(3, 16), torch.randn(4, 12))
recovery/tests/test_splits.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from mitointeract_recovery.splits import (
2
+ assign_grouped_splits,
3
+ assign_random_splits,
4
+ assert_group_disjoint,
5
+ )
6
+
7
+
8
+ ROWS = [
9
+ {"pair_id": "a1", "protein_id": "p1", "scaffold_id": "s1"},
10
+ {"pair_id": "a2", "protein_id": "p1", "scaffold_id": "s2"},
11
+ {"pair_id": "b1", "protein_id": "p2", "scaffold_id": "s1"},
12
+ {"pair_id": "c1", "protein_id": "p3", "scaffold_id": "s3"},
13
+ {"pair_id": "d1", "protein_id": "p4", "scaffold_id": "s4"},
14
+ {"pair_id": "e1", "protein_id": "p5", "scaffold_id": "s5"},
15
+ ]
16
+
17
+
18
+ def test_random_split_has_expected_counts():
19
+ rows = [{"pair_id": str(index)} for index in range(100)]
20
+ assignments = assign_random_splits(rows, seed=42)
21
+ assert list(assignments.values()).count("train") == 80
22
+ assert list(assignments.values()).count("validation") == 10
23
+ assert list(assignments.values()).count("test") == 10
24
+
25
+
26
+ def test_grouped_split_is_deterministic():
27
+ first = assign_grouped_splits(ROWS, "protein_id", seed=42)
28
+ second = assign_grouped_splits(ROWS, "protein_id", seed=42)
29
+ assert first == second
30
+
31
+
32
+ def test_cold_protein_groups_are_disjoint():
33
+ assignments = assign_grouped_splits(ROWS, "protein_id", seed=42)
34
+ assert_group_disjoint(ROWS, assignments, "protein_id")
35
+
36
+
37
+ def test_cold_scaffold_groups_are_disjoint():
38
+ assignments = assign_grouped_splits(ROWS, "scaffold_id", seed=7)
39
+ assert_group_disjoint(ROWS, assignments, "scaffold_id")
40
+
41
+
42
+ def test_every_pair_is_assigned_once():
43
+ assignments = assign_grouped_splits(ROWS, "protein_id", seed=42)
44
+ assert set(assignments) == {row["pair_id"] for row in ROWS}
45
+ assert set(assignments.values()) <= {"train", "validation", "test"}
recovery/uv.lock CHANGED
The diff for this file is too large to render. See raw diff