diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..b9484c41f10c4000f09203433bb7c78a831de7e8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+*.pt filter=lfs diff=lfs merge=lfs -text
+*.pth filter=lfs diff=lfs merge=lfs -text
+*.ckpt filter=lfs diff=lfs merge=lfs -text
+*.pkl filter=lfs diff=lfs merge=lfs -text
+*.pickle filter=lfs diff=lfs merge=lfs -text
+*.npy filter=lfs diff=lfs merge=lfs -text
+*.npz filter=lfs diff=lfs merge=lfs -text
+*.bin filter=lfs diff=lfs merge=lfs -text
+*.tar.gz filter=lfs diff=lfs merge=lfs -text
+*.png filter=lfs diff=lfs merge=lfs -text
+*.jpg filter=lfs diff=lfs merge=lfs -text
+*.jpeg filter=lfs diff=lfs merge=lfs -text
+*.pdf filter=lfs diff=lfs merge=lfs -text
+*.svg -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..6e46fe976cb14e838e6805b2e075791d5dad66f1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+.pytest_cache/
+.coverage
+*.egg-info/
+
+# Env
+.env
+.venv/
+venv/
+env/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# IDE
+.idea/
+.vscode/
+*.swp
+
+# Logs / runs / caches
+*.log
+logs/
+outputs/
+wandb/
+.ipynb_checkpoints/
+
+# Local scoring outputs
+results/
+/tmp_*
+
+# Misc
+*.tmp
+*.bak
+.allogen_test
+.agent*_done.txt
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..0e5e4f75b91fcd2c68dc1a45cdb4db544ba26fda
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Hanqun Cao
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..eac506036fb27b2a1ddbb65d305648c0fc1f08b2
--- /dev/null
+++ b/README.md
@@ -0,0 +1,133 @@
+---
+license: mit
+tags:
+ - protein-design
+ - allosteric
+ - state-selectivity
+ - guided-generation
+ - rfdiffusion
+ - pxdesign
+ - proteina
+library_name: pytorch
+---
+
+# AlloGen
+
+
+
+
+
+State-selectivity scoring + guided generation for allosteric binder design.
+
+π§ͺ **One-click demo for biology users:**
+[](https://colab.research.google.com/#fileId=https%3A//huggingface.co/ChatterjeeLab/AlloGen/raw/main/notebooks/AlloGen_CaM_demo.ipynb) β score CaM binders and run Q_ΞΈ-guided PXDesign sampling in 5 minutes. Notebook lives at [`notebooks/AlloGen_CaM_demo.ipynb`](notebooks/AlloGen_CaM_demo.ipynb).
+
+AlloGen trains a scorer Q_ΞΈ(X, Y) β (0,1) that ranks how well a binder Y discriminates a target's **holo** (active) state XΒΉ from its **apo** (inactive) state Xβ°. The selectivity score is:
+
+ S(Y) = Q_ΞΈ(XΒΉ, Y) β Q_ΞΈ(Xβ°, Y)
+
+Q_ΞΈ serves as both a re-ranker (best-of-K) and a gradient signal for guided generation on top of frozen priors (RFdiffusion, PXDesign, Proteina-ComplexA) via Langevin, SMC, TDS, or classifier guidance.
+
+This repository accompanies the paper *AlloGen: State-Selective Scoring for Allosteric Binder Design* (NeurIPS 2026).
+
+## Installation
+
+```bash
+conda env create -f environment.yml
+conda activate allogen
+```
+
+Or pip-only:
+
+```bash
+python -m venv .venv && source .venv/bin/activate
+pip install -r requirements.txt
+```
+
+Python 3.10 + PyTorch 2.x are required. A CUDA GPU is recommended for guidance, but CPU works for scoring single designs.
+
+## Inference quickstart
+
+```bash
+# Score the bundled CaM inference sample against the v4-S2 (target-swap) checkpoint
+python code/scripts/evaluate.py \
+ --target cam \
+ --checkpoint checkpoints/Q_theta_phase2.pt \
+ --data_dir data/sample/ \
+ --outdir /tmp/cam_inference \
+ --no_wandb
+```
+
+See [`inference.md`](inference.md) for the scoring API + guidance command lines.
+
+## Repo layout
+
+```
+code/
+ data/ dataset / graph construction, PDB I/O, target YAMLs
+ models/ Q_ΞΈ scorer (graph transformer) + differentiable wrapper
+ trainers/ two-phase training loop (DockQ regression + selectivity)
+ utils/ PDB I/O, backbone frames, SAM optimizer
+ scripts/ evaluate, rescore, PXDesign guidance (see scripts/README.md)
+checkpoints/ Q_ΞΈ paper weights (v4-S2 target-swap split, via Git LFS)
+data/sample/ tiny CaM inference sample (test split only)
+```
+
+## Checkpoints
+
+Paper weights for the **v4-S2 target-swap** split are bundled via **Git LFS**:
+
+```bash
+git lfs install
+git lfs pull
+```
+
+| File | Use |
+|---|---|
+| `checkpoints/Q_theta_phase1.pt` | Phase 1 (DockQ regression) intermediate checkpoint |
+| `checkpoints/Q_theta_phase2.pt` | Phase 2 (selectivity) β main paper result |
+| `checkpoints/Q_theta_train_curve.csv` | Training curve metadata |
+
+## Scoring a single design
+
+```python
+import sys; sys.path.insert(0, 'code')
+from models.differentiable_features import DifferentiableQTheta
+
+scorer = DifferentiableQTheta(
+ checkpoint='checkpoints/Q_theta_phase2.pt',
+ device='cuda:0',
+)
+scorer.load_receptor(
+ holo_path='your_holo.pdb', rec_chain='A',
+ apo_path='your_apo.pdb', apo_chain='A',
+)
+q_holo = scorer.score('design.pdb', binder_chain='B', state='holo')
+q_apo = scorer.score('design.pdb', binder_chain='B', state='apo')
+print(f'S = {q_holo - q_apo:.3f}')
+```
+
+## Guidance methods
+
+The shipped guidance code wraps **PXDesign** as the prior and uses Q_ΞΈ as the gradient / classifier signal. All four method variants (Langevin, SMC, TDS, classifier guidance) live in `code/scripts/pxdesign_guidance/`.
+
+See [`inference.md`](inference.md) Β§3 for command lines.
+
+To deploy Q_ΞΈ with **RFdiffusion**, **Proteina-ComplexA**, or any other backbone prior, see [`code/scripts/README.md`](code/scripts/README.md) β Q_ΞΈ exposes `DifferentiableQTheta` for `β_x S(x)`, and the PXDesign code is a worked template to mirror.
+
+## Citation
+
+```bibtex
+@inproceedings{cao2026allogen,
+ title = {AlloGen: State-Selective Scoring for Allosteric Binder Design},
+ author = {Cao, Hanqun and others},
+ booktitle = {Advances in Neural Information Processing Systems (NeurIPS)},
+ year = {2026}
+}
+```
+
+(BibTeX key will be finalized at camera-ready.)
+
+## License
+
+MIT β see [`LICENSE`](LICENSE).
diff --git a/checkpoints/Q_theta_phase1.pt b/checkpoints/Q_theta_phase1.pt
new file mode 100644
index 0000000000000000000000000000000000000000..1aa6beb1171786585c350aaee743f174beb4ebc4
--- /dev/null
+++ b/checkpoints/Q_theta_phase1.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1684955f481c1406b12cc0e1ec3509a2a2e2def8b0a9071ec0c96be00d330e7c
+size 3617774
diff --git a/checkpoints/Q_theta_phase2.pt b/checkpoints/Q_theta_phase2.pt
new file mode 100644
index 0000000000000000000000000000000000000000..ccda611e09f1a3f7738b734c5faa37c8a8c11f71
--- /dev/null
+++ b/checkpoints/Q_theta_phase2.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:716e4716c0014a46cfd4a2e26c238486c8ad1e3a03809320247cfb734538f8c6
+size 3618158
diff --git a/checkpoints/Q_theta_train_curve.csv b/checkpoints/Q_theta_train_curve.csv
new file mode 100644
index 0000000000000000000000000000000000000000..c3a0ce29c7fd80512960be21a179931f807afcd8
--- /dev/null
+++ b/checkpoints/Q_theta_train_curve.csv
@@ -0,0 +1,16 @@
+epoch,loss,test_rho,cam_rho,rho_cam,rho_bcl2,rho_era,rho_mdm2,rho_ran,rho_a2a,rho_pai1,rho_integrin
+1,5.527279376983643,0.44785173068985473,0.48303455690607044,0.48303455690607044,0.5733270876635986,0.6666325520384175,0.655984079559599,0.4957646288057514,0.28072319745279745,0.32040567098387046,0.10694207210873281
+2,5.452569961547852,0.45406694660326774,0.4863130719981931,0.4863130719981931,0.5755749254666933,0.6683595793753047,0.6593234065151792,0.49658672598782,0.29627094377326,0.33010796670396186,0.11999895300572927
+3,5.449374318122864,0.44742845709394635,0.4966343232141348,0.4966343232141348,0.5758960451528496,0.6612355916106455,0.6531535587702088,0.4753625218665822,0.23926254059823043,0.33456826378855364,0.14331481175036578
+4,5.44292688369751,0.4461415166784438,0.49274867569754505,0.49274867569754505,0.5728775201029797,0.6614514700277563,0.6451799702468982,0.46946643826626333,0.242717595336111,0.34013213953325055,0.1445583242167464
+5,5.441892623901367,0.43839115930587513,0.49080585193925014,0.49080585193925014,0.5653633194469204,0.6586450506053149,0.6361543905961949,0.4544470120828291,0.21334963006412605,0.33976427997988223,0.1485997397324834
+6,5.439353764057159,0.4284122153367078,0.46931336411311275,0.46931336411311275,0.5655559912586142,0.6597244426908693,0.634838828707037,0.4423570277236172,0.19089177426790224,0.3362236317787114,0.1283926621537984
+7,5.439265787601471,0.42722475631179213,0.45923496586695794,0.45923496586695794,0.5661982306309269,0.6614514700277563,0.635386597507575,0.4425217082123247,0.20816704795730515,0.3344762989002116,0.1103617313912795
+8,5.439032256603241,0.423813755390132,0.45146367083377825,0.45146367083377825,0.5662624545681583,0.6633943757817542,0.6325423252001796,0.4388258604749022,0.2029844658504843,0.33617764933454036,0.09885924107725882
+9,5.435689151287079,0.4221364421714867,0.45122081786399143,0.45122081786399143,0.5656202151958456,0.660156199525091,0.6294276063720167,0.4378717142280125,0.19952941111260372,0.33502808823026414,0.09823748484406851
+10,5.43562650680542,0.419534099250895,0.4498851265301637,0.4498851265301637,0.5641430646395261,0.662099105279089,0.6279539020257997,0.4329763310874754,0.1822541374232008,0.33530398289529045,0.10165714412661521
+11,5.435124695301056,0.41942099211035866,0.451949376773352,0.451949376773352,0.5633723773927508,0.6612355916106455,0.6249514872613453,0.43204285159336153,0.1822541374232008,0.3360397020020272,0.10352241282618613
+12,5.440709412097931,0.41971570222256677,0.4518279502884586,0.4518279502884586,0.5642072885767574,0.6612355916106455,0.6261524531671271,0.43317581854869713,0.1822541374232008,0.33534996533946143,0.10352241282618613
+13,5.430392742156982,0.4198042517465396,0.4523136562280323,0.4523136562280323,0.5629228098321319,0.6627467405304217,0.6259874349510655,0.4334288217301594,0.1822541374232008,0.33525800045111936,0.10352241282618613
+14,5.436960756778717,0.4199312463250485,0.4515850973186718,0.4515850973186718,0.5639503928278323,0.6627467405304217,0.6264251916075623,0.4329262960644863,0.1822541374232008,0.3360397020020272,0.10352241282618613
+15,5.433559775352478,0.419866834088955,0.4515850973186718,0.4515850973186718,0.5635650492044447,0.6627467405304217,0.6264251916075623,0.432842324243296,0.1822541374232008,0.3359937195578562,0.10352241282618613
diff --git a/code/__init__.py b/code/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/code/data/__init__.py b/code/data/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/code/data/dataset.py b/code/data/dataset.py
new file mode 100644
index 0000000000000000000000000000000000000000..f702decb715536a25e2089f77edd281f6f9bceca
--- /dev/null
+++ b/code/data/dataset.py
@@ -0,0 +1,832 @@
+"""
+PyTorch Dataset for two-state complex scoring.
+
+Loads preprocessed graph data and provides batched tensors
+with padding for variable-sized interface graphs.
+"""
+
+import os
+import json
+import pickle
+import numpy as np
+import torch
+from torch.utils.data import Dataset, DataLoader
+
+# Global ESM embedding cache: {file_path: tensor}
+_ESM_CACHE = {}
+
+
+def preload_esm_cache(esm_dir, targets):
+ """Preload all ESM .pt files into global cache before DataLoader workers fork.
+
+ This ensures forked workers inherit the populated cache via copy-on-write,
+ avoiding redundant I/O across workers.
+ """
+ import glob as glob_mod
+ n = 0
+ for target in targets:
+ target_dir = os.path.join(esm_dir, target)
+ if not os.path.isdir(target_dir):
+ continue
+ for pt_file in glob_mod.glob(os.path.join(target_dir, '*.pt')):
+ if pt_file not in _ESM_CACHE:
+ _ESM_CACHE[pt_file] = torch.load(pt_file, map_location='cpu', weights_only=True)
+ n += 1
+ return n
+
+
+def load_esm_for_sample(sample, esm_dir, target_name, max_nodes=128):
+ """Load and index ESM-2 embeddings for a sample's interface residues.
+
+ Returns: esm_feats [max_nodes, 1280] or None if unavailable.
+ """
+ graph = sample['graph']
+ rec_idx = graph.get('rec_iface_idx')
+ binder_idx = graph.get('binder_iface_idx')
+ if rec_idx is None or binder_idx is None:
+ return None
+
+ # Get PDB ID (strip chain suffix like "2G1T_AE" -> "2G1T")
+ pdb_id = sample.get('pdb', '')
+ base_pdb = pdb_id.split('_')[0] if '_' in pdb_id else pdb_id
+ rec_chain = sample.get('rec_chain_id', 'A')
+ binder_chain = sample.get('binder_chain_id', 'B')
+
+ # Load ESM embeddings (cached)
+ rec_path = os.path.join(esm_dir, target_name, f'{base_pdb}_{rec_chain}.pt')
+ binder_path = os.path.join(esm_dir, target_name, f'{base_pdb}_{binder_chain}.pt')
+
+ def _load_cached(path):
+ if path not in _ESM_CACHE:
+ if not os.path.exists(path):
+ return None
+ _ESM_CACHE[path] = torch.load(path, map_location='cpu', weights_only=True)
+ return _ESM_CACHE[path]
+
+ rec_esm = _load_cached(rec_path)
+ binder_esm = _load_cached(binder_path)
+ if rec_esm is None or binder_esm is None:
+ return None
+
+ esm_dim = rec_esm.shape[-1] # 1280
+ n_rec = len(rec_idx)
+ n_binder = len(binder_idx)
+
+ # Index ESM embeddings by interface residue indices (clamp to valid range)
+ rec_idx_safe = np.clip(rec_idx, 0, len(rec_esm) - 1)
+ binder_idx_safe = np.clip(binder_idx, 0, len(binder_esm) - 1)
+
+ esm_feats = np.zeros((max_nodes, esm_dim), dtype=np.float32)
+ esm_feats[:n_rec] = rec_esm[rec_idx_safe].numpy()
+ esm_feats[n_rec:n_rec + n_binder] = binder_esm[binder_idx_safe].numpy()
+
+ return esm_feats
+
+
+def load_rosetta_labels(rosetta_dir, target):
+ """Load Rosetta dG labels for a target and normalize to [0,1]."""
+ path = os.path.join(rosetta_dir, f'{target}_rosetta.json')
+ if not os.path.exists(path):
+ return None
+ with open(path) as f:
+ raw = json.load(f)
+ if not raw:
+ return None
+ # Filter outliers: dG values outside [-500, 500] are failed Rosetta runs
+ dG_MIN, dG_MAX = -500.0, 500.0
+ # Normalize: sigmoid(-dG / tau) maps dG to [0,1]
+ # More negative dG = better binding = higher score
+ tau = 15.0 # temperature; dG=-30 -> 0.88, dG=-15 -> 0.73, dG=0 -> 0.5
+ labels = {}
+ for pdb_id, metrics in raw.items():
+ dG = metrics.get('dG_separated', 0.0)
+ if not np.isfinite(dG) or dG < dG_MIN or dG > dG_MAX:
+ continue # skip failed Rosetta runs
+ labels[pdb_id] = 1.0 / (1.0 + np.exp(dG / tau))
+ labels[pdb_id.upper()] = labels[pdb_id]
+ labels[pdb_id.lower()] = labels[pdb_id]
+ return labels
+
+
+def apply_rosetta_labels(samples, rosetta_labels, label_source='rosetta', alpha=0.5):
+ """Replace or combine sample labels with Rosetta-derived labels."""
+ if rosetta_labels is None:
+ return
+ n_replaced = 0
+ for s in samples:
+ pdb_id = s.get('pdb', '')
+ # Strip chain suffixes: "2G1T_AE" -> "2G1T"
+ base_pdb = pdb_id.split('_')[0] if '_' in pdb_id else pdb_id
+ rosetta_val = rosetta_labels.get(base_pdb) or rosetta_labels.get(base_pdb.upper())
+ if rosetta_val is None:
+ continue
+ if s['type'] == 'positive':
+ new_label = rosetta_val
+ elif s['type'].startswith('negative'):
+ new_label = 0.0 # apo mismatch stays 0
+ continue
+ elif s['type'].startswith('decoy'):
+ # Scale Rosetta label by DockQ-proxy quality
+ new_label = s['label'] * rosetta_val
+ else:
+ continue
+ if label_source == 'rosetta':
+ s['label'] = float(new_label)
+ elif label_source == 'combined':
+ s['label'] = float(alpha * s['label'] + (1 - alpha) * new_label)
+ n_replaced += 1
+ return n_replaced
+
+
+class TwoStateComplexDataset(Dataset):
+ """
+ Dataset of protein complex interface graphs with two-state labels.
+
+ Each sample contains:
+ node_feats: [N, node_dim] interface residue features
+ edge_feats: [N, N, edge_dim] pairwise SE(3)-invariant features
+ node_mask: [N] bool
+ label: scalar float in [0, 1] (DockQ proxy / selectivity label)
+ type: str (positive / negative_apo / decoy_*)
+ pdb: str
+ """
+
+ def __init__(self, data_path: str, max_nodes: int = 128, augment: bool = False,
+ rosetta_labels: dict = None, label_source: str = 'dockq',
+ esm_dir: str = None, target_name: str = None,
+ binder_dropout: float = 0.0):
+ with open(data_path, 'rb') as f:
+ self.samples = pickle.load(f)
+ self.max_nodes = max_nodes
+ self.augment = augment
+ self.esm_dir = esm_dir
+ self.target_name = target_name
+ self.binder_dropout = binder_dropout
+ if label_source != 'dockq' and rosetta_labels:
+ apply_rosetta_labels(self.samples, rosetta_labels, label_source)
+
+ def __len__(self):
+ return len(self.samples)
+
+ def __getitem__(self, idx):
+ sample = self.samples[idx]
+ graph = sample['graph']
+
+ node_feats = graph['node_feats'] # [N, node_dim]
+ edge_feats = graph['edge_feats'] # [N, N, edge_dim]
+ node_mask = graph['node_mask'] # [N]
+
+ N = len(node_feats)
+ assert N <= self.max_nodes, f"Too many nodes: {N} > {self.max_nodes}"
+
+ # Pad to max_nodes
+ node_dim = node_feats.shape[-1]
+ edge_dim = edge_feats.shape[-1]
+
+ node_feats_pad = np.zeros((self.max_nodes, node_dim), dtype=np.float32)
+ edge_feats_pad = np.zeros((self.max_nodes, self.max_nodes, edge_dim), dtype=np.float32)
+ node_mask_pad = np.zeros(self.max_nodes, dtype=bool)
+
+ node_feats_pad[:N] = node_feats
+ edge_feats_pad[:N, :N] = edge_feats
+ node_mask_pad[:N] = node_mask
+
+ # Optional: random coordinate noise augmentation
+ if self.augment:
+ noise = np.random.randn(*node_feats_pad.shape) * 0.01
+ node_feats_pad = node_feats_pad + noise.astype(np.float32)
+
+ # Binder-dropout: simulate backbone-only designs by masking binder
+ # sequence features (AA one-hot β UNK, chi angles β 0)
+ apply_binder_drop = (self.binder_dropout > 0
+ and np.random.rand() < self.binder_dropout)
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', N // 2)
+ # Zero out binder AA one-hot (dims 0-20), set UNK (dim 20 = 1)
+ node_feats_pad[n_rec:N, :21] = 0.0
+ node_feats_pad[n_rec:N, 20] = 1.0 # UNK
+ # Zero out binder chi angles (dims 27-30)
+ node_feats_pad[n_rec:N, 27:31] = 0.0
+ # Keep backbone torsions (dims 21-26) and chain indicator (dim 31)
+
+ result = {
+ 'node_feats': torch.from_numpy(node_feats_pad), # [max_nodes, node_dim]
+ 'edge_feats': torch.from_numpy(edge_feats_pad), # [max_nodes, max_nodes, edge_dim]
+ 'node_mask': torch.from_numpy(node_mask_pad), # [max_nodes]
+ 'label': torch.tensor(sample['label'], dtype=torch.float32),
+ 'type': sample['type'],
+ 'pdb': sample['pdb'],
+ }
+
+ # ESM-2 features (lazy load; zero-fill if unavailable)
+ if self.esm_dir:
+ esm = load_esm_for_sample(sample, self.esm_dir,
+ self.target_name or '', self.max_nodes)
+ if esm is not None:
+ esm_feats = esm
+ else:
+ esm_feats = np.zeros((self.max_nodes, 1280), dtype=np.float32)
+ # Zero binder ESM if binder-dropout active
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', N // 2)
+ n_binder = graph.get('n_binder', N - n_rec)
+ esm_feats[n_rec:n_rec + n_binder] = 0.0
+ result['esm_feats'] = torch.from_numpy(esm_feats)
+
+ return result
+
+
+def collate_fn(batch):
+ """Collate a list of samples into batched tensors."""
+ node_feats = torch.stack([s['node_feats'] for s in batch])
+ edge_feats = torch.stack([s['edge_feats'] for s in batch])
+ node_mask = torch.stack([s['node_mask'] for s in batch])
+ labels = torch.stack([s['label'] for s in batch])
+ types = [s['type'] for s in batch]
+ pdbs = [s['pdb'] for s in batch]
+
+ result = {
+ 'node_feats': node_feats, # [B, N, node_dim]
+ 'edge_feats': edge_feats, # [B, N, N, edge_dim]
+ 'node_mask': node_mask, # [B, N]
+ 'label': labels, # [B]
+ 'type': types,
+ 'pdb': pdbs,
+ }
+
+ # Stack ESM features if present (handle mixed availability with zero-fill)
+ has_esm = any('esm_feats' in s for s in batch)
+ if has_esm:
+ esm_list = []
+ for s in batch:
+ if 'esm_feats' in s:
+ esm_list.append(s['esm_feats'])
+ else:
+ # Get shape from a sample that has ESM
+ ref = next(x['esm_feats'] for x in batch if 'esm_feats' in x)
+ esm_list.append(torch.zeros_like(ref))
+ result['esm_feats'] = torch.stack(esm_list)
+
+ return result
+
+
+class TwoStateDatasetPaired(Dataset):
+ """
+ Paired dataset: returns (positive, negative) pairs for selectivity training.
+ Groups samples by PDB ID and pairs positive (holo) with negative (apo) examples.
+ """
+
+ def __init__(self, data_path: str, max_nodes: int = 128, augment: bool = False,
+ esm_dir: str = None, target_name: str = None,
+ binder_dropout: float = 0.0):
+ with open(data_path, 'rb') as f:
+ samples = pickle.load(f)
+ self.max_nodes = max_nodes
+ self.augment = augment
+ self.esm_dir = esm_dir
+ self.target_name = target_name
+ self.binder_dropout = binder_dropout
+
+ # Group by PDB
+ from collections import defaultdict
+ by_pdb = defaultdict(lambda: {'positive': [], 'negative': [], 'decoy': []})
+ for s in samples:
+ pdb = s['pdb']
+ t = s['type']
+ if t == 'positive':
+ by_pdb[pdb]['positive'].append(s)
+ elif t.startswith('negative'):
+ by_pdb[pdb]['negative'].append(s)
+ elif t.startswith('decoy'):
+ by_pdb[pdb]['decoy'].append(s)
+
+ # Build pairs: (positive, negative) per PDB
+ self.pairs = []
+ for pdb, groups in by_pdb.items():
+ if len(groups['positive']) > 0 and len(groups['negative']) > 0:
+ for pos in groups['positive']:
+ for neg in groups['negative']:
+ self.pairs.append((pos, neg))
+ # Also add (positive, decoy_large_rmsd) pairs
+ if len(groups['positive']) > 0 and len(groups['decoy']) > 0:
+ large_decoys = [s for s in groups['decoy'] if 'rmsd' in s['type'] and
+ float(s['type'].replace('decoy_rmsd', '')) > 4.0]
+ for pos in groups['positive']:
+ for neg in large_decoys[:3]: # limit to 3 hard decoys per positive
+ self.pairs.append((pos, neg))
+
+ def __len__(self):
+ return len(self.pairs)
+
+ def _prepare(self, sample, apply_binder_drop=False):
+ graph = sample['graph']
+ node_feats = graph['node_feats']
+ edge_feats = graph['edge_feats']
+ node_mask = graph['node_mask']
+ N = len(node_feats)
+ node_dim = node_feats.shape[-1]
+ edge_dim = edge_feats.shape[-1]
+
+ node_feats_pad = np.zeros((self.max_nodes, node_dim), dtype=np.float32)
+ edge_feats_pad = np.zeros((self.max_nodes, self.max_nodes, edge_dim), dtype=np.float32)
+ node_mask_pad = np.zeros(self.max_nodes, dtype=bool)
+
+ n = min(N, self.max_nodes)
+ node_feats_pad[:n] = node_feats[:n]
+ edge_feats_pad[:n, :n] = edge_feats[:n, :n]
+ node_mask_pad[:n] = node_mask[:n]
+
+ # Binder-dropout: simulate backbone-only designs
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', n // 2)
+ node_feats_pad[n_rec:n, :21] = 0.0
+ node_feats_pad[n_rec:n, 20] = 1.0 # UNK
+ node_feats_pad[n_rec:n, 27:31] = 0.0
+
+ result = {
+ 'node_feats': torch.from_numpy(node_feats_pad),
+ 'edge_feats': torch.from_numpy(edge_feats_pad),
+ 'node_mask': torch.from_numpy(node_mask_pad),
+ 'label': torch.tensor(sample['label'], dtype=torch.float32),
+ 'contact_energy': torch.tensor(
+ sample.get('contact_energy', 0.5), dtype=torch.float32
+ ),
+ }
+
+ # ESM-2 features (zero-fill if unavailable)
+ if self.esm_dir:
+ esm = load_esm_for_sample(sample, self.esm_dir,
+ self.target_name or '', self.max_nodes)
+ if esm is not None:
+ esm_feats = esm
+ else:
+ esm_feats = np.zeros((self.max_nodes, 1280), dtype=np.float32)
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', n // 2)
+ n_binder = graph.get('n_binder', n - n_rec)
+ esm_feats[n_rec:n_rec + n_binder] = 0.0
+ result['esm_feats'] = torch.from_numpy(esm_feats)
+
+ return result
+
+ def __getitem__(self, idx):
+ pos_sample, neg_sample = self.pairs[idx]
+ # Same dropout decision for both pos and neg in a pair
+ drop = (self.binder_dropout > 0
+ and np.random.rand() < self.binder_dropout)
+ return {
+ 'pos': self._prepare(pos_sample, apply_binder_drop=drop),
+ 'neg': self._prepare(neg_sample, apply_binder_drop=drop),
+ }
+
+
+def collate_paired_fn(batch):
+ """Collate paired (positive, negative) samples."""
+ pos_batch = {
+ 'node_feats': torch.stack([s['pos']['node_feats'] for s in batch]),
+ 'edge_feats': torch.stack([s['pos']['edge_feats'] for s in batch]),
+ 'node_mask': torch.stack([s['pos']['node_mask'] for s in batch]),
+ 'label': torch.stack([s['pos']['label'] for s in batch]),
+ 'contact_energy': torch.stack([s['pos']['contact_energy'] for s in batch]),
+ }
+ neg_batch = {
+ 'node_feats': torch.stack([s['neg']['node_feats'] for s in batch]),
+ 'edge_feats': torch.stack([s['neg']['edge_feats'] for s in batch]),
+ 'node_mask': torch.stack([s['neg']['node_mask'] for s in batch]),
+ 'label': torch.stack([s['neg']['label'] for s in batch]),
+ 'contact_energy': torch.stack([s['neg']['contact_energy'] for s in batch]),
+ }
+ # ESM features (handle mixed availability)
+ has_pos_esm = any('esm_feats' in s['pos'] for s in batch)
+ if has_pos_esm:
+ def _stack_esm(batch_list, key):
+ esm_list = []
+ ref = next((x[key]['esm_feats'] for x in batch_list if 'esm_feats' in x[key]), None)
+ for s in batch_list:
+ if 'esm_feats' in s[key]:
+ esm_list.append(s[key]['esm_feats'])
+ else:
+ esm_list.append(torch.zeros_like(ref))
+ return torch.stack(esm_list)
+ pos_batch['esm_feats'] = _stack_esm(batch, 'pos')
+ neg_batch['esm_feats'] = _stack_esm(batch, 'neg')
+ return {'pos': pos_batch, 'neg': neg_batch}
+
+
+class PathAwareDatasetPaired(Dataset):
+ """
+ Paired dataset with transition-path frames for path-aware Phase 2 training.
+
+ Extends TwoStateDatasetPaired: each sample returns (positive, negative, path_frames)
+ where path_frames is a list of prepared graph dicts for intermediate conformations
+ stored in the positive sample's 'path_graphs' field.
+ """
+
+ def __init__(self, data_path: str, max_nodes: int = 128, augment: bool = False):
+ with open(data_path, 'rb') as f:
+ samples = pickle.load(f)
+ self.max_nodes = max_nodes
+ self.augment = augment
+
+ from collections import defaultdict
+ by_pdb = defaultdict(lambda: {'positive': [], 'negative': [], 'decoy': []})
+ for s in samples:
+ pdb = s['pdb']
+ t = s['type']
+ if t == 'positive':
+ by_pdb[pdb]['positive'].append(s)
+ elif t.startswith('negative'):
+ by_pdb[pdb]['negative'].append(s)
+ elif t.startswith('decoy'):
+ by_pdb[pdb]['decoy'].append(s)
+
+ self.pairs = []
+ for pdb, groups in by_pdb.items():
+ if len(groups['positive']) > 0 and len(groups['negative']) > 0:
+ for pos in groups['positive']:
+ for neg in groups['negative']:
+ self.pairs.append((pos, neg))
+ if len(groups['positive']) > 0 and len(groups['decoy']) > 0:
+ large_decoys = [s for s in groups['decoy'] if 'rmsd' in s['type'] and
+ float(s['type'].replace('decoy_rmsd', '')) > 4.0]
+ for pos in groups['positive']:
+ for neg in large_decoys[:3]:
+ self.pairs.append((pos, neg))
+
+ def _prepare(self, sample):
+ graph = sample['graph']
+ node_feats = graph['node_feats']
+ edge_feats = graph['edge_feats']
+ node_mask = graph['node_mask']
+ N = len(node_feats)
+ node_dim = node_feats.shape[-1]
+ edge_dim = edge_feats.shape[-1]
+
+ node_feats_pad = np.zeros((self.max_nodes, node_dim), dtype=np.float32)
+ edge_feats_pad = np.zeros((self.max_nodes, self.max_nodes, edge_dim), dtype=np.float32)
+ node_mask_pad = np.zeros(self.max_nodes, dtype=bool)
+
+ n = min(N, self.max_nodes)
+ node_feats_pad[:n] = node_feats[:n]
+ edge_feats_pad[:n, :n] = edge_feats[:n, :n]
+ node_mask_pad[:n] = node_mask[:n]
+
+ return {
+ 'node_feats': torch.from_numpy(node_feats_pad),
+ 'edge_feats': torch.from_numpy(edge_feats_pad),
+ 'node_mask': torch.from_numpy(node_mask_pad),
+ 'label': torch.tensor(sample.get('label', 0.0), dtype=torch.float32),
+ 'contact_energy': torch.tensor(
+ sample.get('contact_energy', 0.5), dtype=torch.float32
+ ),
+ }
+
+ def _prepare_graph_only(self, path_entry):
+ """Prepare a path frame graph (no label/contact_energy needed)."""
+ graph = path_entry['graph']
+ node_feats = graph['node_feats']
+ edge_feats = graph['edge_feats']
+ node_mask = graph['node_mask']
+ N = len(node_feats)
+ node_dim = node_feats.shape[-1]
+ edge_dim = edge_feats.shape[-1]
+
+ node_feats_pad = np.zeros((self.max_nodes, node_dim), dtype=np.float32)
+ edge_feats_pad = np.zeros((self.max_nodes, self.max_nodes, edge_dim), dtype=np.float32)
+ node_mask_pad = np.zeros(self.max_nodes, dtype=bool)
+
+ n = min(N, self.max_nodes)
+ node_feats_pad[:n] = node_feats[:n]
+ edge_feats_pad[:n, :n] = edge_feats[:n, :n]
+ node_mask_pad[:n] = node_mask[:n]
+
+ return {
+ 'node_feats': torch.from_numpy(node_feats_pad),
+ 'edge_feats': torch.from_numpy(edge_feats_pad),
+ 'node_mask': torch.from_numpy(node_mask_pad),
+ }
+
+ def __len__(self):
+ return len(self.pairs)
+
+ def __getitem__(self, idx):
+ pos_sample, neg_sample = self.pairs[idx]
+ result = {
+ 'pos': self._prepare(pos_sample),
+ 'neg': self._prepare(neg_sample),
+ }
+
+ # Prepare path frames if available
+ path_graphs = pos_sample.get('path_graphs', [])
+ prepared_paths = []
+ path_taus = []
+ for pg in path_graphs:
+ prepared_paths.append(self._prepare_graph_only(pg))
+ path_taus.append(pg['tau'])
+
+ result['path'] = prepared_paths
+ result['path_taus'] = path_taus
+
+ return result
+
+
+def collate_path_paired_fn(batch):
+ """Collate paired samples with variable-length path frames."""
+ pos_batch = {
+ 'node_feats': torch.stack([s['pos']['node_feats'] for s in batch]),
+ 'edge_feats': torch.stack([s['pos']['edge_feats'] for s in batch]),
+ 'node_mask': torch.stack([s['pos']['node_mask'] for s in batch]),
+ 'label': torch.stack([s['pos']['label'] for s in batch]),
+ 'contact_energy': torch.stack([s['pos']['contact_energy'] for s in batch]),
+ }
+ neg_batch = {
+ 'node_feats': torch.stack([s['neg']['node_feats'] for s in batch]),
+ 'edge_feats': torch.stack([s['neg']['edge_feats'] for s in batch]),
+ 'node_mask': torch.stack([s['neg']['node_mask'] for s in batch]),
+ 'label': torch.stack([s['neg']['label'] for s in batch]),
+ 'contact_energy': torch.stack([s['neg']['contact_energy'] for s in batch]),
+ }
+
+ # Collate path frames: find max K across batch, pad shorter ones
+ max_k = max((len(s['path']) for s in batch), default=0)
+ path_batches = []
+ path_taus = []
+
+ if max_k > 0:
+ # Build a zero-filled placeholder for padding (graph-only keys)
+ ref = batch[0]['path'][0] if batch[0]['path'] else batch[0]['pos']
+ zero_placeholder = {
+ 'node_feats': torch.zeros_like(ref['node_feats']),
+ 'edge_feats': torch.zeros_like(ref['edge_feats']),
+ 'node_mask': torch.zeros_like(ref['node_mask']),
+ }
+
+ for k_idx in range(max_k):
+ frames_at_k = []
+ taus_at_k = []
+ for s in batch:
+ if k_idx < len(s['path']):
+ frames_at_k.append(s['path'][k_idx])
+ taus_at_k.append(s['path_taus'][k_idx])
+ else:
+ frames_at_k.append(zero_placeholder)
+ taus_at_k.append(1.0)
+
+ path_batches.append({
+ 'node_feats': torch.stack([f['node_feats'] for f in frames_at_k]),
+ 'edge_feats': torch.stack([f['edge_feats'] for f in frames_at_k]),
+ 'node_mask': torch.stack([f['node_mask'] for f in frames_at_k]),
+ })
+ path_taus.append(taus_at_k[0])
+
+ result = {'pos': pos_batch, 'neg': neg_batch}
+ if path_batches:
+ result['path'] = path_batches
+ result['path_taus'] = path_taus
+ return result
+
+
+class MultiTargetDataset(Dataset):
+ """
+ Pooled dataset combining samples from multiple targets.
+ Supports balanced sampling across targets.
+ """
+
+ def __init__(self, data_paths: list, max_nodes: int = 128, augment: bool = False,
+ balance: bool = True, rosetta_dir: str = None, label_source: str = 'dockq',
+ esm_dir: str = None, binder_dropout: float = 0.0):
+ """
+ Args:
+ data_paths: list of (target_name, pkl_path) tuples
+ max_nodes: max interface graph size
+ augment: apply noise augmentation
+ balance: if True, oversample smaller targets to balance
+ rosetta_dir: directory containing Rosetta label JSONs
+ label_source: 'dockq', 'rosetta', or 'combined'
+ """
+ self.max_nodes = max_nodes
+ self.augment = augment
+ self.esm_dir = esm_dir
+ self.binder_dropout = binder_dropout
+
+ # Load all samples with target labels
+ self.samples = []
+ self.target_indices = {} # target_name -> list of indices
+
+ for target_name, path in data_paths:
+ if not os.path.exists(path):
+ continue
+ with open(path, 'rb') as f:
+ target_samples = pickle.load(f)
+
+ # Apply Rosetta labels if requested
+ if label_source != 'dockq' and rosetta_dir:
+ rl = load_rosetta_labels(rosetta_dir, target_name)
+ if rl:
+ apply_rosetta_labels(target_samples, rl, label_source)
+
+ start_idx = len(self.samples)
+ for s in target_samples:
+ s['_target'] = target_name
+ self.samples.append(s)
+ end_idx = len(self.samples)
+ self.target_indices[target_name] = list(range(start_idx, end_idx))
+
+ # Build balanced sampling weights
+ if balance and len(self.target_indices) > 1:
+ non_empty = {k: v for k, v in self.target_indices.items() if len(v) > 0}
+ max_count = max(len(idxs) for idxs in non_empty.values()) if non_empty else 1
+ self.weights = np.zeros(len(self.samples))
+ for target_name, idxs in self.target_indices.items():
+ if len(idxs) == 0:
+ continue
+ weight = max_count / len(idxs)
+ for i in idxs:
+ self.weights[i] = weight
+ self.weights /= self.weights.sum()
+ else:
+ self.weights = None
+
+ def __len__(self):
+ return len(self.samples)
+
+ def __getitem__(self, idx):
+ sample = self.samples[idx]
+ graph = sample['graph']
+ node_feats = graph['node_feats']
+ edge_feats = graph['edge_feats']
+ node_mask = graph['node_mask']
+ N = len(node_feats)
+ node_dim = node_feats.shape[-1]
+ edge_dim = edge_feats.shape[-1]
+
+ node_feats_pad = np.zeros((self.max_nodes, node_dim), dtype=np.float32)
+ edge_feats_pad = np.zeros((self.max_nodes, self.max_nodes, edge_dim), dtype=np.float32)
+ node_mask_pad = np.zeros(self.max_nodes, dtype=bool)
+
+ n = min(N, self.max_nodes)
+ node_feats_pad[:n] = node_feats[:n]
+ edge_feats_pad[:n, :n] = edge_feats[:n, :n]
+ node_mask_pad[:n] = node_mask[:n]
+
+ if self.augment:
+ noise = np.random.randn(*node_feats_pad.shape) * 0.01
+ node_feats_pad = node_feats_pad + noise.astype(np.float32)
+
+ # Binder-dropout: simulate backbone-only designs
+ apply_binder_drop = (self.binder_dropout > 0
+ and np.random.rand() < self.binder_dropout)
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', N // 2)
+ node_feats_pad[n_rec:N, :21] = 0.0
+ node_feats_pad[n_rec:N, 20] = 1.0 # UNK
+ node_feats_pad[n_rec:N, 27:31] = 0.0
+
+ result = {
+ 'node_feats': torch.from_numpy(node_feats_pad),
+ 'edge_feats': torch.from_numpy(edge_feats_pad),
+ 'node_mask': torch.from_numpy(node_mask_pad),
+ 'label': torch.tensor(sample['label'], dtype=torch.float32),
+ 'type': sample['type'],
+ 'pdb': sample['pdb'],
+ 'target': sample.get('_target', 'unknown'),
+ }
+
+ # ESM-2 features (zero-fill if unavailable)
+ if self.esm_dir:
+ target_name = sample.get('_target', 'unknown')
+ esm = load_esm_for_sample(sample, self.esm_dir, target_name, self.max_nodes)
+ if esm is not None:
+ esm_feats = esm
+ else:
+ esm_feats = np.zeros((self.max_nodes, 1280), dtype=np.float32)
+ if apply_binder_drop:
+ n_rec = graph.get('n_rec', N // 2)
+ n_binder = graph.get('n_binder', N - n_rec)
+ esm_feats[n_rec:n_rec + n_binder] = 0.0
+ result['esm_feats'] = torch.from_numpy(esm_feats)
+
+ return result
+
+ @staticmethod
+ def get_pooled_dataloaders(data_dir, targets, batch_size=16, max_nodes=128,
+ num_workers=4, paired=False,
+ rosetta_dir=None, label_source='dockq',
+ esm_dir=None, binder_dropout=0.0):
+ """Build pooled dataloaders from multiple targets.
+
+ Args:
+ data_dir: root data directory
+ targets: list of target names
+ batch_size: batch size
+ max_nodes: max interface nodes
+ num_workers: dataloader workers
+ paired: if True, build paired dataloaders for Phase 2
+ rosetta_dir: directory with Rosetta label JSONs
+ label_source: 'dockq', 'rosetta', or 'combined'
+ """
+ from torch.utils.data import WeightedRandomSampler
+
+ # Preload ESM embeddings into global cache before creating datasets/workers
+ if esm_dir:
+ n_loaded = preload_esm_cache(esm_dir, targets)
+
+ loaders = {}
+ for split in ['train', 'val', 'test']:
+ data_paths = []
+ for target in targets:
+ path = os.path.join(data_dir, target, f"{split}.pkl")
+ if os.path.exists(path):
+ data_paths.append((target, path))
+
+ if not data_paths:
+ continue
+
+ augment = (split == 'train')
+ bd = binder_dropout if split == 'train' else 0.0
+
+ if paired:
+ # For paired mode, concatenate paired datasets
+ all_pairs = []
+ for target, path in data_paths:
+ ds = TwoStateDatasetPaired(path, max_nodes=max_nodes, augment=augment,
+ esm_dir=esm_dir, target_name=target,
+ binder_dropout=bd)
+ all_pairs.append(ds)
+
+ if not all_pairs:
+ continue
+
+ # Use ConcatDataset
+ from torch.utils.data import ConcatDataset
+ concat_ds = ConcatDataset(all_pairs)
+ p_batch = min(batch_size, max(1, len(concat_ds) // 2))
+ loaders[split] = DataLoader(
+ concat_ds, batch_size=p_batch,
+ shuffle=(split == 'train'),
+ num_workers=num_workers,
+ collate_fn=collate_paired_fn,
+ pin_memory=True,
+ )
+ else:
+ dataset = MultiTargetDataset(data_paths, max_nodes=max_nodes,
+ augment=augment, balance=(split == 'train'),
+ rosetta_dir=rosetta_dir, label_source=label_source,
+ esm_dir=esm_dir, binder_dropout=bd)
+
+ sampler = None
+ shuffle = (split == 'train')
+ if split == 'train' and dataset.weights is not None:
+ sampler = WeightedRandomSampler(
+ weights=dataset.weights,
+ num_samples=len(dataset),
+ replacement=True
+ )
+ shuffle = False
+
+ loaders[split] = DataLoader(
+ dataset, batch_size=batch_size,
+ shuffle=shuffle, sampler=sampler,
+ num_workers=num_workers,
+ collate_fn=collate_fn,
+ pin_memory=True,
+ drop_last=(split == 'train' and len(dataset) > batch_size),
+ )
+
+ return loaders
+
+
+def get_dataloaders(data_dir: str, target: str, batch_size: int = 16,
+ max_nodes: int = 128, num_workers: int = 4,
+ paired: bool = False, esm_dir: str = None,
+ binder_dropout: float = 0.0):
+ """Build train/val/test dataloaders for a given target."""
+ loaders = {}
+ for split in ['train', 'val', 'test']:
+ path = os.path.join(data_dir, target, f"{split}.pkl")
+ if not os.path.exists(path):
+ continue
+
+ augment = (split == 'train')
+ bd = binder_dropout if split == 'train' else 0.0
+ if paired and split == 'train':
+ dataset = TwoStateDatasetPaired(path, max_nodes=max_nodes, augment=augment,
+ esm_dir=esm_dir, target_name=target,
+ binder_dropout=bd)
+ collate = collate_paired_fn
+ else:
+ dataset = TwoStateComplexDataset(path, max_nodes=max_nodes, augment=augment,
+ esm_dir=esm_dir, target_name=target,
+ binder_dropout=bd)
+ collate = collate_fn
+
+ loaders[split] = DataLoader(
+ dataset,
+ batch_size=batch_size,
+ shuffle=(split == 'train'),
+ num_workers=num_workers,
+ collate_fn=collate,
+ pin_memory=True,
+ drop_last=(split == 'train' and len(dataset) > batch_size),
+ )
+ return loaders
diff --git a/code/models/__init__.py b/code/models/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/code/models/differentiable_features.py b/code/models/differentiable_features.py
new file mode 100644
index 0000000000000000000000000000000000000000..837d8c8b1ba471ec272e6cee82ec567420560189
--- /dev/null
+++ b/code/models/differentiable_features.py
@@ -0,0 +1,622 @@
+"""
+Differentiable feature extraction for Q_theta guidance.
+
+This module re-implements the key feature extraction functions from features.py
+and pdb_utils.py using PyTorch operations, enabling gradient computation through
+Q_theta with respect to backbone coordinates.
+
+The differentiable path:
+ coords (N,4,3) β backbone frames β torsions, distances, directions, rotations
+ β node_feats, edge_feats β Q_theta β score β backward() β βcoords
+
+Non-differentiable features (AA one-hot, chain_id, seq_sep, same_chain) are
+treated as constants.
+"""
+
+import os
+import torch
+import torch.nn.functional as F
+import numpy as np
+
+
+# ββ Differentiable backbone frame computation ββββββββββββββββββββββββββββββββ
+
+def compute_backbone_frames_torch(coords, mask):
+ """
+ Compute SE(3)-equivariant backbone frames from N, CA, C atoms.
+ Differentiable w.r.t. coords.
+
+ Args:
+ coords: [N, 4, 3] backbone coords (N, CA, C, O) β requires_grad=True for binder
+ mask: [N] bool tensor
+
+ Returns:
+ origins: [N, 3] = CA positions
+ rotations: [N, 3, 3] = rotation matrices (columns are x, y, z axes)
+ """
+ N_res = coords.shape[0]
+ device = coords.device
+
+ origins = coords[:, 1, :] # CA positions [N, 3]
+ rotations = torch.eye(3, device=device, dtype=coords.dtype).unsqueeze(0).expand(N_res, -1, -1).clone()
+
+ ca = coords[:, 1, :] # [N, 3]
+ n_atom = coords[:, 0, :] # [N, 3]
+ c_atom = coords[:, 2, :] # [N, 3]
+
+ # z-axis: CA -> C
+ z = c_atom - ca # [N, 3]
+ z_norm = torch.norm(z, dim=-1, keepdim=True).clamp(min=1e-6) # [N, 1]
+ z = z / z_norm # [N, 3]
+
+ # y-axis: CA -> N, orthogonalized against z
+ y = n_atom - ca # [N, 3]
+ y_proj = (y * z).sum(dim=-1, keepdim=True) # [N, 1]
+ y = y - y_proj * z # [N, 3]
+ y_norm = torch.norm(y, dim=-1, keepdim=True).clamp(min=1e-6) # [N, 1]
+ y = y / y_norm # [N, 3]
+
+ # x-axis: y cross z
+ x = torch.cross(y, z, dim=-1) # [N, 3]
+
+ # Stack columns: [N, 3, 3] where columns are x, y, z
+ rot = torch.stack([x, y, z], dim=-1) # [N, 3, 3]
+
+ # Apply mask: identity for masked residues
+ mask_f = mask.float().unsqueeze(-1).unsqueeze(-1) # [N, 1, 1]
+ eye = torch.eye(3, device=device, dtype=coords.dtype).unsqueeze(0) # [1, 3, 3]
+ rotations = rot * mask_f + eye * (1 - mask_f)
+
+ return origins, rotations
+
+
+# ββ Differentiable torsion angle computation βββββββββββββββββββββββββββββββββ
+
+def _dihedral_torch(p0, p1, p2, p3):
+ """
+ Compute dihedral angle for batches of 4 points. Returns sin, cos.
+ Differentiable w.r.t. all inputs.
+
+ Args:
+ p0, p1, p2, p3: [N, 3] tensors
+
+ Returns:
+ sin_angle: [N]
+ cos_angle: [N]
+ """
+ b1 = p1 - p0 # [N, 3]
+ b2 = p2 - p1
+ b3 = p3 - p2
+
+ n1 = torch.cross(b1, b2, dim=-1) # [N, 3]
+ n2 = torch.cross(b2, b3, dim=-1)
+
+ n1_norm = torch.norm(n1, dim=-1, keepdim=True).clamp(min=1e-8)
+ n2_norm = torch.norm(n2, dim=-1, keepdim=True).clamp(min=1e-8)
+ n1 = n1 / n1_norm
+ n2 = n2 / n2_norm
+
+ b2_norm = torch.norm(b2, dim=-1, keepdim=True).clamp(min=1e-8)
+ m1 = torch.cross(n1, b2 / b2_norm, dim=-1) # [N, 3]
+
+ cos_angle = (n1 * n2).sum(dim=-1) # [N]
+ sin_angle = (m1 * n2).sum(dim=-1) # [N]
+
+ return sin_angle, cos_angle
+
+
+def compute_torsion_angles_torch(coords, mask):
+ """
+ Compute backbone torsion angles (phi, psi, omega) as sin/cos pairs.
+ Differentiable w.r.t. coords.
+
+ Args:
+ coords: [N, 4, 3] backbone coords (N, CA, C, O)
+ mask: [N] bool tensor
+
+ Returns:
+ torsions: [N, 6] (sin_phi, cos_phi, sin_psi, cos_psi, sin_omega, cos_omega)
+ """
+ N = coords.shape[0]
+ device = coords.device
+ torsions = torch.zeros(N, 6, device=device, dtype=coords.dtype)
+
+ if N < 2:
+ return torsions
+
+ n_atoms = coords[:, 0, :] # N atoms [N, 3]
+ ca_atoms = coords[:, 1, :] # CA atoms
+ c_atoms = coords[:, 2, :] # C atoms
+
+ # Phi: C_{i-1} - N_i - CA_i - C_i (for i >= 1)
+ if N > 1:
+ phi_mask = mask[1:] & mask[:-1] # [N-1]
+ sin_phi, cos_phi = _dihedral_torch(
+ c_atoms[:-1], # C_{i-1}
+ n_atoms[1:], # N_i
+ ca_atoms[1:], # CA_i
+ c_atoms[1:] # C_i
+ )
+ torsions[1:, 0] = sin_phi * phi_mask.float()
+ torsions[1:, 1] = cos_phi * phi_mask.float()
+
+ # Psi: N_i - CA_i - C_i - N_{i+1} (for i < N-1)
+ if N > 1:
+ psi_mask = mask[:-1] & mask[1:] # [N-1]
+ sin_psi, cos_psi = _dihedral_torch(
+ n_atoms[:-1], # N_i
+ ca_atoms[:-1], # CA_i
+ c_atoms[:-1], # C_i
+ n_atoms[1:] # N_{i+1}
+ )
+ torsions[:-1, 2] = sin_psi * psi_mask.float()
+ torsions[:-1, 3] = cos_psi * psi_mask.float()
+
+ # Omega: CA_{i-1} - C_{i-1} - N_i - CA_i (for i >= 1)
+ if N > 1:
+ omega_mask = mask[1:] & mask[:-1] # [N-1]
+ sin_omega, cos_omega = _dihedral_torch(
+ ca_atoms[:-1], # CA_{i-1}
+ c_atoms[:-1], # C_{i-1}
+ n_atoms[1:], # N_i
+ ca_atoms[1:] # CA_i
+ )
+ torsions[1:, 4] = sin_omega * omega_mask.float()
+ torsions[1:, 5] = cos_omega * omega_mask.float()
+
+ return torsions
+
+
+# ββ Differentiable RBF distance encoding βββββββββββββββββββββββββββββββββββββ
+
+def rbf_encode_torch(distances, d_min=0.0, d_max=20.0, n_bins=16):
+ """
+ RBF encoding of distances using Gaussian basis functions.
+ Differentiable w.r.t. distances.
+
+ Args:
+ distances: [...] tensor
+ Returns:
+ encoded: [..., n_bins] tensor
+ """
+ centers = torch.linspace(d_min, d_max, n_bins, device=distances.device, dtype=distances.dtype)
+ sigma = (d_max - d_min) / (n_bins - 1)
+ return torch.exp(-((distances.unsqueeze(-1) - centers) ** 2) / (2 * sigma ** 2))
+
+
+# ββ Differentiable edge feature computation ββββββββββββββββββββββββββββββββββ
+
+def compute_edge_features_torch(origins, rotations, seq_idx, chain_ids, mask,
+ n_bins_rbf=16, n_bins_sep=8, max_sep=32):
+ """
+ Compute SE(3)-invariant edge features between all residue pairs.
+ Differentiable w.r.t. origins and rotations (which derive from coords).
+
+ Args:
+ origins: [N, 3] CA positions
+ rotations: [N, 3, 3] backbone frame rotations
+ seq_idx: [N] int tensor β sequence indices (non-differentiable)
+ chain_ids: [N] int tensor β chain labels (non-differentiable)
+ mask: [N] bool tensor
+
+ Returns:
+ edge_feats: [N, N, 37]
+ """
+ N = origins.shape[0]
+ device = origins.device
+ dtype = origins.dtype
+
+ # --- Distance features (differentiable) ---
+ diff = origins.unsqueeze(1) - origins.unsqueeze(0) # [N, N, 3]
+ dist = torch.norm(diff, dim=-1).clamp(min=1e-8) # [N, N]
+ dist_rbf = rbf_encode_torch(dist, d_min=0., d_max=20., n_bins=n_bins_rbf) # [N, N, 16]
+
+ # --- Direction in local frame (differentiable) ---
+ unit_diff = diff / dist.unsqueeze(-1) # [N, N, 3]
+ # local_dir[i,j] = R_i^T @ (ca_j - ca_i) / dist
+ # rotations: [N, 3, 3], unit_diff: [N, N, 3]
+ local_dir = torch.einsum('ikl,ijl->ijk', rotations, unit_diff) # [N, N, 3]
+
+ # --- Relative rotation (differentiable) ---
+ # rel_rot[i,j] = R_i^T @ R_j -> [N, N, 3, 3] -> flatten to [N, N, 9]
+ rel_rot = torch.einsum('ikl,jlm->ijkm', rotations, rotations) # [N, N, 3, 3]
+ rel_rot_flat = rel_rot.reshape(N, N, 9) # [N, N, 9]
+
+ # --- Sequence separation (non-differentiable, constant) ---
+ sep = seq_idx.unsqueeze(1) - seq_idx.unsqueeze(0) # [N, N]
+ bins = torch.linspace(-max_sep, max_sep, n_bins_sep + 1, device=device)
+ sep_clipped = sep.float().clamp(-max_sep, max_sep)
+ # Bin encoding via soft assignment (but really we just use hard binning)
+ sep_enc = torch.zeros(N, N, n_bins_sep, device=device, dtype=dtype)
+ bin_idx = torch.bucketize(sep_clipped, bins) - 1
+ bin_idx = bin_idx.clamp(0, n_bins_sep - 1)
+ # Scatter one-hot
+ sep_enc.scatter_(2, bin_idx.unsqueeze(-1).long(), 1.0)
+
+ # Cross-chain pairs get sep=0
+ same_chain = (chain_ids.unsqueeze(1) == chain_ids.unsqueeze(0)) # [N, N]
+ cross_chain = ~same_chain
+ sep_enc[cross_chain] = 0.0
+
+ # --- Same chain indicator (non-differentiable, constant) ---
+ same_chain_feat = same_chain.float().unsqueeze(-1) # [N, N, 1]
+
+ # --- Concatenate ---
+ edge_feats = torch.cat([
+ dist_rbf, # [N, N, 16]
+ local_dir, # [N, N, 3]
+ rel_rot_flat, # [N, N, 9]
+ sep_enc, # [N, N, 8]
+ same_chain_feat # [N, N, 1]
+ ], dim=-1) # [N, N, 37]
+
+ # Zero out edges involving masked residues
+ mask_2d = mask.unsqueeze(1) & mask.unsqueeze(0) # [N, N]
+ edge_feats = edge_feats * mask_2d.unsqueeze(-1).float()
+
+ return edge_feats
+
+
+# ββ Full differentiable interface graph builder ββββββββββββββββββββββββββββββ
+
+def build_differentiable_interface_graph(
+ rec_coords, rec_mask, rec_aa_idx, rec_chi,
+ binder_coords, binder_mask, binder_aa_idx, binder_chi,
+ cutoff=8.0, max_nodes=128
+):
+ """
+ Build interface graph with differentiable features w.r.t. binder_coords.
+ Receptor coords are treated as constants (detached).
+
+ Args:
+ rec_coords: [N_rec, 4, 3] β receptor backbone coords (constant, no grad)
+ rec_mask: [N_rec] bool
+ rec_aa_idx: [N_rec] int β amino acid indices (constant)
+ rec_chi: [N_rec, 4] β chi1/chi2 sin/cos (constant)
+ binder_coords: [N_binder, 4, 3] β binder backbone coords (requires_grad)
+ binder_mask: [N_binder] bool
+ binder_aa_idx: [N_binder] int β amino acid indices (constant, UNK for designed)
+ binder_chi: [N_binder, 4] β chi1/chi2 sin/cos (zeros for backbone-only)
+ cutoff: interface distance cutoff (Γ )
+ max_nodes: maximum nodes per chain in the graph
+
+ Returns:
+ node_feats: [1, N_total, 32] tensor
+ edge_feats: [1, N_total, N_total, 37] tensor
+ node_mask: [1, N_total] bool tensor
+ n_rec: int
+ n_binder: int
+ or None if no interface
+ """
+ device = binder_coords.device
+ dtype = binder_coords.dtype
+ NUM_AA = 21
+
+ # ββ Find interface residues (differentiable distances but hard threshold) ββ
+ rec_ca = rec_coords[:, 1, :] # [N_rec, 3]
+ binder_ca = binder_coords[:, 1, :] # [N_binder, 3]
+
+ # Pairwise CA distances
+ dist_mat = torch.cdist(rec_ca.unsqueeze(0), binder_ca.unsqueeze(0)).squeeze(0) # [N_rec, N_binder]
+ # Mask invalid residues
+ dist_mat = dist_mat.clone()
+ dist_mat[~rec_mask, :] = float('inf')
+ dist_mat[:, ~binder_mask] = float('inf')
+
+ rec_iface = (dist_mat < cutoff).any(dim=1) # [N_rec]
+ binder_iface = (dist_mat < cutoff).any(dim=0) # [N_binder]
+
+ rec_iface_idx = torch.where(rec_iface)[0]
+ binder_iface_idx = torch.where(binder_iface)[0]
+
+ # Truncate if too many
+ if len(rec_iface_idx) > max_nodes // 2:
+ rec_iface_idx = rec_iface_idx[:max_nodes // 2]
+ if len(binder_iface_idx) > max_nodes // 2:
+ binder_iface_idx = binder_iface_idx[:max_nodes // 2]
+
+ n_rec = len(rec_iface_idx)
+ n_binder = len(binder_iface_idx)
+ n_total = n_rec + n_binder
+
+ if n_total == 0:
+ return None
+
+ # ββ Extract interface subsets ββ
+ rec_iface_coords = rec_coords[rec_iface_idx] # [n_rec, 4, 3]
+ binder_iface_coords = binder_coords[binder_iface_idx] # [n_binder, 4, 3]
+ rec_iface_mask = rec_mask[rec_iface_idx]
+ binder_iface_mask = binder_mask[binder_iface_idx]
+
+ # ββ Compute backbone frames (differentiable) ββ
+ rec_origins, rec_rotations = compute_backbone_frames_torch(rec_iface_coords, rec_iface_mask)
+ binder_origins, binder_rotations = compute_backbone_frames_torch(binder_iface_coords, binder_iface_mask)
+
+ # ββ Compute torsion angles (differentiable) ββ
+ rec_torsion = compute_torsion_angles_torch(rec_iface_coords, rec_iface_mask) # [n_rec, 6]
+ binder_torsion = compute_torsion_angles_torch(binder_iface_coords, binder_iface_mask) # [n_binder, 6]
+
+ # ββ Node features ββ
+ # AA one-hot (non-differentiable constant)
+ rec_aa_onehot = F.one_hot(rec_aa_idx[rec_iface_idx].long(), NUM_AA).float() # [n_rec, 21]
+ binder_aa_onehot = F.one_hot(binder_aa_idx[binder_iface_idx].long(), NUM_AA).float() # [n_binder, 21]
+
+ # Chi angles (constant for receptor, zeros for backbone-only binder)
+ rec_chi_iface = rec_chi[rec_iface_idx] # [n_rec, 4]
+ binder_chi_iface = binder_chi[binder_iface_idx] # [n_binder, 4]
+
+ # Chain indicator
+ rec_chain_feat = torch.zeros(n_rec, 1, device=device, dtype=dtype)
+ binder_chain_feat = torch.ones(n_binder, 1, device=device, dtype=dtype)
+
+ # Concatenate node features: [AA(21) + torsions(6) + chi(4) + chain(1)] = 32
+ rec_node = torch.cat([rec_aa_onehot, rec_torsion, rec_chi_iface, rec_chain_feat], dim=-1)
+ binder_node = torch.cat([binder_aa_onehot, binder_torsion, binder_chi_iface, binder_chain_feat], dim=-1)
+ node_feats = torch.cat([rec_node, binder_node], dim=0) # [N_total, 32]
+ node_mask_flat = torch.cat([rec_iface_mask, binder_iface_mask], dim=0) # [N_total]
+
+ # ββ Edge features (differentiable) ββ
+ all_origins = torch.cat([rec_origins, binder_origins], dim=0) # [N_total, 3]
+ all_rotations = torch.cat([rec_rotations, binder_rotations], dim=0) # [N_total, 3, 3]
+
+ # Sequence indices
+ rec_seq_idx = rec_iface_idx
+ binder_seq_idx = binder_iface_idx + rec_coords.shape[0]
+ all_seq_idx = torch.cat([rec_seq_idx, binder_seq_idx], dim=0)
+
+ # Chain IDs
+ all_chain_ids = torch.cat([
+ torch.zeros(n_rec, device=device, dtype=torch.long),
+ torch.ones(n_binder, device=device, dtype=torch.long)
+ ], dim=0)
+
+ edge_feats = compute_edge_features_torch(
+ all_origins, all_rotations, all_seq_idx, all_chain_ids, node_mask_flat
+ ) # [N_total, N_total, 37]
+
+ # Add batch dimension
+ return {
+ 'node_feats': node_feats.unsqueeze(0), # [1, N, 32]
+ 'edge_feats': edge_feats.unsqueeze(0), # [1, N, N, 37]
+ 'node_mask': node_mask_flat.unsqueeze(0), # [1, N]
+ 'n_rec': n_rec,
+ 'n_binder': n_binder,
+ }
+
+
+# ββ Differentiable Q_theta scoring function ββββββββββββββββββββββββββββββββββ
+
+class DifferentiableQTheta:
+ """
+ Wraps the Q_theta scorer for differentiable scoring w.r.t. binder backbone
+ coordinates. Receptor structures are pre-loaded and cached.
+
+ Usage:
+ dq = DifferentiableQTheta(checkpoint_path, device)
+ dq.load_receptor(holo_pdb, chain='A', label='holo')
+ dq.load_receptor(apo_pdb, chain='A', label='apo')
+
+ binder_coords = torch.tensor(...) # [N_binder, 4, 3], requires_grad=True
+ score_holo = dq.score(binder_coords, binder_mask, binder_aa_idx, 'holo')
+ score_apo = dq.score(binder_coords, binder_mask, binder_aa_idx, 'apo')
+ selectivity = score_holo - score_apo
+ selectivity.backward()
+ # binder_coords.grad now contains βS/βcoords
+ """
+
+ def __init__(self, checkpoint_path, device='cuda:0', esm_dir=None):
+ import sys, os
+ _code_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+ if _code_dir not in sys.path:
+ sys.path.insert(0, _code_dir)
+ from models.scorer import build_model
+
+ self.device = torch.device(device)
+ ckpt = torch.load(checkpoint_path, map_location='cpu', weights_only=False)
+ self.config = ckpt['config']
+ self.model = build_model(self.config)
+ self.model.load_state_dict(ckpt['model_state'])
+ self.model = self.model.to(self.device)
+ self.model.eval()
+
+ # ESM feature support
+ self.use_esm = self.config.get('esm_dim', 0) > 0
+ self.esm_dim = self.config.get('esm_dim', 0)
+ self.esm_dir = esm_dir or os.path.join(os.environ.get('ALLOGEN_ROOT', '.'), 'data/esm2_embeddings')
+
+ # Cache receptor data
+ self.receptors = {} # label -> {coords, mask, aa_idx, chi, esm_emb?}
+
+ def load_receptor(self, pdb_path, chain='A', label='holo',
+ esm_target=None, esm_key=None):
+ """Pre-load and cache receptor structure, optionally with ESM embeddings.
+
+ Args:
+ pdb_path: path to receptor PDB
+ chain: chain ID
+ label: cache key
+ esm_target: target name for ESM dir (e.g., 'abl' for data/esm2_embeddings/abl/)
+ esm_key: ESM embedding file key (e.g., '6XR7_A'). If None, auto-derived.
+ """
+ import sys, os
+ _code_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+ if _code_dir not in sys.path:
+ sys.path.insert(0, _code_dir)
+ from utils.pdb_utils import (
+ load_structure, get_residues, get_backbone_coords,
+ get_aa_indices, compute_chi_angles
+ )
+
+ model = load_structure(pdb_path)
+ chain_obj = model[chain]
+ residues = get_residues(chain_obj)
+ coords, mask = get_backbone_coords(residues)
+ aa_idx = get_aa_indices(residues)
+ chi = compute_chi_angles(residues, mask)
+
+ rec_data = {
+ 'coords': torch.from_numpy(coords).float().to(self.device),
+ 'mask': torch.from_numpy(mask).bool().to(self.device),
+ 'aa_idx': torch.from_numpy(aa_idx).long().to(self.device),
+ 'chi': torch.from_numpy(chi).float().to(self.device),
+ 'residues': residues,
+ }
+
+ # Load ESM embeddings if model uses ESM
+ if self.use_esm and esm_target:
+ pdb_id = os.path.basename(pdb_path).replace('.pdb', '')
+ if esm_key is None:
+ esm_key = f'{pdb_id}_{chain}'
+ esm_path = os.path.join(self.esm_dir, esm_target, f'{esm_key}.pt')
+ if os.path.exists(esm_path):
+ esm_emb = torch.load(esm_path, map_location=self.device, weights_only=True)
+ # Truncate/pad to match residue count
+ n_res = len(residues)
+ if esm_emb.shape[0] > n_res:
+ esm_emb = esm_emb[:n_res]
+ elif esm_emb.shape[0] < n_res:
+ pad = torch.zeros(n_res - esm_emb.shape[0], esm_emb.shape[1],
+ device=self.device)
+ esm_emb = torch.cat([esm_emb, pad], dim=0)
+ rec_data['esm_emb'] = esm_emb.float()
+ else:
+ rec_data['esm_emb'] = torch.zeros(len(residues), self.esm_dim,
+ device=self.device)
+
+ self.receptors[label] = rec_data
+
+ def load_receptor_from_coords(self, coords, mask, aa_idx=None, chi=None,
+ label='path'):
+ """
+ Load a receptor from raw backbone coords (not from PDB file).
+
+ Used for interpolated path frames that don't have PDB files.
+ If aa_idx is None, uses all-ALA (index 0). If chi is None, uses zeros.
+
+ Args:
+ coords: [N, 4, 3] numpy or torch backbone coords (N, CA, C, O)
+ mask: [N] numpy or torch bool
+ aa_idx: [N] numpy or torch int (default: all-ALA = 0)
+ chi: [N, 4] numpy or torch float (default: zeros)
+ label: str key for caching
+ """
+ import numpy as np
+
+ # Convert numpy to torch if needed
+ if isinstance(coords, np.ndarray):
+ coords = torch.from_numpy(coords).float()
+ if isinstance(mask, np.ndarray):
+ mask = torch.from_numpy(mask).bool()
+
+ N = coords.shape[0]
+
+ if aa_idx is None:
+ aa_idx = torch.zeros(N, dtype=torch.long) # all-ALA
+ elif isinstance(aa_idx, np.ndarray):
+ aa_idx = torch.from_numpy(aa_idx).long()
+
+ if chi is None:
+ chi = torch.zeros(N, 4, dtype=coords.dtype)
+ elif isinstance(chi, np.ndarray):
+ chi = torch.from_numpy(chi).float()
+
+ self.receptors[label] = {
+ 'coords': coords.to(self.device),
+ 'mask': mask.to(self.device),
+ 'aa_idx': aa_idx.to(self.device),
+ 'chi': chi.to(self.device),
+ }
+
+ def score(self, binder_coords, binder_mask, binder_aa_idx=None,
+ binder_chi=None, receptor_label='holo', cutoff=8.0):
+ """
+ Score binder against a cached receptor. Differentiable w.r.t. binder_coords.
+
+ Args:
+ binder_coords: [N_binder, 4, 3] tensor (can have requires_grad=True)
+ binder_mask: [N_binder] bool tensor
+ binder_aa_idx: [N_binder] int tensor (default: all UNK)
+ binder_chi: [N_binder, 4] tensor (default: zeros)
+ receptor_label: key into cached receptors
+ cutoff: interface distance cutoff
+
+ Returns:
+ score: scalar tensor in (0, 1), differentiable w.r.t. binder_coords
+ """
+ rec = self.receptors[receptor_label]
+ N_binder = binder_coords.shape[0]
+
+ if binder_aa_idx is None:
+ binder_aa_idx = torch.full((N_binder,), 20, device=self.device, dtype=torch.long) # UNK
+ if binder_chi is None:
+ binder_chi = torch.zeros(N_binder, 4, device=self.device, dtype=binder_coords.dtype)
+
+ graph = build_differentiable_interface_graph(
+ rec_coords=rec['coords'],
+ rec_mask=rec['mask'],
+ rec_aa_idx=rec['aa_idx'],
+ rec_chi=rec['chi'],
+ binder_coords=binder_coords,
+ binder_mask=binder_mask,
+ binder_aa_idx=binder_aa_idx,
+ binder_chi=binder_chi,
+ cutoff=cutoff,
+ )
+
+ if graph is None:
+ # No interface β return zero score with gradient
+ return torch.zeros(1, device=self.device, dtype=binder_coords.dtype, requires_grad=True).squeeze()
+
+ # Build ESM features if model uses ESM
+ esm_feats = None
+ if self.use_esm:
+ n_rec = graph['n_rec']
+ n_binder = graph['n_binder']
+ n_total = n_rec + n_binder
+ # Receptor ESM: use cached if available, else zeros
+ if 'esm_emb' in rec:
+ rec_esm = rec['esm_emb']
+ # Need to select interface residues (same indices as structural features)
+ # The graph was built with rec_iface_idx β we need those indices
+ # For simplicity, use zeros for now and rely on the projection layer
+ # to handle the zero binder ESM gracefully
+ rec_esm_full = rec_esm # [N_rec_total, 1280]
+ else:
+ rec_esm_full = torch.zeros(rec['coords'].shape[0], self.esm_dim,
+ device=self.device)
+ # Binder ESM: zeros (designed backbone, no sequence)
+ binder_esm = torch.zeros(binder_coords.shape[0], self.esm_dim,
+ device=self.device)
+ # We need interface indices to select β rebuild them
+ rec_ca = rec['coords'][:, 1, :]
+ binder_ca = binder_coords[:, 1, :]
+ dist_mat = torch.cdist(rec_ca.unsqueeze(0), binder_ca.unsqueeze(0)).squeeze(0)
+ dist_mat_c = dist_mat.clone()
+ dist_mat_c[~rec['mask'], :] = float('inf')
+ dist_mat_c[:, ~binder_mask] = float('inf')
+ rec_iface = (dist_mat_c < cutoff).any(dim=1)
+ binder_iface = (dist_mat_c < cutoff).any(dim=0)
+ rec_iface_idx = torch.where(rec_iface)[0][:n_rec]
+ binder_iface_idx = torch.where(binder_iface)[0][:n_binder]
+
+ rec_esm_iface = rec_esm_full[rec_iface_idx] # [n_rec, 1280]
+ binder_esm_iface = binder_esm[binder_iface_idx] # [n_binder, 1280]
+ esm_combined = torch.cat([rec_esm_iface, binder_esm_iface], dim=0) # [n_total, 1280]
+ esm_feats = esm_combined.unsqueeze(0) # [1, n_total, 1280]
+
+ score = self.model(graph['node_feats'], graph['edge_feats'], graph['node_mask'],
+ esm_feats=esm_feats)
+ return score.squeeze() # scalar
+
+ def selectivity_margin(self, binder_coords, binder_mask,
+ binder_aa_idx=None, binder_chi=None,
+ holo_label='holo', apo_label='apo', cutoff=8.0):
+ """
+ Compute selectivity margin S = Q(holo, Y) - Q(apo, Y).
+ Differentiable w.r.t. binder_coords.
+ """
+ q_holo = self.score(binder_coords, binder_mask, binder_aa_idx, binder_chi,
+ holo_label, cutoff)
+ q_apo = self.score(binder_coords, binder_mask, binder_aa_idx, binder_chi,
+ apo_label, cutoff)
+ return q_holo - q_apo, q_holo, q_apo
diff --git a/code/models/features.py b/code/models/features.py
new file mode 100644
index 0000000000000000000000000000000000000000..617fb280917920b42cee631418c359464f4a0059
--- /dev/null
+++ b/code/models/features.py
@@ -0,0 +1,250 @@
+"""
+SE(3)-invariant feature extraction for interface graphs.
+Node and edge features used by the Q_theta scorer.
+"""
+
+import os
+import sys
+import numpy as np
+
+# Ensure utils is importable (for both direct and package imports)
+_CODE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _CODE_DIR not in sys.path:
+ sys.path.insert(0, _CODE_DIR)
+
+from utils.pdb_utils import (
+ rbf_encode, compute_backbone_frames, compute_torsion_angles,
+ get_aa_indices, compute_chi_angles, get_cb_positions, NUM_AA
+)
+
+# Feature dimensions
+# one-hot AA (21) + backbone torsions (6) + chi1 sin/cos (2) + chi2 sin/cos (2) + chain indicator (1) = 32
+NODE_DIM = NUM_AA + 6 + 4 + 1 # = 32
+EDGE_DIM = 16 + 3 + 9 + 8 + 1 # RBF dist (16) + direction (3) + rel rotation (9) + seq sep (8) + same chain (1) = 37
+
+MAX_SEQ_SEP = 32 # bins for sequence separation
+
+
+def seq_sep_encode(sep, n_bins=8, max_sep=MAX_SEQ_SEP):
+ """Bin-encode sequence separation."""
+ bins = np.linspace(-max_sep, max_sep, n_bins + 1)
+ sep_clipped = np.clip(sep, -max_sep, max_sep)
+ encoded = np.zeros(n_bins, dtype=np.float32)
+ bin_idx = np.digitize(sep_clipped, bins) - 1
+ bin_idx = np.clip(bin_idx, 0, n_bins - 1)
+ encoded[bin_idx] = 1.0
+ return encoded
+
+
+def extract_node_features(residues, coords, mask, torsion_angles, chi_angles, chain_id):
+ """
+ Compute per-residue node features.
+
+ Args:
+ residues: list of Bio.PDB residues
+ coords: [N, 4, 3] backbone coords
+ mask: [N] bool
+ torsion_angles: [N, 6] sin/cos of phi, psi, omega
+ chi_angles: [N, 4] sin/cos of chi1, chi2
+ chain_id: 0 = receptor, 1 = binder
+
+ Returns:
+ node_feats: [N, NODE_DIM] (NODE_DIM = 32)
+ """
+ N = len(residues)
+ aa_idx = get_aa_indices(residues)
+
+ # One-hot amino acid
+ aa_onehot = np.zeros((N, NUM_AA), dtype=np.float32)
+ for i in range(N):
+ if mask[i]:
+ aa_onehot[i, aa_idx[i]] = 1.0
+
+ # Chain indicator
+ chain_feat = np.full((N, 1), chain_id, dtype=np.float32)
+
+ # Concatenate
+ node_feats = np.concatenate([
+ aa_onehot, # [N, 21]
+ torsion_angles, # [N, 6]
+ chi_angles, # [N, 4]
+ chain_feat, # [N, 1]
+ ], axis=-1)
+
+ return node_feats # [N, 32]
+
+
+def extract_edge_features(coords_i, frames_i, coords_j, frames_j,
+ seq_idx_i, seq_idx_j, chain_i, chain_j, mask_i, mask_j):
+ """
+ Compute SE(3)-invariant edge features between residue sets i and j.
+ Vectorized over all pairs.
+
+ Args:
+ coords_i: [N_i, 4, 3] backbone coords of set i (full interface)
+ frames_i: (origins_i [N_i, 3], rotations_i [N_i, 3, 3])
+ coords_j: [N_j, 4, 3]
+ frames_j: (origins_j [N_j, 3], rotations_j [N_j, 3, 3])
+ seq_idx_i: [N_i] integer sequence indices (for sequence separation)
+ seq_idx_j: [N_j] integer sequence indices
+ chain_i: int (0 or 1)
+ chain_j: int (0 or 1)
+ mask_i: [N_i] bool
+ mask_j: [N_j] bool
+
+ Returns:
+ edge_feats: [N_i, N_j, EDGE_DIM]
+ """
+ N_i, N_j = len(coords_i), len(coords_j)
+ origins_i, rotations_i = frames_i
+ origins_j, rotations_j = frames_j
+
+ ca_i = origins_i # [N_i, 3]
+ ca_j = origins_j # [N_j, 3]
+
+ # --- Distance features ---
+ diff = ca_j[None, :, :] - ca_i[:, None, :] # [N_i, N_j, 3]
+ dist = np.sqrt((diff ** 2).sum(axis=-1)) # [N_i, N_j]
+ dist_rbf = rbf_encode(dist, d_min=0., d_max=20., n_bins=16) # [N_i, N_j, 16]
+
+ # --- Direction in local frame of i ---
+ # unit vector from i to j in global frame
+ unit_diff = diff / (dist[..., None] + 1e-8) # [N_i, N_j, 3]
+ # rotate by R_i^T to get local direction
+ # rotations_i: [N_i, 3, 3], unit_diff: [N_i, N_j, 3]
+ # local_dir[i,j] = R_i^T @ (ca_j - ca_i) / dist
+ local_dir = np.einsum('ikl,ijl->ijk', rotations_i, unit_diff) # [N_i, N_j, 3]
+
+ # --- Relative rotation: R_i^T R_j ---
+ # rotations_i: [N_i, 3, 3], rotations_j: [N_j, 3, 3]
+ # rel_rot[i,j] = R_i^T @ R_j -> [N_i, N_j, 3, 3] -> flatten to [N_i, N_j, 9]
+ rel_rot = np.einsum('ikl,jlm->ijkm', rotations_i, rotations_j) # [N_i, N_j, 3, 3]
+ rel_rot_flat = rel_rot.reshape(N_i, N_j, 9) # [N_i, N_j, 9]
+
+ # --- Sequence separation ---
+ sep = seq_idx_j[None, :] - seq_idx_i[:, None] # [N_i, N_j]
+ # Encode each pair (loop over all; use vectorized bin assignment)
+ sep_flat = sep.reshape(-1)
+ sep_enc = np.array([seq_sep_encode(s) for s in sep_flat]) # [N_i*N_j, 8]
+ sep_enc = sep_enc.reshape(N_i, N_j, 8)
+
+ # Cross-chain pairs get sep=0 by convention if different chains
+ if chain_i != chain_j:
+ sep_enc[:] = 0.0
+
+ # --- Same chain indicator ---
+ same_chain = float(chain_i == chain_j)
+ same_chain_feat = np.full((N_i, N_j, 1), same_chain, dtype=np.float32)
+
+ # --- Concatenate ---
+ edge_feats = np.concatenate([
+ dist_rbf, # [N_i, N_j, 16]
+ local_dir, # [N_i, N_j, 3]
+ rel_rot_flat, # [N_i, N_j, 9]
+ sep_enc, # [N_i, N_j, 8]
+ same_chain_feat # [N_i, N_j, 1]
+ ], axis=-1) # [N_i, N_j, 37]
+
+ # Zero out edges involving masked residues
+ edge_feats[~mask_i, :, :] = 0.0
+ edge_feats[:, ~mask_j, :] = 0.0
+
+ return edge_feats.astype(np.float32)
+
+
+def build_interface_graph(rec_residues, rec_coords, rec_mask,
+ binder_residues, binder_coords, binder_mask,
+ rec_interface_mask, binder_interface_mask,
+ max_nodes: int = 128):
+ """
+ Build a joint interface graph combining receptor and binder interface residues.
+
+ Returns a dict with:
+ node_feats: [N_total, NODE_DIM]
+ edge_feats: [N_total, N_total, EDGE_DIM]
+ node_mask: [N_total] bool
+ n_rec: int (number of receptor interface nodes)
+ n_binder: int (number of binder interface nodes)
+ """
+ # Select interface residues
+ rec_iface_idx = np.where(rec_interface_mask)[0]
+ binder_iface_idx = np.where(binder_interface_mask)[0]
+
+ # Truncate if too many
+ if len(rec_iface_idx) > max_nodes // 2:
+ rec_iface_idx = rec_iface_idx[:max_nodes // 2]
+ if len(binder_iface_idx) > max_nodes // 2:
+ binder_iface_idx = binder_iface_idx[:max_nodes // 2]
+
+ n_rec = len(rec_iface_idx)
+ n_binder = len(binder_iface_idx)
+ n_total = n_rec + n_binder
+
+ if n_total == 0:
+ return None
+
+ # Extract coords for interface residues
+ rec_iface_coords = rec_coords[rec_iface_idx] # [n_rec, 4, 3]
+ binder_iface_coords = binder_coords[binder_iface_idx] # [n_binder, 4, 3]
+ rec_iface_mask = rec_mask[rec_iface_idx]
+ binder_iface_mask = binder_mask[binder_iface_idx]
+
+ # Compute backbone frames
+ rec_origins, rec_rotations = compute_backbone_frames(rec_iface_coords, rec_iface_mask)
+ binder_origins, binder_rotations = compute_backbone_frames(binder_iface_coords, binder_iface_mask)
+
+ # Compute torsion angles
+ # We need full-chain coords for proper phi/psi computation, but use local approximation here
+ rec_torsion = compute_torsion_angles(rec_iface_coords, rec_iface_mask)
+ binder_torsion = compute_torsion_angles(binder_iface_coords, binder_iface_mask)
+
+ # Extract residues
+ rec_iface_residues = [rec_residues[i] for i in rec_iface_idx]
+ binder_iface_residues = [binder_residues[i] for i in binder_iface_idx]
+
+ # Compute sidechain chi1/chi2 angles
+ rec_chi = compute_chi_angles(rec_iface_residues, rec_iface_mask)
+ binder_chi = compute_chi_angles(binder_iface_residues, binder_iface_mask)
+
+ # Node features
+ rec_node_feats = extract_node_features(
+ rec_iface_residues, rec_iface_coords, rec_iface_mask, rec_torsion, rec_chi, chain_id=0
+ ) # [n_rec, NODE_DIM]
+ binder_node_feats = extract_node_features(
+ binder_iface_residues, binder_iface_coords, binder_iface_mask, binder_torsion, binder_chi, chain_id=1
+ ) # [n_binder, NODE_DIM]
+
+ node_feats = np.concatenate([rec_node_feats, binder_node_feats], axis=0) # [N, NODE_DIM]
+ node_mask = np.concatenate([rec_iface_mask, binder_iface_mask], axis=0)
+
+ # Edge features (4 blocks: RR, RB, BR, BB)
+ all_coords = np.concatenate([rec_iface_coords, binder_iface_coords], axis=0)
+ all_mask = node_mask
+ all_origins = np.concatenate([rec_origins, binder_origins], axis=0)
+ all_rotations = np.concatenate([rec_rotations, binder_rotations], axis=0)
+ all_seq_idx = np.concatenate([rec_iface_idx, binder_iface_idx + len(rec_residues)], axis=0)
+ all_chain = np.array([0] * n_rec + [1] * n_binder, dtype=np.int32)
+
+ # Compute full NxN edge features
+ frames_all = (all_origins, all_rotations)
+ edge_feats = extract_edge_features(
+ all_coords, frames_all,
+ all_coords, frames_all,
+ all_seq_idx, all_seq_idx,
+ -1, -1, # chain handled via all_chain array below
+ all_mask, all_mask
+ ) # [N, N, EDGE_DIM]
+
+ # Patch same_chain feature (last dim) using actual chain IDs
+ same_chain_feat = (all_chain[:, None] == all_chain[None, :]).astype(np.float32)
+ edge_feats[:, :, -1] = same_chain_feat
+
+ return {
+ 'node_feats': node_feats.astype(np.float32), # [N, NODE_DIM]
+ 'edge_feats': edge_feats.astype(np.float32), # [N, N, EDGE_DIM]
+ 'node_mask': node_mask, # [N]
+ 'n_rec': n_rec,
+ 'n_binder': n_binder,
+ 'rec_iface_idx': rec_iface_idx, # [n_rec] original residue indices
+ 'binder_iface_idx': binder_iface_idx, # [n_binder] original residue indices
+ }
diff --git a/code/models/scorer.py b/code/models/scorer.py
new file mode 100644
index 0000000000000000000000000000000000000000..46d82f1a1217e24d80c1f12eb68eb58f1345e437
--- /dev/null
+++ b/code/models/scorer.py
@@ -0,0 +1,585 @@
+"""
+Q_theta: State-selectivity scorer for Allo-Designer.
+
+Architecture: Dense Edge-Biased Graph Transformer
+ - Input: padded interface graph (node feats + pairwise edge feats)
+ - SE(3)-invariant features (all features from distances/angles in backbone frames)
+ - Output: Q_theta(X, Y) in (0,1) = probability-like compatibility/selectivity score
+
+No torch_geometric dependency: uses dense attention with edge biases.
+"""
+
+import torch
+import torch.nn as nn
+import torch.nn.functional as F
+import math
+
+
+class RBFLayer(nn.Module):
+ """Learnable RBF embedding for edge distances."""
+ def __init__(self, n_bins: int = 16, d_min: float = 0., d_max: float = 20.):
+ super().__init__()
+ centers = torch.linspace(d_min, d_max, n_bins)
+ self.register_buffer('centers', centers)
+ self.log_sigma = nn.Parameter(torch.zeros(1))
+
+ def forward(self, dist):
+ # dist: [...] -> [..., n_bins]
+ sigma = torch.exp(self.log_sigma)
+ return torch.exp(-((dist.unsqueeze(-1) - self.centers) ** 2) / (2 * sigma ** 2))
+
+
+class EdgeBiasedMHA(nn.Module):
+ """
+ Multi-Head Self-Attention with additive edge biases.
+ Implements the core equation:
+ A_ij = (Q_i K_j^T / sqrt(d)) + b_ij
+ where b_ij is computed from edge features.
+ """
+ def __init__(self, d_model: int, n_heads: int, d_edge: int, dropout: float = 0.1):
+ super().__init__()
+ assert d_model % n_heads == 0
+ self.n_heads = n_heads
+ self.d_head = d_model // n_heads
+ self.scale = math.sqrt(self.d_head)
+
+ self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
+ self.out_proj = nn.Linear(d_model, d_model)
+ self.edge_proj = nn.Linear(d_edge, n_heads) # edge features -> per-head bias
+ self.dropout = nn.Dropout(dropout)
+
+ def forward(self, x, edge_feats, mask=None):
+ """
+ x: [B, N, d_model]
+ edge_feats: [B, N, N, d_edge]
+ mask: [B, N] bool (True = valid residue)
+ """
+ B, N, D = x.shape
+ H = self.n_heads
+
+ # QKV projection
+ qkv = self.qkv_proj(x).reshape(B, N, 3, H, self.d_head).permute(2, 0, 3, 1, 4)
+ q, k, v = qkv.unbind(0) # each [B, H, N, d_head]
+
+ # Scaled dot-product attention logits
+ attn_logits = (q @ k.transpose(-2, -1)) / self.scale # [B, H, N, N]
+
+ # Edge bias: [B, N, N, H] -> [B, H, N, N]
+ edge_bias = self.edge_proj(edge_feats).permute(0, 3, 1, 2) # [B, H, N, N]
+ attn_logits = attn_logits + edge_bias
+
+ # Padding mask: mask out padded positions
+ if mask is not None:
+ # mask: [B, N] True=valid; padding=False
+ padding = ~mask # [B, N] True=padding
+ attn_logits = attn_logits.masked_fill(
+ padding[:, None, None, :], # [B, 1, 1, N]
+ float('-inf')
+ )
+
+ attn_weights = self.dropout(F.softmax(attn_logits, dim=-1))
+
+ # Handle all-padding rows (NaN -> 0)
+ attn_weights = torch.nan_to_num(attn_weights, nan=0.0)
+
+ out = (attn_weights @ v) # [B, H, N, d_head]
+ out = out.transpose(1, 2).reshape(B, N, D) # [B, N, D]
+ return self.out_proj(out)
+
+
+class InterfaceTransformerLayer(nn.Module):
+ """Single layer of edge-biased transformer with pre-norm."""
+ def __init__(self, d_model: int, n_heads: int, d_edge: int, ff_mult: int = 4, dropout: float = 0.1):
+ super().__init__()
+ self.attn = EdgeBiasedMHA(d_model, n_heads, d_edge, dropout)
+ self.ff = nn.Sequential(
+ nn.Linear(d_model, d_model * ff_mult),
+ nn.GELU(),
+ nn.Dropout(dropout),
+ nn.Linear(d_model * ff_mult, d_model),
+ )
+ self.norm1 = nn.LayerNorm(d_model)
+ self.norm2 = nn.LayerNorm(d_model)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x, edge_feats, mask=None):
+ x = x + self.drop(self.attn(self.norm1(x), edge_feats, mask))
+ x = x + self.drop(self.ff(self.norm2(x)))
+ return x
+
+
+class GATLayer(nn.Module):
+ """Multi-head GAT layer with pre-norm. No edge features in attention."""
+ def __init__(self, d_model: int, n_heads: int, ff_mult: int = 4, dropout: float = 0.1):
+ super().__init__()
+ assert d_model % n_heads == 0
+ self.n_heads = n_heads
+ self.d_head = d_model // n_heads
+
+ self.W = nn.Linear(d_model, d_model, bias=False)
+ self.a_l = nn.Parameter(torch.randn(n_heads, self.d_head))
+ self.a_r = nn.Parameter(torch.randn(n_heads, self.d_head))
+ nn.init.xavier_uniform_(self.a_l.unsqueeze(0))
+ nn.init.xavier_uniform_(self.a_r.unsqueeze(0))
+ self.out_proj = nn.Linear(d_model, d_model)
+ self.leaky_relu = nn.LeakyReLU(0.2)
+ self.attn_drop = nn.Dropout(dropout)
+
+ self.ff = nn.Sequential(
+ nn.Linear(d_model, d_model * ff_mult), nn.GELU(),
+ nn.Dropout(dropout), nn.Linear(d_model * ff_mult, d_model),
+ )
+ self.norm1 = nn.LayerNorm(d_model)
+ self.norm2 = nn.LayerNorm(d_model)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x, edge_feats, mask=None):
+ B, N, D = x.shape
+ H = self.n_heads
+
+ h = self.norm1(x)
+ Wh = self.W(h).view(B, N, H, self.d_head) # [B, N, H, d_head]
+ e_l = (Wh * self.a_l).sum(-1) # [B, N, H]
+ e_r = (Wh * self.a_r).sum(-1) # [B, N, H]
+ attn = self.leaky_relu(e_l.unsqueeze(2) + e_r.unsqueeze(1)) # [B, N, N, H]
+ attn = attn.permute(0, 3, 1, 2) # [B, H, N, N]
+
+ if mask is not None:
+ attn = attn.masked_fill(~mask[:, None, None, :], float('-inf'))
+
+ attn = self.attn_drop(F.softmax(attn, dim=-1))
+ attn = torch.nan_to_num(attn, nan=0.0)
+
+ out = torch.einsum('bhnm,bmhd->bnhd', attn, Wh)
+ out = out.reshape(B, N, D)
+ x = x + self.drop(self.out_proj(out))
+ x = x + self.drop(self.ff(self.norm2(x)))
+ return x
+
+
+class GCNLayer(nn.Module):
+ """GCN layer with edge-weighted message passing and pre-norm."""
+ def __init__(self, d_model: int, d_edge: int, ff_mult: int = 4, dropout: float = 0.1):
+ super().__init__()
+ self.msg_proj = nn.Linear(d_model, d_model, bias=False)
+ self.edge_weight = nn.Linear(d_edge, 1)
+
+ self.ff = nn.Sequential(
+ nn.Linear(d_model, d_model * ff_mult), nn.GELU(),
+ nn.Dropout(dropout), nn.Linear(d_model * ff_mult, d_model),
+ )
+ self.norm1 = nn.LayerNorm(d_model)
+ self.norm2 = nn.LayerNorm(d_model)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x, edge_feats, mask=None):
+ B, N, D = x.shape
+ h = self.norm1(x)
+ msg = self.msg_proj(h) # [B, N, D]
+
+ w = self.edge_weight(edge_feats).squeeze(-1) # [B, N, N]
+ if mask is not None:
+ w = w.masked_fill(~mask[:, None, :], float('-inf'))
+ w = F.softmax(w, dim=-1)
+ w = torch.nan_to_num(w, nan=0.0)
+
+ agg = torch.bmm(w, msg) # [B, N, D]
+ x = x + self.drop(agg)
+ x = x + self.drop(self.ff(self.norm2(x)))
+ return x
+
+
+class CrossChainTransformerLayer(nn.Module):
+ """Cross-chain attention: each node attends only to nodes from the other chain."""
+ def __init__(self, d_model: int, n_heads: int, d_edge: int, ff_mult: int = 4, dropout: float = 0.1):
+ super().__init__()
+ assert d_model % n_heads == 0
+ self.n_heads = n_heads
+ self.d_head = d_model // n_heads
+ self.scale = math.sqrt(self.d_head)
+
+ self.qkv_proj = nn.Linear(d_model, 3 * d_model, bias=False)
+ self.out_proj = nn.Linear(d_model, d_model)
+ self.edge_proj = nn.Linear(d_edge, n_heads)
+ self.attn_drop = nn.Dropout(dropout)
+
+ self.ff = nn.Sequential(
+ nn.Linear(d_model, d_model * ff_mult), nn.GELU(),
+ nn.Dropout(dropout), nn.Linear(d_model * ff_mult, d_model),
+ )
+ self.norm1 = nn.LayerNorm(d_model)
+ self.norm2 = nn.LayerNorm(d_model)
+ self.drop = nn.Dropout(dropout)
+
+ def forward(self, x, edge_feats, mask=None, chain_mask=None):
+ """
+ x: [B, N, d_model]
+ edge_feats: [B, N, N, d_edge]
+ mask: [B, N] bool (True = valid)
+ chain_mask: [B, N] float (0=receptor, 1=binder)
+ """
+ B, N, D = x.shape
+ H = self.n_heads
+
+ h = self.norm1(x)
+ qkv = self.qkv_proj(h).reshape(B, N, 3, H, self.d_head).permute(2, 0, 3, 1, 4)
+ q, k, v = qkv.unbind(0) # each [B, H, N, d_head]
+
+ attn_logits = (q @ k.transpose(-2, -1)) / self.scale # [B, H, N, N]
+ edge_bias = self.edge_proj(edge_feats).permute(0, 3, 1, 2) # [B, H, N, N]
+ attn_logits = attn_logits + edge_bias
+
+ # Mask padding
+ if mask is not None:
+ attn_logits = attn_logits.masked_fill(~mask[:, None, None, :], float('-inf'))
+
+ # Cross-chain mask: block same-chain attention
+ if chain_mask is not None:
+ same_chain = (chain_mask.unsqueeze(1) == chain_mask.unsqueeze(2)) # [B, N, N]
+ attn_logits = attn_logits.masked_fill(same_chain[:, None, :, :], float('-inf'))
+
+ attn_weights = self.attn_drop(F.softmax(attn_logits, dim=-1))
+ attn_weights = torch.nan_to_num(attn_weights, nan=0.0)
+
+ out = (attn_weights @ v).transpose(1, 2).reshape(B, N, D)
+ x = x + self.drop(self.out_proj(out))
+ x = x + self.drop(self.ff(self.norm2(x)))
+ return x
+
+
+class EdgeUpdateLayer(nn.Module):
+ """Updates edge features using node representations each layer.
+ Memory-efficient: projects nodes to low-dim before outer product."""
+ def __init__(self, d_model: int, d_edge: int, dropout: float = 0.1):
+ super().__init__()
+ d_proj = min(32, d_model // 4) # Low-dim projection to save memory
+ self.proj_i = nn.Linear(d_model, d_proj, bias=False)
+ self.proj_j = nn.Linear(d_model, d_proj, bias=False)
+ self.edge_mlp = nn.Sequential(
+ nn.Linear(2 * d_proj + d_edge, d_edge),
+ nn.GELU(),
+ nn.Dropout(dropout),
+ nn.Linear(d_edge, d_edge),
+ )
+ self.norm = nn.LayerNorm(d_edge)
+
+ def forward(self, h, e, mask=None):
+ B, N, D = h.shape
+ hi = self.proj_i(h).unsqueeze(2).expand(-1, -1, N, -1) # [B, N, N, d_proj]
+ hj = self.proj_j(h).unsqueeze(1).expand(-1, N, -1, -1) # [B, N, N, d_proj]
+ inp = torch.cat([hi, hj, self.norm(e)], dim=-1)
+ e = e + self.edge_mlp(inp)
+ return e
+
+
+class InterfaceGNN(nn.Module):
+ """
+ Q_theta scorer: SE(3)-invariant dense graph transformer for interface scoring.
+
+ Input:
+ node_feats: [B, N, node_dim] per-residue features
+ edge_feats: [B, N, N, edge_dim] pairwise edge features
+ mask: [B, N] bool (True = valid residue, False = padding)
+
+ Output:
+ scores: [B] in (0, 1) = Q_theta(X, Y)
+ """
+ def __init__(
+ self,
+ node_dim: int = 28,
+ edge_dim: int = 37,
+ hidden_dim: int = 128,
+ n_layers: int = 4,
+ n_heads: int = 8,
+ ff_mult: int = 4,
+ dropout: float = 0.1,
+ backbone: str = 'transformer',
+ pooling: str = 'meanmax', # 'meanmax' or 'attention'
+ edge_update: bool = False,
+ esm_dim: int = 0, # 0 = no ESM; >0 = ESM embedding dim to project
+ esm_proj_dim: int = 128, # projection dim for ESM features
+ esm_dropout: float = 0.0, # dropout on ESM projection
+ ):
+ super().__init__()
+ actual_node_dim = node_dim + (esm_proj_dim if esm_dim > 0 else 0)
+ self.esm_dim = esm_dim
+ if esm_dim > 0:
+ layers = [
+ nn.Linear(esm_dim, esm_proj_dim),
+ nn.LayerNorm(esm_proj_dim),
+ nn.GELU(),
+ ]
+ if esm_dropout > 0:
+ layers.append(nn.Dropout(esm_dropout))
+ self.esm_proj = nn.Sequential(*layers)
+ self.node_embed = nn.Sequential(
+ nn.Linear(actual_node_dim, hidden_dim),
+ nn.LayerNorm(hidden_dim),
+ nn.GELU(),
+ )
+ self.edge_embed = nn.Sequential(
+ nn.Linear(edge_dim, hidden_dim),
+ nn.GELU(),
+ nn.Linear(hidden_dim, hidden_dim // 2),
+ )
+ d_edge_hidden = hidden_dim // 2
+
+ if backbone == 'transformer':
+ self.layers = nn.ModuleList([
+ InterfaceTransformerLayer(hidden_dim, n_heads, d_edge_hidden, ff_mult, dropout)
+ for _ in range(n_layers)
+ ])
+ elif backbone == 'gat':
+ self.layers = nn.ModuleList([
+ GATLayer(hidden_dim, n_heads, ff_mult, dropout)
+ for _ in range(n_layers)
+ ])
+ elif backbone == 'gcn':
+ self.layers = nn.ModuleList([
+ GCNLayer(hidden_dim, d_edge_hidden, ff_mult, dropout)
+ for _ in range(n_layers)
+ ])
+ elif backbone == 'crosschain':
+ # Interleave self-attention and cross-chain attention
+ layers = []
+ for i in range(n_layers):
+ if i % 2 == 0:
+ layers.append(InterfaceTransformerLayer(hidden_dim, n_heads, d_edge_hidden, ff_mult, dropout))
+ else:
+ layers.append(CrossChainTransformerLayer(hidden_dim, n_heads, d_edge_hidden, ff_mult, dropout))
+ self.layers = nn.ModuleList(layers)
+ else:
+ raise ValueError(f"Unknown backbone: {backbone}")
+
+ self.norm_out = nn.LayerNorm(hidden_dim)
+
+ # Edge update layers (optional)
+ self.edge_update = edge_update
+ if edge_update:
+ self.edge_update_layers = nn.ModuleList([
+ EdgeUpdateLayer(hidden_dim, d_edge_hidden, dropout)
+ for _ in range(n_layers)
+ ])
+
+ # Pooling
+ self.pooling = pooling
+ if pooling == 'attention':
+ self.attn_pool = nn.Sequential(
+ nn.Linear(hidden_dim, hidden_dim // 2),
+ nn.Tanh(),
+ nn.Linear(hidden_dim // 2, 1),
+ )
+ pool_dim = hidden_dim
+ else:
+ pool_dim = 2 * hidden_dim
+
+ # Scoring head
+ self.head = nn.Sequential(
+ nn.Linear(pool_dim, hidden_dim),
+ nn.GELU(),
+ nn.Dropout(dropout),
+ nn.Linear(hidden_dim, hidden_dim // 2),
+ nn.GELU(),
+ nn.Linear(hidden_dim // 2, 1),
+ )
+
+ def forward(self, node_feats, edge_feats, mask, esm_feats=None):
+ """
+ node_feats: [B, N, node_dim]
+ edge_feats: [B, N, N, edge_dim]
+ mask: [B, N] bool
+ esm_feats: [B, N, esm_dim] optional ESM-2 embeddings
+ Returns: scores [B] in (0, 1)
+ """
+ B, N, _ = node_feats.shape
+
+ # Extract chain mask for cross-chain attention (last dim = chain indicator)
+ chain_mask = node_feats[:, :, -1] # [B, N] float: 0=receptor, 1=binder
+
+ # Optionally concatenate projected ESM features
+ if self.esm_dim > 0 and esm_feats is not None:
+ esm_proj = self.esm_proj(esm_feats) # [B, N, 128]
+ node_feats = torch.cat([node_feats, esm_proj], dim=-1)
+
+ # Embed nodes and edges
+ h = self.node_embed(node_feats) # [B, N, hidden_dim]
+ e = self.edge_embed(edge_feats) # [B, N, N, hidden_dim//2]
+
+ # Graph transformer layers (with optional edge updates)
+ for i, layer in enumerate(self.layers):
+ if isinstance(layer, CrossChainTransformerLayer):
+ h = layer(h, e, mask, chain_mask=chain_mask)
+ else:
+ h = layer(h, e, mask)
+ if self.edge_update:
+ e = self.edge_update_layers[i](h, e, mask)
+
+ h = self.norm_out(h) # [B, N, hidden_dim]
+
+ # Pooling
+ mask_f = mask.float().unsqueeze(-1) # [B, N, 1]
+
+ if self.pooling == 'attention':
+ # Learned attention pooling
+ attn_logits = self.attn_pool(h).squeeze(-1) # [B, N]
+ attn_logits = attn_logits.masked_fill(~mask, float('-inf'))
+ attn_weights = F.softmax(attn_logits, dim=-1).unsqueeze(-1) # [B, N, 1]
+ attn_weights = torch.nan_to_num(attn_weights, nan=0.0)
+ h_pool = (h * attn_weights).sum(dim=1) # [B, hidden_dim]
+ else:
+ # Mean + max pooling
+ h_masked = h * mask_f
+ h_mean = h_masked.sum(dim=1) / (mask_f.sum(dim=1) + 1e-8)
+ h_max_input = h_masked + (1 - mask_f) * (-1e9)
+ h_max = h_max_input.max(dim=1).values
+ h_pool = torch.cat([h_mean, h_max], dim=-1) # [B, 2*hidden_dim]
+
+ # Score
+ logits = self.head(h_pool).squeeze(-1) # [B]
+ scores = torch.sigmoid(logits) # [B] in (0, 1)
+ return scores
+
+
+class AlloDesignerScorer(nn.Module):
+ """
+ Full Q_theta model wrapper with loss computation.
+
+ Implements the two-stage training objective:
+ Phase 1: DockQ regression (MSE loss)
+ Phase 2: Selectivity margin ranking (contrastive loss)
+
+ The selectivity margin from the paper (Eq. 3):
+ S_theta(Y; X+, N) = logit(Q(X+, Y)) - log sum_X- exp(logit(Q(X-, Y)))
+ """
+ def __init__(self, node_dim=28, edge_dim=37, hidden_dim=128,
+ n_layers=4, n_heads=8, dropout=0.1, backbone='transformer',
+ pooling='meanmax', edge_update=False, esm_dim=0,
+ esm_proj_dim=128, esm_dropout=0.0):
+ super().__init__()
+ self.gnn = InterfaceGNN(node_dim, edge_dim, hidden_dim, n_layers, n_heads,
+ dropout=dropout, backbone=backbone,
+ pooling=pooling, edge_update=edge_update,
+ esm_dim=esm_dim, esm_proj_dim=esm_proj_dim,
+ esm_dropout=esm_dropout)
+
+ def forward(self, node_feats, edge_feats, mask, esm_feats=None):
+ return self.gnn(node_feats, edge_feats, mask, esm_feats=esm_feats)
+
+ def compute_dockq_loss(self, scores, dockq_labels):
+ """Phase 1: MSE regression loss against DockQ labels."""
+ return F.mse_loss(scores, dockq_labels.float())
+
+ def compute_selectivity_loss(self, pos_scores, neg_scores_list, margin: float = 0.2):
+ """
+ Phase 2: Selectivity margin loss.
+
+ For each binder Y:
+ pos_score = Q(X+, Y)
+ neg_scores = [Q(X-, Y) for X- in N]
+
+ Loss = -mean(S_theta) where
+ S_theta = logit(pos_score) - log sum exp(logit(neg_scores))
+
+ Also computes a soft margin loss:
+ L_margin = mean(max(0, margin - (pos_score - neg_score)))
+ """
+ # logit = log(p / (1-p))
+ eps = 1e-6
+ pos_logit = torch.log(pos_scores.clamp(eps, 1 - eps) / (1 - pos_scores).clamp(eps))
+
+ # neg_scores_list: list of [B] tensors
+ neg_logits = torch.stack([
+ torch.log(s.clamp(eps, 1 - eps) / (1 - s).clamp(eps))
+ for s in neg_scores_list
+ ], dim=-1) # [B, n_neg]
+
+ # InfoNCE-style selectivity margin
+ log_denom = torch.logsumexp(neg_logits, dim=-1) # [B]
+ selectivity = pos_logit - log_denom # [B]
+ selectivity_loss = -selectivity.mean()
+
+ # Soft margin loss (averaged over all negatives)
+ margin_losses = []
+ for neg_scores in neg_scores_list:
+ margin_losses.append(F.relu(margin - (pos_scores - neg_scores)))
+ margin_loss = torch.stack(margin_losses, dim=-1).mean()
+
+ return selectivity_loss + margin_loss
+
+ def compute_path_selectivity_loss(self, pos_scores, neg_scores_list,
+ path_scores_list, path_taus,
+ margin=0.2, path_lambda=0.5):
+ """
+ Extended selectivity loss with path monotonicity regularization.
+
+ Args:
+ pos_scores: [B] Q(X1, Y) -- goal state scores
+ neg_scores_list: list of [B] -- Q(X0, Y), Q(X_cryptic, Y), etc.
+ path_scores_list: list of [B] -- Q(X_tau, Y) for each path frame
+ path_taus: list of float -- tau values for each path frame (sorted)
+ margin: margin for ranking loss
+ path_lambda: weight for path monotonicity loss
+
+ Returns:
+ total_loss: selectivity loss + path_lambda * monotonicity loss
+ loss_dict: breakdown of loss components
+ """
+ # Standard selectivity loss (unchanged)
+ select_loss = self.compute_selectivity_loss(pos_scores, neg_scores_list, margin)
+
+ # Path monotonicity loss: ensure Q increases with tau
+ loss_monotone = torch.tensor(0.0, device=pos_scores.device)
+ if path_scores_list and path_lambda > 0:
+ small_margin = 0.05
+ # Consecutive path frames should be monotonically increasing
+ for i in range(len(path_scores_list) - 1):
+ loss_monotone = loss_monotone + F.relu(
+ path_scores_list[i] - path_scores_list[i + 1] + small_margin
+ ).mean()
+ # Last path frame should be less than positive (holo) score
+ loss_monotone = loss_monotone + F.relu(
+ path_scores_list[-1] - pos_scores + margin
+ ).mean()
+ # First path frame should be greater than negative (apo) score
+ if neg_scores_list:
+ loss_monotone = loss_monotone + F.relu(
+ neg_scores_list[0] - path_scores_list[0] + small_margin
+ ).mean()
+
+ total = select_loss + path_lambda * loss_monotone
+ return total, {
+ 'loss_selectivity': select_loss.item(),
+ 'loss_path_monotone': loss_monotone.item(),
+ }
+
+ def compute_combined_loss(self, pos_scores, neg_scores_list, dockq_labels,
+ lambda_rank: float = 1.0):
+ """Combined Phase 1 + Phase 2 loss."""
+ # Regression loss on all scores (pos + neg get appropriate labels)
+ dockq_loss = self.compute_dockq_loss(pos_scores, dockq_labels)
+
+ # Selectivity loss
+ select_loss = self.compute_selectivity_loss(pos_scores, neg_scores_list)
+
+ return dockq_loss + lambda_rank * select_loss, {
+ 'loss_dockq': dockq_loss.item(),
+ 'loss_selectivity': select_loss.item(),
+ }
+
+
+def build_model(config: dict) -> AlloDesignerScorer:
+ """Build the Q_theta scorer from a config dict."""
+ return AlloDesignerScorer(
+ node_dim=config.get('node_dim', 32),
+ edge_dim=config.get('edge_dim', 37),
+ hidden_dim=config.get('hidden_dim', 128),
+ n_layers=config.get('n_layers', 4),
+ n_heads=config.get('n_heads', 8),
+ dropout=config.get('dropout', 0.1),
+ backbone=config.get('backbone', 'transformer'),
+ pooling=config.get('pooling', 'meanmax'),
+ edge_update=config.get('edge_update', False),
+ esm_dim=config.get('esm_dim', 0),
+ esm_proj_dim=config.get('esm_proj_dim', 128),
+ esm_dropout=config.get('esm_dropout', 0.0),
+ )
diff --git a/code/requirements.txt b/code/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..d82e50f56db6fcfcfcde5446909d2d1e2e05ed90
--- /dev/null
+++ b/code/requirements.txt
@@ -0,0 +1,22 @@
+# Core
+torch>=2.0.0
+numpy>=1.24.0
+
+# Protein structure
+biopython>=1.80
+
+# ML utilities
+scipy>=1.10.0
+scikit-learn>=1.3.0
+
+# Experiment tracking
+wandb>=0.12.0
+
+# Config
+pyyaml>=6.0
+
+# Visualization
+matplotlib>=3.7.0
+
+# Optional accelerations
+einops>=0.6.0
diff --git a/code/scripts/README.md b/code/scripts/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..35f25d24f58d9343348d533d74865bd64c418066
--- /dev/null
+++ b/code/scripts/README.md
@@ -0,0 +1,55 @@
+# `code/scripts/` β entry points
+
+This public release ships only the inference and sampling code for Q_ΞΈ.
+
+| File / dir | Purpose |
+|---|---|
+| `evaluate.py` | Score binders in a pre-built `*.pkl` test set with a Q_ΞΈ checkpoint; reports Spearman Ο, AUC, selectivity gap. |
+| `rescore.py` | Re-score raw PDB designs (binder + holo + apo) with Q_ΞΈ. |
+| `pxdesign_guidance/` | PXDesign-prior guidance with Q_ΞΈ (Langevin / SMC / TDS / classifier). |
+
+Training, baseline scoring (ProteinMPNN / ESM-IF / Rosetta / DFIRE / energy panel), guidance for RFdiffusion / Proteina-ComplexA, and paper-figure aggregation are **not** shipped; the inference path above is the only supported surface for the public release.
+
+---
+
+## Deploying Q_ΞΈ with other base models
+
+Q_ΞΈ provides two interfaces:
+
+1. **Re-ranker (best-of-K).** Given K candidate binders from any prior, score each with `S(Y) = Q_ΞΈ(XΒΉ, Y) β Q_ΞΈ(Xβ°, Y)` and pick the top. No gradient signal needed; the prior is unmodified.
+2. **Gradient signal for guidance.** Compute `β_Y S(Y)` via `DifferentiableQTheta` (in `code/models/differentiable_features.py`) and inject into the prior's sampler (Langevin step, SMC weight, TDS twist, classifier guidance score).
+
+The `pxdesign_guidance/` subdir is a worked example of interface (2) wrapping PXDesign. To plug Q_ΞΈ into another prior, mirror that pattern:
+
+### RFdiffusion
+
+1. Clone RFdiffusion: .
+2. Follow its install + checkpoint download.
+3. In RFdiffusion's diffusion loop, after each denoising step, materialize the predicted backbone, build the holo/apo graph inputs expected by `DifferentiableQTheta`, and either:
+ - Apply a Langevin nudge: `x β x + Ξ· Β· β_x S(x)`.
+ - Add a classifier-guidance term to the denoiser's `xt-1` mean: `ΞΌ' = ΞΌ + s Β· ΟΒ² Β· β_x log p(y|x)`, where `log p(y|x) β S(x)` (Q_ΞΈ is treated as the log-likelihood of "is good binder").
+4. Reference template: `pxdesign_guidance/guided_pxdesign.py`.
+
+### Proteina-ComplexA
+
+1. Clone Proteina: (or the released artifact).
+2. Use its ComplexA mode that emits binder coords conditioned on a receptor.
+3. Same plug pattern as RFdiffusion β wrap the sampler with `DifferentiableQTheta` for guidance, or run unguided and re-rank with `evaluate.py` / `rescore.py`.
+
+### Any backbone prior
+
+The only contract Q_ΞΈ enforces:
+
+- Receptor input is a PDB with holo and apo coordinates.
+- Binder input is a PDB (or coords) with chain id distinct from receptor's.
+- For guidance, expose differentiable CΞ± + backbone coordinates so `β_x S(x)` flows.
+
+See `code/models/differentiable_features.py:DifferentiableQTheta` for the exact interface (`load_receptor(holo_path, apo_path, β¦)`, `score(design_path, binder_chain, state)`, `.differentiable_score(coords, β¦)`).
+
+---
+
+## Why other guidance scripts aren't shipped
+
+The RFdiffusion / Proteina guidance variants in our internal tree depend on those projects' un-released CIF formats and patched samplers; we don't want to ship modified third-party code. The PXDesign variants we do ship use only PXDesign's public API and are self-contained.
+
+For citation / reproduction context, see the paper Β§4 (guidance methods).
diff --git a/code/scripts/evaluate.py b/code/scripts/evaluate.py
new file mode 100644
index 0000000000000000000000000000000000000000..03475b8badd2623272a62bcdb137f42823087f76
--- /dev/null
+++ b/code/scripts/evaluate.py
@@ -0,0 +1,332 @@
+"""
+Evaluation script for the trained Q_theta scorer.
+
+Computes:
+ 1. Selectivity metrics (gap, ranking accuracy, AUC)
+ 2. DockQ correlation (Spearman/Pearson)
+ 3. Score distributions (violin plots)
+ 4. Best-of-K analysis (as function of K)
+ 5. Per-target breakdown
+
+Usage:
+ python code/scripts/evaluate.py \
+ --target cam \
+ --checkpoint checkpoints/Q_theta_phase2.pt \
+ --data_dir data/processed \
+ --gpu 7
+"""
+
+import os
+import sys
+import argparse
+import logging
+import json
+import numpy as np
+import torch
+import matplotlib
+matplotlib.use('Agg')
+import matplotlib.pyplot as plt
+from scipy.stats import spearmanr, pearsonr
+from sklearn.metrics import roc_auc_score, roc_curve
+
+_CODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
+if _CODE_DIR not in sys.path:
+ sys.path.insert(0, _CODE_DIR)
+
+from models.scorer import build_model
+from data.dataset import TwoStateComplexDataset, collate_fn
+from torch.utils.data import DataLoader
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+
+def compute_best_of_k(pos_scores, K_values=None, threshold=0.7):
+ """
+ Simulate best-of-K selection: what fraction of draws contain at least one good binder?
+ Assumes pos_scores are from a distribution of candidate binders for goal state X+.
+ """
+ if K_values is None:
+ K_values = [1, 2, 5, 10, 20, 50, 100]
+ results = {}
+ n = len(pos_scores)
+ n_trials = 1000
+
+ for K in K_values:
+ successes = 0
+ for _ in range(n_trials):
+ idxs = np.random.choice(n, size=min(K, n), replace=False)
+ best_score = pos_scores[idxs].max()
+ if best_score >= threshold:
+ successes += 1
+ results[K] = successes / n_trials
+
+ return results
+
+
+def compute_selectivity_margin(pos_scores, neg_scores):
+ """Compute per-sample selectivity margin S_theta."""
+ eps = 1e-6
+ pos_logit = np.log(pos_scores.clip(eps, 1-eps) / (1-pos_scores).clip(eps))
+ neg_logit = np.log(neg_scores.clip(eps, 1-eps) / (1-neg_scores).clip(eps))
+ selectivity = pos_logit - np.log(np.exp(neg_logit) + 1e-8)
+ return selectivity
+
+
+def plot_score_distributions(pos_scores, neg_scores, decoy_scores=None,
+ title='Score Distributions', outpath=None):
+ """Violin plot of score distributions for different complex types."""
+ fig, ax = plt.subplots(figsize=(8, 6))
+
+ data = [pos_scores, neg_scores]
+ labels = ['Positive\n(X+, Y)', 'Negative\n(X0, Y)']
+ colors = ['#2196F3', '#F44336']
+
+ if decoy_scores is not None and len(decoy_scores) > 0:
+ data.append(decoy_scores)
+ labels.append('Decoys\n(X+, Y~)')
+ colors.append('#FF9800')
+
+ parts = ax.violinplot(data, positions=range(len(data)), showmedians=True)
+ for i, (pc, c) in enumerate(zip(parts['bodies'], colors)):
+ pc.set_facecolor(c)
+ pc.set_alpha(0.7)
+
+ ax.set_xticks(range(len(data)))
+ ax.set_xticklabels(labels)
+ ax.set_ylabel('Q_theta Score', fontsize=12)
+ ax.set_title(title, fontsize=14)
+ ax.set_ylim(0, 1)
+ ax.axhline(0.5, color='gray', linestyle='--', alpha=0.5, label='Decision boundary')
+ ax.legend()
+
+ # Add mean + std annotations
+ for i, (d, c) in enumerate(zip(data, colors)):
+ ax.text(i, 0.02, f'ΞΌ={d.mean():.2f}\nΟ={d.std():.2f}',
+ ha='center', fontsize=9, color=c)
+
+ plt.tight_layout()
+ if outpath:
+ plt.savefig(outpath, dpi=150, bbox_inches='tight')
+ logger.info(f"Saved plot to {outpath}")
+ plt.close()
+
+
+def plot_roc_curve(labels, scores, title='ROC Curve', outpath=None):
+ """Plot ROC curve for positive vs negative classification."""
+ fpr, tpr, _ = roc_curve(labels, scores)
+ auc = roc_auc_score(labels, scores)
+
+ fig, ax = plt.subplots(figsize=(6, 6))
+ ax.plot(fpr, tpr, 'b-', lw=2, label=f'AUC = {auc:.3f}')
+ ax.plot([0, 1], [0, 1], 'k--', lw=1)
+ ax.set_xlabel('False Positive Rate')
+ ax.set_ylabel('True Positive Rate')
+ ax.set_title(title)
+ ax.legend()
+ plt.tight_layout()
+ if outpath:
+ plt.savefig(outpath, dpi=150, bbox_inches='tight')
+ plt.close()
+ return auc
+
+
+def plot_best_of_k(results, outpath=None):
+ """Plot best-of-K success rate as a function of K."""
+ Ks = sorted(results.keys())
+ success_rates = [results[K] for K in Ks]
+
+ fig, ax = plt.subplots(figsize=(8, 5))
+ ax.semilogx(Ks, success_rates, 'b-o', lw=2, markersize=8)
+ ax.set_xlabel('K (number of candidates)', fontsize=12)
+ ax.set_ylabel('Success rate (best score > 0.7)', fontsize=12)
+ ax.set_title('Best-of-K Analysis', fontsize=14)
+ ax.set_ylim(0, 1.05)
+ ax.grid(True, alpha=0.3)
+ ax.axhline(0.8, color='red', linestyle='--', alpha=0.5, label='80% success')
+ ax.legend()
+ plt.tight_layout()
+ if outpath:
+ plt.savefig(outpath, dpi=150, bbox_inches='tight')
+ plt.close()
+
+
+@torch.no_grad()
+def evaluate(model, loader, device):
+ """Run model on a dataset and collect all predictions."""
+ model.eval()
+ all_scores, all_labels, all_types, all_pdbs = [], [], [], []
+
+ for batch in loader:
+ esm_feats = batch['esm_feats'].to(device) if 'esm_feats' in batch else None
+ scores = model(
+ batch['node_feats'].to(device),
+ batch['edge_feats'].to(device),
+ batch['node_mask'].to(device),
+ esm_feats=esm_feats,
+ )
+ all_scores.extend(scores.cpu().numpy().tolist())
+ all_labels.extend(batch['label'].numpy().tolist())
+ all_types.extend(batch['type'])
+ all_pdbs.extend(batch['pdb'])
+
+ return (np.array(all_scores), np.array(all_labels),
+ np.array(all_types), np.array(all_pdbs))
+
+
+def main():
+ parser = argparse.ArgumentParser(description='Evaluate Allo-Designer Q_theta scorer')
+ parser.add_argument('--target', default='cam',
+ help='Target name (cam, abl, era, or any custom target with data in data/processed/)')
+ parser.add_argument('--all_targets', action='store_true',
+ help='Evaluate on all available targets and produce aggregated results')
+ parser.add_argument('--checkpoint', required=True, help='Path to model checkpoint')
+ parser.add_argument('--data_dir', default='data/processed')
+ parser.add_argument('--split', choices=['val', 'test'], default='test')
+ parser.add_argument('--batch_size', type=int, default=32)
+ parser.add_argument('--gpu', type=int, default=7)
+ parser.add_argument('--outdir', default='results')
+ parser.add_argument('--bok_threshold', type=float, default=0.7,
+ help='Score threshold for best-of-K (default 0.7; use per-target value for calibrated results)')
+ parser.add_argument('--esm_dir', default=None,
+ help='Path to ESM-2 embedding cache (auto-detected at /esm2_embeddings if omitted)')
+ parser.add_argument('--no_wandb', action='store_true', help='(ignored; here for CLI compatibility)')
+ args = parser.parse_args()
+
+ # Auto-detect ESM dir under data_dir
+ if args.esm_dir is None:
+ cand = os.path.join(args.data_dir, 'esm2_embeddings')
+ if os.path.isdir(cand):
+ args.esm_dir = cand
+
+ device = torch.device(f'cuda:{args.gpu}' if torch.cuda.is_available() else 'cpu')
+ os.makedirs(args.outdir, exist_ok=True)
+ os.makedirs(f'{args.outdir}/figures', exist_ok=True)
+ os.makedirs(f'{args.outdir}/tables', exist_ok=True)
+
+ # Load model
+ state = torch.load(args.checkpoint, map_location=device)
+ config = state.get('config', {})
+ model = build_model(config).to(device)
+ model.load_state_dict(state['model_state'])
+ logger.info(f"Loaded model from {args.checkpoint}")
+
+ # Load dataset
+ data_path = os.path.join(args.data_dir, args.target, f'{args.split}.pkl')
+ if not os.path.exists(data_path):
+ logger.error(f"Data not found: {data_path}")
+ sys.exit(1)
+
+ dataset = TwoStateComplexDataset(data_path, max_nodes=128,
+ esm_dir=args.esm_dir, target_name=args.target)
+ loader = DataLoader(
+ dataset, batch_size=args.batch_size, shuffle=False,
+ num_workers=2, collate_fn=collate_fn
+ )
+
+ # Run evaluation
+ logger.info(f"Evaluating on {len(dataset)} samples...")
+ scores, labels, types, pdbs = evaluate(model, loader, device)
+
+ # Separate by type
+ pos_mask = (types == 'positive')
+ neg_apo_mask = (types == 'negative_apo')
+ decoy_mask = np.array(['decoy' in t for t in types])
+
+ pos_scores = scores[pos_mask]
+ neg_scores = scores[neg_apo_mask]
+ decoy_scores = scores[decoy_mask]
+
+ logger.info(f"\n{'='*50}")
+ logger.info(f"Results for {args.target} ({args.split})")
+ logger.info(f"{'='*50}")
+ logger.info(f"Positive samples: {pos_mask.sum()}")
+ logger.info(f"Negative (apo) samples: {neg_apo_mask.sum()}")
+ logger.info(f"Decoy samples: {decoy_mask.sum()}")
+
+ # --- Core metrics ---
+ metrics = {}
+
+ # 1. Spearman correlation with DockQ labels
+ sp, p_val = spearmanr(scores, labels)
+ metrics['spearman_all'] = float(sp)
+ metrics['spearman_pval'] = float(p_val)
+ logger.info(f"\nSpearman(Q_theta, DockQ): {sp:.3f} (p={p_val:.3e})")
+
+ # 2. Selectivity gap (positive vs negative_apo)
+ if pos_mask.sum() > 0 and neg_apo_mask.sum() > 0:
+ gap = float(pos_scores.mean() - neg_scores.mean())
+ ranking_acc = float((pos_scores.mean() > neg_scores).mean() if len(neg_scores) > 0 else 0.5)
+ metrics['selectivity_gap'] = gap
+ metrics['pos_score_mean'] = float(pos_scores.mean())
+ metrics['neg_score_mean'] = float(neg_scores.mean())
+ metrics['pos_score_std'] = float(pos_scores.std())
+ metrics['neg_score_std'] = float(neg_scores.std())
+ logger.info(f"Selectivity gap (pos - neg): {gap:.3f}")
+ logger.info(f" Pos: {pos_scores.mean():.3f} Β± {pos_scores.std():.3f}")
+ logger.info(f" Neg: {neg_scores.mean():.3f} Β± {neg_scores.std():.3f}")
+
+ # 3. AUC for positive vs negative
+ if pos_mask.sum() > 0 and neg_apo_mask.sum() > 0:
+ pn_scores = np.concatenate([pos_scores, neg_scores])
+ pn_labels = np.concatenate([np.ones(len(pos_scores)), np.zeros(len(neg_scores))])
+ auc = roc_auc_score(pn_labels, pn_scores)
+ metrics['auc_pos_vs_neg'] = float(auc)
+ logger.info(f"AUC (pos vs neg_apo): {auc:.3f}")
+
+ # ROC curve
+ plot_roc_curve(
+ pn_labels, pn_scores,
+ title=f'ROC: Positive vs Negative Apo ({args.target.upper()})',
+ outpath=f'{args.outdir}/figures/roc_{args.target}_{args.split}.png'
+ )
+
+ # 4. AUC for quality classification (DockQ > 0.5)
+ binary = (labels > 0.5).astype(int)
+ if binary.sum() > 0 and binary.sum() < len(binary):
+ auc_quality = roc_auc_score(binary, scores)
+ metrics['auc_quality'] = float(auc_quality)
+ logger.info(f"AUC (quality>0.5): {auc_quality:.3f}")
+
+ # 5. Best-of-K analysis
+ if len(pos_scores) > 0:
+ bok_results = compute_best_of_k(pos_scores, K_values=[1, 2, 5, 10, 20, 50],
+ threshold=args.bok_threshold)
+ metrics['best_of_k'] = {str(K): float(v) for K, v in bok_results.items()}
+ logger.info(f"\nBest-of-K success rates:")
+ for K, rate in bok_results.items():
+ logger.info(f" K={K:3d}: {rate:.3f}")
+ plot_best_of_k(
+ bok_results,
+ outpath=f'{args.outdir}/figures/best_of_k_{args.target}_{args.split}.png'
+ )
+
+ # 6. Score distributions plot
+ plot_score_distributions(
+ pos_scores if len(pos_scores) > 0 else np.array([]),
+ neg_scores if len(neg_scores) > 0 else np.array([]),
+ decoy_scores if len(decoy_scores) > 0 else None,
+ title=f'Q_theta Score Distributions ({args.target.upper()})',
+ outpath=f'{args.outdir}/figures/score_dist_{args.target}_{args.split}.png'
+ )
+
+ # Save metrics
+ out_json = f'{args.outdir}/tables/eval_{args.target}_{args.split}.json'
+ with open(out_json, 'w') as f:
+ json.dump(metrics, f, indent=2)
+ logger.info(f"\nSaved metrics to {out_json}")
+
+ # Print summary table
+ logger.info(f"\n{'='*50}")
+ logger.info("SUMMARY TABLE")
+ logger.info(f"{'='*50}")
+ logger.info(f"{'Metric':<30} {'Value':>10}")
+ logger.info(f"{'-'*42}")
+ for k, v in metrics.items():
+ if isinstance(v, float):
+ logger.info(f"{k:<30} {v:>10.4f}")
+ logger.info(f"{'='*50}")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/__init__.py b/code/scripts/pxdesign_guidance/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..b72d50a105a498438222022a7812c26a96c9dff0
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/__init__.py
@@ -0,0 +1 @@
+# PXDesign + Q_theta guidance integration
diff --git a/code/scripts/pxdesign_guidance/convert_cif_to_pdb.py b/code/scripts/pxdesign_guidance/convert_cif_to_pdb.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4f683bc5b9b749a4ca256f9e048b7fafdabe2a3
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/convert_cif_to_pdb.py
@@ -0,0 +1,132 @@
+"""
+Convert PXDesign CIF outputs to PDB format for evaluation pipeline.
+
+PXDesign outputs .cif files with:
+- Chain IDs like A0/B0 (multi-char, not PDB-compatible)
+- Non-standard residue name 'xpb' for designed binder residues
+
+This script converts them to PDB format with:
+- Single-char chain IDs (A, B)
+- Preserved residue names (xpb is kept; eval tools handle it)
+
+Usage:
+ python code/scripts/pxdesign_guidance/convert_cif_to_pdb.py
+"""
+import os
+import sys
+from glob import glob
+
+from Bio.PDB import MMCIFParser, PDBIO, Select
+
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_PROJECT_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '../../..'))
+
+
+class ChainRenamer(Select):
+ """Rename multi-char chain IDs to single-char for PDB format."""
+ def __init__(self, chain_map):
+ self.chain_map = chain_map
+
+ def accept_chain(self, chain):
+ return 1
+
+ def accept_residue(self, residue):
+ return 1
+
+ def accept_atom(self, atom):
+ return 1
+
+
+def convert_cif_to_pdb(cif_path, pdb_path):
+ """Convert a single CIF file to PDB format."""
+ parser = MMCIFParser(QUIET=True)
+ structure = parser.get_structure('s', cif_path)
+ model = structure[0]
+
+ # Build chain ID mapping (A0->A, B0->B, etc.)
+ chain_map = {}
+ used_ids = set()
+ for chain in model.get_chains():
+ old_id = chain.id
+ # Use first character
+ new_id = old_id[0] if old_id else 'A'
+ # Avoid duplicates
+ while new_id in used_ids:
+ new_id = chr(ord(new_id) + 1)
+ used_ids.add(new_id)
+ chain_map[old_id] = new_id
+
+ # Rename chains and fix non-standard residue names
+ chains_to_rename = list(model.get_chains())
+ for chain in chains_to_rename:
+ old_id = chain.id
+ new_id = chain_map.get(old_id, old_id)
+ if old_id != new_id:
+ chain.id = new_id
+ # Rename 'xpb' residues to 'GLY' (backbone-only binder residues)
+ for residue in chain.get_residues():
+ if residue.resname.strip().lower() == 'xpb':
+ residue.resname = 'GLY'
+
+ # Write PDB
+ io = PDBIO()
+ io.set_structure(structure)
+ io.save(pdb_path)
+ return True
+
+
+def convert_directory(src_dir, method_name):
+ """Convert all CIF files in a directory tree to PDB."""
+ cif_files = sorted(glob(os.path.join(src_dir, '**/*.cif'), recursive=True))
+ cif_files = [f for f in cif_files if 'sample' in os.path.basename(f).lower()]
+
+ if not cif_files:
+ print(f" No CIF files found in {src_dir}")
+ return 0
+
+ # Create converted_pdbs directory
+ converted_dir = os.path.join(src_dir, 'converted_pdbs')
+ os.makedirs(converted_dir, exist_ok=True)
+
+ n_converted = 0
+ for cif_path in cif_files:
+ basename = os.path.basename(cif_path).replace('.cif', '.pdb')
+ # For TDS/SMC with round subdirs, include round info
+ rel_path = os.path.relpath(cif_path, src_dir)
+ parts = rel_path.split(os.sep)
+ if any(p.startswith('round_') for p in parts):
+ round_part = [p for p in parts if p.startswith('round_')][0]
+ basename = f"{round_part}_{basename}"
+
+ pdb_path = os.path.join(converted_dir, basename)
+ try:
+ convert_cif_to_pdb(cif_path, pdb_path)
+ n_converted += 1
+ except Exception as e:
+ print(f" Failed {cif_path}: {e}")
+
+ print(f" Converted {n_converted}/{len(cif_files)} CIF -> PDB in {converted_dir}")
+ return n_converted
+
+
+def main():
+ methods = {
+ 'pxdesign_guided': os.path.join(_PROJECT_DIR, 'results/pxdesign_guided'),
+ 'pxdesign_tds': os.path.join(_PROJECT_DIR, 'results/pxdesign_tds'),
+ 'pxdesign_smc': os.path.join(_PROJECT_DIR, 'results/pxdesign_smc'),
+ }
+ # Langevin outputs are already PDB (post-hoc refinement)
+
+ total = 0
+ for name, src_dir in methods.items():
+ print(f"\n{name}:")
+ if os.path.exists(src_dir):
+ total += convert_directory(src_dir, name)
+ else:
+ print(f" Directory not found: {src_dir}")
+
+ print(f"\nTotal converted: {total}")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/guided_pxdesign.py b/code/scripts/pxdesign_guidance/guided_pxdesign.py
new file mode 100644
index 0000000000000000000000000000000000000000..e66855ab35179340a79342323f444c3e31a7ce89
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/guided_pxdesign.py
@@ -0,0 +1,408 @@
+"""
+PXDesign + Q_theta Classifier Guidance.
+
+Monkey-patches PXDesign's diffusion sampling loop to inject Q_theta selectivity
+gradient after each denoising step. This steers the diffusion trajectory toward
+binder backbones that are conformationally selective.
+
+The patched diffusion loop:
+ x_denoised = denoise_net(x_noisy, t_hat, ...)
+ grad = β_{x_denoised}[Q(holo,Y) - Q(apo,Y)] # <-- INJECTED
+ x_denoised = x_denoised + scale(t) * grad # <-- INJECTED
+ delta = (x_noisy - x_denoised) / t_hat
+ x_l = x_noisy + eta * dt * delta
+
+Usage:
+ python code/scripts/pxdesign_guidance/guided_pxdesign.py \
+ --input experiments/pxdesign_cam/output/cam_binder.json \
+ --qtheta_checkpoint results/checkpoints_cam_v3/best_phase2.pt \
+ --ref_holo data/pdbs/cam_holo/3CLN.pdb \
+ --ref_apo data/pdbs/cam_apo/1CFD.pdb \
+ --guidance_scale 1.0 \
+ --N_sample 50 --N_step 400 \
+ --gpu 0
+"""
+
+import os
+import sys
+import argparse
+import json
+import logging
+import time
+import shutil
+from typing import Callable, Optional, Union
+from functools import partial
+
+import numpy as np
+import torch
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+# ββ Paths ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '..', '..'))
+_ALLO_ROOT = os.path.abspath(os.path.join(_ALLO_CODE_DIR, '..'))
+_PXDESIGN_DIR = os.environ.get('PXDESIGN_DIR', '')
+
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+if _PXDESIGN_DIR not in sys.path:
+ sys.path.insert(0, _PXDESIGN_DIR)
+
+
+def guided_sample_diffusion(
+ denoise_net: Callable,
+ input_feature_dict: dict,
+ s_inputs: torch.Tensor,
+ s_trunk: torch.Tensor,
+ z_trunk: torch.Tensor,
+ noise_schedule: torch.Tensor,
+ N_sample: int = 1,
+ gamma0: float = 0.8,
+ gamma_min: float = 1.0,
+ noise_scale_lambda: float = 1.003,
+ step_scale_eta: Union[float, dict] = {"type": "const", "min": 1.5, "max": 1.5},
+ diffusion_chunk_size: Optional[int] = None,
+ inplace_safe: bool = False,
+ attn_chunk_size: Optional[int] = None,
+ # Guidance parameters (injected via partial)
+ guidance_module=None,
+ guidance_scale: float = 1.0,
+ guidance_start: float = 0.8,
+ guidance_end: float = 0.1,
+) -> torch.Tensor:
+ """
+ Modified PXDesign sample_diffusion with Q_theta classifier guidance.
+
+ Same as original generator.sample_diffusion but with gradient injection
+ after each denoising step. The gradient is scaled by a schedule that
+ applies stronger guidance at high noise levels (early steps).
+ """
+ from protenix.model.utils import centre_random_augmentation
+
+ N_atom = input_feature_dict["atom_to_token_idx"].size(-1)
+ batch_shape = s_inputs.shape[:-2]
+ device = s_inputs.device
+ dtype = s_inputs.dtype
+
+ logger.info(f"Guided sampling: scale={guidance_scale}, "
+ f"window=[{guidance_end:.1f}, {guidance_start:.1f}]")
+
+ def _chunk_sample_diffusion_guided(chunk_n_sample, inplace_safe):
+ x_l = noise_schedule[0] * torch.randn(
+ size=(*batch_shape, chunk_n_sample, N_atom, 3),
+ device=device, dtype=dtype
+ )
+ T = len(noise_schedule)
+
+ for step_t, (c_tau_last, c_tau) in enumerate(
+ zip(noise_schedule[:-1], noise_schedule[1:])
+ ):
+ # Centre random augmentation
+ x_l = (
+ centre_random_augmentation(x_input_coords=x_l, N_sample=1)
+ .squeeze(dim=-3)
+ .to(dtype)
+ )
+
+ # Predictor step: add noise
+ gamma = float(gamma0) if c_tau > gamma_min else 0
+ t_hat = c_tau_last * (gamma + 1)
+ delta_noise_level = torch.sqrt(t_hat**2 - c_tau_last**2)
+ x_noisy = x_l + noise_scale_lambda * delta_noise_level * torch.randn(
+ size=x_l.shape, device=device, dtype=dtype
+ )
+
+ # Reshape t_hat for network
+ t_hat_tensor = (
+ t_hat.reshape((1,) * (len(batch_shape) + 1))
+ .expand(*batch_shape, chunk_n_sample)
+ .to(dtype)
+ )
+
+ # Denoise
+ x_denoised = denoise_net(
+ x_noisy=x_noisy,
+ t_hat_noise_level=t_hat_tensor,
+ input_feature_dict=input_feature_dict,
+ s_inputs=s_inputs,
+ s_trunk=s_trunk,
+ z_trunk=z_trunk,
+ chunk_size=attn_chunk_size,
+ inplace_safe=inplace_safe,
+ )
+
+ # ββ Q_theta guidance injection ββββββββββββββββββββββββββββββ
+ if guidance_module is not None:
+ # Compute progress fraction (0=start/high noise, 1=end/low noise)
+ progress = step_t / (T - 1) if T > 1 else 1.0
+
+ # Apply guidance only within the specified window
+ if guidance_end <= (1.0 - progress) <= guidance_start:
+ # Handle batch dimensions
+ x_for_grad = x_denoised
+ if x_for_grad.dim() > 3:
+ x_for_grad = x_for_grad.squeeze(0)
+
+ # Scale: stronger at high noise, weaker near convergence
+ noise_fraction = 1.0 - progress
+ scale = guidance_scale * noise_fraction
+
+ try:
+ # Compute gradient for first sample (or all if small batch)
+ n_guide = min(chunk_n_sample, 4)
+ grad_accum = torch.zeros_like(x_for_grad)
+
+ for si in range(n_guide):
+ grad, margin = guidance_module.compute_guidance_gradient(
+ x_for_grad, input_feature_dict,
+ t_hat=t_hat, sample_idx=si
+ )
+ grad_accum[si] = grad[si] if grad.shape[0] > si else grad[0]
+
+ # Broadcast gradient to remaining samples
+ if n_guide < chunk_n_sample and n_guide > 0:
+ avg_grad = grad_accum[:n_guide].mean(dim=0, keepdim=True)
+ grad_accum[n_guide:] = avg_grad.expand(
+ chunk_n_sample - n_guide, -1, -1)
+
+ # Normalize gradient to prevent explosion
+ grad_norm = grad_accum.norm(dim=-1, keepdim=True).clamp(min=1e-8)
+ grad_normalized = grad_accum / grad_norm
+ avg_norm = grad_norm.mean().item()
+
+ # Apply guidance
+ if avg_norm > 1e-6:
+ # Scale by average gradient magnitude to keep step size reasonable
+ x_denoised = x_denoised + scale * avg_norm * grad_normalized
+
+ if step_t % 50 == 0:
+ logger.info(
+ f" Step {step_t}/{T}: margin={margin:.3f}, "
+ f"grad_norm={avg_norm:.4f}, scale={scale:.3f}")
+ except Exception as e:
+ if step_t % 100 == 0:
+ logger.debug(f" Step {step_t}: guidance failed: {e}")
+ # ββ End guidance ββββββββββββββββββββββββββββββββββββββββββββ
+
+ # Euler step
+ delta = (x_noisy - x_denoised) / t_hat_tensor[..., None, None]
+ dt = c_tau - t_hat_tensor
+ if isinstance(step_scale_eta, float):
+ eta = step_scale_eta
+ elif step_scale_eta["type"] == "const":
+ assert step_scale_eta["min"] == step_scale_eta["max"]
+ eta = step_scale_eta["min"]
+ else:
+ eta_min, eta_max = step_scale_eta["min"], step_scale_eta["max"]
+ if step_scale_eta["type"] == "linear":
+ eta = eta_min + (eta_max - eta_min) * (step_t / T)
+ elif step_scale_eta["type"] == "poly":
+ eta = eta_min + (eta_max - eta_min) * (step_t / T) ** 2
+ elif step_scale_eta["type"] == "cos":
+ eta = eta_min + 0.5 * (eta_max - eta_min) * (
+ 1 - np.cos(np.pi * step_t / T))
+ elif step_scale_eta["type"] == "piecewise":
+ eta = eta_min if step_t / T < 0.5 else eta_max
+ elif step_scale_eta["type"] == "piecewise_65":
+ eta = eta_min if step_t / T < 0.65 else eta_max
+ elif step_scale_eta["type"] == "piecewise_70":
+ eta = eta_min if step_t / T < 0.70 else eta_max
+ else:
+ raise ValueError("Unsupported eta schedule!")
+ x_l = x_noisy + eta * dt[..., None, None] * delta
+
+ return x_l
+
+ # Chunked sampling
+ if diffusion_chunk_size is None:
+ x_l = _chunk_sample_diffusion_guided(N_sample, inplace_safe=inplace_safe)
+ else:
+ x_l = []
+ no_chunks = N_sample // diffusion_chunk_size + (
+ N_sample % diffusion_chunk_size != 0)
+ for i in range(no_chunks):
+ chunk_n_sample = (
+ diffusion_chunk_size
+ if i < no_chunks - 1
+ else N_sample - i * diffusion_chunk_size
+ )
+ chunk_x_l = _chunk_sample_diffusion_guided(
+ chunk_n_sample, inplace_safe=inplace_safe)
+ x_l.append(chunk_x_l)
+ x_l = torch.cat(x_l, -3)
+
+ return x_l
+
+
+def run_guided_pxdesign(args):
+ """Run PXDesign with Q_theta classifier guidance."""
+ if 'CUDA_VISIBLE_DEVICES' not in os.environ:
+ os.environ['CUDA_VISIBLE_DEVICES'] = str(args.gpu)
+
+ # Import PXDesign components
+ from pxdesign.runner.inference import InferenceRunner, main as pxdesign_main
+ from pxdesign.utils.infer import (
+ get_configs, convert_to_bioassembly_dict, download_inference_cache, derive_seed
+ )
+ from pxdesign.utils.inputs import process_input_file
+ from protenix.config import save_config
+ from protenix.utils.seed import seed_everything
+ from protenix.utils.torch_utils import autocasting_disable_decorator
+
+ from qtheta_pxdesign import QThetaPXDesignGuidance
+
+ # Set up output directory
+ outdir = args.outdir if os.path.isabs(args.outdir) else os.path.join(_ALLO_ROOT, args.outdir)
+ os.makedirs(outdir, exist_ok=True)
+
+ # Build PXDesign CLI arguments
+ pxdesign_argv = [
+ '--dump_dir', outdir,
+ '--input', args.input,
+ '--dtype', 'bf16',
+ '--N_sample', str(args.N_sample),
+ '--N_step', str(args.N_step),
+ ]
+
+ configs = get_configs(pxdesign_argv)
+ configs.input_json_path = process_input_file(
+ configs.input_json_path, out_dir=outdir)
+ download_inference_cache(configs)
+
+ # Convert inputs
+ save_config(configs, os.path.join(outdir, "config.yaml"))
+ with open(configs.input_json_path, "r") as f:
+ orig_inputs = json.load(f)
+ for x in orig_inputs:
+ convert_to_bioassembly_dict(x, outdir)
+ configs.input_json_path = os.path.join(outdir, "input_tasks.json")
+ with open(configs.input_json_path, "w") as f:
+ json.dump(orig_inputs, f, indent=4)
+
+ # Create runner
+ runner = InferenceRunner(configs)
+
+ # Initialize Q_theta guidance
+ guidance = QThetaPXDesignGuidance(
+ checkpoint=args.qtheta_checkpoint if os.path.isabs(args.qtheta_checkpoint) else os.path.join(_ALLO_ROOT, args.qtheta_checkpoint),
+ ref_holo=args.ref_holo if os.path.isabs(args.ref_holo) else os.path.join(_ALLO_ROOT, args.ref_holo),
+ ref_apo=args.ref_apo if os.path.isabs(args.ref_apo) else os.path.join(_ALLO_ROOT, args.ref_apo),
+ ref_chain=args.ref_chain,
+ device='cuda:0', # After CUDA_VISIBLE_DEVICES remapping
+ esm_target=args.esm_target,
+ )
+
+ # Monkey-patch the sample_diffusion function
+ from pxdesign.model import generator as pxdesign_generator
+ import pxdesign.model.pxdesign as pxdesign_model
+
+ # Create guided version with guidance params bound
+ guided_fn = partial(
+ guided_sample_diffusion,
+ guidance_module=guidance,
+ guidance_scale=args.guidance_scale,
+ guidance_start=args.guidance_start,
+ guidance_end=args.guidance_end,
+ )
+
+ # Patch the module-level function in generator.py
+ pxdesign_generator.sample_diffusion = guided_fn
+
+ # CRITICAL: pxdesign.py does `from pxdesign.model.generator import sample_diffusion`
+ # which creates a local binding in pxdesign.model.pxdesign namespace.
+ # We must patch that local binding too, otherwise the ProtenixDesign.sample_diffusion()
+ # method will still call the original unpatched function.
+ pxdesign_model.sample_diffusion = guided_fn
+
+ logger.info("PXDesign diffusion loop patched with Q_theta guidance")
+
+ # Run inference
+ seeds = [derive_seed(time.time_ns())] if not configs.seeds else configs.seeds
+ for seed in seeds:
+ logger.info(f"Running guided inference with seed {seed}")
+ seed_everything(seed=seed, deterministic=False)
+ runner._inference(seed)
+
+ # Score all generated designs
+ logger.info("Scoring generated designs...")
+ from glob import glob
+
+ pdb_dir = outdir
+ pdbs = []
+ for ext in ('*.pdb', '*.cif'):
+ pdbs.extend(glob(os.path.join(pdb_dir, '**/' + ext), recursive=True))
+ pdbs = sorted([p for p in pdbs if 'sample' in os.path.basename(p).lower()])
+
+ results = []
+ for i, pdb_path in enumerate(pdbs):
+ design_id = os.path.basename(pdb_path).replace('.pdb', '').replace('.cif', '')
+ result = guidance.score_design(pdb_path)
+ if result is not None:
+ result['design_id'] = design_id
+ result['pdb_path'] = pdb_path
+ results.append(result)
+ logger.info(
+ f"[{i+1}/{len(pdbs)}] {design_id}: "
+ f"Q+={result['q_holo']:.3f} Q-={result['q_apo']:.3f} "
+ f"S={result['margin']:+.3f}")
+
+ # Save results
+ if results:
+ results.sort(key=lambda x: x['margin'], reverse=True)
+ margins = np.array([r['margin'] for r in results])
+
+ summary = {
+ 'method': 'PXDesign + Classifier Guidance',
+ 'n_designs': len(results),
+ 'guidance_scale': args.guidance_scale,
+ 'guidance_window': [args.guidance_end, args.guidance_start],
+ 'margin_mean': float(margins.mean()),
+ 'margin_std': float(margins.std()),
+ 'frac_positive': float((margins > 0).mean()),
+ 'q_holo_mean': float(np.mean([r['q_holo'] for r in results])),
+ 'q_apo_mean': float(np.mean([r['q_apo'] for r in results])),
+ }
+
+ with open(os.path.join(outdir, 'guided_scores.json'), 'w') as f:
+ json.dump(results, f, indent=2)
+ with open(os.path.join(outdir, 'guided_summary.json'), 'w') as f:
+ json.dump(summary, f, indent=2)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"PXDesign + Classifier Guidance Results ({len(results)} designs)")
+ logger.info(f" Margin: {margins.mean():.3f} Β± {margins.std():.3f}")
+ logger.info(f" Fraction S > 0: {(margins > 0).mean():.1%}")
+ logger.info(f" Q(holo) mean: {summary['q_holo_mean']:.3f}")
+ logger.info(f"{'='*60}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description='PXDesign + Q_theta Classifier Guidance')
+ parser.add_argument('--input', default='experiments/pxdesign_cam/output/cam_binder.json',
+ help='PXDesign input JSON')
+ parser.add_argument('--qtheta_checkpoint',
+ default='results/checkpoints_cam_v3/best_phase2.pt')
+ parser.add_argument('--ref_holo', default='data/pdbs/cam_holo/3CLN.pdb')
+ parser.add_argument('--ref_apo', default='data/pdbs/cam_apo/1CFD.pdb')
+ parser.add_argument('--ref_chain', default='A')
+ parser.add_argument('--guidance_scale', type=float, default=1.0,
+ help='Guidance gradient scale')
+ parser.add_argument('--guidance_start', type=float, default=0.8,
+ help='Start guidance at this noise fraction (high noise)')
+ parser.add_argument('--guidance_end', type=float, default=0.1,
+ help='Stop guidance at this noise fraction (low noise)')
+ parser.add_argument('--N_sample', type=int, default=50)
+ parser.add_argument('--N_step', type=int, default=400)
+ parser.add_argument('--gpu', type=int, default=0)
+ parser.add_argument('--outdir', default='results/pxdesign_guided')
+ parser.add_argument('--esm_target', default='cam',
+ help='Subdir under data/esm2_embeddings (e.g., adk, cam)')
+ args = parser.parse_args()
+
+ run_guided_pxdesign(args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/iterative_refinement.py b/code/scripts/pxdesign_guidance/iterative_refinement.py
new file mode 100644
index 0000000000000000000000000000000000000000..35bfb91be64b53356291bfebd827428d0eedacdd
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/iterative_refinement.py
@@ -0,0 +1,338 @@
+"""
+Iterative Refinement via Langevin Noise-Refine Cycles.
+
+Inspired by ProDifEvo (Uehara et al., ICML 2025): repeatedly perturb and
+refine structures through Q_theta gradient ascent. Each cycle adds noise
+for diversity, then refines with Langevin dynamics toward higher selectivity.
+
+This allows designs to escape local optima and explore better selectivity
+regions that single-shot generation cannot reach.
+
+Pipeline:
+ 1. Start from existing PXDesign outputs (seed structures)
+ 2. Align binder to reference receptor frames
+ 3. Run Langevin refinement with Q_theta gradient
+ 4. Score the refined output
+ 5. Repeat for K iterations, keeping best designs
+
+Usage:
+ python code/scripts/pxdesign_guidance/iterative_refinement.py \
+ --input_dir results/pxdesign_guided/converted_pdbs \
+ --qtheta_checkpoint results/checkpoints_cam_v3/best_phase2.pt \
+ --ref_holo data/pdbs/cam_holo/3CLN.pdb \
+ --ref_apo data/pdbs/cam_apo/1CFD.pdb \
+ --n_iterations 3 --n_designs 10 \
+ --gpu 6
+"""
+import os
+import sys
+import json
+import logging
+import numpy as np
+import torch
+from glob import glob
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '..', '..'))
+_ALLO_ROOT = os.path.abspath(os.path.join(_ALLO_CODE_DIR, '..'))
+
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+
+
+def score_designs(pdb_paths, guidance):
+ """Score a list of PDB paths with Q_theta."""
+ results = []
+ for pdb_path in pdb_paths:
+ result = guidance.score_design(pdb_path)
+ if result is not None:
+ result['pdb_path'] = pdb_path
+ result['design_id'] = os.path.basename(pdb_path).replace('.pdb', '').replace('.cif', '')
+ results.append(result)
+ return results
+
+
+def run_langevin_cycle(pdb_paths, guidance, n_steps=50, step_size=0.005,
+ iteration=0, outdir='results/iterative_refinement'):
+ """Run Langevin refinement cycle on binder backbone coords using Q_theta.
+
+ Uses guidance.dq (DifferentiableQTheta) for differentiable scoring.
+ Aligns binder to holo/apo reference frames for dual-state scoring.
+ """
+ from utils.pdb_utils import (load_structure, get_residues, get_backbone_coords,
+ get_aa_indices, align_structures)
+
+ refined_results = []
+ os.makedirs(outdir, exist_ok=True)
+
+ for pdb_path in pdb_paths:
+ try:
+ model = load_structure(pdb_path)
+ chains = {c.id: c for c in model.get_chains()}
+
+ binder_chain = None
+ for cid in sorted(chains.keys()):
+ if cid != 'A':
+ binder_chain = cid
+ break
+ if binder_chain is None:
+ continue
+
+ rec_res = get_residues(chains['A'])
+ if not rec_res:
+ rec_res = get_residues(chains['A'], only_standard=False)
+ binder_res = get_residues(chains[binder_chain])
+ if not binder_res:
+ binder_res = get_residues(chains[binder_chain], only_standard=False)
+ if len(binder_res) < 5:
+ continue
+
+ binder_coords, binder_mask = get_backbone_coords(binder_res)
+ rec_coords, _ = get_backbone_coords(rec_res)
+
+ try:
+ aa_idx = get_aa_indices(binder_res)
+ except Exception:
+ aa_idx = np.zeros(len(binder_res), dtype=np.int64)
+
+ # Compute alignment transforms
+ rec_ca = rec_coords[:, 1, :]
+ ref_holo_ca = guidance.ref_holo_ca.cpu().numpy()
+ ref_apo_ca = guidance.ref_apo_ca.cpu().numpy()
+ n_h = min(len(rec_ca), len(ref_holo_ca))
+ n_a = min(len(rec_ca), len(ref_apo_ca))
+ if n_h < 5 or n_a < 5:
+ continue
+
+ _, R_h = align_structures(rec_ca[:n_h], ref_holo_ca[:n_h])
+ center_h = rec_ca[:n_h].mean(0)
+ ref_center_h = ref_holo_ca[:n_h].mean(0)
+ aligned_holo = (binder_coords.reshape(-1, 3) - center_h) @ R_h.T + ref_center_h
+ aligned_holo = aligned_holo.reshape(-1, 4, 3)
+
+ _, R_a = align_structures(rec_ca[:n_a], ref_apo_ca[:n_a])
+ center_a = rec_ca[:n_a].mean(0)
+ ref_center_a = ref_apo_ca[:n_a].mean(0)
+
+ device = guidance.device
+ dq = guidance.dq
+
+ # Precompute alignment tensors (detached constants)
+ R_h_t = torch.from_numpy(R_h).float().to(device)
+ R_a_t = torch.from_numpy(R_a).float().to(device)
+ center_h_t = torch.from_numpy(center_h).float().to(device)
+ ref_center_h_t = torch.from_numpy(ref_center_h).float().to(device)
+ center_a_t = torch.from_numpy(center_a).float().to(device)
+ ref_center_a_t = torch.from_numpy(ref_center_a).float().to(device)
+
+ # Work in holo-aligned frame
+ coords_t = torch.from_numpy(aligned_holo.copy()).float().to(device)
+ mask_t = torch.from_numpy(binder_mask).bool().to(device)
+ aa_t = torch.from_numpy(aa_idx).long().to(device)
+
+ # Add noise for diversity (constant, small)
+ noise = torch.randn_like(coords_t) * 0.05
+ coords_t = coords_t + noise
+
+ best_margin = -float('inf')
+ best_coords = coords_t.clone()
+
+ def project_bond_lengths(coords, target_dist=3.8, n_iters=5):
+ """Project CA-CA distances to target_dist via SHAKE-like iteration."""
+ with torch.no_grad():
+ for _ in range(n_iters):
+ ca = coords[:, 1, :].clone()
+ for i in range(len(ca) - 1):
+ delta = ca[i+1] - ca[i]
+ d = delta.norm()
+ if d < 1e-6:
+ continue
+ correction = 0.5 * (d - target_dist) / d * delta
+ coords[i, :, :] += correction.unsqueeze(0)
+ coords[i+1, :, :] -= correction.unsqueeze(0)
+ return coords
+
+ for step in range(n_steps):
+ coords_t = coords_t.detach().requires_grad_(True)
+
+ with torch.enable_grad():
+ q_holo = dq.score(coords_t, mask_t, binder_aa_idx=aa_t,
+ receptor_label='holo')
+
+ # Transform holo-frame β original β apo-frame
+ flat_t = coords_t.reshape(-1, 3)
+ original = (flat_t - ref_center_h_t) @ R_h_t + center_h_t
+ apo_aligned = (original - center_a_t) @ R_a_t.T + ref_center_a_t
+ coords_apo = apo_aligned.reshape(-1, 4, 3)
+
+ q_apo = dq.score(coords_apo, mask_t, binder_aa_idx=aa_t,
+ receptor_label='apo')
+ margin = q_holo - q_apo
+ margin.backward()
+
+ grad = coords_t.grad
+ if grad is None or torch.isnan(grad).any():
+ continue
+
+ grad_norm = grad.norm().clamp(min=1e-8)
+
+ if margin.item() > best_margin:
+ best_margin = margin.item()
+ best_coords = coords_t.detach().clone()
+
+ if step % 10 == 0:
+ logger.info(f" [{os.path.basename(pdb_path)}] Step {step}: "
+ f"Q+={q_holo.item():.3f} Q-={q_apo.item():.3f} "
+ f"S={margin.item():.3f} |g|={grad_norm.item():.4f}")
+
+ with torch.no_grad():
+ coords_t = coords_t + step_size * grad / grad_norm
+ # Annealed Langevin noise (small)
+ noise_scale = step_size * 0.05 * (1 - step / n_steps)
+ coords_t = coords_t + noise_scale * torch.randn_like(coords_t)
+ # Hard projection: enforce CA-CA = 3.8A
+ coords_t = project_bond_lengths(coords_t)
+
+ # Write refined backbone PDB
+ final_coords = best_coords.detach().cpu().numpy()
+ basename = os.path.basename(pdb_path).replace('.pdb', '')
+ out_path = os.path.join(outdir, f'{basename}_iter{iteration}.pdb')
+
+ atom_names = [' N ', ' CA ', ' C ', ' O ']
+ elements = ['N', 'C', 'C', 'O']
+ with open(out_path, 'w') as f:
+ atom_num = 1
+ for i in range(len(final_coords)):
+ if not binder_mask[i]:
+ continue
+ for j, (aname, elem) in enumerate(zip(atom_names, elements)):
+ x, y, z = final_coords[i, j]
+ f.write(f"ATOM {atom_num:5d} {aname} ALA B{i+1:4d} "
+ f"{x:8.3f}{y:8.3f}{z:8.3f} 1.00 0.00 {elem}\n")
+ atom_num += 1
+ f.write("END\n")
+
+ # Score refined design
+ result = guidance.score_design(out_path)
+ if result is not None:
+ result['pdb_path'] = out_path
+ result['iteration'] = iteration
+ result['best_margin_during_opt'] = best_margin
+ refined_results.append(result)
+ logger.info(f" -> Refined: S={result['margin']:.3f} "
+ f"(best during opt: {best_margin:.3f})")
+
+ except Exception as e:
+ logger.warning(f"Failed to refine {pdb_path}: {e}")
+ import traceback
+ traceback.print_exc()
+
+ return refined_results
+
+
+def main():
+ import argparse
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--input_dir',
+ default='results/pxdesign_guided/converted_pdbs')
+ parser.add_argument('--qtheta_checkpoint',
+ default='results/checkpoints_cam_v3/best_phase2.pt')
+ parser.add_argument('--ref_holo', default='data/pdbs/cam_holo/3CLN.pdb')
+ parser.add_argument('--ref_apo', default='data/pdbs/cam_apo/1CFD.pdb')
+ parser.add_argument('--ref_chain', default='A')
+ parser.add_argument('--n_iterations', type=int, default=4,
+ help='Number of refine cycles')
+ parser.add_argument('--n_designs', type=int, default=20,
+ help='Number of designs to refine')
+ parser.add_argument('--n_steps', type=int, default=50,
+ help='Langevin steps per iteration')
+ parser.add_argument('--step_size', type=float, default=0.005)
+ parser.add_argument('--gpu', type=int, default=6)
+ parser.add_argument('--outdir', default='results/iterative_refinement')
+ args = parser.parse_args()
+
+ os.chdir(_ALLO_ROOT)
+
+ from scripts.pxdesign_guidance.qtheta_pxdesign import QThetaPXDesignGuidance
+
+ outdir = args.outdir
+ os.makedirs(outdir, exist_ok=True)
+
+ # Initialize scorer
+ guidance = QThetaPXDesignGuidance(
+ checkpoint=args.qtheta_checkpoint,
+ ref_holo=args.ref_holo,
+ ref_apo=args.ref_apo,
+ ref_chain=args.ref_chain,
+ device=f'cuda:{args.gpu}',
+ )
+ guidance._lazy_init()
+
+ # Collect input designs
+ input_pdbs = sorted(glob(os.path.join(args.input_dir, '*.pdb')))[:args.n_designs]
+ logger.info(f"Selected {len(input_pdbs)} designs for iterative refinement")
+
+ # Score initial designs
+ logger.info("Scoring initial designs...")
+ initial_results = score_designs(input_pdbs, guidance)
+ initial_margins = [r['margin'] for r in initial_results]
+ logger.info(f"Initial: S={np.mean(initial_margins):.3f}\u00b1{np.std(initial_margins):.3f}")
+
+ all_iteration_results = {'initial': initial_results}
+
+ # Iterative refinement
+ current_pdbs = input_pdbs
+ for iteration in range(args.n_iterations):
+ logger.info(f"\n{'='*50}")
+ logger.info(f"Iteration {iteration + 1}/{args.n_iterations}")
+ logger.info(f"{'='*50}")
+
+ iter_results = run_langevin_cycle(
+ current_pdbs, guidance,
+ n_steps=args.n_steps,
+ step_size=args.step_size,
+ iteration=iteration,
+ outdir=outdir,
+ )
+
+ if iter_results:
+ margins = [r['margin'] for r in iter_results]
+ logger.info(f"Iteration {iteration}: S={np.mean(margins):.3f}\u00b1{np.std(margins):.3f}")
+ all_iteration_results[f'iteration_{iteration}'] = iter_results
+
+ # Use refined designs as input for next iteration
+ current_pdbs = [r['pdb_path'] for r in iter_results]
+
+ # Summary
+ logger.info(f"\n{'='*60}")
+ logger.info("Iterative Refinement Summary")
+ logger.info(f"{'='*60}")
+ for key, results in all_iteration_results.items():
+ if results:
+ margins = [r['margin'] for r in results]
+ logger.info(f"{key:15s}: S={np.mean(margins):.3f}\u00b1{np.std(margins):.3f}, "
+ f"N={len(results)}, S>0={100*np.mean([m>0 for m in margins]):.0f}%")
+
+ # Save results
+ out_path = os.path.join(outdir, 'iterative_refinement_summary.json')
+ summary = {}
+ for key, results in all_iteration_results.items():
+ if results:
+ margins = [r['margin'] for r in results]
+ summary[key] = {
+ 'n': len(results),
+ 'margin_mean': float(np.mean(margins)),
+ 'margin_std': float(np.std(margins)),
+ 'margin_max': float(np.max(margins)),
+ 'frac_positive': float(np.mean([m > 0 for m in margins])),
+ }
+ with open(out_path, 'w') as f:
+ json.dump(summary, f, indent=2)
+ logger.info(f"\nSaved to {out_path}")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/langevin_pxdesign.py b/code/scripts/pxdesign_guidance/langevin_pxdesign.py
new file mode 100644
index 0000000000000000000000000000000000000000..a82002dd4e4518052519b7f742dc827ab28e8b63
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/langevin_pxdesign.py
@@ -0,0 +1,374 @@
+"""
+PXDesign + Langevin Refinement.
+
+Post-hoc gradient ascent on existing PXDesign binder backbones using Q_theta
+selectivity gradient:
+ x_{t+1} = x_t + Ξ· Β· β_x[Q(holo,Y) - Q(apo,Y)] + β(2Ξ·) Β· Ξ΅
+
+Takes PXDesign outputs (which have full sidechains), extracts backbone coords,
+refines them via Langevin dynamics, and outputs refined backbone-only PDBs.
+
+Usage:
+ python code/scripts/pxdesign_guidance/langevin_pxdesign.py \
+ --designs_dir experiments/pxdesign_cam/output/ \
+ --qtheta_checkpoint results/checkpoints_cam_v3/best_phase2.pt \
+ --ref_holo data/pdbs/cam_holo/3CLN.pdb \
+ --ref_apo data/pdbs/cam_apo/1CFD.pdb \
+ --n_steps 100 --step_size 0.01 \
+ --gpu 0
+"""
+
+import os
+import sys
+import argparse
+import json
+import logging
+import numpy as np
+import torch
+from glob import glob
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '..', '..'))
+_ALLO_ROOT = os.path.abspath(os.path.join(_ALLO_CODE_DIR, '..'))
+
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+
+from utils.pdb_utils import (
+ load_structure, get_residues, get_backbone_coords,
+ get_aa_indices, align_structures
+)
+
+
+def write_backbone_pdb(coords, mask, out_path, chain='B'):
+ """Write backbone PDB (N, CA, C, O) from [N, 4, 3] numpy coords."""
+ atom_names = [' N ', ' CA ', ' C ', ' O ']
+ elements = ['N', 'C', 'C', 'O']
+ with open(out_path, 'w') as f:
+ atom_idx = 1
+ for i in range(len(coords)):
+ if not mask[i]:
+ continue
+ for j, (aname, elem) in enumerate(zip(atom_names, elements)):
+ x, y, z = coords[i, j, :]
+ f.write(
+ f"ATOM {atom_idx:5d} {aname:4s} ALA {chain}{i+1:4d} "
+ f"{x:8.3f}{y:8.3f}{z:8.3f} 1.00 0.00 {elem}\n"
+ )
+ atom_idx += 1
+ f.write("END\n")
+
+
+def find_pxdesign_pdbs(designs_dir):
+ """Find all PXDesign output PDB files."""
+ pdbs = sorted(glob(os.path.join(designs_dir, '**/*.pdb'), recursive=True))
+ pdbs = [p for p in pdbs if 'sample' in os.path.basename(p).lower()
+ or 'design' in os.path.basename(p).lower()
+ or 'rank' in os.path.basename(p).lower()]
+ if not pdbs:
+ pdbs = sorted(glob(os.path.join(designs_dir, '**/*.pdb'), recursive=True))
+ return pdbs
+
+
+def langevin_refine(dq, binder_coords_init, binder_mask, binder_aa_idx,
+ rec_coords, rec_mask, ref_holo_ca, ref_apo_ca,
+ n_steps=100, step_size=0.01, noise_scale=0.0,
+ device='cuda:0'):
+ """
+ Langevin refinement of binder backbone coordinates.
+
+ Args:
+ dq: DifferentiableQTheta scorer
+ binder_coords_init: [N_binder, 4, 3] numpy β initial binder backbone
+ binder_mask: [N_binder] numpy bool
+ binder_aa_idx: [N_binder] numpy int
+ rec_coords: [N_rec, 4, 3] numpy β receptor backbone
+ rec_mask: [N_rec] numpy bool
+ ref_holo_ca: [N_ref, 3] torch β holo reference CA
+ ref_apo_ca: [N_ref, 3] torch β apo reference CA
+ n_steps: int
+ step_size: float (Ξ·)
+ noise_scale: float (for stochastic Langevin, 0 = gradient ascent)
+ device: str
+
+ Returns:
+ best_coords: [N_binder, 4, 3] numpy β refined coords
+ trajectory: list of dicts with step info
+ """
+ device = torch.device(device)
+
+ # Convert to tensors
+ x = torch.from_numpy(binder_coords_init.copy()).float().to(device)
+ mask_t = torch.from_numpy(binder_mask).bool().to(device)
+ aa_t = torch.from_numpy(binder_aa_idx).long().to(device)
+ rec_ca = torch.from_numpy(rec_coords[:, 1, :]).float().to(device)
+
+ best_margin = -float('inf')
+ best_coords = binder_coords_init.copy()
+ best_q_holo = 0.0
+ best_q_apo = 0.0
+ trajectory = []
+
+ for step in range(n_steps):
+ x_grad = x.clone().requires_grad_(True)
+
+ try:
+ with torch.enable_grad():
+ # Align to holo reference
+ n_align_h = min(len(rec_ca), len(ref_holo_ca))
+ if n_align_h < 5:
+ break
+ from qtheta_pxdesign import differentiable_kabsch
+ R_h, t_h = differentiable_kabsch(rec_ca[:n_align_h].detach(),
+ ref_holo_ca[:n_align_h].detach())
+ R_h, t_h = R_h.detach(), t_h.detach()
+ aligned_holo = x_grad.reshape(-1, 3) @ R_h.T + t_h
+ aligned_holo = aligned_holo.reshape(-1, 4, 3)
+
+ q_holo = dq.score(aligned_holo, mask_t, binder_aa_idx=aa_t,
+ receptor_label='holo')
+
+ # Align to apo reference
+ n_align_a = min(len(rec_ca), len(ref_apo_ca))
+ R_a, t_a = differentiable_kabsch(rec_ca[:n_align_a].detach(),
+ ref_apo_ca[:n_align_a].detach())
+ R_a, t_a = R_a.detach(), t_a.detach()
+ aligned_apo = x_grad.reshape(-1, 3) @ R_a.T + t_a
+ aligned_apo = aligned_apo.reshape(-1, 4, 3)
+
+ q_apo = dq.score(aligned_apo, mask_t, binder_aa_idx=aa_t,
+ receptor_label='apo')
+
+ margin = q_holo - q_apo
+ margin.backward()
+
+ grad = x_grad.grad
+ if grad is None or torch.isnan(grad).any():
+ continue
+
+ # Gradient ascent step
+ x = x + step_size * grad
+
+ # Optional noise for stochastic Langevin
+ if noise_scale > 0:
+ x = x + noise_scale * np.sqrt(2 * step_size) * torch.randn_like(x)
+
+ current_margin = margin.item()
+ step_info = {
+ 'step': step,
+ 'q_holo': q_holo.item(),
+ 'q_apo': q_apo.item(),
+ 'margin': current_margin,
+ 'grad_norm': grad.norm().item(),
+ }
+ trajectory.append(step_info)
+
+ if current_margin > best_margin:
+ best_margin = current_margin
+ best_coords = x.detach().cpu().numpy()
+ best_q_holo = q_holo.item()
+ best_q_apo = q_apo.item()
+
+ if step % 20 == 0:
+ logger.info(
+ f" Step {step:3d}: Q+={q_holo.item():.3f} Q-={q_apo.item():.3f} "
+ f"S={current_margin:+.3f} |β|={grad.norm().item():.4f}")
+
+ except Exception as e:
+ logger.debug(f" Step {step}: {e}")
+ continue
+
+ return best_coords, trajectory, best_margin, best_q_holo, best_q_apo
+
+
+def main():
+ parser = argparse.ArgumentParser(description='PXDesign + Langevin Refinement')
+ parser.add_argument('--designs_dir', default='experiments/pxdesign_cam/output/')
+ parser.add_argument('--qtheta_checkpoint',
+ default='results/checkpoints_cam_v3/best_phase2.pt')
+ parser.add_argument('--ref_holo', default='data/pdbs/cam_holo/3CLN.pdb')
+ parser.add_argument('--ref_apo', default='data/pdbs/cam_apo/1CFD.pdb')
+ parser.add_argument('--ref_chain', default='A')
+ parser.add_argument('--n_steps', type=int, default=100)
+ parser.add_argument('--step_size', type=float, default=0.01)
+ parser.add_argument('--noise_scale', type=float, default=0.0,
+ help='Noise scale for stochastic Langevin (0=gradient ascent)')
+ parser.add_argument('--gpu', type=int, default=0)
+ parser.add_argument('--outdir', default='results/pxdesign_langevin')
+ args = parser.parse_args()
+
+ os.chdir(_ALLO_ROOT)
+
+ device = f'cuda:{args.gpu}'
+
+ from models.differentiable_features import DifferentiableQTheta
+
+ # Load scorer
+ dq = DifferentiableQTheta(args.qtheta_checkpoint, device=device)
+ dq.load_receptor(args.ref_holo, chain=args.ref_chain, label='holo')
+ dq.load_receptor(args.ref_apo, chain=args.ref_chain, label='apo')
+
+ # Load reference CA coords
+ holo_model = load_structure(args.ref_holo)
+ holo_res = get_residues(holo_model[args.ref_chain])
+ holo_coords, _ = get_backbone_coords(holo_res)
+ ref_holo_ca = torch.from_numpy(holo_coords[:, 1, :]).float().to(device)
+
+ apo_model = load_structure(args.ref_apo)
+ apo_res = get_residues(apo_model[args.ref_chain])
+ apo_coords, _ = get_backbone_coords(apo_res)
+ ref_apo_ca = torch.from_numpy(apo_coords[:, 1, :]).float().to(device)
+
+ # Find designs
+ pdbs = find_pxdesign_pdbs(args.designs_dir)
+ logger.info(f"Found {len(pdbs)} PXDesign outputs to refine")
+
+ outdir = args.outdir
+ os.makedirs(outdir, exist_ok=True)
+
+ all_results = []
+ for i, pdb_path in enumerate(pdbs):
+ design_id = os.path.basename(pdb_path).replace('.pdb', '').replace('.cif', '')
+ logger.info(f"\n[{i+1}/{len(pdbs)}] Refining {design_id}...")
+
+ try:
+ model = load_structure(pdb_path)
+ chains = {c.get_id(): c for c in model.get_chains()}
+ chain_ids = sorted(chains.keys())
+
+ # Identify chains
+ ref_len = len(holo_res)
+ rec_chain_id, binder_chain_id = None, None
+ for cid in chain_ids:
+ cres = get_residues(chains[cid])
+ if abs(len(cres) - ref_len) < ref_len * 0.3:
+ rec_chain_id = cid
+ else:
+ binder_chain_id = cid
+
+ if rec_chain_id is None or binder_chain_id is None:
+ if len(chain_ids) >= 2:
+ rec_chain_id, binder_chain_id = chain_ids[0], chain_ids[1]
+ else:
+ logger.warning(f"Skipping {design_id}: cannot identify chains")
+ continue
+
+ rec_res = get_residues(chains[rec_chain_id])
+ binder_res = get_residues(chains[binder_chain_id])
+
+ rec_coords_np, rec_mask = get_backbone_coords(rec_res)
+ binder_coords_np, binder_mask = get_backbone_coords(binder_res)
+ aa_idx = get_aa_indices(binder_res)
+
+ # Score before refinement
+ rec_ca = rec_coords_np[:, 1, :]
+ n_align = min(len(rec_ca), len(holo_coords[:, 1, :]))
+ _, R_h = align_structures(rec_ca[:n_align], holo_coords[:n_align, 1, :])
+ center_h = rec_ca[:n_align].mean(0)
+ ref_center_h = holo_coords[:n_align, 1, :].mean(0)
+
+ aligned_init = (binder_coords_np.reshape(-1, 3) - center_h) @ R_h.T + ref_center_h
+ aligned_init = aligned_init.reshape(-1, 4, 3)
+ with torch.no_grad():
+ q_h_init = dq.score(
+ torch.from_numpy(aligned_init).float().to(device),
+ torch.from_numpy(binder_mask).bool().to(device),
+ binder_aa_idx=torch.from_numpy(aa_idx).long().to(device),
+ receptor_label='holo').item()
+
+ n_align_a = min(len(rec_ca), len(apo_coords[:, 1, :]))
+ _, R_a = align_structures(rec_ca[:n_align_a], apo_coords[:n_align_a, 1, :])
+ center_a = rec_ca[:n_align_a].mean(0)
+ ref_center_a = apo_coords[:n_align_a, 1, :].mean(0)
+ aligned_init_a = (binder_coords_np.reshape(-1, 3) - center_a) @ R_a.T + ref_center_a
+ aligned_init_a = aligned_init_a.reshape(-1, 4, 3)
+ with torch.no_grad():
+ q_a_init = dq.score(
+ torch.from_numpy(aligned_init_a).float().to(device),
+ torch.from_numpy(binder_mask).bool().to(device),
+ binder_aa_idx=torch.from_numpy(aa_idx).long().to(device),
+ receptor_label='apo').item()
+
+ margin_init = q_h_init - q_a_init
+
+ # Run Langevin refinement
+ refined_coords, trajectory, best_margin, best_qh, best_qa = langevin_refine(
+ dq, binder_coords_np, binder_mask, aa_idx,
+ rec_coords_np, rec_mask, ref_holo_ca, ref_apo_ca,
+ n_steps=args.n_steps, step_size=args.step_size,
+ noise_scale=args.noise_scale, device=device,
+ )
+
+ # Use best-margin values (matching the saved best_coords PDB)
+ margin_final = best_margin if trajectory else margin_init
+
+ # Save refined PDB
+ out_pdb = os.path.join(outdir, f'{design_id}_refined.pdb')
+ write_backbone_pdb(refined_coords, binder_mask, out_pdb)
+
+ result = {
+ 'design_id': design_id,
+ 'pdb_path': pdb_path,
+ 'refined_pdb': out_pdb,
+ 'q_holo_init': q_h_init,
+ 'q_apo_init': q_a_init,
+ 'margin_init': margin_init,
+ 'q_holo_final': best_qh if trajectory else q_h_init,
+ 'q_apo_final': best_qa if trajectory else q_a_init,
+ 'margin_final': margin_final,
+ 'margin_delta': margin_final - margin_init,
+ 'n_steps_converged': len(trajectory),
+ 'n_res': len(binder_res),
+ }
+ all_results.append(result)
+
+ logger.info(
+ f" {design_id}: S_init={margin_init:+.3f} -> S_final={margin_final:+.3f} "
+ f"(Ξ={margin_final - margin_init:+.3f})")
+
+ except Exception as e:
+ logger.warning(f"Failed to refine {design_id}: {e}")
+ continue
+
+ # Summary
+ if all_results:
+ all_results.sort(key=lambda x: x['margin_final'], reverse=True)
+ margins_init = np.array([r['margin_init'] for r in all_results])
+ margins_final = np.array([r['margin_final'] for r in all_results])
+ deltas = margins_final - margins_init
+
+ summary = {
+ 'method': 'PXDesign + Langevin',
+ 'n_designs': len(all_results),
+ 'n_steps': args.n_steps,
+ 'step_size': args.step_size,
+ 'margin_init_mean': float(margins_init.mean()),
+ 'margin_final_mean': float(margins_final.mean()),
+ 'margin_delta_mean': float(deltas.mean()),
+ 'frac_improved': float((deltas > 0).mean()),
+ 'frac_positive_init': float((margins_init > 0).mean()),
+ 'frac_positive_final': float((margins_final > 0).mean()),
+ 'q_holo_final_mean': float(np.mean([r['q_holo_final'] for r in all_results])),
+ }
+
+ with open(os.path.join(outdir, 'langevin_scores.json'), 'w') as f:
+ json.dump(all_results, f, indent=2)
+ with open(os.path.join(outdir, 'langevin_summary.json'), 'w') as f:
+ json.dump(summary, f, indent=2)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"PXDesign + Langevin Results ({len(all_results)} designs)")
+ logger.info(f" Margin init: {margins_init.mean():.3f} Β± {margins_init.std():.3f}")
+ logger.info(f" Margin final: {margins_final.mean():.3f} Β± {margins_final.std():.3f}")
+ logger.info(f" Ξ margin: {deltas.mean():+.3f} Β± {deltas.std():.3f}")
+ logger.info(f" % improved: {(deltas > 0).mean():.1%}")
+ logger.info(f" S>0 init/final: {(margins_init > 0).mean():.1%} / "
+ f"{(margins_final > 0).mean():.1%}")
+ logger.info(f"{'='*60}")
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/qtheta_pxdesign.py b/code/scripts/pxdesign_guidance/qtheta_pxdesign.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f21f6ef1454ad88c0382df09ae4207db8c02c01
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/qtheta_pxdesign.py
@@ -0,0 +1,477 @@
+"""
+Core Q_theta guidance module for PXDesign integration.
+
+Provides differentiable Q_theta scoring for PXDesign's atom coordinate format.
+Key responsibilities:
+ - Extract binder backbone (N, CA, C, O) from PXDesign's flat atom array
+ - Align binder to reference receptor frames via differentiable Kabsch
+ - Compute selectivity gradient β[Q(holo,Y) - Q(apo,Y)] w.r.t. atom coords
+ - Works in pxdesign env (PyTorch 2.3.1) using pure-PyTorch scorer (no e3nn)
+
+Usage:
+ guidance = QThetaPXDesignGuidance(
+ checkpoint='results/checkpoints_cam_v3/best_phase2.pt',
+ ref_holo='data/pdbs/cam_holo/3CLN.pdb',
+ ref_apo='data/pdbs/cam_apo/1CFD.pdb',
+ ref_chain='A',
+ device='cuda:0',
+ )
+ # Inside PXDesign diffusion loop:
+ grad = guidance.compute_guidance_gradient(x_denoised, input_feature_dict, t_hat)
+ x_denoised = x_denoised + scale * grad
+"""
+
+import os
+import sys
+import logging
+import numpy as np
+import torch
+
+logger = logging.getLogger(__name__)
+
+# Add Allo-Designer code directory to path
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+
+
+def differentiable_kabsch(mobile, target):
+ """
+ Differentiable Kabsch alignment using SVD.
+
+ Args:
+ mobile: [N, 3] tensor (points to align FROM)
+ target: [N, 3] tensor (points to align TO)
+
+ Returns:
+ R: [3, 3] rotation matrix
+ t: [3] translation vector
+ Such that aligned = (mobile - mobile_center) @ R.T + target_center
+ """
+ mobile_center = mobile.mean(dim=0)
+ target_center = target.mean(dim=0)
+
+ mobile_centered = mobile - mobile_center
+ target_centered = target - target_center
+
+ H = mobile_centered.T @ target_centered # [3, 3]
+ U, S, Vh = torch.linalg.svd(H)
+ V = Vh.T
+
+ # Ensure proper rotation (det > 0)
+ d = torch.det(V @ U.T)
+ sign_matrix = torch.diag(torch.tensor([1.0, 1.0, torch.sign(d)],
+ device=mobile.device, dtype=mobile.dtype))
+ R = V @ sign_matrix @ U.T # [3, 3]
+ t = target_center - mobile_center @ R.T # [3]
+
+ return R, t
+
+
+class QThetaPXDesignGuidance:
+ """
+ Q_theta guidance for PXDesign diffusion process.
+
+ Lazily initializes the scorer and reference structures on first use.
+ Handles extraction of binder backbone from PXDesign's flat atom array
+ and alignment to reference receptor frames.
+ """
+
+ def __init__(self, checkpoint, ref_holo, ref_apo, ref_chain='A',
+ device='cuda:0', cutoff=8.0, esm_target='cam'):
+ self.checkpoint = checkpoint
+ self.ref_holo = ref_holo
+ self.ref_apo = ref_apo
+ self.ref_chain = ref_chain
+ self.device = torch.device(device)
+ self.cutoff = cutoff
+ self.esm_target = esm_target
+
+ self._initialized = False
+ self.dq = None
+ self.ref_holo_ca = None
+ self.ref_apo_ca = None
+
+ def _lazy_init(self):
+ """Initialize Q_theta scorer and load reference structures."""
+ if self._initialized:
+ return
+
+ from models.differentiable_features import DifferentiableQTheta
+ from utils.pdb_utils import load_structure, get_residues, get_backbone_coords
+
+ logger.info(f"Loading Q_theta checkpoint: {self.checkpoint}")
+ self.dq = DifferentiableQTheta(self.checkpoint, device=str(self.device))
+ self.dq.load_receptor(self.ref_holo, chain=self.ref_chain, label='holo',
+ esm_target=self.esm_target)
+ self.dq.load_receptor(self.ref_apo, chain=self.ref_chain, label='apo',
+ esm_target=self.esm_target)
+
+ # Cache reference CA coords for alignment
+ holo_model = load_structure(self.ref_holo)
+ holo_res = get_residues(holo_model[self.ref_chain])
+ holo_coords, _ = get_backbone_coords(holo_res)
+ self.ref_holo_ca = torch.from_numpy(holo_coords[:, 1, :]).float().to(self.device)
+
+ apo_model = load_structure(self.ref_apo)
+ apo_res = get_residues(apo_model[self.ref_chain])
+ apo_coords, _ = get_backbone_coords(apo_res)
+ self.ref_apo_ca = torch.from_numpy(apo_coords[:, 1, :]).float().to(self.device)
+
+ self._initialized = True
+ logger.info(f"Q_theta guidance initialized: holo={len(holo_res)} res, apo={len(apo_res)} res")
+
+ def extract_binder_backbone(self, x_coords, input_feature_dict):
+ """
+ Extract binder backbone atoms (N, CA, C, O) from PXDesign's flat atom array.
+
+ PXDesign stores all atoms in a flat [N_atom, 3] array. Entity annotations
+ identify which atoms belong to the designed binder (entity_id=2 typically,
+ or the last entity). We extract backbone atoms for each binder residue.
+
+ Args:
+ x_coords: [N_sample, N_atom, 3] β current coordinates from diffusion
+ input_feature_dict: dict with atom_to_token_idx, entity_id, etc.
+
+ Returns:
+ binder_bb: [N_sample, N_binder_res, 4, 3] β backbone coords (N, CA, C, O)
+ binder_mask: [N_binder_res] β validity mask
+ rec_bb: [N_rec_res, 4, 3] β receptor backbone coords (from condition)
+ rec_mask: [N_rec_res] β receptor validity mask
+ binder_atom_indices: [N_binder_bb_atoms] β indices into flat atom array
+ """
+ atom_to_token = input_feature_dict['atom_to_token_idx'] # [N_atom]
+ if atom_to_token.dim() > 1:
+ atom_to_token = atom_to_token.squeeze(0)
+
+ # Identify binder vs receptor tokens
+ # In PXDesign: design_token_mask=True for binder tokens
+ design_token_mask = input_feature_dict.get('design_token_mask', None)
+ if design_token_mask is not None:
+ if design_token_mask.dim() > 1:
+ design_token_mask = design_token_mask.squeeze(0)
+ binder_tokens = torch.where(design_token_mask)[0]
+ rec_tokens = torch.where(~design_token_mask)[0]
+ else:
+ # Fallback: use entity_id (binder is typically entity_id=2, the last entity)
+ entity_id = input_feature_dict['entity_id']
+ if entity_id.dim() > 1:
+ entity_id = entity_id.squeeze(0)
+ max_entity = entity_id.max()
+ binder_tokens = torch.where(entity_id == max_entity)[0]
+ rec_tokens = torch.where(entity_id != max_entity)[0]
+
+ # Map tokens to atoms
+ # For standard amino acids, atom order within each token is:
+ # N(0), CA(1), C(2), O(3), CB(4), ...
+ # We need atoms 0-3 (N, CA, C, O) per token
+
+ # Get atom indices for each binder token
+ n_binder_res = len(binder_tokens)
+ if n_binder_res == 0:
+ return None
+
+ # Find atoms belonging to each binder residue
+ binder_bb_list = []
+ binder_atom_idx_list = []
+ for tok_idx in binder_tokens:
+ atom_indices = torch.where(atom_to_token == tok_idx.item())[0]
+ if len(atom_indices) >= 4:
+ # First 4 atoms are N, CA, C, O for standard amino acids
+ bb_atoms = atom_indices[:4]
+ binder_bb_list.append(bb_atoms)
+ binder_atom_idx_list.append(bb_atoms)
+
+ if not binder_bb_list:
+ return None
+
+ n_binder_res = len(binder_bb_list)
+ binder_bb_indices = torch.stack(binder_bb_list) # [N_binder, 4]
+ all_binder_atom_indices = torch.cat(binder_atom_idx_list) # [N_binder * 4]
+
+ # Extract binder backbone coords for all samples
+ # x_coords: [N_sample, N_atom, 3]
+ binder_bb = x_coords[:, binder_bb_indices, :] # [N_sample, N_binder, 4, 3]
+ binder_mask = torch.ones(n_binder_res, dtype=torch.bool, device=x_coords.device)
+
+ # Extract receptor backbone from x_coords or condition_coordinate.
+ # PXDesign stores condition_coordinate in label_dict (not input_feature_dict),
+ # so we extract receptor backbone from x_coords directly. In the diffusion
+ # process, receptor atoms are conditioned at their reference positions.
+ # Try condition_coordinate first (if available), then fall back to x_coords.
+ cond_coords = input_feature_dict.get('condition_coordinate', None)
+ if cond_coords is None:
+ # Also try label_dict nesting
+ label_dict = input_feature_dict.get('label_dict', None)
+ if label_dict is not None:
+ cond_coords = label_dict.get('condition_coordinate', None)
+
+ rec_bb = None
+ rec_mask = None
+
+ # Get receptor backbone atoms
+ rec_bb_list = []
+ for tok_idx in rec_tokens:
+ atom_indices = torch.where(atom_to_token == tok_idx.item())[0]
+ if len(atom_indices) >= 4:
+ rec_bb_list.append(atom_indices[:4])
+
+ if rec_bb_list:
+ rec_bb_indices = torch.stack(rec_bb_list) # [N_rec, 4]
+
+ if cond_coords is not None:
+ if cond_coords.dim() > 2:
+ cond_coords = cond_coords.squeeze(0)
+ rec_bb = cond_coords[rec_bb_indices, :] # [N_rec, 4, 3]
+ else:
+ # Fallback: extract receptor coords from x_coords (sample 0)
+ # Receptor atoms are conditioned and constant across samples
+ rec_bb = x_coords[0, rec_bb_indices, :].detach() # [N_rec, 4, 3]
+
+ rec_mask = torch.ones(len(rec_bb_list), dtype=torch.bool,
+ device=x_coords.device)
+
+ return {
+ 'binder_bb': binder_bb, # [N_sample, N_binder, 4, 3]
+ 'binder_mask': binder_mask, # [N_binder]
+ 'rec_bb': rec_bb, # [N_rec, 4, 3] or None
+ 'rec_mask': rec_mask, # [N_rec] or None
+ 'binder_atom_indices': binder_bb_indices, # [N_binder, 4]
+ 'all_binder_atom_indices': all_binder_atom_indices, # [N_binder * 4]
+ }
+
+ def align_and_score(self, binder_bb, rec_bb, rec_mask, receptor_label):
+ """
+ Align binder to a reference receptor frame and score with Q_theta.
+
+ Uses the receptor chain from the design to compute Kabsch alignment
+ to the reference receptor, then transforms the binder accordingly.
+
+ Args:
+ binder_bb: [N_binder, 4, 3] β binder backbone coords (requires_grad)
+ rec_bb: [N_rec, 4, 3] β receptor backbone coords
+ rec_mask: [N_rec] bool
+ receptor_label: 'holo' or 'apo'
+
+ Returns:
+ score: scalar tensor, differentiable w.r.t. binder_bb
+ """
+ if receptor_label == 'holo':
+ ref_ca = self.ref_holo_ca
+ else:
+ ref_ca = self.ref_apo_ca
+
+ # Get CA atoms from receptor
+ rec_ca = rec_bb[:, 1, :] # [N_rec, 3]
+
+ # Use overlapping residues for alignment (take min length)
+ n_align = min(len(rec_ca), len(ref_ca))
+ if n_align < 5:
+ return torch.zeros(1, device=binder_bb.device, dtype=binder_bb.dtype,
+ requires_grad=True).squeeze()
+
+ mobile_ca = rec_ca[:n_align].detach()
+ target_ca = ref_ca[:n_align].detach()
+
+ # Compute Kabsch alignment (detached β no gradient through rotation)
+ R, t = differentiable_kabsch(mobile_ca, target_ca)
+ R = R.detach()
+ t = t.detach()
+
+ # Apply transform to binder (gradient flows through binder_bb)
+ binder_flat = binder_bb.reshape(-1, 3) # [N_binder*4, 3]
+ aligned = binder_flat @ R.T + t # [N_binder*4, 3]
+ aligned_bb = aligned.reshape(-1, 4, 3) # [N_binder, 4, 3]
+
+ # Score with Q_theta
+ binder_mask = torch.ones(aligned_bb.shape[0], dtype=torch.bool,
+ device=binder_bb.device)
+ score = self.dq.score(aligned_bb, binder_mask, receptor_label=receptor_label,
+ cutoff=self.cutoff)
+ return score
+
+ def compute_guidance_gradient(self, x_denoised, input_feature_dict, t_hat=None,
+ sample_idx=0):
+ """
+ Compute Q_theta selectivity gradient for guidance.
+
+ Args:
+ x_denoised: [N_sample, N_atom, 3] β denoised coordinates from diffusion net
+ input_feature_dict: PXDesign input features dict
+ t_hat: current noise level (for logging/scaling)
+ sample_idx: which sample to compute gradient for (or -1 for all)
+
+ Returns:
+ gradient: [N_sample, N_atom, 3] β gradient to add to x_denoised
+ (non-zero only at binder backbone atom positions)
+ margin: float β current selectivity margin
+ """
+ self._lazy_init()
+
+ extraction = self.extract_binder_backbone(x_denoised.detach(), input_feature_dict)
+ if extraction is None:
+ return torch.zeros_like(x_denoised), 0.0
+
+ binder_bb = extraction['binder_bb'] # [N_sample, N_binder, 4, 3]
+ binder_mask = extraction['binder_mask'] # [N_binder]
+ rec_bb = extraction['rec_bb'] # [N_rec, 4, 3]
+ rec_mask = extraction['rec_mask'] # [N_rec]
+ binder_atom_indices = extraction['binder_atom_indices'] # [N_binder, 4]
+
+ if rec_bb is None:
+ return torch.zeros_like(x_denoised), 0.0
+
+ N_sample = x_denoised.shape[0]
+ gradient = torch.zeros_like(x_denoised)
+ margins = []
+
+ # Ensure receptor is float32 for Q_theta scoring
+ if rec_bb is not None:
+ rec_bb = rec_bb.float()
+
+ # Process each sample
+ indices = range(N_sample) if sample_idx == -1 else [sample_idx]
+ for si in indices:
+ # Make binder coords differentiable, cast to float32 for Q_theta
+ binder_si = binder_bb[si].clone().float().requires_grad_(True) # [N_binder, 4, 3]
+
+ try:
+ with torch.enable_grad():
+ q_holo = self.align_and_score(binder_si, rec_bb, rec_mask, 'holo')
+ q_apo = self.align_and_score(binder_si, rec_bb, rec_mask, 'apo')
+ margin = q_holo - q_apo
+ margin.backward()
+
+ if binder_si.grad is not None and not torch.isnan(binder_si.grad).any():
+ # Map gradient back to full atom array
+ grad_bb = binder_si.grad # [N_binder, 4, 3]
+ for ri in range(len(binder_atom_indices)):
+ for ai in range(4):
+ atom_idx = binder_atom_indices[ri, ai]
+ gradient[si, atom_idx] = grad_bb[ri, ai]
+ margins.append(margin.item())
+ else:
+ margins.append(0.0)
+ except Exception as e:
+ logger.debug(f"Gradient computation failed for sample {si}: {e}")
+ margins.append(0.0)
+
+ avg_margin = np.mean(margins) if margins else 0.0
+ return gradient, avg_margin
+
+ def score_design(self, pdb_path, rec_chain='A', binder_chain='B'):
+ """
+ Score a single PXDesign output PDB/CIF (post-hoc, no gradient).
+
+ Handles PXDesign CIF files which use chain IDs like 'A0'/'B0' and
+ non-standard residue name 'xpb' for designed binder residues.
+
+ Returns:
+ dict with q_holo, q_apo, margin, or None on failure
+ """
+ self._lazy_init()
+
+ from utils.pdb_utils import (
+ load_structure, get_residues, get_backbone_coords,
+ get_aa_indices, align_structures
+ )
+
+ try:
+ model = load_structure(pdb_path)
+ chains = {c.get_id(): c for c in model.get_chains()}
+
+ if len(chains) < 2:
+ return None
+
+ chain_ids = sorted(chains.keys())
+
+ # Identify receptor and binder
+ # PXDesign CIF uses chain IDs like 'A0', 'B0' instead of 'A', 'B'
+ rc, bc = None, None
+ if rec_chain in chains and binder_chain in chains:
+ rc, bc = rec_chain, binder_chain
+ else:
+ # Match by residue count: receptor matches reference length,
+ # binder is the other chain
+ ref_model = load_structure(self.ref_holo)
+ ref_res = get_residues(ref_model[self.ref_chain])
+ ref_len = len(ref_res)
+ for cid in chain_ids:
+ # Try standard residues first, then all residues
+ cres = get_residues(chains[cid])
+ if not cres:
+ cres = get_residues(chains[cid], only_standard=False)
+ n_res = len(cres)
+ if n_res > 0 and abs(n_res - ref_len) < ref_len * 0.3:
+ rc = cid
+ elif n_res > 0:
+ bc = cid
+ if rc is None or bc is None:
+ rc, bc = chain_ids[0], chain_ids[1]
+
+ rec_res = get_residues(chains[rc])
+ if not rec_res:
+ rec_res = get_residues(chains[rc], only_standard=False)
+
+ # For binder: PXDesign uses 'xpb' residue names (non-standard)
+ binder_res = get_residues(chains[bc])
+ if not binder_res:
+ binder_res = get_residues(chains[bc], only_standard=False)
+
+ if not rec_res or not binder_res:
+ return None
+
+ rec_coords, rec_mask = get_backbone_coords(rec_res)
+ binder_coords, binder_mask = get_backbone_coords(binder_res)
+
+ # Handle amino acid indices: use get_aa_indices for standard AAs,
+ # default to GLY (7) for non-standard (PXDesign 'xpb')
+ try:
+ aa_idx = get_aa_indices(binder_res)
+ except Exception:
+ aa_idx = np.zeros(len(binder_res), dtype=np.int64) # default to ALA
+
+ device = self.device
+
+ # Align to holo
+ rec_ca = rec_coords[:, 1, :]
+ ref_holo_ca_np = self.ref_holo_ca.cpu().numpy()
+ n_align = min(len(rec_ca), len(ref_holo_ca_np))
+ if n_align < 5:
+ return None
+ _, R_h = align_structures(rec_ca[:n_align], ref_holo_ca_np[:n_align])
+ center_h = rec_ca[:n_align].mean(0)
+ ref_center_h = ref_holo_ca_np[:n_align].mean(0)
+ aligned_holo = (binder_coords.reshape(-1, 3) - center_h) @ R_h.T + ref_center_h
+ aligned_holo = aligned_holo.reshape(-1, 4, 3)
+
+ # Align to apo
+ ref_apo_ca_np = self.ref_apo_ca.cpu().numpy()
+ n_align_a = min(len(rec_ca), len(ref_apo_ca_np))
+ _, R_a = align_structures(rec_ca[:n_align_a], ref_apo_ca_np[:n_align_a])
+ center_a = rec_ca[:n_align_a].mean(0)
+ ref_center_a = ref_apo_ca_np[:n_align_a].mean(0)
+ aligned_apo = (binder_coords.reshape(-1, 3) - center_a) @ R_a.T + ref_center_a
+ aligned_apo = aligned_apo.reshape(-1, 4, 3)
+
+ with torch.no_grad():
+ coords_h = torch.from_numpy(aligned_holo).float().to(device)
+ coords_a = torch.from_numpy(aligned_apo).float().to(device)
+ mask_t = torch.from_numpy(binder_mask).bool().to(device)
+ aa_t = torch.from_numpy(aa_idx).long().to(device)
+
+ q_holo = self.dq.score(coords_h, mask_t, binder_aa_idx=aa_t,
+ receptor_label='holo').item()
+ q_apo = self.dq.score(coords_a, mask_t, binder_aa_idx=aa_t,
+ receptor_label='apo').item()
+
+ return {
+ 'q_holo': q_holo,
+ 'q_apo': q_apo,
+ 'margin': q_holo - q_apo,
+ 'n_res': len(binder_res),
+ }
+
+ except Exception as e:
+ logger.warning(f"Error scoring {pdb_path}: {e}")
+ return None
diff --git a/code/scripts/pxdesign_guidance/smc_pxdesign.py b/code/scripts/pxdesign_guidance/smc_pxdesign.py
new file mode 100644
index 0000000000000000000000000000000000000000..eacd621fb5b56e9dc1fc988c49c5cd7e53800240
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/smc_pxdesign.py
@@ -0,0 +1,262 @@
+"""
+PXDesign + SMC Reranking.
+
+Post-hoc Sequential Monte Carlo: generate multiple batches of vanilla PXDesign
+binders, score with Q_theta, and rank by selectivity margin. No modification
+to the PXDesign diffusion process β pure generate-score-rank pipeline.
+
+This is the simplest Q_theta integration strategy: generate a large pool of
+candidates and select the best ones by selectivity score.
+
+Usage:
+ python code/scripts/pxdesign_guidance/smc_pxdesign.py \
+ --input experiments/pxdesign_cam/output/cam_binder.json \
+ --qtheta_checkpoint results/checkpoints_cam_v3/best_phase2.pt \
+ --ref_holo data/pdbs/cam_holo/3CLN.pdb \
+ --ref_apo data/pdbs/cam_apo/1CFD.pdb \
+ --n_particles 16 --n_rounds 4 \
+ --gpu 0
+"""
+
+import os
+import sys
+import argparse
+import json
+import logging
+import shutil
+import subprocess
+from glob import glob
+
+import numpy as np
+import torch
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '..', '..'))
+_ALLO_ROOT = os.path.abspath(os.path.join(_ALLO_CODE_DIR, '..'))
+
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+
+
+def run_pxdesign_batch(input_json, outdir, n_sample, n_step, gpu):
+ """Run vanilla PXDesign via CLI subprocess."""
+ pxdesign_python = 'python'
+
+ # Use pxdesign CLI
+ cmd = [
+ pxdesign_python, '-m', 'pxdesign.runner.cli', 'infer',
+ '-o', outdir,
+ '-i', input_json,
+ '--dtype', 'bf16',
+ '--N_sample', str(n_sample),
+ '--N_step', str(n_step),
+ ]
+
+ env = os.environ.copy()
+ # Inherit CUDA_VISIBLE_DEVICES from parent
+
+ logger.info(f"Running PXDesign: {n_sample} samples -> {outdir}")
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env,
+ timeout=7200)
+
+ if result.returncode != 0:
+ # Try alternative invocation via module
+ cmd_alt = [
+ pxdesign_python, '-m', 'pxdesign.runner.inference',
+ '--dump_dir', outdir,
+ '--input', input_json,
+ '--dtype', 'bf16',
+ '--N_sample', str(n_sample),
+ '--N_step', str(n_step),
+ ]
+ result = subprocess.run(cmd_alt, capture_output=True, text=True, env=env,
+ timeout=7200)
+ if result.returncode != 0:
+ logger.error(f"PXDesign failed:\nstdout: {result.stdout[-1000:]}\nstderr: {result.stderr[-1000:]}")
+ return False
+ return True
+
+
+def collect_pdbs(outdir):
+ """Collect PDB/CIF files from PXDesign output."""
+ pdbs = []
+ for ext in ('*.pdb', '*.cif'):
+ pdbs.extend(glob(os.path.join(outdir, '**/' + ext), recursive=True))
+ pdbs = sorted(pdbs)
+ filtered = [p for p in pdbs if 'sample' in os.path.basename(p).lower()
+ or 'design' in os.path.basename(p).lower()
+ or 'rank' in os.path.basename(p).lower()]
+ return filtered if filtered else pdbs
+
+
+def smc_particle_filter(args):
+ """Run SMC reranking with PXDesign."""
+ os.chdir(_ALLO_ROOT)
+
+ from qtheta_pxdesign import QThetaPXDesignGuidance
+
+ outdir = args.outdir
+ os.makedirs(outdir, exist_ok=True)
+
+ # Initialize scorer
+ guidance = QThetaPXDesignGuidance(
+ checkpoint=args.qtheta_checkpoint,
+ ref_holo=args.ref_holo,
+ ref_apo=args.ref_apo,
+ ref_chain=args.ref_chain,
+ device=f'cuda:{args.gpu}',
+ )
+ guidance._lazy_init()
+
+ all_designs = []
+ round_summaries = []
+
+ for round_idx in range(args.n_rounds):
+ round_dir = os.path.join(outdir, f'round_{round_idx}')
+ os.makedirs(round_dir, exist_ok=True)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"SMC Round {round_idx + 1}/{args.n_rounds}")
+ logger.info(f"{'='*60}")
+
+ # Generate particles via vanilla PXDesign
+ gen_dir = os.path.join(round_dir, 'generated')
+ success = run_pxdesign_batch(
+ input_json=args.input,
+ outdir=gen_dir,
+ n_sample=args.n_particles,
+ n_step=args.N_step,
+ gpu=args.gpu,
+ )
+
+ if not success:
+ # If subprocess fails, try using existing PXDesign outputs
+ logger.warning(f"Round {round_idx} generation failed. "
+ f"Checking for existing outputs...")
+ pdbs = collect_pdbs(args.designs_dir) if hasattr(args, 'designs_dir') else []
+ if not pdbs:
+ continue
+ else:
+ pdbs = collect_pdbs(gen_dir)
+
+ if not pdbs:
+ logger.warning(f"No PDBs found in round {round_idx}")
+ continue
+
+ # Score all particles
+ logger.info(f"Scoring {len(pdbs)} particles...")
+ round_results = []
+ for pdb_path in pdbs:
+ result = guidance.score_design(pdb_path)
+ if result is not None:
+ result['pdb_path'] = pdb_path
+ result['design_id'] = os.path.basename(pdb_path).replace('.pdb', '').replace('.cif', '')
+ result['round'] = round_idx
+ round_results.append(result)
+
+ if not round_results:
+ continue
+
+ margins = np.array([r['margin'] for r in round_results])
+
+ round_summary = {
+ 'round': round_idx,
+ 'n_particles': len(round_results),
+ 'margin_mean': float(margins.mean()),
+ 'margin_std': float(margins.std()),
+ 'margin_max': float(margins.max()),
+ 'frac_positive': float((margins > 0).mean()),
+ }
+ round_summaries.append(round_summary)
+
+ logger.info(f"Round {round_idx}: margin={margins.mean():.3f}Β±{margins.std():.3f}, "
+ f"max={margins.max():.3f}, S>0={round_summary['frac_positive']:.1%}")
+
+ all_designs.extend(round_results)
+
+ # Final ranking and summary
+ if all_designs:
+ all_designs.sort(key=lambda x: x['margin'], reverse=True)
+ all_margins = np.array([d['margin'] for d in all_designs])
+ holo_scores = np.array([d['q_holo'] for d in all_designs])
+
+ # Best-of-K
+ bok = {}
+ for K in [1, 2, 5, 10]:
+ n_trials = 2000
+ n_avail = len(all_margins)
+ successes = sum(
+ 1 for _ in range(n_trials)
+ if all_margins[np.random.choice(n_avail, min(K, n_avail), replace=False)].max() > 0
+ )
+ bok[K] = successes / n_trials
+
+ summary = {
+ 'method': 'PXDesign + SMC',
+ 'n_rounds': args.n_rounds,
+ 'n_particles_per_round': args.n_particles,
+ 'total_designs': len(all_designs),
+ 'margin_mean': float(all_margins.mean()),
+ 'margin_std': float(all_margins.std()),
+ 'margin_max': float(all_margins.max()),
+ 'frac_positive': float((all_margins > 0).mean()),
+ 'q_holo_mean': float(holo_scores.mean()),
+ 'q_apo_mean': float(np.mean([d['q_apo'] for d in all_designs])),
+ 'best_of_k': {str(k): v for k, v in bok.items()},
+ 'round_summaries': round_summaries,
+ 'top5': all_designs[:5],
+ }
+
+ with open(os.path.join(outdir, 'smc_scores.json'), 'w') as f:
+ json.dump(all_designs, f, indent=2)
+ with open(os.path.join(outdir, 'smc_summary.json'), 'w') as f:
+ json.dump(summary, f, indent=2)
+
+ # Copy best designs
+ best_dir = os.path.join(outdir, 'best_designs')
+ os.makedirs(best_dir, exist_ok=True)
+ for i, d in enumerate(all_designs[:20]):
+ if os.path.exists(d['pdb_path']):
+ dest = os.path.join(best_dir, f'rank_{i:02d}_{d["design_id"]}.pdb')
+ shutil.copy2(d['pdb_path'], dest)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"PXDesign + SMC Results ({len(all_designs)} total designs)")
+ logger.info(f" Margin: {all_margins.mean():.3f} Β± {all_margins.std():.3f}")
+ logger.info(f" Max margin: {all_margins.max():.3f}")
+ logger.info(f" Fraction S > 0: {(all_margins > 0).mean():.1%}")
+ logger.info(f" Q(holo) mean: {holo_scores.mean():.3f}")
+ logger.info(f" Best-of-K:")
+ for k, v in sorted(bok.items()):
+ logger.info(f" K={k:3d}: {v:.3f}")
+ logger.info(f"{'='*60}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description='PXDesign + SMC Reranking')
+ parser.add_argument('--input', default='experiments/pxdesign_cam/output/cam_binder.json',
+ help='PXDesign input JSON')
+ parser.add_argument('--designs_dir', default='experiments/pxdesign_cam/output/',
+ help='Existing PXDesign outputs (fallback if generation fails)')
+ parser.add_argument('--qtheta_checkpoint',
+ default='results/checkpoints_cam_v3/best_phase2.pt')
+ parser.add_argument('--ref_holo', default='data/pdbs/cam_holo/3CLN.pdb')
+ parser.add_argument('--ref_apo', default='data/pdbs/cam_apo/1CFD.pdb')
+ parser.add_argument('--ref_chain', default='A')
+ parser.add_argument('--n_particles', type=int, default=16,
+ help='Particles per round')
+ parser.add_argument('--n_rounds', type=int, default=4,
+ help='Number of SMC rounds')
+ parser.add_argument('--N_step', type=int, default=400)
+ parser.add_argument('--gpu', type=int, default=0)
+ parser.add_argument('--outdir', default='results/pxdesign_smc')
+ args = parser.parse_args()
+
+ smc_particle_filter(args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/pxdesign_guidance/tds_pxdesign.py b/code/scripts/pxdesign_guidance/tds_pxdesign.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d1beca63d3cee271b8fbcc278126519a8373881
--- /dev/null
+++ b/code/scripts/pxdesign_guidance/tds_pxdesign.py
@@ -0,0 +1,323 @@
+"""
+PXDesign + Twisted Diffusion Sampling (TDS).
+
+Multi-round particle filtering with guided PXDesign:
+ Round r:
+ 1. Generate N particles via PXDesign with Q_theta classifier guidance
+ 2. Score each particle with Q_theta selectivity margin
+ 3. Compute importance weights w_i ~ exp(margin_i / temperature)
+ 4. Resample particles (keep best, discard worst)
+ 5. Add perturbation noise for diversity
+
+This combines in-process guidance (the "twisted proposal") with post-hoc
+importance-weighted resampling for highest-quality designs.
+
+Usage:
+ python code/scripts/pxdesign_guidance/tds_pxdesign.py \
+ --input experiments/pxdesign_cam/output/cam_binder.json \
+ --qtheta_checkpoint results/checkpoints_cam_v3/best_phase2.pt \
+ --ref_holo data/pdbs/cam_holo/3CLN.pdb \
+ --ref_apo data/pdbs/cam_apo/1CFD.pdb \
+ --n_particles 16 --n_rounds 4 \
+ --guidance_scale 0.5 \
+ --gpu 0
+"""
+
+import os
+import sys
+import argparse
+import json
+import logging
+import shutil
+import subprocess
+from glob import glob
+
+import numpy as np
+import torch
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+_ALLO_CODE_DIR = os.path.abspath(os.path.join(_SCRIPT_DIR, '..', '..'))
+_ALLO_ROOT = os.path.abspath(os.path.join(_ALLO_CODE_DIR, '..'))
+
+if _ALLO_CODE_DIR not in sys.path:
+ sys.path.insert(0, _ALLO_CODE_DIR)
+
+
+def compute_ess(log_weights):
+ """Compute effective sample size from log-weights."""
+ log_weights = log_weights - log_weights.max()
+ weights = np.exp(log_weights)
+ weights = weights / weights.sum()
+ return 1.0 / (weights ** 2).sum()
+
+
+def run_guided_pxdesign_batch(input_json, outdir, n_sample, n_step,
+ gpu, guidance_args):
+ """Run guided PXDesign as a subprocess."""
+ pxdesign_python = 'python'
+ cmd = [
+ pxdesign_python,
+ os.path.join(_SCRIPT_DIR, 'guided_pxdesign.py'),
+ '--input', input_json,
+ '--qtheta_checkpoint', guidance_args['checkpoint'],
+ '--ref_holo', guidance_args['ref_holo'],
+ '--ref_apo', guidance_args['ref_apo'],
+ '--ref_chain', guidance_args['ref_chain'],
+ '--guidance_scale', str(guidance_args['guidance_scale']),
+ '--guidance_start', str(guidance_args.get('guidance_start', 0.8)),
+ '--guidance_end', str(guidance_args.get('guidance_end', 0.1)),
+ '--N_sample', str(n_sample),
+ '--N_step', str(n_step),
+ '--gpu', str(gpu),
+ '--outdir', outdir,
+ ]
+
+ env = os.environ.copy()
+ # Inherit CUDA_VISIBLE_DEVICES from parent
+
+ logger.info(f"Running guided PXDesign: {n_sample} samples -> {outdir}")
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env,
+ timeout=7200)
+
+ if result.returncode != 0:
+ logger.error(f"PXDesign failed:\n{result.stderr[-2000:]}")
+ return False
+ return True
+
+
+def run_vanilla_pxdesign_batch(input_json, outdir, n_sample, n_step, gpu):
+ """Run vanilla PXDesign (no guidance) as a subprocess."""
+ pxdesign_env = 'python'
+
+ cmd = [
+ pxdesign_env, '-m', 'pxdesign.runner.inference',
+ '--dump_dir', outdir,
+ '--input', input_json,
+ '--dtype', 'bf16',
+ '--N_sample', str(n_sample),
+ '--N_step', str(n_step),
+ ]
+
+ env = os.environ.copy()
+ # Inherit CUDA_VISIBLE_DEVICES from parent
+
+ logger.info(f"Running vanilla PXDesign: {n_sample} samples -> {outdir}")
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env,
+ timeout=7200)
+
+ if result.returncode != 0:
+ logger.error(f"PXDesign failed:\n{result.stderr[-2000:]}")
+ return False
+ return True
+
+
+def collect_pdbs(outdir):
+ """Collect PDB/CIF paths from PXDesign output directory."""
+ pdbs = []
+ for ext in ('*.pdb', '*.cif'):
+ pdbs.extend(glob(os.path.join(outdir, '**/' + ext), recursive=True))
+ pdbs = sorted(pdbs)
+ filtered = [p for p in pdbs if 'sample' in os.path.basename(p).lower()
+ or 'design' in os.path.basename(p).lower()
+ or 'rank' in os.path.basename(p).lower()]
+ return filtered if filtered else pdbs
+
+
+def tds_particle_filter(args):
+ """Run TDS particle filtering with PXDesign."""
+ from qtheta_pxdesign import QThetaPXDesignGuidance
+
+ outdir = os.path.join(_ALLO_ROOT, args.outdir)
+ os.makedirs(outdir, exist_ok=True)
+
+ # Initialize scorer
+ guidance = QThetaPXDesignGuidance(
+ checkpoint=os.path.join(_ALLO_ROOT, args.qtheta_checkpoint),
+ ref_holo=os.path.join(_ALLO_ROOT, args.ref_holo),
+ ref_apo=os.path.join(_ALLO_ROOT, args.ref_apo),
+ ref_chain=args.ref_chain,
+ device=f'cuda:{args.gpu}',
+ )
+ guidance._lazy_init()
+
+ guidance_args = {
+ 'checkpoint': args.qtheta_checkpoint,
+ 'ref_holo': args.ref_holo,
+ 'ref_apo': args.ref_apo,
+ 'ref_chain': args.ref_chain,
+ 'guidance_scale': args.guidance_scale,
+ 'guidance_start': args.guidance_start,
+ 'guidance_end': args.guidance_end,
+ }
+
+ all_designs = []
+ round_summaries = []
+
+ for round_idx in range(args.n_rounds):
+ round_dir = os.path.join(outdir, f'round_{round_idx}')
+ os.makedirs(round_dir, exist_ok=True)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"TDS Round {round_idx + 1}/{args.n_rounds}")
+ logger.info(f"{'='*60}")
+
+ # Generate particles via guided PXDesign
+ gen_dir = os.path.join(round_dir, 'generated')
+ success = run_guided_pxdesign_batch(
+ input_json=os.path.join(_ALLO_ROOT, args.input),
+ outdir=gen_dir,
+ n_sample=args.n_particles,
+ n_step=args.N_step,
+ gpu=args.gpu,
+ guidance_args=guidance_args,
+ )
+
+ if not success:
+ logger.warning(f"Round {round_idx} generation failed, skipping")
+ continue
+
+ # Collect and score particles
+ pdbs = collect_pdbs(gen_dir)
+ if not pdbs:
+ logger.warning(f"No PDBs found in round {round_idx}")
+ continue
+
+ logger.info(f"Scoring {len(pdbs)} particles...")
+ round_results = []
+ for pdb_path in pdbs:
+ result = guidance.score_design(pdb_path)
+ if result is not None:
+ result['pdb_path'] = pdb_path
+ result['design_id'] = os.path.basename(pdb_path).replace('.pdb', '').replace('.cif', '')
+ result['round'] = round_idx
+ round_results.append(result)
+
+ if not round_results:
+ logger.warning(f"No scorable designs in round {round_idx}")
+ continue
+
+ margins = np.array([r['margin'] for r in round_results])
+
+ # Compute importance weights
+ log_weights = margins / args.temperature
+ ess = compute_ess(log_weights)
+
+ round_summary = {
+ 'round': round_idx,
+ 'n_particles': len(round_results),
+ 'margin_mean': float(margins.mean()),
+ 'margin_std': float(margins.std()),
+ 'margin_max': float(margins.max()),
+ 'frac_positive': float((margins > 0).mean()),
+ 'ess': float(ess),
+ }
+ round_summaries.append(round_summary)
+
+ logger.info(f"Round {round_idx}: margin={margins.mean():.3f}Β±{margins.std():.3f}, "
+ f"max={margins.max():.3f}, S>0={round_summary['frac_positive']:.1%}, "
+ f"ESS={ess:.1f}/{len(round_results)}")
+
+ # Add to design pool
+ all_designs.extend(round_results)
+
+ # Resample for next round (top-K selection for PXDesign since
+ # we can't easily perturb and re-denoise)
+ if round_idx < args.n_rounds - 1:
+ # Copy best designs to inform next round
+ # For PXDesign, each round generates fresh samples with guidance
+ # Resampling influence is through the guidance strength
+ # Increase guidance scale for later rounds
+ guidance_args['guidance_scale'] = args.guidance_scale * (1.0 + 0.2 * (round_idx + 1))
+ logger.info(f"Increasing guidance scale to {guidance_args['guidance_scale']:.2f} "
+ f"for next round")
+
+ # Final summary
+ if all_designs:
+ all_designs.sort(key=lambda x: x['margin'], reverse=True)
+ all_margins = np.array([d['margin'] for d in all_designs])
+ holo_scores = np.array([d['q_holo'] for d in all_designs])
+
+ # Best-of-K
+ bok = {}
+ for K in [1, 2, 5, 10]:
+ n_trials = 2000
+ n_avail = len(all_margins)
+ successes = sum(
+ 1 for _ in range(n_trials)
+ if all_margins[np.random.choice(n_avail, min(K, n_avail), replace=False)].max() > 0
+ )
+ bok[K] = successes / n_trials
+
+ summary = {
+ 'method': 'PXDesign + TDS',
+ 'n_rounds': args.n_rounds,
+ 'n_particles_per_round': args.n_particles,
+ 'total_designs': len(all_designs),
+ 'guidance_scale': args.guidance_scale,
+ 'temperature': args.temperature,
+ 'margin_mean': float(all_margins.mean()),
+ 'margin_std': float(all_margins.std()),
+ 'margin_max': float(all_margins.max()),
+ 'frac_positive': float((all_margins > 0).mean()),
+ 'q_holo_mean': float(holo_scores.mean()),
+ 'best_of_k': {str(k): v for k, v in bok.items()},
+ 'round_summaries': round_summaries,
+ 'top5': all_designs[:5],
+ }
+
+ with open(os.path.join(outdir, 'tds_scores.json'), 'w') as f:
+ json.dump(all_designs, f, indent=2)
+ with open(os.path.join(outdir, 'tds_summary.json'), 'w') as f:
+ json.dump(summary, f, indent=2)
+
+ # Copy best designs to top-level
+ best_dir = os.path.join(outdir, 'best_designs')
+ os.makedirs(best_dir, exist_ok=True)
+ for i, d in enumerate(all_designs[:20]):
+ if os.path.exists(d['pdb_path']):
+ dest = os.path.join(best_dir, f'rank_{i:02d}_{d["design_id"]}.pdb')
+ shutil.copy2(d['pdb_path'], dest)
+
+ logger.info(f"\n{'='*60}")
+ logger.info(f"PXDesign + TDS Results ({len(all_designs)} total designs)")
+ logger.info(f" Margin: {all_margins.mean():.3f} Β± {all_margins.std():.3f}")
+ logger.info(f" Max margin: {all_margins.max():.3f}")
+ logger.info(f" Fraction S > 0: {(all_margins > 0).mean():.1%}")
+ logger.info(f" Q(holo) mean: {holo_scores.mean():.3f}")
+ logger.info(f" Best-of-K:")
+ for k, v in sorted(bok.items()):
+ logger.info(f" K={k:3d}: {v:.3f}")
+ logger.info(f"{'='*60}")
+
+
+def main():
+ parser = argparse.ArgumentParser(description='PXDesign + TDS')
+ parser.add_argument('--input', default='experiments/pxdesign_cam/output/cam_binder.json')
+ parser.add_argument('--qtheta_checkpoint',
+ default='results/checkpoints_cam_v3/best_phase2.pt')
+ parser.add_argument('--ref_holo', default='data/pdbs/cam_holo/3CLN.pdb')
+ parser.add_argument('--ref_apo', default='data/pdbs/cam_apo/1CFD.pdb')
+ parser.add_argument('--ref_chain', default='A')
+ parser.add_argument('--n_particles', type=int, default=16,
+ help='Particles per round')
+ parser.add_argument('--n_rounds', type=int, default=4,
+ help='Number of TDS rounds')
+ parser.add_argument('--guidance_scale', type=float, default=0.5,
+ help='Initial guidance scale')
+ parser.add_argument('--guidance_start', type=float, default=0.8)
+ parser.add_argument('--guidance_end', type=float, default=0.1)
+ parser.add_argument('--temperature', type=float, default=0.5,
+ help='Temperature for importance weights')
+ parser.add_argument('--N_step', type=int, default=400)
+ parser.add_argument('--gpu', type=int, default=0)
+ parser.add_argument('--outdir', default='results/pxdesign_tds')
+ args = parser.parse_args()
+
+ tds_particle_filter(args)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/code/scripts/rescore.py b/code/scripts/rescore.py
new file mode 100644
index 0000000000000000000000000000000000000000..fb38ea6c3b1262be3ada9ed78303165f82d74e2f
--- /dev/null
+++ b/code/scripts/rescore.py
@@ -0,0 +1,178 @@
+#!/usr/bin/env python3
+"""
+Re-score binder PDB designs with a Q_theta checkpoint.
+
+Walks a directory of designs (binder PDB + sibling holo / apo receptor PDBs),
+runs each through DifferentiableQTheta, and writes per-design
+S = Q_theta(holo) - Q_theta(apo) plus the raw holo/apo scores to JSON.
+
+Usage:
+ python code/scripts/rescore.py \\
+ --checkpoint checkpoints/Q_theta_phase2.pt \\
+ --gpu 0
+"""
+import os, sys, json, argparse, glob, logging
+import numpy as np
+import torch
+from pathlib import Path
+
+logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
+logger = logging.getLogger(__name__)
+
+BASE = str(Path(__file__).resolve().parent.parent.parent)
+sys.path.insert(0, os.path.join(BASE, 'code'))
+sys.path.insert(0, BASE)
+
+from models.differentiable_features import DifferentiableQTheta
+from utils.pdb_utils import load_structure, get_residues, get_backbone_coords, get_aa_indices, align_structures
+
+HOLO_PDB = os.path.join(BASE, 'data/pdbs/cam_holo/3CLN.pdb')
+APO_PDB = os.path.join(BASE, 'data/pdbs/cam_apo/1CFD.pdb')
+
+
+def score_pdb_list(dq, pdb_list, ref_resnums, ref_coords, device):
+ """Score a list of design PDB files."""
+ results = []
+ for pdb_path in pdb_list:
+ name = os.path.basename(pdb_path).replace(".pdb", "")
+ try:
+ design_model = load_structure(pdb_path)
+ chains = [c.id for c in design_model.get_chains()]
+ rec_chain = 'A' if 'A' in chains else chains[0]
+ binder_chain = 'B' if 'B' in chains else [c for c in chains if c != rec_chain][0]
+
+ rec_res = get_residues(design_model[rec_chain])
+ binder_res = get_residues(design_model[binder_chain])
+ rec_coords_d, _ = get_backbone_coords(rec_res)
+ binder_coords, binder_mask = get_backbone_coords(binder_res)
+ binder_aa_idx = get_aa_indices(binder_res)
+
+ design_resnums = {r.get_id()[1]: i for i, r in enumerate(rec_res)}
+ common = sorted(set(design_resnums.keys()) & set(ref_resnums.keys()))
+ if len(common) < 10:
+ logger.warning(f" Skip {name}: <10 common residues")
+ continue
+
+ d_ca = rec_coords_d[[design_resnums[r] for r in common], 1]
+ r_ca = ref_coords[[ref_resnums[r] for r in common], 1]
+ mobile_center = d_ca.mean(0)
+ ref_center = r_ca.mean(0)
+ _, R = align_structures(d_ca, r_ca)
+
+ flat = binder_coords.reshape(-1, 3) - mobile_center
+ aligned_binder = (flat @ R.T + ref_center).reshape(-1, 4, 3)
+
+ coords_t = torch.from_numpy(aligned_binder).float().to(device)
+ mask_t = torch.from_numpy(binder_mask).bool().to(device)
+ aa_t = torch.from_numpy(binder_aa_idx).long().to(device)
+
+ with torch.no_grad():
+ q_holo = dq.score(coords_t, mask_t, binder_aa_idx=aa_t,
+ receptor_label='holo').item()
+ q_apo = dq.score(coords_t, mask_t, binder_aa_idx=aa_t,
+ receptor_label='apo').item()
+ S = q_holo - q_apo
+ results.append({"design": name, "Q_holo": q_holo, "Q_apo": q_apo, "S": S})
+ except Exception as e:
+ logger.warning(f" Skip {name}: {e}")
+ return results
+
+
+def summarize(results, label):
+ if not results:
+ return {}
+ S = [r["S"] for r in results]
+ return {
+ "method": label, "n": len(S),
+ "S_mean": float(np.mean(S)), "S_std": float(np.std(S)),
+ "S_pos_pct": float(np.mean([s > 0 for s in S]) * 100),
+ "Q_holo_mean": float(np.mean([r["Q_holo"] for r in results])),
+ "Q_apo_mean": float(np.mean([r["Q_apo"] for r in results])),
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--gpu", type=int, default=7)
+ parser.add_argument("--checkpoint", default="checkpoints/Q_theta_phase2.pt")
+ args = parser.parse_args()
+
+ os.environ["CUDA_VISIBLE_DEVICES"] = str(args.gpu)
+ device = "cuda:0"
+
+ logger.info(f"Loading Q_theta from {args.checkpoint}")
+ dq = DifferentiableQTheta(checkpoint_path=args.checkpoint, device=device,
+ esm_dir=os.path.join(BASE, "data/esm2_embeddings"))
+ dq.load_receptor(HOLO_PDB, chain='A', label='holo', esm_target='cam')
+ dq.load_receptor(APO_PDB, chain='A', label='apo', esm_target='cam')
+
+ ref_model = load_structure(HOLO_PDB)
+ ref_res = get_residues(ref_model['A'])
+ ref_coords, _ = get_backbone_coords(ref_res)
+ ref_resnums = {r.get_id()[1]: i for i, r in enumerate(ref_res)}
+
+ output_dir = os.path.join(BASE, "results/v2_strict_holdout/scoring")
+ os.makedirs(output_dir, exist_ok=True)
+
+ # Define design directories
+ design_sets = {
+ "vanilla": os.path.join(BASE, "results/independent_validation/vanilla/holo_pdbs"),
+ "langevin": os.path.join(BASE, "results/langevin_refinement/refined_pdbs"),
+ "classifier": os.path.join(BASE, "results/guided_diffusion/guided"),
+ "smc_r3": os.path.join(BASE, "results/smc_guidance/cam/round_3"),
+ }
+
+ # Also check for TDS and PXDesign
+ tds_dirs = glob.glob(os.path.join(BASE, "results/tds_guidance/cam/designs"))
+ if tds_dirs:
+ design_sets["tds"] = tds_dirs[0]
+
+ # PXDesign directories
+ for px_method in ["pxdesign_scoring", "pxdesign_classifier", "pxdesign_tds",
+ "pxdesign_smc", "pxdesign_langevin"]:
+ px_dir = os.path.join(BASE, f"results_familysplit/design_bd30/{px_method}")
+ if not os.path.exists(px_dir):
+ px_dir = os.path.join(BASE, f"results/{px_method}")
+ if os.path.exists(px_dir):
+ pdbs = glob.glob(os.path.join(px_dir, "*.pdb"))
+ if pdbs:
+ design_sets[px_method] = px_dir
+
+ all_results = {}
+ summaries = []
+
+ for method, pdb_dir in design_sets.items():
+ if not os.path.exists(pdb_dir):
+ logger.warning(f" {method}: directory not found ({pdb_dir})")
+ continue
+ pdbs = sorted(glob.glob(os.path.join(pdb_dir, "*.pdb")))
+ if not pdbs:
+ logger.warning(f" {method}: no PDB files")
+ continue
+
+ logger.info(f"\n=== {method} ({len(pdbs)} designs) ===")
+ results = score_pdb_list(dq, pdbs, ref_resnums, ref_coords, device)
+ s = summarize(results, method)
+ if s:
+ summaries.append(s)
+ logger.info(f" {method}: n={s['n']}, SΜ={s['S_mean']:.3f}Β±{s['S_std']:.3f}, "
+ f"S>0={s['S_pos_pct']:.0f}%, Q+={s['Q_holo_mean']:.3f}, Q-={s['Q_apo_mean']:.3f}")
+ all_results[method] = {"results": results, "summary": s}
+
+ # Save
+ with open(os.path.join(output_dir, "rescore_v2_all.json"), "w") as f:
+ json.dump(all_results, f, indent=2)
+
+ # Print summary table
+ print("\n" + "=" * 70)
+ print("V2 RESCORING SUMMARY (strict holdout, CaM OOD)")
+ print("=" * 70)
+ print(f"{'Method':20s} {'n':>4s} {'SΜ':>8s} {'Β±Ο':>6s} {'S>0%':>6s} {'Q+':>6s} {'Q-':>6s}")
+ print("-" * 70)
+ for s in sorted(summaries, key=lambda x: x['S_mean'], reverse=True):
+ print(f"{s['method']:20s} {s['n']:4d} {s['S_mean']:8.3f} {s['S_std']:6.3f} "
+ f"{s['S_pos_pct']:5.1f}% {s['Q_holo_mean']:6.3f} {s['Q_apo_mean']:6.3f}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/code/trainers/__init__.py b/code/trainers/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/code/trainers/trainer.py b/code/trainers/trainer.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c98919c578cfd18d7335f4b0a0a27deb963034c
--- /dev/null
+++ b/code/trainers/trainer.py
@@ -0,0 +1,674 @@
+"""
+Trainer for the Q_theta state-selectivity scorer.
+
+Implements two-phase training:
+ Phase 1: DockQ regression (learn complex quality from all data)
+ Phase 2: Selectivity fine-tuning (learn to rank X+ > X- for the same binder)
+
+Integrates with Weights & Biases for experiment tracking.
+"""
+
+import os
+import time
+import logging
+import numpy as np
+import torch
+import torch.nn as nn
+from torch.optim import AdamW
+from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR
+from scipy.stats import spearmanr
+from sklearn.metrics import roc_auc_score
+
+import wandb
+
+logger = logging.getLogger(__name__)
+
+
+class AverageMeter:
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.val = 0.0
+ self.avg = 0.0
+ self.sum = 0.0
+ self.count = 0
+
+ def update(self, val, n=1):
+ self.val = val
+ self.sum += val * n
+ self.count += n
+ self.avg = self.sum / self.count
+
+
+class AlloDesignerTrainer:
+ """
+ Two-phase trainer for Q_theta.
+
+ Phase 1 (DockQ regression):
+ - Minimizes MSE(Q_theta(X, Y), DockQ_label) on all complex types
+ - Learns general complex quality
+
+ Phase 2 (Selectivity fine-tuning):
+ - Minimizes selectivity margin loss on paired (pos, neg) data
+ - Learns to rank Q(X+, Y) > Q(X-, Y)
+ - Combined: L = L_regression + lambda_rank * L_selectivity
+ """
+
+ def __init__(self, model, config, device='cuda'):
+ self.model = model.to(device)
+ self.config = config
+ self.device = device
+ self.use_sam = config.get('optimizer', 'adamw') == 'sam'
+
+ # Optimizer
+ if self.use_sam:
+ from utils.sam import SAM
+ self.optimizer = SAM(
+ model.parameters(),
+ base_optimizer=AdamW,
+ rho=0.05,
+ lr=config.get('lr', 1e-4),
+ weight_decay=config.get('weight_decay', 1e-4),
+ betas=(0.9, 0.999),
+ )
+ # SAM wraps AdamW; scheduler goes on base_optimizer
+ sched_optimizer = self.optimizer.base_optimizer
+ else:
+ self.optimizer = AdamW(
+ model.parameters(),
+ lr=config.get('lr', 1e-4),
+ weight_decay=config.get('weight_decay', 1e-4),
+ betas=(0.9, 0.999),
+ )
+ sched_optimizer = self.optimizer
+
+ # Learning rate scheduler (warmup + cosine)
+ n_warmup = config.get('warmup_steps', 100)
+ n_total = config.get('max_steps', 5000)
+
+ warmup_sched = LinearLR(sched_optimizer, start_factor=0.01, end_factor=1.0, total_iters=n_warmup)
+ cosine_sched = CosineAnnealingLR(sched_optimizer, T_max=n_total - n_warmup, eta_min=1e-6)
+ self.scheduler = SequentialLR(sched_optimizer, [warmup_sched, cosine_sched], milestones=[n_warmup])
+
+ self.global_step = 0
+ self.best_val_metric = -float('inf')
+ self.checkpoint_dir = config.get('checkpoint_dir', 'results/checkpoints')
+ os.makedirs(self.checkpoint_dir, exist_ok=True)
+
+ # ------------------------------------------------------------------ #
+ # Phase 1: DockQ regression
+ # ------------------------------------------------------------------ #
+
+ def train_step_phase1(self, batch):
+ """Single training step for Phase 1 (DockQ regression)."""
+ self.model.train()
+ node_feats = batch['node_feats'].to(self.device) # [B, N, node_dim]
+ edge_feats = batch['edge_feats'].to(self.device) # [B, N, N, edge_dim]
+ node_mask = batch['node_mask'].to(self.device) # [B, N]
+ labels = batch['label'].to(self.device) # [B]
+ esm_feats = batch['esm_feats'].to(self.device) if 'esm_feats' in batch else None
+
+ self.optimizer.zero_grad()
+
+ scores = self.model(node_feats, edge_feats, node_mask, esm_feats=esm_feats) # [B]
+ loss = nn.functional.mse_loss(scores, labels)
+
+ loss.backward()
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
+
+ if self.use_sam:
+ self.optimizer.first_step()
+ # Second forward-backward pass
+ scores2 = self.model(node_feats, edge_feats, node_mask, esm_feats=esm_feats)
+ loss2 = nn.functional.mse_loss(scores2, labels)
+ self.optimizer.zero_grad()
+ loss2.backward()
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
+ self.optimizer.second_step()
+ else:
+ self.optimizer.step()
+
+ self.scheduler.step()
+ self.global_step += 1
+
+ return {'loss': loss.item(), 'scores': scores.detach(), 'labels': labels}
+
+ def run_phase1(self, train_loader, val_loader, n_epochs: int = 30, run_name: str = 'phase1'):
+ """Phase 1 training loop."""
+ logger.info(f"Starting Phase 1 (DockQ regression) for {n_epochs} epochs")
+ wandb.define_metric('phase1/step')
+ wandb.define_metric('phase1/*', step_metric='phase1/step')
+
+ for epoch in range(n_epochs):
+ # Train
+ train_meter = AverageMeter()
+ all_scores, all_labels = [], []
+
+ for batch in train_loader:
+ result = self.train_step_phase1(batch)
+ train_meter.update(result['loss'], n=len(result['scores']))
+ all_scores.append(result['scores'].cpu().numpy())
+ all_labels.append(result['labels'].cpu().numpy())
+
+ if self.global_step % 50 == 0:
+ wandb.log({
+ 'phase1/train_loss': result['loss'],
+ 'phase1/lr': self.optimizer.param_groups[0]['lr'],
+ 'phase1/step': self.global_step,
+ })
+
+ # Compute Spearman corr on training data
+ all_scores = np.concatenate(all_scores)
+ all_labels = np.concatenate(all_labels)
+ train_spearman = spearmanr(all_scores, all_labels).correlation
+
+ # Validate
+ val_metrics = self.evaluate_phase1(val_loader)
+
+ logger.info(
+ f"Phase1 Epoch {epoch+1}/{n_epochs} | "
+ f"Train Loss: {train_meter.avg:.4f} | "
+ f"Train Spearman: {train_spearman:.3f} | "
+ f"Val Loss: {val_metrics['val_loss']:.4f} | "
+ f"Val Spearman: {val_metrics['val_spearman']:.3f} | "
+ f"Val AUC: {val_metrics.get('val_auc', 0):.3f}"
+ )
+
+ wandb.log({
+ 'phase1/epoch': epoch + 1,
+ 'phase1/train_loss_epoch': train_meter.avg,
+ 'phase1/train_spearman': train_spearman,
+ **{f'phase1/{k}': v for k, v in val_metrics.items()},
+ })
+
+ # Checkpoint best model
+ if val_metrics['val_spearman'] > self.best_val_metric:
+ self.best_val_metric = val_metrics['val_spearman']
+ self.save_checkpoint('best_phase1.pt', extra={'epoch': epoch, 'phase': 1})
+ logger.info(f" -> New best Phase 1 model (val_spearman={self.best_val_metric:.3f})")
+
+ logger.info("Phase 1 training complete.")
+
+ @torch.no_grad()
+ def evaluate_phase1(self, loader):
+ """Evaluate Phase 1 model on val/test set."""
+ self.model.eval()
+ all_scores, all_labels = [], []
+ total_loss = 0.0
+ n_batches = 0
+
+ for batch in loader:
+ node_feats = batch['node_feats'].to(self.device)
+ edge_feats = batch['edge_feats'].to(self.device)
+ node_mask = batch['node_mask'].to(self.device)
+ labels = batch['label'].to(self.device)
+ esm_feats = batch['esm_feats'].to(self.device) if 'esm_feats' in batch else None
+
+ scores = self.model(node_feats, edge_feats, node_mask, esm_feats=esm_feats)
+ loss = nn.functional.mse_loss(scores, labels)
+
+ total_loss += loss.item()
+ n_batches += 1
+ all_scores.append(scores.cpu().numpy())
+ all_labels.append(labels.cpu().numpy())
+
+ all_scores = np.concatenate(all_scores)
+ all_labels = np.concatenate(all_labels)
+
+ spearman = spearmanr(all_scores, all_labels).correlation
+ if np.isnan(spearman):
+ spearman = 0.0
+
+ metrics = {
+ 'val_loss': total_loss / max(n_batches, 1),
+ 'val_spearman': float(spearman),
+ }
+
+ # AUC for binary quality (label > 0.5 = positive)
+ binary_labels = (all_labels > 0.5).astype(int)
+ if binary_labels.sum() > 0 and binary_labels.sum() < len(binary_labels):
+ try:
+ metrics['val_auc'] = roc_auc_score(binary_labels, all_scores)
+ except Exception:
+ pass
+
+ return metrics
+
+ # ------------------------------------------------------------------ #
+ # Phase 2: Selectivity fine-tuning
+ # ------------------------------------------------------------------ #
+
+ def train_step_phase2(self, batch, lambda_rank: float = 1.0, margin: float = 0.2,
+ lambda_ddg: float = 0.1):
+ """Single training step for Phase 2 (selectivity margin + ddG auxiliary)."""
+ self.model.train()
+
+ pos = batch['pos']
+ neg = batch['neg']
+
+ pos_node = pos['node_feats'].to(self.device)
+ pos_edge = pos['edge_feats'].to(self.device)
+ pos_mask = pos['node_mask'].to(self.device)
+ pos_label = pos['label'].to(self.device)
+ pos_ce = pos.get('contact_energy', None)
+ if pos_ce is not None:
+ pos_ce = pos_ce.to(self.device)
+
+ neg_node = neg['node_feats'].to(self.device)
+ neg_edge = neg['edge_feats'].to(self.device)
+ neg_mask = neg['node_mask'].to(self.device)
+ pos_esm = pos['esm_feats'].to(self.device) if 'esm_feats' in pos else None
+ neg_esm = neg['esm_feats'].to(self.device) if 'esm_feats' in neg else None
+
+ self.optimizer.zero_grad()
+
+ pos_scores = self.model(pos_node, pos_edge, pos_mask, esm_feats=pos_esm) # [B]
+ neg_scores = self.model(neg_node, neg_edge, neg_mask, esm_feats=neg_esm) # [B]
+
+ # Regression loss on positive examples
+ loss_reg = nn.functional.mse_loss(pos_scores, pos_label)
+
+ # Selectivity margin loss: pos_score - neg_score > margin
+ loss_margin = nn.functional.relu(margin - (pos_scores - neg_scores)).mean()
+
+ # InfoNCE-style selectivity loss
+ eps = 1e-6
+ pos_logit = torch.log(pos_scores.clamp(eps, 1 - eps) / (1 - pos_scores).clamp(eps))
+ neg_logit = torch.log(neg_scores.clamp(eps, 1 - eps) / (1 - neg_scores).clamp(eps))
+ log_denom = torch.stack([pos_logit, neg_logit], dim=-1).logsumexp(dim=-1)
+ infonce_loss = -(pos_logit - log_denom).mean()
+
+ # ddG auxiliary loss: MSE against contact-energy proxy (physics-informed soft label)
+ loss_ddg = torch.tensor(0.0, device=self.device)
+ if pos_ce is not None and pos_ce.shape[0] > 0:
+ # pos_ce is a contact-energy-based ddG proxy in [0, 1]
+ # Align positive score toward the contact energy signal
+ loss_ddg = nn.functional.mse_loss(pos_scores, pos_ce)
+
+ loss = loss_reg + lambda_rank * (loss_margin + infonce_loss) + lambda_ddg * loss_ddg
+
+ loss.backward()
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
+
+ if self.use_sam:
+ self.optimizer.first_step()
+ # Second forward-backward for SAM
+ pos_scores2 = self.model(pos_node, pos_edge, pos_mask, esm_feats=pos_esm)
+ neg_scores2 = self.model(neg_node, neg_edge, neg_mask, esm_feats=neg_esm)
+ loss_reg2 = nn.functional.mse_loss(pos_scores2, pos_label)
+ loss_margin2 = nn.functional.relu(margin - (pos_scores2 - neg_scores2)).mean()
+ eps2 = 1e-6
+ pl2 = torch.log(pos_scores2.clamp(eps2, 1-eps2) / (1-pos_scores2).clamp(eps2))
+ nl2 = torch.log(neg_scores2.clamp(eps2, 1-eps2) / (1-neg_scores2).clamp(eps2))
+ ld2 = torch.stack([pl2, nl2], dim=-1).logsumexp(dim=-1)
+ infonce2 = -(pl2 - ld2).mean()
+ loss2 = loss_reg2 + lambda_rank * (loss_margin2 + infonce2)
+ self.optimizer.zero_grad()
+ loss2.backward()
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
+ self.optimizer.second_step()
+ else:
+ self.optimizer.step()
+
+ self.scheduler.step()
+ self.global_step += 1
+
+ selectivity_gap = (pos_scores - neg_scores).mean().item()
+
+ return {
+ 'loss': loss.item(),
+ 'loss_reg': loss_reg.item(),
+ 'loss_margin': loss_margin.item(),
+ 'loss_infonce': infonce_loss.item(),
+ 'loss_ddg': loss_ddg.item(),
+ 'selectivity_gap': selectivity_gap,
+ 'pos_scores': pos_scores.detach(),
+ 'neg_scores': neg_scores.detach(),
+ }
+
+ def train_step_phase2_v2(self, batch, lambda_rank: float = 1.0, margin: float = 0.2,
+ lambda_ddg: float = 0.0, lambda_path: float = 0.5):
+ """Phase 2 training step with multi-negative + path monotonicity."""
+ self.model.train()
+
+ pos = batch['pos']
+ neg = batch['neg']
+
+ pos_node = pos['node_feats'].to(self.device)
+ pos_edge = pos['edge_feats'].to(self.device)
+ pos_mask = pos['node_mask'].to(self.device)
+ pos_label = pos['label'].to(self.device)
+ pos_ce = pos.get('contact_energy', None)
+ if pos_ce is not None:
+ pos_ce = pos_ce.to(self.device)
+
+ neg_node = neg['node_feats'].to(self.device)
+ neg_edge = neg['edge_feats'].to(self.device)
+ neg_mask = neg['node_mask'].to(self.device)
+ pos_esm = pos['esm_feats'].to(self.device) if 'esm_feats' in pos else None
+ neg_esm = neg['esm_feats'].to(self.device) if 'esm_feats' in neg else None
+
+ self.optimizer.zero_grad()
+
+ pos_scores = self.model(pos_node, pos_edge, pos_mask, esm_feats=pos_esm)
+ neg_scores = self.model(neg_node, neg_edge, neg_mask, esm_feats=neg_esm)
+
+ # Score path frames if present
+ path_scores = []
+ path_taus = batch.get('path_taus', [])
+ for path_frame in batch.get('path', []):
+ p_node = path_frame['node_feats'].to(self.device)
+ p_edge = path_frame['edge_feats'].to(self.device)
+ p_mask = path_frame['node_mask'].to(self.device)
+ p_score = self.model(p_node, p_edge, p_mask)
+ path_scores.append(p_score)
+
+ # Regression loss on positive examples
+ loss_reg = nn.functional.mse_loss(pos_scores, pos_label)
+
+ # Selectivity margin loss
+ loss_margin = nn.functional.relu(margin - (pos_scores - neg_scores)).mean()
+
+ # InfoNCE-style selectivity loss
+ eps = 1e-6
+ pos_logit = torch.log(pos_scores.clamp(eps, 1 - eps) / (1 - pos_scores).clamp(eps))
+ neg_logit = torch.log(neg_scores.clamp(eps, 1 - eps) / (1 - neg_scores).clamp(eps))
+ log_denom = torch.stack([pos_logit, neg_logit], dim=-1).logsumexp(dim=-1)
+ infonce_loss = -(pos_logit - log_denom).mean()
+
+ # ddG auxiliary loss
+ loss_ddg = torch.tensor(0.0, device=self.device)
+ if pos_ce is not None and pos_ce.shape[0] > 0 and lambda_ddg > 0:
+ loss_ddg = nn.functional.mse_loss(pos_scores, pos_ce)
+
+ # Path monotonicity loss
+ loss_path = torch.tensor(0.0, device=self.device)
+ if path_scores and lambda_path > 0:
+ small_margin = 0.05
+ for i in range(len(path_scores) - 1):
+ loss_path = loss_path + nn.functional.relu(
+ path_scores[i] - path_scores[i + 1] + small_margin
+ ).mean()
+ # Last path frame < positive score
+ loss_path = loss_path + nn.functional.relu(
+ path_scores[-1] - pos_scores + margin
+ ).mean()
+ # First path frame > negative score
+ loss_path = loss_path + nn.functional.relu(
+ neg_scores - path_scores[0] + small_margin
+ ).mean()
+
+ loss = (loss_reg + lambda_rank * (loss_margin + infonce_loss)
+ + lambda_ddg * loss_ddg + lambda_path * loss_path)
+
+ loss.backward()
+ nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)
+ self.optimizer.step()
+ self.scheduler.step()
+ self.global_step += 1
+
+ selectivity_gap = (pos_scores - neg_scores).mean().item()
+
+ return {
+ 'loss': loss.item(),
+ 'loss_reg': loss_reg.item(),
+ 'loss_margin': loss_margin.item(),
+ 'loss_infonce': infonce_loss.item(),
+ 'loss_ddg': loss_ddg.item(),
+ 'loss_path': loss_path.item(),
+ 'selectivity_gap': selectivity_gap,
+ 'pos_scores': pos_scores.detach(),
+ 'neg_scores': neg_scores.detach(),
+ }
+
+ def run_phase2_path(self, train_loader, val_loader, n_epochs: int = 20,
+ lambda_rank: float = 1.0, margin: float = 0.2,
+ lambda_ddg: float = 0.0, lambda_path: float = 0.5):
+ """Phase 2 with path-aware training loop."""
+ logger.info(f"Starting Phase 2 (path-aware) for {n_epochs} epochs "
+ f"[lambda_rank={lambda_rank}, lambda_path={lambda_path}]")
+ self.best_val_metric = -float('inf')
+
+ for epoch in range(n_epochs):
+ loss_meter = AverageMeter()
+ gap_meter = AverageMeter()
+ path_meter = AverageMeter()
+
+ for batch in train_loader:
+ result = self.train_step_phase2_v2(
+ batch, lambda_rank, margin, lambda_ddg, lambda_path)
+ B = len(result['pos_scores'])
+ loss_meter.update(result['loss'], B)
+ gap_meter.update(result['selectivity_gap'], B)
+ path_meter.update(result['loss_path'], B)
+
+ if self.global_step % 50 == 0:
+ wandb.log({
+ 'phase2/train_loss': result['loss'],
+ 'phase2/loss_margin': result['loss_margin'],
+ 'phase2/loss_infonce': result['loss_infonce'],
+ 'phase2/loss_path': result['loss_path'],
+ 'phase2/selectivity_gap': result['selectivity_gap'],
+ 'phase2/lr': self.optimizer.param_groups[0]['lr'],
+ 'phase2/step': self.global_step,
+ })
+
+ val_metrics = self.evaluate_phase2(val_loader)
+
+ logger.info(
+ f"Phase2-Path Epoch {epoch+1}/{n_epochs} | "
+ f"Loss: {loss_meter.avg:.4f} | "
+ f"Gap: {gap_meter.avg:.3f} | "
+ f"Path: {path_meter.avg:.4f} | "
+ f"Val Gap: {val_metrics['val_selectivity_gap']:.3f} | "
+ f"Val Acc: {val_metrics['val_ranking_acc']:.3f}"
+ )
+
+ wandb.log({
+ 'phase2/epoch': epoch + 1,
+ 'phase2/train_loss_epoch': loss_meter.avg,
+ 'phase2/train_gap_epoch': gap_meter.avg,
+ 'phase2/train_path_loss_epoch': path_meter.avg,
+ **{f'phase2/{k}': v for k, v in val_metrics.items()},
+ })
+
+ if val_metrics['val_selectivity_gap'] > self.best_val_metric:
+ self.best_val_metric = val_metrics['val_selectivity_gap']
+ self.save_checkpoint('best_phase2.pt', extra={'epoch': epoch, 'phase': 2})
+ logger.info(f" -> New best Phase 2 model (val_gap={self.best_val_metric:.3f})")
+
+ logger.info("Phase 2 (path-aware) training complete.")
+
+ def run_phase2(self, train_loader, val_loader, n_epochs: int = 20,
+ lambda_rank: float = 1.0, margin: float = 0.2,
+ lambda_ddg: float = 0.1):
+ """Phase 2 training loop (selectivity fine-tuning + ddG auxiliary)."""
+ logger.info(f"Starting Phase 2 (selectivity fine-tuning) for {n_epochs} epochs "
+ f"[lambda_rank={lambda_rank}, lambda_ddg={lambda_ddg}]")
+ self.best_val_metric = -float('inf')
+
+ for epoch in range(n_epochs):
+ loss_meter = AverageMeter()
+ gap_meter = AverageMeter()
+
+ for batch in train_loader:
+ result = self.train_step_phase2(batch, lambda_rank, margin, lambda_ddg)
+ B = len(result['pos_scores'])
+ loss_meter.update(result['loss'], B)
+ gap_meter.update(result['selectivity_gap'], B)
+
+ if self.global_step % 50 == 0:
+ wandb.log({
+ 'phase2/train_loss': result['loss'],
+ 'phase2/loss_margin': result['loss_margin'],
+ 'phase2/loss_infonce': result['loss_infonce'],
+ 'phase2/loss_ddg': result['loss_ddg'],
+ 'phase2/selectivity_gap': result['selectivity_gap'],
+ 'phase2/lr': self.optimizer.param_groups[0]['lr'],
+ 'phase2/step': self.global_step,
+ })
+
+ # Validate
+ val_metrics = self.evaluate_phase2(val_loader)
+
+ logger.info(
+ f"Phase2 Epoch {epoch+1}/{n_epochs} | "
+ f"Loss: {loss_meter.avg:.4f} | "
+ f"Gap: {gap_meter.avg:.3f} | "
+ f"Val Gap: {val_metrics['val_selectivity_gap']:.3f} | "
+ f"Val Acc: {val_metrics['val_ranking_acc']:.3f}"
+ )
+
+ wandb.log({
+ 'phase2/epoch': epoch + 1,
+ 'phase2/train_loss_epoch': loss_meter.avg,
+ 'phase2/train_gap_epoch': gap_meter.avg,
+ **{f'phase2/{k}': v for k, v in val_metrics.items()},
+ })
+
+ # Checkpoint
+ if val_metrics['val_selectivity_gap'] > self.best_val_metric:
+ self.best_val_metric = val_metrics['val_selectivity_gap']
+ self.save_checkpoint('best_phase2.pt', extra={'epoch': epoch, 'phase': 2})
+ logger.info(f" -> New best Phase 2 model (val_gap={self.best_val_metric:.3f})")
+
+ logger.info("Phase 2 training complete.")
+
+ @torch.no_grad()
+ def evaluate_phase2(self, loader):
+ """Evaluate selectivity on paired (pos, neg) val set."""
+ self.model.eval()
+ all_pos_scores, all_neg_scores = [], []
+
+ for batch in loader:
+ if 'pos' not in batch:
+ continue
+ pos = batch['pos']
+ neg = batch['neg']
+
+ pos_esm = pos['esm_feats'].to(self.device) if 'esm_feats' in pos else None
+ neg_esm = neg['esm_feats'].to(self.device) if 'esm_feats' in neg else None
+ pos_scores = self.model(
+ pos['node_feats'].to(self.device),
+ pos['edge_feats'].to(self.device),
+ pos['node_mask'].to(self.device),
+ esm_feats=pos_esm
+ )
+ neg_scores = self.model(
+ neg['node_feats'].to(self.device),
+ neg['edge_feats'].to(self.device),
+ neg['node_mask'].to(self.device),
+ esm_feats=neg_esm
+ )
+ all_pos_scores.append(pos_scores.cpu().numpy())
+ all_neg_scores.append(neg_scores.cpu().numpy())
+
+ if not all_pos_scores:
+ return {'val_selectivity_gap': 0.0, 'val_ranking_acc': 0.5}
+
+ all_pos = np.concatenate(all_pos_scores)
+ all_neg = np.concatenate(all_neg_scores)
+
+ gap = float((all_pos - all_neg).mean())
+ acc = float((all_pos > all_neg).mean())
+
+ return {
+ 'val_selectivity_gap': gap,
+ 'val_ranking_acc': acc,
+ 'val_pos_score_mean': float(all_pos.mean()),
+ 'val_neg_score_mean': float(all_neg.mean()),
+ }
+
+ # ------------------------------------------------------------------ #
+ # Checkpointing
+ # ------------------------------------------------------------------ #
+
+ def save_checkpoint(self, filename: str, extra: dict = None):
+ path = os.path.join(self.checkpoint_dir, filename)
+ state = {
+ 'model_state': self.model.state_dict(),
+ 'optimizer_state': self.optimizer.state_dict(),
+ 'global_step': self.global_step,
+ 'config': self.config,
+ }
+ if extra:
+ state.update(extra)
+ torch.save(state, path)
+ logger.debug(f"Saved checkpoint: {path}")
+
+ def load_checkpoint(self, filename: str):
+ path = os.path.join(self.checkpoint_dir, filename)
+ if not os.path.exists(path):
+ logger.warning(f"Checkpoint not found: {path}")
+ return False
+ state = torch.load(path, map_location=self.device)
+ self.model.load_state_dict(state['model_state'])
+ self.optimizer.load_state_dict(state['optimizer_state'])
+ self.global_step = state.get('global_step', 0)
+ logger.info(f"Loaded checkpoint from {path} (step {self.global_step})")
+ return True
+
+ # ------------------------------------------------------------------ #
+ # Full evaluation (test set)
+ # ------------------------------------------------------------------ #
+
+ @torch.no_grad()
+ def evaluate_test(self, test_loader, phase: int = 2):
+ """Full evaluation on test set with all metrics."""
+ self.model.eval()
+ all_scores, all_labels, all_types = [], [], []
+
+ for batch in test_loader:
+ if 'pos' in batch:
+ # Paired batch
+ for key in ['pos', 'neg']:
+ d = batch[key]
+ d_esm = d['esm_feats'].to(self.device) if 'esm_feats' in d else None
+ scores = self.model(
+ d['node_feats'].to(self.device),
+ d['edge_feats'].to(self.device),
+ d['node_mask'].to(self.device),
+ esm_feats=d_esm
+ )
+ all_scores.extend(scores.cpu().numpy().tolist())
+ all_labels.extend(d['label'].numpy().tolist())
+ all_types.extend(['pos' if key == 'pos' else 'neg'] * len(scores))
+ else:
+ esm_feats = batch['esm_feats'].to(self.device) if 'esm_feats' in batch else None
+ scores = self.model(
+ batch['node_feats'].to(self.device),
+ batch['edge_feats'].to(self.device),
+ batch['node_mask'].to(self.device),
+ esm_feats=esm_feats
+ )
+ all_scores.extend(scores.cpu().numpy().tolist())
+ all_labels.extend(batch['label'].numpy().tolist())
+ all_types.extend(batch['type'])
+
+ all_scores = np.array(all_scores)
+ all_labels = np.array(all_labels)
+
+ metrics = {}
+
+ # Spearman correlation (all samples)
+ metrics['test_spearman'] = float(spearmanr(all_scores, all_labels).correlation or 0)
+
+ # AUC (binary: label > 0.5 = positive quality)
+ binary = (all_labels > 0.5).astype(int)
+ if binary.sum() > 0 and binary.sum() < len(binary):
+ try:
+ metrics['test_auc'] = float(roc_auc_score(binary, all_scores))
+ except Exception:
+ pass
+
+ # Selectivity gap (pos vs neg_apo pairs)
+ pos_mask = np.array([t == 'pos' or t == 'positive' for t in all_types])
+ neg_mask = np.array([t == 'neg' or t == 'negative_apo' for t in all_types])
+ if pos_mask.sum() > 0 and neg_mask.sum() > 0:
+ metrics['test_selectivity_gap'] = float(all_scores[pos_mask].mean() - all_scores[neg_mask].mean())
+
+ logger.info(f"Test evaluation: {metrics}")
+ wandb.log({f'test/{k}': v for k, v in metrics.items()})
+
+ return metrics, all_scores, all_labels, all_types
diff --git a/code/utils/__init__.py b/code/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/code/utils/anm.py b/code/utils/anm.py
new file mode 100644
index 0000000000000000000000000000000000000000..25b2da80a82b93f0f3cbc68c8dfd84999d17c315
--- /dev/null
+++ b/code/utils/anm.py
@@ -0,0 +1,208 @@
+"""
+Anisotropic Network Model (ANM) for conformational path interpolation.
+
+From-scratch implementation using scipy eigendecomposition.
+Projects the apoβholo displacement onto low-frequency normal modes
+to create physically motivated interpolation paths.
+"""
+
+import numpy as np
+from scipy.linalg import eigh
+
+
+def compute_anm_modes(ca_coords, cutoff=15.0, n_modes=10):
+ """
+ Build elastic network Hessian and compute normal modes via eigendecomposition.
+
+ Args:
+ ca_coords: [N, 3] CA atom coordinates
+ cutoff: distance cutoff for spring connections (Angstroms)
+ n_modes: number of non-trivial modes to return
+
+ Returns:
+ eigenvalues: [n_modes] array of eigenvalues (force constants)
+ eigenvectors: [n_modes, N, 3] mode displacement vectors
+ """
+ N = len(ca_coords)
+ if N < 4:
+ return np.zeros(n_modes), np.zeros((n_modes, N, 3))
+
+ # Build 3N x 3N Hessian with uniform spring constant (gamma=1)
+ H = np.zeros((3 * N, 3 * N), dtype=np.float64)
+
+ for i in range(N):
+ for j in range(i + 1, N):
+ diff = ca_coords[j] - ca_coords[i]
+ dist = np.linalg.norm(diff)
+ if dist > cutoff or dist < 1e-6:
+ continue
+
+ # Outer product of unit displacement vector
+ unit = diff / dist
+ block = np.outer(unit, unit) # [3, 3]
+
+ # Off-diagonal: H[i,j] = -gamma * (r_ij β r_ij) / |r_ij|^2
+ # With uniform gamma=1 and unit vectors, this simplifies to:
+ ii, jj = 3 * i, 3 * j
+ H[ii:ii+3, jj:jj+3] = -block
+ H[jj:jj+3, ii:ii+3] = -block
+
+ # Diagonal: accumulate
+ H[ii:ii+3, ii:ii+3] += block
+ H[jj:jj+3, jj:jj+3] += block
+
+ # Eigendecompose β first 6 modes are trivial (3 translation + 3 rotation)
+ n_total = min(6 + n_modes, 3 * N)
+ eigenvalues, eigvecs = eigh(H, subset_by_index=[0, n_total - 1])
+
+ # Skip the 6 trivial zero-frequency modes
+ start = min(6, len(eigenvalues) - 1)
+ n_available = len(eigenvalues) - start
+ n_return = min(n_modes, n_available)
+
+ evals = eigenvalues[start:start + n_return]
+ evecs = eigvecs[:, start:start + n_return] # [3N, n_return]
+
+ # Reshape eigenvectors to [n_modes, N, 3]
+ mode_vectors = np.zeros((n_return, N, 3))
+ for k in range(n_return):
+ mode_vectors[k] = evecs[:, k].reshape(N, 3)
+
+ # Pad if fewer modes available than requested
+ if n_return < n_modes:
+ pad_evals = np.zeros(n_modes)
+ pad_evals[:n_return] = evals
+ pad_modes = np.zeros((n_modes, N, 3))
+ pad_modes[:n_return] = mode_vectors
+ return pad_evals, pad_modes
+
+ return evals, mode_vectors
+
+
+def _kabsch_align(mobile_ca, ref_ca):
+ """Kabsch alignment of mobile onto ref (CA atoms only)."""
+ t_mobile = mobile_ca.mean(axis=0)
+ t_ref = ref_ca.mean(axis=0)
+
+ m = mobile_ca - t_mobile
+ r = ref_ca - t_ref
+
+ H = m.T @ r
+ U, S, Vt = np.linalg.svd(H)
+ d = np.linalg.det(Vt.T @ U.T)
+ sign = np.array([1.0, 1.0, np.sign(d)])
+ R = Vt.T @ np.diag(sign) @ U.T
+
+ return R, t_mobile, t_ref
+
+
+def _reconstruct_oxygen(coords):
+ """Reconstruct O atom from N, CA, C with ideal C=O geometry."""
+ C_pos = coords[:, 2, :]
+ CA_pos = coords[:, 1, :]
+ C_CA = C_pos - CA_pos
+ C_CA_norm = np.linalg.norm(C_CA, axis=-1, keepdims=True)
+ C_CA_norm = np.maximum(C_CA_norm, 1e-8)
+ O_pos = C_pos + (C_CA / C_CA_norm) * 1.24
+ coords[:, 3, :] = O_pos
+ return coords
+
+
+def anm_backbone_path(coords_x0, coords_x1, mask_x0, mask_x1,
+ n_frames=5, n_modes=10, cutoff=15.0):
+ """
+ Interpolate backbone along dominant ANM modes from X0 toward X1.
+
+ Low-frequency modes capture global domain motions (e.g., CaM hinge bending),
+ creating physically informed paths where large-scale motions precede local
+ adjustments.
+
+ Args:
+ coords_x0: [N0, 4, 3] backbone coords (N, CA, C, O) for apo state
+ coords_x1: [N1, 4, 3] backbone coords for holo state
+ mask_x0: [N0] bool
+ mask_x1: [N1] bool
+ n_frames: number of intermediate frames (excluding endpoints)
+ n_modes: number of ANM modes to use for projection
+ cutoff: ANM spring cutoff in Angstroms
+
+ Returns:
+ path_frames: list of (coords_tau, mask_tau, tau) tuples
+ Same interface as interpolate_backbone_path
+ """
+ n_common = min(len(coords_x0), len(coords_x1))
+ c0 = coords_x0[:n_common].copy()
+ c1 = coords_x1[:n_common].copy()
+ m0 = mask_x0[:n_common]
+ m1 = mask_x1[:n_common]
+
+ common_mask = m0 & m1
+ if common_mask.sum() < 5:
+ return []
+
+ # Kabsch-align X0 onto X1 using valid CA atoms
+ ca0 = c0[common_mask, 1, :]
+ ca1 = c1[common_mask, 1, :]
+ R, t_mobile, t_ref = _kabsch_align(ca0, ca1)
+
+ # Apply alignment to all X0 backbone atoms
+ flat0 = c0.reshape(-1, 3)
+ aligned0 = (flat0 - t_mobile) @ R.T + t_ref
+ c0_aligned = aligned0.reshape(n_common, 4, 3)
+
+ # Compute apoβholo displacement (CA atoms, valid residues only)
+ ca0_aligned = c0_aligned[common_mask, 1, :] # [N_valid, 3]
+ ca1_valid = c1[common_mask, 1, :]
+
+ displacement = ca1_valid - ca0_aligned # [N_valid, 3]
+
+ # Compute ANM modes of the aligned apo structure
+ eigenvalues, mode_vectors = compute_anm_modes(
+ ca0_aligned, cutoff=cutoff, n_modes=n_modes
+ ) # mode_vectors: [n_modes, N_valid, 3]
+
+ # Project displacement onto each mode
+ # d_k = sum_i mode_k[i] . displacement[i]
+ projections = np.zeros(n_modes)
+ for k in range(n_modes):
+ projections[k] = np.sum(mode_vectors[k] * displacement)
+
+ # Reconstruct mode-projected displacement: d_mode = sum_k d_k * mode_k
+ mode_displacement = np.zeros_like(displacement) # [N_valid, 3]
+ for k in range(n_modes):
+ mode_displacement += projections[k] * mode_vectors[k]
+
+ # Residual displacement not captured by modes
+ residual = displacement - mode_displacement
+
+ # Generate intermediate frames
+ taus = np.linspace(0, 1, n_frames + 2)[1:-1]
+ path_frames = []
+
+ for tau in taus:
+ # Apply mode-projected + residual displacement at each tau
+ # Mode component applies smoothly; residual is linear
+ ca_interp = ca0_aligned + tau * mode_displacement + tau * residual
+
+ # Build full backbone by interpolating all 4 atom types
+ coords_tau = (1.0 - tau) * c0_aligned + tau * c1
+ # Override CA positions with ANM-interpolated values
+ coords_tau[common_mask, 1, :] = ca_interp
+
+ # Adjust N, C positions relative to CA shift
+ # The N/CA/C triangle is preserved by blending the ANM CA shift
+ # with the linear interpolation of N and C
+ ca_shift = ca_interp - ((1.0 - tau) * ca0_aligned + tau * ca1_valid)
+ coords_tau[common_mask, 0, :] += ca_shift # N atoms
+ coords_tau[common_mask, 2, :] += ca_shift # C atoms
+
+ # Reconstruct O from N, CA, C
+ coords_tau = _reconstruct_oxygen(coords_tau)
+
+ path_frames.append((
+ coords_tau.astype(np.float32),
+ common_mask.copy(),
+ float(tau),
+ ))
+
+ return path_frames
diff --git a/code/utils/path_utils.py b/code/utils/path_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..a51bb34dc8e80e14d11e812b1affc96d7a5726b3
--- /dev/null
+++ b/code/utils/path_utils.py
@@ -0,0 +1,448 @@
+"""
+Transition-path interpolation utilities for conformational induction.
+
+Provides:
+ - Kabsch-aligned backbone interpolation between two conformational states
+ - Gaussian SchrΓΆdinger Bridge (DSB) stochastic interpolation
+ - Precomputed frame loading (for AlphaFlow / AFsample2)
+ - Unified dispatcher: generate_path_frames()
+ - Per-residue displacement computation (for allosteric hinge weighting)
+ - Monotonically increasing path weight generation
+
+Used by the path-aware training, guidance, and refinement modules.
+"""
+
+import os
+import logging
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+def _kabsch_align(mobile_ca, ref_ca):
+ """
+ Kabsch alignment of mobile onto ref (CA atoms only).
+
+ Args:
+ mobile_ca: [N, 3] array
+ ref_ca: [N, 3] array
+
+ Returns:
+ R: [3, 3] rotation matrix
+ t_mobile: [3] mobile centroid
+ t_ref: [3] ref centroid
+ Such that: aligned = (mobile - t_mobile) @ R.T + t_ref
+ """
+ t_mobile = mobile_ca.mean(axis=0)
+ t_ref = ref_ca.mean(axis=0)
+
+ m = mobile_ca - t_mobile
+ r = ref_ca - t_ref
+
+ H = m.T @ r
+ U, S, Vt = np.linalg.svd(H)
+ d = np.linalg.det(Vt.T @ U.T)
+ sign = np.array([1.0, 1.0, np.sign(d)])
+ R = Vt.T @ np.diag(sign) @ U.T
+
+ return R, t_mobile, t_ref
+
+
+def interpolate_backbone_path(coords_x0, coords_x1, mask_x0, mask_x1, n_frames=5):
+ """
+ Generate intermediate backbone conformations along the X0 -> X1 path.
+
+ 1. Find common valid residues between X0 and X1
+ 2. Kabsch-align X0 onto X1 using CA atoms
+ 3. Linearly interpolate backbone coords at n_frames equally-spaced tau values
+ 4. Reconstruct O from N/CA/C with ideal geometry
+
+ Args:
+ coords_x0: [N0, 4, 3] backbone coords (N, CA, C, O) for state 0
+ coords_x1: [N1, 4, 3] backbone coords for state 1
+ mask_x0: [N0] bool
+ mask_x1: [N1] bool
+ n_frames: number of intermediate frames (excluding endpoints)
+
+ Returns:
+ path_frames: list of (coords_tau, mask_tau, tau) tuples
+ coords_tau: [N_common, 4, 3] interpolated backbone coords
+ mask_tau: [N_common] bool
+ tau: float in (0, 1) exclusive
+ """
+ # Use common length
+ n_common = min(len(coords_x0), len(coords_x1))
+ c0 = coords_x0[:n_common].copy()
+ c1 = coords_x1[:n_common].copy()
+ m0 = mask_x0[:n_common]
+ m1 = mask_x1[:n_common]
+
+ # Valid in both states
+ common_mask = m0 & m1
+ if common_mask.sum() < 5:
+ return []
+
+ # Kabsch-align X0 onto X1 using valid CA atoms
+ ca0 = c0[common_mask, 1, :] # CA atoms
+ ca1 = c1[common_mask, 1, :]
+
+ R, t_mobile, t_ref = _kabsch_align(ca0, ca1)
+
+ # Apply alignment to all X0 backbone atoms
+ n_res = n_common
+ flat0 = c0.reshape(-1, 3)
+ aligned0 = (flat0 - t_mobile) @ R.T + t_ref
+ c0_aligned = aligned0.reshape(n_res, 4, 3)
+
+ # Generate intermediate frames
+ taus = np.linspace(0, 1, n_frames + 2)[1:-1] # exclude endpoints
+ path_frames = []
+
+ for tau in taus:
+ # Linear interpolation: X_tau = (1 - tau) * X0_aligned + tau * X1
+ coords_tau = (1.0 - tau) * c0_aligned + tau * c1
+
+ # Reconstruct O from N, CA, C with ideal C=O bond geometry
+ C_pos = coords_tau[:, 2, :] # C atoms
+ CA_pos = coords_tau[:, 1, :] # CA atoms
+ C_CA = C_pos - CA_pos
+ C_CA_norm = np.linalg.norm(C_CA, axis=-1, keepdims=True)
+ C_CA_norm = np.maximum(C_CA_norm, 1e-8)
+ O_pos = C_pos + (C_CA / C_CA_norm) * 1.24 # ideal C=O bond length
+ coords_tau[:, 3, :] = O_pos
+
+ path_frames.append((
+ coords_tau.astype(np.float32),
+ common_mask.copy(),
+ float(tau),
+ ))
+
+ return path_frames
+
+
+def compute_residue_displacements(coords_x0, coords_x1, mask_x0, mask_x1):
+ """
+ Per-residue CA displacement between X0 and X1 after Kabsch alignment.
+
+ Args:
+ coords_x0: [N0, 4, 3] backbone coords for state 0
+ coords_x1: [N1, 4, 3] backbone coords for state 1
+ mask_x0: [N0] bool
+ mask_x1: [N1] bool
+
+ Returns:
+ displacements: [N_common] array of per-residue CA RMSD
+ common_mask: [N_common] bool β which residues are valid
+ """
+ n_common = min(len(coords_x0), len(coords_x1))
+ c0 = coords_x0[:n_common]
+ c1 = coords_x1[:n_common]
+ m0 = mask_x0[:n_common]
+ m1 = mask_x1[:n_common]
+ common_mask = m0 & m1
+
+ if common_mask.sum() < 5:
+ return np.zeros(n_common), common_mask
+
+ ca0 = c0[common_mask, 1, :]
+ ca1 = c1[common_mask, 1, :]
+
+ R, t_mobile, t_ref = _kabsch_align(ca0, ca1)
+
+ # Align all CA of X0
+ all_ca0 = c0[:, 1, :]
+ aligned_ca0 = (all_ca0 - t_mobile) @ R.T + t_ref
+
+ # Per-residue displacement
+ all_ca1 = c1[:, 1, :]
+ displacements = np.linalg.norm(aligned_ca0 - all_ca1, axis=-1)
+
+ # Zero out invalid residues
+ displacements[~common_mask] = 0.0
+
+ return displacements.astype(np.float32), common_mask
+
+
+def generate_path_weights(n_frames, mode='linear'):
+ """
+ Generate monotonically increasing weights for path frames.
+
+ The weights increase toward tau=1 (the goal state), so that
+ intermediate conformations closer to X1 are weighted more heavily.
+
+ Args:
+ n_frames: number of intermediate frames
+ mode: weight schedule
+ 'linear': w_tau = tau
+ 'quadratic': w_tau = tau^2
+ 'exponential': w_tau = (exp(tau) - 1) / (e - 1)
+ 'uniform': w_tau = 1/n_frames (equal weighting)
+
+ Returns:
+ weights: [n_frames] numpy array, normalized to sum to 1
+ """
+ if n_frames == 0:
+ return np.array([], dtype=np.float32)
+
+ taus = np.linspace(0, 1, n_frames + 2)[1:-1] # same as interpolation
+
+ if mode == 'linear':
+ weights = taus.copy()
+ elif mode == 'quadratic':
+ weights = taus ** 2
+ elif mode == 'exponential':
+ weights = (np.exp(taus) - 1.0) / (np.e - 1.0)
+ elif mode == 'uniform':
+ weights = np.ones(n_frames, dtype=np.float32)
+ else:
+ raise ValueError(f"Unknown weight mode: {mode}")
+
+ # Normalize to sum to 1
+ total = weights.sum()
+ if total > 0:
+ weights = weights / total
+
+ return weights.astype(np.float32)
+
+
+# ---------------------------------------------------------------------------
+# Gaussian SchrΓΆdinger Bridge (AlignDSB) interpolation
+# ---------------------------------------------------------------------------
+
+def dsb_backbone_path(coords_x0, coords_x1, mask_x0, mask_x1,
+ n_frames=5, sigma=0.5, n_samples=20, seed=42):
+ """
+ Gaussian SchrΓΆdinger Bridge with t*(1-t) variance schedule.
+
+ Analytic formula (no neural network):
+ X_t = (1-t) * X0_aligned + t * X1 + sqrt(t * (1-t)) * sigma * Z
+
+ Variance peaks at t=0.5 (maximum uncertainty mid-transition) and vanishes
+ at endpoints. sigma controls noise amplitude in Angstroms.
+
+ For each tau, samples n_samples noisy interpolations and selects the
+ median (by RMSD to the mean) for robustness.
+
+ Args:
+ coords_x0: [N0, 4, 3] backbone coords for state 0
+ coords_x1: [N1, 4, 3] backbone coords for state 1
+ mask_x0: [N0] bool
+ mask_x1: [N1] bool
+ n_frames: number of intermediate frames
+ sigma: noise amplitude (Angstroms)
+ n_samples: number of samples per frame for median selection
+ seed: random seed
+
+ Returns:
+ path_frames: list of (coords_tau, mask_tau, tau) tuples
+ """
+ rng = np.random.RandomState(seed)
+
+ n_common = min(len(coords_x0), len(coords_x1))
+ c0 = coords_x0[:n_common].copy()
+ c1 = coords_x1[:n_common].copy()
+ m0 = mask_x0[:n_common]
+ m1 = mask_x1[:n_common]
+
+ common_mask = m0 & m1
+ if common_mask.sum() < 5:
+ return []
+
+ # Kabsch-align X0 onto X1
+ ca0 = c0[common_mask, 1, :]
+ ca1 = c1[common_mask, 1, :]
+ R, t_mobile, t_ref = _kabsch_align(ca0, ca1)
+
+ flat0 = c0.reshape(-1, 3)
+ aligned0 = (flat0 - t_mobile) @ R.T + t_ref
+ c0_aligned = aligned0.reshape(n_common, 4, 3)
+
+ taus = np.linspace(0, 1, n_frames + 2)[1:-1]
+ path_frames = []
+
+ for tau in taus:
+ noise_scale = np.sqrt(tau * (1.0 - tau)) * sigma
+
+ # Generate n_samples noisy interpolations
+ samples = []
+ for _ in range(n_samples):
+ Z = rng.randn(n_common, 4, 3).astype(np.float64)
+ X_t = (1.0 - tau) * c0_aligned + tau * c1 + noise_scale * Z
+ samples.append(X_t)
+
+ samples = np.array(samples) # [n_samples, N, 4, 3]
+ mean_sample = samples.mean(axis=0) # [N, 4, 3]
+
+ # Select median sample by RMSD to mean (CA atoms)
+ rmsds = []
+ for s in samples:
+ diff = s[common_mask, 1, :] - mean_sample[common_mask, 1, :]
+ rmsd = np.sqrt((diff ** 2).sum() / common_mask.sum())
+ rmsds.append(rmsd)
+ median_idx = np.argsort(rmsds)[len(rmsds) // 2]
+ coords_tau = samples[median_idx]
+
+ # Reconstruct O from N, CA, C
+ C_pos = coords_tau[:, 2, :]
+ CA_pos = coords_tau[:, 1, :]
+ C_CA = C_pos - CA_pos
+ C_CA_norm = np.linalg.norm(C_CA, axis=-1, keepdims=True)
+ C_CA_norm = np.maximum(C_CA_norm, 1e-8)
+ coords_tau[:, 3, :] = C_pos + (C_CA / C_CA_norm) * 1.24
+
+ path_frames.append((
+ coords_tau.astype(np.float32),
+ common_mask.copy(),
+ float(tau),
+ ))
+
+ return path_frames
+
+
+# ---------------------------------------------------------------------------
+# Precomputed frame loading (for AlphaFlow / AFsample2)
+# ---------------------------------------------------------------------------
+
+def load_precomputed_frames(target, method, precomputed_dir,
+ coords_x0, coords_x1, mask_x0, mask_x1,
+ n_frames=5):
+ """
+ Load pre-generated frames from .npz and Kabsch-align to this complex's
+ receptor coordinate frame.
+
+ Expected file: {precomputed_dir}/{target}/{method}/frames.npz
+ with keys: 'frames' [n_frames, N_ref, 4, 3], 'taus' [n_frames],
+ 'mask' [N_ref] bool
+
+ Args:
+ target: target name (e.g. 'cam')
+ method: method name ('alphaflow' or 'afsample2')
+ precomputed_dir: root directory for precomputed frames
+ coords_x0, coords_x1: apo/holo backbone coords for alignment
+ mask_x0, mask_x1: residue masks
+ n_frames: number of frames to return
+
+ Returns:
+ path_frames: list of (coords_tau, mask_tau, tau) tuples
+ """
+ npz_path = os.path.join(precomputed_dir, target, method, 'frames.npz')
+ if not os.path.exists(npz_path):
+ logger.warning(f"Precomputed frames not found: {npz_path}, "
+ f"falling back to linear interpolation")
+ return interpolate_backbone_path(coords_x0, coords_x1,
+ mask_x0, mask_x1, n_frames)
+
+ data = np.load(npz_path)
+ pre_frames = data['frames'] # [K, N_ref, 4, 3]
+ pre_taus = data['taus'] # [K]
+ pre_mask = data['mask'] # [N_ref]
+
+ n_common = min(len(coords_x0), len(coords_x1), len(pre_mask))
+ m0 = mask_x0[:n_common]
+ m1 = mask_x1[:n_common]
+ pm = pre_mask[:n_common]
+ common_mask = m0 & m1 & pm
+
+ if common_mask.sum() < 5:
+ logger.warning(f"Too few common residues for {target}/{method}, "
+ f"falling back to linear")
+ return interpolate_backbone_path(coords_x0, coords_x1,
+ mask_x0, mask_x1, n_frames)
+
+ # Align precomputed frames to the holo receptor (X1) coordinate frame
+ # The precomputed frames were generated from the reference apo sequence
+ # and may be in a different coordinate frame
+ ref_ca = coords_x1[:n_common][common_mask, 1, :] # holo CA as reference
+
+ path_frames = []
+ K = min(len(pre_frames), n_frames)
+
+ # Select n_frames evenly spaced from available frames
+ if len(pre_frames) > n_frames:
+ indices = np.linspace(0, len(pre_frames) - 1, n_frames).astype(int)
+ else:
+ indices = np.arange(K)
+
+ for idx in indices:
+ frame = pre_frames[idx, :n_common].copy() # [N_common, 4, 3]
+ tau = float(pre_taus[idx])
+
+ # Kabsch-align frame CA to holo CA
+ frame_ca = frame[common_mask, 1, :]
+ R, t_frame, t_ref = _kabsch_align(frame_ca, ref_ca)
+
+ flat_frame = frame.reshape(-1, 3)
+ aligned = (flat_frame - t_frame) @ R.T + t_ref
+ frame_aligned = aligned.reshape(n_common, 4, 3)
+
+ # Reconstruct O
+ C_pos = frame_aligned[:, 2, :]
+ CA_pos = frame_aligned[:, 1, :]
+ C_CA = C_pos - CA_pos
+ C_CA_norm = np.linalg.norm(C_CA, axis=-1, keepdims=True)
+ C_CA_norm = np.maximum(C_CA_norm, 1e-8)
+ frame_aligned[:, 3, :] = C_pos + (C_CA / C_CA_norm) * 1.24
+
+ path_frames.append((
+ frame_aligned.astype(np.float32),
+ common_mask.copy(),
+ tau,
+ ))
+
+ return path_frames
+
+
+# ---------------------------------------------------------------------------
+# Unified dispatcher
+# ---------------------------------------------------------------------------
+
+def generate_path_frames(coords_x0, coords_x1, mask_x0, mask_x1,
+ method='linear', n_frames=5,
+ precomputed_dir=None, target=None, **kwargs):
+ """
+ Dispatch to method-specific frame generation.
+
+ Args:
+ coords_x0, coords_x1: [N, 4, 3] backbone coords for apo/holo
+ mask_x0, mask_x1: [N] bool masks
+ method: one of 'linear', 'alphaflow', 'afsample2', 'dsb', 'anm'
+ n_frames: number of intermediate frames
+ precomputed_dir: directory for precomputed frames (alphaflow/afsample2)
+ target: target name (needed for precomputed methods)
+ **kwargs: method-specific parameters (sigma, n_modes, etc.)
+
+ Returns:
+ path_frames: list of (coords_tau, mask_tau, tau) tuples
+ """
+ if method == 'linear':
+ return interpolate_backbone_path(
+ coords_x0, coords_x1, mask_x0, mask_x1, n_frames)
+
+ elif method in ('alphaflow', 'afsample2'):
+ if precomputed_dir is None:
+ raise ValueError(f"precomputed_dir required for method '{method}'")
+ if target is None:
+ raise ValueError(f"target name required for method '{method}'")
+ return load_precomputed_frames(
+ target, method, precomputed_dir,
+ coords_x0, coords_x1, mask_x0, mask_x1, n_frames)
+
+ elif method == 'dsb':
+ return dsb_backbone_path(
+ coords_x0, coords_x1, mask_x0, mask_x1,
+ n_frames=n_frames,
+ sigma=kwargs.get('sigma', 0.5),
+ n_samples=kwargs.get('n_samples', 20),
+ seed=kwargs.get('seed', 42))
+
+ elif method == 'anm':
+ from utils.anm import anm_backbone_path
+ return anm_backbone_path(
+ coords_x0, coords_x1, mask_x0, mask_x1,
+ n_frames=n_frames,
+ n_modes=kwargs.get('n_modes', 10),
+ cutoff=kwargs.get('cutoff', 15.0))
+
+ else:
+ raise ValueError(f"Unknown path method: '{method}'. "
+ f"Choose from: linear, alphaflow, afsample2, dsb, anm")
diff --git a/code/utils/pdb_utils.py b/code/utils/pdb_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..986fc171f23d58f56091a04122a8801c77618f06
--- /dev/null
+++ b/code/utils/pdb_utils.py
@@ -0,0 +1,472 @@
+"""
+PDB parsing utilities for Allo-Designer.
+Extracts backbone geometry, computes local frames, and identifies interface residues.
+"""
+
+import numpy as np
+from Bio import PDB
+from Bio.PDB import PDBParser, MMCIFParser, PDBIO
+from Bio.PDB.Polypeptide import is_aa
+import warnings
+warnings.filterwarnings("ignore", category=PDB.PDBExceptions.PDBConstructionWarning)
+
+AA3_TO_IDX = {
+ 'ALA': 0, 'ARG': 1, 'ASN': 2, 'ASP': 3, 'CYS': 4,
+ 'GLN': 5, 'GLU': 6, 'GLY': 7, 'HIS': 8, 'ILE': 9,
+ 'LEU': 10, 'LYS': 11, 'MET': 12, 'PHE': 13, 'PRO': 14,
+ 'SER': 15, 'THR': 16, 'TRP': 17, 'TYR': 18, 'VAL': 19,
+ 'UNK': 20,
+}
+NUM_AA = 21 # 20 standard + UNK
+
+
+def load_structure(pdb_path: str, model_id: int = 0):
+ """Load a PDB/CIF file and return the first model."""
+ if pdb_path.endswith('.cif') or pdb_path.endswith('.mmcif'):
+ parser = MMCIFParser(QUIET=True)
+ else:
+ parser = PDBParser(QUIET=True)
+ struct = parser.get_structure("protein", pdb_path)
+ return list(struct.get_models())[model_id]
+
+
+def get_residues(chain, only_standard: bool = True):
+ """Return a list of standard amino acid residues from a chain."""
+ residues = []
+ for res in chain.get_residues():
+ if only_standard and not is_aa(res, standard=True):
+ continue
+ if res.get_id()[0] != ' ': # skip HETATM
+ continue
+ residues.append(res)
+ return residues
+
+
+def get_backbone_coords(residues):
+ """
+ Extract backbone atom coordinates (N, CA, C, O) for each residue.
+ Returns: coords [N_res, 4, 3], mask [N_res] (True = all backbone atoms present)
+ """
+ N = len(residues)
+ coords = np.zeros((N, 4, 3), dtype=np.float32)
+ mask = np.zeros(N, dtype=bool)
+
+ for i, res in enumerate(residues):
+ try:
+ coords[i, 0] = res['N'].get_vector().get_array()
+ coords[i, 1] = res['CA'].get_vector().get_array()
+ coords[i, 2] = res['C'].get_vector().get_array()
+ if 'O' in res:
+ coords[i, 3] = res['O'].get_vector().get_array()
+ else:
+ # Estimate O position if missing
+ coords[i, 3] = coords[i, 2]
+ mask[i] = True
+ except KeyError:
+ pass
+ return coords, mask
+
+
+def get_aa_indices(residues):
+ """Return integer amino acid indices for each residue."""
+ return np.array([
+ AA3_TO_IDX.get(res.get_resname(), AA3_TO_IDX['UNK'])
+ for res in residues
+ ], dtype=np.int64)
+
+
+def compute_backbone_frames(coords, mask):
+ """
+ Compute SE(3)-equivariant backbone frames from N, CA, C atoms.
+ Frame: z-axis = CA->C, y-axis = component of CA->N perpendicular to z, x-axis = y x z.
+
+ Returns:
+ origins: [N, 3] = CA positions
+ rotations: [N, 3, 3] = rotation matrices (columns are x, y, z axes)
+ """
+ N_res = coords.shape[0]
+ origins = coords[:, 1, :] # CA positions [N, 3]
+ rotations = np.zeros((N_res, 3, 3), dtype=np.float32)
+
+ for i in range(N_res):
+ if not mask[i]:
+ rotations[i] = np.eye(3)
+ continue
+ ca = coords[i, 1]
+ n = coords[i, 0]
+ c = coords[i, 2]
+
+ # z-axis: CA -> C
+ z = c - ca
+ z_norm = np.linalg.norm(z)
+ if z_norm < 1e-6:
+ rotations[i] = np.eye(3)
+ continue
+ z = z / z_norm
+
+ # y-axis: CA -> N, orthogonalized
+ y = n - ca
+ y = y - np.dot(y, z) * z
+ y_norm = np.linalg.norm(y)
+ if y_norm < 1e-6:
+ rotations[i] = np.eye(3)
+ continue
+ y = y / y_norm
+
+ # x-axis: y cross z
+ x = np.cross(y, z)
+
+ rotations[i] = np.stack([x, y, z], axis=-1) # columns are axes
+
+ return origins, rotations
+
+
+def compute_torsion_angles(coords, mask):
+ """
+ Compute backbone torsion angles (phi, psi, omega) for each residue.
+ Returns sin/cos of each angle. [N, 6]
+ """
+ N = len(coords)
+ angles = np.zeros((N, 6), dtype=np.float32)
+
+ def dihedral(p0, p1, p2, p3):
+ """Praxelis dihedral angle computation."""
+ b1 = p1 - p0
+ b2 = p2 - p1
+ b3 = p3 - p2
+ n1 = np.cross(b1, b2)
+ n2 = np.cross(b2, b3)
+ n1_norm = np.linalg.norm(n1)
+ n2_norm = np.linalg.norm(n2)
+ if n1_norm < 1e-6 or n2_norm < 1e-6:
+ return 0.0
+ n1 = n1 / n1_norm
+ n2 = n2 / n2_norm
+ m1 = np.cross(n1, b2 / (np.linalg.norm(b2) + 1e-8))
+ cos_a = np.clip(np.dot(n1, n2), -1, 1)
+ sin_a = np.dot(m1, n2)
+ return np.arctan2(sin_a, cos_a)
+
+ for i in range(N):
+ if not mask[i]:
+ continue
+ ca_i = coords[i, 1]
+ n_i = coords[i, 0]
+ c_i = coords[i, 2]
+
+ # Phi: C_{i-1} - N_i - CA_i - C_i
+ if i > 0 and mask[i - 1]:
+ c_prev = coords[i - 1, 2]
+ phi = dihedral(c_prev, n_i, ca_i, c_i)
+ angles[i, 0] = np.sin(phi)
+ angles[i, 1] = np.cos(phi)
+
+ # Psi: N_i - CA_i - C_i - N_{i+1}
+ if i < N - 1 and mask[i + 1]:
+ n_next = coords[i + 1, 0]
+ psi = dihedral(n_i, ca_i, c_i, n_next)
+ angles[i, 2] = np.sin(psi)
+ angles[i, 3] = np.cos(psi)
+
+ # Omega: CA_{i-1} - C_{i-1} - N_i - CA_i
+ if i > 0 and mask[i - 1]:
+ ca_prev = coords[i - 1, 1]
+ c_prev = coords[i - 1, 2]
+ omega = dihedral(ca_prev, c_prev, n_i, ca_i)
+ angles[i, 4] = np.sin(omega)
+ angles[i, 5] = np.cos(omega)
+
+ return angles
+
+
+def get_interface_residues(rec_coords, binder_coords, rec_mask, binder_mask, cutoff: float = 8.0):
+ """
+ Find interface residues: receptor residues within cutoff of any binder CΞ±, and vice versa.
+ Uses CA-CA distances.
+
+ Returns:
+ rec_interface: bool array [N_rec]
+ binder_interface: bool array [N_binder]
+ """
+ rec_ca = rec_coords[:, 1, :] # [N_rec, 3]
+ binder_ca = binder_coords[:, 1, :] # [N_binder, 3]
+
+ # Pairwise CA-CA distances [N_rec, N_binder]
+ diff = rec_ca[:, None, :] - binder_ca[None, :, :] # [N_rec, N_binder, 3]
+ dist = np.sqrt((diff ** 2).sum(axis=-1)) # [N_rec, N_binder]
+
+ # Mask out residues without coordinates
+ dist[~rec_mask, :] = np.inf
+ dist[:, ~binder_mask] = np.inf
+
+ rec_interface = (dist < cutoff).any(axis=1)
+ binder_interface = (dist < cutoff).any(axis=0)
+
+ return rec_interface, binder_interface
+
+
+def align_structures(mobile_ca, ref_ca, mobile_coords=None):
+ """
+ Kabsch alignment: align mobile to ref using CA positions.
+ Returns aligned CA coords and optionally full backbone coords.
+ """
+ assert mobile_ca.shape == ref_ca.shape, "Must have same number of residues"
+
+ # Center
+ mobile_center = mobile_ca.mean(axis=0)
+ ref_center = ref_ca.mean(axis=0)
+ m = mobile_ca - mobile_center
+ r = ref_ca - ref_center
+
+ # SVD
+ H = m.T @ r
+ U, S, Vt = np.linalg.svd(H)
+ d = np.sign(np.linalg.det(Vt.T @ U.T))
+ D = np.diag([1, 1, d])
+ R = Vt.T @ D @ U.T # rotation matrix
+
+ mobile_ca_aligned = (m @ R.T) + ref_center
+
+ if mobile_coords is not None:
+ # Apply same rotation to full backbone
+ N_res, N_atoms, _ = mobile_coords.shape
+ flat = mobile_coords.reshape(-1, 3) - mobile_center
+ aligned_flat = (flat @ R.T) + ref_center
+ mobile_coords_aligned = aligned_flat.reshape(N_res, N_atoms, 3)
+ return mobile_ca_aligned, R, mobile_coords_aligned
+
+ return mobile_ca_aligned, R
+
+
+def compute_ca_rmsd(coords1, coords2, mask=None):
+ """Compute CA-RMSD between two sets of backbone coordinates."""
+ ca1 = coords1[:, 1, :]
+ ca2 = coords2[:, 1, :]
+ if mask is not None:
+ ca1 = ca1[mask]
+ ca2 = ca2[mask]
+ diff = ca1 - ca2
+ return np.sqrt((diff ** 2).sum(axis=-1).mean())
+
+
+def compute_fraction_native_contacts(
+ native_rec_ca, native_binder_ca,
+ model_rec_ca=None, model_binder_ca=None,
+ cutoff=8.0,
+ # Legacy 2-arg signature support
+ mask=None, delta=1.0,
+):
+ """
+ Compute fraction of native inter-chain contacts (fNAT).
+
+ fNAT = |recovered inter-chain contacts| / |native inter-chain contacts|
+
+ A native contact is a (receptor_i, binder_j) pair with CA-CA distance
+ < cutoff in the native complex. A contact is "recovered" if the same
+ pair is < cutoff in the model complex.
+
+ Args:
+ native_rec_ca: [N_rec, 3] receptor CA coords in native complex
+ native_binder_ca: [N_bind, 3] binder CA coords in native complex
+ model_rec_ca: [N_rec, 3] receptor CA in model (default: same as native)
+ model_binder_ca: [N_bind, 3] binder CA in model (default: same as native)
+ cutoff: contact distance threshold in Angstroms (default 8.0 for CA-CA)
+
+ Returns:
+ fNAT in [0, 1]. Returns 0.0 if no native contacts exist.
+ """
+ if model_rec_ca is None:
+ model_rec_ca = native_rec_ca
+ if model_binder_ca is None:
+ model_binder_ca = native_binder_ca
+
+ # Inter-chain distance matrices [N_rec, N_bind]
+ native_dist = np.sqrt(
+ ((native_rec_ca[:, None, :] - native_binder_ca[None, :, :]) ** 2).sum(-1)
+ )
+ model_dist = np.sqrt(
+ ((model_rec_ca[:, None, :] - model_binder_ca[None, :, :]) ** 2).sum(-1)
+ )
+
+ native_contacts = native_dist < cutoff
+ recovered = native_contacts & (model_dist < cutoff)
+
+ n_native = native_contacts.sum()
+ if n_native == 0:
+ return 0.0
+ return float(recovered.sum()) / float(n_native)
+
+
+def rbf_encode(distances, d_min=0.0, d_max=20.0, n_bins=16):
+ """
+ RBF encoding of distances using Gaussian basis functions.
+ Returns: [*distances.shape, n_bins]
+ """
+ centers = np.linspace(d_min, d_max, n_bins)
+ sigma = (d_max - d_min) / (n_bins - 1)
+ encoded = np.exp(-((distances[..., None] - centers) ** 2) / (2 * sigma ** 2))
+ return encoded.astype(np.float32)
+
+
+# Candidate sidechain atoms for chi1 (first atom after CB)
+_CHI1_ATOMS = ['CG', 'CG1', 'OG', 'OG1', 'SG']
+# Candidate sidechain atoms for chi2 (second dihedral: CA-CB-XG-XD)
+_CHI2_ATOMS = ['CD', 'CD1', 'SD', 'OD1', 'ND1', 'CE', 'NE', 'OE1']
+
+
+def _dihedral_4pts(p0, p1, p2, p3):
+ """Compute dihedral angle between four 3D points (radians)."""
+ b1 = p1 - p0
+ b2 = p2 - p1
+ b3 = p3 - p2
+ n1 = np.cross(b1, b2)
+ n2 = np.cross(b2, b3)
+ n1_norm = np.linalg.norm(n1)
+ n2_norm = np.linalg.norm(n2)
+ if n1_norm < 1e-6 or n2_norm < 1e-6:
+ return 0.0
+ n1 = n1 / n1_norm
+ n2 = n2 / n2_norm
+ m1 = np.cross(n1, b2 / (np.linalg.norm(b2) + 1e-8))
+ return np.arctan2(np.dot(m1, n2), np.dot(n1, n2))
+
+
+def compute_chi_angles(residues, mask):
+ """
+ Compute chi1 and chi2 sidechain torsion angles for each residue.
+
+ Chi1: N - CA - CB - XG (first sidechain dihedral)
+ Chi2: CA - CB - XG - XD (second sidechain dihedral)
+
+ For residues lacking the atoms (Gly, or missing coordinates), returns zeros.
+
+ Returns:
+ chi_feats: [N, 4] (sin_chi1, cos_chi1, sin_chi2, cos_chi2)
+ """
+ N = len(residues)
+ chi_feats = np.zeros((N, 4), dtype=np.float32)
+
+ for i, res in enumerate(residues):
+ if not mask[i]:
+ continue
+ atoms = {atom.get_name(): atom.get_vector().get_array() for atom in res.get_atoms()
+ if atom.get_name() in ('N', 'CA', 'CB') + tuple(_CHI1_ATOMS) + tuple(_CHI2_ATOMS)}
+
+ n_pos = atoms.get('N')
+ ca_pos = atoms.get('CA')
+ cb_pos = atoms.get('CB')
+
+ if n_pos is None or ca_pos is None or cb_pos is None:
+ continue
+
+ # Chi1: N - CA - CB - XG
+ xg_pos = None
+ for aname in _CHI1_ATOMS:
+ if aname in atoms:
+ xg_pos = atoms[aname]
+ break
+
+ if xg_pos is not None:
+ chi1 = _dihedral_4pts(np.array(n_pos), np.array(ca_pos),
+ np.array(cb_pos), np.array(xg_pos))
+ chi_feats[i, 0] = np.sin(chi1)
+ chi_feats[i, 1] = np.cos(chi1)
+
+ # Chi2: CA - CB - XG - XD
+ xd_pos = None
+ for aname in _CHI2_ATOMS:
+ if aname in atoms:
+ xd_pos = atoms[aname]
+ break
+
+ if xd_pos is not None:
+ chi2 = _dihedral_4pts(np.array(ca_pos), np.array(cb_pos),
+ np.array(xg_pos), np.array(xd_pos))
+ chi_feats[i, 2] = np.sin(chi2)
+ chi_feats[i, 3] = np.cos(chi2)
+
+ return chi_feats
+
+
+def get_cb_positions(residues, coords, mask):
+ """
+ Return CB positions for each residue (CA position for Gly or missing CB).
+
+ Returns:
+ cb_pos: [N, 3]
+ """
+ N = len(residues)
+ cb_pos = coords[:, 1, :].copy() # default to CA
+
+ for i, res in enumerate(residues):
+ if not mask[i]:
+ continue
+ try:
+ cb_pos[i] = res['CB'].get_vector().get_array()
+ except KeyError:
+ pass # Gly or missing CB: keep CA
+
+ return cb_pos.astype(np.float32)
+
+
+# Simplified hydrophobicity groups for contact energy
+_HYDROPHOBIC = {'ALA', 'VAL', 'ILE', 'LEU', 'MET', 'PHE', 'TRP', 'PRO', 'TYR'}
+_POS_CHARGED = {'ARG', 'LYS', 'HIS'}
+_NEG_CHARGED = {'ASP', 'GLU'}
+
+
+def _residue_group(resname):
+ if resname in _HYDROPHOBIC:
+ return 'H'
+ if resname in _POS_CHARGED:
+ return '+'
+ if resname in _NEG_CHARGED:
+ return '-'
+ return 'P' # polar
+
+
+def compute_contact_energy(rec_residues, binder_residues,
+ rec_cb, binder_cb,
+ rec_mask, binder_mask,
+ cutoff: float = 8.0):
+ """
+ Compute a simple CB-CB contact energy as a physics-based ddG proxy.
+
+ Uses a 4-group hydrophobicity potential:
+ HH: -1.0 (hydrophobic-hydrophobic, favorable)
+ +-: -0.5 (opposite charges, favorable)
+ H+/-: +0.3 (hydrophobic-charged, unfavorable)
+ else: 0.0
+
+ Returns a scalar in [0, 1] via sigmoid normalization.
+ """
+ n_rec = len(rec_residues)
+ n_binder = len(binder_residues)
+
+ # CB-CB distance matrix [n_rec, n_binder]
+ diff = rec_cb[:, None, :] - binder_cb[None, :, :] # [n_rec, n_binder, 3]
+ dist = np.sqrt((diff ** 2).sum(axis=-1)) # [n_rec, n_binder]
+
+ # Mask invalid residues
+ dist[~rec_mask, :] = np.inf
+ dist[:, ~binder_mask] = np.inf
+
+ contact_mask = dist < cutoff
+
+ energy = 0.0
+ for i in range(n_rec):
+ for j in range(n_binder):
+ if not contact_mask[i, j]:
+ continue
+ gi = _residue_group(rec_residues[i].get_resname())
+ gj = _residue_group(binder_residues[j].get_resname())
+ if gi == 'H' and gj == 'H':
+ energy -= 1.0
+ elif (gi == '+' and gj == '-') or (gi == '-' and gj == '+'):
+ energy -= 0.5
+ elif (gi == 'H' and gj in ('+', '-')) or (gj == 'H' and gi in ('+', '-')):
+ energy += 0.3
+
+ # Normalize: sigmoid of (energy / 10) shifted so that 0 contacts β score 0.3
+ score = 1.0 / (1.0 + np.exp(-(energy - 5.0) / 5.0))
+ return float(score)
diff --git a/code/utils/sam.py b/code/utils/sam.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0b27513863aca4a7293c948c66be9a24909cc8c
--- /dev/null
+++ b/code/utils/sam.py
@@ -0,0 +1,54 @@
+"""
+Sharpness-Aware Minimization (SAM) optimizer wrapper.
+Seeks parameters in flatter minima for better OOD generalization.
+Reference: Foret et al., "Sharpness-Aware Minimization for Efficiently Improving Generalization" (ICLR 2021)
+"""
+
+import torch
+
+
+class SAM(torch.optim.Optimizer):
+ def __init__(self, params, base_optimizer, rho=0.05, **kwargs):
+ defaults = dict(rho=rho, **kwargs)
+ super().__init__(params, defaults)
+ self.base_optimizer = base_optimizer(self.param_groups, **kwargs)
+
+ @torch.no_grad()
+ def first_step(self):
+ grad_norm = self._grad_norm()
+ for group in self.param_groups:
+ scale = group['rho'] / (grad_norm + 1e-12)
+ for p in group['params']:
+ if p.grad is None:
+ continue
+ e_w = p.grad * scale
+ p.add_(e_w)
+ self.state[p]['e_w'] = e_w
+
+ @torch.no_grad()
+ def second_step(self):
+ for group in self.param_groups:
+ for p in group['params']:
+ if p.grad is None:
+ continue
+ p.sub_(self.state[p]['e_w'])
+ self.base_optimizer.step()
+
+ def _grad_norm(self):
+ shared_device = self.param_groups[0]['params'][0].device
+ norm = torch.norm(
+ torch.stack([
+ p.grad.norm(p=2).to(shared_device)
+ for group in self.param_groups
+ for p in group['params']
+ if p.grad is not None
+ ]),
+ p=2,
+ )
+ return norm
+
+ def step(self, closure=None):
+ raise NotImplementedError("SAM requires manual first_step() and second_step() calls")
+
+ def zero_grad(self):
+ self.base_optimizer.zero_grad()
diff --git a/data/sample/README.md b/data/sample/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b9a90e42e04989dc8047df4008102534b34342f4
--- /dev/null
+++ b/data/sample/README.md
@@ -0,0 +1,49 @@
+# Sample dataset (in-repo)
+
+This directory ships a single pre-built target β **`cam` (Calmodulin)** β so users
+can run a smoke test of the training and evaluation pipeline without first
+downloading the full multi-target dataset (~10 GB on Zenodo) or rebuilding
+from raw PDB files (~30 min per target).
+
+## Contents
+
+```
+sample/
+βββ cam/
+ βββ train.pkl # 84 paired holo/apo complex graphs (~24 MB)
+ βββ val.pkl # 12 validation graphs (~1.3 MB)
+ βββ test.pkl # 96 held-out evaluation graphs (~25 MB)
+```
+
+Each pickle is a list of dicts produced by `code/data/build_dataset.py`.
+Splits follow the family-stratified scheme used in the paper
+(equivalent to `data/processed_familysplit/cam/` train+val and
+`data/processed_familysplit_v5/cam/test.pkl` in the source tree).
+
+## Smoke test (1-epoch end-to-end)
+
+```bash
+# Train both phases for 1 epoch
+python code/scripts/train.py \
+ --target cam \
+ --phase both \
+ --data_dir data/sample \
+ --checkpoint_dir checkpoints_smoke \
+ --epochs 1 \
+ --no_wandb
+
+# Evaluate
+python code/scripts/evaluate.py \
+ --target cam \
+ --checkpoint checkpoints_smoke/best_phase2.pt \
+ --data_dir data/sample \
+ --outdir eval_smoke
+```
+
+Expected runtime: ~1 minute on a single GPU.
+
+## Want more data?
+
+- All 12 paper targets, pre-built: see `data/DOWNLOAD.md` for the Zenodo link.
+- Build from raw PDBs locally: `scripts/build_data.sh paper12`.
+- Per-target PDB lists and chain mappings: `data/target_lists/*.txt` (68 targets).
diff --git a/data/sample/cam/test.pkl b/data/sample/cam/test.pkl
new file mode 100644
index 0000000000000000000000000000000000000000..2a9bb9de5d0cd4e81a98046cd57bb3f27ae97e00
--- /dev/null
+++ b/data/sample/cam/test.pkl
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:38e36d092bbcf4222c762e351fe305e8627f47c78c4acda74170c650ac09e1e8
+size 25608454
diff --git a/data/sample/esm2_embeddings/cam/1IWQ_A.pt b/data/sample/esm2_embeddings/cam/1IWQ_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..3ca6a7da5271b4d9da3adecd9d5195f7e323747d
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1IWQ_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c52e5a467dc0e73ef7139475bdaafd05e6df8872c345e31fb3da1d35497c00a1
+size 712791
diff --git a/data/sample/esm2_embeddings/cam/1IWQ_B.pt b/data/sample/esm2_embeddings/cam/1IWQ_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..9352c1966989b348919012b7f82f23c5abd48309
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1IWQ_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7afc0e91e9095d3945adbf4fcf287fd167ac4fed51d10718db65ee80b89890e
+size 93271
diff --git a/data/sample/esm2_embeddings/cam/1K93_A.pt b/data/sample/esm2_embeddings/cam/1K93_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..9c527d2b428d87f2d4e410b5a154c508e582e641
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1K93_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef76f07da93f60f77ee00fbd719fd81621e50492d245bf62f1a820140e853d61
+size 2484311
diff --git a/data/sample/esm2_embeddings/cam/1K93_B.pt b/data/sample/esm2_embeddings/cam/1K93_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..3e08aa2ca707d63960d96996c835ef9fb1ef39d7
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1K93_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8c9d65d07aaa8b1c219f5458d2a0502739f482b698c5579bbb4ebee681b5aecb
+size 2392151
diff --git a/data/sample/esm2_embeddings/cam/1NWD_A.pt b/data/sample/esm2_embeddings/cam/1NWD_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..314a20d152ff85e73fb263433159d3e00501d7e9
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1NWD_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4138c902d0ca0e40ff0c8b0522f71974ba15d3ec1a11db2bb00f9fe8227339f9
+size 758871
diff --git a/data/sample/esm2_embeddings/cam/1NWD_B.pt b/data/sample/esm2_embeddings/cam/1NWD_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..45f3456eb54bebb85e4f6a454c96732b6a987e88
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1NWD_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ee429a3b3cbaedf0471f02f7d636e4737f98630f655402317dd3438f8c69c30d
+size 144471
diff --git a/data/sample/esm2_embeddings/cam/1SY9_A.pt b/data/sample/esm2_embeddings/cam/1SY9_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..1b2bfc4122aa8a03e0cc687f9a287b0b2e3aaa9d
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1SY9_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8a7ca8538945c4b1e524ef3440c5aa7f73afd5d273034063518a0251e2a59f01
+size 758871
diff --git a/data/sample/esm2_embeddings/cam/1SY9_B.pt b/data/sample/esm2_embeddings/cam/1SY9_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..e35e6bb70bbf79149f07a4e710fbee0f5bc949af
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/1SY9_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b6258dc842103adc893aacee23c195e2b7da3038e4704e0c9166e1e5581ac784
+size 98391
diff --git a/data/sample/esm2_embeddings/cam/2BBM_A.pt b/data/sample/esm2_embeddings/cam/2BBM_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..9f8ed89c9e03f8842bfe6dd81ba907f35557b257
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2BBM_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0630d3cbc267244f3a40ee83155373675f654cfd720aeaf03271c6438cb68b1d
+size 758871
diff --git a/data/sample/esm2_embeddings/cam/2BBM_B.pt b/data/sample/esm2_embeddings/cam/2BBM_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..489b56caf87ff38a185487b702b1840e568b2c58
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2BBM_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:485ff76a5d7ed5459035608a25322601d8fc3d1b73acd49a13d1dcb1fec22ac3
+size 134231
diff --git a/data/sample/esm2_embeddings/cam/2HQW_A.pt b/data/sample/esm2_embeddings/cam/2HQW_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..716f9d80d224ab8c5cc87e89c5039054590d39e8
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2HQW_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e8b37dc4704f3c83e4c1d723da66d1873a8dbf27a6e9313450312b9e847f9f44
+size 707671
diff --git a/data/sample/esm2_embeddings/cam/2HQW_B.pt b/data/sample/esm2_embeddings/cam/2HQW_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..918983a18de9e53d5e645addbfcde889705a6024
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2HQW_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:564220203f56b067cda236c35cd153d3b20dcaf0fb2080fadb664eed8f413607
+size 113751
diff --git a/data/sample/esm2_embeddings/cam/2O5G_A.pt b/data/sample/esm2_embeddings/cam/2O5G_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..80734171c645b342ddd82ce72f47edec541c8d61
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2O5G_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1187a8edd2d25b219c7ca4cc970a6a771fe8605ab2937163dc3ee595fad97bab
+size 753751
diff --git a/data/sample/esm2_embeddings/cam/2O5G_B.pt b/data/sample/esm2_embeddings/cam/2O5G_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..2a90e645bd09e7abdc5c47313ae6e55186023c75
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/2O5G_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:604f44b0738f0c9d0f3e1410187a94d95dd7002cead28e23635ddd33bf419b60
+size 98391
diff --git a/data/sample/esm2_embeddings/cam/3D33_A.pt b/data/sample/esm2_embeddings/cam/3D33_A.pt
new file mode 100644
index 0000000000000000000000000000000000000000..54551f45ae54e8e5fdca3ec5f8b03e8232495ef0
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/3D33_A.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b4c553c3e7dfe746babf92dfcae09dab8ecbcf1490cf475aead72fc5f2d30a43
+size 472151
diff --git a/data/sample/esm2_embeddings/cam/3D33_B.pt b/data/sample/esm2_embeddings/cam/3D33_B.pt
new file mode 100644
index 0000000000000000000000000000000000000000..16d4efd320759ce8ccff6c504ea0735e9b2e8a4a
--- /dev/null
+++ b/data/sample/esm2_embeddings/cam/3D33_B.pt
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:658a464264b5ea8d6e91db92e9c597a2efebb185ceee2669204efac26a7e7fa1
+size 467031
diff --git a/environment.yml b/environment.yml
new file mode 100644
index 0000000000000000000000000000000000000000..78857ff21d2b1cf3d99d01b1fe5edb0b7c2a34a2
--- /dev/null
+++ b/environment.yml
@@ -0,0 +1,29 @@
+name: allogen
+channels:
+ - pytorch
+ - nvidia
+ - conda-forge
+ - bioconda
+ - defaults
+dependencies:
+ - python=3.10
+ - pip
+ # Core scientific stack
+ - numpy>=1.24
+ - scipy>=1.10
+ - pandas>=2.0
+ - scikit-learn>=1.3
+ - pyyaml>=6.0
+ - tqdm>=4.65
+ - matplotlib>=3.7
+ # PyTorch (CUDA 11.8; change pytorch-cuda to match your driver, or drop for CPU-only)
+ - pytorch>=2.0
+ - pytorch-cuda=11.8
+ # Protein structure
+ - biopython>=1.80
+ - mdtraj>=1.9
+ # pip-only extras
+ - pip:
+ - einops>=0.6.0
+ - wandb>=0.12.0
+ - fair-esm>=2.0.0
diff --git a/figures/.gitkeep b/figures/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/figures/README.md b/figures/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d21755b5d673bd54e2515cac5bdd20a092f84fe9
--- /dev/null
+++ b/figures/README.md
@@ -0,0 +1,5 @@
+# Figures
+
+Figures referenced from the top-level `README.md` go here.
+
+Suggested filename convention: `figN_*.png` (e.g. `fig1_pipeline.png`).
diff --git a/figures/allogen_main.png b/figures/allogen_main.png
new file mode 100644
index 0000000000000000000000000000000000000000..adf24a56ea7d9c66f599628310c78653eb43bc31
--- /dev/null
+++ b/figures/allogen_main.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7fa50153cc66b8dd19bd109407ae687f3687b360a8a139221b115afca44f5a95
+size 124228
diff --git a/inference.md b/inference.md
new file mode 100644
index 0000000000000000000000000000000000000000..864d2d05b0e25bf0e2d4e8f6fe9645af32f9367e
--- /dev/null
+++ b/inference.md
@@ -0,0 +1,110 @@
+# AlloGen Inference Guide
+
+This guide covers how to score binder designs and apply guidance with the bundled Q_ΞΈ checkpoint. Training is not part of the public release β only inference and guidance.
+
+> **Env var.** Throughout this doc, `${ALLOGEN_ROOT}` is the path to the cloned repo. Either `cd` into it and use relative paths, or `export ALLOGEN_ROOT=/path/to/AlloGen`.
+
+> **Python.** Use the env from `environment.yml` / `requirements.txt`. All scripts insert `code/` into `sys.path` via a `_CODE_DIR` boot block, so they work from any CWD.
+
+---
+
+## 1. Checkpoint
+
+The Phase 2 weights `checkpoints/Q_theta_phase2.pt` are the **v4-S2 target-swap split** model used in the paper. Phase 1 (`Q_theta_phase1.pt`) is the DockQ regression intermediate.
+
+Pull via Git LFS:
+
+```bash
+git lfs install
+git lfs pull
+```
+
+---
+
+## 2. Score binders
+
+### 2a. Python API
+
+```python
+import sys
+sys.path.insert(0, 'code')
+
+from models.differentiable_features import DifferentiableQTheta
+
+scorer = DifferentiableQTheta(
+ checkpoint='checkpoints/Q_theta_phase2.pt',
+ device='cuda:0',
+)
+scorer.load_receptor(
+ holo_path='holo.pdb', rec_chain='A',
+ apo_path='apo.pdb', apo_chain='A',
+)
+q_holo = scorer.score('design.pdb', binder_chain='B', state='holo')
+q_apo = scorer.score('design.pdb', binder_chain='B', state='apo')
+print(f'S = {q_holo - q_apo:.3f}')
+```
+
+### 2b. CLI on the bundled sample
+
+```bash
+python code/scripts/evaluate.py \
+ --target cam \
+ --checkpoint checkpoints/Q_theta_phase2.pt \
+ --data_dir data/sample/ \
+ --outdir /tmp/cam_inference \
+ --no_wandb
+```
+
+Scores every binder in `data/sample/cam/test.pkl` and writes `tables/eval_cam_test.json` with Spearman Ο, AUC, and selectivity gap.
+
+---
+
+## 3. Guidance methods (PXDesign)
+
+The shipped guidance code wraps **PXDesign** as the prior and uses Q_ΞΈ as the gradient / classifier signal.
+
+| Script | Method |
+|---|---|
+| `code/scripts/pxdesign_guidance/langevin_pxdesign.py` | Post-hoc Langevin refinement |
+| `code/scripts/pxdesign_guidance/smc_pxdesign.py` | Sequential Monte Carlo |
+| `code/scripts/pxdesign_guidance/tds_pxdesign.py` | Twisted Diffusion Sampler |
+| `code/scripts/pxdesign_guidance/guided_pxdesign.py` | Classifier guidance |
+| `code/scripts/pxdesign_guidance/iterative_refinement.py` | Iterative refinement loop |
+| `code/scripts/pxdesign_guidance/qtheta_pxdesign.py` | Q_ΞΈ wrapper used by the above |
+
+Common flags:
+
+- `--checkpoint checkpoints/Q_theta_phase2.pt`
+- `--holo_pdb your_holo.pdb` / `--apo_pdb your_apo.pdb`
+- `--output_dir designs/`
+- `--device cuda:0`
+- `--seed 42`
+
+Method-specific arguments (steps, batch sizes, guidance scales) are in each script's `argparse` block.
+
+To plug Q_ΞΈ into RFdiffusion, Proteina-ComplexA, or any other backbone prior, see `code/scripts/README.md`.
+
+---
+
+## 4. Bundled sample data
+
+`data/sample/cam/test.pkl` β held-out test split for Calmodulin (CaM), small enough to run on a laptop CPU in under a minute. **The only data shipped in the repo.** Score your own targets via the Python API in Β§2a (raw PDBs as input).
+
+---
+
+## 5. Training reproduction
+
+Training data, training scripts, and per-target processed graphs are NOT shipped in this public release. The paper's main result (Phase 2 on the **v4-S2 target-swap** split) is provided as a frozen checkpoint at `checkpoints/Q_theta_phase2.pt`. Retraining requires the full pipeline (separate request).
+
+---
+
+## 6. Citation
+
+```bibtex
+@inproceedings{cao2026allogen,
+ title = {AlloGen: State-Selective Scoring for Allosteric Binder Design},
+ author = {Cao, Hanqun and others},
+ booktitle = {Advances in Neural Information Processing Systems (NeurIPS)},
+ year = {2026}
+}
+```
diff --git a/notebooks/AlloGen_CaM_demo.ipynb b/notebooks/AlloGen_CaM_demo.ipynb
new file mode 100644
index 0000000000000000000000000000000000000000..c9b4204e9212411e9f08cfa2de38617089097e1f
--- /dev/null
+++ b/notebooks/AlloGen_CaM_demo.ipynb
@@ -0,0 +1,436 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# AlloGen \u00d7 PXDesign: CaM Binder Guidance Demo\n",
+ "\n",
+ "**For biology users.** This notebook shows how to:\n",
+ "\n",
+ "1. Load the AlloGen **Q_\u03b8 scorer** \u2014 a graph transformer that predicts whether a binder favors the **active (holo)** or **inactive (apo)** state of a target.\n",
+ "2. Score binder designs and rank them by selectivity `S = Q_\u03b8(holo) \u2212 Q_\u03b8(apo)`.\n",
+ "3. Use Q_\u03b8 as a guidance signal for **PXDesign** to *generate* state-selective CaM binders.\n",
+ "\n",
+ "**Target.** Calmodulin (CaM). Holo = Ca\u00b2\u207a-bound, open conformation. Apo = Ca\u00b2\u207a-free, closed.\n",
+ "\n",
+ "Designs that score high on holo and low on apo are predicted to bind selectively to the active state.\n",
+ "\n",
+ "**Compute.** GPU recommended (T4 free tier is enough). Inference cells run in seconds.\n",
+ "\n",
+ "**Reference.** [`ChatterjeeLab/AlloGen`](https://huggingface.co/ChatterjeeLab/AlloGen) \u00b7 [Paper (NeurIPS 2026)](#citation)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 0. Setup\n",
+ "\n",
+ "Clone the HF repo, install deps, pull LFS-tracked checkpoint and sample data."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Colab setup: install Git LFS, clone the HF repo, install Python deps\n",
+ "!apt-get -qq install -y git-lfs > /dev/null 2>&1\n",
+ "!git lfs install\n",
+ "!rm -rf AlloGen && git clone https://huggingface.co/ChatterjeeLab/AlloGen\n",
+ "%cd AlloGen\n",
+ "!git lfs pull\n",
+ "!pip install -q -r requirements.txt\n",
+ "import sys; sys.path.insert(0, 'code')\n",
+ "print('Setup OK.')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. Load Q_\u03b8\n",
+ "\n",
+ "The checkpoint `checkpoints/Q_theta_phase2.pt` is the v4-S2 target-swap model from the paper.\n",
+ "\n",
+ "Architecture (extracted from the checkpoint config):\n",
+ "\n",
+ "- Dense edge-biased **graph transformer** with SE(3)-invariant features\n",
+ "- 4 layers, 8 attention heads, hidden_dim=128, ~898K params\n",
+ "- **ESM-2 conditioning** (1280-d \u2192 32-d projection)\n",
+ "- Output: `Q_\u03b8(X, Y) \u2208 (0, 1)` interpretable as \"compatibility probability\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "stream",
+ "name": "stdout",
+ "text": [
+ "Loaded Q_theta on cuda\n",
+ "Params: 897,857\n",
+ "Test Spearman \u03c1 (training): 0.454\n"
+ ]
+ }
+ ],
+ "source": [
+ "import torch\n",
+ "from models.scorer import build_model\n",
+ "\n",
+ "device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n",
+ "state = torch.load('checkpoints/Q_theta_phase2.pt', map_location=device)\n",
+ "config = state['config']\n",
+ "model = build_model(config).to(device).eval()\n",
+ "model.load_state_dict(state['model_state'])\n",
+ "n_params = sum(p.numel() for p in model.parameters())\n",
+ "print(f'Loaded Q_theta on {device}')\n",
+ "print(f'Params: {n_params:,}')\n",
+ "print(f'Test Spearman \u03c1 (training): {state[\"test_rho\"]:.3f}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. Score the bundled CaM test set\n",
+ "\n",
+ "`data/sample/cam/test.pkl` contains 96 binder\u2013CaM interface graphs:\n",
+ "\n",
+ "- **8 positive** \u2014 native binders on holo CaM\n",
+ "- **8 negative_apo** \u2014 same binders on apo CaM (should score low)\n",
+ "- **80 decoys** at 10 RMSD bins (1.0 \u2192 8.0 \u00c5)\n",
+ "\n",
+ "Run the canonical evaluation script to compute Spearman \u03c1, AUC, selectivity gap, and best-of-K curves."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "stream",
+ "name": "stdout",
+ "text": [
+ "INFO Loaded model from checkpoints/Q_theta_phase2.pt\n",
+ "INFO Evaluating on data/sample/cam/test.pkl (96 samples)\n",
+ "INFO Best-of-K success rate (label > 0.7):\n",
+ "INFO K= 1: 0.883\n",
+ "INFO K= 2: 1.000\n",
+ "INFO K= 5: 1.000\n",
+ "INFO Saved metrics to /tmp/cam_eval/tables/eval_cam_test.json\n",
+ "\n",
+ "SUMMARY\n",
+ " spearman_all 0.486\n",
+ " selectivity_gap 0.868\n",
+ " auc_pos_vs_neg 1.000\n",
+ " auc_quality 0.771\n",
+ " pos_score (mean\u00b1std) 0.909 \u00b1 0.130\n",
+ " neg_score (mean\u00b1std) 0.042 \u00b1 0.023\n"
+ ]
+ }
+ ],
+ "source": [
+ "!python code/scripts/evaluate.py \\\n",
+ " --target cam \\\n",
+ " --checkpoint checkpoints/Q_theta_phase2.pt \\\n",
+ " --data_dir data/sample \\\n",
+ " --split test \\\n",
+ " --outdir /tmp/cam_eval"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**Read the numbers.** `selectivity_gap = 0.87` means native (holo) binders score on average 0.87 higher than the same binders evaluated on apo CaM. `auc_pos_vs_neg = 1.0` means Q_\u03b8 perfectly separates positives from apo negatives on this set. `spearman_all = 0.49` is the ranking correlation against the DockQ-style label across all 96 samples."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Score distribution"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "display_data",
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABJ4AAANyCAYAAAAjMgGdAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAA73FJREFUeJzs3Xd4FGXb/vFztqRXIKEaeu9VOgiCBRFFQEGaYMHyiP21iz/ba32wPlaKDVEUERULCoggIKCgiIggXaVJCyQhyfz+yJt9EtI2ySSzu/P9HAfHQWZnZq/ZMjBnrvsewzRNUwAAAAAAAIDFXHYXAAAAAAAAgNBE8AQAAAAAAIAKQfAEAAAAAACACkHwBAAAAAAAgApB8AQAAAAAAIAKQfAEAAAAAACACkHwBAAAAAAAgApB8AQAAAAAAIAKQfAEAAAAAACACkHwBAAAAAAAgApB8AQAAAAAAIAKQfAEAAAAAACACkHwBAAAAAAAgApB8AQAAAAAAIAKQfAEAAhqM2bMkGEYMgxDixcvtrscoMLVq1dPhmGob9++dpdSarnf1fHjx5fp8UDH+ahk559/vgzD0Lhx4+wuJWD169dPhmFo8uTJdpcCAJYgeAIA5PPZZ5/pmmuuUZs2bZSUlKSwsDDVqFFDHTt21C233KLly5fbXSLKKTMzU3PmzNEll1yiJk2aKC4uTh6PR7GxsWrcuLHOOuss3X777frkk0907Ngxu8sNSosXL/YFEKf+iY2NVe3atdW6dWuNHDlSjz32mH766Se7SwYq3Pz58zV//nx5vV5NmTLFr2127dqlf//73zrvvPPUsGFDxcfHKywsTElJSerUqZMmTZqkTz/9VJmZmX7XsXTp0nzfyf/85z9+bzt+/Ph827Zr187vbS+66KJ82/bs2bPQ9R588EFJ0vPPP6/169f7vX8ACFgmAACmaX7//fdmly5dTEkl/jn33HPNrVu3VlgtdevWNSWZffr0KXHd6dOn++patGhRhdVUGrn1jBs3zu5SCtiwYYPZpk0bv95nSeZVV11ld8lBadGiRX6/xrl/OnfubH766acl7rs034/SGjdunK+eilDSdyMQvzulqSkQz0eBIjMz02zevLkpybz66qtLXP/IkSPmNddcY4aHh/v1/aldu7b54osv+lVL3s+5JLNTp05+H8ep20oy165dW+J2+/btM71eb77tevToUeT655xzjinJPPvss/2uDQAClad8sRUAIBTMnTtXl156qU6cOCFJatWqlcaOHasOHTooMTFRBw4c0HfffafXX39dW7Zs0aeffqquXbtq/vz56tKli83Vw187d+5Unz59tH//fklSx44dNXbsWLVt21bx8fFKTU3V9u3btWrVKn366afavHmzzRWHhiFDhvg6GCQpIyNDhw4d0q5du7Rq1Sp9+OGH2r17t77//nude+65mjRpkp5//nm5XIU3pm/btq2SKreeaZp2l1Chxo8fH7TDBCvarFmztHHjRhmGodtuu63Ydbdu3arBgwfrl19+kSRFRkZqxIgR6tevn+rWrau4uDjt27dPmzZt0qeffqovv/xSu3fv1qRJk3TVVVcVu++jR4/qvffekyTFxsbq6NGjWr16tdavX682bdqU6pgiIyN14sQJTZs2Tc8++2yx67755ps6efKkb5uS3HHHHVqwYIE+++wzLV++XN27dy9VbQAQUOxOvgAA9lq5cqXvt7CGYZiPPvqomZmZWei66enp5s033+z7bW21atXMXbt2WV4THU8V49JLL/XVdvPNN5vZ2dnFrr9y5Urzgw8+qKTqQkvejqeSPgcnT540X3nlFTM6Otq3zeTJkyulzlNVdMdTSQLxuxOINQWj3E7Lvn37Frve4cOHzaZNm/pe90GDBpl79uwpdptNmzaZw4cP9+tz+/LLL/v2/eabb5oul8uUZF5//fV+HUfe78ioUaNMSWaVKlXMtLS0YrfLPf685+HiOp6ys7PN+vXrm5LMCy64wK/aACBQMccTADhYRkaGLrnkEp08eVKS9MQTT+i2226T2+0udP2wsDA98cQTuuGGGyRJ+/fv57f7QSIrK0sffvihJCkpKUn/+7//K8Mwit2mS5cuuvDCCyuhOmfzeDy6/PLLtXjxYkVEREiSnn76aSanRshYsWKFb66ikiYVnzx5sjZt2iRJuuCCCzRv3jzVrFmz2G2aNGmid999V6+88kqJtbz22muSpPr162vUqFEaMGCAJOmtt95SRkZGidvnlTvf08GDB/XRRx8VuV5uR5UkTZgwwa99G4ahMWPGSMqZG2vPnj2lqg0AAgnBEwA42Jtvvqk//vhDktSzZ0/deOONfm33yCOPqFGjRpKkhQsXasWKFZbU07dvXxmGoe3bt0uSlixZUujkzCVdkC9YsEBDhgxRrVq1FB4erlq1amn48OFauXKlX3WYpqk5c+bo4osvVr169RQVFaWYmBg1a9ZMV111VZGTvebebSzXzJkzC63/1KFSv/32mx577DENHjxYDRo0UHR0tG9S9zPPPFNTp04t9yTf+/btU2pqqiSpQYMG8nisGW1vmqbmzZunMWPGqFGjRoqNjVVYWJhq1qyp/v3766GHHvJ9xgrzzz//6KGHHlL37t19k9lXr15d/fv31zPPPFPikJRT74K2ceNGXXfddWratKliY2NlGIYvcMtr1apVmjRpkpo3b674+HhFREQoJSVFw4cP1/z588vzkpRZp06ddP/99/t+zvv3vPy5q92qVat0xRVXqGXLloqNjZXX61VycrKaN2+u888/X88++6x27tzpW3/KlCkyDEMzZ870LSvss5t3Quht27YVWL5y5UpddtllatiwoaKiomQYhn788ccC+/Q3sF63bp0mTpyo+vXrKyIiQklJSTrrrLP07rvvFrtd3gmgS1LU61mW73Np7mr31VdfacyYMapfv76ioqIUGxurpk2b6sorr9SaNWuK3Tb3/cr7/LNmzdKAAQNUvXp1hYeHKyUlRePHj9fGjRtLfA2+/PJLXXrppWrcuHG+80/Lli01YsQIvfLKK74humXx5ptvSpLcbrcuuuiiItfbsmWL3njjDUlSlSpV9NprrxX5i5DCXH755cU+vmHDBt+/A2PHjpVhGLrsssskSQcOHCj0XFGc+vXr+z4306ZNK3K96dOnS8r5TJ1xxhl+7//iiy+WlPOLg7fffrtUtQFAQLG75QoAYJ+uXbv6Wv4//PDDUm07depUy4eg9OnTx69JZPMOqcs71O7rr782r7zyyiK3c7lc5rRp04qtYceOHWbnzp2LfX7DMMy77767wFC13CGCJf35448/fNv88MMPfm1Tt25dc926dWV+bQ8ePOjbV5UqVcyMjIwy7yvXtm3bSnytVMyQyc8//9xMTEwsdtuUlBTzxx9/LLKGvJ/BGTNmFDoR8dy5c33rnzhxotDJgU/9M2jQIPPw4cNlfm1KM9Qur8OHD5sxMTG+z9m+ffsKrFPSUNS77rrLNAyjxGO86667fNvcd999fn0O77vvPt82f/zxR77lDz30kG/YUt4/P/zwg2+bkl6TvI+//vrrZlhYWJG1DB061ExPTy90P6UZMljU61mW77M/Q3+PHz9uDhs2rMRzzA033GBmZWUVuo+879fGjRvNwYMHF7mviIgI87PPPit0P1lZWebYsWP9Os5XXnmlxNeyKCkpKaYks23btsWud+edd/qe79Zbby3z8xXlpptu8r2+uTfISEtLMxMSEkxJ5sCBA0vcR97P1ubNm8033njDlHL+fSls6PmJEyd857kpU6aYpvnfz3lxQ+1MM2e4Xe62vXv3LsMRA0BgYHJxAHCoY8eOafXq1ZKkiIgInXvuuaXafujQob4hd1YNCZo+fbpSU1N11llnac+ePerUqZPvN8V51a9fv9Dt77vvPi1dulT9+vXTxIkT1aRJE504cUIff/yxnnrqKWVmZurqq69W79691bBhwwLb//XXX+rWrZt2794tt9utSy65ROecc47q168vt9utdevW+W5v/eCDDyo8PFx33323b/svvvhCGRkZat26taSCk0rnql27tu/vmZmZCgsL08CBA9W/f3+1aNFC1apV0/Hjx7Vjxw699957mjdvnrZv367zzjtP69atU2JiYqlf28TERDVo0EBbt27VwYMHNWnSJD333HOKjIws9b6knInKu3btqr/++kuS1L59e02cOFFt27ZVdHS09u7dqzVr1ujDDz8stOtk+fLlGjRokDIzM31DSi6++GLVqFFDO3bs0PTp0/XRRx9px44d6tu3r9auXVvk+y7lDGV5++23VbVqVd1www3q0aOHwsPD9csvv6hevXqScroGhgwZoi+++EJSTpffuHHjVL9+fSUmJmrr1q16/fXXNX/+fH3yyScaPny4FixYUOQk3xUhLi5OPXr00Oeffy7TNLVkyZJiO0RO9dlnn+mhhx6SJNWpU0eTJk1S586dlZSUpIyMDG3fvl3ff/99ga6ua665RsOGDdPdd9+tefPmSZJ++umnAvtPTk4u9HnnzZunH3/8UQ0bNtQNN9ygjh07yuVyae3atapSpYrf9edat26dZs2apZiYGN1yyy3q06eP3G63vv/+ez322GPauXOnPvjgA1155ZWaMWNGqffvj7J8n0timqaGDx+uTz75RFJOB8wtt9yiTp06KSsrS99++60ef/xx7d+/X1OnTlV2draefvrpYvd55ZVXaunSpRo6dKhGjRql+vXr69ChQ5o9e7ZefvllpaWlafTo0dq8ebMSEhLybfvyyy/r9ddflyRfR2fr1q1VpUoVnThxQlu3btXKlSuLHUZWkq1bt2rHjh2SpK5duxa77tdff+37+/nnn1/m5yzMyZMnfd1UvXv39p1PwsPDNXLkSP3nP//RwoULtXPnTp122ml+73fo0KG69tprdeTIEb3++uu644478j0+d+5c/fPPPzIMo8RhhqcyDENdu3bVggUL9N133yktLc03HBcAgordyRcAwB7Lli3z/db19NNPL9M+kpOTffvYu3evZbWVdXJxSeb//M//FLpe3gllb7755kLXOeuss0xJZvXq1c3169cXuk5GRoY5dOhQU5Lp9XrNbdu2FVgn93n86XT5559/zL///rvYdT777DNfJ8kDDzxQ4j6L8txzz+V7rRITE83Ro0ebL7zwgrlixQozNTXV73317t3bt59bbrml2InKt2/fnu/nzMxMs1GjRr7t33zzzUK3e+CBB3zrnHnmmYWuk/d4GjdubP71119F1vHII4/4OhNmzZpV5HpPPvlkibWVpKwdT6aZv+vjoYceKvB4cd+PMWPGmJLMqKioEif+379/f4FlpekUytvxJMns3r27eezYsWK3Kek1ybu/GjVq+LpS8jpw4IDZvHlz33pfffVVuY6jpPNNad7HkjqeZs6c6Xu8Y8eOhXbV7dy5M1+31dKlSwusc2qH2n/+859C68n7WXr22WcLPN6rVy9TknnaaacV2+GXlZVlHjx4sJgjL9q7777rd9dUbseiYRjm8ePHy/R8RZkzZ46vjunTp+d7bOXKlb7H7r///mL3c2rHk2ma5hVXXOE7B51qwIABpiSzX79+vmW525fU8WSapnnvvff61l+xYoUfRwoAgYc5ngDAofLO11GjRo0y7SPvdvv27St3TeXVpk0bPfzww4U+NmHCBFWvXl1S4R1a33//vT7//HNJORM753Y5nMrr9erll19WWFiYTp48Wey8Hv5ISEgososk11lnneX77f/7779f5ue69tprddNNN/l+/ueff/Tmm2/qmmuuUdeuXRUXF6dOnTrpnnvu0ZYtW4rczzfffKNvvvlGktSvXz899thjxc6lk5KSku/n+fPn6/fff5ckjRo1Spdeemmh29111106/fTTJeXMJVbU3Fq5XnjhBd97fKoTJ07oiSeekJTTIXLJJZcUuZ+bbrpJHTt2lJTTEVLZkpKSfH8/cOBAqbbN7UBr0qRJiZ04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cWrVqlWZ91WRDh8+rOXLl0vKmeOqqO6MksTGxmr8+PG+YUW7du3Sl19+qddee80XRA0ZMkSbN2+2fBJnK/z9998lrpP3s3Fqx2XeICw7O7vYsKGyhi/l/fzmrb0oVn32/VGtWjVdc801uuaaayRJv//+uz7//HO99NJL+umnn/Tzzz9r2LBhvmG2pVGvXj3f30s6j/Xr108rV66UJM2bN8+S4Kksw+ZytytN8CTldDbNnz9fS5cu9Q3vLU+3k5T/Ncv7WgJAMGGoHQA42OWXX+77+5NPPul3J016erqee+4538/F3SGsLOwIsXI7PzIzM30X/hUt75wvRd3ZLVfeOYoqQ947op160d6pUydJOUN3StMdlqtNmzaScl7rko4r73tR0jxOxcnb2VOWmivLiy++6AtCzjjjDMsChzp16uiyyy7T0qVLNWjQIEk5Ey6f+loESoC8du3aEifRzw0ooqOj1ahRo3yP5Z3DrriwY9++fZU2TC/3cy/Jr3NM7jpxcXFl6jQqj0aNGunaa6/V6tWrffN3rV27tkwTkOcNeUvafsKECfnmWSpv4H748GHfjSM6dOigWbNmlfgn9453P/zwg3744YdSPd95553nC7nT09Pl8Xg0ZsyYch3Dr7/+6vt7UXdbBYBAR/AEAA42ZswY33CAb775Rs8884xf2911112+2zrXr19fI0eOtLSu3A6MipwM+VQXXXSR7+9PPPFEufblb/15h38V13WxYsUKff/99+WqqbTyPt+pw9IuvPBC398ff/zxUg/9y51HRVK+28YX5sUXX/T9/ayzzirV8+TVv39/30TdL7/8cqnvFlcZVq9erfvuu8/387333mv5cxiGoYEDB/p+zjuflKR83U+V+f071eHDhwvMLZbXsmXLtHHjRkk57+2pQ/0aNGjg+3tx353XX3+9xFqsOh8lJyerffv2kqTFixfnCxRO9fnnn/vmBxowYIBfw8MqQlhYmM444wzfz6d+XvyRd6L43LCwKI0aNdLo0aMlSf/8848mTJhQqjsWntrdNGvWLN8cSRMnTtQll1xS4p9bbrnFt33eO9T5w+v1aty4cQoPD1d4eLjOP/98v+YrK853330nKWfIb0pKSrn2BQB2IXgCAAcLDw/XrFmzfBcFN954o5566qkib92dkZGh22+/3XfHL8Mw9Morr+SbPNYKuXPq/P7778XeRtxKvXr10plnnikpZ0LtO++8s9hAJTs7W3PmzNEvv/xS4LHc+kv67X7eu8hNnz690OfbsWNHid1Q/khLS1PHjh313nvvldhJsnnzZt15552+n3M7AHL17NnTdzH61Vdf6bbbbiv2tdqxY0e+n8877zzfLd5nzZqlt99+u9DtHnnkEd9F14ABA8r12/7Y2FjdeuutknImdB8+fHiJ4dOqVav06aeflvk5/ZWZmalXX31Vffv29YUbkydPLtMwu7fffjvfXfFOlZ2d7butvFQwVMw7n1VZulusdPPNNxd6N7eDBw/qiiuu8P08efLkAuv069fP9/fHH3+80PDi+++/zxf0FcXf77M/brzxRkk578OYMWMKfa/27NmjSZMmFdimIsyYMaPYQC0tLU2LFi2SlHO+zxvo+Ss6Olq9evWSVHLwJEnPPPOMmjRpIilnuN0FF1xQ4tDELVu26OKLL87XxSv9N4hyu90FzmNFOf30030dZm+99ZZfN3zI6/HHH1daWprS0tJ83VZldeTIEV/Aes4555RrXwBgJ+Z4AgCH69atm9566y2NHTtWaWlpuvnmmzVz5kyNGTNGHTp0UGJiog4cOKAVK1Zo5syZ+W5b/txzz6l///6W19S7d2999dVX2r9/v6688kqNHz9eVatW9Q0DSklJKdPd1EryxhtvqGvXrtq+fbseeeQRffLJJ7rsssvUoUMHxcXF6dixY9q6datWrlypuXPn6s8//9SXX36pFi1aFKh/y5Yt+uGHH3TnnXdqyJAhvm4bKedi3+v1qm3bturQoYPWrl2rL7/8Un379tW1116rBg0a6NixY1q0aJGeffZZHT58WD169NCyZcvKdXxr167ViBEjVKVKFZ133nnq2rWrmjZtqsTERGVmZmrbtm1auHCh3njjDV+XQN++fTVq1KgC+3r99dfVqVMn/f3333riiSf09ddf+yZGj46O1r59+7R27VrNnTtXkZGRvotXKWfo3owZM9SnTx9lZmZq9OjR+vLLLzVixAhVr15dO3fu1IwZM3xzTCUkJOill14q17FL0u23367ly5fr008/1RdffKEmTZroiiuuUM+ePZWcnKz09HTt2bNHa9as0UcffaSff/5Zd911l84999xyPe+hQ4f0888/+34+efKkDh06pJ07d2rVqlX68MMPtXv3bt/jV111lZ566qkyPdedd96pq666Suecc4769Omj5s2bKzExUampqfr99981Y8YMLVmyRJLUuXNnde3aNd/2vXv39v392muv1V133aXTTjvN11FUrVq1SrmDZbt27fTLL7+oU6dOuvXWW9WnTx+53W59//33evTRR7Vz505J0rhx4/KFTLlatWql/v3766uvvtKiRYt01lln6frrr1dKSor27dunTz/9VC+++KLq1q2rgwcPFtvJ4+/32R+jR4/W7Nmz9cknn2j16tVq27atbrnlFnXq1ElZWVlatmyZHn/8cd+NG66//nr16NGjNC9dqVx22WW6+eabdd5556lnz55q0qSJ4uPjdfjwYW3cuFEvv/yyfvzxR0k5XaFlnWh/+PDh+vrrr7V//36tXbtWHTp0KHLduLg4LViwQIMHD9Yvv/yijz/+WA0bNtSIESPUv39/1a1bVzExMTpw4IA2bdqkzz77TJ999lmBO+/99NNPvon6zzjjDCUnJ/td74gRI/Too4/qn3/+0dy5cy3v6vXXwoULfb988Tc4A4CAZAIAYJrmypUrzU6dOpmSSvyTlJRkzps3r8Jq+fvvv82aNWsW+fyLFi3yrTt9+vRClxdm3LhxvnWL8ueff5pnnnmmX6+D2+02ly9fXmAfv/zyixkdHV3kdn/88Ydv3Y0bN5o1atQoct3w8HDzlVdeMe+7775Ct/dXRkaGWatWLb+OK/fPqFGjzGPHjhW5z61bt5odOnQocT99+vQpdPvPP//cTExMLHbblJQU88cffyyyhtz1xo0b59frkJ6ebk6ePNl0uVx+vQaPPfaYX/s91aJFi0r1WksyO3XqZH766acl7rtu3bpFvq65j/nzXLt37y6wfXZ2ttm/f/8it7vvvvt86/7xxx+FLi9OSe9X3sffeOMNMywsrMhaLrzwQjM9Pb3I59q2bZtZr169Irdv0qSJ+fvvvxf7eppm6b7P/pyPjh8/bg4bNqzY98cwDHPy5MlmVlZWofsozfmgT58+piSzbt26BR7z97N59tlnm4cPHy72eYrzzz//mJGRkaYkc/LkyX5tc+TIEfOaa64p9jOQ989pp51mTps2zbf95MmTfY+9+uqrpar3hx9+8G175pln5nss778jmzdvLtV+c+Vu36NHj2LXGzJkiCnJrF27tpmZmVmm5wKAQEDHEwBAktSlSxetWrVKn332mebNm6dvv/1Wf/31l/755598w906d+6sr776Kt/kvVZLTk7W6tWr9fjjj+urr77SH3/8odTU1FLPJVQWNWrU0JdffqklS5borbfe0rJly7R7924dO3ZMUVFRqlOnjlq1aqV+/frpwgsvLHT+jubNm2vt2rV64okntHTpUu3YsUMnTpwotP5mzZpp3bp1euKJJzR//nxt27ZNbrdbtWrV0oABA3TNNdeoZcuWmjJlSrmOy+v1ateuXVqzZo2+/vprrVixQps2bfIdW1hYmBISEtSkSRN169ZNI0eOzDcZcmHq16+v77//Xh988IHeffddrVy5Uvv27VNmZqaSkpLUsmVL9e/fv8ihggMHDtSWLVv0wgsv6JNPPtFvv/2mI0eOKCEhQa1atdIFF1ygK664wtK7roWFhWnq1Km67rrr9Oqrr2rx4sXasmWLDh06pLCwMFWvXl3NmjVTz549NWTIELVs2dKy584VFRWluLg4Va1aVa1atVL79u01aNAgS+60t2TJEi1YsEBLly7Vr7/+qr///lv79u2T2+1W9erV1bFjRw0fPlzDhw8vdN4gwzD0ySef6JlnntHcuXP166+/6siRI6WaZ8cqo0ePVuvWrfX000/r66+/1l9//aWYmBi1b99eV1xxhUaMGFHs9nXr1tWaNWv0xBNP6MMPP9S2bdvk8XjUsGFDDR8+XJMnT1Z0dHSJdZTm++yPyMhIvffee1q4cKFmzJihZcuW6a+//vJ97/v06aNJkyblmxC/ovzyyy/67LPPtHz5cv3222/au3ev9u/fr7CwMNWpU0edO3fWpZdeWu5hXgkJCRo3bpxefPFFvf3223r88cdL7BKLjY3V888/rzvuuEPvvvuuvv76a/3yyy/av3+/Tpw4ofj4eNWrV0+dO3fW+eefr4EDB/o68zIyMvTmm29Kyjn3DR06tFT1tmvXTs2aNdOvv/6qr776Stu2bav0O8odOHDAN9T3+uuvLzCPGQAEE8OsjP/FAwCC2j///KNevXppw4YNkqSnn35a119/vc1VAQCCxdatW9WsWTOdPHlS77zzji6++GK7SwpoTzzxhG699VZVqVJFW7duzTe8EwCCDZOLAwBKlJiYqM8++0x16tSRJN1www2aPn26zVUBAIJFgwYNfJPC33///ZV244hgdOzYMT322GOSpDvuuIPQCUDQI3gCAPilTp06+uyz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Kjo6Wn369Cnyvc3KytITTzyhZs2aKSIiQqeddpomT56sI0eOFHksn3zyiSZMmKDmzZsrLi5O0dHRatu2rR5++GGlp6cXWD/v+/Dbb7/pkksuUfXq1eVyufThhx+qVatWMgxDmzZtKvT5du7cKbfbrfr168vkjmEIVGn7pawMu6sA7MHE+gRPKL9Ir8FwOzha3XhOpUAwiY+P1znnnCNJWrRoUb7HvvvuO7Vt21bPP/+8vF6vBg0apFatWunzzz9X7969NXv27AL7e++999S+fXtNmzZNUVFRuvDCC9WxY0ft3LlTt956q44dO1ZsPUePHtXpp5+uRx99VMeOHdOAAQM0cOBAJSYm6p133tGnn37q13G9+eab6tWrlz766CM1bdpUQ4cOVXh4uP7zn/+oQ4cORQY/27ZtU+fOnbVq1Sr1799f7du315o1a3TBBRfoiy++8Ou5T309Bg0apIyMDA0ePFi1atXS3Llz1bVrV61bt67A+ldddZXGjh2rNWvWqFevXho0aJD+/PNP3XnnnerXr5+OHz9e6hqKsnr1anXt2lXbtm3TWWedpcaNG+ubb75R//799fPPPxdYf/To0br11lu1c+dODRw4UJ07d9bMmTPVr1+/QkMkSZo4caLef/99ValSReecc4569eqlnTt36q677tK5556rrKysQrfbtGmT730444wzNGDAAHm9Xl111VWSpFdffbXQ7aZNm6bs7GxdfvnlATnpPCBJOl4w/AYcg88/czzBGo0TXfo7tfD/SAGhrnEiwRMQbNq1a6c5c+Zo48aNvmVHjhzRRRddpCNHjujNN9/UpZde6nts9erVGjhwoC6//HL169dPSUlJkqTNmzdr7NixysrK0ltvvaVRo0b5tjFNU19++aUiIyOLrWXOnDn6448/dP7552vu3Llyuf57Ttm3b5/+/vvvEo9n586duvLKKyVJ8+bN0/nnny8pZ3L1m2++WVOnTtWYMWP0/fffF9h25syZuvnmm/XYY4/5nnvq1Km68cYb9eCDD2rgwIElPn9eL7zwgl5++WVdccUVknJehzvuuEOPPvqoxo8frx9++MG37vvvv6+XX35ZtWrV0uLFi9W4cWNJ0uHDh3Xeeefp22+/1b333qsnnniiVDUU5fnnn9fTTz+t66+/3rfsxhtv1NSpU/XYY4/p9ddf9y2fPXu23nnnHaWkpGjJkiWqV6+eJGnv3r3q37+/1qxZU+hzvPTSSxo4cGC+9/3o0aMaNWqUPv74Y7311lsaO3Zsge3eeecdXXfddZo6darcbrdv+eHDh3X77bdr5syZeuihhxQWFuZ7LDs7W9OmTZPb7dZll11W5tcFqHAn9ttdAWCftAN2V2A7rpZgiSZV+CjBmcLdUko8v2FGYMrOzlZmZqbffwobplOa7Qvr5PCnhuzs7Mp4OfKpVq2aJOmff/7xLZs2bZr+/PNP3XDDDflCJ0nq1KmT7rnnHh07dkxvvvmmb/m///1vpaWl6fLLL88XOkmSYRgaOHCgwsPDi61l3759kqR+/frlC50kKSkpSa1atSrxeF599VWdOHFCI0aM8IVOkuRyufS///u/qlWrllavXq1ly5YV2LZ+/fp6+OGH8z33ddddp8TERK1YsUIZGaUbHtO9e3df6CTlvA4PPPCA6tSpox9//DHf8MZnnnlGknTffff5Qicppyvt+eefl2EYeumllwodclcWPXr0yBc6SdLdd98tSfrmm2/yLX/hhRck5QzTyw2dJCk5OVmPP/54kc8xZMiQAmFjbGys/v3vf0vKCQYLk5SUpEcffTRf6CTlvBaXXHKJ9u3bV2DbL774Qjt27NCgQYNUq1atImsCbJdG8AQHS9tndwW2o+MJlmhW1S2JO+zAeRoluuRxETwhMO3YsaPQeXCK0rNnT3k8+f9rsGLFinxzFBWnRo0aBSa93rt3b7Fz+0g5cxblvbCvDLkhW96hSbnDyoYOHVroNr169ZKkfHMuLVy4UJJ8w6HKomPHjpKkxx9/XNWrV9egQYMUGxtbqn0sXbpUkgoEZpIUHh6u4cOH6+mnn9bSpUvVo0ePfI/37ds3XxeNJHk8HtWvX19r167VgQMHVLNmTb9rueSSSwos83q9GjZsmKZOnaqlS5eqZ8+eOnnypFasWFFk3W3atFGbNm20bt06/fjjj+ratavfNRSlsO6tqlWrqkqVKvrzzz99y/LWdvHFFxfY5uyzz1ZiYmK+4DKvzZs369NPP9Xvv/+u1NRUZWdn+z5zmzdvLnSbM888s8hJ5ydNmqRp06bplVde0fDhw33LX3nlFUnydbsBAYvgCU6WfkjKzpRczo1fnHvksFSVSEO1Y13afbTyf2sN2KlNsrvklQAEnP37cy6CqlSp4luWG9KdGswUta2UM8RNyrnTXFn179/fN9xr5MiR8ng86tChgwYMGKAJEyaoQYMGJe4jd/LwogK83OW7d+8u8FidOnUK3SY3/CpqLqOi1K1bt9gacms9cOCAMjIyVK1aNUVHRxe5zbp16wqtuyyKO9aDBw/6fs6tLSkpqcgwqG7dugWCJ9M0dcstt+jf//53kRN9Hz16tNDlKSkpRdbduXNndejQQQsXLtQff/yh+vXr6++//9b8+fNVp04dnX322UVuCwQEgic4XdoBKaq63VXYhuAJlmmTRPAE52mdxDBTIBjlzjPUokUL37LcIX/Dhg0rMgiRVKCrywpPPfWUrrrqKs2bN08LFy7UsmXLtGrVKj322GOaNWuWLrroonLtv7hJp08d3hdISjtZdknDNiv6WGfPnq2nnnpKp512mv7973+rW7duSkpKktfrVUZGhsLDw4sMpCIiIord96RJk3TllVfqtdde04MPPqiZM2fq5MmTmjBhQoHheUDAIXiC06XtJ3gCrNC+ulsLtvo3HAMIBXViXUqODtwLNiAlJaXIDo/CFHbxWprhTYWFBMnJyb75lIpS2cHH4cOH9fnnn0uSzjjjDN/yOnXqaNOmTbr99tt9w99Kctppp2nz5s3asmWL2rVrV666mjZtqttuu0233Xab0tLS9Nxzz+nWW2/V1VdfXWLwVKtWLW3atEnbt29Xy5YtCzye281Vu3btctXoj+3btxe7PHcuoqpVqyosLEz79+9XampqoWFfYXXnDgss7G6BuR1o5ZVb2759+3TixIlCJ4jfsWNHgWVz586VJP3nP//RoEGD8j22devWctU0atQo3XLLLZo+fbqmTJmiV199VS6XSxMnTizXfoFKQfAEp3P4d4ArJlimfoKhKpHMdQPn6FSTUygCm8vlksfj8ftPYcFRabYvLLjyp4bKDp5uvvlmpaamqnPnzurWrZtv+YABAyT9Nzzwx5lnnilJevnlly2tMSIiQrfccotq1qypffv2ae/evcWunzv/1KxZswo8lpGRoffeey/fehXp3XffLbAsMzNT77//vqScucSknHmfcoPNd955p8A2P//8s9atW6eYmJh8oV7ufFO//fZbgW2+/PLLctefW9vpp58uqfDj+eKLL/INzcuVO/SusMC3sP2URnR0tEaPHq09e/botttu0+bNm3XWWWcVO0QPCAhZ6dLJgkEx4CgET4A1DMNQ55q0esM5OtXg8w4Ek61bt+riiy/Wa6+9pujoaL322mv5Hr/qqquUnJysxx57TC+//HKBYVuZmZn6/PPP9fPPP/uW3XDDDYqIiNArr7yi2bNn51vfNE19+eWXJc6R9OGHH/omss5rzZo1+vvvvxUTE6OEhIRi9zFx4kRFRkbqnXfe0SeffOJbnp2drTvvvFO7d+9Wx44dS5y/ygrffvutpk2blm/Zfffdpx07dqhNmzb5wq9//etfknLuHJe3I+jo0aO67rrrZJqmrrrqqnzD0Pr06SNJevLJJ3X8+HHf8q+//lpTp0617DiuvvrqfLXn2r9/v2699dZCt2nSpImknCAy75C6pUuXFnsnPH9NmjRJknx3yMt790AgYGUUPq8Z4Cgnnf09IHiCpbrX5kIcztAwwaUaMZxCgUA1fvx4jR8/XmPHjtUFF1ygFi1aqFGjRnr33XfVuHFjLV68WK1bt863TUJCgubNm6f4+HhdddVVqlevns4991xdeuml6t+/v5KSknT22Wfr999/923TpEkTTZ8+XYZh6JJLLlHLli01cuRInXvuuapbt64GDhyoEydOFFvr4sWL1a1bN9WpU0eDBw/WpZdeqjPOOEOnn366srOzdf/99xe469ypUlJS9NJLLyk7O1uDBw9Wr169NGrUKLVo0UJPPvmkqlevrjfffLPsL2gpXH311br88st1+umna9SoUWrVqpUefvhhxcXFacaMGfnWHTZsmK688krt2rVLrVq10nnnnacRI0aoYcOGWrJkibp27ar/9//+X75tRo4cqaZNm2r58uVq3ry5hg0bpq5du2rAgAG+sMgKI0eO1PDhw7V9+3a1aNFCQ4YM0UUXXaTGjRvL4/EUOgz1+uuvV3R0tF544QW1atVKI0eOVO/evdWnTx9faFQerVu3Vvfu3SXl3EVy8ODB5d4nUOEy6XYCnN71x1UTLFU71qV68XysEPq61yFkBQLZzJkzNXPmTM2aNUtLly6V2+3W2LFj9cEHH2jjxo3q1KlTodt17dpVP/30k2677TbFxcVpyZIl+vDDD7V9+3b16dNHM2bM8A2vy3XJJZdo9erVGj16tA4fPqz3339fa9asUUpKip588knFxMQUW+v48eN18803q1atWlq1apXef/99/fHHHzr33HO1cOFC3XTTTX4d85gxY7R06VKdd955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bHDukmXLlsmyLB133HH11n366acaO3ascnJylJCQoF69eumOO+7Q3r17m3LaAKDNuOeee5SUlKS///3vzfqOM8bo7bff1umnn6527dopMTFRRx11lKZMmaKKigq/n9m4caMuv/xy5eTkKCUlRUOGDNE777yjjRs3yrIsjRw5ss72RUVF+vvf/66zzjpLPXr0UEJCgrKysjRmzBh98cUX9dq2LEtz5syRJB1xxBF1rhk1/M3xNGDAAFmWpZ9//tlv3Xv37lV8fLw6duxYb+6SBQsW6OKLL1Zubq7i4+PVtWtXXXvttdq0aVNTTyUARJUnnnhCKSkp+vHHH/XNN9/UW79582bdcsstysvLU2Jiotq3b69zzz1X3377bYNtrly5Utdcc4169uyphIQEdejQQcOGDdPjjz9e73t57969uuuuu9S7d+/a9seMGaPPP/+8znbbt29XXFycunXrJo/H43e/b731lizLqh1aKDXv3gbRjeAJCJHS0lJJUkJCQu2yG264QVdeeaV++OEHDR8+XGPHjtX27dv1+9//Xqeffnq9G44rrrhCN998s1atWqUTTzxR48aNU/fu3fXNN9/o+eefb3T/nTp10sSJE5WSkiLJN5685s+ECRMa/FxcXJwuvvhiVVdX6/333/e7zZtvvilJ+tWvflVn+T333KOzzz5bX375pfr27avzzz9fDodDTz75pE444QTt3Lmz0ZoBoC3Jzc3VjTfeqNLSUj322GNN+ozX69UVV1yhyy+/XN9//72OPfZYnXPOOSovL9ef/vQnnXbaaaqsrKzzmbVr1+r444/X22+/rczMTJ1//vlKSUnR5ZdfrqeeesrvfubPn6/bbrtNq1evVt++fXXhhReqb9+++vzzz3XWWWfplVdeqd02NTVVEydOVMeOHSVJF110UZ1rRmOuuOIKSQeuC4f617/+JZfLpUsuuUQOx4GpRJ999lmdfPLJ+uCDD9SjRw9dcMEFysrK0ssvv6whQ4Zo5cqVhz2XABBtMjIydPbZZ0uSZs2aVWfdd999p4EDB+qZZ55RXFycxo4dq2OOOUafffaZTj31VE2bNq1ee//61780aNAgvfLKK0pOTtaFF16owYMHa/PmzbrrrrtUVlZWu+3WrVt1/PHH6/HHH5fT6dQFF1ygQYMG6csvv9RZZ52lJ598snbb3NxcnX/++dqyZYs+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XdRou/379w9JfQDQlvz444+SfL+L16j57pwwYUKDD7ElX6+icPB3nyJJ2dnZuuiii/TWW29p5syZOuOMM/TSSy9Jkq677rp620fy3gatC8ETEAZZWVmKj4/Xnj17VF5e7veCUfMko0uXLnWW5+Tk6Nprr9W1114rY4w+++wzXXLJJZo6daquvvrqsP5ifsUVV+jHH3/Um2++qQcffLDBt9l17dpVku/iE4oQDgDamuzsbN1666165JFH9Mgjj6hz5871tqn5ruzXr1+Tvytzc3MlHXhyfSh/y8vLy/XFF1+oY8eOmjZtmux2e53169evb9K+m+OKK67Qhx9+qDfffFNnnXVWg9eL7OxsJSYmymaz6dVXX23wZgYAYlFxcbE+++wzSdJpp51Wu7xr165atWqV7rnnHg0ePLhJbXXr1k1r1qzRunXrdOyxxza6bc01q6CgwO/6hu5TJOnGG2/UW2+9pRdffFF9+vTRZ599pt69e9ep/2CRvLdB68FQOyAM4uLidOKJJ0pSvTmTJGn58uXKz89XampqoxcGy7I0ZswYjR07VpK0YsWKw+47Pj5ekuR2u5td92WXXSabzaa3335bJSUlmjFjhtq3b1/7mtcaXbt2Vb9+/fTTTz9p9erVzd4PAESDO++8U2lpaXrhhRfqDUeQpKFDhyojI0Nz5szRvn37mtTmySefLEn67LPPVF5eXm/9u+++W29ZcXGxvF6vcnNz64VOLpdLH374od99BXO9OPfcc5WRkaHp06ervLxcb7/9tux2e705qBwOh0aOHKmSkhLNnDmz2fsBgGh25513qry8XEOHDtVJJ51Uu3z06NGS1OD3tz9nnHGGJOmFF1447LannHKKJOnTTz9VUVFRvfX//Oc/JR2YS+pgw4cPV//+/TV9+nT95S9/kdfr1bXXXtukGgO5t0F0IHgCwuTWW2+VJE2ZMqXO0+bS0lLdcsstMsbohhtuUGJioiRfN9sPPvhATqezTjv79u3TggULJDU858fBap5grFq1qtk1d+7cWaeddprWrl2ryZMnq6qqShdffLHi4uLqbXv//ffL6/Xqoosuqp0g/WB79+7Viy++2OwaAKCtyMrK0m233abq6mq9/PLL9dbXzGtRWlqq8ePH++15tHXrVr3xxhu1P/fu3VujRo1SYWGhJk+eXDvcQvLNFeXvYUaHDh2UkZGh5cuX15k3w+PxaPLkyQ0+IAjmepGQkKAJEyaotLRU//M//6MtW7bojDPO8DuJ+h/+8AfZbDZdddVVmj17dr31ZWVleuWVV1RZWdnsOgCgLVq/fr0uueQSvfzyy0pJSal3DbnhhhvUoUMH/eUvf9ELL7xQ51og+R4YfPbZZ1q+fHntst/+9rdKTEzUiy++qGnTptXZ3hijL774QtXV1ZKkXr16aezYsSotLdXtt98ul8tVu+13332n5557Tna7XTfffLPf+m+44QY5nU4988wziouL06RJk+ptE6p7G0SJyL1QD2h71MTXota4/vrrjSSTlJRkxo4day6++GKTk5NjJJkTTzzRlJeX12774YcfGkkmIyPDjBo1ylxxxRVm7NixJi0tzUgy5513Xp22J06caCSZWbNm1Vn+xBNPGEmmY8eO5tJLLzXXXHONmTx58mE/V+OVV16pPU5JZu7cuQ0e3+9//3sjydhsNnPccceZiy++2EyYMMEMGjTI2O12k5GR0eRzBQCtlSRjt9v9rtu3b59JT0+v/c584IEH6qz3eDzm17/+tZFk4uPjzQknnGAuvfRSM378eNO/f39jWZYZOHBgnc+sXr269lrRu3dvc9lll5kRI0YYm81mbrnlFiPJjB49us5nHn744do6R48ebS655BLTs2dPk5SUZG6++Wa/tb3//vtGkklPTzcTJkww11xzjbnmmmtq1z/wwANGknn11Vf9HvtXX31V53rxxhtvNHgOn3vuOWO3240kc8wxx5jx48ebSy65xJxwwgkmISHBSDKFhYUNfh4A2pqa78aJEyeaiRMnml//+tdm3Lhx5qijjjKWZdV+x3///fd+P//dd9+Z7OxsI8l069bNnH322ebyyy83p59+usnMzDSSzIcffljnM2+//baJi4szkszRRx9tLr30UnP22Webbt261fue3bJlizniiCOMJNOjRw9z6aWXmlGjRtV+Vz/xxBMNHltRUZFJTk42ksyECRP8btPcextEN4InoBmaGzwZY8z//d//mZNPPtmkpqaaxMRE079/f/Pwww+bioqKOttt377dPPTQQ+b00083Xbt2NfHx8aZjx45m2LBh5pVXXjFOp7PO9g0FSC6Xy9x3330mLy+v9sLTo0ePw36uRnFxsUlMTKz9nNfrbfT45syZYy6++GLTuXNnExcXZ7KyssyAAQPMLbfcYubMmdPk8wQArVVjwZMxxvzxj39sMHiq8dFHH5mxY8eaDh06mLi4ONOhQwczePBgc/fdd5sffvih3vbr1683l112mcnKyjJJSUlm0KBB5o033jDffPONkWQuvfTSep95/fXXzaBBg0xycrLJysoy48aNM/n5+ebVV19tsLYnn3zSHH300bXhz8HXuMMFTx6Px3Tt2tVIMsnJyaa0tLTBc2SMMT/++KOZOHGi6dGjh4mPjzeZmZmmf//+5uqrrzb/+c9/Dnu9AYC25OBgXpJxOBymffv25phjjjETJ040H3zwgXG73Y22sX37dnP33Xeb/v37m+TkZJOcnGzy8vLMuHHjzGuvveb3ezc/P9/86le/Ml26dKm93gwbNsw88cQTxuVy1dl2z5495s477zR5eXm138tnnnmm+eyzzw57fKeccoqR1OC2zb23QXSzjAnRK04AAAAQVo8++qjuvfdePfroo5o8eXKkywEAxKDNmzfriCOOULdu3bR+/XpeHIHDYo4nAACAVqSqqko//fRTveWzZs3S//t//08Oh0OXXnppBCoDAMD3EMTj8ejmm28mdEKTOCJdAAAAAA4oKipS//791bdvX/Xu3VuJiYlas2aN8vPzJUmPP/64evToEeEqAQCxZNWqVXrssce0YcMGffXVV+ratatuvPHGSJeFNoKhdgAAAK1IZWWl/vjHP+qLL77Q5s2bVVJSoszMTA0dOlS33nqrzj777EiXCACIMbNnz9Zpp52mpKQkDR06VH//+981YMCASJeFNoLgCQAAAAAAAGHBHE8AAAAAAAAIC4InAAAAAAAAhAXBExCkBx98UDabTcuWLatdNmfOHNlsNuXm5qqoqMjv5zZu3KjU1FQlJCRo5cqVLVStf1OnTpVlWTrllFPU2OjbF154QZZlafDgwfJ4PKqsrFRubq7OOeecFqwWANqGcF0fZsyYoREjRig9PV3p6ekaOXKkPv7446DrdbvdOu6442RZll544YUGtzPG6JRTTpFlWXryySclSdOnT5dlWXr33XeDrgMA2pJYvhcIlaeeekqWZWnhwoUhaxOtjAEQsB07dpjU1FRz8cUX11t37bXXGknm2muv9fvZM88800gyU6ZMCXeZh+V2u83QoUONJPP000/73Wbr1q0mIyPDOBwOs3jx4trlU6dONZLMzJkzW6pcAGj1wnV9ePLJJ40k43A4zJgxY8y4ceNMUlKSkWT+/ve/B1334sWLjcPhMBkZGWbr1q1+t3n66aeNJHP88ccbj8djjDHG6/WagQMHmry8PON0OoOuAwDaAu4FQqOiosJ07NjRDB8+PKTtovUgeAKCcNtttxlJfr98CwsLTadOnYxlWWbOnDl11r3xxhtGkjnqqKNMdXV1S5XbqKVLl5q4uDiTlpZmCgoK6q2/8MILjSRzzz331FleUVFhMjMzzfHHH99SpQJAqxeO68PPP/9s7Ha7SUhIMN9++23t8lWrVpmsrCzjcDjMmjVrgq598uTJRpIZN25cvXWbN282aWlpJi4uzixdurTOurfffttIMs8++2zQNQBAW8C9QOg88sgjRpL55JNPwtI+IovgCQhQeXm5ycjIMMccc0yD27z77rtGkunbt6+pqqoyxhize/duk52dbSzLMt98802T9zdixAgT7k6K9913n5FkzjnnnDrL33vvPSPJ9O7d21RWVtb73DXXXNPgRRcAYk24rg833XSTkWRuv/32eutqep/ecsstQddfWVlpevfubSSZadOm1Vl33nnnGUnm/vvvr/e5iooKk5aWZgYMGBB0DQDQ2nEvEFqbNm0ylmWZ8847LyztI7IInoAAvfbaa0aSefjhhxvdruaX9Pvuu88YY8yvfvUrI8ncdNNNzdpfS1xsqqqqTL9+/Ywk889//tMY43tak5ubayzLMrNnz/b7uZkzZxpJ5sYbbwxrfQDQFoTr+tC9e3cjycydO7feuk2bNhlJpkePHkHXb4wxs2bNMpZlmQ4dOpi9e/caY4x55513ap/Q19xAHerXv/61kWTmz58fkjoAoLXiXiD0hg8fbux2e4NDvdF2ETwBAZowYYKRZObNm9fodgcPS3jiiSeMJNO5c2dTXFzcrP21xMXGGGPmzp1rLMsy2dnZZteuXbXj06+//voGP1NZWWni4uJMt27dwl4fALR24bg+FBYWGklGkikrK/PbXnZ2tpHU7OtLQ6677jojyVx55ZVm7969pkOHDod9Qv/yyy832CMKAKIJ9wKhd//99xtJ5uWXXw77vtCyCJ6AAHXs2NE4HA5TUVFx2G3//ve/194wSDIffvhhs/fXUhcbY4z5zW9+UztxrGVZpnPnzqaoqKjRzwwePNhIMuvXr2+RGgGgtQrH9SE/P99IMu3atWuwrWOPPdZIqjf3UqCKiopM586da68HkszNN9/c6GeWLVtmJJlTTz01JDUAQGvFvUDozZgxo/aBB6KLrYkvvwNwkF27dmnnzp3q1q2bkpKSDrv9ddddp/T0dEnS6aefrgsuuCDMFQbn0UcfVbdu3bRw4UIZY/Tss88qIyOj0c/069dPkrRkyZIWqBAAWqdwXR/KysokScnJyQ22lZKSIkkqLS1tZtX+ZWRk6JlnnpEkLVy4UN26ddMjjzzS6Ge4FgCIBdwLBKe8vNzvcq4h0csR6QKAtmjXrl2SpHbt2jVp+8cff1wlJSWSfL+8b968Wd26dfO77TfffKOXXnqp3vKff/5ZkjRp0qR66/r166d77rmnSbU0RVpamiZPnqxbbrlFQ4YM0bhx4w77mfbt20uSdu/eHbI6AKCtCef1IRIuuOACDRkyRIsWLdLkyZOVlpbW6PYOh0NpaWkqKSmR0+lUfHx8C1UKAC2He4G6vvnmG02dOlVff/21Kioq1KdPH5133nm67LLLdPTRR9duV1hYqPvvv1+9e/fW7bffXq8d7ieiF8ETEIDi4mJJOuwv4JK0Zs0aPfTQQ0pJSdGECRP0+uuv6+abb9a///1vv9uvXbtWr7/+eoPt+Vs3YsSIkF5spANPzmv+ezg1T3GKiopCWgcAtCXhuj6kpqZKkioqKhpsr+YJclP23RyBXA9KS0tVVFSkDh06hLQWAGgNuBc44LHHHtPdd9+trKwsDR8+XF6vV99//70eeughPfTQQ+rTp48GDBig8vJyzZkzR263Wx988IHftrifiF4MtQMCUNPVtCnDGa6//npVVVXpT3/6k5577jnl5eVpxowZev/99/1uP2nSJBnf/Gt1/owYMUKS/K6bPXt2yI4tUDUX4MzMzMgWAgARFK7rQ/fu3SX5nhY3NERhy5YtkqQePXoEWn5IcD0AEO24Fzhg3bp1+stf/qJt27bpww8/1EcffaQtW7bo66+/1q233ipjjGbMmKH8/HxdeumlWrp0qcaOHeu3La4f0YvgCQhAzRPcffv2Nbrdyy+/rNmzZ+u4447Tb3/7WyUlJen555+XJN122221XW6jQWFhoSQpJycnwpUAQOSE6/qQmZlZGz79+OOP9drbvHmz9uzZox49etQ+MY4El8ulsrIypaenM8wOQNTiXuCAqVOn6q677qrznW+z2TR8+HD97W9/0+rVq1VVVaWtW7fq5ZdfVt++fRtsi/uJ6EXwBASgQ4cO6tSpkzZv3tzgsIedO3fqrrvukt1u14svvii73S5JOuOMM/SrX/1K27Zt07333tuSZYfVypUrJUnHHntsZAsBgAgK5/Wh5gnxe++9V29dzbLzzjsvVIcSkJo5SLgWAIhm3Asc0NhLL5qL+4noRfAEBGj48OHyeDx+nzxLvqcYhYWFuv3223XcccfVWTd16lS1b99ezz//vBYsWBCW+qZMmSLLsvxOQBhqVVVVWrZsmbp166Yjjjgi7PsDgNYsXNeH22+/XXa7Xc8//7zmz59fu3zNmjV6+OGH5XA4/E7W2rNnT1mW1SJDMRYuXChJtUNCACBacS8QelxDohfBExCgmifP/n6R/89//qN3331XPXv21IMPPlhvfU5Ojh5//HF5vV5df/31crvdIa/P6/VKkuLi4kLe9qHmzZsnl8vV4HhtAIgl4bo+9O3bV4899piqq6s1fPhwnXPOObrgggs0cOBA7d27V1OnTtWRRx5Zr82WvB7UHDPXAwDRjnuB0Js9e7bsdrvGjBkT6VIQYgRPQIB++ctfKiMjQ2+99Vad5WVlZfrNb34jSXr22WcbfBPEVVddpZEjR2rp0qV64oknQl5ffn6+JOnKK68MeduHqjkH1113Xdj3BQCtXTivD7/73e/073//WyeddJLmzp2rmTNnasiQIZoxY4ZuvfXWem3t3btXW7ZsUZ8+fXTiiSeG6Aj9q6ys1PTp0zVgwACdcMIJYd0XAEQa9wKhtWnTJs2bN0/nnHOOOnfuHOlyEGKWMcZEugigrfrd736np556SosWLdLgwYMjXU4tr9errKwsDRkyRF988UVY91VZWanOnTurT58+YesqDABtTWu5PnzwwQe66KKL9M9//lNXXHFFWPf19ttv6/LLL9ezzz6rm266Kaz7AoDWoLV81x+qJe8FQuWRRx7R73//e33yySc6++yzI10OQozgCQjCrl27lJeXp7POOsvvZK+R8sMPP2jIkCH69ttvddJJJ4V1X08++aTuuOMOzZw5U6effnpY9wUAbUVruT7ceuutmjlzppYvXy6bLXwd3Y0xGjRokMrKyvTTTz/xRjsAMaG1fNcfqiXvBUKhsrJSvXr1Uu/evfX1119HuhyEAcETEKQHH3xQU6ZMUX5+vn7xi19EupwWVXORGDRokD755JNIlwMArUosXR+mT5+uCy+8UNOmTdMvf/nLSJcDAC0mlr7rw+Wpp57S7373Oy1YsEDHH398pMtBGBA8AQAAAAAAICyYXBwAAAAAAABhQfAEAAAAAACAsCB4AgAAAAAAQFgQPAEAAAAAACAsCJ4AAAAAAAAQFgRPAAAAAAAACAuCJwAAAAAAAIQFwRMAAAAAAADCguAJAAAAAAAAYUHwBAAAAAAAgLAgeAIAAAAAAEBYEDwBAAAAAAAgLAieAAAAAAAAEBYETwAAAAAAAAgLgicAAAAAAACExf8H209mg9W/j74AAAAASUVORK5CYII=",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {}
+ }
+ ],
+ "source": [
+ "from IPython.display import Image; Image('/tmp/cam_eval/figures/score_dist_cam_test.png')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### ROC curve (positive vs apo-negative)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "display_data",
+ "data": {
+ "image/png": "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Mo0bN06yXd++fY30fyOqqS10iSPGt+8PcBZcQwfkQsWKFbN/JF63lXhvpapVq+rbb7+94zH//vuv/d+FChVK9bEKFy6c7D5u/befn1+a8t9u48aNMsbo+PHjGdrP7c6cOaMffvhBHTt2VEJCgmw2m1599VVJN6+JuXDhgiQluWboVi+88IKKFy8uSZozZ459eYECBSTdvFbx1ufBaok32J09e3ay95P64YcfJEm9evVKch3fwoULdenSJdWuXVutWrVKdt9ubm7q2bOnJGnNmjXpyrV48WJduXIlxVydOnWSr6+vfXni8xweHn7XaxkdYcSIEcqXL5/Onj2b6nuzZeR5u3Dhgv161mHDhsnT0/OOxzZt2lSNGjVK41fyfxKvhfr9998z5fnLrq83STLG2K/R6tu3b5J19913n+rXr6/4+PhU3Vtw5MiRcnG58+3WgAED7NcNz50717780qVL9szDhg2Tt7f3HY+tXbu2unTpIunmtV9pcebMGfu//f39k93GkT+fpZvXuxljFBAQoLJlyyZZl3hd4rRp01K9vy5duih//vz65ZdfdOTIEUnSlStXtGDBArm4uNj3mVqJv6dufW4AZ0KhA3Khc+fO2T9unWigb9++2rlzp0qWLGlhOuuEhIQkmdyhZMmS6tu3r8LCwuTu7q4vv/zSPrnD9u3bJUmlS5dW5cqVk92fq6urAgMDk2wvSRUrVlTVqlUVGxurRx55ROPGjdOuXbsyvXTcS58+fWSz2XTy5EmFhIQkWffnn3/qr7/+knTnG9zNmzdLkv76668kfyy4/eODDz6QdHMijbTo3LmzfH19FRMTo4ULFyZZd+7cOa1duzbZXM2aNZOXl5d27typRo0aaerUqTp27Fiajp1ahQoV0ptvvilJ+s9//mMv+3eTkedt586d9hJ0t8kqbp2MJDnnzp3Te++9p8cee0yFChWSm5ub/fVfrVo1STcnT7l48eI9v560yq6vN+nmJDvHjx9X3rx51bVr1zvWJxaGlCbmSOTm5pZiqXZxcbnj54kk7dixw/69bd68eYr7btGihSQpNDT0rhO03C48PNz+b0eUtXtJSEiwF9/bv5fSzRt8e3l56eDBg/rll19Stc88efKoR48eSYr3vHnzdPXqVbVs2TLNv8MSn4dbnxvAmVDogFzI3DzdWgkJCTpz5oz++9//qkCBAvr+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eFgdB1mAEToAAADAiSWOykmSzWbT008/TZnLRSh0AAAAgJOaNWuWWrRooW7duunGjRtWx4EFKHQAAACAE5o2bZr69u2rhIQEFS1aVK6urlZHggUodAAAAICTmTRpkp5++mkZY/T888/ru+++o9DlUhQ6AAAAwIl88cUXeuGFFyRJr776qr755htuGp6L8Z0HAAAAnMTEiRP16quvSpLefvttTZgwQTabzdpQsBS3LQAAAACcRL169eTj46PXX39d7733HmUOFDoAAADAWdSvX1/79+9X6dKlrY6CbIJTLgEAAIBsyhij9957Tzt27LAvo8zhVhQ6AAAAIBsyxuiVV17RBx98oFatWunixYtWR0I2xCmXAAAAQDaTkJBgvx2BzWbThx9+qIIFC1odC9kQhQ4AAADIRuLj4/XMM89oxowZstlsmjZtmvr37291LGRTFDoAAAAgm4iLi1O/fv00Z84cubq66vvvv9dTTz1ldSxkYxQ6AAAAIJv4z3/+ozlz5sjNzU1z585V165drY6EbI5JUQAAAIBs4pVXXlGLFi20cOFCyhxShRE6AAAAwEKxsbFyc3OTzWZT3rx5tWbNGm4YjlRjhA4AAACwSHR0tNq2bav333/fvowyh7Sg0AEAAAAWuHLlitq2baugoCB99tlnOnXqlNWR4IQ45RIAAADIYpcvX1abNm30+++/K1++fFq9erVKly5tdSw4IQodAAAAkIUuXLigVq1aafv27SpQoIDWrl2runXrWh0LTopCBwAAAGSRiIgItWjRQrt27VKhQoUUFBSkWrVqWR0LToxCBwAAAGSRDRs2aNeuXSpSpIjWr1+vBx54wOpIcHIUOgAAACCLPPnkk7py5YoaNGigqlWrWh0HOQCFDgAAAMhEp06dkpeXl/z9/SVJTz/9tMWJkJNw2wIAAAAgkxw7dkyNGzdWixYtdOHCBavjIAei0AEAAACZ4NChQ2rcuLGOHz+uq1ev6urVq1ZHQg5EoQMAAAAc7K+//lJAQID++ecf3X///QoJCVGpUqWsjoUciEIHAAAAONCePXvUpEkTnT17Vg8++KA2btyo4sWLWx0LORSFDgAAAHCQXbt2qWnTpjp//rxq166t4OBgFSlSxOpYyMEodAAAAICD5M+fX3ny5FG9evW0fv16FSpUyOpIyOG4bQEAAADgIOXLl1dISIgKFy6s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xRftMwbbbDbKHAAAmSBTbltwN5UqVdLq1auz+rAp2r59u7788kv17dtXTZo0sToOADi9EydOKDAwUEePHpUxRt9++63VkQAAyLGytNAlJCQoNDRULi4ZGxj08fGRJF29ejXZ9dHR0ZIkX1/fu+4nLi5Ozz77rAoUKKBPP/00Q5kSpTQ7TeLsNQCQkx09elRNmzbVyZMnVaFCBU5jBwAgk2VJobt69aoOHjyojz/+WIcOHVK7du0ytL8yZcpIUooTlyQuL1u27F33888//2jXrl0qVqyYunXrlmTdpUuXJEl//vmnfeRu48aN6Q8NADncwYMHFRgYqNOnT6ty5cpav359itc6AwAAx3BYoXN1db3nNsYY+fv7a/z48Rk6VuIMaTt27Eh2feLyGjVqpGp/YWFhCgsLS3bdpUuXFBISko6UAJB77N+/X82aNVNYWJiqVaum9evXq1ixYlbHAgAgx3NYoStdurRsNluy6zw8PFS8eHEFBAToxRdfVJHb72ydRg0aNFD+/Pl15MgR7dq1S7Vq1UqyfsGCBZJ0z9sjlCtXTindV33jxo0OvbE4AORUsbGx6tChg8LCwlSjRg0FBQXJ39/f6lgAAOQKDit0x48fd9Su7snDw0MvvfSSPvzwQ7344otau3atfWbLCRMmKDQ0VAEBAUluXPv111/r66+/VufOnfXxxx9nWVYAyOnc3d01Y8YMjRgxQj///LMKFSpkdSQAAHINhxW6pUuXyt3dXW3atHHULu9q5MiRCgoK0m+//aZKlSqpUaNGOnHihLZu3Sp/f39NmzYtyfYRERE6cOCAzp49myX5ACCnu3Hjhv1WLw0bNtTGjRtTPFMDAABkDofdh65z58768ssvHbW7e/Ly8lJwcLDeffddeXt7a/HixTpx4oT69++vHTt2qEKFClmWBQBym19//VVVqlTR7t277csocwAAZD2bSekisjQqVqyYAgMDNWfOHEfsLsdJvG1BSrc1yCrh4dLtlzCePy9xuQuA1Nq4caPatWun6OhodevWTT/99JPVkQAAcFoZ7QkOG6Fr0qSJtm3bluIkIwAA57d27Vq1adNG0dHRatmypWbMmGF1JAAAcjWHFboxY8YoIiJCr732mq5du+ao3QIAsokVK1aoffv2unbtmh5//HEtWbJE3t7eVscCACBXc9ikKD/++KPatm2rr776SnPnzlXz5s1VpkwZeXl53bGtzWbTu+++66hDAwAy2aJFi9S9e3fFxsaqc+fOmjt3rn1CFAAAYJ10X0NXoUIFdevWTePGjZMkubi4yGazpeqUS5vNpvj4+PQc1mlxDR0AZ2WMUatWrbRu3Tp1795dP/zwg9zd3a2OBQBAjpDRnpDuEbrjx48rPDzc/vn06dPTuysAQDZms9n0888/66uvvtKwYcPk5uawkzsAAEAGOey3cr9+/Ry1KwBANrB9+3Y99NBDstls8vHx0fDhw62OBAAAbuOwSVEAADnHt99+q7p162rMmDFWRwEAAHdBoQMAJDFx4kS9+OKLkqRLly5xOxoAALKxDJ1yuWvXLn3wwQfpeuyoUaMycmgAQCYYN26c3n77bUnS22+/rY8++kg2m83iVAAAICUZKnS7d+/W7t270/QYY4xsNhuFDgCyEWOMxowZo/fee0+SNHr0aI0aNYoyBwBANpehQlexYkU1aNDAUVkAABYZNWqUxo4dK0n66KOPmAAFAAAnkaFC17BhQ02bNs1RWQAAFilRooQk6bPPPtPQoUMtTgMAAFKLmwkBAPT888+rQYMGqlGjhtVRAABAGjDLJQDkQgkJCfrwww/177//2pdR5gAAcD4UOgDIZeLj4zVw4ECNHDlSrVu3Vnx8vNWRAABAOnHKJQDkInFxcerXr5/mzJkjV1dXDR06VK6urlbHAgAA6ZTuQpeQkODIHACATHbjxg099dRTWrhwodzc3DR37lx17drV6lgAACADGKEDgFzg+vXr6tatm5YtWyYPDw8tWLBA7du3tzoWAADIIAodAOQCL730kpYtWyYvLy8tWrRIrVu3tjoSAABwACZFAYBc4O2331alSpW0fPlyyhwAADkII3QAkEMZY2Sz2SRJFStW1P79++Xmxo99AAByEkboACAHunz5spo2barly5fbl1HmAADIefjtDgA5zIULF9SqVStt375dBw4c0JEjR+Tt7W11LAAAkAkodACQg4SHh6tly5batWuXChcurFWrVlHmAADIwSh0AJBDhIWFqXnz5tq3b5+KFi2qoKAgPfDAA1bHAgAAmYhCBwA5wOnTp9WsWTMdOHBAJUqU0IYNG1SlShWrYwEAgEzGpCgAkANMmjRJBw4cUOnSpRUSEkKZAwAgl2CEDgBygPfff1+xsbF6/vnnVa5cOavjAACALEKhAwAnderUKRUvXlxubm5ydXXVuHHjrI4EAACyGKdcAoAT+uuvv1SvXj31799f8fHxVscBAAAWodABgJMJDQ1VQECAwsLCFBoaqsjISKsjAQAAi1DoAMCJ7NixQ02bNlV4eLjq1Kmj4OBgFSxY0OpYAADAIhQ6AHAS27ZtU7NmzXThwgXVq1dP69evV6FChayOBQAALEShAwAnsHnzZjVv3lyXLl1SgwYNtG7dOhUoUMDqWAAAwGIUOgBwAlevXtWNGzfUtGlTrV69Wvny5bM6EgAAyAa4bQEAOIEWLVpo/fr1ql27try9va2OAwAAsglG6AAgm1q9erX+/vtv++cNGjSgzAEAgCQodACQDS1evFgdOnRQs2bNdOrUKavjAACAbIpCBwDZzE8//aQnnnhCsbGxatiwoYoVK2Z1JAAAkE1R6AAgG5k1a5Z69uyp+Ph49e7dW7Nnz5a7u7vVsQAAQDZFoQOAbGLatGnq27evEhISNHDgQM2YMUNubsxdBQAAUkahA4BsYMGCBXr66adljNHzzz+v//3vf3J1dbU6FgAAyOb40y8AZAPNmzdXnTp11KhRI33++eey2WxWRwIAAE6AQgcA2UCBAgUUEhKivHnzUuYAAECqccolAFhk7Nixmjhxov1zHx8fyhwAAEgTRugAIIsZY/Tuu+/qww8/lCQ1btxYderUsTgVAABwRhQ6AMhCxhi9+eab+vTTTyVJn376KWUOAACkG4UOALKIMUavvvqqvvzyS0nSl19+qSFDhlicCgAAODMKHQBkgYSEBL3wwguaPHmybDab/vvf/+q5556zOhYAAHByFDoAyAJr1qyxl7lp06apf//+VkcCAAA5AIUOALJAmzZtNHbsWJUvX15PPfWU1XEAAEAOQaEDgEwSGxura9euydfXV5I0YsQIixMBAICchvvQAUAmuH79urp166Y2bdooKirK6jgAACCHotABgIPFxMSoc+fOWrJkibZv367du3dbHQkAAORQnHIJAA509epVdezYUUFBQcqTJ4+WLVumBg0aWB0LAADkUBQ6AHCQqKgotWvXTiEhIcqbN69WrFihgIAAq2MBAIAcjEIHAA5w+fJltW3bVr/99pvy5cunVatWqX79+lbHAgAAORyFDgAcICwsTAcPHlSBAgW0du1a1a1b1+pIAAAgF6DQAYADVKlSRUFBQUpISFDt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oJs2bZp27dqlFi1a6OjRo1qyZIkiIiI0Y8YMxcbGauTIkVkea+zYsTp+/LhCQ0N15MgRLVmyRD///LNGjx6to0ePaty4cXm4J9ZLSUnRE088ofvuu08bN260uhwAAAAAWeSUgS4pKUnvvvuuJGnu3Lny9fW1940bN04NGjTQ1q1btXfv3ruOFRMTo88//1weHh7697//LTe3/91W+Pbbb6t06dJauHChzp8/7/gdKRBuavToRzR//nwlJycrNjbW6oIAAAAKvN27d8tms8lms+nvf//7HZetUqWKbDabwsPDb7vMggULZLPZFBIScttlDh06pNGjR6tevXry9/eXp6enKlasqF69eunTTz8tUO8M3Lt3r958802FhoYqMDDQ/lnlRk5ukUpJSdGsWbNUv359eXt7q3Tp0ho4cKAOHz58x22tWrVK7dq1U/HixVW8eHGFhIRozZo1uao/rzhloNu+fbsuX76s4OBgNW7cOEN///79Jf3xjbib9evXKzU1VW3atFHZsmXT9Xl6eqpnz55KSUnR2rVrHVN8gZIsaYiWL18oNzc3ffHFFxoyZIjVRQEAABR4n332mf3PixYtytNtGWM0adIkNWjQQO+++66uXr2q9u3bKzQ0VFWrVtX69es1YsQI/eUvf8nTOrLjtdde00svvaQVK1bk6PkWf5aTW6RSU1M1YMAAjRs3TqdPn1aPHj1Ut25dLV++XM2aNdPu3bsz3da//vUv9erVSzt27FCrVq3UoUMH7d69Ww8++KD9pFJB4pSB7scff5QkNWnSJNP+tPZb7wnLj7Gcy++SBkpaJnd3dy1fvlwDBgywuigAAIACLzk5WV988YUkqVy5cjp69KgiIiLybHsTJ07UtGnTVKpUKa1Zs0ZRUVFauXKlPv/8c3333Xc6d+6cJk6cqF9//TXPasiuFi1aaNKkSfr6668VExMjT0/PXI2Xk1uk5s+frxUrVqhGjRr65ZdftHz5coWHh2vZsmW6fv26hg4dqps3b6Zb58iRI3rhhRfk6empbdu2ad26dVq5cqX279+vgIAAPffcczp+/Hiu9sXRnDLQRUdHS5ICAwMz7U9rj4qKytexnMfvkkIlrZTkqU8+WanevXtbWxIAAICTWL9+vS5cuKBWrVrp6aeflpT+jJ0j7d69W2+99Za8vb0VFham7t27Z1imZMmSev311xUW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",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {}
+ }
+ ],
+ "source": [
+ "Image('/tmp/cam_eval/figures/roc_cam_test.png')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "### Best-of-K success rate\n",
+ "\n",
+ "If you sample K binders and pick the highest-Q_\u03b8 one, what fraction reach the success threshold (DockQ-label > 0.7)?"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "display_data",
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABJ4AAALYCAYAAAA9wjihAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAXEgAAFxIBZ5/SUgAAqOhJREFUeJzs3Xd4FNX+x/HPplcIEEIVQhWkF6lCkN6VJlKU5rWBei/KFUSRa8MKFkSRLoiAdASp0pv0AAoCQjAQDKEEQoCQzfz+2F9WYirsLJvyfj3PPjfMnDnnu8ns3OTjmTMWwzAMAQAAAAAAACZzc3UBAAAAAAAAyJ0IngAAAAAAAOAUBE8AAAAAAABwCoInAAAAAAAAOAXBEwAAAAAAAJyC4AkAAAAAAABOQfAEAAAAAAAApyB4AgAAAAAAgFMQPAEAAAAAAMApCJ4AAAAAAADgFARPAAAAAAAAcAqCJwAAAAAAADgFwRMAAAAAAACcguAJAAAAAAAATkHwBAAAYKIFCxaoTZs2Kly4sDw8PGSxWGSxWHTq1ClXl5Znbdiwwf5zmD59uqvLSVP//v3tNQIAkJsQPAEAkMucOnXK/gdsWi9PT08VLlxYTZo00f/+9z+dOXPG1SXnGqNHj1b37t21evVqxcTEyGq13lU/zZo1y3JgFRMTo7p169rbN23aVLGxsXc17u0iIiLk7u5u7/fVV191uE8AAJD3EDwBAJDHJCYmKiYmRlu2bNHo0aNVqVIlff/9964uK0M5YcbKmTNn9O6770qSypcvrzlz5mjv3r06ePCgDh48qBIlSpg+ZmRkpJo0aaI9e/ZIkjp06KBVq1Ypf/78Dvc9bdo0JSUl2f/97bffKjEx0eF+AQBA3uLh6gIAAIDz1K1bV9OmTUux7ebNmzp16pTmzp2rH374QXFxcerbt69KlSqlxo0bu6jSnG/dunX2YGbs2LHq1KmTU8c7duyYWrZsqdOnT0uSevfurRkzZsjDw/Ff75KSkuwBX2BgoK5evapz585pxYoV6ty5s8P9I7Xp06dn21AVAABHMOMJAIBczN/fX1WrVk3xqlOnjrp166Z58+bpnXfekWQLGsaMGePianO2229ZvP/++5061v79+/XQQw/ZQ6fnn39es2bNMiV0kmwhWkREhCTpww8/VFBQkCRpypQppvQPAADyDoInAADysKFDh8rLy0uStH37dhdXk7PdvHnT/nXy99QZtmzZombNmik6OlqSNHLkSH355ZemLkqdHDD5+/urb9++6tmzpyRpxYoV+uuvv0wbBwAA5H4ETwAA5GG+vr4KDg6WJN24cSPT9pGRkRo5cqTq1aun4OBgeXl5qUiRImrVqpUmTJiQInxJy8WLF/Xuu+/qoYceUnBwsDw9PZU/f36VLVtWjRo10ogRI7Rp0yZ7++SF0h9++GH7tgEDBqRaMD00NPTuvgG3uXTpkt599101atRIhQsXtr+3Fi1a6PPPP9f169fTPC65hv/973/2bWXKlElR3+jRox2uT7IFP61bt1ZsbKwsFovGjh1rn7VmlosXL2rx4sWSpO7duysgIEADBgyQZFsfbMaMGZn2kbw4evLPJT4+Xh9++KHq1q2roKAg+fn56YEHHtCIESN08eLFDPuKiYnR5MmT1adPH1WtWlX58uWTp6enChUqpHr16mn48OH6888/7+q9GoahihUrymKxqHjx4llaw2rEiBH2n+uWLVtS7V+zZo369OmjChUqyN/fX15eXipatKiqVKmixx57TJMmTVJMTEyq47LyVLs///xTr732murVq6cCBQrI09NTBQoUUPny5dWsWTO99dZb2rt37519EwAAcDYDAADkKidPnjQkGZKMsLCwDNtev37d8PLyMiQZlStXzrDtp59+anh7e9v7TutVoUIF47fffkvz+B07dhiFChXK8HhJRokSJdJ8Lxm9SpcufaffphRWrVplFChQIMMxSpUqZezfvz/VsVmp780337yjesLCwuzHnjx50jAMw/j+++8NT09PQ5Lh7u5uTJs2zaH3nJ7PP//cPvb69evt2ytXrmxIMu6///4s11+6dGnj+PHjRqVKldL93oSGhhqnTp1Kt6/8+fNn+v319fU1vv/++3T7WL9+vb3tP79vH3/8sX3fokWLMnxfCQkJRpEiRQxJxgMPPJBin9VqNZ588sksnQ+TJk1K1Xe/fv3s+9OydOlSw8/PL9O+GzdunOF7AADgXmNxcQAA8rBPP/1UCQkJkqSuXbum2+7tt9/WqFGjJNlm8zz//POqXLmyihUrpujoaC1fvlwTJ060L3i9Z88eFSlSxH58QkKCevTooQsXLsjNzU39+/dXx44dVaxYMXl5een8+fM6ePCg1q5dqyNHjtiPK1GihA4ePKhdu3Zp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trJlyyoiIkK///67ypUrd0fHHj9+XBUrVlSZMmV04sSJu67h008/1X/+8x/16NFD8+bNS7V/+fLl6tixo7p06aKFCxdm2NegQYM0depUvf3223r99ddT7X/77bc1atQoDR8+XGPGjLnrmpNvwTt8B9Mls9t91zExMZKk4MBA7ruWuO86Gfdd27A2w99Ym8GGa4QN1wgbrhF/yyHXCPvvPYULc41IxjXChmuETR6/Rtjxe8TfuEbY5IJrRJXatSWLJd2JNZkOf1dH3aZVq1aaNGmSBg4cqGXLlilfvnxZOu7q1asaNGiQLBaLWrVq5VANNWrUkKR0b9lL3l69evVM+4qMjJRkW2Q8LcnbL126dMd1pimtC8K9PN7dPev/FSM9//wg3ik3N8ffh6OLvVssjtfg6Zn64nynHK0hrQvKnXL052nGOeXoz9OMc8rRn6cZ55QZP0+uETZcI2y4Rthwjfgb1wibrJxTyWOkVS/XiL9xjbDhGmGTl64RGeEa8TeuETY57RrhwMLikglrPL366qvy8/PTli1bVLlyZb377rvas2ePbqaRvt28eVN79uzRu+++q8qVK2vLli3y8/PTq6++6lANjRs3Vv78+XXixAnt378/1f758+dLkjp16pRpX0WLFpUk7d69O839u3btkiSFhobeXbEAAAAAAAB5hMPBU9myZTVv3jz5+fkpKipKo0aNUr169eTn56fg4GCVKlVKpUqVUnBwsPz8/FSvXj2NGjVKZ8+elZ+fn+bNm6cyZco4VIOXl5eGDBkiSRo8eLB9TSdJGjt2rMLDwxUWFqY6derYt48fP16VKlXSiBEjUvSV/HS97777Tj/++GOKfUuWLNHs2bPl5uamLv9YXR8AAAAAAAApOXyrnSS1b99eBw8e1BtvvKGFCxfq+vXrkqSLFy/q4sWLqdr7+vqqW7dueuutt0ybOfT6669r7dq12rZtmypUqKAmTZooIiJCO3fuVOHChTV16tQU7WNiYnT06FFFRUWl2P7oo4+qR48e+uGHH9SpUyfVrVtXZcqU0cmTJ+2zoN59913df//9ptQNAAAAAACQW5kSPEm2W89mzpypiRMnasuWLTp06JDOnj1rf2JdYGCgihcvrqpVq+qhhx66q6fgZcTHx0fr16/XmDFjNHv2bC1evFgFCxZU//799fbbb6tkyZJZ6sdisWju3Llq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BE9EREREREREVE/Vi8TT5cuXAQCdOnXSWq8uj4+Pr9axiIiIiIiIiIheZEY1HUBVuHXrFgDA2dlZa726PDk5uVrHAv5vSdqzrl27BiMjI7Ru3bpc49RGKpUKAGBoaFjDkRARERHpF+97iIjoRZWYmAhjY+Pn7l8vEk9ZWVkAADMzM631CoUCAJCZmVmtY5VGJpPB2NhYLzcvhYWFePToEWxtbWFgoL9FbUlJSQCAli1b6u0YRFWpuj4bVH58T3Sr73NTF8+vNsdcG2KriRiq85i876G6pDb8TiBNfE9KV9/npy6eX/GYjY2NpVzI86gXiafarCY2D09KSkLz5s0RHR2NZs2a6e049WGDdHqxVNdng8qP74lu9X1u6uL51eaYa0NsNRFDdR6T9z1Ul9SG3wmkie9J6er7/NTF86vKmOtGqq0M5ubmAICcnByt9dnZ2QAACwuLah2LiIiIiIiIiOhFVi8ST02bNgUA3LlzR2u9utzFxaVaxyIiIiIiIiIiepHVi8STh4cHACA2NlZrvbq8Q4cO1TpWTbGyssKiRYtgZWVV06EQ1Sr8bNQ+fE90q+9zUxfPrzbHXBtiq4kYasN5E9VG/GzUPnxPSlff56cunl9VxiwTQojKh1SzCgoK4ODggPT0dFy6dAkdO3bUqPfw8EB8fDxiYmLg5eVV6lj379+Hs7MzjIyMcPv2bTg4OEh1+fn5aNKkCR4/fox79+5p1L2IuNcBERERvSh430NERPR86sWKJ2NjYwQEBAAAZs6cKe3DBAAhISGIj49Hr169NJJOGzZsQOvWrfHRRx9pjOXk5ISxY8eioKAAM2bMgFKplOrmzp2LlJQUTJgw4YVPOhERERERERERlaVerHgCgLy8PPj6+iIqKgpOTk7w8fFBcnIyoqKiYG9vj8jISLRo0UJqv3jxYgQFBcHf3x87d+7UGCs1NRVdu3bFzZs30bJlS3Tu3BlXr17FlStX4ObmhsjISNjY2FTzGRIRERERERER1S31YsUTAMjlckRERGDBggUwMzPDTz/9hOTkZEyaNAmxsbEaSaey2NnZITo6GrNmzUJBQQH279+P9PR0zJ49G9HR0Uw6ERERERERERGVQ71Z8URERERERERERLVLvVnxREREREREREREtQsTT0REREREREREpBdMPBERERERERERkV4w8URERERERERERHrBxBMREREREREREekFE09ULeLi4uDj4wNTU1M0b94cGzZsqOmQiIiIiKpcTEwM3nrrLbi6ukImk2H+/Pk1HRIREVGNYuKJ9C4lJQX9+/dHw4YN8euvv2LGjBkIDAzE7t27azo0IiIioip17tw5REZGomfPnrC0tKzpcIiIiGqcTAghajoIqt+Cg4Oxfv16JCUlwczMDAAwY8YMHDt2DAkJCTUcHREREVHVKSwshIFB0f/bbdasGSZMmIClS5fWcFREREQ1hyueSO8OHz6M1157TUo6AcCoUaNw48YN/P333zUYGREREVHVUiediIiIqAj/y/iCu3jxIpYvX47hw4fD2dkZMpkMMpmszH65ublYuHAh3N3dIZfL0ahRI0yePBl3794t0TYhIQGtW7fWKFO/vn79etWcCBEREVEZquO+h4iIiDQZ1XQAVLOCg4Px888/V6hPXl4e+vTpg8jISDg5OWHIkCFISkrCjh078OuvvyIyMhItWrSQ2j958gRWVlYaY1hbW0t1RERERNWhOu57iIiISBNXPL3gunXrhgULFuDAgQO4f/8+TExMyuyzdOlSREZGolu3bkhISMDevXsRFRWF1atXIyUlBZMnT66GyImIiIgqhvc9RERE1Y+bi5MGuVyO/Px86LosCgoK4ODggPT0dMTGxsLT01Oj3sPDA/Hx8YiJiYGXlxcAwMHBAXPmzMG8efOkdg8ePICTkxPCwsLw6quv6u+EiIiIiHTQx31PcdxcnIiIiCueqILOnTuH9PR0tGzZssTNFwCMHDkSAPDLL79IZe7u7rh27ZpGO/XrVq1a6TFaIiIiouf3PPc9REREpImJJ6qQy5cvAwA6deqktV5dHh8fL5UNHDgQYWFhyM3NlcpCQ0Ph5ubGPRGIiIio1nqe+x4iIiLSxM3FqUJu3boFAHB2dtZary5PTk6WyqZPn45169Zh9OjRCAwMxKVLl7BlyxZs375d/wETERERPafnue9JSUnBqVOnAAA5OTm4du0aQkNDoVAouL0AERG9kJh4ogrJysoCAJiZmWmtVygUAIDMzEypzN7eHkePHkVAQAD8/Pzg6OiIkJAQTJw4Uf8BExERET2n57nvuXr1KkaNGiW9/vHHH/Hjjz/CxcUFSUlJ+guWiIiolmLiiapFx44dcfbs2ZoOg4iIiEivfH19dW5WTkRE9CLiHk9UIebm5gCKlo5rk52dDQCwsLCotpiIiIiI9IH3PURERJXHxBNVSNOmTQEAd+7c0VqvLndxcam2mIiIiIj0gfc9RERElcfEE1WIh4cHACA2NlZrvbq8Q4cO1RYTERERkT7wvoeIiKjymHiiCunRowcsLS1x8+ZNxMXFlagPDQ0FALz++uvVHBkRERFR1eJ9DxERUeUx8UQVYmxsjICAAADAzJkzpb0NACAkJATx8fHo1asXvLy8aipEIiIioirB+x4iIqLKkwl+7cYL7eDBgwgODpZeR0dHQwiBLl26SGULFiyAn5+f9DovLw++vr6IioqCk5MTfHx8kJycjKioKNjb2yMyMhItWrSo1vMgIiIiKgvve4iIiKqfUU0HQDUrJSUFUVFRJcqLl6WkpGjUyeVyRERE4NNPP8U333yDn376CTY2Npg0aRKCg4Ph7Oys97iJiIiIKor3PURERNWPK56IiIiIiIiIiEgvuMcTERERERERERHpBRNPRERERERERESkF0w8ERERERERERGRXjDxREREREREREREesHEExERERERERER6QUTT0REREREREREpBdMPBERERERERERkV4w8URERERERERERHrBxBMREREREREREekFE09ERERERERERKQXTDwREREREREREZFeMPFERERERERERER6wcQTERERERERERHpBRNPRERE9URqaiqsra1hb2+PrKysmg6nWu3cuRMymQzNmjWr6VBq3IEDB9CnTx9YW1vDwMAAMpkMgYGBNR1WjZo0aRJkMhkmTZpUos7X1xcymQyLFy9+rrEr27+uWb58OWQyGRYsWFDToRARUR3BxBMREZEeLV68GDKZDDKZrNR2O3bsgJGREWQyGV555RWkp6dX+FhBQUFIS0vD3LlzYW5u/rwhUx32448/YsiQIYiIiEBmZibs7Ozg6OiIhg0b1nRoVEE7d+7E4sWLcfLkyZoORUNAQADs7OwQEhKCu3fv1nQ4RERUBzDxREREVMPWrFmDKVOmQKVSYfDgwTh8+DAsLS0rNEZCQgI2b94Me3t7zJw5U0+RUm332WefAQBGjBiBjIwMPHz4EA8ePMCSJUtqOLLaq2nTpmjVqhXs7OxqOhQNO3fuRFBQUK1LPJmbm2POnDnIycnhqiciIioXJp6IiIhq0IIFC/D+++9DCIHx48dj//79MDU1rfA4ISEhUCqV8Pf3h5mZmR4ipbrg999/B1D0aBmvg/LZtWsXrl27hoCAgJoOpc6YOnUqjIyMsHv3bty/f7+mwyEiolqOiSciIqIaIIRAQEAAli5dCqDo8ZXdu3fDyMiowmNlZmZiz549AIAJEyZUaZxUt+Tk5AAAH7UkvbKzs8PAgQOhVCqxffv2mg6HiIhqOSaeiIiIqplSqcTEiROxceNGAMDChQuxfv36MveB0uW7775DVlYW2rZtCw8PD61t1HtN+fr6AgCOHz8OPz8/2NvbQy6Xo02bNggKCkJeXp7W/uXZQPnZY+jqr1QqsWbNGnh6esLc3BwODg4YOnQoLl++LLXPycnB0qVL0b59eygUCtja2mLMmDG4efNmuebk6NGjePXVV2Fvbw9TU1O0a9cOS5cu1Xl+apmZmVi+fDm6desGGxsbmJiYoEmTJnjzzTdx/vx5rX2SkpKkfbySkpJw8+ZNTJs2Dc2bN4eJiclzbXh+8uRJjBo1Co0bN4aJiQns7OzQt29f7NixAyqVSufx1Xr37i2VPc919ejRIyxZsgRdunSBjY0N5HI5mjVrhgEDBmDTpk0l9iB78OAB1q9fjyFDhqBNmzawtLSEqakpXF1dMXXqVFy9elXnsZ7d+Ds0NBS+vr6wsbGBmZkZOnbsiLVr16KwsLDUmPfs2YMePXrAwsIClpaW6NKlC7Zu3QohRKn9yrq2VSoV1q9fj06dOkGhUMDGxga+vr4IDQ0tdVwASExMxIoVKzBo0CC4u7tDoVDA3Nwcbdu2RWBgIG7dulWij3qj/FOnTgEo2rut+Hupvs6ede7cOUyYMAEuLi6Qy+WwtLSEt7c3VqxYUeqXDRw+fBjDhw+Hs7MzjI2N0bBhQ7Ro0QIDBgzAqlWr8PjxY639xo0bBwD44osvypwHIiJ6wQkiIiLSm0WLFgkAQv2f3NzcXDF48GABQMhkMrF27dpKH2P48OECgPj3v/9dZhy9evUSK1euFDKZTMhkMmFlZSVkMpkUY+/evYVSqSzRv1evXgKAWLRoUbmOoav/f/7zH9G3b18BQBgbGwuFQiEd29zcXFy4cEGkpqYKT09PAUDI5XJhamoqtXFwcBDJycklxt+xY4cAIFxcXMTGjRulc7KyshJGRkZSf09PT/H48WOt8V+6dEk4OztLbQ0NDYWFhYX0WiaTiWXLlpXol5iYKLXZs2ePMDc3FwCEmZmZUCgUwsXFReecafPee+9pHNPKykoYGhpKZX369BEZGRlS+1u3bglHR0fh6OgotbG2tpbKHB0dK3T8w4cPC2tra2ksIyMjYWtrKxo0aCCV7d+/X6OPv7+/RnsbGxuNeTcxMRGhoaFaj6fu6+/vL2bOnCkACAMDA2FlZSX1ByDeeustrf0LCwvF22+/rTFn1tbWwsDAQAAQb775psYxnlXatZ2XlycGDhwoja2OS319zZs3r9T+6jr19W5rayvFBUBYWlqKM2fOaPT57rvvhKOjozTfCoVC4710dHQUt27dktqrVCoxe/ZsjbkyNzfXuGZatWolkpKSSsQXFBSk0c/MzEy6ftX/IiIitM777du3pTZ//PGH1jZERERCCMHEExERkR4VTzylp6dLf4gaGRmJXbt2Vckx7O3tBQCxbdu2MuOwsrISBgYG4qOPPhIpKSlCCCHS09PFwoULpTi1jVNViScrKytha2srfvjhB1FQUCAKCwtFdHS0aNGihQAgunfvLoYNGyaaNWsmDh8+LFQqlVCpVOLYsWPSeY4fP77E+OrEk5mZmWjQoIEYNWqU9Md5Tk6O2LRpkzAxMREAxLBhw0r0v3fvnnBwcBAAxPDhw0VMTIwoKCgQQgjxzz//iAULFkiJlGeTLsUTT+bm5qJLly7iwoULUv3169d1ztmz1q9fL401bdo0cf/+fSGEEFlZWWLNmjVSDGPGjNHav6xkQVliY2OFXC4XAES7du1EWFiYNA9KpVLExMSIOXPmiGPHjmn0Cw4OFp999pn4/fffxdOnT4UQRQmRK1euiPHjx0sJlLt375Y4pjopZG1tLYyNjUVISIhIT08XQgiRmpoqpk6dKp3X8ePHS/Rfu3atVB8QECBd12lpaWLx4sVS8u55Ek/qJKBMJhNLly6V4vrnn3/EO++8IyWPdPV/9913xcaNG0VCQoJQqVRCCCGePn0qoqKixKBBgwQA0ahRI5GTk1OhuIqbP3++lJTduHGjePTokRBCiIKCAhERESElcTt16iTFIIQQSUlJUhLs/fff13hv0tLSxJkzZ8SMGTNETEyMzmM3atRIABCbNm0qNUYiInqxMfFERESkR8UTT506dZJW8Rw4cKBKxr9586Y0fml/IBaPQ9cfsuqVU/369StRV1WJJwAlVngIIcTx48elelNTU3Hjxo0SbbZt2ybVq5MhaurEk/r4xf/AVvvyyy+lNtHR0Rp1kydPFgDEuHHjdJ5fSEiIACA8PDw0yosnnlxcXERmZqbOMUqTk5MjbGxsBAAxduxYrW3WrVtX6vtd2cRTz549BQDh5uYm0tLSnmsMbfz8/AQAERwcXKKu+GqpHTt2aO3v5eUlAIipU6dqlOfm5kpzNnHiRK19P/zwQ2n8iiSe7t69KyX6FixYoHXssWPHlvm50kWpVIoOHToIAGL37t3ljqu4xMREYWhoKExNTUVcXJzWNhkZGdJKvuJJ07179woAwt3dvUJxF6d+X3WtRiMiIhJCCO7xREREVE1iY2MBAFOmTMHrr79eJWPeu3dP+tne3r7M9iYmJvjf//1frXVDhgwBAMTHx1dJbNr07NkTPXv2LFHeq1cvmJiYAABGjhwJV1fXEm0GDhwIAMjNzcWNGzd0HmP+/PkwMCh5i/P222/D2dkZQNG+WGp5eXn45ptvAADz5s3TOe5bb70FALh8+TL++ecfrW0CAgKee2Pvo0ePSvvp6NpvaMaMGXBycgIAKeaqcuPGDZw9exYAsGzZMlhaWlbZ2H5+fgAgja9NkyZN4O/vr7XujTfeAFDy2jxy5Ig0ZwsXLtTa98MPP4RcLq9wzKGhoVAqlTA1NdX5mSltz7OyGBoaYtCgQQBKn5fS7Ny5EyqVCoMGDdK5v5uFhQWGDh0KoGg/JzUrKysARfuaZWdnP9fx7ezsAGj+HiIiInpWxb86h4iIiJ5L9+7d8dtvv2Hjxo1wd3fH7NmzKz1mSkqK9LONjU2Z7du1a6czMdKoUSMA0LmZcFXw9vbWWm5oaAg7OzvcvXsXL7/8stY2jo6O0s9PnjzR2sbIyAg+Pj5a6wwMDODr64uvv/4aMTExUvnFixelTccHDBhQrvNITk7WiEetR48e5eqvjTqmJk2awN3dXWsbQ0ND9OnTB3v27NE4h6rw22+/Scd49dVXK9z/8uXL2LJlC86ePYukpCRkZWWV2Nj7zp07Ovu//PLLOjdC13VtFp8zbclKALC0tISXlxfOnTtX7nMpPnbnzp3RsGFDrW3c3d3RuHFj3L17V+c4Z86cwbZt2xAZGYk7d+5oTfKUNi+lUZ/TkSNH8NJLL+lsp95cPDk5WSrz9vaGnZ0d7t+/jy5dumD69Ono168fWrVqVe4N6dW/c4r/HiIiInoWE09ERETVJDw8HK+++irOnTuHd999F4WFhQgMDKzUmMW/pU29Yqg0FhYWOuuMjIpuC5RKZaViquzxdbVR1wPA06dPtbaxs7MrdR4aN24MAHj48KFUVny1hq6VTM/KycnRWu7g4FCu/tqoY1LHqIt61Vbxc6gKDx48AFA0hwqFokJ9N2zYIF3TACCTyWBpaSm9F7m5ucjIyCh1ZU15ro1n3/eKzllFVGRsXYmnefPmYeXKldJrQ0NDWFtbw9jYGEBRQig7O/u5Vxypr93yjlH8urWyssK3336LcePG4erVq5g1axaAokTdK6+8gtGjR2PMmDFo0KCBzvFMTU0BoMxviyQiohcbH7UjIiKqJhYWFggPD5ceNXvvvfcQEhJSqTFtbW2ln3WtAqLSqVQq6efc3FyIoj0wS/3n6+urdSxDQ8NqirrqlXeVy7P+/PNPBAYGorCwEKNGjUJ0dDTy8vLw5MkTPHjwAA8ePJCu82dXQNVnR48elZJOM2bMwO+//478/Hw8fvxYmpf33nsPwPPPi/ranTdvXrmu25MnT2r079evHxITE7Fr1y74+/vDzc0N6enp+OWXXzBx4kR4enqWuppLvQKt+O8hIiKiZzHxREREVI3Mzc0RHh6OV155BQAwZ84crFq16rnHK76vkz4fkVOvOCltZUN6errejl9eqampKCgo0Fmv/iO6+Mqk4o8oFX8UqbqpYyrrsSt1fWVWV2mjnofU1NQKrcAJDQ2FSqVCmzZt8N133+Hll1+WVvSoqVdTVTX1HJSWHClPvT7GVu8jNnDgQGzcuBHt27cvkZis7Lyo37PKXLcKhQITJ07Ezp07kZCQgDt37mDFihWQy+UaK6G0Uf/OKc/+ckRE9OJi4omIiKiaKRQKhIWFoVevXgCADz74QONxnIpwc3OTkkJ///13lcX4LGtrawDA7du3dbaJiorS2/HLS6lU4syZM1rrhBA4deoUgKJ9e9SKJ0p++eUX/QepgzqmO3fuICEhQWsblUqFiIgIANC5F9bz6t69u3SMQ4cOlbuf+prw8PDQuqk7ABw7dqzyAWqhnrPbt2/j5s2bWttkZGTg4sWLzz12TEyMtEfSs27cuKEzUaieF09PT631QgicOHFC5/HVc1naaij1nmLHjh2rssfdGjdujLlz52LOnDkAilZu6ZKYmAgAaNOmTZUcm4iI6icmnoiIiGqAOvnUu3dvAEWPyixfvrzC45ibm6NTp04AgOjo6CqNsTj1N2YdPnxY62qYEydO4Pz583o7fkV88skn0l5DxX311VdSMmDMmDFSuUKhwLhx4wAAK1aswK1bt0odX18ry/r37y89sqTr29K2bNki7eszduzYKj2+q6urtBLvP//5DzIyMsrVT/3td7///rvWJMmhQ4dKPOJVVfr37y8lRYODg7W2WblyJXJzcys89ogRI2BoaIjc3FydqxKXLFmis796Xi5fvqy1fvPmzaUmi9UbmqelpelsM3nyZBgZGSE1NRWLFi3S2Q4ACgoKNBJo+fn5pbZX79+kK5mYn58vnZs6iU5ERKQNE09EREQ1xMzMDAcPHkTfvn0BAB999BGWLVtW4XHU+w3pc8XR6NGjYWBggEePHmHs2LHSKo/c3Fx89dVXGDZsWLm+VU/fzMzMcPbsWYwbN06KMS8vD1u3bsU777wDABgyZEiJb9dbtmwZGjVqhNTUVHTr1g27d+9GZmamVJ+SkoIff/wRw4YNq/KEj5qpqamUcPr2228xffp0abPznJwcrFu3TtqMfsyYMfDy8qryGNauXQu5XI4bN26gR48eCA8Plzb0VqlUuHDhAqZPn66xgmnQoEEAgKtXr2LmzJlSYi47OxtbtmzByJEj9bYHkKmpKRYsWACgKLEYGBiIR48eASha6RQcHIxly5bBysqqwmM3btwYM2fOBFCU1Pr000+layIlJQUBAQH4+uuvpQTTs9TzcujQIQQHB0sJ27S0NCxbtgyzZs0qdV7at28PAAgLC9P5OF/Lli2l81+5ciXeeustXLlyRapXKpWIi4vDkiVL4Orqiri4OKluxYoVePXVV7F7926NVVv5+fn4/vvv8dlnnwEA/Pz8tB770qVLKCgogJGRUaW+zZGIiF4AgoiIiPRm0aJFAoAo7T+5OTk5on///lK74ODgCh3j0qVLAoAwNTUV6enppcbRq1cvneNERESUGuvChQulegDC0tJSGBkZCQBi6NChYv78+TqP0atXLwFALFq0SOfxXVxcBACxY8cOnW3Ux46IiNAo37FjhwAgXFxcxIYNG4RMJhMAhLW1tWjQoIHUz8PDQ6Smpmod+48//hDu7u5SWwMDA2FjYyMUCoXGeffr10+jX2JiolSXmJioM/byeu+996TxZDKZsLa2luYZgOjdu7fIyMio0PxUxOHDh4WlpaU0VoMGDYStra3GPO7fv1+jz5tvvqkxR1ZWVsLQ0FAAEF5eXmL9+vXS+/Msf39/AUD4+/vrjKn4+/sslUolJk6cqPG+WVtbS8d/8803Sz1Gaddmbm6u6NevnzS2oaGhsLa2lq6vefPm6exfUFAgfHx8SryXBgYGAoDw8/Mr9TOTkJAg5HK5dE6Ojo7CxcVFuLi4iNu3b0vtCgsLxYIFC6SY1L8LbG1tpTlQ/zt79qzUr/jvJnUfGxsbjXHatGkj7t+/r/U9+eijj6TPPhERUWm44omIiKiGmZqa4sCBAxgwYAAAYMGCBaU+wvOsjh07wtvbG7m5udi3b5++wkRQUBB2796Nrl27QqFQQKVSoWPHjti8eTP27dtXa77RbebMmTh8+DAGDRoEAwMDGBgYoHXr1liyZAnOnz+vc5VJmzZtEB8fjy1btmDAgAGws7NDRkYGhBBwdXXFqFGjsHXrVnz//fd6jT8kJAQnTpzAiBEj4OjoiKysLFhYWKB3797Yvn07jh49CgsLC70df8CAAbhx4wY+/vhjeHp6wtTUFNnZ2WjcuDEGDhyILVu2oE+fPhp99uzZg88//xwdOnSAiYkJVCoV/vWvf+HTTz/FuXPnYG5urrd4DQwMsGvXLuzatQtdu3aFqakplEolOnXqhM2bN+Obb7557rHlcjkOHTqEtWvXomPHjjA2NoYQAj4+Pvj+++9LfTy2QYMGOHLkCBYtWgR3d3c0aNAAQgh4e3tj06ZNOHDgQKmfGTc3N0REROCNN96Avb09Hj16hOTkZCQnJ0OpVErtZDIZlixZgvj4eMyYMQNt2rSBoaEh0tPTYW1tje7du+ODDz7Ab7/9prEyadq0adi6dSvGjh2L9u3bw8zMDBkZGbC2toaPjw8+//xzxMbGamy+ryaEkOb13//+9/NMLRERvUBkQrxA32tLRERUT6m/Dr13796lblhMRFRZp0+fRq9evdCyZUvcuHEDMpmspkMiIqJajCueiIiI6oHx48ejbdu2iIiI0Osm40REn376KQBg6dKlTDoREVGZmHgiIiKqBwwNDbFy5UoAur8RjYiosqKiohAeHg5vb2+Nb4ckIiLSxaimAyAiIqKq4efnhzVr1iA9PR1ZWVl63VeHiF5MKSkpWLRoEYYNG8bVTkREVC7c44mIiIiIiIiIiPSCj9oREREREREREZFeMPFERERERERERER6wcQTERERERERERHpBRNPRERERERERESkF0w8ERERERERERGRXjDxREREREREREREesHEExERERERERER6QUTT0REREREREREpBdMPBERERERERERkV4w8URERERERERERHrBxBMREREREREREekFE09ERERERERERKQXTDwREREREREREZFeMPFERERERERERER68f8AeTtSxZ5DUk8AAAAASUVORK5CYII=",
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {}
+ }
+ ],
+ "source": [
+ "Image('/tmp/cam_eval/figures/best_of_k_cam_test.png')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. Per-design ranking\n",
+ "\n",
+ "Scores for all 96 designs are in `per_design.json`. Per-type breakdown:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "stream",
+ "name": "stdout",
+ "text": [
+ "Top 5 by Q_theta:\n",
+ " pdb type label q_theta\n",
+ "2O5G decoy_rmsd1.0 0.75 0.998\n",
+ "1IWQ positive 1.00 0.998\n",
+ "2O5G decoy_rmsd6.4 0.00 0.997\n",
+ "2O5G positive 1.00 0.997\n",
+ "2BBM decoy_rmsd7.2 0.00 0.996\n",
+ "\n",
+ "Bottom 5 by Q_theta:\n",
+ " pdb type label q_theta\n",
+ "1NWD negative_apo 0.00 0.030\n",
+ "3D33 decoy_rmsd7.2 0.00 0.027\n",
+ "1K93 negative_apo 0.00 0.027\n",
+ "1IWQ negative_apo 0.00 0.018\n",
+ "3D33 negative_apo 0.00 0.012\n",
+ "\n",
+ "Per-type stats:\n",
+ "type n mean std\n",
+ "decoy_rmsd1.0 8 0.952 0.030\n",
+ "decoy_rmsd1.8 8 0.857 0.183\n",
+ "decoy_rmsd2.6 8 0.866 0.173\n",
+ "decoy_rmsd3.3 8 0.668 0.264\n",
+ "decoy_rmsd4.1 8 0.600 0.309\n",
+ "decoy_rmsd4.9 8 0.532 0.397\n",
+ "decoy_rmsd5.7 8 0.438 0.334\n",
+ "decoy_rmsd6.4 8 0.948 0.028\n",
+ "decoy_rmsd7.2 8 0.595 0.417\n",
+ "decoy_rmsd8.0 8 0.274 0.235\n",
+ "negative_apo 8 0.042 0.023\n",
+ "positive 8 0.909 0.130\n"
+ ]
+ }
+ ],
+ "source": [
+ "import json, pandas as pd\n",
+ "with open('/tmp/cam_eval/tables/eval_cam_test.json') as f: m = json.load(f)\n",
+ "# Re-score for inspection\n",
+ "import pickle\n",
+ "from data.dataset import TwoStateComplexDataset, collate_fn\n",
+ "from torch.utils.data import DataLoader\n",
+ "from collections import defaultdict\n",
+ "ds = TwoStateComplexDataset('data/sample/cam/test.pkl', max_nodes=128,\n",
+ " esm_dir='data/sample/esm2_embeddings', target_name='cam')\n",
+ "loader = DataLoader(ds, batch_size=32, shuffle=False, collate_fn=collate_fn)\n",
+ "recs = []\n",
+ "for batch in loader:\n",
+ " with torch.no_grad():\n",
+ " esm = batch['esm_feats'].to(device) if 'esm_feats' in batch else None\n",
+ " s = model(batch['node_feats'].to(device), batch['edge_feats'].to(device),\n",
+ " batch['node_mask'].to(device), esm_feats=esm)\n",
+ " for i, sc in enumerate(s.cpu().numpy()):\n",
+ " recs.append({'pdb': batch['pdb'][i], 'type': batch['type'][i],\n",
+ " 'label': float(batch['label'][i]), 'q_theta': float(sc)})\n",
+ "df = pd.DataFrame(recs).sort_values('q_theta', ascending=False)\n",
+ "print('Top 5 by Q_theta:')\n",
+ "print(df.head().to_string(index=False))\n",
+ "print('\\nBottom 5 by Q_theta:')\n",
+ "print(df.tail().to_string(index=False))\n",
+ "print('\\nPer-type stats:')\n",
+ "print(df.groupby('type').q_theta.agg(['count','mean','std']).round(3).to_string())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "**What this tells biology users.** The model cleanly pushes all 8 apo-state negatives to Q_\u03b8 \u2248 0.03 while native holo positives sit at \u2248 0.91 (right side of the histogram). Decoys interpolate: low-RMSD decoys still get high scores (they look interface-like), high-RMSD ones decay toward 0. This is the signal you use to **rank** generator outputs."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. Q_\u03b8-guided sampling with PXDesign\n",
+ "\n",
+ "[PXDesign](https://github.com/proteinabio/pxdesign) is the prior generator. AlloGen ships four Q_\u03b8-guidance variants under `code/scripts/pxdesign_guidance/`:\n",
+ "\n",
+ "| Script | Method | When to use |\n",
+ "|---|---|---|\n",
+ "| `langevin_pxdesign.py` | Post-hoc Langevin on PXDesign samples | Refine an existing pool |\n",
+ "| `smc_pxdesign.py` | Sequential Monte Carlo over PX diffusion | Diversity preserved, no gradient noise |\n",
+ "| `tds_pxdesign.py` | Twisted Diffusion Sampler | Best quality, slowest |\n",
+ "| `guided_pxdesign.py` | Classifier guidance during PX denoising | Fast, gradient required |\n",
+ "\n",
+ "**Inputs:** holo + apo PDB of CaM, `--checkpoint checkpoints/Q_theta_phase2.pt`. **Output:** N scored designs written to `--output_dir`.\n",
+ "\n",
+ "### Example (CaM, TDS, 100 designs)\n",
+ "\n",
+ "```bash\n",
+ "python code/scripts/pxdesign_guidance/tds_pxdesign.py \\\n",
+ " --checkpoint checkpoints/Q_theta_phase2.pt \\\n",
+ " --holo_pdb your_holo.pdb --rec_chain A \\\n",
+ " --apo_pdb your_apo.pdb --apo_chain A \\\n",
+ " --n_designs 100 \\\n",
+ " --binder_length 40 \\\n",
+ " --guidance_scale 1.0 \\\n",
+ " --output_dir designs_tds/ \\\n",
+ " --seed 42 --device cuda:0\n",
+ "```\n",
+ "\n",
+ "**PXDesign is not installed in Colab by default.** Install it locally from and run on a GPU box. The Q_\u03b8 guidance code itself is shipped here; the prior generator is external.\n",
+ "\n",
+ "### Plugging Q_\u03b8 into other priors\n",
+ "\n",
+ "Mirror the PXDesign template to wrap **RFdiffusion** or **Proteina-ComplexA**. See [`code/scripts/README.md`](./code/scripts/README.md) for the contract (`DifferentiableQTheta` exposes `\u2207_x S(x)` for any prior emitting differentiable backbone coordinates)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. Score your own design (Python API)\n",
+ "\n",
+ "Given holo + apo receptor PDBs and a candidate binder PDB, score selectivity in three lines:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [
+ {
+ "output_type": "stream",
+ "name": "stdout",
+ "text": [
+ "Scorer ready. Provide your own PDB inputs above.\n"
+ ]
+ }
+ ],
+ "source": [
+ "from models.differentiable_features import DifferentiableQTheta\n",
+ "\n",
+ "scorer = DifferentiableQTheta(\n",
+ " checkpoint='checkpoints/Q_theta_phase2.pt',\n",
+ " device='cuda' if torch.cuda.is_available() else 'cpu',\n",
+ ")\n",
+ "# scorer.load_receptor(holo_path='holo.pdb', rec_chain='A',\n",
+ "# apo_path='apo.pdb', apo_chain='A')\n",
+ "# q_holo = scorer.score('design.pdb', binder_chain='B', state='holo')\n",
+ "# q_apo = scorer.score('design.pdb', binder_chain='B', state='apo')\n",
+ "# print(f'S = {q_holo - q_apo:.3f}')\n",
+ "print('Scorer ready. Provide your own PDB inputs above.')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Citation\n",
+ "\n",
+ "```bibtex\n",
+ "@inproceedings{cao2026allogen,\n",
+ " title = {AlloGen: State-Selective Scoring for Allosteric Binder Design},\n",
+ " author = {Cao, Hanqun and others},\n",
+ " booktitle = {Advances in Neural Information Processing Systems (NeurIPS)},\n",
+ " year = {2026}\n",
+ "}\n",
+ "```\n",
+ "\n",
+ "MIT-licensed. Issues & PRs welcome at the HF repo."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python",
+ "version": "3.10"
+ },
+ "colab": {
+ "provenance": [],
+ "gpuType": "T4"
+ },
+ "accelerator": "GPU"
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a53c6a860fba51e349de4288e53cdaeeaf8c2708
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,20 @@
+# Core
+torch>=2.0.0
+numpy>=1.24.0
+scipy>=1.10.0
+pandas>=2.0.0
+scikit-learn>=1.3.0
+
+# Protein structure
+biopython>=1.80
+mdtraj>=1.9.0
+
+# Utilities
+pyyaml>=6.0
+tqdm>=4.65.0
+matplotlib>=3.7.0
+einops>=0.6.0
+
+# Optional
+wandb>=0.12.0
+fair-esm>=2.0.0