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 + +

+ AlloGen method overview +

+ +State-selectivity scoring + guided generation for allosteric binder design. + +πŸ§ͺ **One-click demo for biology users:** +[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](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/P99IMu3atWuwrWOP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", + "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+++/19ddfJ/uYW0fl0jKqdOtMc7fu49Z/p+bNb1Zwd3dX0aJFVbRoURUrVkzly5dXgwYNNGzYMO3du1cvvfSSfdvz589L0j3fOCT+BT5xe+lm0Zs7d67Kly+vEydO6O2331bt2rWVL18+tWjRQpMmTUp2Rr/MVqZMGXs5SBwdSZT4ed26dVW1atUk6xL/qn3t2rUkfyy4/SNxVr60fm3e3t72N9W35/rxxx8VHx+vYsWK2d/gJqpYsaKmTJkiHx8f/f7773rmmWdUoUIFFSlSRN27d9eSJUuSHRlKr1dffVXFihXT5cuX9eGHH95z+4w8b7e+8SxRokSKx7jb6/P3339X1apV9cEHH2jLli26cOGC8uTJoyJFiqho0aJJRtejo6Pv+fWkVXZ9vUnS1KlTJd38Y4KPj88d6xNLyOHDh+8oo7cqXLhwsqOniRK/P7f+fLj133f7/iX+bImLi0vTz9BbZ9ZMKZsjfz4HBQXp5MmT8vb21hNPPHHH+vz586tTp06S0jZKN3DgQEnS999/r4SEBHuxS1yeFnny5JGkDM14C1iJQgfkYjabTcWLF9egQYO0aNEi2Ww2vfnmm/ZTuW5VtmxZ+xTQO3bsSPUxdu7caf939erVk/33rdtYqX79+goLC1NYWJjOnj2ro0eP6tdff9V//vOfFEfh0qtmzZr6+++/tXDhQj333HN64IEHFBMTo6CgIL3wwguqWrWq9uzZ49BjpkbiX9AXLFigmJgYSTffMCae1pV4mtytEkcWu3fvbv9jwd0+0nNqbGKujRs36tSpU/bliW/8n3rqKbm6ut7xuF69eunEiRP673//q+7du6t06dIKDw/XTz/9pE6dOikgICBD07/fKm/evBo1apSkm9O83377hNs56nm7/TYWqREXF6eePXvq0qVLqlWrllauXKnIyEhduXJF586dU1hYmLZs2WLf3pHF91bZ8fV28eJFLVq0SNLN2xPcflsGm82mggUL2t/8J5Y/Z3FrWUtp5DVxdFbK+M/nxOfn6tWrypcvX7LPZ+Ipp/Pnz0/2tOrk1KtXT9WqVdOpU6f0zTff6LfffpOfn586dOiQ5oyJpTUtlxMA2QmFDoCkm6dm9enTR8YYDRky5I7T/9zd3e2nDq1duzbVv3R//vlnSUlPL5JuXt+TeF1J4psnZ1KkSBFJuuNecbdLXJ+4/a08PDzUpUsXTZ48WXv27FF4eLj++9//ys/PT6dOnUrzdSCO8MQTTyhPnjyKjIzUkiVLJN38fp8/f17u7u7265JuVaxYMUnpO7UttZo0aaLSpUsrISHBfi+r/fv32/+4kNypXIn8/Pw0aNAgzZ07VydPntThw4f19ttvy2az6ZdffknxGsj0ePbZZ1WpUiVdv35d77333l23zcjzduu1T3e77uf06dPJLv/999914sQJubq6avny5WrTpk2S6w8lKSwsLM250io7vt5mz56dppGaBQsWpPhHgYiICN24cSPFxyZ+f279+XDrv+/28yVxnZubW5pOnbz1tZPS6Fvx4sXtpS4jI9n//vuvFi9enOrto6Ojk1xPeC+J90N84403JN38w87dRkRTkvg8pHRNIZDdUegA2I0aNUqurq7av3+/Zs6cecf6559/XpIUFRWlCRMm3HN/hw4dsv9y7ty5s/2NmCQVLVrUfhrdnDlzdPDgwVTnzKzRgrR4+OGHJd18U5VS9vj4eAUHB0u6eerYvRQqVEiDBg3SuHHjJN38y3hqT29NLMcZfW58fX3tpz8ljn4l/rdNmzZJTsNL1KBBA0k3r3s6e/Zsho6fEpvNpt69eyebq0aNGineHDk5FStW1Mcff2y/ofC6descltPNzU1jx46VdPNUsH379qW4bUaet9q1a9tH5jZu3JjidimtSxzl9Pf3T/G0vqCgoBT3m5Nfb4kjSq+88oquXLmS4sfly5fl7++vmJiYFCcmiYuLS/G6MGOM/XTNxJ8nklSnTh3787t+/foUcyZ+f2rWrCl3d/dUf32VKlWSm5ubJOno0aMpbvfiiy9KuvlzfNasWanef0JCgv3fs2bN0o0bN1SkSBFdvnz5rs/nK6+8Iiltp1326dNHbm5u9tKcntMtJdmvrb3//vvT9XjAahQ6AHYVK1ZU9+7dJUljxoy540L79u3b20fZPvzwQy1fvjzFff3777/q1q2brl27Jm9vb40ZM+aObcaOHSsfHx/FxMSoS5cuKY4mJLp48aK6du2qy5cvp/Erc7wWLVrYT89JaYRn8uTJ9tGTW0carl+/ftd9J17PISnZ2fGSky9fPkk3Z8jLqMTRrrVr1+rQoUP2kZOURsG6deumAgUKKDY2VkOHDr3rm/yEhIR0Z0w8/v79+7V9+3b7SF1KuVL7PKf2OU6tbt266eGHH1ZCQoKGDx9+1+3S+7z5+fmpadOmkqTPPvss2VGgTZs2pVgm8ufPL+n/Jki63T///KMvv/wyxTw59fW2Y8cO7dq1S9LN/2d9fHxS/MiXL599psm7nXb54YcfJik5iWbOnGkv1ok/d6Wbs7Mmzt45fvz4ZK8B3L17t32CoORGMe/Gx8dHderUkSRt27Ytxe2efvppPfjgg5JulrtNmzbddb/x8fH64IMPtGLFCvuyxOelS5cuypcv312fzx49ekiStmzZov3796fqaylatKg+//xzvf7663r//fdVu3btVD3udlu3bpV09wmGgGzN8XdCAJAdpebG4sYYs2fPHvvNfJO7SfLZs2dNhQoV7DeDffnll83+/fvt6y9dumRmzJhhypQpY79X05w5c1I83qJFi4yHh4eRZAoXLmw++eQTc+jQIfv6uLg4s2PHDvPuu+/a7z118eLFJPvIihuLJ+fWG4sPGjTIhIWFGWNu3jj8iy++sN8Y+PYbi8+YMcPUr1/f/Pe//zVHjhyxL4+LizOrV682pUqVMpLMY489luRxd7sP3a33CZw3b16Kme92X7BbcxQrVsxIMg8//LCRZAoWLJjsvc5u/ZoSj9+mTRuzZcsW+7234uPjzf79+82nn35qqlatan744YcU93MviXkS/+vq6mrOnDmT7LbPPPOM6datm1mwYIE5d+6cffmVK1fMpEmT7K+7lO6DlpLk7kN3u1vv45X4kdyNxTPyvP3yyy/2/1dbtWplDh48aIy5eQ+2hQsXmsKFC6d4Y/FLly6ZvHnz2u+9lXhT8sTXYMWKFU2hQoXs2Y4dO5bk8Tn19fbCCy+k6WfJ+vXr7Tn27NljX377jcW7d+9uTp06ZYwxJiYmxkyePNl4eXkZ3XJvy1vdemPxhg0bJrmx+IoVK0yJEiWMlP4bi7/55ptGkmnduvVdtzt8+LD9Jubu7u7mhRdeMNu2bTNxcXH2bY4dO2a++eYbc9999xlJZtGiRcYYY7Zt22Z/bm6/MXxyEhIS7L83hg4dmmTd7fehS63U3Ifu1puo3/q7DHAmFDogl0htoTPGmI4dOxpJplSpUsm+qTp//rz9Zs+JH15eXvbClfhRvHhxs2rVqnse79dff7W/GUj88PDwMH5+fvYbeksyNpvN9OzZ09y4cSPJ460qdMb83w2lE/MVLFjQfsNfSaZp06ZJbnZtTNJCIMl4enqaQoUKJflaS5QoYf76668kj7tboTPm/240Lcn4+vqasmXLmrJlyya5sXJq3mAbY8zQoUOTZBw0aNA9n4tbS9KtX1fiG9PEj1mzZt1zXym59WbtiUUmJf369UuyrY+Pzx2v0YYNG5qoqKg0ZUhNoTPGmBYtWtyz0BmTseft888/T7JNgQIFjKenp5FkHnjgAfv6KlWqJHvc25+fxJJRuHBhs3Tp0hQLnTE57/UWExNjf328/vrrqXpMXFycKVKkiJFkXn31VfvyW2+8/vXXX9uLd8GCBZPkq1mzpomIiEh233Pnzk3y9eXLl8/+/ZFkSpcune4CsnPnTiPJ5MmTx1y+fPmu254+fTrJ91qScXFxMX5+fknySTKPPPKI/Y9UgwYNMpJM0aJFkxTAu0l8HRQpUiTJz/nMLHSTJ082kkytWrXStG8gO6HQAblEWgrdrX9Z/eKLL1LcbsOGDeaZZ54xVapUMfny5TOenp6mZMmSpk2bNuabb74x0dHRqc4XFxdnfvzxR9OrVy9z3333mXz58hl3d3dTuHBh07BhQzNixAjz999/J/tYKwudMTefh65du5pixYoZd3d3U7BgQdO0aVMzbdq0ZN/I/Pvvv+b77783AwYMMDVr1jRFihQxbm5uJn/+/KZevXpmzJgxd4xCGnPvQnfx4kXz2muvmcqVKyd54/fee+/Zt0ntG+xdu3YleaO2efPmVD0Xx44dM2+88YapWbOmyZcvn3F1dTUFCxY0Dz/8sBkyZIhZt26dfSQlPcLDw5O8IZ49e3aK2x4+fNh8+eWXpnPnzqZq1aqmQIECxs3NzRQpUsS0aNEixe/PvaS20P3555/2N/J3K3TGZOx527hxo2nbtq0pWLCg8fLyMlWqVDEjR440UVFRZsKECfY32slZsWKFadKkib3MVaxY0QwZMsScPn3aHDt27K6FLqe93mbNmmU//tatW1P1GGOMGTx4sL0EX79+3RiTtNAZY8zatWtN69atTaFChYynp6epWrWq+eCDD8zVq1fvuu9Dhw6ZQYMGmYoVKxpPT0/j4+NjatWqZd5///17FrF7qVev3j1fl7fatGmTefHFF02NGjWMn5+f/WdWzZo1zeDBg83GjRvt2169etXky5fPSDLPP/98qjNt2bLF/j1YuHChfXlmFrrGjRsbKfkzUgBnYTMmG8wuAAAAHK5Xr16aM2eOBg4c6HTT6zuzGTNmaMCAASpbtmy6btORFb7//nv169dPTZs2TfZWNbnB8ePHVaFCBfn6+uqff/65Y6ZXwFkwKQoAADnQwYMH7bcNad26tcVpkN306tVL1apVU3Bw8F0nR8nJxo0bJ2OMhg8fTpmDU6PQAQDgpEaNGqWvv/5aJ0+etM+kGB0drXnz5qlp06a6du2aqlatar81AJDI1dVV//nPfySlPFNvTnbq1ClNmzZNZcqU0auvvmp1HCBD3KwOAAAA0ic0NFRLlizRkCFD5O7uLl9fX126dMle7kqWLKn58+en6T5lyD0ef/xxff7557p8+bKioqLk4+NjdaQsc+LECQ0fPlxNmzaVl5eX1XGADKHQAQDgpF577TWVKFFCv/32m86ePasLFy7I19dXlStXVrt27fTSSy/Jz8/P6pjIxnLr6FTDhg3VsGFDq2MADsGkKAAAAADgpLiGDgAAAACcFIUOAAAAAJwUhQ4AAAAAnBSFDgAAAACcFIUOAAAAAJwUhS6LdOjQQR06dLA6BgAAAIBsJKM9gdsWZBFfX1/FxsaqYsWKVkcBAAAAkE0cOXJE7u7uunLlSroezwgdAAAAADgpN6sD5BZlypSRJO3bt8/iJAAAAACyi+rVq2fo8YzQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk6LQAQAAAICTotABAAAAgJOi0AEAAACAk3LaQvfnn3/qk08+UZcuXVSqVCnZbDbZbLZ07+/ixYt65ZVXVLZsWXl6eqps2bJ69dVXdenSJceFBgAAAAAHshljjNUh0qNTp05asmTJHcvT8+VEREToscce0+HDh1WhQgU9/PDD2rdvn/bt26fKlSvr999/l5+fX4byVq9eXZK0b9++DO0HAAAAQM6R0Z7gtCN0jz32mN59910tXbpUZ8+elaenZ7r39eqrr+rw4cPq0qWLDhw4oHnz5mnv3r0aMmSIDh48qKFDhzowOQAAAAA4htOO0N3Oy8tL169fT/MI3dmzZ1WqVCm5ubnp5MmTKlq0qH3d9evXVbp0aV24cEFnzpxRkSJF0p2PEToAAAAAt8u1I3SOsnr1aiUkJKhRo0ZJypwkeXp6qn379oqPj9fKlSstSggAAAAAyXOzOoDVdu/eLUmqU6dOsuvr1KmjadOmKTQ0NCtjZbq4OOniRatTAAAAANYqVEhyceJhrlxf6E6ePClJKlWqVLLrE5efOHEiyzJltlmzpJdeki5ftjoJAAAAYK3z5yV/f6tTpF+uL3RRUVGSJG9v72TX582bV5J05cqVVO0v8RzY2x05ckQVK1ZMR0LHioujzAEAACA32yDpd0kjrA7iELm+0OU2Fy9S5gAAAJBbrZbUWdI1SRUl9bA2jgPk+kLn4+MjSbp69Wqy66OjoyVJvr6+qdpfSrPTpDRyBwAAACArrJLUSdINSe11s9g5v1xf6MqUKSNJ+ueff5Jdn7i8bNmyWZYpq+3fLxUubHUKAAAAIPMcPVpJHTsWUt269fXf/86Rh4eHpJuTojizXF/oatasKUnasWNHsusTl9eoUSPLMmW1woWd+0JQAAAA4F78/e/Ttm1bVKJECbm55Zwa5MQTdDpG69at5eLiol9++UXnz59Psu769etatmyZXF1d1bZtW4sSAgAAAEiP77//Psn9pMuUKZOjypyUiwrd119/rapVq2r48OFJlhcvXlw9e/bUjRs39MILLyguLs6+7s0331R4eLh69+6tIkWKZHVkAAAAAOn0v//9T/3791eXLl1SnOciJ3DaerpixQqNGTPG/vmNGzckSY8++qh92bvvvqvHH39ckhQREaEDBw7o7Nmzd+xr4sSJ2rJlixYuXKiqVavq4Ycf1r59+7R3715VqlRJEyZMyOSvBgAAAICjfPPNN3rppZckSc8++6yqVatmcaLM47SFLjw8XFu3br1j+a3LwsPDU7WvwoULa9u2bRo9erQWL16sRYsWqWjRonr55Zf1/vvvq0CBAo6KDQAAACATTZgwQa+//rok6fXXX9f48eNls9ksTpV5bMYYY3WI3CDxtgVWD/eGh0u3nz16/jyTogAAAMD5ffzxx3rnnXckSe+8847Gjh2b7ctcRntCrrmGDgAAAEDOtWTJEnuZ++CDD/Thhx9m+zLnCE57yiUAAAAAJGrXrp2eeuop1ahRQ2+99ZbVcbIMhQ4AAACAUzLGKCEhQa6urnJ1ddWsWbNyxajcrTjlEgAAAIDTSUhI0EsvvaQBAwYoPj5eknJdmZModAAAAACcTEJCggYNGqRvv/1Ws2bN0ubNm62OZBlOuQQAAADgNOLj4zVw4EB9//33cnFx0YwZM9S4cWOrY1mGQgcAAADAKcTGxqpv376aO3euXF1dNXv2bHXv3t3qWJai0AEAAADI9m7cuKGePXvq559/lru7u+bNm6fOnTtbHctyFDoAAAAA2d6uXbu0fPlyeXh4aOHChWrXrp3VkbIFCh0AAACAbK9evXpasGCBPDw81KpVK6vjZBsUOgAAAADZUnR0tM6dO6cKFSpIktq3b29xouyH2xYAAAAAyHYiIyPVunVrNW7cWEePHrU6TrZFoQMAAACQrVy6dEktW7bUr7/+qqioKEVERFgdKdvilEsAAAAA2caFCxfUsmVL/fnnn/Lz89PatWv10EMPWR0r26LQAQAAAMgWwsPD1bx5c4WGhsrf31/r1q1TzZo1rY6VrVHoAAAAAFguLCxMzZo10/79+1WsWDGtX79e1apVszpWtkehAwAAAGA5T09PeXh4qGTJktqwYYMqV65sdSSnQKEDAAAAYLmCBQtq3bp1ioyMtN+mAPfGLJcAAAAALHHkyBFNmzbN/nnhwoUpc2nECB0AAACALHfgwAE1a9ZMp0+flqenp3r16mV1JKdEoQMAAACQpfbv36/AwECdO3dO1apVU7NmzayO5LQ45RIAAABAlgkNDVWTJk107tw51axZUxs3blSxYsWsjuW0KHQAAAAAssSff/6ppk2bKjw8XA899JA2bNggf39/q2M5NQodAAAAgEx35swZNWvWTBcuXNCjjz6qoKAg+fn5WR3L6VHoAAAAAGS6EiVK6LXXXlPDhg21du1aFShQwOpIOQKFDgAAAECmMcbY/z1q1CitX79evr6+FibKWSh0AAAAADLFmjVr1KZNG0VHR0uSbDabPDw8LE6Vs1DoAAAAADjc8uXL1aFDB61Zs0bjx4+3Ok6ORaEDAAAA4FCLFi1Sly5ddOPGDXXp0kXvvPOO1ZFyLAodAAAAAIeZN2+eunXrptjYWPXo0UPz5s3jNMtMRKEDAAAA4BDff/+9nnrqKcXHx6tv376aNWuW3NzcrI6Vo1HoAAAAAGRYZGSkhg0bpoSEBD3zzDOaPn26XF1drY6V41GXAQAAAGRYvnz5tHr1as2dO1cff/yxXFwYO8oKFDoAAAAA6Xb69GmVLFlSklS7dm3Vrl3b4kS5C7UZAAAAQLp88sknqlKlijZv3mx1lFyLQgcAAAAgTYwx+uCDDzR8+HBFR0frl19+sTpSrsUplwAAAABSzRijkSNH6qOPPpIkffzxx3r77bctTpV7UegAAAAApIoxRm+88YYmTJggSZowYYJee+01i1PlbhQ6AAAAAPeUkJCgV155RV9//bUk6euvv9aLL75ocSpQ6AAAAADcU3x8vE6cOCGbzabJkyfr2WeftToSRKEDAAAAkAru7u6aP3++QkJC1LJlS6vj4P9jlksAAAAAyYqLi9PMmTNljJEkeXp6UuayGQodAAAAgDvcuHFDPXr0UP/+/fXWW29ZHQcp4JRLAAAAAElcv35d3bp107Jly+Th4aFGjRpZHQkpoNABAAAAsIuJiVHnzp21Zs0aeXl5afHixWrVqpXVsZACCh0AAAAASVJ0dLQ6dOigDRs2yNvbW8uWLVNgYKDVsXAXFDoAAAAAMsbYy5yPj49WrlzJqZZOgElRAAAAAMhms+mZZ56Rn5+f1q1bR5lzEozQAQAAAJAk9ezZU23atFGBAgWsjoJUYoQOAAAAyKXCw8PVpUsXnT592r6MMudcGKEDAAAAcqGwsDA1b95c+/bt07///quNGzfKZrNZHQtpRKEDAAAAcpnTp0+rWbNmOnDggEqUKKHvvvuOMuekKHQAAABALnLy5EkFBgbqyJEjKlOmjDZs2KCKFStaHQvpRKEDAAAAcomjR48qMDBQJ06cUPny5RUcHKyyZctaHQsZwKQoAAAAQC4xePBgnThxQpUqVdKmTZsoczkAhQ4AAADIJWbMmKEOHTooJCREpUqVsjoOHIBTLgEAAIAcLDIyUvny5ZMklShRQkuWLLE4ERyJEToAAAAgh9qxY4fuu+8+zZkzx+ooyCQUOgAAACAH2rp1qwIDAxUeHq5vv/1WCQkJVkdCJqDQAQAAADnMr7/+qhYtWujy5ctq2LChVq5cKRcX3vrnRHxXAQAAgBwkODhYrVq10pUrV9S0aVOtWrXKfg0dch4KHQAAAJBDrF27Vm3bttXVq1fVsmVLLV++XD4+PlbHQiai0AEAAAA5xIYNG3Tt2jU9/vjjWrJkiby9va2OhEzGbQsAAACAHOLjjz9WpUqV1KdPH3l4eFgdB1mAEToAAADAiSWOykmSzWbT008/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+fM6ePCg1q5dqyNHjtiPK1GihA4ePKhdu3Zp4MCBkqR33nlHjzzySIr6HLm1bdu2berQoYMSExNlsVj0xBNPqGfPnipatKhOnz6tadOmaenSpTp9+rSaNWumvXv3qkyZMvbjDx48KEmaMGGCvvrqK0nSqlWrVLx4cXubkJCQu65Pkr7++msNHjxYSUlJ8vb21pw5c/Too4861Gd6km+zK1OmjMLCwuzb+/fvr1dffVVHjx7V1q1bs7QQfXx8vDp06KCIiAi99NJLat++vQoXLqzTp0/rs88+0/r163Xq1Ck99dRTWrNmTZp9WK1WNWjQQO3atVONGjVUtGhRubm56fTp09q0aZMmT56s+Ph4PfHEEwoNDVWDBg3u6P0OGDBAr7/+um7cuKFvvvkmw+/rkiVL7LcaPvPMMyn2ffPNN/r2228lSZUqVdIzzzyjatWqqWDBgrp+/br++OMP7dy5U0uXLr2j+iQpOjpaffr0UXx8vHx9ffXUU0+pdevWKlKkiCwWi/766y/t379fq1atuuO+AQBwOlcnXwAAwFy3zxKqW7eucfDgwRSvPXv2GAsWLDAef/xxw2KxGJKM2rVrG5cuXUqzv61btxpubm6GJOPFF180bt26lWa7LVu2GD4+PoYk46mnnkqxb926dfaaxo0bl2H9MTExqbZlNGPFEYmJiUb58uXtfc+aNSvNdm+//ba9TcuWLdNs8+abb6aapXS3bp/x9Oyzz9q/DggIMNatW+dQ3xnZvXt3urO0zp49a7i7uxuSjAEDBmS5/oCAAGPXrl2p2ty6dcto2rSpvd3BgwfT7Ovo0aMZjnXq1CmjePHihiSjRYsWabbJ7PxJnqnk5uZmREREpDtWq1at7DOs/vl5adKkiSHJuO+++4zY2Nh0+7BarcbFixdTbc9oxtOUKVOyPCsrrc8PAACuxBpPAADkYrt371a1atVSvJKfajdnzhyVKFFC48aN0+bNm+1PLvund999V0lJSapatarGjh2b7pPTGjdurOeff16SNHPmzBRrRp07d87+9e3rNaWlUKFCd/gu796yZct0/PhxSVLv3r3Vp0+fNNuNHDlS9evXlyStXbtW4eHh96zGr7/+WpLk4+Ojn3/+Wc2bN3faWFOnTpVkW7eqX79+KfYVK1ZMrVu3liTNmzdPcXFxWepz9OjRqlu3bqrtHh4eGjZsmP3fGzZsSPP4ihUrZth/6dKl9d///leS9PPPP+vy5ctZqut2zz33nCTb0x3Te3LfyZMntXbtWknSY489lurzknyO16lTR/ny5Ut3LDc3NxUoUOCO6suunx8AALKC4AkAgDwsMjJSU6dO1bJly9LcHxcXp9WrV0uy/bHt7u6eYX/JfxTfvHlTu3fvtm8vWbKk/evJkyfLMAxHSzdF8nuTpGeffTbddhaLxR5O/PM4Z0tebPrGjRtasmSJ08a5ceOGZs+eLUlq2rRpitsJkyUvMn7t2jXNnTs3S/0++eST6e5LDvMk6cSJE1nq7/z58zp+/LgOHz6sQ4cO6dChQ/Lz85NkWyx83759Werndg0aNFDNmjUl2cI3q9Waqs2kSZPs5+0/b7OT/j7HN23apKNHj95xDRm5/fMzadIkU/sGAMDZCJ4AAMjFwsLCZBhGildiYqL++usvLVmyRA0bNtTBgwf1+OOP6+233051/N69e+1P+ho1alSqp8n989WpUyf7sVFRUfavGzdurAceeECSNH78eFWsWFHDhw/XTz/9pOjoaFPfc3IYkdbrzJkzKdomz1zy8PBQvXr1Muy3UaNG9q8PHDhwRzWdOXMmw7oyMn78eHl7e0uyzT577bXX7mjsrFqwYIF9tlD//v3TbNO5c2cVLFhQktKdGXS74OBgFS5cON39t8/OuXLlSrrtli1bps6dO6tAgQIKCQlRhQoVVLVqVfssvqefftreNq0nxmVFcvAYGRmpFStWpNiXmJioadOmSZKqVaumhg0bpjo+uYaLFy+qevXq6tq1qyZOnKgDBw5k6Wl5GXnkkUfs64QNGzZMNWvW1P/+97+7nuEFAMC9RPAEAEAe4+7urpCQEHXu3FkbN260/xE9atSoVI+HdyQUio+PTzHm8uXL7Qs/Hz9+XB988IHat2+vIkWKqFKlSho2bJj9tjdH/PPWwttfI0eOTNH2woULkqSgoCB7uJOeYsWKpTouq0aOHJlhXRlp3769Fi9eLB8fH0nSmDFjNHz48DsaPyuSb7Pz9/dX9+7d02zj7e2tXr16SZK2b9+u3377LcM+/f39M9zv5vb3r6JpzTK6deuWevbsqc6dO2vZsmVZClluP+/uRJ8+fRQYGCjJtlD47ZYuXWq/3e32kOt2jz/+uD755BP5+fkpISFBixYt0rPPPquaNWuqQIEC6tixo2bPnn1XIVT+/Pm1atUqVa5cWZIt+Bw9erRatGihggULqmbNmho9enSKsBcAgOyC4AkAgDzM09NTr7zyiv3f//yD+/Y/kkeOHKmDBw9m+fXPJ8+FhoZq+/bt2rhxo/7zn/+oTp069vWijh49qo8//liVKlXSmDFjnPiOc6a2bdtqyZIl9vDpgw8+sK9rZIaTJ09q/fr1kmy30QUGBqY7q+3LL7+0H5ccVjnLBx98oHnz5kmSqlSpom+++Ubh4eG6dOmSEhIS7LP41q1bZz/mbm/jDAgI0BNPPCFJ+umnn1LMjkv+XPj5+dnbpGXo0KGKiIjQl19+qS5dutif7BgXF6fly5erT58+qlWrlk6ePHnH9dWsWVMHDx7U8uXL9eyzz6pq1apyc3OTYRg6cOCA/ve//6lcuXL2J+sBAJBdpL06KAAAyDOSb4GTpP3796fYd/ttUu7u7qpatarD4zVt2lRNmzaVZJudsnXrVi1YsEDTpk1TQkKCXnvtNVWtWjXFbXt34k6Ch+RbvS5fvqybN29mOOvp9gWe73QB5+nTp2v69Ol3dMw/tW7dWkuXLtUjjzyi69ev66OPPpLVatUnn3ziUL+SLUC6m8Dm22+/1XvvvSdPT0+Ha0jLV199JUkqU6aMdu7cme4MqosXL5oy3nPPPacJEybIarVqypQpGjVqlE6dOqU1a9ZIknr27Kn8+fNn2EdwcLCef/55+0L7x48f16pVqzRx4kQdPHhQhw4dUvfu3bVnz547rs/d3V3t27dX+/btJUmxsbHauHGj5syZo7lz5+r69esaMGCAqlevbl+zCgAAV2PGEwAAedzts5pu3bqVYl+tWrXst0Nt2rTJ9LH9/PzUqlUrff3115o1a5Z9e/Ii18mSF9g2W/Xq1SXZvge//PJLhm23bdtm/7pGjRpOqSczrVq10rJly+Tr6ytJGjt2rP7zn/841GdSUpJmzJghSbrvvvv0/fffZ/pKXmg9OjpaP/74o2NvKh0XLlzQ2bNnJUmPPvpohrftZfazy6qqVavqoYcekmRbwyopKUmTJ09WUlKSpLQXFc9M+fLlNXjwYO3evdseBu3du9eUBcjz58+vzp07a/bs2Xr//fcl2X6eWV34HQCAe4HgCQCAPO72P9pLlSqVYl/BggXVrFkzSdLGjRu1a9cup9XRpk0b+9fnz59PsS85aJFsT8wzS9u2be1fJ8+uSc/XX39t//r2Wu+1Fi1aaPny5fYnuX366af697//fdf9rV69Wn/++ack2zpFWXm98cYb9kDSWbfb3R6IXrt2Ld12V65csQdnZkgO1U6fPq0ff/zRvqh4jRo1UjyF7055eXnZn/oopT7HHZXR5wcAAFcieAIAIA+LiYnRe++9Z/93586dU7UZPXq0LBaLDMNQjx49dOTIkQz7/PPPP1M98WzTpk2ZLkR9+5PEypUrl2Jf8eLF7V+b+aj6jh07qkKFCpKk77//PtVMq2RjxozR9u3bJdlmHWW2ILizPfzww1q+fLl9FtBnn32mF1988a76uv1n9fjjj2fpmGLFitlvl/zpp5/sM5PMVLhwYfsT9JYtW5ZmmHLjxg316dPH1Ccjdu/eXcHBwZJsT7pLfm+ZzXaaPn16hqHojRs37OtoWSwWlS1bNss1/fTTTzp9+nSGbTL6/AAA4Eqs8QQAQC527do1HTp0KMW2pKQkxcTEaPv27ZowYYL9D+uqVavqX//6V6o+mjRpovfee08jRoxQRESEatWqpSeeeEJt27a1z5CKiYnRgQMHtHr1am3cuFENGjTQoEGD7H38/PPPeuutt/Tggw+qffv2qlmzpooVKyY3NzedO3dOK1eu1OTJkyXZFjxPnnWSrGTJkipTpoxOnjypKVOmqHLlynrwwQfti217enre1R/bbm5umj59usLCwpSYmKi+fftqzZo1euyxx1SkSBH9+eefmj59uhYvXizJ9vS7iRMn3vE4ztCsWTOtWLFCHTp0UFxcnL744gslJSVp/PjxWe7jwoULWrp0qSSpQoUKql27dpaP7dmzpzZs2CCr1aoZM2ZoxIgRd/weMuLm5qZ+/fpp3LhxioqKUoMGDTRs2DBVr15dbm5u2rt3rz7//HMdPXpUTZs2Ne1WUC8vLw0cOFAffvih/Slx/v7+6tOnT4bHDRgwQC+//LI6duyohx56SBUrVlT+/PkVGxur3377Td988419DbVu3bqlCFMzM3fuXM2aNUtNmjRRmzZtVL16dYWEhCgpKUlnzpzR4sWLNXPmTEm22+/69et3d28eAABnMAAAQK5y8uRJQ9IdvZo0aWJERUVl2O/UqVONwMDALPXXvn37FMe++eabWTouMDDQmD9/fprjf/vtt+keV7p0aYe+Z6tWrTIKFCiQYW2lSpUy9u/fn24ft7/HkydPOlRPWFhYlvvavHmzERAQYG//3HPPGUlJSVkaZ9y4cfbjXn/99Tuq8fz584aHh4chyahQoUKa9Wfl55I8fr9+/VLti4uLMxo3bpzhz+Wpp54y1q1bZ//3tGnTUvWzfv36DPf/04kTJwyLxWI/ZtCgQVl+H5m92rZta8TGxqY6vl+/fvY2Ge3L6FWkSBFj8+bNmdYKAMC9xIwnAADyGIvFooCAAJUsWVIPPvigevbsqXbt2mW6gPeAAQPUpUsXTZkyRatWrdKhQ4fsTxMrWLCgypcvrwYNGqhdu3YKCwtLceywYcNUq1YtbdiwQXv27NHZs2cVHR2t69evKygoSJUrV1br1q319NNPKyQkJM3xn3jiCRUrVkwTJkzQ7t27FR0dbdp6T61bt9aJEyc0YcIELV++XL///ruuXLmioKAgVa1aVY8++qj+9a9/pVhrKrt46KGHtHLlSrVr105Xr17VV199paSkJH311VeZ/kxvX58pq7fZJQsODlaLFi20atUqHTt2TJs2bbLffmcWf39/rV+/Xl999ZVmz56tX3/9VQkJCQoJCVH9+vU1aNAgtW3bVhs2bDB13LJlyyosLMzeb1YWFf/111+1cuVKbdu2Tb///ruio6MVExMjLy8v+2etT58+ateu3R3XM27cOLVr104bNmzQgQMHFBUVpejoaCUmJqpgwYKqUqWKOnbsqIEDBypfvnx33D8AAM5kMYy7eHYuAAAAkEvdunVLJUuWVHR0tGrVqqW9e/e6uiQAAHIsFhcHAAAAbrNkyRL7guXPPvusi6sBACBnY8YTAAAA8P+SkpJUr1497dmzR0FBQYqMjLQ/PRAAANw51ngCAABAnhYdHa0rV64oJiZGX3zxhfbs2SNJeuWVVwidAABwEDOeAAAAkKf1799fM2bMSLGtZs2a2rFjh7y9vV1UFQAAuQNrPAEAAACSPDw8VK5cOQ0dOlQ///wzoRMAACZgxhMAAAAAAACcghlPAAAAAAAAcAqCJwAAAAAAADgFwRMAAAAAAACcguAJAAAAAAAATuHh6gLyqqJFi+ratWsqVaqUq0sBAAAAAABI0+nTp+Xv769z587d1fHMeHKRa9eu6datW64uwyFWq1VWq9XVZQAAADgdv/cAAPKqW7du6dq1a3d9PDOeXCR5ptPhw4ddXMndi4mJkSQFBwe7uBIAAADn4vceAEBeVaVKFYeOZ8YTAAAAAAAAnILgCQAAAAAAAE5B8AQAAAAAAACnIHgCAAAAAACAUxA8AQAAAAAAwCkIngAAAAAAAOAUBE8AAAAAAABwCoInAAAAAAAAOAXBEwAAAAAAAJyC4AkAAAAAAABOQfAEAAAAAAAApyB4AgAAAAAAgFMQPAEAAAAAAMApCJ4AAAAAAADgFARPAAAAAAAAcAqCJwAAAAAAADgFwRMAAAAAAACcwsPVBQCAM1mt0sqV0tSp0okTUlycFBAglSsnDRwotW0rubu7ukogb+DziJzm9nP26NEgXbtmUf78nLPInrjGAtkLn8m/WQzDMFxdRF5UpUoVSdLhw4ddXMndi4mJkSQFBwe7uBIgNatV+vxz2+vUqfTbhYZKL74ovfSS5MYcUMAp+Dwip+GcRU7C+QpkL7nxM+lofpFrgqc9e/ZozZo1+uWXX/TLL7/ozJkzkqS7fXuXLl3S6NGjtXjxYp07d05FixZVly5dNHr0aAUFBTlcL8ET4DzXr0t9+0oLF2b9mK5dpVmzJF9f59UF5EV8HpHTcM4iJ+F8BbKX3PqZJHj6f48++qiWLFmSavvdvL2YmBg1bNhQx48fV9myZVW3bl0dPnxYhw8fVsWKFbV9+3YVLFjQoXoJngDnsFqlHj2kRYvu/NiuXaV58/LOlFfA2fg8IqfhnEVOwvkKZC+5+TPpaH6RzSd0ZV3Dhg31xhtvaOnSpYqKipK3t/dd9/Xvf/9bx48fV9euXXX06FHNnTtXhw4d0gsvvKDff/9dQ4cONbFyAGb6/PO7u9hLtv8y8cUX5tYD5GV8HpHTcM4iJ+F8BbIXPpPpyzUznv7Jx8dHN2/evOMZT1FRUSpZsqQ8PDx0+vRpFSlSxL7v5s2buu+++3Tx4kWdPXtWISEhd10fM54A81mtUvnyGd9LnZkyZaRjx7Lvf20Acgo+j8hpOGeRk3C+AtlLbv9MOppf8FS7f1i5cqWSkpLUpEmTFKGTJHl7e6tTp06aOnWqVqxYof79+7umSABpWrnSsYu9JJ08KX31ldSkiSklAXnWpk18HpGzcM4iJ+F8BbIXsz6Tq1ZJ7dubUlK2QvD0DwcOHJAk1a5dO839tWvX1tSpUxUeHn4vywKQBVOnmtPPCy+Y0w8Ax/F5RE7DOYuchPMVyF6mTCF4yhNOnz4tSSpZsmSa+5O3R0REZKm/5Clp/3TixAmFhobab1fLiWJjY11dApDC0aNB4rIGAAAAICf6/fdExcRcdnUZqVitVrk7cA9grllc3CxxcXGSJD8/vzT3+/v7S5KuXr16z2oCkDnDkC5csLi6DAAAAAC4K3FxufPvGaYGOFl6i28lz4TKDQtz54b3gJwrMlKaMUOaNk06d868frPjon5ATmK1mtcXn0fcC5yzyEk4X4HsxazPZFCQe7b8+9qR2U4SwVMqAQEBkqT4+Pg091+7dk2SFBgYeM9qApDSzZvSkiW2NZ1Wr7bNdjJT167SggXm9gnkNd262R4N7Cg+j7hXOGeRk3C+AtmLWZ/JsmUd7yM74la7fyhVqpQkKTIyMs39ydtLly59z2oCYLN/v/Tii1Lx4lLPnranPpgdOknSoEHm9wnkNQMHmtMPn0fcK5yzyEk4X4Hshc9kxgie/qFGjRqSpL1796a5P3l79erV71lNQF524YL0xRdSrVq21xdfSBcvpt/e09Ox8cqUkdq0cawPAFLbtlJoqGN98HnEvcQ5i5yE8xXIXvhMZozg6R/atm0rNzc3bd68WdHR0Sn23bx5U8uWLZO7u7va58ZnHALZhNVqm83Us6dtdtOLL9pmO6UnMFD617+k7dul9993bOwXX2StA8AM7u62z5Mj+DziXuKcRU7C+QpkL3wmM5Zng6fx48erUqVKGjFiRIrtxYoVU69evZSQkKDnn39eiYmJ9n3//e9/df78efXt21chISH3umQg1ztxQnrjDdt/LWjbVpo3T0pISL99s2bSt99KUVHSN99IDRpIL71kW6/gbnTr5vj/YQD424sv8nlEzsI5i5yE8xXIXvhMpi/XBE/Lly9XgwYN7K+E//9r9fZty5cvt7ePiYnR0aNHFRUVlaqvTz/9VOXKldOCBQtUqVIlPf7446pWrZo+//xzVahQQWPHjr1n7wvI7a5ds4VHzZpJ5ctL77xje1JdekqWlF5/XTp+XFq/XnriCcnf/+/97u7SrFl3ftHv1k2aOVNyyzVXRcD1+Dwip+GcRU7C+QpkL3wm05dr3tr58+e1c+dO+8v4/xWHb992/vz5LPUVHBysX375RS+88IISEhK0aNEixcbG6sUXX9Qvv/yiggULOvOtALmeYUg7dkhPPy0VKyb16ydt3Jh+ey+vvxcTP3VKevttqVy59Nv7+tpmS40bl/m91mXK2NrNm2c7DoC5+Dwip+GcRU7C+QpkL3wm02YxDGc8EwqZqVKliiTp8OHDLq7k7sXExEiyBXVAVpw7Z0vzp06VjhzJvH2tWrYnRPTuLd1t3pu8XtSUKdIff0hXr9rWhCpb1vbUiDZtcu+91EB2w+cROc3t5+zvvycqLs6ioCB3zllkS1xjgewlN30mHc0vCJ5chOAJecWtW9KKFbawafly2wU4IwULSn37SgMGSDVr3pMSAQDIFL/3AADyKkfzCw8ziwGAZL/+agubZs6U/vGAyFTc3KTWrW2zmzp3lry9702NAAAAAADnIngCYJrYWGnuXFvgtHNn5u3LlbOFTU8+aVs0HAAAAACQuxA8AXBIUpJtYfCpU6UFC6Tr1zNu7+cn9ehhC5yaNJEslntTJwAAAADg3iN4AnBXTp+WZsyQpk2TTp7MvH2jRraw6bHHbIvqAQAAAAByP4InAFl244a0eLFtdtPatVJmjyYoWtR2G92AAVKlSvekRAAAAABANkLwBCBDhiHt22cLm777Trp8OeP2Hh5Sp0622U1t29r+DQAAAADIm/iTEECaYmJsQdO0adKBA5m3r1LFFjb17SuFhDi/PgAAAABA9kfwBMDOapVWr7bNblqyRLp1K+P2+fJJvXvbAqe6dVkoHAAAAACQEsETAB07ZpvZ9O230pkzmbdv3twWNnXpYntKHQAAAAAAaSF4AvKouDhp/nzb7KbNmzNvX6qUbZHwfv2kMmWcXx8AAAAAIOcjeALyEMOQtm2zzW6aO9cWPmXE21vq2tU2u6l5c8nN7d7UCQAAAADIHQiegDwgKsp2G93UqdLvv2fevm5dW9j0+ONSgQLOrw8AAAAAkDsRPAG5VEKC9OOPttlNP/1kWzg8I4UKSU88Ybudrnr1e1MjAAAAACB3I3gCcplDh2wzm2bOlGJiMm7r5ia1a2cLmzp1kry87k2NAAAAAIC8geAJyAUuX5bmzLEFTrt2Zd6+QgXbrXRPPikVL+708gAAAAAAeRTBE5BDJSVJ69fbwqaFC6UbNzJu7+8v9expm93UuLFksdybOgEAAAAAeRfBE5DDRERI06fb1m6KiMi8/UMP2WY39eghBQQ4vTwAAAAAAOwInoAc4Pp1adEiW9i0bp1kGBm3L1ZM6tfPNrupYsV7UyMAAAAAAP9E8ARkU4Yh7dlju5Vu9mwpNjbj9p6eUufOttlNrVtLHny6AQAAAAAuxp+mQDZz/rw0a5ZtdtPBg5m3r1bNFjb16SMVLuz8+gAAAAAAyCqCJyAbSEyUVq2yzW5atky6dSvj9vnz24KmAQOkOnVYKBwAAAAAkD0RPAEu9PvvtplNM2ZIUVEZt7VYpBYtbLObHn1U8vW9JyUCAAAAAHDXCJ6Ae+zqVemHH2yzm7Zuzbx9aKhtZlO/flLp0k4vDwAAAAAA0xA8AfeAYUhbtthmN82bJ127lnF7Hx+pWzfb7KZmzSQ3t3tSJgAAAAAApiJ4ApzozBnp229tgdOxY5m3r1fPNrvp8celoCCnlwcAAAAAgFMRPAEmu3nTtkD4tGnSypVSUlLG7QsXlp54whY4Va16b2oEAAAAAOBeIHgCTBIeblu3adYs6cKFjNu6u0vt29vCpg4dJC+ve1MjAAAAAAD3EsET4IBLl6Tvv7cFTnv2ZN7+/vtt6zY98YRUrJjz6wMAAAAAwJUInoA7lJQkrVtnC5sWLbLdWpeRgADbmk0DBkgNG0oWy72pEwAAAAAAVyN4ArLo5Elp+nTb6/TpzNs3bWqb3dS9u+Tv7+zqAAAAAADIfgiegAzEx0sLF9pmN61fn3n7EiWkfv2k/v2lChWcXh4AAAAAANkawRPwD4Yh7dplC5u+/166ciXj9p6e0qOP2mY3tWplWzgcAAAAAAAQPAF2f/1leyLd1KnSr79m3r5GDVvY1Lu3FBzs/PoAAAAAAMhpCJ6QpyUmSj/9ZAubfvzR9u+MFCgg9eljC5xq1bo3NQIAAAAAkFMRPCFPOnJEmjZN+vZb6dy5jNtaLLZb6AYOlB55RPLxuTc1AgAAAACQ0xE8Ic+4ckWaN882u2n79szbly0rDRggPfmkVKqU8+sDAAAAACC3IXhCrmYY0ubNtrDphx9sT6nLiK+v1L27bXZT06aSm9u9qRMAAAAAgNyI4Am5UmSkNGOG7Xa6Eycyb9+ggS1seuwxKX9+59cHAAAAAEBeQPCEXOPmTWnpUtvsptWrpaSkjNuHhNhuoxswQHrggXtTIwAAAAAAeQnBE3K8/fttYdN330kXL2bc1t1d6tjRNrupXTvJ0/OelAgAAAAAQJ5E8IQc6eJFafZsW+C0b1/m7StXtoVNfftKRYs6vz4AAAAAAEDwhBzEapXWrrWFTYsXSwkJGbcPDJR69bIFTvXqSRbLPSkTAAAAAAD8P4InZHsnTkjTp9tekZGZt2/WzBY2desm+fk5uTgAAAAAAJAugidkS9euSQsW2GY3bdyYefv77pP697e9ypZ1dnUAAAAAACArCJ6QbRiGtHOnLWyaM0e6ejXj9l5eUpcuttlNLVrYFg4HAAAAAADZB8ETXO7cOWnmTFvgdORI5u1r17aFTb16SQULOr8+AAAAAABwdwiecEesVmnlSltIdPRokK5dsyh/fqlcOVsY1LZt1mYe3bolrVhh62f5clu/GSlY0PZEugEDpJo1TXkrAAAAAADAyQiekCVWq/T557bXqVPJW/8+fQ4ckBYulEJDpRdflF56SXJzS93Pr7/awqaZM6Xo6IzHdHOT2rSxBVqdOkne3ia9GQAAAAAAcE8QPCFT16/bZhstXJh521OnpKFDpS1bpFmzJF9fKTZWmjvXFjjt3Jl5H8mzp558UipZ0uHyAQAAAACAixA8IUNWq9Snj7Ro0Z0dt3ChbbbSfffZjr1+PeP2fn7SY4/ZAqeHHpIslruvGQAAAAAAZA8ET8jQ55/feeiUbPPmzNs0amQLmx57TAoMvLtxAAAAAABA9kTwhHQlr+tktqJFpX79pP79pUqVzO8fAAAAAABkDwRPSNfKlbcvJO4YDw/bAuHJT77z4MwDAAAAACDX489/pGvqVHP6qVZNWrtWCgkxpz8AAAAAAJAzpPHAe8DmxAlz+nF3J3QCAAAAACAvInhCuuLizOnn6lVz+gEAAAAAADkLwRPSFRBgTj88rQ4AAAAAgLyJ4AnpKlfOnH7KljWnHwAAAAAAkLMQPCFdAwea08+gQeb0AwAAAAAAchaCJ6SrbVspNNSxPsqUkdq0MaUcAAAAAACQwxA8IV3u7tKLLzrWx4sv2voBAAAAAAB5D8ETMvTii1LXrnd3bLdujgdXAAAAAAAg5yJ4Qobc3aVZs+48fOrWTZo5U3LjDAMAAAAAIM+6J7HAlStXFB0dLavVei+Gg8l8faV586Rx4zJf86lMGVu7efNsxwEAAAAAgLzLw+wOT506pVWrVmnjxo3avn27oqKidOvWLfv+/Pnzq3LlygoLC1NYWJhatmwpdxYByvbc3aV//1t64QVp1SppyhTp998TFRdnUVCQu8qWtT29rk0b1nQCAAAAAAA2FsMwDEc7SUpK0uLFizVx4kStW7dOhmEos24tFoskKSQkRAMHDtS//vUvhTr6CLUcpEqVKpKkw4cPu7iSuxcTEyNJCg4OdnElAAAAzsXvPQCAvMrR/MLhGU9LlizR8OHD9fvvv9vDpnLlyql+/fqqVauWgoODVbBgQfn6+urixYu6ePGiTp48qZ07d2rPnj3666+/9P777+ujjz7Sv/71L40ePVqFCxd2tCwAAAAAAAC4mEPBU7NmzbR582YZhqEaNWqob9++6t27t4oVK5al45OSkrRu3TrNmjVLixcv1ldffaXvvvtOM2fOVKdOnRwpDQAAAAAAAC7m0OLimzZtUuvWrbV9+3bt27dPL7/8cpZDJ0lyc3NTq1atNGPGDEVFRWnMmDHy8vLSvn37HCkLAAAAAAAA2YBDM562b9+u+vXrm1KIn5+fXn31VQ0ZMkSnTp0ypU8AAAAAAAC4jkMznswKnW7n7+9vX7gKAAAAAAAAOZdDwRMAAAAAAACQHoInAAAAAAAAOIXDwdPMmTN15MgRM2oBAAAAAABALuJw8NSvXz9VqVJFjRs31vTp0xUfH29GXQAAAAAAAMjhTLnVzjAM7dixQ4MGDVKxYsX07LPPateuXWZ0DQAAAAAAgBzKlODJz89PVapUkWEYunr1qiZNmqQGDRqoRo0a+uKLL3Tp0iUzhgEAAAAAAEAOYkrwlC9fPoWHh2v79u166qmnFBAQIMMwdPDgQf373/9WiRIl1KdPH/38889mDAcAAAAAAIAcwNSn2tWvX1/ffPONoqKiNGXKFDVu3FiGYejGjRuaM2eOWrVqpfLly+u9997T2bNnzRwaAAAAAAAA2YypwVMyPz8/DRgwQJs3b9Zvv/2ml19+WYULF5ZhGPrjjz/0xhtvKDQ0VJ07d9bSpUuVlJTkjDIAAAAAAADgQk4Jnm53//3366OPPlJkZKQWLFig9u3by83NTYmJiVq+fLm6dOmi++67z9llAAAAAAAA4B5zevCUzMPDQ126dNGPP/6oU6dO6a233lJoaKgMw9C5c+fuVRkAAAAAAAC4R+5Z8HS7EiVK6PXXX9eJEye0Zs0aPf74464oAwAAAAAAAE7k4eoCWrRooRYtWri6DAAAAAAAAJjMJTOeAAAAAAAAkPs5PONp2rRp8vX1NaMWAAAAAAAA5CIOB0/9+vUzow4AAAAAAADkMtxqBwAAAAAAAKcgeAIAAAAAAIBTEDwBAAAAAADAKRxe48kRZcuWlSRZLBadOHHClaUAAAAAAADAZC4Nnk6dOiXJFjwBAAAAAAAgd3Fp8NS0aVNCJwAAAAAAgFzKpcHThg0bXDk8AAAAAAAAnChXLS5+/fp1jRo1ShUrVpSPj4+KFy+ugQMH6syZM3fc15o1a9ShQwcVLlxYnp6eKlSokFq3bq1FixY5oXIAAAAAAIDcJ9cETzdu3FDz5s319ttvKy4uTo888ojuu+8+TZs2TbVq1dIff/yR5b4+/fRTtW7dWj/99JMqVqyobt26qVKlSlq7dq26du2qkSNHOvGdAAAAAAAA5A65Jnh65513tGPHDjVs2FC///675s6dq507d+qTTz7R+fPnNXDgwCz1c/78eQ0fPlyenp5av369tm7dqjlz5mjr1q3asGGDvL29NWbMmDsKsgAAAAAAAPIiU4On2NhYzZgxQ3369FHNmjUVEhIiX19f+fr6KiQkRDVr1lSfPn00Y8YMxcbGmjZuQkKCxo8fL0n68ssvFRAQYN83dOhQVa9eXRs3btSePXsy7Wvnzp26efOmmjdvrrCwsBT7mjZtqjZt2sgwDO3evdu0+gEAAAAAAHIj04Knjz/+WKGhoRo4cKDmzJmj8PBwxcTE6ObNm7p586ZiYmIUHh6uOXPmaODAgSpdurQ++eQTU8beunWrYmNjVa5cOdWqVSvV/u7du0uSli1blmlf3t7eWRqzUKFCd1YkAAAAAABAHmPKU+2eeuopTZs2TYZhSJIqV66sqlWrqkSJEvLz85MkxcfH68yZMzp06JB+++03XblyRf/973915MgRTZo0yaHxDxw4IEmqXbt2mvuTt4eHh2faV7169RQUFKSff/5ZGzduTDHradOmTVq1apUqVKigJk2aOFQzAAAAAABAbudw8LRw4UJNnTpVkvT0009rxIgRKl26dIbHnD59Wu+//74mTpyoqVOnqkOHDnr00UfvuobTp09LkkqWLJnm/uTtERERmfaVP39+TZkyRb1799bDDz+sRo0aqWTJkoqMjNS2bdvUuHFjffvtt/Ly8rrrelO4cSPrbb29JYvl7o93d5c8PVNus1qlW7ey3oeXl+T2j4lyN29K/x86ZsrNzdbH7ZKSpISErNfg6Wl7L7dLSLD1kxUWi+17eTvDsL2PrPLwsL1ud+uW7fuZVT4+qbc5+vNMTLS9siqtc8rRn6cZ55SjP08zzilHf55mnFNm/Dy5RthwjbDhGmHDNeJvXCNssnJOJX+vbt7kGpGMa4QN1wibvH6NSMbvEX/jGmGTG64RhpH6Z3kHHA6evvnmG1ksFo0aNUpvvvlmlo4pVaqUJkyYoKJFi2r06NGaOHGiQ8FTXFycJNlnV/2Tv7+/JOnq1atZ6q9r16766aef9Nhjj2nr1q327fny5VPr1q1VokSJLNdWpUqVNLefOHFCZUJCFD9qVJb7in/xxVQnnO9nn8mSxRMusWpVJbRvn2Kb+8GD8v7ppyzXcL1fPxlFikiSfZ0un+nT5RYdnaXjrffdp5u9eqXY5nb6tHzmzMlyDTe7dJG1QoUU27wWLZLHsWNZOj4pXz7dePbZFNsssbHynTgxyzUkNG+uxLp1U2zzXLdOnllYRyxZ/H//m2qb34cfZvn4W3Xq6FaLFim2eezeLa+ff85yH9efeUZG/vwptvl8/bXcrlzJ0vGJFSoooUuXFNvcjx2T96JFWa7hxuOPK6lUqRTbvL//Xu5//pml45NCQnSjf/8U2yx//SXfGTOyXMPNdu1krVYtxTavFSvkcehQlo43vL11/aWXUm68cUN+n3+e5RpuNW6sW40bp9jmuXWrPG+7BmUmu10jknGNsOEaYcM14v9xjbDLKdcIt///gy0uJIRrxP/jGmHDNcImr18jkvF7xN+4RtjkhmuEER0tyz8+m3fC4TWe9u7dKzc3Nw0bNuyOj33llVfk7u6uvXv3OlqGqT755BO1bNlSTZs2VXh4uOLi4hQeHq7mzZtr1KhR6tq1q6tLBAAAAAAAyPYcnvF09epVBQQEpDvbKCN+fn4KCAjI8kyk9CQ/xS4+Pj7N/deuXZMkBQYGZtrXhg0b9Morr6h27dr64Ycf5Pb/0/KqVaum+fPnq27dulq+fLl++ukntWvXLtP+Dh8+nOb2KlWqSNeu3dH3zS84OPUUO3//1NP20pMvnxQcnHJbgQLSndRQqFCqPgICAqT/n3WWqcBABf6zhri4O6uhQIHU7yMwMOt9BAQo4J/He3jcWQ1BQalryJ//zvr45/HSHR2v/PlT1xAUdOc/z6CglBsDArI+5TIwMHUNMTF3VkPBgub/PBMTHT+n8uXLeh8+PvL/5/E3bjj+87ybcyobXiPENcKGa4QN1wgbrhF/y2HXCD+uEX8PxzXCVgPXCBuuETZcI/4ejmuErYZccI2w/PN2xTtkMYys3mCZtrJlyyoiIkK///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//79MDU1rfA4ISEh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OvnUu3dvAEWPyixfvrzC45ibm6NTp04AgOjo6CqNsTj1N2YdPnxY62qYEydO4Pz583o7fkV88skn0l5DxX311VdSMmDMmDFSuUKhwLhx4wAAK1aswK1bt0odX18ry/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