lhallee commited on
Commit
5dd0dc4
·
verified ·
1 Parent(s): 406ad9f

Upload folder using huggingface_hub

Browse files
LICENSE CHANGED
@@ -1,9 +1,9 @@
1
- **License (MIT)**
2
-
3
- Copyright 2026 Chan Zuckerberg Biohub, Inc.
4
-
5
- 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:
6
-
7
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
-
9
- 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.
 
1
+ **License (MIT)**
2
+
3
+ Copyright 2026 Chan Zuckerberg Biohub, Inc.
4
+
5
+ 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:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ 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.
README.md CHANGED
@@ -1,122 +1,247 @@
1
- ---
2
- library_name: transformers
3
- tags:
4
- - biology
5
- - protein-structure
6
- - esmfold2
7
- - multimodal-protein-model
8
- ---
9
-
10
- # FastPLMs ESMFold2
11
-
12
- FastPLMs ESMFold2 is a self-contained Hugging Face `AutoModel` wrapper for Biohub's ESMFold2 and ESMFold2-Fast structure predictors. It vendors the released Biohub ESMFold2 model code, ESMC backbone code, input builder, MSA helpers, and structure export utilities needed for remote-code loading.
13
-
14
- ## Load With AutoModel
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ```python
17
- import torch
18
- from transformers import AutoModel
19
-
20
  model = AutoModel.from_pretrained(
21
  "Synthyra/ESMFold2-Fast",
22
- trust_remote_code=True,
23
- dtype=torch.float32,
24
- ).eval().cuda()
25
- ```
26
-
27
- Use `Synthyra/ESMFold2` for the full model and `Synthyra/ESMFold2-Fast` for the faster release variant.
28
- The folding trunk runs in fp32; the 6B ESMC backbone is loaded in bf16 by default via `esmc_precision="bf16"`.
29
-
30
- ## Fold One Protein
31
-
32
- ```python
33
- sequence = "MKTLLILAVVAAALA"
34
-
35
- result = model.fold_protein(
36
- sequence,
37
- num_loops=3,
38
- num_sampling_steps=50,
39
- num_diffusion_samples=1,
40
- seed=0,
41
- )
42
-
43
- print(float(result.plddt.mean()))
44
- print(float(result.ptm))
45
- ```
46
-
47
- ## Save mmCIF or PDB
48
-
49
- ```python
50
- model.save_as_cif(result, "prediction.cif")
51
- model.save_as_pdb(result, "prediction.pdb")
52
-
53
- cif_text = model.result_to_cif(result)
54
- pdb_text = model.result_to_pdb(result)
55
- ```
56
-
57
- `result_to_cif` preserves the full `MolecularComplex`. `result_to_pdb` converts through Biohub's protein-only `ProteinComplex` representation, so use mmCIF for complexes with ligands or nucleic acids.
58
-
59
- ## Fold Complexes
60
-
61
- ```python
62
- types = model.input_types
63
-
64
- complex_input = types.StructurePredictionInput(
65
- sequences=[
66
- types.ProteinInput(id="A", sequence="MKTLLILAVVAAALA"),
67
- types.DNAInput(id="B", sequence="GATAGC"),
68
- types.LigandInput(id="L", ccd=["SAH"]),
69
- ]
70
- )
71
-
72
- result = model.fold(
73
- complex_input,
74
- num_loops=3,
75
- num_sampling_steps=50,
76
- num_diffusion_samples=1,
77
- seed=0,
78
- )
79
-
80
- model.save_as_cif(result, "complex_prediction.cif")
81
- ```
82
-
83
- ## Use MSAs
84
-
85
- ```python
86
- types = model.input_types
87
-
88
- msa = types.MSA.from_a3m("query.a3m", max_sequences=128)
89
- input_with_msa = types.StructurePredictionInput(
90
- sequences=[
91
- types.ProteinInput(id="A", sequence=msa.query, msa=msa),
92
- ]
93
- )
94
-
95
- result = model.fold(input_with_msa, num_sampling_steps=50, seed=0)
96
- ```
97
-
98
- ## Raw Tensor Inference
99
-
100
- ```python
101
- features, chain_infos = model.prepare_structure_input(complex_input, seed=0)
102
-
103
- with torch.inference_mode():
104
- output = model(
105
- **features,
106
- num_loops=3,
107
- num_sampling_steps=50,
108
- num_diffusion_samples=1,
109
- )
110
-
111
- decoded = model.input_builder.decode(output, features, chain_infos)
112
  ```
113
 
114
- Set `load_esmc=False` when loading if you want to provide precomputed `lm_hidden_states` manually or run folding-trunk tests without loading the 6B ESMC backbone:
 
 
115
 
116
  ```python
117
  model = AutoModel.from_pretrained(
118
  "Synthyra/ESMFold2-Fast",
119
  trust_remote_code=True,
120
- load_esmc=False,
121
  ).cuda().eval()
122
  ```
 
 
 
 
1
+ ---
2
+ library_name: transformers
3
+ tags:
4
+ - biology
5
+ - protein-structure
6
+ - esmfold2
7
+ - multimodal-protein-model
8
+ ---
9
+
10
+ # FastPLMs ESMFold2
11
+
12
+ FastPLMs ESMFold2 is a self-contained Hugging Face `AutoModel` wrapper for
13
+ Biohub's ESMFold2, ESMFold2-Fast, and experimental ESMFold2 structure
14
+ predictors. It vendors the released Biohub ESMFold2 model code, input builder,
15
+ MSA helpers, and structure export utilities, while loading the PLM backbone
16
+ through FastPLMs ESM++.
17
+
18
+ ## Load With AutoModel
19
+
20
+ ```python
21
+ import torch
22
+ from transformers import AutoModel
23
+
24
+ model = AutoModel.from_pretrained(
25
+ "Synthyra/ESMFold2-Fast",
26
+ trust_remote_code=True,
27
+ dtype=torch.float32,
28
+ ).eval().cuda()
29
+ ```
30
+
31
+ Use `Synthyra/ESMFold2` for the full model, `Synthyra/ESMFold2-Fast` for the
32
+ faster release variant, and the `Synthyra/ESMFold2-Experimental*` checkpoints
33
+ for differentiable binder design and experimental critic ensembles.
34
+ The folding trunk runs in fp32; the 6B FastPLMs ESM++ backbone is loaded in
35
+ bf16 by default via `esmc_precision="bf16"` and uses the flex attention backend
36
+ by default inside ESMFold2.
37
+
38
+ ## Fold One Protein
39
+
40
+ ```python
41
+ sequence = "MKTLLILAVVAAALA"
42
+
43
+ result = model.fold_protein(
44
+ sequence,
45
+ num_loops=3,
46
+ num_sampling_steps=50,
47
+ num_diffusion_samples=1,
48
+ seed=0,
49
+ )
50
+
51
+ print(float(result.plddt.mean()))
52
+ print(float(result.ptm))
53
+ ```
54
+
55
+ ## Experimental Test-Time Training
56
+
57
+ TTT is disabled by default. Standard `fold_protein(...)`, `fold(...)`, raw tensor
58
+ inference, and `state_dict()` keys are unchanged unless you explicitly pass
59
+ `ttt=True` or call `fold_protein_ttt(...)`.
60
+
61
+ The ESMFold2 TTT path is experimental and protein-only in v1. It trains local
62
+ LoRA adapters only on `_esmc` with a masked language modeling objective. The
63
+ folding trunk, confidence head, diffusion head, and structure input pipeline are
64
+ frozen. TTT can improve difficult low-confidence folds, but it adds substantial
65
+ test-time compute and can degrade already confident predictions.
66
+
67
+ ```python
68
+ result = model.fold_protein(
69
+ "MSTNPKPQRKTKRNT",
70
+ num_loops=1,
71
+ num_sampling_steps=10,
72
+ num_diffusion_samples=1,
73
+ seed=0,
74
+ ttt=True,
75
+ ttt_config={
76
+ "steps": 1,
77
+ "ags": 1,
78
+ "batch_size": 1,
79
+ "lora_rank": 8,
80
+ "lora_alpha": 32.0,
81
+ },
82
+ )
83
+
84
+ print(result.ttt_metrics["losses"])
85
+ print(result.ttt_metrics["step_plddts"])
86
+ print(result.ttt_metrics["best_step"])
87
+ ```
88
+
89
+ `load_esmc=True` is required for TTT because the ESM++ MLM head is loaded lazily
90
+ from `config.esmc_id`. If that pretrained MLM head cannot be loaded, TTT raises
91
+ an assertion instead of silently using a random head.
92
+
93
+ ## Save mmCIF or PDB
94
+
95
+ ```python
96
+ model.save_as_cif(result, "prediction.cif")
97
+ model.save_as_pdb(result, "prediction.pdb")
98
+
99
+ cif_text = model.result_to_cif(result)
100
+ pdb_text = model.result_to_pdb(result)
101
+ ```
102
+
103
+ `result_to_cif` preserves the full `MolecularComplex`. `result_to_pdb` converts through Biohub's protein-only `ProteinComplex` representation, so use mmCIF for complexes with ligands or nucleic acids.
104
+
105
+ ## Fold Complexes
106
+
107
+ ```python
108
+ types = model.input_types
109
+
110
+ complex_input = types.StructurePredictionInput(
111
+ sequences=[
112
+ types.ProteinInput(id="A", sequence="MKTLLILAVVAAALA"),
113
+ types.DNAInput(id="B", sequence="GATAGC"),
114
+ types.LigandInput(id="L", ccd=["SAH"]),
115
+ ]
116
+ )
117
+
118
+ result = model.fold(
119
+ complex_input,
120
+ num_loops=3,
121
+ num_sampling_steps=50,
122
+ num_diffusion_samples=1,
123
+ seed=0,
124
+ )
125
+
126
+ model.save_as_cif(result, "complex_prediction.cif")
127
+ ```
128
+
129
+ ## Binder Design With FastPLMs ESMFold2
130
+
131
+ FastPLMs includes a FastPLMs-only port of the Biohub ESMFold2 binder design
132
+ tutorial at `cookbook/tutorials/binder_design_fastplms.py`. The workflow uses
133
+ ESMFold2 experimental checkpoints for differentiable folding losses, ESM++ for
134
+ sequence regularization, and ESMFold2 hero critics for final confidence scoring.
135
+
136
+ ![FastPLMs EGFR minibinder design](https://raw.githubusercontent.com/Synthyra/FastPLMs/main/docs/assets/egfr_fastplms_binder_design.png)
137
+
138
+ The optimizer follows the official strategy:
139
+
140
+ 1. Optimize mutable `#` residues as continuous amino acid logits.
141
+ 2. Suppress cysteine design by masking cysteine logits and gradients.
142
+ 3. Backpropagate through ESMFold2 `res_type_soft` using intra-contact,
143
+ inter-contact, and globularity losses from the distogram.
144
+ 4. Add an ESM++ masked-LM pseudoperplexity regularizer on mutable binder
145
+ residues.
146
+ 5. Keep the late-trajectory sequence with the best iPTM.
147
+ 6. Fold the selected sequence with the final critic ensemble and write
148
+ `results.parquet`, `selection.parquet`, `trajectory.jsonl`,
149
+ `best_sequences.fasta`, and per-critic PDB/CIF/logit files.
150
+
151
+ Run the verified EGFR 128 amino acid de novo minibinder example:
152
+
153
+ ```bash
154
+ cd /home/ubuntu/FastPLMs
155
+
156
+ sudo -n docker run --gpus all --rm \
157
+ -v /home/ubuntu/FastPLMs:/app \
158
+ -v /home/ubuntu/FastPLMs:/workspace \
159
+ -v /home/ubuntu/.cache/huggingface:/workspace/.cache/huggingface \
160
+ -w /workspace fastplms-esmfold2 \
161
+ python /app/cookbook/tutorials/binder_design_fastplms.py \
162
+ --backend local \
163
+ --target-name egfr \
164
+ --binder-sequence '################################################################################################################################' \
165
+ --not-antibody \
166
+ --steps 150 \
167
+ --batch-size 1 \
168
+ --seed 103 \
169
+ --output-dir /workspace/campaign_egfr_len128_b1_s150_seed103_consensus_cli
170
+ ```
171
+
172
+ Verified result:
173
+
174
+ | Metric | Value |
175
+ | :--- | :--- |
176
+ | Binder length | `128` |
177
+ | Seed | `103` |
178
+ | Steps | `150` |
179
+ | Hero mean iPTM | `0.913870` |
180
+ | Hero min iPTM | `0.904600` |
181
+ | All four hero critics above 0.9 | `True` |
182
+
183
+ Binder sequence:
184
+
185
+ ```text
186
+ SAVKHLLEIVKYLEEAIEKALEVDPVFLVPPAAEELLIAAKVIKELAKENPELIEVYELLMKAVKGLKKLVRSNDKEILREVIRLLRKAAKVIREILKNNPDLDPELRKALEELAKVLEEIAEVLEQQ
187
+ ```
188
+
189
+ See the full guide in [`docs/binder_design.md`](https://github.com/Synthyra/FastPLMs/blob/main/docs/binder_design.md)
190
+ for Modal execution, official pI and selection scoring, per-critic metrics, and
191
+ the tested cheaper step-count boundary.
192
+
193
+ ## Use MSAs
194
+
195
+ ```python
196
+ types = model.input_types
197
+
198
+ msa = types.MSA.from_a3m("query.a3m", max_sequences=128)
199
+ input_with_msa = types.StructurePredictionInput(
200
+ sequences=[
201
+ types.ProteinInput(id="A", sequence=msa.query, msa=msa),
202
+ ]
203
+ )
204
+
205
+ result = model.fold(input_with_msa, num_sampling_steps=50, seed=0)
206
+ ```
207
+
208
+ ## Raw Tensor Inference
209
+
210
+ ```python
211
+ features, chain_infos = model.prepare_structure_input(complex_input, seed=0)
212
+
213
+ with torch.inference_mode():
214
+ output = model(
215
+ **features,
216
+ num_loops=3,
217
+ num_sampling_steps=50,
218
+ num_diffusion_samples=1,
219
+ )
220
+
221
+ decoded = model.input_builder.decode(output, features, chain_infos)
222
+ ```
223
+
224
+ Set `load_esmc=False` when loading if you want to provide precomputed `lm_hidden_states` manually or run folding-trunk tests without loading the 6B ESM++ backbone:
225
 
226
  ```python
 
 
 
227
  model = AutoModel.from_pretrained(
228
  "Synthyra/ESMFold2-Fast",
229
+ trust_remote_code=True,
230
+ load_esmc=False,
231
+ ).cuda().eval()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  ```
233
 
234
+ For FP8 LM inference, install `transformer_engine.pytorch` in a CUDA
235
+ environment with FP8-capable hardware and load the shared FastPLMs ESM++
236
+ backbone with:
237
 
238
  ```python
239
  model = AutoModel.from_pretrained(
240
  "Synthyra/ESMFold2-Fast",
241
  trust_remote_code=True,
242
+ esmc_precision="fp8",
243
  ).cuda().eval()
244
  ```
245
+
246
+ FP8 is inference-only for the ESMFold2 LM backbone. TTT remains a bf16/fp32
247
+ path.
__init__.py CHANGED
@@ -1,6 +1,5 @@
1
- from .configuration_esmfold2 import ESMFold2Config
2
- from .modeling_esmfold2_experimental import ESMFold2ExperimentalModel
3
- from .modeling_esmfold2 import ESMFold2Model
4
-
5
- __all__ = ["ESMFold2Config", "ESMFold2ExperimentalModel", "ESMFold2Model"]
6
 
 
 
1
+ from .configuration_esmfold2 import ESMFold2Config
2
+ from .modeling_esmfold2_experimental import ESMFold2ExperimentalModel
3
+ from .modeling_esmfold2 import ESMFold2Model
 
 
4
 
5
+ __all__ = ["ESMFold2Config", "ESMFold2ExperimentalModel", "ESMFold2Model"]
configuration_esmfold2.py CHANGED
@@ -1,194 +1,206 @@
1
- # Copyright 2026 Biohub. All rights reserved.
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- """ESMFold2 model configuration."""
15
-
16
- from __future__ import annotations
17
-
18
- from dataclasses import asdict, dataclass, field
19
-
20
- from transformers.configuration_utils import PretrainedConfig
21
-
22
- # ---------------------------------------------------------------------------
23
- # Nested dataclass configs
24
- # ---------------------------------------------------------------------------
25
-
26
- _DEFAULT_ESMC_HF_REPO = "biohub/ESMC-6B"
27
-
28
-
29
- @dataclass
30
- class MSAEncoderConfig:
31
- """Config for the optional MSA encoder module (Large MSA models only)."""
32
-
33
- enabled: bool = False
34
- d_msa: int = 128
35
- d_hidden: int = 32
36
- n_layers: int = 4
37
- n_heads_msa: int = 8
38
- msa_head_width: int = 32
39
-
40
-
41
- @dataclass
42
- class ParcaeConfig:
43
- """Release-only config for the parcae diffusion-loop scheduler."""
44
-
45
- enabled: bool = True
46
- poisson_mean: float = 3.0
47
- min_steps: int = 1
48
- max_steps: int | None = 6
49
- coda_n_layers: int = 2
50
-
51
-
52
- @dataclass
53
- class LMEncoderConfig:
54
- """Release-only config for the LM-side pair encoder."""
55
-
56
- enabled: bool = True
57
- n_layers: int = 4
58
- lm_dropout: float = 0.25
59
- per_loop_lm_dropout: bool = True
60
-
61
-
62
- @dataclass
63
- class AtomAttentionConfig:
64
- """Config for SWA atom encoder/decoder with 3D RoPE."""
65
-
66
- d_atom: int = 128
67
- d_token: int = 768
68
- n_blocks: int = 3
69
- n_heads: int = 4
70
- swa_window_size: int = 128
71
- expansion_ratio: int = 2
72
- # 3D RoPE config
73
- spatial_rope_base_frequency: float = 20.0
74
- n_spatial_rope_pairs_per_axis: int = 2
75
- n_uid_rope_pairs: int = 10
76
- uid_rope_base_frequency: float = 10000.0
77
-
78
-
79
- @dataclass
80
- class FoldingTrunkConfig:
81
- n_layers: int = 24
82
- n_heads: int = 8
83
- dropout: float = 0.0
84
-
85
-
86
- @dataclass
87
- class InputsEmbedderConfig:
88
- d_inputs: int = 451
89
- atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig)
90
-
91
- def __post_init__(self):
92
- if isinstance(self.atom_encoder, dict):
93
- self.atom_encoder = AtomAttentionConfig(**self.atom_encoder)
94
-
95
-
96
- @dataclass
97
- class DiffusionModuleConfig:
98
- """Config for the DiffusionModule."""
99
-
100
- sigma_data: float = 16.0
101
- c_atom: int = 128
102
- c_token: int = 768
103
- c_z: int = 256
104
- c_s_inputs: int = 451
105
- fourier_dim: int = 256
106
- relpos_r_max: int = 32
107
- relpos_s_max: int = 2
108
- atom_num_blocks: int = 3
109
- atom_num_heads: int = 4
110
- token_num_blocks: int = 12
111
- token_num_heads: int = 16
112
- transition_multiplier: int = 2
113
-
114
-
115
- @dataclass
116
- class DiffusionStructureHeadConfig:
117
- """Config for the diffusion-based structure prediction head."""
118
-
119
- diffusion_module: DiffusionModuleConfig = field(
120
- default_factory=DiffusionModuleConfig
121
- )
122
- distogram_bins: int = 128
123
-
124
- # Training noise: sigma ~ sigma_data * exp(mu + sigma * N(0,1))
125
- train_noise_log_mean: float = -1.2
126
- train_noise_log_std: float = 1.5
127
-
128
- # Sampling defaults (ODE)
129
- gamma_0: float = 0.605
130
- gamma_min: float = 1.107
131
- noise_scale: float = 0.0
132
- step_scale: float = 1.0
133
-
134
- # Inference schedule defaults
135
- inference_s_max: float = 160.0
136
- inference_s_min: float = 4e-4
137
- inference_p: float = 8.0
138
- inference_num_steps: int = 68
139
-
140
- def __post_init__(self):
141
- if isinstance(self.diffusion_module, dict):
142
- self.diffusion_module = DiffusionModuleConfig(**self.diffusion_module)
143
-
144
-
145
- @dataclass
146
- class ConfidenceHeadConfig:
147
- enabled: bool = True
148
- num_plddt_bins: int = 50
149
- num_pde_bins: int = 64
150
- num_pae_bins: int = 64
151
- min_dist: float = 2.0
152
- max_dist: float = 52.0
153
- distogram_bins: int = 128
154
- folding_trunk: FoldingTrunkConfig = field(
155
- default_factory=lambda: FoldingTrunkConfig(n_layers=4)
156
- )
157
-
158
- def __post_init__(self):
159
- if isinstance(self.folding_trunk, dict):
160
- self.folding_trunk = FoldingTrunkConfig(**self.folding_trunk)
161
-
162
-
163
- # ---------------------------------------------------------------------------
164
- # Top-level config
165
- # ---------------------------------------------------------------------------
166
-
167
-
168
- class ESMFold2Config(PretrainedConfig):
169
- """
170
- Configuration for the ESMFold2 structure prediction model.
171
-
172
- Uses SWA atom encoders with 3D RoPE, a diffusion transformer,
173
- a folding trunk, and an ESMC 6B PLM backbone.
174
-
175
- Configuration objects inherit from [`PretrainedConfig`] and can be used to control
176
- the model outputs. Read the documentation from [`PretrainedConfig`] for more
177
- information.
178
-
179
- Args:
180
- d_single (`int`, defaults to 384):
181
- Dimensionality of single (per-residue) representations.
182
- d_pair (`int`, defaults to 256):
183
- Dimensionality of pair (residue-residue) representations.
184
- n_relative_residx_bins (`int`, defaults to 32):
185
- Number of bins for relative residue index encoding.
186
- n_relative_chain_bins (`int`, defaults to 2):
187
- Number of bins for relative chain encoding.
188
- num_loops (`int`, defaults to 10):
189
- Number of trunk loops for iterative refinement.
190
- num_diffusion_samples (`int`, defaults to 8):
191
- Number of parallel structure predictions to generate.
 
 
 
 
 
 
 
 
 
 
 
 
192
  lm_dropout (`float`, defaults to 0.0):
193
  Dropout probability on LM pair embeddings. When > 0, dropout is
194
  applied with ``training=True`` (including at inference) to match
@@ -196,103 +208,121 @@ class ESMFold2Config(PretrainedConfig):
196
  force_lm_dropout_during_inference (`bool`, defaults to False):
197
  When True, apply ``lm_dropout`` even when ``model.eval()`` and
198
  ``lm_dropout`` > 0. Binder-design loads set this to True.
 
 
 
199
  disable_msa_features (`bool`, defaults to False):
200
  When True, zero out MSA-derived ``profile`` and ``deletion_mean``
201
  before the inputs embedder (experimental medium/large checkpoints).
202
- inputs (`InputsEmbedderConfig`):
203
- Configuration for the inputs embedder module.
204
- folding_trunk (`FoldingTrunkConfig`):
205
- Configuration for the folding trunk.
206
- structure_head (`DiffusionStructureHeadConfig`):
207
- Configuration for the diffusion-based structure prediction head.
208
- confidence_head (`ConfidenceHeadConfig`):
209
- Configuration for the confidence prediction head.
210
-
211
- Examples:
212
-
213
- ```python
214
- >>> from transformers import ESMFold2Config, ESMFold2ExperimentalModel
215
-
216
- >>> # Initializing an ESMFold2 configuration
217
- >>> configuration = ESMFold2Config(type="experimental")
218
-
219
- >>> # Initializing a model (with random weights) from the configuration
220
- >>> model = ESMFold2ExperimentalModel(configuration)
221
-
222
- >>> # Accessing the model configuration
223
- >>> configuration = model.config
224
- ```
225
- """
226
-
227
- model_type = "esmfold2"
228
- has_no_defaults_at_init = True
229
-
230
- def __init__(self, **kwargs):
231
- super().__init__(**kwargs)
232
-
233
- self.type: str = kwargs.get("type", "release")
234
- if self.type not in ("release", "experimental"):
235
- raise ValueError(
236
- f"ESMFold2Config.type must be 'release' or 'experimental', "
237
- f"got {self.type!r}"
238
- )
239
-
240
- # Top-level scalar fields
241
- self.d_single: int = kwargs.get("d_single", 384)
242
- self.d_pair: int = kwargs.get("d_pair", 256)
243
- self.n_relative_residx_bins: int = kwargs.get("n_relative_residx_bins", 32)
244
- self.n_relative_chain_bins: int = kwargs.get("n_relative_chain_bins", 2)
245
- self.num_loops: int = kwargs.get("num_loops", 10)
246
- self.num_diffusion_samples: int = kwargs.get("num_diffusion_samples", 8)
247
- # If True, ``profile`` / ``deletion_mean`` are zeroed before the inputs
248
- # embedder.
249
- self.disable_msa_features: bool = kwargs.get("disable_msa_features", False)
250
- self.lm_dropout: float = kwargs.get("lm_dropout", 0.0)
251
  self.force_lm_dropout_during_inference: bool = kwargs.get(
252
  "force_lm_dropout_during_inference", False
253
  )
 
254
 
255
  self.lm_d_model: int = kwargs.get("lm_d_model", 2560)
256
- self.lm_num_layers: int = kwargs.get("lm_num_layers", 80)
257
- # Required, no default every shipped HF export must name its ESMC backbone.
258
- self.esmc_id: str = kwargs.get("esmc_id", _DEFAULT_ESMC_HF_REPO)
259
-
260
- def _init_nested(cls, val):
261
- if isinstance(val, cls):
262
- return val
263
- if isinstance(val, dict):
264
- return cls(**val)
265
- return cls()
266
-
267
- self.inputs = _init_nested(InputsEmbedderConfig, kwargs.get("inputs"))
268
- self.folding_trunk = _init_nested(
269
- FoldingTrunkConfig, kwargs.get("folding_trunk")
270
- )
271
- self.structure_head = _init_nested(
272
- DiffusionStructureHeadConfig, kwargs.get("structure_head")
273
- )
274
- self.confidence_head = _init_nested(
275
- ConfidenceHeadConfig, kwargs.get("confidence_head")
276
- )
277
- self.msa_encoder = _init_nested(MSAEncoderConfig, kwargs.get("msa_encoder"))
278
- # Release-only modules — ignored when ``type == "experimental"``.
279
- self.parcae = _init_nested(ParcaeConfig, kwargs.get("parcae"))
280
- self.lm_encoder = _init_nested(LMEncoderConfig, kwargs.get("lm_encoder"))
281
- # If True, MSA encoder output replaces the pair stream; if False, it is added.
282
- self.msa_encoder_overwrite: bool = bool(
283
- kwargs.get("msa_encoder_overwrite", True)
284
- )
285
-
286
- def to_dict(self):
287
- output = super().to_dict()
288
- output["inputs"] = asdict(self.inputs)
289
- output["folding_trunk"] = asdict(self.folding_trunk)
290
- output["structure_head"] = asdict(self.structure_head)
291
- output["confidence_head"] = asdict(self.confidence_head)
292
- output["msa_encoder"] = asdict(self.msa_encoder)
293
- output["parcae"] = asdict(self.parcae)
294
- output["lm_encoder"] = asdict(self.lm_encoder)
295
- return output
296
-
297
-
298
- __all__ = ["ESMFold2Config", "MSAEncoderConfig", "ParcaeConfig", "LMEncoderConfig"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Biohub. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """ESMFold2 model configuration."""
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import asdict, dataclass, field
19
+
20
+ from transformers.configuration_utils import PretrainedConfig
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Nested dataclass configs
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _DEFAULT_ESMC_HF_REPO = "Synthyra/ESMplusplus_6B"
27
+ _DEFAULT_ESMC_ATTN_BACKEND = "flex"
28
+ _ESMC_ID_ALIASES = {
29
+ "biohub/ESMC-300M": "Synthyra/ESMplusplus_small",
30
+ "biohub/ESMC-600M": "Synthyra/ESMplusplus_large",
31
+ "biohub/ESMC-6B": "Synthyra/ESMplusplus_6B",
32
+ }
33
+
34
+
35
+ def normalize_esmc_id(esmc_id: str) -> str:
36
+ if esmc_id in _ESMC_ID_ALIASES:
37
+ return _ESMC_ID_ALIASES[esmc_id]
38
+ return esmc_id
39
+
40
+
41
+ @dataclass
42
+ class MSAEncoderConfig:
43
+ """Config for the optional MSA encoder module (Large MSA models only)."""
44
+
45
+ enabled: bool = False
46
+ d_msa: int = 128
47
+ d_hidden: int = 32
48
+ n_layers: int = 4
49
+ n_heads_msa: int = 8
50
+ msa_head_width: int = 32
51
+
52
+
53
+ @dataclass
54
+ class ParcaeConfig:
55
+ """Release-only config for the parcae diffusion-loop scheduler."""
56
+
57
+ enabled: bool = True
58
+ poisson_mean: float = 3.0
59
+ min_steps: int = 1
60
+ max_steps: int | None = 6
61
+ coda_n_layers: int = 2
62
+
63
+
64
+ @dataclass
65
+ class LMEncoderConfig:
66
+ """Release-only config for the LM-side pair encoder."""
67
+
68
+ enabled: bool = True
69
+ n_layers: int = 4
70
+ lm_dropout: float = 0.25
71
+ per_loop_lm_dropout: bool = True
72
+
73
+
74
+ @dataclass
75
+ class AtomAttentionConfig:
76
+ """Config for SWA atom encoder/decoder with 3D RoPE."""
77
+
78
+ d_atom: int = 128
79
+ d_token: int = 768
80
+ n_blocks: int = 3
81
+ n_heads: int = 4
82
+ swa_window_size: int = 128
83
+ expansion_ratio: int = 2
84
+ # 3D RoPE config
85
+ spatial_rope_base_frequency: float = 20.0
86
+ n_spatial_rope_pairs_per_axis: int = 2
87
+ n_uid_rope_pairs: int = 10
88
+ uid_rope_base_frequency: float = 10000.0
89
+
90
+
91
+ @dataclass
92
+ class FoldingTrunkConfig:
93
+ n_layers: int = 24
94
+ n_heads: int = 8
95
+ dropout: float = 0.0
96
+
97
+
98
+ @dataclass
99
+ class InputsEmbedderConfig:
100
+ d_inputs: int = 451
101
+ atom_encoder: AtomAttentionConfig = field(default_factory=AtomAttentionConfig)
102
+
103
+ def __post_init__(self):
104
+ if isinstance(self.atom_encoder, dict):
105
+ self.atom_encoder = AtomAttentionConfig(**self.atom_encoder)
106
+
107
+
108
+ @dataclass
109
+ class DiffusionModuleConfig:
110
+ """Config for the DiffusionModule."""
111
+
112
+ sigma_data: float = 16.0
113
+ c_atom: int = 128
114
+ c_token: int = 768
115
+ c_z: int = 256
116
+ c_s_inputs: int = 451
117
+ fourier_dim: int = 256
118
+ relpos_r_max: int = 32
119
+ relpos_s_max: int = 2
120
+ atom_num_blocks: int = 3
121
+ atom_num_heads: int = 4
122
+ token_num_blocks: int = 12
123
+ token_num_heads: int = 16
124
+ transition_multiplier: int = 2
125
+
126
+
127
+ @dataclass
128
+ class DiffusionStructureHeadConfig:
129
+ """Config for the diffusion-based structure prediction head."""
130
+
131
+ diffusion_module: DiffusionModuleConfig = field(
132
+ default_factory=DiffusionModuleConfig
133
+ )
134
+ distogram_bins: int = 128
135
+
136
+ # Training noise: sigma ~ sigma_data * exp(mu + sigma * N(0,1))
137
+ train_noise_log_mean: float = -1.2
138
+ train_noise_log_std: float = 1.5
139
+
140
+ # Sampling defaults (ODE)
141
+ gamma_0: float = 0.605
142
+ gamma_min: float = 1.107
143
+ noise_scale: float = 0.0
144
+ step_scale: float = 1.0
145
+
146
+ # Inference schedule defaults
147
+ inference_s_max: float = 160.0
148
+ inference_s_min: float = 4e-4
149
+ inference_p: float = 8.0
150
+ inference_num_steps: int = 68
151
+
152
+ def __post_init__(self):
153
+ if isinstance(self.diffusion_module, dict):
154
+ self.diffusion_module = DiffusionModuleConfig(**self.diffusion_module)
155
+
156
+
157
+ @dataclass
158
+ class ConfidenceHeadConfig:
159
+ enabled: bool = True
160
+ num_plddt_bins: int = 50
161
+ num_pde_bins: int = 64
162
+ num_pae_bins: int = 64
163
+ min_dist: float = 2.0
164
+ max_dist: float = 52.0
165
+ distogram_bins: int = 128
166
+ folding_trunk: FoldingTrunkConfig = field(
167
+ default_factory=lambda: FoldingTrunkConfig(n_layers=4)
168
+ )
169
+
170
+ def __post_init__(self):
171
+ if isinstance(self.folding_trunk, dict):
172
+ self.folding_trunk = FoldingTrunkConfig(**self.folding_trunk)
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Top-level config
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class ESMFold2Config(PretrainedConfig):
181
+ """
182
+ Configuration for the ESMFold2 structure prediction model.
183
+
184
+ Uses SWA atom encoders with 3D RoPE, a diffusion transformer,
185
+ a folding trunk, and an ESMC 6B PLM backbone.
186
+
187
+ Configuration objects inherit from [`PretrainedConfig`] and can be used to control
188
+ the model outputs. Read the documentation from [`PretrainedConfig`] for more
189
+ information.
190
+
191
+ Args:
192
+ d_single (`int`, defaults to 384):
193
+ Dimensionality of single (per-residue) representations.
194
+ d_pair (`int`, defaults to 256):
195
+ Dimensionality of pair (residue-residue) representations.
196
+ n_relative_residx_bins (`int`, defaults to 32):
197
+ Number of bins for relative residue index encoding.
198
+ n_relative_chain_bins (`int`, defaults to 2):
199
+ Number of bins for relative chain encoding.
200
+ num_loops (`int`, defaults to 10):
201
+ Number of trunk loops for iterative refinement.
202
+ num_diffusion_samples (`int`, defaults to 8):
203
+ Number of parallel structure predictions to generate.
204
  lm_dropout (`float`, defaults to 0.0):
205
  Dropout probability on LM pair embeddings. When > 0, dropout is
206
  applied with ``training=True`` (including at inference) to match
 
208
  force_lm_dropout_during_inference (`bool`, defaults to False):
209
  When True, apply ``lm_dropout`` even when ``model.eval()`` and
210
  ``lm_dropout`` > 0. Binder-design loads set this to True.
211
+ lm_mask_pct (`float`, defaults to 0.0):
212
+ Fraction of LM residue tokens randomly replaced with the LM mask
213
+ token before running the PLM backbone.
214
  disable_msa_features (`bool`, defaults to False):
215
  When True, zero out MSA-derived ``profile`` and ``deletion_mean``
216
  before the inputs embedder (experimental medium/large checkpoints).
217
+ inputs (`InputsEmbedderConfig`):
218
+ Configuration for the inputs embedder module.
219
+ folding_trunk (`FoldingTrunkConfig`):
220
+ Configuration for the folding trunk.
221
+ structure_head (`DiffusionStructureHeadConfig`):
222
+ Configuration for the diffusion-based structure prediction head.
223
+ confidence_head (`ConfidenceHeadConfig`):
224
+ Configuration for the confidence prediction head.
225
+
226
+ Examples:
227
+
228
+ ```python
229
+ >>> from transformers import ESMFold2Config, ESMFold2ExperimentalModel
230
+
231
+ >>> # Initializing an ESMFold2 configuration
232
+ >>> configuration = ESMFold2Config(type="experimental")
233
+
234
+ >>> # Initializing a model (with random weights) from the configuration
235
+ >>> model = ESMFold2ExperimentalModel(configuration)
236
+
237
+ >>> # Accessing the model configuration
238
+ >>> configuration = model.config
239
+ ```
240
+ """
241
+
242
+ model_type = "esmfold2"
243
+ has_no_defaults_at_init = True
244
+
245
+ def __init__(self, **kwargs):
246
+ super().__init__(**kwargs)
247
+
248
+ self.type: str = kwargs.get("type", "release")
249
+ if self.type not in ("release", "experimental"):
250
+ raise ValueError(
251
+ f"ESMFold2Config.type must be 'release' or 'experimental', "
252
+ f"got {self.type!r}"
253
+ )
254
+
255
+ # Top-level scalar fields
256
+ self.d_single: int = kwargs.get("d_single", 384)
257
+ self.d_pair: int = kwargs.get("d_pair", 256)
258
+ self.n_relative_residx_bins: int = kwargs.get("n_relative_residx_bins", 32)
259
+ self.n_relative_chain_bins: int = kwargs.get("n_relative_chain_bins", 2)
260
+ self.num_loops: int = kwargs.get("num_loops", 10)
261
+ self.num_diffusion_samples: int = kwargs.get("num_diffusion_samples", 8)
262
+ # If True, ``profile`` / ``deletion_mean`` are zeroed before the inputs
263
+ # embedder.
264
+ self.disable_msa_features: bool = kwargs.get("disable_msa_features", False)
265
+ self.lm_dropout: float = kwargs.get("lm_dropout", 0.0)
266
  self.force_lm_dropout_during_inference: bool = kwargs.get(
267
  "force_lm_dropout_during_inference", False
268
  )
269
+ self.lm_mask_pct: float = kwargs.get("lm_mask_pct", 0.0)
270
 
271
  self.lm_d_model: int = kwargs.get("lm_d_model", 2560)
272
+ self.lm_num_layers: int = kwargs.get("lm_num_layers", 80)
273
+ # Backward-compatible field name; values now point to FastPLMs ESM++.
274
+ raw_esmc_id = (
275
+ kwargs["esmc_id"] if "esmc_id" in kwargs else _DEFAULT_ESMC_HF_REPO
276
+ )
277
+ self.esmc_id: str = normalize_esmc_id(raw_esmc_id)
278
+ self.esmc_attn_backend: str = (
279
+ kwargs["esmc_attn_backend"]
280
+ if "esmc_attn_backend" in kwargs
281
+ else _DEFAULT_ESMC_ATTN_BACKEND
282
+ )
283
+
284
+ def _init_nested(cls, val):
285
+ if isinstance(val, cls):
286
+ return val
287
+ if isinstance(val, dict):
288
+ return cls(**val)
289
+ return cls()
290
+
291
+ self.inputs = _init_nested(InputsEmbedderConfig, kwargs.get("inputs"))
292
+ self.folding_trunk = _init_nested(
293
+ FoldingTrunkConfig, kwargs.get("folding_trunk")
294
+ )
295
+ self.structure_head = _init_nested(
296
+ DiffusionStructureHeadConfig, kwargs.get("structure_head")
297
+ )
298
+ self.confidence_head = _init_nested(
299
+ ConfidenceHeadConfig, kwargs.get("confidence_head")
300
+ )
301
+ self.msa_encoder = _init_nested(MSAEncoderConfig, kwargs.get("msa_encoder"))
302
+ # Release-only modules — ignored when ``type == "experimental"``.
303
+ self.parcae = _init_nested(ParcaeConfig, kwargs.get("parcae"))
304
+ self.lm_encoder = _init_nested(LMEncoderConfig, kwargs.get("lm_encoder"))
305
+ # If True, MSA encoder output replaces the pair stream; if False, it is added.
306
+ self.msa_encoder_overwrite: bool = bool(
307
+ kwargs.get("msa_encoder_overwrite", True)
308
+ )
309
+
310
+ def to_dict(self):
311
+ output = super().to_dict()
312
+ output["inputs"] = asdict(self.inputs)
313
+ output["folding_trunk"] = asdict(self.folding_trunk)
314
+ output["structure_head"] = asdict(self.structure_head)
315
+ output["confidence_head"] = asdict(self.confidence_head)
316
+ output["msa_encoder"] = asdict(self.msa_encoder)
317
+ output["parcae"] = asdict(self.parcae)
318
+ output["lm_encoder"] = asdict(self.lm_encoder)
319
+ return output
320
+
321
+
322
+ __all__ = [
323
+ "ESMFold2Config",
324
+ "MSAEncoderConfig",
325
+ "ParcaeConfig",
326
+ "LMEncoderConfig",
327
+ "normalize_esmc_id",
328
+ ]
esmfold2_affine3d.py CHANGED
@@ -1,560 +1,560 @@
1
- from __future__ import annotations
2
-
3
- import typing as T
4
- from abc import ABC
5
- from dataclasses import dataclass
6
-
7
- import torch
8
- from torch.nn import functional as F
9
- from typing_extensions import Self
10
-
11
- from .esmfold2_misc import fp32_autocast_context
12
-
13
-
14
- class Rotation(ABC):
15
- @classmethod
16
- def identity(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
17
-
18
- @classmethod
19
- def random(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
20
-
21
- def __getitem__(self, idx: T.Any) -> Self: ...
22
-
23
- @property
24
- def tensor(self) -> torch.Tensor:
25
- # We claim that this should be zero-cost abstraction that returns the raw tensor backing this
26
- # object. The raw tensor should always have exactly 1 more dim than self.shape, which should be
27
- # implemented using reshaping
28
- ...
29
-
30
- @property
31
- def shape(self) -> torch.Size:
32
- # The "shape" of the rotation, as if it was a torch.tensor object
33
- # This means that 1x4 quaternions are treated as size (1,) for example
34
- ...
35
-
36
- def as_matrix(self) -> RotationMatrix: ...
37
-
38
- def as_quat(self, normalize: bool = False) -> RotationQuat: ...
39
-
40
- def compose(self, other: Self) -> Self:
41
- # To be safe, we force users to explicitly convert between rotation types.
42
- ...
43
-
44
- def convert_compose(self, other: Self) -> Self:
45
- # This function will automatically convert between types of rotations
46
- ...
47
-
48
- def apply(self, p: torch.Tensor) -> torch.Tensor:
49
- # rotates points by this rotation object
50
- ...
51
-
52
- def invert(self) -> Self: ...
53
-
54
- @property
55
- def dtype(self) -> torch.dtype:
56
- return self.tensor.dtype
57
-
58
- @property
59
- def device(self) -> torch.device:
60
- return self.tensor.device
61
-
62
- @property
63
- def requires_grad(self) -> bool:
64
- return self.tensor.requires_grad
65
-
66
- @classmethod
67
- def _from_tensor(cls, t: torch.Tensor) -> Self:
68
- # This function exists to simplify the below functions, esp type signatures
69
- # Its implementation is different from Affine3D.from_tensor and does not
70
- # autodetect rotation types.
71
- return cls(t) # type: ignore
72
-
73
- def to(self, **kwargs) -> Self:
74
- return self._from_tensor(self.tensor.to(**kwargs))
75
-
76
- def detach(self, *args, **kwargs) -> Self:
77
- return self._from_tensor(self.tensor.detach(**kwargs))
78
-
79
- def tensor_apply(self, func) -> Self:
80
- # Applys a function to the underlying tensor
81
- return self._from_tensor(
82
- torch.stack([func(x) for x in self.tensor.unbind(dim=-1)], dim=-1)
83
- )
84
-
85
-
86
- class RotationMatrix(Rotation):
87
- def __init__(self, rots: torch.Tensor):
88
- if rots.shape[-1] == 9:
89
- rots = rots.unflatten(-1, (3, 3))
90
- assert rots.shape[-1] == 3
91
- assert rots.shape[-2] == 3
92
- # Force full precision
93
- rots = rots.to(torch.float32)
94
- self._rots = rots
95
-
96
- @classmethod
97
- def identity(cls, shape, **tensor_kwargs):
98
- rots = torch.eye(3, **tensor_kwargs)
99
- rots = rots.view(*[1 for _ in range(len(shape))], 3, 3)
100
- rots = rots.expand(*shape, -1, -1)
101
- return cls(rots)
102
-
103
- @classmethod
104
- def random(cls, shape, **tensor_kwargs):
105
- return RotationQuat.random(shape, **tensor_kwargs).as_matrix()
106
-
107
- def __getitem__(self, idx: T.Any) -> RotationMatrix:
108
- indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
109
- return RotationMatrix(self._rots[indices + (slice(None), slice(None))])
110
-
111
- @property
112
- def shape(self) -> torch.Size:
113
- return self._rots.shape[:-2]
114
-
115
- def as_matrix(self) -> RotationMatrix:
116
- return self
117
-
118
- def as_quat(self, normalize: bool = False) -> RotationQuat:
119
- m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
120
- self._rots.flatten(-2), dim=-1
121
- )
122
- q_abs = _sqrt_subgradient(
123
- torch.stack(
124
- [
125
- 1.0 + m00 + m11 + m22,
126
- 1.0 + m00 - m11 - m22,
127
- 1.0 - m00 + m11 - m22,
128
- 1.0 - m00 - m11 + m22,
129
- ],
130
- dim=-1,
131
- )
132
- )
133
- # we produce the desired quaternion multiplied by each of r, i, j, k
134
- quat_by_rijk = torch.stack(
135
- [
136
- x
137
- for lst in [
138
- [q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01],
139
- [m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20],
140
- [m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21],
141
- [m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2],
142
- ]
143
- for x in lst
144
- ],
145
- dim=-1,
146
- ).unflatten(-1, (4, 4))
147
-
148
- # We floor here at 0.1 but the exact level is not important; if q_abs is small,
149
- # the candidate won't be picked.
150
- flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
151
- quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
152
-
153
- # if not for numerical problems, quat_candidates[i] should be same (up to a sign),
154
- # forall i; we pick the best-conditioned one (with the largest denominator)
155
- # We manually implement one_hot so torch.compile works
156
- one_hot = torch.zeros_like(q_abs, dtype=torch.bool)
157
- one_hot.scatter_(-1, q_abs.argmax(dim=-1, keepdim=True), True)
158
- quat = quat_candidates[one_hot, :].reshape(q_abs.shape)
159
- return RotationQuat(quat)
160
-
161
- def compose(self, other: RotationMatrix) -> RotationMatrix:
162
- with fp32_autocast_context(self._rots.device.type):
163
- return RotationMatrix(self._rots @ other._rots)
164
-
165
- def convert_compose(self, other: Rotation):
166
- return self.compose(other.as_matrix())
167
-
168
- def apply(self, p: torch.Tensor) -> torch.Tensor:
169
- with fp32_autocast_context(self.device.type):
170
- if self._rots.shape[-3] == 1:
171
- # This is a slight speedup over einsum for batched rotations
172
- return p @ self._rots.transpose(-1, -2).squeeze(-3)
173
- else:
174
- # einsum way faster than bmm!
175
- return torch.einsum("...ij,...j", self._rots, p)
176
-
177
- def invert(self) -> RotationMatrix:
178
- return RotationMatrix(self._rots.transpose(-1, -2))
179
-
180
- @property
181
- def tensor(self) -> torch.Tensor:
182
- return self._rots.flatten(-2)
183
-
184
- def to_3x3(self) -> torch.Tensor:
185
- return self._rots
186
-
187
- @staticmethod
188
- def from_graham_schmidt(
189
- x_axis: torch.Tensor, xy_plane: torch.Tensor, eps: float = 1e-12
190
- ) -> RotationMatrix:
191
- # A low eps here is necessary for good stability!
192
- return RotationMatrix(_graham_schmidt(x_axis, xy_plane, eps))
193
-
194
-
195
- class RotationQuat(Rotation):
196
- def __init__(self, quats: torch.Tensor, normalized=False):
197
- assert quats.shape[-1] == 4
198
- self._normalized = normalized
199
- # Force float32 as well
200
- if normalized:
201
- self._quats = F.normalize(quats.to(torch.float32), dim=-1)
202
- self._quats = self._quats.where(self._quats[..., :1] >= 0, -self._quats)
203
- else:
204
- self._quats = quats.to(torch.float32)
205
-
206
- @classmethod
207
- def identity(cls, shape, **tensor_kwargs):
208
- q = torch.ones((*shape, 4), **tensor_kwargs)
209
- mult = torch.tensor([1, 0, 0, 0], device=q.device)
210
- return RotationQuat(q * mult)
211
-
212
- @classmethod
213
- def random(cls, shape, **tensor_kwargs):
214
- quat = torch.randn((*shape, 4), **tensor_kwargs)
215
- return RotationQuat(quat, normalized=True)
216
-
217
- def __getitem__(self, idx: T.Any) -> RotationQuat:
218
- indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
219
- return RotationQuat(self._quats[indices + (slice(None),)])
220
-
221
- @property
222
- def shape(self) -> torch.Size:
223
- return self._quats.shape[:-1]
224
-
225
- def compose(self, other: RotationQuat) -> RotationQuat:
226
- with fp32_autocast_context(self._quats.device.type):
227
- return RotationQuat(_quat_mult(self._quats, other._quats))
228
-
229
- def convert_compose(self, other: Rotation):
230
- return self.compose(other.as_quat())
231
-
232
- def as_matrix(self) -> RotationMatrix:
233
- q = self.normalized().tensor
234
- r, i, j, k = torch.unbind(q, -1)
235
- two_s = 2.0 / torch.linalg.norm(q, dim=-1)
236
-
237
- o = torch.stack(
238
- (
239
- 1 - two_s * (j * j + k * k),
240
- two_s * (i * j - k * r),
241
- two_s * (i * k + j * r),
242
- two_s * (i * j + k * r),
243
- 1 - two_s * (i * i + k * k),
244
- two_s * (j * k - i * r),
245
- two_s * (i * k - j * r),
246
- two_s * (j * k + i * r),
247
- 1 - two_s * (i * i + j * j),
248
- ),
249
- -1,
250
- )
251
- return RotationMatrix(o.reshape(q.shape[:-1] + (3, 3)))
252
-
253
- def as_quat(self, normalize: bool = False) -> RotationQuat:
254
- return self
255
-
256
- def apply(self, p: torch.Tensor) -> torch.Tensor:
257
- return _quat_rotation(self.normalized()._quats, p)
258
-
259
- def invert(self) -> RotationQuat:
260
- return RotationQuat(_quat_invert(self._quats))
261
-
262
- @property
263
- def tensor(self) -> torch.Tensor:
264
- return self._quats
265
-
266
- def normalized(self) -> RotationQuat:
267
- return self if self._normalized else RotationQuat(self._quats, normalized=True)
268
-
269
-
270
- @dataclass(frozen=True)
271
- class Affine3D:
272
- trans: torch.Tensor
273
- rot: Rotation
274
-
275
- def __post_init__(self):
276
- assert self.trans.shape[:-1] == self.rot.shape
277
-
278
- @staticmethod
279
- def identity(
280
- shape_or_affine: T.Union[tuple[int, ...], "Affine3D"],
281
- rotation_type: T.Type[Rotation] = RotationMatrix,
282
- **tensor_kwargs,
283
- ):
284
- # Creates a new identity Affine3D object with a specified shape
285
- # or the same shape as another Affine3D object.
286
- if isinstance(shape_or_affine, Affine3D):
287
- kwargs = {"dtype": shape_or_affine.dtype, "device": shape_or_affine.device}
288
- kwargs.update(tensor_kwargs)
289
- shape = shape_or_affine.shape
290
- rotation_type = type(shape_or_affine.rot)
291
- else:
292
- kwargs = tensor_kwargs
293
- shape = shape_or_affine
294
- return Affine3D(
295
- torch.zeros((*shape, 3), **kwargs), rotation_type.identity(shape, **kwargs)
296
- )
297
-
298
- @staticmethod
299
- def random(
300
- shape: tuple[int, ...],
301
- std: float = 1,
302
- rotation_type: T.Type[Rotation] = RotationMatrix,
303
- **tensor_kwargs,
304
- ) -> "Affine3D":
305
- return Affine3D(
306
- trans=torch.randn((*shape, 3), **tensor_kwargs).mul(std),
307
- rot=rotation_type.random(shape, **tensor_kwargs),
308
- )
309
-
310
- def __getitem__(self, idx: T.Any) -> "Affine3D":
311
- indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
312
- return Affine3D(trans=self.trans[indices + (slice(None),)], rot=self.rot[idx])
313
-
314
- @property
315
- def shape(self) -> torch.Size:
316
- return self.trans.shape[:-1]
317
-
318
- @property
319
- def dtype(self) -> torch.dtype:
320
- return self.trans.dtype
321
-
322
- @property
323
- def device(self) -> torch.device:
324
- return self.trans.device
325
-
326
- @property
327
- def requires_grad(self) -> bool:
328
- return self.trans.requires_grad
329
-
330
- def to(self, **kwargs) -> "Affine3D":
331
- return Affine3D(self.trans.to(**kwargs), self.rot.to(**kwargs))
332
-
333
- def detach(self, *args, **kwargs) -> "Affine3D":
334
- return Affine3D(self.trans.detach(**kwargs), self.rot.detach(**kwargs))
335
-
336
- def tensor_apply(self, func) -> "Affine3D":
337
- # Applys a function to the underlying tensor
338
- return self.from_tensor(
339
- torch.stack([func(x) for x in self.tensor.unbind(dim=-1)], dim=-1)
340
- )
341
-
342
- def as_matrix(self):
343
- return Affine3D(trans=self.trans, rot=self.rot.as_matrix())
344
-
345
- def as_quat(self, normalize: bool = False):
346
- return Affine3D(trans=self.trans, rot=self.rot.as_quat(normalize))
347
-
348
- def compose(self, other: "Affine3D", autoconvert: bool = False):
349
- rot = self.rot
350
- new_rot = (rot.convert_compose if autoconvert else rot.compose)(other.rot)
351
- new_trans = rot.apply(other.trans) + self.trans
352
- return Affine3D(trans=new_trans, rot=new_rot)
353
-
354
- def compose_rotation(self, other: Rotation, autoconvert: bool = False):
355
- return Affine3D(
356
- trans=self.trans,
357
- rot=(self.rot.convert_compose if autoconvert else self.rot.compose)(other),
358
- )
359
-
360
- def scale(self, v: torch.Tensor | float):
361
- return Affine3D(self.trans * v, self.rot)
362
-
363
- def mask(self, mask: torch.Tensor, with_zero=False):
364
- # Returns a transform where True positions in mask is identity
365
- if with_zero:
366
- tensor = self.tensor
367
- return Affine3D.from_tensor(
368
- torch.zeros_like(tensor).where(mask[..., None], tensor)
369
- )
370
- else:
371
- identity = self.identity(
372
- self.shape,
373
- rotation_type=type(self.rot),
374
- device=self.device,
375
- dtype=self.dtype,
376
- ).tensor
377
- return Affine3D.from_tensor(identity.where(mask[..., None], self.tensor))
378
-
379
- def apply(self, p: torch.Tensor) -> torch.Tensor:
380
- return self.rot.apply(p) + self.trans
381
-
382
- def invert(self):
383
- inv_rot = self.rot.invert()
384
- return Affine3D(trans=-inv_rot.apply(self.trans), rot=inv_rot)
385
-
386
- @property
387
- def tensor(self) -> torch.Tensor:
388
- return torch.cat([self.rot.tensor, self.trans], dim=-1)
389
-
390
- @staticmethod
391
- def from_tensor(t: torch.Tensor) -> "Affine3D":
392
- match t.shape[-1]:
393
- case 4:
394
- # Assume tensor 4x4 for backward compat with alphafold
395
- trans = t[..., :3, 3]
396
- rot = RotationMatrix(t[..., :3, :3])
397
- case 6:
398
- # Assume quaternion representation with real part = 1
399
- trans = t[..., -3:]
400
- rot = RotationQuat(F.pad(t[..., :3], (1, 0), value=1))
401
- case 7:
402
- trans = t[..., -3:]
403
- rot = RotationQuat(t[..., :4])
404
- case 12:
405
- trans = t[..., -3:]
406
- rot = RotationMatrix(t[..., :-3].unflatten(-1, (3, 3)))
407
- case _:
408
- raise RuntimeError(
409
- f"Cannot detect rotation fromat from {t.shape[-1] -3}-d flat vector"
410
- )
411
- return Affine3D(trans, rot)
412
-
413
- @staticmethod
414
- def from_tensor_pair(t: torch.Tensor, r: torch.Tensor) -> "Affine3D":
415
- return Affine3D(t, RotationMatrix(r))
416
-
417
- @staticmethod
418
- def from_graham_schmidt(
419
- neg_x_axis: torch.Tensor,
420
- origin: torch.Tensor,
421
- xy_plane: torch.Tensor,
422
- eps: float = 1e-10,
423
- ):
424
- # The arguments of this function is for parity with AlphaFold
425
- x_axis = origin - neg_x_axis
426
- xy_plane = xy_plane - origin
427
- return Affine3D(
428
- trans=origin, rot=RotationMatrix.from_graham_schmidt(x_axis, xy_plane, eps)
429
- )
430
-
431
- @staticmethod
432
- def cat(affines: list["Affine3D"], dim: int = 0):
433
- if dim < 0:
434
- dim = len(affines[0].shape) + dim
435
- return Affine3D.from_tensor(torch.cat([x.tensor for x in affines], dim=dim))
436
-
437
-
438
- def _quat_mult(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
439
- """
440
- Multiply two quaternions.
441
- Usual torch rules for broadcasting apply.
442
-
443
- Args:
444
- a: Quaternions as tensor of shape (..., 4), real part first.
445
- b: Quaternions as tensor of shape (..., 4), real part first.
446
-
447
- Returns:
448
- The product of a and b, a tensor of quaternions shape (..., 4).
449
- """
450
- aw, ax, ay, az = torch.unbind(a, -1)
451
- bw, bx, by, bz = torch.unbind(b, -1)
452
- ow = aw * bw - ax * bx - ay * by - az * bz
453
- ox = aw * bx + ax * bw + ay * bz - az * by
454
- oy = aw * by - ax * bz + ay * bw + az * bx
455
- oz = aw * bz + ax * by - ay * bx + az * bw
456
- return torch.stack((ow, ox, oy, oz), -1)
457
-
458
-
459
- def _quat_rotation(q: torch.Tensor, p: torch.Tensor) -> torch.Tensor:
460
- """
461
- Rotates p by quaternion q. Usual torch rules for broadcasting apply.
462
-
463
- Args:
464
- q: Quaternions as tensor of shape (..., 4), real part first.
465
- p: Points as tensor of shape (..., 3)
466
-
467
- Returns:
468
- The rotated version of p, of shape (..., 3)
469
- """
470
- aw, ax, ay, az = torch.unbind(q, -1)
471
- bx, by, bz = torch.unbind(p, -1)
472
- # fmt: off
473
- ow = - ax * bx - ay * by - az * bz
474
- ox = aw * bx + ay * bz - az * by
475
- oy = aw * by - ax * bz + az * bx
476
- oz = aw * bz + ax * by - ay * bx
477
- # fmt: on
478
- q_mul_pts = torch.stack((ow, ox, oy, oz), -1)
479
- return _quat_mult(q_mul_pts, _quat_invert(q))[..., 1:]
480
-
481
-
482
- def _quat_invert(q: torch.Tensor):
483
- return q * torch.tensor([1, -1, -1, -1], device=q.device)
484
-
485
-
486
- def _sqrt_subgradient(x: torch.Tensor) -> torch.Tensor:
487
- # Returns torch.sqrt(torch.max(0, x)) but with a zero subgradient where x is 0.
488
- ret = torch.zeros_like(x)
489
- positive_mask = x > 0
490
- ret[positive_mask] = torch.sqrt(x[positive_mask])
491
- return ret
492
-
493
-
494
- def _graham_schmidt(x_axis: torch.Tensor, xy_plane: torch.Tensor, eps: float = 1e-12):
495
- # A low eps here is necessary for good stability!
496
- with fp32_autocast_context(x_axis.device.type):
497
- e1 = xy_plane
498
-
499
- denom = torch.sqrt((x_axis**2).sum(dim=-1, keepdim=True) + eps)
500
- x_axis = x_axis / denom
501
- dot = (x_axis * e1).sum(dim=-1, keepdim=True)
502
- e1 = e1 - x_axis * dot
503
- denom = torch.sqrt((e1**2).sum(dim=-1, keepdim=True) + eps)
504
- e1 = e1 / denom
505
- e2 = torch.cross(x_axis, e1, dim=-1)
506
-
507
- rots = torch.stack([x_axis, e1, e2], dim=-1)
508
-
509
- return rots
510
-
511
-
512
- def build_affine3d_from_coordinates(
513
- coords: torch.Tensor, # (N, CA, C).
514
- ) -> tuple[Affine3D, torch.Tensor]:
515
- _MAX_SUPPORTED_DISTANCE = 1e6
516
- coord_mask = torch.all(
517
- torch.all(torch.isfinite(coords) & (coords < _MAX_SUPPORTED_DISTANCE), dim=-1),
518
- dim=-1,
519
- )
520
-
521
- def atom3_to_backbone_affine(bb_positions: torch.Tensor) -> Affine3D:
522
- N, CA, C = bb_positions.unbind(dim=-2)
523
- return Affine3D.from_graham_schmidt(C, CA, N)
524
-
525
- coords = coords.clone().float()
526
- coords[~coord_mask] = 0
527
-
528
- # NOTE(thayes): If you have already normalized the coordinates, then
529
- # the black hole affine translations will be zeros and the rotations will be
530
- # the identity.
531
- average_per_n_ca_c = coords.masked_fill(~coord_mask[..., None, None], 0).sum(1) / (
532
- coord_mask.sum(-1)[..., None, None] + 1e-8
533
- )
534
- affine_from_average = atom3_to_backbone_affine(
535
- average_per_n_ca_c.float()
536
- ).as_matrix()
537
-
538
- B, S, _, _ = coords.shape
539
- assert isinstance(B, int)
540
- assert isinstance(S, int)
541
- affine_rot_mats = affine_from_average.rot.tensor[..., None, :].expand(B, S, 9)
542
- affine_trans = affine_from_average.trans[..., None, :].expand(B, S, 3)
543
-
544
- # We use the identity rotation whereever we have no coordinates. This is
545
- # important because otherwise the rotation matrices will be all zeros, which
546
- # will cause collapse in the distance/direction attention mechanism.
547
- identity_rot = RotationMatrix.identity(
548
- (B, S), dtype=torch.float32, device=coords.device, requires_grad=False
549
- )
550
- affine_rot_mats = affine_rot_mats.where(
551
- coord_mask.any(-1)[..., None, None], identity_rot.tensor
552
- )
553
- black_hole_affine = Affine3D(affine_trans, RotationMatrix(affine_rot_mats))
554
-
555
- affine = atom3_to_backbone_affine(coords.float())
556
- affine = Affine3D.from_tensor(
557
- affine.tensor.where(coord_mask[..., None], black_hole_affine.tensor)
558
- )
559
-
560
- return affine, coord_mask
 
1
+ from __future__ import annotations
2
+
3
+ import typing as T
4
+ from abc import ABC
5
+ from dataclasses import dataclass
6
+
7
+ import torch
8
+ from torch.nn import functional as F
9
+ from typing_extensions import Self
10
+
11
+ from .esmfold2_misc import fp32_autocast_context
12
+
13
+
14
+ class Rotation(ABC):
15
+ @classmethod
16
+ def identity(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
17
+
18
+ @classmethod
19
+ def random(cls, shape: tuple[int, ...], **tensor_kwargs) -> Self: ...
20
+
21
+ def __getitem__(self, idx: T.Any) -> Self: ...
22
+
23
+ @property
24
+ def tensor(self) -> torch.Tensor:
25
+ # We claim that this should be zero-cost abstraction that returns the raw tensor backing this
26
+ # object. The raw tensor should always have exactly 1 more dim than self.shape, which should be
27
+ # implemented using reshaping
28
+ ...
29
+
30
+ @property
31
+ def shape(self) -> torch.Size:
32
+ # The "shape" of the rotation, as if it was a torch.tensor object
33
+ # This means that 1x4 quaternions are treated as size (1,) for example
34
+ ...
35
+
36
+ def as_matrix(self) -> RotationMatrix: ...
37
+
38
+ def as_quat(self, normalize: bool = False) -> RotationQuat: ...
39
+
40
+ def compose(self, other: Self) -> Self:
41
+ # To be safe, we force users to explicitly convert between rotation types.
42
+ ...
43
+
44
+ def convert_compose(self, other: Self) -> Self:
45
+ # This function will automatically convert between types of rotations
46
+ ...
47
+
48
+ def apply(self, p: torch.Tensor) -> torch.Tensor:
49
+ # rotates points by this rotation object
50
+ ...
51
+
52
+ def invert(self) -> Self: ...
53
+
54
+ @property
55
+ def dtype(self) -> torch.dtype:
56
+ return self.tensor.dtype
57
+
58
+ @property
59
+ def device(self) -> torch.device:
60
+ return self.tensor.device
61
+
62
+ @property
63
+ def requires_grad(self) -> bool:
64
+ return self.tensor.requires_grad
65
+
66
+ @classmethod
67
+ def _from_tensor(cls, t: torch.Tensor) -> Self:
68
+ # This function exists to simplify the below functions, esp type signatures
69
+ # Its implementation is different from Affine3D.from_tensor and does not
70
+ # autodetect rotation types.
71
+ return cls(t) # type: ignore
72
+
73
+ def to(self, **kwargs) -> Self:
74
+ return self._from_tensor(self.tensor.to(**kwargs))
75
+
76
+ def detach(self, *args, **kwargs) -> Self:
77
+ return self._from_tensor(self.tensor.detach(**kwargs))
78
+
79
+ def tensor_apply(self, func) -> Self:
80
+ # Applys a function to the underlying tensor
81
+ return self._from_tensor(
82
+ torch.stack([func(x) for x in self.tensor.unbind(dim=-1)], dim=-1)
83
+ )
84
+
85
+
86
+ class RotationMatrix(Rotation):
87
+ def __init__(self, rots: torch.Tensor):
88
+ if rots.shape[-1] == 9:
89
+ rots = rots.unflatten(-1, (3, 3))
90
+ assert rots.shape[-1] == 3
91
+ assert rots.shape[-2] == 3
92
+ # Force full precision
93
+ rots = rots.to(torch.float32)
94
+ self._rots = rots
95
+
96
+ @classmethod
97
+ def identity(cls, shape, **tensor_kwargs):
98
+ rots = torch.eye(3, **tensor_kwargs)
99
+ rots = rots.view(*[1 for _ in range(len(shape))], 3, 3)
100
+ rots = rots.expand(*shape, -1, -1)
101
+ return cls(rots)
102
+
103
+ @classmethod
104
+ def random(cls, shape, **tensor_kwargs):
105
+ return RotationQuat.random(shape, **tensor_kwargs).as_matrix()
106
+
107
+ def __getitem__(self, idx: T.Any) -> RotationMatrix:
108
+ indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
109
+ return RotationMatrix(self._rots[indices + (slice(None), slice(None))])
110
+
111
+ @property
112
+ def shape(self) -> torch.Size:
113
+ return self._rots.shape[:-2]
114
+
115
+ def as_matrix(self) -> RotationMatrix:
116
+ return self
117
+
118
+ def as_quat(self, normalize: bool = False) -> RotationQuat:
119
+ m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
120
+ self._rots.flatten(-2), dim=-1
121
+ )
122
+ q_abs = _sqrt_subgradient(
123
+ torch.stack(
124
+ [
125
+ 1.0 + m00 + m11 + m22,
126
+ 1.0 + m00 - m11 - m22,
127
+ 1.0 - m00 + m11 - m22,
128
+ 1.0 - m00 - m11 + m22,
129
+ ],
130
+ dim=-1,
131
+ )
132
+ )
133
+ # we produce the desired quaternion multiplied by each of r, i, j, k
134
+ quat_by_rijk = torch.stack(
135
+ [
136
+ x
137
+ for lst in [
138
+ [q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01],
139
+ [m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20],
140
+ [m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21],
141
+ [m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2],
142
+ ]
143
+ for x in lst
144
+ ],
145
+ dim=-1,
146
+ ).unflatten(-1, (4, 4))
147
+
148
+ # We floor here at 0.1 but the exact level is not important; if q_abs is small,
149
+ # the candidate won't be picked.
150
+ flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
151
+ quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
152
+
153
+ # if not for numerical problems, quat_candidates[i] should be same (up to a sign),
154
+ # forall i; we pick the best-conditioned one (with the largest denominator)
155
+ # We manually implement one_hot so torch.compile works
156
+ one_hot = torch.zeros_like(q_abs, dtype=torch.bool)
157
+ one_hot.scatter_(-1, q_abs.argmax(dim=-1, keepdim=True), True)
158
+ quat = quat_candidates[one_hot, :].reshape(q_abs.shape)
159
+ return RotationQuat(quat)
160
+
161
+ def compose(self, other: RotationMatrix) -> RotationMatrix:
162
+ with fp32_autocast_context(self._rots.device.type):
163
+ return RotationMatrix(self._rots @ other._rots)
164
+
165
+ def convert_compose(self, other: Rotation):
166
+ return self.compose(other.as_matrix())
167
+
168
+ def apply(self, p: torch.Tensor) -> torch.Tensor:
169
+ with fp32_autocast_context(self.device.type):
170
+ if self._rots.shape[-3] == 1:
171
+ # This is a slight speedup over einsum for batched rotations
172
+ return p @ self._rots.transpose(-1, -2).squeeze(-3)
173
+ else:
174
+ # einsum way faster than bmm!
175
+ return torch.einsum("...ij,...j", self._rots, p)
176
+
177
+ def invert(self) -> RotationMatrix:
178
+ return RotationMatrix(self._rots.transpose(-1, -2))
179
+
180
+ @property
181
+ def tensor(self) -> torch.Tensor:
182
+ return self._rots.flatten(-2)
183
+
184
+ def to_3x3(self) -> torch.Tensor:
185
+ return self._rots
186
+
187
+ @staticmethod
188
+ def from_graham_schmidt(
189
+ x_axis: torch.Tensor, xy_plane: torch.Tensor, eps: float = 1e-12
190
+ ) -> RotationMatrix:
191
+ # A low eps here is necessary for good stability!
192
+ return RotationMatrix(_graham_schmidt(x_axis, xy_plane, eps))
193
+
194
+
195
+ class RotationQuat(Rotation):
196
+ def __init__(self, quats: torch.Tensor, normalized=False):
197
+ assert quats.shape[-1] == 4
198
+ self._normalized = normalized
199
+ # Force float32 as well
200
+ if normalized:
201
+ self._quats = F.normalize(quats.to(torch.float32), dim=-1)
202
+ self._quats = self._quats.where(self._quats[..., :1] >= 0, -self._quats)
203
+ else:
204
+ self._quats = quats.to(torch.float32)
205
+
206
+ @classmethod
207
+ def identity(cls, shape, **tensor_kwargs):
208
+ q = torch.ones((*shape, 4), **tensor_kwargs)
209
+ mult = torch.tensor([1, 0, 0, 0], device=q.device)
210
+ return RotationQuat(q * mult)
211
+
212
+ @classmethod
213
+ def random(cls, shape, **tensor_kwargs):
214
+ quat = torch.randn((*shape, 4), **tensor_kwargs)
215
+ return RotationQuat(quat, normalized=True)
216
+
217
+ def __getitem__(self, idx: T.Any) -> RotationQuat:
218
+ indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
219
+ return RotationQuat(self._quats[indices + (slice(None),)])
220
+
221
+ @property
222
+ def shape(self) -> torch.Size:
223
+ return self._quats.shape[:-1]
224
+
225
+ def compose(self, other: RotationQuat) -> RotationQuat:
226
+ with fp32_autocast_context(self._quats.device.type):
227
+ return RotationQuat(_quat_mult(self._quats, other._quats))
228
+
229
+ def convert_compose(self, other: Rotation):
230
+ return self.compose(other.as_quat())
231
+
232
+ def as_matrix(self) -> RotationMatrix:
233
+ q = self.normalized().tensor
234
+ r, i, j, k = torch.unbind(q, -1)
235
+ two_s = 2.0 / torch.linalg.norm(q, dim=-1)
236
+
237
+ o = torch.stack(
238
+ (
239
+ 1 - two_s * (j * j + k * k),
240
+ two_s * (i * j - k * r),
241
+ two_s * (i * k + j * r),
242
+ two_s * (i * j + k * r),
243
+ 1 - two_s * (i * i + k * k),
244
+ two_s * (j * k - i * r),
245
+ two_s * (i * k - j * r),
246
+ two_s * (j * k + i * r),
247
+ 1 - two_s * (i * i + j * j),
248
+ ),
249
+ -1,
250
+ )
251
+ return RotationMatrix(o.reshape(q.shape[:-1] + (3, 3)))
252
+
253
+ def as_quat(self, normalize: bool = False) -> RotationQuat:
254
+ return self
255
+
256
+ def apply(self, p: torch.Tensor) -> torch.Tensor:
257
+ return _quat_rotation(self.normalized()._quats, p)
258
+
259
+ def invert(self) -> RotationQuat:
260
+ return RotationQuat(_quat_invert(self._quats))
261
+
262
+ @property
263
+ def tensor(self) -> torch.Tensor:
264
+ return self._quats
265
+
266
+ def normalized(self) -> RotationQuat:
267
+ return self if self._normalized else RotationQuat(self._quats, normalized=True)
268
+
269
+
270
+ @dataclass(frozen=True)
271
+ class Affine3D:
272
+ trans: torch.Tensor
273
+ rot: Rotation
274
+
275
+ def __post_init__(self):
276
+ assert self.trans.shape[:-1] == self.rot.shape
277
+
278
+ @staticmethod
279
+ def identity(
280
+ shape_or_affine: T.Union[tuple[int, ...], "Affine3D"],
281
+ rotation_type: T.Type[Rotation] = RotationMatrix,
282
+ **tensor_kwargs,
283
+ ):
284
+ # Creates a new identity Affine3D object with a specified shape
285
+ # or the same shape as another Affine3D object.
286
+ if isinstance(shape_or_affine, Affine3D):
287
+ kwargs = {"dtype": shape_or_affine.dtype, "device": shape_or_affine.device}
288
+ kwargs.update(tensor_kwargs)
289
+ shape = shape_or_affine.shape
290
+ rotation_type = type(shape_or_affine.rot)
291
+ else:
292
+ kwargs = tensor_kwargs
293
+ shape = shape_or_affine
294
+ return Affine3D(
295
+ torch.zeros((*shape, 3), **kwargs), rotation_type.identity(shape, **kwargs)
296
+ )
297
+
298
+ @staticmethod
299
+ def random(
300
+ shape: tuple[int, ...],
301
+ std: float = 1,
302
+ rotation_type: T.Type[Rotation] = RotationMatrix,
303
+ **tensor_kwargs,
304
+ ) -> "Affine3D":
305
+ return Affine3D(
306
+ trans=torch.randn((*shape, 3), **tensor_kwargs).mul(std),
307
+ rot=rotation_type.random(shape, **tensor_kwargs),
308
+ )
309
+
310
+ def __getitem__(self, idx: T.Any) -> "Affine3D":
311
+ indices = (idx,) if isinstance(idx, int) or idx is None else tuple(idx)
312
+ return Affine3D(trans=self.trans[indices + (slice(None),)], rot=self.rot[idx])
313
+
314
+ @property
315
+ def shape(self) -> torch.Size:
316
+ return self.trans.shape[:-1]
317
+
318
+ @property
319
+ def dtype(self) -> torch.dtype:
320
+ return self.trans.dtype
321
+
322
+ @property
323
+ def device(self) -> torch.device:
324
+ return self.trans.device
325
+
326
+ @property
327
+ def requires_grad(self) -> bool:
328
+ return self.trans.requires_grad
329
+
330
+ def to(self, **kwargs) -> "Affine3D":
331
+ return Affine3D(self.trans.to(**kwargs), self.rot.to(**kwargs))
332
+
333
+ def detach(self, *args, **kwargs) -> "Affine3D":
334
+ return Affine3D(self.trans.detach(**kwargs), self.rot.detach(**kwargs))
335
+
336
+ def tensor_apply(self, func) -> "Affine3D":
337
+ # Applys a function to the underlying tensor
338
+ return self.from_tensor(
339
+ torch.stack([func(x) for x in self.tensor.unbind(dim=-1)], dim=-1)
340
+ )
341
+
342
+ def as_matrix(self):
343
+ return Affine3D(trans=self.trans, rot=self.rot.as_matrix())
344
+
345
+ def as_quat(self, normalize: bool = False):
346
+ return Affine3D(trans=self.trans, rot=self.rot.as_quat(normalize))
347
+
348
+ def compose(self, other: "Affine3D", autoconvert: bool = False):
349
+ rot = self.rot
350
+ new_rot = (rot.convert_compose if autoconvert else rot.compose)(other.rot)
351
+ new_trans = rot.apply(other.trans) + self.trans
352
+ return Affine3D(trans=new_trans, rot=new_rot)
353
+
354
+ def compose_rotation(self, other: Rotation, autoconvert: bool = False):
355
+ return Affine3D(
356
+ trans=self.trans,
357
+ rot=(self.rot.convert_compose if autoconvert else self.rot.compose)(other),
358
+ )
359
+
360
+ def scale(self, v: torch.Tensor | float):
361
+ return Affine3D(self.trans * v, self.rot)
362
+
363
+ def mask(self, mask: torch.Tensor, with_zero=False):
364
+ # Returns a transform where True positions in mask is identity
365
+ if with_zero:
366
+ tensor = self.tensor
367
+ return Affine3D.from_tensor(
368
+ torch.zeros_like(tensor).where(mask[..., None], tensor)
369
+ )
370
+ else:
371
+ identity = self.identity(
372
+ self.shape,
373
+ rotation_type=type(self.rot),
374
+ device=self.device,
375
+ dtype=self.dtype,
376
+ ).tensor
377
+ return Affine3D.from_tensor(identity.where(mask[..., None], self.tensor))
378
+
379
+ def apply(self, p: torch.Tensor) -> torch.Tensor:
380
+ return self.rot.apply(p) + self.trans
381
+
382
+ def invert(self):
383
+ inv_rot = self.rot.invert()
384
+ return Affine3D(trans=-inv_rot.apply(self.trans), rot=inv_rot)
385
+
386
+ @property
387
+ def tensor(self) -> torch.Tensor:
388
+ return torch.cat([self.rot.tensor, self.trans], dim=-1)
389
+
390
+ @staticmethod
391
+ def from_tensor(t: torch.Tensor) -> "Affine3D":
392
+ match t.shape[-1]:
393
+ case 4:
394
+ # Assume tensor 4x4 for backward compat with alphafold
395
+ trans = t[..., :3, 3]
396
+ rot = RotationMatrix(t[..., :3, :3])
397
+ case 6:
398
+ # Assume quaternion representation with real part = 1
399
+ trans = t[..., -3:]
400
+ rot = RotationQuat(F.pad(t[..., :3], (1, 0), value=1))
401
+ case 7:
402
+ trans = t[..., -3:]
403
+ rot = RotationQuat(t[..., :4])
404
+ case 12:
405
+ trans = t[..., -3:]
406
+ rot = RotationMatrix(t[..., :-3].unflatten(-1, (3, 3)))
407
+ case _:
408
+ raise RuntimeError(
409
+ f"Cannot detect rotation fromat from {t.shape[-1] -3}-d flat vector"
410
+ )
411
+ return Affine3D(trans, rot)
412
+
413
+ @staticmethod
414
+ def from_tensor_pair(t: torch.Tensor, r: torch.Tensor) -> "Affine3D":
415
+ return Affine3D(t, RotationMatrix(r))
416
+
417
+ @staticmethod
418
+ def from_graham_schmidt(
419
+ neg_x_axis: torch.Tensor,
420
+ origin: torch.Tensor,
421
+ xy_plane: torch.Tensor,
422
+ eps: float = 1e-10,
423
+ ):
424
+ # The arguments of this function is for parity with AlphaFold
425
+ x_axis = origin - neg_x_axis
426
+ xy_plane = xy_plane - origin
427
+ return Affine3D(
428
+ trans=origin, rot=RotationMatrix.from_graham_schmidt(x_axis, xy_plane, eps)
429
+ )
430
+
431
+ @staticmethod
432
+ def cat(affines: list["Affine3D"], dim: int = 0):
433
+ if dim < 0:
434
+ dim = len(affines[0].shape) + dim
435
+ return Affine3D.from_tensor(torch.cat([x.tensor for x in affines], dim=dim))
436
+
437
+
438
+ def _quat_mult(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
439
+ """
440
+ Multiply two quaternions.
441
+ Usual torch rules for broadcasting apply.
442
+
443
+ Args:
444
+ a: Quaternions as tensor of shape (..., 4), real part first.
445
+ b: Quaternions as tensor of shape (..., 4), real part first.
446
+
447
+ Returns:
448
+ The product of a and b, a tensor of quaternions shape (..., 4).
449
+ """
450
+ aw, ax, ay, az = torch.unbind(a, -1)
451
+ bw, bx, by, bz = torch.unbind(b, -1)
452
+ ow = aw * bw - ax * bx - ay * by - az * bz
453
+ ox = aw * bx + ax * bw + ay * bz - az * by
454
+ oy = aw * by - ax * bz + ay * bw + az * bx
455
+ oz = aw * bz + ax * by - ay * bx + az * bw
456
+ return torch.stack((ow, ox, oy, oz), -1)
457
+
458
+
459
+ def _quat_rotation(q: torch.Tensor, p: torch.Tensor) -> torch.Tensor:
460
+ """
461
+ Rotates p by quaternion q. Usual torch rules for broadcasting apply.
462
+
463
+ Args:
464
+ q: Quaternions as tensor of shape (..., 4), real part first.
465
+ p: Points as tensor of shape (..., 3)
466
+
467
+ Returns:
468
+ The rotated version of p, of shape (..., 3)
469
+ """
470
+ aw, ax, ay, az = torch.unbind(q, -1)
471
+ bx, by, bz = torch.unbind(p, -1)
472
+ # fmt: off
473
+ ow = - ax * bx - ay * by - az * bz
474
+ ox = aw * bx + ay * bz - az * by
475
+ oy = aw * by - ax * bz + az * bx
476
+ oz = aw * bz + ax * by - ay * bx
477
+ # fmt: on
478
+ q_mul_pts = torch.stack((ow, ox, oy, oz), -1)
479
+ return _quat_mult(q_mul_pts, _quat_invert(q))[..., 1:]
480
+
481
+
482
+ def _quat_invert(q: torch.Tensor):
483
+ return q * torch.tensor([1, -1, -1, -1], device=q.device)
484
+
485
+
486
+ def _sqrt_subgradient(x: torch.Tensor) -> torch.Tensor:
487
+ # Returns torch.sqrt(torch.max(0, x)) but with a zero subgradient where x is 0.
488
+ ret = torch.zeros_like(x)
489
+ positive_mask = x > 0
490
+ ret[positive_mask] = torch.sqrt(x[positive_mask])
491
+ return ret
492
+
493
+
494
+ def _graham_schmidt(x_axis: torch.Tensor, xy_plane: torch.Tensor, eps: float = 1e-12):
495
+ # A low eps here is necessary for good stability!
496
+ with fp32_autocast_context(x_axis.device.type):
497
+ e1 = xy_plane
498
+
499
+ denom = torch.sqrt((x_axis**2).sum(dim=-1, keepdim=True) + eps)
500
+ x_axis = x_axis / denom
501
+ dot = (x_axis * e1).sum(dim=-1, keepdim=True)
502
+ e1 = e1 - x_axis * dot
503
+ denom = torch.sqrt((e1**2).sum(dim=-1, keepdim=True) + eps)
504
+ e1 = e1 / denom
505
+ e2 = torch.cross(x_axis, e1, dim=-1)
506
+
507
+ rots = torch.stack([x_axis, e1, e2], dim=-1)
508
+
509
+ return rots
510
+
511
+
512
+ def build_affine3d_from_coordinates(
513
+ coords: torch.Tensor, # (N, CA, C).
514
+ ) -> tuple[Affine3D, torch.Tensor]:
515
+ _MAX_SUPPORTED_DISTANCE = 1e6
516
+ coord_mask = torch.all(
517
+ torch.all(torch.isfinite(coords) & (coords < _MAX_SUPPORTED_DISTANCE), dim=-1),
518
+ dim=-1,
519
+ )
520
+
521
+ def atom3_to_backbone_affine(bb_positions: torch.Tensor) -> Affine3D:
522
+ N, CA, C = bb_positions.unbind(dim=-2)
523
+ return Affine3D.from_graham_schmidt(C, CA, N)
524
+
525
+ coords = coords.clone().float()
526
+ coords[~coord_mask] = 0
527
+
528
+ # NOTE(thayes): If you have already normalized the coordinates, then
529
+ # the black hole affine translations will be zeros and the rotations will be
530
+ # the identity.
531
+ average_per_n_ca_c = coords.masked_fill(~coord_mask[..., None, None], 0).sum(1) / (
532
+ coord_mask.sum(-1)[..., None, None] + 1e-8
533
+ )
534
+ affine_from_average = atom3_to_backbone_affine(
535
+ average_per_n_ca_c.float()
536
+ ).as_matrix()
537
+
538
+ B, S, _, _ = coords.shape
539
+ assert isinstance(B, int)
540
+ assert isinstance(S, int)
541
+ affine_rot_mats = affine_from_average.rot.tensor[..., None, :].expand(B, S, 9)
542
+ affine_trans = affine_from_average.trans[..., None, :].expand(B, S, 3)
543
+
544
+ # We use the identity rotation whereever we have no coordinates. This is
545
+ # important because otherwise the rotation matrices will be all zeros, which
546
+ # will cause collapse in the distance/direction attention mechanism.
547
+ identity_rot = RotationMatrix.identity(
548
+ (B, S), dtype=torch.float32, device=coords.device, requires_grad=False
549
+ )
550
+ affine_rot_mats = affine_rot_mats.where(
551
+ coord_mask.any(-1)[..., None, None], identity_rot.tensor
552
+ )
553
+ black_hole_affine = Affine3D(affine_trans, RotationMatrix(affine_rot_mats))
554
+
555
+ affine = atom3_to_backbone_affine(coords.float())
556
+ affine = Affine3D.from_tensor(
557
+ affine.tensor.where(coord_mask[..., None], black_hole_affine.tensor)
558
+ )
559
+
560
+ return affine, coord_mask
esmfold2_aligner.py CHANGED
@@ -1,101 +1,101 @@
1
- from __future__ import annotations
2
-
3
- from dataclasses import Field, replace
4
- from typing import Any, ClassVar, Protocol, TypeVar
5
-
6
- import numpy as np
7
- import torch
8
-
9
- from .esmfold2_protein_structure import compute_affine_and_rmsd
10
-
11
-
12
- class Alignable(Protocol):
13
- # Trick to detect whether an object is a dataclass
14
- __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
15
-
16
- @property
17
- def atom37_positions(self) -> np.ndarray: # type: ignore
18
- pass
19
-
20
- @property
21
- def atom37_mask(self) -> np.ndarray: # type: ignore
22
- pass
23
-
24
- def __len__(self) -> int: ...
25
-
26
-
27
- T = TypeVar("T", bound=Alignable)
28
-
29
-
30
- class Aligner:
31
- def __init__(
32
- self,
33
- mobile: Alignable,
34
- target: Alignable,
35
- only_use_backbone: bool = False,
36
- use_reflection: bool = False,
37
- ):
38
- """
39
- Aligns a mobile protein chain against a target protein chain.
40
-
41
- Args:
42
- mobile (ProteinChain): Protein chain to be aligned.
43
- target (ProteinChain): Protein chain target.
44
- only_use_backbone (bool): Whether to only use backbone atoms.
45
- use_reflection (bool): Whether to align to target reflection.
46
- """
47
- # Check proteins must have same number of residues
48
- assert len(mobile) == len(target)
49
-
50
- # Determine overlapping atoms
51
- joint_atom37_mask = mobile.atom37_mask.astype(bool) & target.atom37_mask.astype(
52
- bool
53
- )
54
-
55
- # Backbone atoms are first sites in atom37 representation
56
- if only_use_backbone:
57
- joint_atom37_mask[:, 3:] = False
58
-
59
- # Extract matching atom positions and convert to batched tensors
60
- mobile_atom_tensor = (
61
- torch.from_numpy(mobile.atom37_positions).type(torch.double).unsqueeze(0)
62
- )
63
- target_atom_tensor = (
64
- torch.from_numpy(target.atom37_positions).type(torch.double).unsqueeze(0)
65
- )
66
- joint_atom37_mask = (
67
- torch.from_numpy(joint_atom37_mask).type(torch.bool).unsqueeze(0)
68
- )
69
-
70
- # If using reflection flip target
71
- if use_reflection:
72
- target_atom_tensor = -target_atom_tensor
73
-
74
- # Compute alignment and rmsd
75
- affine3D, rmsd = compute_affine_and_rmsd(
76
- mobile_atom_tensor, target_atom_tensor, atom_exists_mask=joint_atom37_mask
77
- )
78
- self._affine3D = affine3D
79
- self._rmsd = rmsd.item()
80
-
81
- @property
82
- def rmsd(self):
83
- return self._rmsd
84
-
85
- def apply(self, mobile: T) -> T:
86
- """Apply alignment to a protein chain"""
87
- # Extract atom positions and convert to batched tensors
88
- mobile_atom_tensor = (
89
- torch.from_numpy(mobile.atom37_positions[mobile.atom37_mask])
90
- .type(torch.float32)
91
- .unsqueeze(0)
92
- )
93
-
94
- # Transform atom arrays
95
- aligned_atom_tensor = self._affine3D.apply(mobile_atom_tensor).squeeze(0)
96
-
97
- # Rebuild atom37 positions
98
- aligned_atom37_positions = np.full_like(mobile.atom37_positions, np.nan)
99
- aligned_atom37_positions[mobile.atom37_mask] = aligned_atom_tensor
100
-
101
- return replace(mobile, atom37_positions=aligned_atom37_positions)
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import Field, replace
4
+ from typing import Any, ClassVar, Protocol, TypeVar
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+ from .esmfold2_protein_structure import compute_affine_and_rmsd
10
+
11
+
12
+ class Alignable(Protocol):
13
+ # Trick to detect whether an object is a dataclass
14
+ __dataclass_fields__: ClassVar[dict[str, Field[Any]]]
15
+
16
+ @property
17
+ def atom37_positions(self) -> np.ndarray: # type: ignore
18
+ pass
19
+
20
+ @property
21
+ def atom37_mask(self) -> np.ndarray: # type: ignore
22
+ pass
23
+
24
+ def __len__(self) -> int: ...
25
+
26
+
27
+ T = TypeVar("T", bound=Alignable)
28
+
29
+
30
+ class Aligner:
31
+ def __init__(
32
+ self,
33
+ mobile: Alignable,
34
+ target: Alignable,
35
+ only_use_backbone: bool = False,
36
+ use_reflection: bool = False,
37
+ ):
38
+ """
39
+ Aligns a mobile protein chain against a target protein chain.
40
+
41
+ Args:
42
+ mobile (ProteinChain): Protein chain to be aligned.
43
+ target (ProteinChain): Protein chain target.
44
+ only_use_backbone (bool): Whether to only use backbone atoms.
45
+ use_reflection (bool): Whether to align to target reflection.
46
+ """
47
+ # Check proteins must have same number of residues
48
+ assert len(mobile) == len(target)
49
+
50
+ # Determine overlapping atoms
51
+ joint_atom37_mask = mobile.atom37_mask.astype(bool) & target.atom37_mask.astype(
52
+ bool
53
+ )
54
+
55
+ # Backbone atoms are first sites in atom37 representation
56
+ if only_use_backbone:
57
+ joint_atom37_mask[:, 3:] = False
58
+
59
+ # Extract matching atom positions and convert to batched tensors
60
+ mobile_atom_tensor = (
61
+ torch.from_numpy(mobile.atom37_positions).type(torch.double).unsqueeze(0)
62
+ )
63
+ target_atom_tensor = (
64
+ torch.from_numpy(target.atom37_positions).type(torch.double).unsqueeze(0)
65
+ )
66
+ joint_atom37_mask = (
67
+ torch.from_numpy(joint_atom37_mask).type(torch.bool).unsqueeze(0)
68
+ )
69
+
70
+ # If using reflection flip target
71
+ if use_reflection:
72
+ target_atom_tensor = -target_atom_tensor
73
+
74
+ # Compute alignment and rmsd
75
+ affine3D, rmsd = compute_affine_and_rmsd(
76
+ mobile_atom_tensor, target_atom_tensor, atom_exists_mask=joint_atom37_mask
77
+ )
78
+ self._affine3D = affine3D
79
+ self._rmsd = rmsd.item()
80
+
81
+ @property
82
+ def rmsd(self):
83
+ return self._rmsd
84
+
85
+ def apply(self, mobile: T) -> T:
86
+ """Apply alignment to a protein chain"""
87
+ # Extract atom positions and convert to batched tensors
88
+ mobile_atom_tensor = (
89
+ torch.from_numpy(mobile.atom37_positions[mobile.atom37_mask])
90
+ .type(torch.float32)
91
+ .unsqueeze(0)
92
+ )
93
+
94
+ # Transform atom arrays
95
+ aligned_atom_tensor = self._affine3D.apply(mobile_atom_tensor).squeeze(0)
96
+
97
+ # Rebuild atom37 positions
98
+ aligned_atom37_positions = np.full_like(mobile.atom37_positions, np.nan)
99
+ aligned_atom37_positions[mobile.atom37_mask] = aligned_atom_tensor
100
+
101
+ return replace(mobile, atom37_positions=aligned_atom37_positions)
esmfold2_atom_indexer.py CHANGED
@@ -1,15 +1,15 @@
1
- import numpy as np
2
-
3
- from .esmfold2_protein_structure import index_by_atom_name
4
-
5
-
6
- class AtomIndexer:
7
- def __init__(self, structure, property: str, dim: int):
8
- self.structure = structure
9
- self.property = property
10
- self.dim = dim
11
-
12
- def __getitem__(self, atom_names: str | list[str]) -> np.ndarray:
13
- return index_by_atom_name(
14
- getattr(self.structure, self.property), atom_names, self.dim
15
- )
 
1
+ import numpy as np
2
+
3
+ from .esmfold2_protein_structure import index_by_atom_name
4
+
5
+
6
+ class AtomIndexer:
7
+ def __init__(self, structure, property: str, dim: int):
8
+ self.structure = structure
9
+ self.property = property
10
+ self.dim = dim
11
+
12
+ def __getitem__(self, atom_names: str | list[str]) -> np.ndarray:
13
+ return index_by_atom_name(
14
+ getattr(self.structure, self.property), atom_names, self.dim
15
+ )
esmfold2_conformers.py CHANGED
@@ -1,291 +1,291 @@
1
- """CCD conformer loading utilities.
2
-
3
- Loads idealized conformer coordinates from a CCD pickle file containing RDKit molecules.
4
- Conformer priority follows AF3 Section 2.8: Computed > Ideal > first available.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import os
10
- import pickle
11
- from pathlib import Path
12
-
13
- import numpy as np
14
- from huggingface_hub import hf_hub_download
15
-
16
- from .esmfold2_constants import RES_TYPE_TO_CCD
17
-
18
- if os.environ.get("ESMCFOLD_CCD_PATH"):
19
- CCD_PICKLE_PATH = Path(os.environ["ESMCFOLD_CCD_PATH"])
20
- else:
21
- CCD_PICKLE_PATH = None
22
-
23
-
24
- # Lazily loaded CCD dictionary
25
- _CCD_MOLECULES: dict | None = None
26
-
27
- # Caches
28
- _CCD_CONFORMERS: dict[str, dict[str, np.ndarray]] = {}
29
- _CCD_ATOM_CACHE: dict[str, list[tuple[str, str, int]]] = {}
30
- _CCD_BONDS_CACHE: dict[str, list[tuple[str, str]]] = {}
31
- _CCD_LEAVING_ATOMS_CACHE: dict[str, set[str]] = {}
32
- _IDEALIZED_POS_CACHE: dict[tuple[int, str], np.ndarray | None] = {}
33
- _LIGAND_IDEALIZED_POS_CACHE: dict[tuple[str, str], np.ndarray | None] = {}
34
-
35
-
36
- def load_ccd(cache_dir: Path | str | None = None) -> dict:
37
- """Load CCD molecules from pickle file, downloading if needed.
38
-
39
- Args:
40
- cache_dir: Directory to cache the downloaded CCD pickle.
41
- If None, uses CCD_PICKLE_PATH env var or downloads to ~/.cache/esmcfold/.
42
- """
43
- global _CCD_MOLECULES
44
- if _CCD_MOLECULES is not None:
45
- return _CCD_MOLECULES
46
-
47
- # Determine pickle path
48
- if CCD_PICKLE_PATH is not None and CCD_PICKLE_PATH.exists():
49
- pkl_path = CCD_PICKLE_PATH
50
- elif cache_dir is not None:
51
- cache_dir = Path(cache_dir)
52
- cache_dir.mkdir(parents=True, exist_ok=True)
53
- pkl_path = cache_dir / "ccd.pkl"
54
- else:
55
- try:
56
- pkl_path = Path(
57
- hf_hub_download(repo_id="biohub/ESMFold2", filename="ccd.pkl")
58
- )
59
- except Exception as e:
60
- raise FileNotFoundError(
61
- f"Failed to download CCD pickle file from Hugging Face repository: {e}"
62
- )
63
-
64
- if not pkl_path.exists():
65
- raise FileNotFoundError(
66
- f"CCD pickle file not found: {pkl_path}. Please set the ESMCFOLD_CCD_PATH environment variable to the path of a valid CCD pickle file or download the file from the Hugging Face repository."
67
- )
68
-
69
- print(f"Loading CCD dictionary from {pkl_path}")
70
- with open(pkl_path, "rb") as f:
71
- _CCD_MOLECULES = pickle.load(f)
72
-
73
- if _CCD_MOLECULES is None:
74
- _CCD_MOLECULES = {}
75
-
76
- return _CCD_MOLECULES
77
-
78
-
79
- def _get_ccd_molecules() -> dict:
80
- """Get CCD molecules, loading lazily on first call."""
81
- global _CCD_MOLECULES
82
- if _CCD_MOLECULES is None:
83
- return load_ccd()
84
- return _CCD_MOLECULES
85
-
86
-
87
- def _get_ccd_mol_with_significant_h(comp_id: str):
88
- """Get CCD molecule with only chemically significant hydrogens.
89
-
90
- Returns (mol, conformer) tuple or (None, None) if not available.
91
- """
92
- ccd = _get_ccd_molecules()
93
- if comp_id not in ccd:
94
- return None, None
95
-
96
- mol = ccd[comp_id]
97
- if mol.GetNumConformers() == 0:
98
- return None, None
99
-
100
- # Find the "Computed" conformer (RDKit ETKDGv3), fall back to "Ideal"
101
- conf_idx = 0
102
- for i, c in enumerate(mol.GetConformers()):
103
- props = c.GetPropsAsDict()
104
- if props.get("name") == "Computed":
105
- conf_idx = i
106
- break
107
- else:
108
- for i, c in enumerate(mol.GetConformers()):
109
- props = c.GetPropsAsDict()
110
- if props.get("name") == "Ideal":
111
- conf_idx = i
112
- break
113
-
114
- from rdkit import Chem
115
-
116
- mol_no_h = Chem.RemoveHs(mol, sanitize=False)
117
-
118
- if mol_no_h.GetNumConformers() == 0:
119
- return None, None
120
-
121
- return mol_no_h, mol_no_h.GetConformer(
122
- min(conf_idx, mol_no_h.GetNumConformers() - 1)
123
- )
124
-
125
-
126
- def get_ccd_conformer(comp_id: str) -> dict[str, np.ndarray] | None:
127
- """Get idealized conformer as dict of atom_name -> position [3].
128
-
129
- Conformer priority: Computed > Ideal > first available.
130
- """
131
- if comp_id in _CCD_CONFORMERS:
132
- cached = _CCD_CONFORMERS[comp_id]
133
- return cached if cached else None
134
-
135
- mol, conf = _get_ccd_mol_with_significant_h(comp_id)
136
- if mol is None or conf is None:
137
- _CCD_CONFORMERS[comp_id] = {}
138
- return None
139
-
140
- conformer: dict[str, np.ndarray] = {}
141
- for atom in mol.GetAtoms():
142
- props = atom.GetPropsAsDict()
143
- atom_name = props.get("name")
144
- if not isinstance(atom_name, str) or not atom_name:
145
- continue
146
- idx = atom.GetIdx()
147
- pos = conf.GetAtomPosition(idx)
148
- conformer[atom_name] = np.array([pos.x, pos.y, pos.z], dtype=np.float32)
149
-
150
- _CCD_CONFORMERS[comp_id] = conformer
151
- return conformer if conformer else None
152
-
153
-
154
- def get_idealized_atom_pos(res_type: int, atom_name: str) -> np.ndarray | None:
155
- """Get idealized position for a standard residue atom.
156
-
157
- Uses res_type index to look up CCD component, then returns position.
158
- Returns None if not found.
159
- """
160
- cache_key = (res_type, atom_name)
161
- if cache_key in _IDEALIZED_POS_CACHE:
162
- return _IDEALIZED_POS_CACHE[cache_key]
163
-
164
- comp_id = RES_TYPE_TO_CCD.get(res_type)
165
- if comp_id:
166
- ccd_conformer = get_ccd_conformer(comp_id)
167
- if ccd_conformer and atom_name in ccd_conformer:
168
- pos = ccd_conformer[atom_name]
169
- _IDEALIZED_POS_CACHE[cache_key] = pos
170
- return pos
171
-
172
- _IDEALIZED_POS_CACHE[cache_key] = None
173
- return None
174
-
175
-
176
- def get_ligand_idealized_atom_pos(res_name: str, atom_name: str) -> np.ndarray | None:
177
- """Get idealized position for a ligand/modified residue atom.
178
-
179
- Returns None if not found.
180
- """
181
- cache_key = (res_name, atom_name)
182
- if cache_key in _LIGAND_IDEALIZED_POS_CACHE:
183
- return _LIGAND_IDEALIZED_POS_CACHE[cache_key]
184
-
185
- ccd_conformer = get_ccd_conformer(res_name)
186
- if ccd_conformer and atom_name in ccd_conformer:
187
- pos = ccd_conformer[atom_name]
188
- _LIGAND_IDEALIZED_POS_CACHE[cache_key] = pos
189
- return pos
190
-
191
- _LIGAND_IDEALIZED_POS_CACHE[cache_key] = None
192
- return None
193
-
194
-
195
- def get_ligand_ccd_atoms_with_charges(
196
- comp_id: str,
197
- ) -> list[tuple[str, str, int]] | None:
198
- """Get list of (atom_name, element, charge) for a CCD component.
199
-
200
- Uses RDKit RemoveHs(sanitize=False) to keep chemically significant hydrogens.
201
- Returns None if CCD data not available.
202
- """
203
- if comp_id in _CCD_ATOM_CACHE:
204
- cached = _CCD_ATOM_CACHE[comp_id]
205
- return cached if cached else None
206
-
207
- mol, _ = _get_ccd_mol_with_significant_h(comp_id)
208
- if mol is None:
209
- _CCD_ATOM_CACHE[comp_id] = []
210
- return None
211
-
212
- atoms: list[tuple[str, str, int]] = []
213
- for atom in mol.GetAtoms():
214
- props = atom.GetPropsAsDict()
215
- atom_name = props.get("name")
216
- if not isinstance(atom_name, str) or not atom_name:
217
- continue
218
- element = atom.GetSymbol()
219
- charge = atom.GetFormalCharge()
220
- atoms.append((atom_name, element, charge))
221
-
222
- _CCD_ATOM_CACHE[comp_id] = atoms
223
- return atoms if atoms else None
224
-
225
-
226
- def get_ligand_ccd_bonds(comp_id: str) -> list[tuple[str, str]] | None:
227
- """Get list of (atom1_name, atom2_name) bonds for a CCD component.
228
-
229
- Returns None if CCD data not available.
230
- """
231
- if comp_id in _CCD_BONDS_CACHE:
232
- cached = _CCD_BONDS_CACHE[comp_id]
233
- return cached if cached else None
234
-
235
- mol, _ = _get_ccd_mol_with_significant_h(comp_id)
236
- if mol is None:
237
- _CCD_BONDS_CACHE[comp_id] = []
238
- return None
239
-
240
- # Get included atom names
241
- included_atoms = set()
242
- for atom in mol.GetAtoms():
243
- props = atom.GetPropsAsDict()
244
- atom_name = props.get("name")
245
- if isinstance(atom_name, str) and atom_name:
246
- included_atoms.add(atom_name)
247
-
248
- bonds: list[tuple[str, str]] = []
249
- for bond in mol.GetBonds():
250
- a1 = bond.GetBeginAtom()
251
- a2 = bond.GetEndAtom()
252
- n1 = a1.GetPropsAsDict().get("name")
253
- n2 = a2.GetPropsAsDict().get("name")
254
- if (
255
- isinstance(n1, str)
256
- and isinstance(n2, str)
257
- and n1
258
- and n2
259
- and n1 in included_atoms
260
- and n2 in included_atoms
261
- ):
262
- bonds.append((n1, n2))
263
-
264
- _CCD_BONDS_CACHE[comp_id] = bonds
265
- return bonds if bonds else None
266
-
267
-
268
- def get_ccd_leaving_atoms(comp_id: str) -> set[str]:
269
- """Get set of atom names marked as leaving atoms in CCD.
270
-
271
- Leaving atoms are removed during polymerization (e.g., OP3 in nucleotides).
272
- """
273
- if comp_id in _CCD_LEAVING_ATOMS_CACHE:
274
- return _CCD_LEAVING_ATOMS_CACHE[comp_id]
275
-
276
- ccd = _get_ccd_molecules()
277
- if comp_id not in ccd:
278
- _CCD_LEAVING_ATOMS_CACHE[comp_id] = set()
279
- return set()
280
-
281
- mol = ccd[comp_id]
282
- leaving_atoms = set()
283
- for atom in mol.GetAtoms():
284
- if atom.HasProp("leaving_atom"):
285
- if atom.GetProp("leaving_atom") == "1":
286
- name = atom.GetProp("name") if atom.HasProp("name") else ""
287
- if name:
288
- leaving_atoms.add(name)
289
-
290
- _CCD_LEAVING_ATOMS_CACHE[comp_id] = leaving_atoms
291
- return leaving_atoms
 
1
+ """CCD conformer loading utilities.
2
+
3
+ Loads idealized conformer coordinates from a CCD pickle file containing RDKit molecules.
4
+ Conformer priority follows AF3 Section 2.8: Computed > Ideal > first available.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import pickle
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ from huggingface_hub import hf_hub_download
15
+
16
+ from .esmfold2_constants import RES_TYPE_TO_CCD
17
+
18
+ if os.environ.get("ESMCFOLD_CCD_PATH"):
19
+ CCD_PICKLE_PATH = Path(os.environ["ESMCFOLD_CCD_PATH"])
20
+ else:
21
+ CCD_PICKLE_PATH = None
22
+
23
+
24
+ # Lazily loaded CCD dictionary
25
+ _CCD_MOLECULES: dict | None = None
26
+
27
+ # Caches
28
+ _CCD_CONFORMERS: dict[str, dict[str, np.ndarray]] = {}
29
+ _CCD_ATOM_CACHE: dict[str, list[tuple[str, str, int]]] = {}
30
+ _CCD_BONDS_CACHE: dict[str, list[tuple[str, str]]] = {}
31
+ _CCD_LEAVING_ATOMS_CACHE: dict[str, set[str]] = {}
32
+ _IDEALIZED_POS_CACHE: dict[tuple[int, str], np.ndarray | None] = {}
33
+ _LIGAND_IDEALIZED_POS_CACHE: dict[tuple[str, str], np.ndarray | None] = {}
34
+
35
+
36
+ def load_ccd(cache_dir: Path | str | None = None) -> dict:
37
+ """Load CCD molecules from pickle file, downloading if needed.
38
+
39
+ Args:
40
+ cache_dir: Directory to cache the downloaded CCD pickle.
41
+ If None, uses CCD_PICKLE_PATH env var or downloads to ~/.cache/esmcfold/.
42
+ """
43
+ global _CCD_MOLECULES
44
+ if _CCD_MOLECULES is not None:
45
+ return _CCD_MOLECULES
46
+
47
+ # Determine pickle path
48
+ if CCD_PICKLE_PATH is not None and CCD_PICKLE_PATH.exists():
49
+ pkl_path = CCD_PICKLE_PATH
50
+ elif cache_dir is not None:
51
+ cache_dir = Path(cache_dir)
52
+ cache_dir.mkdir(parents=True, exist_ok=True)
53
+ pkl_path = cache_dir / "ccd.pkl"
54
+ else:
55
+ try:
56
+ pkl_path = Path(
57
+ hf_hub_download(repo_id="biohub/ESMFold2", filename="ccd.pkl")
58
+ )
59
+ except Exception as e:
60
+ raise FileNotFoundError(
61
+ f"Failed to download CCD pickle file from Hugging Face repository: {e}"
62
+ )
63
+
64
+ if not pkl_path.exists():
65
+ raise FileNotFoundError(
66
+ f"CCD pickle file not found: {pkl_path}. Please set the ESMCFOLD_CCD_PATH environment variable to the path of a valid CCD pickle file or download the file from the Hugging Face repository."
67
+ )
68
+
69
+ print(f"Loading CCD dictionary from {pkl_path}")
70
+ with open(pkl_path, "rb") as f:
71
+ _CCD_MOLECULES = pickle.load(f)
72
+
73
+ if _CCD_MOLECULES is None:
74
+ _CCD_MOLECULES = {}
75
+
76
+ return _CCD_MOLECULES
77
+
78
+
79
+ def _get_ccd_molecules() -> dict:
80
+ """Get CCD molecules, loading lazily on first call."""
81
+ global _CCD_MOLECULES
82
+ if _CCD_MOLECULES is None:
83
+ return load_ccd()
84
+ return _CCD_MOLECULES
85
+
86
+
87
+ def _get_ccd_mol_with_significant_h(comp_id: str):
88
+ """Get CCD molecule with only chemically significant hydrogens.
89
+
90
+ Returns (mol, conformer) tuple or (None, None) if not available.
91
+ """
92
+ ccd = _get_ccd_molecules()
93
+ if comp_id not in ccd:
94
+ return None, None
95
+
96
+ mol = ccd[comp_id]
97
+ if mol.GetNumConformers() == 0:
98
+ return None, None
99
+
100
+ # Find the "Computed" conformer (RDKit ETKDGv3), fall back to "Ideal"
101
+ conf_idx = 0
102
+ for i, c in enumerate(mol.GetConformers()):
103
+ props = c.GetPropsAsDict()
104
+ if props.get("name") == "Computed":
105
+ conf_idx = i
106
+ break
107
+ else:
108
+ for i, c in enumerate(mol.GetConformers()):
109
+ props = c.GetPropsAsDict()
110
+ if props.get("name") == "Ideal":
111
+ conf_idx = i
112
+ break
113
+
114
+ from rdkit import Chem
115
+
116
+ mol_no_h = Chem.RemoveHs(mol, sanitize=False)
117
+
118
+ if mol_no_h.GetNumConformers() == 0:
119
+ return None, None
120
+
121
+ return mol_no_h, mol_no_h.GetConformer(
122
+ min(conf_idx, mol_no_h.GetNumConformers() - 1)
123
+ )
124
+
125
+
126
+ def get_ccd_conformer(comp_id: str) -> dict[str, np.ndarray] | None:
127
+ """Get idealized conformer as dict of atom_name -> position [3].
128
+
129
+ Conformer priority: Computed > Ideal > first available.
130
+ """
131
+ if comp_id in _CCD_CONFORMERS:
132
+ cached = _CCD_CONFORMERS[comp_id]
133
+ return cached if cached else None
134
+
135
+ mol, conf = _get_ccd_mol_with_significant_h(comp_id)
136
+ if mol is None or conf is None:
137
+ _CCD_CONFORMERS[comp_id] = {}
138
+ return None
139
+
140
+ conformer: dict[str, np.ndarray] = {}
141
+ for atom in mol.GetAtoms():
142
+ props = atom.GetPropsAsDict()
143
+ atom_name = props.get("name")
144
+ if not isinstance(atom_name, str) or not atom_name:
145
+ continue
146
+ idx = atom.GetIdx()
147
+ pos = conf.GetAtomPosition(idx)
148
+ conformer[atom_name] = np.array([pos.x, pos.y, pos.z], dtype=np.float32)
149
+
150
+ _CCD_CONFORMERS[comp_id] = conformer
151
+ return conformer if conformer else None
152
+
153
+
154
+ def get_idealized_atom_pos(res_type: int, atom_name: str) -> np.ndarray | None:
155
+ """Get idealized position for a standard residue atom.
156
+
157
+ Uses res_type index to look up CCD component, then returns position.
158
+ Returns None if not found.
159
+ """
160
+ cache_key = (res_type, atom_name)
161
+ if cache_key in _IDEALIZED_POS_CACHE:
162
+ return _IDEALIZED_POS_CACHE[cache_key]
163
+
164
+ comp_id = RES_TYPE_TO_CCD.get(res_type)
165
+ if comp_id:
166
+ ccd_conformer = get_ccd_conformer(comp_id)
167
+ if ccd_conformer and atom_name in ccd_conformer:
168
+ pos = ccd_conformer[atom_name]
169
+ _IDEALIZED_POS_CACHE[cache_key] = pos
170
+ return pos
171
+
172
+ _IDEALIZED_POS_CACHE[cache_key] = None
173
+ return None
174
+
175
+
176
+ def get_ligand_idealized_atom_pos(res_name: str, atom_name: str) -> np.ndarray | None:
177
+ """Get idealized position for a ligand/modified residue atom.
178
+
179
+ Returns None if not found.
180
+ """
181
+ cache_key = (res_name, atom_name)
182
+ if cache_key in _LIGAND_IDEALIZED_POS_CACHE:
183
+ return _LIGAND_IDEALIZED_POS_CACHE[cache_key]
184
+
185
+ ccd_conformer = get_ccd_conformer(res_name)
186
+ if ccd_conformer and atom_name in ccd_conformer:
187
+ pos = ccd_conformer[atom_name]
188
+ _LIGAND_IDEALIZED_POS_CACHE[cache_key] = pos
189
+ return pos
190
+
191
+ _LIGAND_IDEALIZED_POS_CACHE[cache_key] = None
192
+ return None
193
+
194
+
195
+ def get_ligand_ccd_atoms_with_charges(
196
+ comp_id: str,
197
+ ) -> list[tuple[str, str, int]] | None:
198
+ """Get list of (atom_name, element, charge) for a CCD component.
199
+
200
+ Uses RDKit RemoveHs(sanitize=False) to keep chemically significant hydrogens.
201
+ Returns None if CCD data not available.
202
+ """
203
+ if comp_id in _CCD_ATOM_CACHE:
204
+ cached = _CCD_ATOM_CACHE[comp_id]
205
+ return cached if cached else None
206
+
207
+ mol, _ = _get_ccd_mol_with_significant_h(comp_id)
208
+ if mol is None:
209
+ _CCD_ATOM_CACHE[comp_id] = []
210
+ return None
211
+
212
+ atoms: list[tuple[str, str, int]] = []
213
+ for atom in mol.GetAtoms():
214
+ props = atom.GetPropsAsDict()
215
+ atom_name = props.get("name")
216
+ if not isinstance(atom_name, str) or not atom_name:
217
+ continue
218
+ element = atom.GetSymbol()
219
+ charge = atom.GetFormalCharge()
220
+ atoms.append((atom_name, element, charge))
221
+
222
+ _CCD_ATOM_CACHE[comp_id] = atoms
223
+ return atoms if atoms else None
224
+
225
+
226
+ def get_ligand_ccd_bonds(comp_id: str) -> list[tuple[str, str]] | None:
227
+ """Get list of (atom1_name, atom2_name) bonds for a CCD component.
228
+
229
+ Returns None if CCD data not available.
230
+ """
231
+ if comp_id in _CCD_BONDS_CACHE:
232
+ cached = _CCD_BONDS_CACHE[comp_id]
233
+ return cached if cached else None
234
+
235
+ mol, _ = _get_ccd_mol_with_significant_h(comp_id)
236
+ if mol is None:
237
+ _CCD_BONDS_CACHE[comp_id] = []
238
+ return None
239
+
240
+ # Get included atom names
241
+ included_atoms = set()
242
+ for atom in mol.GetAtoms():
243
+ props = atom.GetPropsAsDict()
244
+ atom_name = props.get("name")
245
+ if isinstance(atom_name, str) and atom_name:
246
+ included_atoms.add(atom_name)
247
+
248
+ bonds: list[tuple[str, str]] = []
249
+ for bond in mol.GetBonds():
250
+ a1 = bond.GetBeginAtom()
251
+ a2 = bond.GetEndAtom()
252
+ n1 = a1.GetPropsAsDict().get("name")
253
+ n2 = a2.GetPropsAsDict().get("name")
254
+ if (
255
+ isinstance(n1, str)
256
+ and isinstance(n2, str)
257
+ and n1
258
+ and n2
259
+ and n1 in included_atoms
260
+ and n2 in included_atoms
261
+ ):
262
+ bonds.append((n1, n2))
263
+
264
+ _CCD_BONDS_CACHE[comp_id] = bonds
265
+ return bonds if bonds else None
266
+
267
+
268
+ def get_ccd_leaving_atoms(comp_id: str) -> set[str]:
269
+ """Get set of atom names marked as leaving atoms in CCD.
270
+
271
+ Leaving atoms are removed during polymerization (e.g., OP3 in nucleotides).
272
+ """
273
+ if comp_id in _CCD_LEAVING_ATOMS_CACHE:
274
+ return _CCD_LEAVING_ATOMS_CACHE[comp_id]
275
+
276
+ ccd = _get_ccd_molecules()
277
+ if comp_id not in ccd:
278
+ _CCD_LEAVING_ATOMS_CACHE[comp_id] = set()
279
+ return set()
280
+
281
+ mol = ccd[comp_id]
282
+ leaving_atoms = set()
283
+ for atom in mol.GetAtoms():
284
+ if atom.HasProp("leaving_atom"):
285
+ if atom.GetProp("leaving_atom") == "1":
286
+ name = atom.GetProp("name") if atom.HasProp("name") else ""
287
+ if name:
288
+ leaving_atoms.add(name)
289
+
290
+ _CCD_LEAVING_ATOMS_CACHE[comp_id] = leaving_atoms
291
+ return leaving_atoms
esmfold2_constants.py CHANGED
@@ -1,562 +1,562 @@
1
- """Constants for the ESMFold2 input pipeline.
2
-
3
- Includes molecule types, residue types, vocabularies, atom lists, and element data.
4
- """
5
-
6
- # =============================================================================
7
- # Molecule types
8
- # =============================================================================
9
-
10
- MOL_TYPE_PROTEIN = 0
11
- MOL_TYPE_DNA = 1
12
- MOL_TYPE_RNA = 2
13
- MOL_TYPE_NONPOLYMER = 3
14
-
15
- # =============================================================================
16
- # Residue type indices
17
- # =============================================================================
18
-
19
- # Standard amino acids (indices 2-21), MSE mapped to MET
20
- PROTEIN_RESIDUE_TO_RES_TYPE = {
21
- "ALA": 2,
22
- "ARG": 3,
23
- "ASN": 4,
24
- "ASP": 5,
25
- "CYS": 6,
26
- "GLN": 7,
27
- "GLU": 8,
28
- "GLY": 9,
29
- "HIS": 10,
30
- "ILE": 11,
31
- "LEU": 12,
32
- "LYS": 13,
33
- "MET": 14,
34
- "PHE": 15,
35
- "PRO": 16,
36
- "SER": 17,
37
- "THR": 18,
38
- "TRP": 19,
39
- "TYR": 20,
40
- "VAL": 21,
41
- "MSE": 14, # Selenomethionine -> MET
42
- }
43
- PROTEIN_UNK_RES_TYPE = 22
44
-
45
- # RNA nucleotides (indices 23-26, unknown=27)
46
- RNA_RESIDUE_TO_RES_TYPE = {"A": 23, "G": 24, "C": 25, "U": 26}
47
- RNA_UNK_RES_TYPE = 27
48
-
49
- # DNA nucleotides (indices 28-31, unknown=32)
50
- DNA_RESIDUE_TO_RES_TYPE = {"DA": 28, "DG": 29, "DC": 30, "DT": 31}
51
- DNA_UNK_RES_TYPE = 32
52
-
53
- GAP_RES_TYPE = 32
54
-
55
- # =============================================================================
56
- # Vocabularies
57
- # =============================================================================
58
-
59
- # 3-letter to 1-letter codes for proteins
60
- PROTEIN_3TO1 = {
61
- "ALA": "A",
62
- "ARG": "R",
63
- "ASN": "N",
64
- "ASP": "D",
65
- "CYS": "C",
66
- "GLN": "Q",
67
- "GLU": "E",
68
- "GLY": "G",
69
- "HIS": "H",
70
- "ILE": "I",
71
- "LEU": "L",
72
- "LYS": "K",
73
- "MET": "M",
74
- "PHE": "F",
75
- "PRO": "P",
76
- "SER": "S",
77
- "THR": "T",
78
- "TRP": "W",
79
- "TYR": "Y",
80
- "VAL": "V",
81
- "MSE": "M",
82
- }
83
-
84
- # 1-letter to 3-letter codes
85
- PROTEIN_1TO3 = {v: k for k, v in PROTEIN_3TO1.items() if k != "MSE"}
86
- PROTEIN_1TO3["X"] = "UNK"
87
-
88
- # DNA 1-letter to CCD code
89
- DNA_1TO3 = {"A": "DA", "T": "DT", "C": "DC", "G": "DG"}
90
-
91
- # RNA 1-letter to CCD code
92
- RNA_1TO3 = {"A": "A", "U": "U", "C": "C", "G": "G"}
93
-
94
- # ESM-2 input_ids vocabulary for proteins
95
- ESM_PROTEIN_VOCAB = {
96
- "L": 4,
97
- "A": 5,
98
- "G": 6,
99
- "V": 7,
100
- "S": 8,
101
- "E": 9,
102
- "R": 10,
103
- "T": 11,
104
- "I": 12,
105
- "D": 13,
106
- "P": 14,
107
- "K": 15,
108
- "Q": 16,
109
- "N": 17,
110
- "F": 18,
111
- "Y": 19,
112
- "M": 20,
113
- "H": 21,
114
- "W": 22,
115
- "C": 23,
116
- "X": 3, # Unknown
117
- }
118
-
119
- # For DNA/RNA/ligands
120
- DNA_RNA_LIGAND_INPUT_ID = 24
121
-
122
- # MSA tokens
123
- MSA_PAD_TOKEN_ID = 0
124
- MSA_GAP_TOKEN_ID = 1 # Gap/insertion token for MSA
125
-
126
- # res_type int -> CCD component ID (for conformer lookup)
127
- RES_TYPE_TO_CCD = {
128
- # Proteins (2-22)
129
- 2: "ALA",
130
- 3: "ARG",
131
- 4: "ASN",
132
- 5: "ASP",
133
- 6: "CYS",
134
- 7: "GLN",
135
- 8: "GLU",
136
- 9: "GLY",
137
- 10: "HIS",
138
- 11: "ILE",
139
- 12: "LEU",
140
- 13: "LYS",
141
- 14: "MET",
142
- 15: "PHE",
143
- 16: "PRO",
144
- 17: "SER",
145
- 18: "THR",
146
- 19: "TRP",
147
- 20: "TYR",
148
- 21: "VAL",
149
- 22: "UNK",
150
- # RNA (23-27)
151
- 23: "A",
152
- 24: "G",
153
- 25: "C",
154
- 26: "U",
155
- 27: "N",
156
- # DNA (28-32)
157
- 28: "DA",
158
- 29: "DG",
159
- 30: "DC",
160
- 31: "DT",
161
- 32: "DN",
162
- }
163
-
164
- # =============================================================================
165
- # Charged atoms at physiological pH
166
- # =============================================================================
167
-
168
- CHARGED_ATOMS: dict[tuple[str, str], int] = {
169
- ("LYS", "NZ"): 1,
170
- ("ARG", "NH2"): 1,
171
- ("HIS", "ND1"): 1,
172
- ("PO4", "O2"): -1,
173
- ("PO4", "O3"): -1,
174
- ("PO4", "O4"): -1,
175
- ("SO4", "O3"): -1,
176
- ("SO4", "O4"): -1,
177
- ("MG", "MG"): 2,
178
- ("ZN", "ZN"): 2,
179
- ("CA", "CA"): 2,
180
- ("FE2", "FE"): 2,
181
- ("MN", "MN"): 2,
182
- ("CO", "CO"): 2,
183
- ("NCO", "CO"): 3,
184
- ("CU", "CU"): 2,
185
- ("NI", "NI"): 2,
186
- ("K", "K"): 1,
187
- ("NA", "NA"): 1,
188
- ("CD", "CD"): 2,
189
- ("CL", "CL"): -1,
190
- ("ACT", "OXT"): -1,
191
- ("NAD", "O2N"): -1,
192
- ("NAD", "N1N"): 1,
193
- ("NAP", "O2N"): -1,
194
- ("NAP", "N1N"): 1,
195
- ("IMD", "N3"): 1,
196
- ("SAM", "SD"): 1,
197
- ("FE", "FE"): 3,
198
- ("A1BH3", "N3"): 1,
199
- }
200
-
201
- # =============================================================================
202
- # Element atomic numbers (Z=1 to 92)
203
- # =============================================================================
204
-
205
- ELEMENT_TO_ATOMIC_NUM = {
206
- "H": 1,
207
- "LI": 3,
208
- "BE": 4,
209
- "B": 5,
210
- "C": 6,
211
- "N": 7,
212
- "O": 8,
213
- "F": 9,
214
- "NE": 10,
215
- "NA": 11,
216
- "MG": 12,
217
- "AL": 13,
218
- "SI": 14,
219
- "P": 15,
220
- "S": 16,
221
- "CL": 17,
222
- "AR": 18,
223
- "K": 19,
224
- "CA": 20,
225
- "SC": 21,
226
- "TI": 22,
227
- "V": 23,
228
- "CR": 24,
229
- "MN": 25,
230
- "FE": 26,
231
- "CO": 27,
232
- "NI": 28,
233
- "CU": 29,
234
- "ZN": 30,
235
- "GA": 31,
236
- "GE": 32,
237
- "AS": 33,
238
- "SE": 34,
239
- "BR": 35,
240
- "KR": 36,
241
- "RB": 37,
242
- "SR": 38,
243
- "Y": 39,
244
- "ZR": 40,
245
- "NB": 41,
246
- "MO": 42,
247
- "TC": 43,
248
- "RU": 44,
249
- "RH": 45,
250
- "PD": 46,
251
- "AG": 47,
252
- "CD": 48,
253
- "IN": 49,
254
- "SN": 50,
255
- "SB": 51,
256
- "TE": 52,
257
- "I": 53,
258
- "XE": 54,
259
- "CS": 55,
260
- "BA": 56,
261
- "LA": 57,
262
- "CE": 58,
263
- "PR": 59,
264
- "ND": 60,
265
- "PM": 61,
266
- "SM": 62,
267
- "EU": 63,
268
- "GD": 64,
269
- "TB": 65,
270
- "DY": 66,
271
- "HO": 67,
272
- "ER": 68,
273
- "TM": 69,
274
- "YB": 70,
275
- "LU": 71,
276
- "HF": 72,
277
- "TA": 73,
278
- "W": 74,
279
- "RE": 75,
280
- "OS": 76,
281
- "IR": 77,
282
- "PT": 78,
283
- "AU": 79,
284
- "HG": 80,
285
- "TL": 81,
286
- "PB": 82,
287
- "BI": 83,
288
- "PO": 84,
289
- "AT": 85,
290
- "RN": 86,
291
- "FR": 87,
292
- "RA": 88,
293
- "AC": 89,
294
- "TH": 90,
295
- "PA": 91,
296
- "U": 92,
297
- }
298
-
299
- # Inverse mapping: atomic number → element symbol
300
- ELEMENT_NUMBER_TO_SYMBOL = {v: k for k, v in ELEMENT_TO_ATOMIC_NUM.items()}
301
-
302
- # =============================================================================
303
- # Standard heavy atoms per residue type
304
- # =============================================================================
305
-
306
- PROTEIN_HEAVY_ATOMS = {
307
- "ALA": ["N", "CA", "C", "O", "CB"],
308
- "ARG": ["N", "CA", "C", "O", "CB", "CG", "CD", "NE", "CZ", "NH1", "NH2"],
309
- "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2"],
310
- "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2"],
311
- "CYS": ["N", "CA", "C", "O", "CB", "SG"],
312
- "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2"],
313
- "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2"],
314
- "GLY": ["N", "CA", "C", "O"],
315
- "HIS": ["N", "CA", "C", "O", "CB", "CG", "ND1", "CD2", "CE1", "NE2"],
316
- "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1"],
317
- "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2"],
318
- "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ"],
319
- "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
320
- "PHE": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
321
- "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD"],
322
- "SER": ["N", "CA", "C", "O", "CB", "OG"],
323
- "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2"],
324
- "TRP": [
325
- "N",
326
- "CA",
327
- "C",
328
- "O",
329
- "CB",
330
- "CG",
331
- "CD1",
332
- "CD2",
333
- "NE1",
334
- "CE2",
335
- "CE3",
336
- "CZ2",
337
- "CZ3",
338
- "CH2",
339
- ],
340
- "TYR": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "OH"],
341
- "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2"],
342
- "MSE": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
343
- "UNK": ["N", "CA", "C", "O"],
344
- }
345
-
346
- DNA_HEAVY_ATOMS = {
347
- "DA": [
348
- "P",
349
- "OP1",
350
- "OP2",
351
- "O5'",
352
- "C5'",
353
- "C4'",
354
- "O4'",
355
- "C3'",
356
- "O3'",
357
- "C2'",
358
- "C1'",
359
- "N9",
360
- "C8",
361
- "N7",
362
- "C5",
363
- "C6",
364
- "N6",
365
- "N1",
366
- "C2",
367
- "N3",
368
- "C4",
369
- ],
370
- "DG": [
371
- "P",
372
- "OP1",
373
- "OP2",
374
- "O5'",
375
- "C5'",
376
- "C4'",
377
- "O4'",
378
- "C3'",
379
- "O3'",
380
- "C2'",
381
- "C1'",
382
- "N9",
383
- "C8",
384
- "N7",
385
- "C5",
386
- "C6",
387
- "O6",
388
- "N1",
389
- "C2",
390
- "N2",
391
- "N3",
392
- "C4",
393
- ],
394
- "DC": [
395
- "P",
396
- "OP1",
397
- "OP2",
398
- "O5'",
399
- "C5'",
400
- "C4'",
401
- "O4'",
402
- "C3'",
403
- "O3'",
404
- "C2'",
405
- "C1'",
406
- "N1",
407
- "C2",
408
- "O2",
409
- "N3",
410
- "C4",
411
- "N4",
412
- "C5",
413
- "C6",
414
- ],
415
- "DT": [
416
- "P",
417
- "OP1",
418
- "OP2",
419
- "O5'",
420
- "C5'",
421
- "C4'",
422
- "O4'",
423
- "C3'",
424
- "O3'",
425
- "C2'",
426
- "C1'",
427
- "N1",
428
- "C2",
429
- "O2",
430
- "N3",
431
- "C4",
432
- "O4",
433
- "C5",
434
- "C7",
435
- "C6",
436
- ],
437
- }
438
-
439
- RNA_HEAVY_ATOMS = {
440
- "A": [
441
- "P",
442
- "OP1",
443
- "OP2",
444
- "O5'",
445
- "C5'",
446
- "C4'",
447
- "O4'",
448
- "C3'",
449
- "O3'",
450
- "C2'",
451
- "O2'",
452
- "C1'",
453
- "N9",
454
- "C8",
455
- "N7",
456
- "C5",
457
- "C6",
458
- "N6",
459
- "N1",
460
- "C2",
461
- "N3",
462
- "C4",
463
- ],
464
- "G": [
465
- "P",
466
- "OP1",
467
- "OP2",
468
- "O5'",
469
- "C5'",
470
- "C4'",
471
- "O4'",
472
- "C3'",
473
- "O3'",
474
- "C2'",
475
- "O2'",
476
- "C1'",
477
- "N9",
478
- "C8",
479
- "N7",
480
- "C5",
481
- "C6",
482
- "O6",
483
- "N1",
484
- "C2",
485
- "N2",
486
- "N3",
487
- "C4",
488
- ],
489
- "C": [
490
- "P",
491
- "OP1",
492
- "OP2",
493
- "O5'",
494
- "C5'",
495
- "C4'",
496
- "O4'",
497
- "C3'",
498
- "O3'",
499
- "C2'",
500
- "O2'",
501
- "C1'",
502
- "N1",
503
- "C2",
504
- "O2",
505
- "N3",
506
- "C4",
507
- "N4",
508
- "C5",
509
- "C6",
510
- ],
511
- "U": [
512
- "P",
513
- "OP1",
514
- "OP2",
515
- "O5'",
516
- "C5'",
517
- "C4'",
518
- "O4'",
519
- "C3'",
520
- "O3'",
521
- "C2'",
522
- "O2'",
523
- "C1'",
524
- "N1",
525
- "C2",
526
- "O2",
527
- "N3",
528
- "C4",
529
- "O4",
530
- "C5",
531
- "C6",
532
- ],
533
- }
534
-
535
- # Unknown nucleotide backbone atoms
536
- DNA_BACKBONE_ATOMS = [
537
- "P",
538
- "OP1",
539
- "OP2",
540
- "O5'",
541
- "C5'",
542
- "C4'",
543
- "O4'",
544
- "C3'",
545
- "O3'",
546
- "C2'",
547
- "C1'",
548
- ]
549
- RNA_BACKBONE_ATOMS = [
550
- "P",
551
- "OP1",
552
- "OP2",
553
- "O5'",
554
- "C5'",
555
- "C4'",
556
- "O4'",
557
- "C3'",
558
- "O3'",
559
- "C2'",
560
- "O2'",
561
- "C1'",
562
- ]
 
1
+ """Constants for the ESMFold2 input pipeline.
2
+
3
+ Includes molecule types, residue types, vocabularies, atom lists, and element data.
4
+ """
5
+
6
+ # =============================================================================
7
+ # Molecule types
8
+ # =============================================================================
9
+
10
+ MOL_TYPE_PROTEIN = 0
11
+ MOL_TYPE_DNA = 1
12
+ MOL_TYPE_RNA = 2
13
+ MOL_TYPE_NONPOLYMER = 3
14
+
15
+ # =============================================================================
16
+ # Residue type indices
17
+ # =============================================================================
18
+
19
+ # Standard amino acids (indices 2-21), MSE mapped to MET
20
+ PROTEIN_RESIDUE_TO_RES_TYPE = {
21
+ "ALA": 2,
22
+ "ARG": 3,
23
+ "ASN": 4,
24
+ "ASP": 5,
25
+ "CYS": 6,
26
+ "GLN": 7,
27
+ "GLU": 8,
28
+ "GLY": 9,
29
+ "HIS": 10,
30
+ "ILE": 11,
31
+ "LEU": 12,
32
+ "LYS": 13,
33
+ "MET": 14,
34
+ "PHE": 15,
35
+ "PRO": 16,
36
+ "SER": 17,
37
+ "THR": 18,
38
+ "TRP": 19,
39
+ "TYR": 20,
40
+ "VAL": 21,
41
+ "MSE": 14, # Selenomethionine -> MET
42
+ }
43
+ PROTEIN_UNK_RES_TYPE = 22
44
+
45
+ # RNA nucleotides (indices 23-26, unknown=27)
46
+ RNA_RESIDUE_TO_RES_TYPE = {"A": 23, "G": 24, "C": 25, "U": 26}
47
+ RNA_UNK_RES_TYPE = 27
48
+
49
+ # DNA nucleotides (indices 28-31, unknown=32)
50
+ DNA_RESIDUE_TO_RES_TYPE = {"DA": 28, "DG": 29, "DC": 30, "DT": 31}
51
+ DNA_UNK_RES_TYPE = 32
52
+
53
+ GAP_RES_TYPE = 32
54
+
55
+ # =============================================================================
56
+ # Vocabularies
57
+ # =============================================================================
58
+
59
+ # 3-letter to 1-letter codes for proteins
60
+ PROTEIN_3TO1 = {
61
+ "ALA": "A",
62
+ "ARG": "R",
63
+ "ASN": "N",
64
+ "ASP": "D",
65
+ "CYS": "C",
66
+ "GLN": "Q",
67
+ "GLU": "E",
68
+ "GLY": "G",
69
+ "HIS": "H",
70
+ "ILE": "I",
71
+ "LEU": "L",
72
+ "LYS": "K",
73
+ "MET": "M",
74
+ "PHE": "F",
75
+ "PRO": "P",
76
+ "SER": "S",
77
+ "THR": "T",
78
+ "TRP": "W",
79
+ "TYR": "Y",
80
+ "VAL": "V",
81
+ "MSE": "M",
82
+ }
83
+
84
+ # 1-letter to 3-letter codes
85
+ PROTEIN_1TO3 = {v: k for k, v in PROTEIN_3TO1.items() if k != "MSE"}
86
+ PROTEIN_1TO3["X"] = "UNK"
87
+
88
+ # DNA 1-letter to CCD code
89
+ DNA_1TO3 = {"A": "DA", "T": "DT", "C": "DC", "G": "DG"}
90
+
91
+ # RNA 1-letter to CCD code
92
+ RNA_1TO3 = {"A": "A", "U": "U", "C": "C", "G": "G"}
93
+
94
+ # ESM-2 input_ids vocabulary for proteins
95
+ ESM_PROTEIN_VOCAB = {
96
+ "L": 4,
97
+ "A": 5,
98
+ "G": 6,
99
+ "V": 7,
100
+ "S": 8,
101
+ "E": 9,
102
+ "R": 10,
103
+ "T": 11,
104
+ "I": 12,
105
+ "D": 13,
106
+ "P": 14,
107
+ "K": 15,
108
+ "Q": 16,
109
+ "N": 17,
110
+ "F": 18,
111
+ "Y": 19,
112
+ "M": 20,
113
+ "H": 21,
114
+ "W": 22,
115
+ "C": 23,
116
+ "X": 3, # Unknown
117
+ }
118
+
119
+ # For DNA/RNA/ligands
120
+ DNA_RNA_LIGAND_INPUT_ID = 24
121
+
122
+ # MSA tokens
123
+ MSA_PAD_TOKEN_ID = 0
124
+ MSA_GAP_TOKEN_ID = 1 # Gap/insertion token for MSA
125
+
126
+ # res_type int -> CCD component ID (for conformer lookup)
127
+ RES_TYPE_TO_CCD = {
128
+ # Proteins (2-22)
129
+ 2: "ALA",
130
+ 3: "ARG",
131
+ 4: "ASN",
132
+ 5: "ASP",
133
+ 6: "CYS",
134
+ 7: "GLN",
135
+ 8: "GLU",
136
+ 9: "GLY",
137
+ 10: "HIS",
138
+ 11: "ILE",
139
+ 12: "LEU",
140
+ 13: "LYS",
141
+ 14: "MET",
142
+ 15: "PHE",
143
+ 16: "PRO",
144
+ 17: "SER",
145
+ 18: "THR",
146
+ 19: "TRP",
147
+ 20: "TYR",
148
+ 21: "VAL",
149
+ 22: "UNK",
150
+ # RNA (23-27)
151
+ 23: "A",
152
+ 24: "G",
153
+ 25: "C",
154
+ 26: "U",
155
+ 27: "N",
156
+ # DNA (28-32)
157
+ 28: "DA",
158
+ 29: "DG",
159
+ 30: "DC",
160
+ 31: "DT",
161
+ 32: "DN",
162
+ }
163
+
164
+ # =============================================================================
165
+ # Charged atoms at physiological pH
166
+ # =============================================================================
167
+
168
+ CHARGED_ATOMS: dict[tuple[str, str], int] = {
169
+ ("LYS", "NZ"): 1,
170
+ ("ARG", "NH2"): 1,
171
+ ("HIS", "ND1"): 1,
172
+ ("PO4", "O2"): -1,
173
+ ("PO4", "O3"): -1,
174
+ ("PO4", "O4"): -1,
175
+ ("SO4", "O3"): -1,
176
+ ("SO4", "O4"): -1,
177
+ ("MG", "MG"): 2,
178
+ ("ZN", "ZN"): 2,
179
+ ("CA", "CA"): 2,
180
+ ("FE2", "FE"): 2,
181
+ ("MN", "MN"): 2,
182
+ ("CO", "CO"): 2,
183
+ ("NCO", "CO"): 3,
184
+ ("CU", "CU"): 2,
185
+ ("NI", "NI"): 2,
186
+ ("K", "K"): 1,
187
+ ("NA", "NA"): 1,
188
+ ("CD", "CD"): 2,
189
+ ("CL", "CL"): -1,
190
+ ("ACT", "OXT"): -1,
191
+ ("NAD", "O2N"): -1,
192
+ ("NAD", "N1N"): 1,
193
+ ("NAP", "O2N"): -1,
194
+ ("NAP", "N1N"): 1,
195
+ ("IMD", "N3"): 1,
196
+ ("SAM", "SD"): 1,
197
+ ("FE", "FE"): 3,
198
+ ("A1BH3", "N3"): 1,
199
+ }
200
+
201
+ # =============================================================================
202
+ # Element atomic numbers (Z=1 to 92)
203
+ # =============================================================================
204
+
205
+ ELEMENT_TO_ATOMIC_NUM = {
206
+ "H": 1,
207
+ "LI": 3,
208
+ "BE": 4,
209
+ "B": 5,
210
+ "C": 6,
211
+ "N": 7,
212
+ "O": 8,
213
+ "F": 9,
214
+ "NE": 10,
215
+ "NA": 11,
216
+ "MG": 12,
217
+ "AL": 13,
218
+ "SI": 14,
219
+ "P": 15,
220
+ "S": 16,
221
+ "CL": 17,
222
+ "AR": 18,
223
+ "K": 19,
224
+ "CA": 20,
225
+ "SC": 21,
226
+ "TI": 22,
227
+ "V": 23,
228
+ "CR": 24,
229
+ "MN": 25,
230
+ "FE": 26,
231
+ "CO": 27,
232
+ "NI": 28,
233
+ "CU": 29,
234
+ "ZN": 30,
235
+ "GA": 31,
236
+ "GE": 32,
237
+ "AS": 33,
238
+ "SE": 34,
239
+ "BR": 35,
240
+ "KR": 36,
241
+ "RB": 37,
242
+ "SR": 38,
243
+ "Y": 39,
244
+ "ZR": 40,
245
+ "NB": 41,
246
+ "MO": 42,
247
+ "TC": 43,
248
+ "RU": 44,
249
+ "RH": 45,
250
+ "PD": 46,
251
+ "AG": 47,
252
+ "CD": 48,
253
+ "IN": 49,
254
+ "SN": 50,
255
+ "SB": 51,
256
+ "TE": 52,
257
+ "I": 53,
258
+ "XE": 54,
259
+ "CS": 55,
260
+ "BA": 56,
261
+ "LA": 57,
262
+ "CE": 58,
263
+ "PR": 59,
264
+ "ND": 60,
265
+ "PM": 61,
266
+ "SM": 62,
267
+ "EU": 63,
268
+ "GD": 64,
269
+ "TB": 65,
270
+ "DY": 66,
271
+ "HO": 67,
272
+ "ER": 68,
273
+ "TM": 69,
274
+ "YB": 70,
275
+ "LU": 71,
276
+ "HF": 72,
277
+ "TA": 73,
278
+ "W": 74,
279
+ "RE": 75,
280
+ "OS": 76,
281
+ "IR": 77,
282
+ "PT": 78,
283
+ "AU": 79,
284
+ "HG": 80,
285
+ "TL": 81,
286
+ "PB": 82,
287
+ "BI": 83,
288
+ "PO": 84,
289
+ "AT": 85,
290
+ "RN": 86,
291
+ "FR": 87,
292
+ "RA": 88,
293
+ "AC": 89,
294
+ "TH": 90,
295
+ "PA": 91,
296
+ "U": 92,
297
+ }
298
+
299
+ # Inverse mapping: atomic number → element symbol
300
+ ELEMENT_NUMBER_TO_SYMBOL = {v: k for k, v in ELEMENT_TO_ATOMIC_NUM.items()}
301
+
302
+ # =============================================================================
303
+ # Standard heavy atoms per residue type
304
+ # =============================================================================
305
+
306
+ PROTEIN_HEAVY_ATOMS = {
307
+ "ALA": ["N", "CA", "C", "O", "CB"],
308
+ "ARG": ["N", "CA", "C", "O", "CB", "CG", "CD", "NE", "CZ", "NH1", "NH2"],
309
+ "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2"],
310
+ "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2"],
311
+ "CYS": ["N", "CA", "C", "O", "CB", "SG"],
312
+ "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2"],
313
+ "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2"],
314
+ "GLY": ["N", "CA", "C", "O"],
315
+ "HIS": ["N", "CA", "C", "O", "CB", "CG", "ND1", "CD2", "CE1", "NE2"],
316
+ "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1"],
317
+ "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2"],
318
+ "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ"],
319
+ "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
320
+ "PHE": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
321
+ "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD"],
322
+ "SER": ["N", "CA", "C", "O", "CB", "OG"],
323
+ "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2"],
324
+ "TRP": [
325
+ "N",
326
+ "CA",
327
+ "C",
328
+ "O",
329
+ "CB",
330
+ "CG",
331
+ "CD1",
332
+ "CD2",
333
+ "NE1",
334
+ "CE2",
335
+ "CE3",
336
+ "CZ2",
337
+ "CZ3",
338
+ "CH2",
339
+ ],
340
+ "TYR": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "OH"],
341
+ "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2"],
342
+ "MSE": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
343
+ "UNK": ["N", "CA", "C", "O"],
344
+ }
345
+
346
+ DNA_HEAVY_ATOMS = {
347
+ "DA": [
348
+ "P",
349
+ "OP1",
350
+ "OP2",
351
+ "O5'",
352
+ "C5'",
353
+ "C4'",
354
+ "O4'",
355
+ "C3'",
356
+ "O3'",
357
+ "C2'",
358
+ "C1'",
359
+ "N9",
360
+ "C8",
361
+ "N7",
362
+ "C5",
363
+ "C6",
364
+ "N6",
365
+ "N1",
366
+ "C2",
367
+ "N3",
368
+ "C4",
369
+ ],
370
+ "DG": [
371
+ "P",
372
+ "OP1",
373
+ "OP2",
374
+ "O5'",
375
+ "C5'",
376
+ "C4'",
377
+ "O4'",
378
+ "C3'",
379
+ "O3'",
380
+ "C2'",
381
+ "C1'",
382
+ "N9",
383
+ "C8",
384
+ "N7",
385
+ "C5",
386
+ "C6",
387
+ "O6",
388
+ "N1",
389
+ "C2",
390
+ "N2",
391
+ "N3",
392
+ "C4",
393
+ ],
394
+ "DC": [
395
+ "P",
396
+ "OP1",
397
+ "OP2",
398
+ "O5'",
399
+ "C5'",
400
+ "C4'",
401
+ "O4'",
402
+ "C3'",
403
+ "O3'",
404
+ "C2'",
405
+ "C1'",
406
+ "N1",
407
+ "C2",
408
+ "O2",
409
+ "N3",
410
+ "C4",
411
+ "N4",
412
+ "C5",
413
+ "C6",
414
+ ],
415
+ "DT": [
416
+ "P",
417
+ "OP1",
418
+ "OP2",
419
+ "O5'",
420
+ "C5'",
421
+ "C4'",
422
+ "O4'",
423
+ "C3'",
424
+ "O3'",
425
+ "C2'",
426
+ "C1'",
427
+ "N1",
428
+ "C2",
429
+ "O2",
430
+ "N3",
431
+ "C4",
432
+ "O4",
433
+ "C5",
434
+ "C7",
435
+ "C6",
436
+ ],
437
+ }
438
+
439
+ RNA_HEAVY_ATOMS = {
440
+ "A": [
441
+ "P",
442
+ "OP1",
443
+ "OP2",
444
+ "O5'",
445
+ "C5'",
446
+ "C4'",
447
+ "O4'",
448
+ "C3'",
449
+ "O3'",
450
+ "C2'",
451
+ "O2'",
452
+ "C1'",
453
+ "N9",
454
+ "C8",
455
+ "N7",
456
+ "C5",
457
+ "C6",
458
+ "N6",
459
+ "N1",
460
+ "C2",
461
+ "N3",
462
+ "C4",
463
+ ],
464
+ "G": [
465
+ "P",
466
+ "OP1",
467
+ "OP2",
468
+ "O5'",
469
+ "C5'",
470
+ "C4'",
471
+ "O4'",
472
+ "C3'",
473
+ "O3'",
474
+ "C2'",
475
+ "O2'",
476
+ "C1'",
477
+ "N9",
478
+ "C8",
479
+ "N7",
480
+ "C5",
481
+ "C6",
482
+ "O6",
483
+ "N1",
484
+ "C2",
485
+ "N2",
486
+ "N3",
487
+ "C4",
488
+ ],
489
+ "C": [
490
+ "P",
491
+ "OP1",
492
+ "OP2",
493
+ "O5'",
494
+ "C5'",
495
+ "C4'",
496
+ "O4'",
497
+ "C3'",
498
+ "O3'",
499
+ "C2'",
500
+ "O2'",
501
+ "C1'",
502
+ "N1",
503
+ "C2",
504
+ "O2",
505
+ "N3",
506
+ "C4",
507
+ "N4",
508
+ "C5",
509
+ "C6",
510
+ ],
511
+ "U": [
512
+ "P",
513
+ "OP1",
514
+ "OP2",
515
+ "O5'",
516
+ "C5'",
517
+ "C4'",
518
+ "O4'",
519
+ "C3'",
520
+ "O3'",
521
+ "C2'",
522
+ "O2'",
523
+ "C1'",
524
+ "N1",
525
+ "C2",
526
+ "O2",
527
+ "N3",
528
+ "C4",
529
+ "O4",
530
+ "C5",
531
+ "C6",
532
+ ],
533
+ }
534
+
535
+ # Unknown nucleotide backbone atoms
536
+ DNA_BACKBONE_ATOMS = [
537
+ "P",
538
+ "OP1",
539
+ "OP2",
540
+ "O5'",
541
+ "C5'",
542
+ "C4'",
543
+ "O4'",
544
+ "C3'",
545
+ "O3'",
546
+ "C2'",
547
+ "C1'",
548
+ ]
549
+ RNA_BACKBONE_ATOMS = [
550
+ "P",
551
+ "OP1",
552
+ "OP2",
553
+ "O5'",
554
+ "C5'",
555
+ "C4'",
556
+ "O4'",
557
+ "C3'",
558
+ "O3'",
559
+ "C2'",
560
+ "O2'",
561
+ "C1'",
562
+ ]
esmfold2_constants_esm3.py CHANGED
@@ -1,137 +1,137 @@
1
- import os
2
- from functools import cache
3
- from pathlib import Path
4
-
5
- from huggingface_hub import snapshot_download
6
-
7
- SEQUENCE_BOS_TOKEN = 0
8
- SEQUENCE_PAD_TOKEN = 1
9
- SEQUENCE_EOS_TOKEN = 2
10
- SEQUENCE_CHAINBREAK_TOKEN = 31
11
- SEQUENCE_MASK_TOKEN = 32
12
-
13
- VQVAE_CODEBOOK_SIZE = 4096
14
- VQVAE_SPECIAL_TOKENS = {
15
- "MASK": VQVAE_CODEBOOK_SIZE,
16
- "EOS": VQVAE_CODEBOOK_SIZE + 1,
17
- "BOS": VQVAE_CODEBOOK_SIZE + 2,
18
- "PAD": VQVAE_CODEBOOK_SIZE + 3,
19
- "CHAINBREAK": VQVAE_CODEBOOK_SIZE + 4,
20
- }
21
- VQVAE_DIRECTION_LOSS_BINS = 16
22
- VQVAE_PAE_BINS = 64
23
- VQVAE_MAX_PAE_BIN = 31.0
24
- VQVAE_PLDDT_BINS = 50
25
-
26
- STRUCTURE_MASK_TOKEN = VQVAE_SPECIAL_TOKENS["MASK"]
27
- STRUCTURE_BOS_TOKEN = VQVAE_SPECIAL_TOKENS["BOS"]
28
- STRUCTURE_EOS_TOKEN = VQVAE_SPECIAL_TOKENS["EOS"]
29
- STRUCTURE_PAD_TOKEN = VQVAE_SPECIAL_TOKENS["PAD"]
30
- STRUCTURE_CHAINBREAK_TOKEN = VQVAE_SPECIAL_TOKENS["CHAINBREAK"]
31
- STRUCTURE_UNDEFINED_TOKEN = 955
32
-
33
- SASA_PAD_TOKEN = 0
34
-
35
- SS8_PAD_TOKEN = 0
36
-
37
- INTERPRO_PAD_TOKEN = 0
38
-
39
- RESIDUE_PAD_TOKEN = 0
40
-
41
- CHAIN_BREAK_STR = "|"
42
-
43
- SEQUENCE_BOS_STR = "<cls>"
44
- SEQUENCE_EOS_STR = "<eos>"
45
-
46
- MASK_STR_SHORT = "_"
47
- SEQUENCE_MASK_STR = "<mask>"
48
- SASA_MASK_STR = "<unk>"
49
- SS8_MASK_STR = "<unk>"
50
-
51
- # fmt: off
52
- SEQUENCE_VOCAB = [
53
- "<cls>", "<pad>", "<eos>", "<unk>",
54
- "L", "A", "G", "V", "S", "E", "R", "T", "I", "D", "P", "K",
55
- "Q", "N", "F", "Y", "M", "H", "W", "C", "X", "B", "U", "Z",
56
- "O", ".", "-", "|",
57
- "<mask>",
58
- ]
59
- # fmt: on
60
-
61
- SEQUENCE_STANDARD_AA_MIN_TOKEN = 4 # L
62
- SEQUENCE_STANDARD_AA_MAX_TOKEN = 24 # X (exclusive)
63
-
64
- SSE_8CLASS_VOCAB = "GHITEBSC"
65
- SSE_3CLASS_VOCAB = "HEC"
66
- SSE_8CLASS_TO_3CLASS_MAP = {
67
- "G": "H",
68
- "H": "H",
69
- "I": "H",
70
- "T": "C",
71
- "E": "E",
72
- "B": "E",
73
- "S": "C",
74
- "C": "C",
75
- }
76
-
77
- SASA_DISCRETIZATION_BOUNDARIES = [
78
- 0.8,
79
- 4.0,
80
- 9.6,
81
- 16.4,
82
- 24.5,
83
- 32.9,
84
- 42.0,
85
- 51.5,
86
- 61.2,
87
- 70.9,
88
- 81.6,
89
- 93.3,
90
- 107.2,
91
- 125.4,
92
- 151.4,
93
- ]
94
-
95
- MAX_RESIDUE_ANNOTATIONS = 16
96
-
97
-
98
- TFIDF_VECTOR_SIZE = 58641
99
-
100
- FUNCTION_TOKENS_DEPTH = 8
101
-
102
-
103
- @staticmethod
104
- @cache
105
- def data_root(model: str):
106
- if "INFRA_PROVIDER" in os.environ:
107
- return Path("")
108
- # Try to download from huggingface if it doesn't exist
109
- if model.startswith("esm3"):
110
- path = Path(snapshot_download(repo_id="biohub/esm3-sm-open-v1"))
111
- elif model.startswith("esmc-300"):
112
- path = Path(snapshot_download(repo_id="biohub/esmc-300m-2024-12"))
113
- elif model.startswith("esmc-600"):
114
- path = Path(snapshot_download(repo_id="biohub/esmc-600m-2024-12"))
115
- elif model.startswith("esmc-6b"):
116
- path = Path(snapshot_download(repo_id="biohub/esmc-6b-2024-12"))
117
- else:
118
- raise ValueError(f"{model=} is an invalid model name.")
119
- return path
120
-
121
-
122
- IN_REPO_DATA_FOLDER = Path(__file__).parents[2] / "data"
123
-
124
- INTERPRO_ENTRY = IN_REPO_DATA_FOLDER / "entry_list_safety_29026.list"
125
- INTERPRO_HIERARCHY = IN_REPO_DATA_FOLDER / "ParentChildTreeFile.txt"
126
- INTERPRO2GO = IN_REPO_DATA_FOLDER / "ParentChildTreeFile.txt"
127
- INTERPRO_2ID = "data/tag_dict_4_safety_filtered.json"
128
-
129
- LSH_TABLE_PATHS = {"8bit": "data/hyperplanes_8bit_58641.npz"}
130
-
131
- KEYWORDS_VOCABULARY = (
132
- IN_REPO_DATA_FOLDER / "keyword_vocabulary_safety_filtered_58641.txt"
133
- )
134
- KEYWORDS_IDF = IN_REPO_DATA_FOLDER / "keyword_idf_safety_filtered_58641.npy"
135
-
136
- RESID_CSV = "data/uniref90_and_mgnify90_residue_annotations_gt_1k_proteins.csv"
137
- INTERPRO2KEYWORDS = IN_REPO_DATA_FOLDER / "interpro_29026_to_keywords_58641.csv"
 
1
+ import os
2
+ from functools import cache
3
+ from pathlib import Path
4
+
5
+ from huggingface_hub import snapshot_download
6
+
7
+ SEQUENCE_BOS_TOKEN = 0
8
+ SEQUENCE_PAD_TOKEN = 1
9
+ SEQUENCE_EOS_TOKEN = 2
10
+ SEQUENCE_CHAINBREAK_TOKEN = 31
11
+ SEQUENCE_MASK_TOKEN = 32
12
+
13
+ VQVAE_CODEBOOK_SIZE = 4096
14
+ VQVAE_SPECIAL_TOKENS = {
15
+ "MASK": VQVAE_CODEBOOK_SIZE,
16
+ "EOS": VQVAE_CODEBOOK_SIZE + 1,
17
+ "BOS": VQVAE_CODEBOOK_SIZE + 2,
18
+ "PAD": VQVAE_CODEBOOK_SIZE + 3,
19
+ "CHAINBREAK": VQVAE_CODEBOOK_SIZE + 4,
20
+ }
21
+ VQVAE_DIRECTION_LOSS_BINS = 16
22
+ VQVAE_PAE_BINS = 64
23
+ VQVAE_MAX_PAE_BIN = 31.0
24
+ VQVAE_PLDDT_BINS = 50
25
+
26
+ STRUCTURE_MASK_TOKEN = VQVAE_SPECIAL_TOKENS["MASK"]
27
+ STRUCTURE_BOS_TOKEN = VQVAE_SPECIAL_TOKENS["BOS"]
28
+ STRUCTURE_EOS_TOKEN = VQVAE_SPECIAL_TOKENS["EOS"]
29
+ STRUCTURE_PAD_TOKEN = VQVAE_SPECIAL_TOKENS["PAD"]
30
+ STRUCTURE_CHAINBREAK_TOKEN = VQVAE_SPECIAL_TOKENS["CHAINBREAK"]
31
+ STRUCTURE_UNDEFINED_TOKEN = 955
32
+
33
+ SASA_PAD_TOKEN = 0
34
+
35
+ SS8_PAD_TOKEN = 0
36
+
37
+ INTERPRO_PAD_TOKEN = 0
38
+
39
+ RESIDUE_PAD_TOKEN = 0
40
+
41
+ CHAIN_BREAK_STR = "|"
42
+
43
+ SEQUENCE_BOS_STR = "<cls>"
44
+ SEQUENCE_EOS_STR = "<eos>"
45
+
46
+ MASK_STR_SHORT = "_"
47
+ SEQUENCE_MASK_STR = "<mask>"
48
+ SASA_MASK_STR = "<unk>"
49
+ SS8_MASK_STR = "<unk>"
50
+
51
+ # fmt: off
52
+ SEQUENCE_VOCAB = [
53
+ "<cls>", "<pad>", "<eos>", "<unk>",
54
+ "L", "A", "G", "V", "S", "E", "R", "T", "I", "D", "P", "K",
55
+ "Q", "N", "F", "Y", "M", "H", "W", "C", "X", "B", "U", "Z",
56
+ "O", ".", "-", "|",
57
+ "<mask>",
58
+ ]
59
+ # fmt: on
60
+
61
+ SEQUENCE_STANDARD_AA_MIN_TOKEN = 4 # L
62
+ SEQUENCE_STANDARD_AA_MAX_TOKEN = 24 # X (exclusive)
63
+
64
+ SSE_8CLASS_VOCAB = "GHITEBSC"
65
+ SSE_3CLASS_VOCAB = "HEC"
66
+ SSE_8CLASS_TO_3CLASS_MAP = {
67
+ "G": "H",
68
+ "H": "H",
69
+ "I": "H",
70
+ "T": "C",
71
+ "E": "E",
72
+ "B": "E",
73
+ "S": "C",
74
+ "C": "C",
75
+ }
76
+
77
+ SASA_DISCRETIZATION_BOUNDARIES = [
78
+ 0.8,
79
+ 4.0,
80
+ 9.6,
81
+ 16.4,
82
+ 24.5,
83
+ 32.9,
84
+ 42.0,
85
+ 51.5,
86
+ 61.2,
87
+ 70.9,
88
+ 81.6,
89
+ 93.3,
90
+ 107.2,
91
+ 125.4,
92
+ 151.4,
93
+ ]
94
+
95
+ MAX_RESIDUE_ANNOTATIONS = 16
96
+
97
+
98
+ TFIDF_VECTOR_SIZE = 58641
99
+
100
+ FUNCTION_TOKENS_DEPTH = 8
101
+
102
+
103
+ @staticmethod
104
+ @cache
105
+ def data_root(model: str):
106
+ if "INFRA_PROVIDER" in os.environ:
107
+ return Path("")
108
+ # Try to download from huggingface if it doesn't exist
109
+ if model.startswith("esm3"):
110
+ path = Path(snapshot_download(repo_id="biohub/esm3-sm-open-v1"))
111
+ elif model.startswith("esmc-300"):
112
+ path = Path(snapshot_download(repo_id="biohub/esmc-300m-2024-12"))
113
+ elif model.startswith("esmc-600"):
114
+ path = Path(snapshot_download(repo_id="biohub/esmc-600m-2024-12"))
115
+ elif model.startswith("esmc-6b"):
116
+ path = Path(snapshot_download(repo_id="biohub/esmc-6b-2024-12"))
117
+ else:
118
+ raise ValueError(f"{model=} is an invalid model name.")
119
+ return path
120
+
121
+
122
+ IN_REPO_DATA_FOLDER = Path(__file__).parents[2] / "data"
123
+
124
+ INTERPRO_ENTRY = IN_REPO_DATA_FOLDER / "entry_list_safety_29026.list"
125
+ INTERPRO_HIERARCHY = IN_REPO_DATA_FOLDER / "ParentChildTreeFile.txt"
126
+ INTERPRO2GO = IN_REPO_DATA_FOLDER / "ParentChildTreeFile.txt"
127
+ INTERPRO_2ID = "data/tag_dict_4_safety_filtered.json"
128
+
129
+ LSH_TABLE_PATHS = {"8bit": "data/hyperplanes_8bit_58641.npz"}
130
+
131
+ KEYWORDS_VOCABULARY = (
132
+ IN_REPO_DATA_FOLDER / "keyword_vocabulary_safety_filtered_58641.txt"
133
+ )
134
+ KEYWORDS_IDF = IN_REPO_DATA_FOLDER / "keyword_idf_safety_filtered_58641.npy"
135
+
136
+ RESID_CSV = "data/uniref90_and_mgnify90_residue_annotations_gt_1k_proteins.csv"
137
+ INTERPRO2KEYWORDS = IN_REPO_DATA_FOLDER / "interpro_29026_to_keywords_58641.csv"
esmfold2_input_builder.py CHANGED
@@ -1,254 +1,254 @@
1
- from dataclasses import dataclass
2
- from typing import Any, Sequence, TypeAlias, Union
3
-
4
- import numpy as np
5
-
6
- from .esmfold2_msa import MSA
7
-
8
- # fmt: off
9
- MSAInput: TypeAlias = Union[
10
- MSA,
11
- None,
12
- ]
13
- # fmt: on
14
-
15
-
16
- @dataclass
17
- class Modification:
18
- position: int # zero-indexed
19
- ccd: str
20
- smiles: str | None = None # TODO(mlee): add smiles support
21
-
22
-
23
- @dataclass
24
- class ProteinInput:
25
- id: str | list[str]
26
- sequence: str
27
- modifications: list[Modification] | None = None
28
- msa: MSAInput = None
29
-
30
-
31
- @dataclass
32
- class RNAInput:
33
- id: str | list[str]
34
- sequence: str
35
- modifications: list[Modification] | None = None
36
-
37
-
38
- @dataclass
39
- class DNAInput:
40
- id: str | list[str]
41
- sequence: str
42
- modifications: list[Modification] | None = None
43
-
44
-
45
- @dataclass
46
- class LigandInput:
47
- id: str | list[str]
48
- smiles: str | None = None
49
- ccd: list[str] | None = None
50
-
51
-
52
- @dataclass
53
- class DistogramConditioning:
54
- chain_id: str
55
- distogram: np.ndarray
56
-
57
-
58
- @dataclass
59
- class PocketConditioning:
60
- binder_chain_id: str
61
- contacts: list[tuple[str, int]]
62
-
63
-
64
- @dataclass
65
- class CovalentBond:
66
- chain_id1: str
67
- res_idx1: int
68
- atom_idx1: int
69
- chain_id2: str
70
- res_idx2: int
71
- atom_idx2: int
72
-
73
-
74
- @dataclass
75
- class StructurePredictionInput:
76
- sequences: Sequence[ProteinInput | RNAInput | DNAInput | LigandInput]
77
- pocket: PocketConditioning | None = None
78
- distogram_conditioning: list[DistogramConditioning] | None = None
79
- covalent_bonds: list[CovalentBond] | None = None
80
-
81
-
82
- def serialize_structure_prediction_input(all_atom_input: StructurePredictionInput):
83
- def create_chain_data(seq_input, chain_type: str) -> dict[str, Any]:
84
- chain_data: dict[str, Any] = {
85
- "sequence": seq_input.sequence,
86
- "id": seq_input.id,
87
- "type": chain_type,
88
- }
89
- if hasattr(seq_input, "modifications") and seq_input.modifications:
90
- mods = [
91
- {"position": mod.position, "ccd": mod.ccd}
92
- for mod in seq_input.modifications
93
- ]
94
- chain_data["modifications"] = mods
95
- if not hasattr(seq_input, "msa"):
96
- pass
97
- elif seq_input.msa is None:
98
- chain_data["msa"] = None
99
- elif isinstance(seq_input.msa, MSA):
100
- chain_data["msa"] = {"sequences": seq_input.msa.sequences}
101
- else:
102
- error_msg = f"MSA must be None or MSA. Got {seq_input.msa} instead."
103
- raise AttributeError(error_msg)
104
- return chain_data
105
-
106
- sequences = []
107
- for seq_input in all_atom_input.sequences:
108
- if isinstance(seq_input, ProteinInput):
109
- sequences.append(create_chain_data(seq_input, "protein"))
110
- elif isinstance(seq_input, RNAInput):
111
- sequences.append(create_chain_data(seq_input, "rna"))
112
- elif isinstance(seq_input, DNAInput):
113
- sequences.append(create_chain_data(seq_input, "dna"))
114
- elif isinstance(seq_input, LigandInput):
115
- sequences.append(
116
- {
117
- "smiles": seq_input.smiles,
118
- "id": seq_input.id,
119
- "ccd": seq_input.ccd,
120
- "type": "ligand",
121
- }
122
- )
123
- else:
124
- raise ValueError(f"Unsupported sequence input type: {type(seq_input)}")
125
-
126
- result: dict[str, Any] = {"sequences": sequences}
127
-
128
- if all_atom_input.covalent_bonds is not None:
129
- result["covalent_bonds"] = [
130
- {
131
- "chain_id1": bond.chain_id1,
132
- "res_idx1": bond.res_idx1,
133
- "atom_idx1": bond.atom_idx1,
134
- "chain_id2": bond.chain_id2,
135
- "res_idx2": bond.res_idx2,
136
- "atom_idx2": bond.atom_idx2,
137
- }
138
- for bond in all_atom_input.covalent_bonds
139
- ]
140
-
141
- if all_atom_input.pocket is not None:
142
- result["pocket"] = {
143
- "binder_chain_id": all_atom_input.pocket.binder_chain_id,
144
- "contacts": all_atom_input.pocket.contacts,
145
- }
146
-
147
- if all_atom_input.distogram_conditioning is not None:
148
- result["distogram_conditioning"] = [
149
- {"chain_id": disto.chain_id, "distogram": disto.distogram.tolist()}
150
- for disto in all_atom_input.distogram_conditioning
151
- ]
152
-
153
- return result
154
-
155
-
156
- def deserialize_structure_prediction_input(
157
- data: dict[str, Any],
158
- ) -> StructurePredictionInput:
159
- """Inverse of :func:`serialize_structure_prediction_input`.
160
-
161
- Reconstructs a :class:`StructurePredictionInput` from the JSON-safe dict
162
- produced by ``serialize_structure_prediction_input``. Values round-trip;
163
- ``DistogramConditioning.distogram`` dtype follows from JSON (``int64``
164
- for integer entries, ``float64`` for floats) — cast back to the original
165
- dtype if downstream code requires a specific one.
166
- """
167
-
168
- def _mods(chain: dict[str, Any]) -> list[Modification] | None:
169
- raw = chain.get("modifications")
170
- if not raw:
171
- return None
172
- return [Modification(position=m["position"], ccd=m["ccd"]) for m in raw]
173
-
174
- def _msa(chain: dict[str, Any]) -> MSAInput:
175
- if "msa" not in chain or chain["msa"] is None:
176
- return None
177
- msa_blk = chain["msa"]
178
- if isinstance(msa_blk, str):
179
- raise ValueError(f"Unexpected MSA string value: {msa_blk!r}")
180
- return MSA.from_sequences(msa_blk["sequences"])
181
-
182
- sequences: list[ProteinInput | RNAInput | DNAInput | LigandInput] = []
183
- for chain in data["sequences"]:
184
- t = chain["type"]
185
- if t == "protein":
186
- sequences.append(
187
- ProteinInput(
188
- id=chain["id"],
189
- sequence=chain["sequence"],
190
- modifications=_mods(chain),
191
- msa=_msa(chain),
192
- )
193
- )
194
- elif t == "rna":
195
- sequences.append(
196
- RNAInput(
197
- id=chain["id"],
198
- sequence=chain["sequence"],
199
- modifications=_mods(chain),
200
- )
201
- )
202
- elif t == "dna":
203
- sequences.append(
204
- DNAInput(
205
- id=chain["id"],
206
- sequence=chain["sequence"],
207
- modifications=_mods(chain),
208
- )
209
- )
210
- elif t == "ligand":
211
- sequences.append(
212
- LigandInput(
213
- id=chain["id"], smiles=chain.get("smiles"), ccd=chain.get("ccd")
214
- )
215
- )
216
- else:
217
- raise ValueError(f"Unsupported sequence type: {t!r}")
218
-
219
- pocket: PocketConditioning | None = None
220
- if (pocket_blk := data.get("pocket")) is not None:
221
- pocket = PocketConditioning(
222
- binder_chain_id=pocket_blk["binder_chain_id"],
223
- contacts=[tuple(c) for c in pocket_blk["contacts"]],
224
- )
225
-
226
- distogram_conditioning: list[DistogramConditioning] | None = None
227
- if (disto_blk := data.get("distogram_conditioning")) is not None:
228
- distogram_conditioning = [
229
- DistogramConditioning(
230
- chain_id=d["chain_id"], distogram=np.asarray(d["distogram"])
231
- )
232
- for d in disto_blk
233
- ]
234
-
235
- covalent_bonds: list[CovalentBond] | None = None
236
- if (bonds_blk := data.get("covalent_bonds")) is not None:
237
- covalent_bonds = [
238
- CovalentBond(
239
- chain_id1=b["chain_id1"],
240
- res_idx1=b["res_idx1"],
241
- atom_idx1=b["atom_idx1"],
242
- chain_id2=b["chain_id2"],
243
- res_idx2=b["res_idx2"],
244
- atom_idx2=b["atom_idx2"],
245
- )
246
- for b in bonds_blk
247
- ]
248
-
249
- return StructurePredictionInput(
250
- sequences=sequences,
251
- pocket=pocket,
252
- distogram_conditioning=distogram_conditioning,
253
- covalent_bonds=covalent_bonds,
254
- )
 
1
+ from dataclasses import dataclass
2
+ from typing import Any, Sequence, TypeAlias, Union
3
+
4
+ import numpy as np
5
+
6
+ from .esmfold2_msa import MSA
7
+
8
+ # fmt: off
9
+ MSAInput: TypeAlias = Union[
10
+ MSA,
11
+ None,
12
+ ]
13
+ # fmt: on
14
+
15
+
16
+ @dataclass
17
+ class Modification:
18
+ position: int # zero-indexed
19
+ ccd: str
20
+ smiles: str | None = None # TODO(mlee): add smiles support
21
+
22
+
23
+ @dataclass
24
+ class ProteinInput:
25
+ id: str | list[str]
26
+ sequence: str
27
+ modifications: list[Modification] | None = None
28
+ msa: MSAInput = None
29
+
30
+
31
+ @dataclass
32
+ class RNAInput:
33
+ id: str | list[str]
34
+ sequence: str
35
+ modifications: list[Modification] | None = None
36
+
37
+
38
+ @dataclass
39
+ class DNAInput:
40
+ id: str | list[str]
41
+ sequence: str
42
+ modifications: list[Modification] | None = None
43
+
44
+
45
+ @dataclass
46
+ class LigandInput:
47
+ id: str | list[str]
48
+ smiles: str | None = None
49
+ ccd: list[str] | None = None
50
+
51
+
52
+ @dataclass
53
+ class DistogramConditioning:
54
+ chain_id: str
55
+ distogram: np.ndarray
56
+
57
+
58
+ @dataclass
59
+ class PocketConditioning:
60
+ binder_chain_id: str
61
+ contacts: list[tuple[str, int]]
62
+
63
+
64
+ @dataclass
65
+ class CovalentBond:
66
+ chain_id1: str
67
+ res_idx1: int
68
+ atom_idx1: int
69
+ chain_id2: str
70
+ res_idx2: int
71
+ atom_idx2: int
72
+
73
+
74
+ @dataclass
75
+ class StructurePredictionInput:
76
+ sequences: Sequence[ProteinInput | RNAInput | DNAInput | LigandInput]
77
+ pocket: PocketConditioning | None = None
78
+ distogram_conditioning: list[DistogramConditioning] | None = None
79
+ covalent_bonds: list[CovalentBond] | None = None
80
+
81
+
82
+ def serialize_structure_prediction_input(all_atom_input: StructurePredictionInput):
83
+ def create_chain_data(seq_input, chain_type: str) -> dict[str, Any]:
84
+ chain_data: dict[str, Any] = {
85
+ "sequence": seq_input.sequence,
86
+ "id": seq_input.id,
87
+ "type": chain_type,
88
+ }
89
+ if hasattr(seq_input, "modifications") and seq_input.modifications:
90
+ mods = [
91
+ {"position": mod.position, "ccd": mod.ccd}
92
+ for mod in seq_input.modifications
93
+ ]
94
+ chain_data["modifications"] = mods
95
+ if not hasattr(seq_input, "msa"):
96
+ pass
97
+ elif seq_input.msa is None:
98
+ chain_data["msa"] = None
99
+ elif isinstance(seq_input.msa, MSA):
100
+ chain_data["msa"] = {"sequences": seq_input.msa.sequences}
101
+ else:
102
+ error_msg = f"MSA must be None or MSA. Got {seq_input.msa} instead."
103
+ raise AttributeError(error_msg)
104
+ return chain_data
105
+
106
+ sequences = []
107
+ for seq_input in all_atom_input.sequences:
108
+ if isinstance(seq_input, ProteinInput):
109
+ sequences.append(create_chain_data(seq_input, "protein"))
110
+ elif isinstance(seq_input, RNAInput):
111
+ sequences.append(create_chain_data(seq_input, "rna"))
112
+ elif isinstance(seq_input, DNAInput):
113
+ sequences.append(create_chain_data(seq_input, "dna"))
114
+ elif isinstance(seq_input, LigandInput):
115
+ sequences.append(
116
+ {
117
+ "smiles": seq_input.smiles,
118
+ "id": seq_input.id,
119
+ "ccd": seq_input.ccd,
120
+ "type": "ligand",
121
+ }
122
+ )
123
+ else:
124
+ raise ValueError(f"Unsupported sequence input type: {type(seq_input)}")
125
+
126
+ result: dict[str, Any] = {"sequences": sequences}
127
+
128
+ if all_atom_input.covalent_bonds is not None:
129
+ result["covalent_bonds"] = [
130
+ {
131
+ "chain_id1": bond.chain_id1,
132
+ "res_idx1": bond.res_idx1,
133
+ "atom_idx1": bond.atom_idx1,
134
+ "chain_id2": bond.chain_id2,
135
+ "res_idx2": bond.res_idx2,
136
+ "atom_idx2": bond.atom_idx2,
137
+ }
138
+ for bond in all_atom_input.covalent_bonds
139
+ ]
140
+
141
+ if all_atom_input.pocket is not None:
142
+ result["pocket"] = {
143
+ "binder_chain_id": all_atom_input.pocket.binder_chain_id,
144
+ "contacts": all_atom_input.pocket.contacts,
145
+ }
146
+
147
+ if all_atom_input.distogram_conditioning is not None:
148
+ result["distogram_conditioning"] = [
149
+ {"chain_id": disto.chain_id, "distogram": disto.distogram.tolist()}
150
+ for disto in all_atom_input.distogram_conditioning
151
+ ]
152
+
153
+ return result
154
+
155
+
156
+ def deserialize_structure_prediction_input(
157
+ data: dict[str, Any],
158
+ ) -> StructurePredictionInput:
159
+ """Inverse of :func:`serialize_structure_prediction_input`.
160
+
161
+ Reconstructs a :class:`StructurePredictionInput` from the JSON-safe dict
162
+ produced by ``serialize_structure_prediction_input``. Values round-trip;
163
+ ``DistogramConditioning.distogram`` dtype follows from JSON (``int64``
164
+ for integer entries, ``float64`` for floats) — cast back to the original
165
+ dtype if downstream code requires a specific one.
166
+ """
167
+
168
+ def _mods(chain: dict[str, Any]) -> list[Modification] | None:
169
+ raw = chain.get("modifications")
170
+ if not raw:
171
+ return None
172
+ return [Modification(position=m["position"], ccd=m["ccd"]) for m in raw]
173
+
174
+ def _msa(chain: dict[str, Any]) -> MSAInput:
175
+ if "msa" not in chain or chain["msa"] is None:
176
+ return None
177
+ msa_blk = chain["msa"]
178
+ if isinstance(msa_blk, str):
179
+ raise ValueError(f"Unexpected MSA string value: {msa_blk!r}")
180
+ return MSA.from_sequences(msa_blk["sequences"])
181
+
182
+ sequences: list[ProteinInput | RNAInput | DNAInput | LigandInput] = []
183
+ for chain in data["sequences"]:
184
+ t = chain["type"]
185
+ if t == "protein":
186
+ sequences.append(
187
+ ProteinInput(
188
+ id=chain["id"],
189
+ sequence=chain["sequence"],
190
+ modifications=_mods(chain),
191
+ msa=_msa(chain),
192
+ )
193
+ )
194
+ elif t == "rna":
195
+ sequences.append(
196
+ RNAInput(
197
+ id=chain["id"],
198
+ sequence=chain["sequence"],
199
+ modifications=_mods(chain),
200
+ )
201
+ )
202
+ elif t == "dna":
203
+ sequences.append(
204
+ DNAInput(
205
+ id=chain["id"],
206
+ sequence=chain["sequence"],
207
+ modifications=_mods(chain),
208
+ )
209
+ )
210
+ elif t == "ligand":
211
+ sequences.append(
212
+ LigandInput(
213
+ id=chain["id"], smiles=chain.get("smiles"), ccd=chain.get("ccd")
214
+ )
215
+ )
216
+ else:
217
+ raise ValueError(f"Unsupported sequence type: {t!r}")
218
+
219
+ pocket: PocketConditioning | None = None
220
+ if (pocket_blk := data.get("pocket")) is not None:
221
+ pocket = PocketConditioning(
222
+ binder_chain_id=pocket_blk["binder_chain_id"],
223
+ contacts=[tuple(c) for c in pocket_blk["contacts"]],
224
+ )
225
+
226
+ distogram_conditioning: list[DistogramConditioning] | None = None
227
+ if (disto_blk := data.get("distogram_conditioning")) is not None:
228
+ distogram_conditioning = [
229
+ DistogramConditioning(
230
+ chain_id=d["chain_id"], distogram=np.asarray(d["distogram"])
231
+ )
232
+ for d in disto_blk
233
+ ]
234
+
235
+ covalent_bonds: list[CovalentBond] | None = None
236
+ if (bonds_blk := data.get("covalent_bonds")) is not None:
237
+ covalent_bonds = [
238
+ CovalentBond(
239
+ chain_id1=b["chain_id1"],
240
+ res_idx1=b["res_idx1"],
241
+ atom_idx1=b["atom_idx1"],
242
+ chain_id2=b["chain_id2"],
243
+ res_idx2=b["res_idx2"],
244
+ atom_idx2=b["atom_idx2"],
245
+ )
246
+ for b in bonds_blk
247
+ ]
248
+
249
+ return StructurePredictionInput(
250
+ sequences=sequences,
251
+ pocket=pocket,
252
+ distogram_conditioning=distogram_conditioning,
253
+ covalent_bonds=covalent_bonds,
254
+ )
esmfold2_metrics.py CHANGED
@@ -1,373 +1,373 @@
1
- import numpy as np
2
- import torch
3
- import torch.nn.functional as F
4
- from einops import rearrange
5
- from torch import Tensor
6
- from torch.amp import autocast # type: ignore
7
-
8
- from . import esmfold2_residue_constants as residue_constants
9
- from .esmfold2_misc import binpack, unbinpack
10
- from .esmfold2_protein_structure import (
11
- compute_alignment_tensors,
12
- compute_gdt_ts_no_alignment,
13
- compute_rmsd_no_alignment,
14
- )
15
-
16
-
17
- def contact_precision(
18
- predictions: Tensor,
19
- targets: Tensor,
20
- src_lengths: Tensor | None = None,
21
- minsep: int = 6,
22
- maxsep: int | None = None,
23
- override_length: int | None = None, # for casp
24
- ):
25
- """Computes contact precisions.
26
-
27
- For protein contact prediction, precision is measured for the top (L/K) highest confidence predictions,
28
- with L being the length of the protein sequence and K generally being equal to 1 or 5.
29
-
30
- K = 5 measures the predictions of the very highest confidence contacts, while K = 1 is a more general measure
31
- over all relatively high confidence predictions.
32
-
33
- Since there are roughly ~L true contacts in a protein, this is a reasonable cutoff.
34
-
35
-
36
- Args:
37
- predictions (Tensor): Tensor of probabilities of size (B, L, L)
38
- targets (Tensor): Tensor of true contacts of size (B, L, L)
39
- src_lengths (Tensor, optional): Lengths of each sample in the batch, if using variable lengths.
40
- If not provided, inferred from the size of the predictions.
41
- minsep (int): Minimum separation distance to consider. We often want to measure contacts at a
42
- certain range. Typical ranges are short [6, 12), medium [12, 24), and long [24, inf).
43
- maxsep (int, optional): Used in conjunction with minsep to specify a contact range. If not provided uses
44
- assumes no maximum range
45
- override_length (int, optional): Used for casp evaluation where sometimes the "true" length is not
46
- the same as the length of the input. Kept for posterity, we probably don't need this argument.
47
- """
48
- if predictions.dim() == 2:
49
- predictions = predictions.unsqueeze(0)
50
- if targets.dim() == 2:
51
- targets = targets.unsqueeze(0)
52
-
53
- # Check sizes
54
- if predictions.size() != targets.size():
55
- raise ValueError(
56
- f"Size mismatch. Received predictions of size {predictions.size()}, "
57
- f"targets of size {targets.size()}"
58
- )
59
- device = predictions.device
60
-
61
- batch_size, seqlen, _ = predictions.size()
62
-
63
- # Step 1) Construct a mask of size [B, L, L] to mask invalid contacts
64
- seqlen_range = torch.arange(seqlen, device=device)
65
- sep = seqlen_range.unsqueeze(0) - seqlen_range.unsqueeze(1)
66
- sep = sep.unsqueeze(0)
67
- # Mask contacts that are closer than minsep
68
- valid_mask = sep >= minsep
69
- # Mask contacts where target is negative (padding or unknown)
70
- valid_mask = valid_mask & (targets >= 0) # negative targets are invalid
71
-
72
- # Mask contacts that are farther than maxsep, if provided
73
- if maxsep is not None:
74
- valid_mask &= sep < maxsep
75
-
76
- if src_lengths is not None:
77
- # If the lengths of the individual sequences are provided, mask positions
78
- # that are farther than the end of the sequence.
79
- valid = seqlen_range.unsqueeze(0) < src_lengths.unsqueeze(1)
80
- valid_mask &= valid.unsqueeze(1) & valid.unsqueeze(2)
81
- else:
82
- src_lengths = torch.full([batch_size], seqlen, device=device, dtype=torch.long)
83
-
84
- # Fill in the logit tensor with -inf for all invalid positions
85
- predictions = predictions.masked_fill(~valid_mask, float("-inf"))
86
-
87
- # Step 2) Select the top half of the prediction (should be symmetric)
88
- x_ind, y_ind = np.triu_indices(seqlen, minsep)
89
- predictions_upper = predictions[:, x_ind, y_ind]
90
- targets_upper = targets[:, x_ind, y_ind]
91
-
92
- # Step 3) Select the topk values in each batch where k = L (length of sequence)
93
- topk = seqlen if override_length is None else max(seqlen, override_length)
94
- # Indices are the indices into the predictions corresponding to the most confident predictions
95
- indices = predictions_upper.argsort(dim=-1, descending=True)[:, :topk]
96
- # topk_targets are the target values corresponding to the above indices
97
- topk_targets = targets_upper[torch.arange(batch_size).unsqueeze(1), indices]
98
- if topk_targets.size(1) < topk:
99
- # If there aren't enough targets, pad to the output.
100
- topk_targets = F.pad(topk_targets, [0, topk - topk_targets.size(1)])
101
-
102
- # Step 4) Sum the accuracy at of the top-i predictions for i in 1, L
103
- # topk_targets => 1/0 true vs. false contact, sorted by confidence of prediction
104
- # cmumulative sum => Number of correct answers for the top-i predictions.
105
- cumulative_dist = topk_targets.type_as(predictions).cumsum(-1)
106
-
107
- # Step 5) Find the gather indices. This should be P@(L / K) for varous values of K
108
- # The values will differ for each batch.
109
- gather_lengths = src_lengths.unsqueeze(1)
110
- if override_length is not None:
111
- gather_lengths = override_length * torch.ones_like(
112
- gather_lengths, device=device
113
- )
114
-
115
- # This gets you (0.1 * L, 0.2 * L, 0.3 * L, etc.)
116
- gather_indices = (
117
- (torch.arange(0.1, 1.1, 0.1, device=device).unsqueeze(0) * gather_lengths).type(
118
- torch.long
119
- )
120
- - 1
121
- ).clamp_min(0)
122
-
123
- # Step 6) Gather the results and divide by the number of guesses to get the precision.
124
- binned_cumulative_dist = cumulative_dist.gather(1, gather_indices)
125
- binned_precisions = binned_cumulative_dist / (gather_indices + 1).type_as(
126
- binned_cumulative_dist
127
- )
128
-
129
- # Select specific P@L/k. pl5 is index 1 b/c that corresponds to L * 0.2 in
130
- # gather_indices above
131
- pl5 = binned_precisions[:, 1]
132
- # pl2 = binned_precisions[:, 4]
133
- pl = binned_precisions[:, 9]
134
- # AUC is the integral wrt K of P@L/K for K in range(1, L)
135
- auc = binned_precisions.mean(-1)
136
-
137
- return {"AUC": auc, "P@L": pl, "P@L5": pl5}
138
-
139
-
140
- def compute_lddt(
141
- all_atom_pred_pos: torch.Tensor,
142
- all_atom_positions: torch.Tensor,
143
- all_atom_mask: torch.Tensor,
144
- pairwise_all_atom_mask: torch.Tensor | None = None,
145
- cutoff: float | torch.Tensor = 15.0,
146
- eps: float = 1e-10,
147
- per_residue: bool = True,
148
- sequence_id: torch.Tensor | None = None,
149
- ) -> torch.Tensor:
150
- """
151
- Computes LDDT for a protein. Tensor sizes below include some optional dimensions. Specifically:
152
- Nstates:
153
- all_atom_pred_pos can contain multiple states in the first dimension which corresponds to outputs from different layers of a model (e.g. each IPA block). The return size will be [Nstates x Batch size] if this is included.
154
- Natoms:
155
- LDDT can be computed for all atoms or some atoms. The second to last dimension should contain the *FLATTENED* representation of L x Natoms. If you want to calculate for atom37, e.g., this will be of size (L * 37). If you are only calculating CA LDDT, it will be of size L.
156
-
157
- Args:
158
- all_atom_pred_pos (Tensor[float], [(Nstates x) B x (L * Natoms x) 3]): Tensor of predicted positions
159
- all_atom_positions (Tensor[float], [B x (L * Natoms x) 3]): Tensor of true positions
160
- all_atom_mask (Tensor[float], [B x (L * Natoms)]): Tensor of masks, indicating whether an atom exists.
161
- pairwise_all_atom_mask (Tensor[float], [B x (L * Natoms x L * Natoms)], optional): Tensor of masks, indicating whether a pair of atoms should be considered in the LDDT calculation.
162
- cutoff (float): Max distance to score lddt over. This can either be a float, or a tensor of shape [B, L, L] to allow for per-residue cutoffs, e.g. if you want to use a different cutoff for nucleic acids.
163
- per_residue (bool): Whether to return per-residue or full-protein lddt.
164
- sequence_id (Tensor, optional): Sequence id tensor for binpacking. NOTE: only supported for lddt_ca calculations, not when Natoms is passed!
165
-
166
- Returns:
167
- LDDT Tensor:
168
- if per_residue:
169
- Tensor[float], [(Nstates x) B x (L * Natoms)]
170
- else:
171
- Tensor[float], [(Nstates x) B]
172
- """
173
- all_atom_mask = all_atom_mask[..., None] # add a dimension for broadcasting
174
- dmat_true = torch.sqrt(
175
- eps
176
- + torch.sum(
177
- (all_atom_positions[..., None, :] - all_atom_positions[..., None, :, :])
178
- ** 2,
179
- dim=-1,
180
- )
181
- )
182
-
183
- dmat_pred = torch.sqrt(
184
- eps
185
- + torch.sum(
186
- (all_atom_pred_pos[..., None, :] - all_atom_pred_pos[..., None, :, :]) ** 2,
187
- dim=-1,
188
- )
189
- )
190
- mask = all_atom_mask * rearrange(all_atom_mask, "... a b -> ... b a")
191
- if pairwise_all_atom_mask is not None:
192
- mask = mask * pairwise_all_atom_mask
193
-
194
- if sequence_id is not None:
195
- # TODO: This will work for lddt_ca, but not for regular lddt
196
- # Problem is that regular lddt has natoms * nres scores, so would need to repeat this mask by natoms
197
- # Leaving for now because it won't fail silently so should be ook.
198
- seqid_mask = sequence_id[..., None] == sequence_id[..., None, :]
199
- mask = mask * seqid_mask.type_as(mask)
200
-
201
- return compute_lddt_from_dmat(
202
- dmat_pred, dmat_true, mask, cutoff=cutoff, eps=eps, per_residue=per_residue
203
- )
204
-
205
-
206
- def compute_lddt_from_dmat(
207
- dmat_pred: torch.Tensor,
208
- dmat_true: torch.Tensor,
209
- pairwise_mask: torch.Tensor,
210
- cutoff: float | torch.Tensor = 15.0,
211
- eps: float = 1e-10,
212
- per_residue: bool = True,
213
- ):
214
- """
215
- Compute LDDT from pre-computed distance matrices.
216
- This is useful when you want to compute LDDT with multiple different masks or cutoffs, e.g. for different molecule types (protein, nucleic acid, etc.).
217
-
218
- Args:
219
- dmat_pred (Tensor[float], [B x L x L]): Predicted distance matrix
220
- dmat_true (Tensor[float], [B x L x L]): True distance matrix
221
- pairwise_mask (Tensor[float], [B x L x L]): Pairwise mask indicating which pairs of atoms to consider
222
- cutoff (float): Max distance to score lddt over. This can either be a float, or a tensor of shape [B, L, L] to allow for per-residue cutoffs, e.g. if you want to use a different cutoff for nucleic acids.
223
- per_residue (bool): Whether to return per-residue or full-protein lddt.
224
-
225
- Returns:
226
- LDDT Tensor:
227
- if per_residue:
228
- Tensor[float], [B x L]
229
- else:
230
- Tensor[float], [B]
231
- """
232
- n = dmat_true.size(-1)
233
- dists_to_score = (
234
- (dmat_true < cutoff)
235
- * pairwise_mask
236
- * (1.0 - torch.eye(n, device=dmat_true.device))
237
- )
238
-
239
- dist_l1 = torch.abs(dmat_true - dmat_pred)
240
- score = (
241
- (dist_l1 < 0.5).type(dist_l1.dtype)
242
- + (dist_l1 < 1.0).type(dist_l1.dtype)
243
- + (dist_l1 < 2.0).type(dist_l1.dtype)
244
- + (dist_l1 < 4.0).type(dist_l1.dtype)
245
- )
246
- score = score * 0.25
247
-
248
- dims = (-1,) if per_residue else (-2, -1)
249
- norm = 1.0 / (eps + torch.sum(dists_to_score, dim=dims))
250
- score = norm * (eps + torch.sum(dists_to_score * score, dim=dims))
251
- return score
252
-
253
-
254
- def compute_lddt_ca(
255
- all_atom_pred_pos: torch.Tensor,
256
- all_atom_positions: torch.Tensor,
257
- all_atom_mask: torch.Tensor,
258
- cutoff: float = 15.0,
259
- eps: float = 1e-10,
260
- per_residue: bool = True,
261
- sequence_id: torch.Tensor | None = None,
262
- ) -> torch.Tensor:
263
- ca_pos = residue_constants.atom_order["CA"]
264
- if all_atom_pred_pos.dim() != 3:
265
- all_atom_pred_pos = all_atom_pred_pos[..., ca_pos, :]
266
- all_atom_positions = all_atom_positions[..., ca_pos, :]
267
- all_atom_mask = all_atom_mask[..., ca_pos]
268
-
269
- return compute_lddt(
270
- all_atom_pred_pos,
271
- all_atom_positions,
272
- all_atom_mask,
273
- cutoff=cutoff,
274
- eps=eps,
275
- per_residue=per_residue,
276
- sequence_id=sequence_id,
277
- )
278
-
279
-
280
- # NOTE(roshan): no_grad required for stack_variable_length_tensors apparently... let's revisit if we want to backprop
281
- @torch.no_grad()
282
- @autocast("cuda", enabled=False)
283
- def compute_rmsd(
284
- mobile: torch.Tensor,
285
- target: torch.Tensor,
286
- atom_exists_mask: torch.Tensor | None = None,
287
- sequence_id: torch.Tensor | None = None,
288
- reduction: str = "batch",
289
- ):
290
- """
291
- Compute RMSD between two batches of structures with support for masking invalid atoms using PyTorch.
292
-
293
- Args:
294
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
295
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
296
- - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
297
- - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
298
- - reduction (str): One of "batch", "per_sample", "per_residue".
299
-
300
- Returns:
301
- If reduction == "batch":
302
- (torch.Tensor): 0-dim, Average Root Mean Square Deviation between the structures for each batch
303
- If reduction == "per_sample":
304
- (torch.Tensor): (B,)-dim, Root Mean Square Deviation between the structures for each batch
305
- If reduction == "per_residue":
306
- (torch.Tensor): (B, N)-dim, Root Mean Square Deviation between the structures for residue in the batch
307
- """
308
-
309
- (centered_mobile, _, centered_target, _, rotation_matrix, num_valid_atoms) = (
310
- compute_alignment_tensors(
311
- mobile=mobile,
312
- target=target,
313
- atom_exists_mask=atom_exists_mask,
314
- sequence_id=sequence_id,
315
- )
316
- )
317
-
318
- # Apply transformation to centered structure
319
- rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
320
-
321
- # Compute rmsd for centered structures
322
- rmsd = compute_rmsd_no_alignment(
323
- rotated_mobile, centered_target, num_valid_atoms, reduction=reduction
324
- )
325
- if reduction == "per_residue" and sequence_id is not None:
326
- rmsd = binpack(rmsd, sequence_id, pad_value=0)
327
- return rmsd
328
-
329
-
330
- def compute_gdt_ts(
331
- mobile: torch.Tensor,
332
- target: torch.Tensor,
333
- atom_exists_mask: torch.Tensor | None = None,
334
- sequence_id: torch.Tensor | None = None,
335
- reduction: str = "per_sample",
336
- ):
337
- """
338
- Compute GDT_TS between two batches of structures with support for masking invalid atoms using PyTorch.
339
-
340
- Args:
341
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
342
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
343
- - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
344
- - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
345
- - reduction (str): One of "batch", "per_sample", "per_residue".
346
-
347
- Returns:
348
- If reduction == "batch":
349
- (torch.Tensor): 0-dim, GDT_TS between the structures for each batch
350
- If reduction == "per_sample":
351
- (torch.Tensor): (B,)-dim, GDT_TS between the structures for each sample in the batch
352
- """
353
- if atom_exists_mask is None:
354
- atom_exists_mask = torch.isfinite(target).all(dim=-1)
355
- (centered_mobile, _, centered_target, _, rotation_matrix, _) = (
356
- compute_alignment_tensors(
357
- mobile=mobile,
358
- target=target,
359
- atom_exists_mask=atom_exists_mask,
360
- sequence_id=sequence_id,
361
- )
362
- )
363
-
364
- # Apply transformation to centered structure
365
- rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
366
-
367
- # the coordinate tensors returned by `compute_alignment_tensors` are unbinpacked and contain zeros for invalid positions
368
- # so `compute_gdt_ts_no_alignment` requires `atom_exists_mask` to be passed and be unbinpacked
369
- if sequence_id is not None:
370
- atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=False)
371
- return compute_gdt_ts_no_alignment(
372
- rotated_mobile, centered_target, atom_exists_mask, reduction
373
- )
 
1
+ import numpy as np
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from einops import rearrange
5
+ from torch import Tensor
6
+ from torch.amp import autocast # type: ignore
7
+
8
+ from . import esmfold2_residue_constants as residue_constants
9
+ from .esmfold2_misc import binpack, unbinpack
10
+ from .esmfold2_protein_structure import (
11
+ compute_alignment_tensors,
12
+ compute_gdt_ts_no_alignment,
13
+ compute_rmsd_no_alignment,
14
+ )
15
+
16
+
17
+ def contact_precision(
18
+ predictions: Tensor,
19
+ targets: Tensor,
20
+ src_lengths: Tensor | None = None,
21
+ minsep: int = 6,
22
+ maxsep: int | None = None,
23
+ override_length: int | None = None, # for casp
24
+ ):
25
+ """Computes contact precisions.
26
+
27
+ For protein contact prediction, precision is measured for the top (L/K) highest confidence predictions,
28
+ with L being the length of the protein sequence and K generally being equal to 1 or 5.
29
+
30
+ K = 5 measures the predictions of the very highest confidence contacts, while K = 1 is a more general measure
31
+ over all relatively high confidence predictions.
32
+
33
+ Since there are roughly ~L true contacts in a protein, this is a reasonable cutoff.
34
+
35
+
36
+ Args:
37
+ predictions (Tensor): Tensor of probabilities of size (B, L, L)
38
+ targets (Tensor): Tensor of true contacts of size (B, L, L)
39
+ src_lengths (Tensor, optional): Lengths of each sample in the batch, if using variable lengths.
40
+ If not provided, inferred from the size of the predictions.
41
+ minsep (int): Minimum separation distance to consider. We often want to measure contacts at a
42
+ certain range. Typical ranges are short [6, 12), medium [12, 24), and long [24, inf).
43
+ maxsep (int, optional): Used in conjunction with minsep to specify a contact range. If not provided uses
44
+ assumes no maximum range
45
+ override_length (int, optional): Used for casp evaluation where sometimes the "true" length is not
46
+ the same as the length of the input. Kept for posterity, we probably don't need this argument.
47
+ """
48
+ if predictions.dim() == 2:
49
+ predictions = predictions.unsqueeze(0)
50
+ if targets.dim() == 2:
51
+ targets = targets.unsqueeze(0)
52
+
53
+ # Check sizes
54
+ if predictions.size() != targets.size():
55
+ raise ValueError(
56
+ f"Size mismatch. Received predictions of size {predictions.size()}, "
57
+ f"targets of size {targets.size()}"
58
+ )
59
+ device = predictions.device
60
+
61
+ batch_size, seqlen, _ = predictions.size()
62
+
63
+ # Step 1) Construct a mask of size [B, L, L] to mask invalid contacts
64
+ seqlen_range = torch.arange(seqlen, device=device)
65
+ sep = seqlen_range.unsqueeze(0) - seqlen_range.unsqueeze(1)
66
+ sep = sep.unsqueeze(0)
67
+ # Mask contacts that are closer than minsep
68
+ valid_mask = sep >= minsep
69
+ # Mask contacts where target is negative (padding or unknown)
70
+ valid_mask = valid_mask & (targets >= 0) # negative targets are invalid
71
+
72
+ # Mask contacts that are farther than maxsep, if provided
73
+ if maxsep is not None:
74
+ valid_mask &= sep < maxsep
75
+
76
+ if src_lengths is not None:
77
+ # If the lengths of the individual sequences are provided, mask positions
78
+ # that are farther than the end of the sequence.
79
+ valid = seqlen_range.unsqueeze(0) < src_lengths.unsqueeze(1)
80
+ valid_mask &= valid.unsqueeze(1) & valid.unsqueeze(2)
81
+ else:
82
+ src_lengths = torch.full([batch_size], seqlen, device=device, dtype=torch.long)
83
+
84
+ # Fill in the logit tensor with -inf for all invalid positions
85
+ predictions = predictions.masked_fill(~valid_mask, float("-inf"))
86
+
87
+ # Step 2) Select the top half of the prediction (should be symmetric)
88
+ x_ind, y_ind = np.triu_indices(seqlen, minsep)
89
+ predictions_upper = predictions[:, x_ind, y_ind]
90
+ targets_upper = targets[:, x_ind, y_ind]
91
+
92
+ # Step 3) Select the topk values in each batch where k = L (length of sequence)
93
+ topk = seqlen if override_length is None else max(seqlen, override_length)
94
+ # Indices are the indices into the predictions corresponding to the most confident predictions
95
+ indices = predictions_upper.argsort(dim=-1, descending=True)[:, :topk]
96
+ # topk_targets are the target values corresponding to the above indices
97
+ topk_targets = targets_upper[torch.arange(batch_size).unsqueeze(1), indices]
98
+ if topk_targets.size(1) < topk:
99
+ # If there aren't enough targets, pad to the output.
100
+ topk_targets = F.pad(topk_targets, [0, topk - topk_targets.size(1)])
101
+
102
+ # Step 4) Sum the accuracy at of the top-i predictions for i in 1, L
103
+ # topk_targets => 1/0 true vs. false contact, sorted by confidence of prediction
104
+ # cmumulative sum => Number of correct answers for the top-i predictions.
105
+ cumulative_dist = topk_targets.type_as(predictions).cumsum(-1)
106
+
107
+ # Step 5) Find the gather indices. This should be P@(L / K) for varous values of K
108
+ # The values will differ for each batch.
109
+ gather_lengths = src_lengths.unsqueeze(1)
110
+ if override_length is not None:
111
+ gather_lengths = override_length * torch.ones_like(
112
+ gather_lengths, device=device
113
+ )
114
+
115
+ # This gets you (0.1 * L, 0.2 * L, 0.3 * L, etc.)
116
+ gather_indices = (
117
+ (torch.arange(0.1, 1.1, 0.1, device=device).unsqueeze(0) * gather_lengths).type(
118
+ torch.long
119
+ )
120
+ - 1
121
+ ).clamp_min(0)
122
+
123
+ # Step 6) Gather the results and divide by the number of guesses to get the precision.
124
+ binned_cumulative_dist = cumulative_dist.gather(1, gather_indices)
125
+ binned_precisions = binned_cumulative_dist / (gather_indices + 1).type_as(
126
+ binned_cumulative_dist
127
+ )
128
+
129
+ # Select specific P@L/k. pl5 is index 1 b/c that corresponds to L * 0.2 in
130
+ # gather_indices above
131
+ pl5 = binned_precisions[:, 1]
132
+ # pl2 = binned_precisions[:, 4]
133
+ pl = binned_precisions[:, 9]
134
+ # AUC is the integral wrt K of P@L/K for K in range(1, L)
135
+ auc = binned_precisions.mean(-1)
136
+
137
+ return {"AUC": auc, "P@L": pl, "P@L5": pl5}
138
+
139
+
140
+ def compute_lddt(
141
+ all_atom_pred_pos: torch.Tensor,
142
+ all_atom_positions: torch.Tensor,
143
+ all_atom_mask: torch.Tensor,
144
+ pairwise_all_atom_mask: torch.Tensor | None = None,
145
+ cutoff: float | torch.Tensor = 15.0,
146
+ eps: float = 1e-10,
147
+ per_residue: bool = True,
148
+ sequence_id: torch.Tensor | None = None,
149
+ ) -> torch.Tensor:
150
+ """
151
+ Computes LDDT for a protein. Tensor sizes below include some optional dimensions. Specifically:
152
+ Nstates:
153
+ all_atom_pred_pos can contain multiple states in the first dimension which corresponds to outputs from different layers of a model (e.g. each IPA block). The return size will be [Nstates x Batch size] if this is included.
154
+ Natoms:
155
+ LDDT can be computed for all atoms or some atoms. The second to last dimension should contain the *FLATTENED* representation of L x Natoms. If you want to calculate for atom37, e.g., this will be of size (L * 37). If you are only calculating CA LDDT, it will be of size L.
156
+
157
+ Args:
158
+ all_atom_pred_pos (Tensor[float], [(Nstates x) B x (L * Natoms x) 3]): Tensor of predicted positions
159
+ all_atom_positions (Tensor[float], [B x (L * Natoms x) 3]): Tensor of true positions
160
+ all_atom_mask (Tensor[float], [B x (L * Natoms)]): Tensor of masks, indicating whether an atom exists.
161
+ pairwise_all_atom_mask (Tensor[float], [B x (L * Natoms x L * Natoms)], optional): Tensor of masks, indicating whether a pair of atoms should be considered in the LDDT calculation.
162
+ cutoff (float): Max distance to score lddt over. This can either be a float, or a tensor of shape [B, L, L] to allow for per-residue cutoffs, e.g. if you want to use a different cutoff for nucleic acids.
163
+ per_residue (bool): Whether to return per-residue or full-protein lddt.
164
+ sequence_id (Tensor, optional): Sequence id tensor for binpacking. NOTE: only supported for lddt_ca calculations, not when Natoms is passed!
165
+
166
+ Returns:
167
+ LDDT Tensor:
168
+ if per_residue:
169
+ Tensor[float], [(Nstates x) B x (L * Natoms)]
170
+ else:
171
+ Tensor[float], [(Nstates x) B]
172
+ """
173
+ all_atom_mask = all_atom_mask[..., None] # add a dimension for broadcasting
174
+ dmat_true = torch.sqrt(
175
+ eps
176
+ + torch.sum(
177
+ (all_atom_positions[..., None, :] - all_atom_positions[..., None, :, :])
178
+ ** 2,
179
+ dim=-1,
180
+ )
181
+ )
182
+
183
+ dmat_pred = torch.sqrt(
184
+ eps
185
+ + torch.sum(
186
+ (all_atom_pred_pos[..., None, :] - all_atom_pred_pos[..., None, :, :]) ** 2,
187
+ dim=-1,
188
+ )
189
+ )
190
+ mask = all_atom_mask * rearrange(all_atom_mask, "... a b -> ... b a")
191
+ if pairwise_all_atom_mask is not None:
192
+ mask = mask * pairwise_all_atom_mask
193
+
194
+ if sequence_id is not None:
195
+ # TODO: This will work for lddt_ca, but not for regular lddt
196
+ # Problem is that regular lddt has natoms * nres scores, so would need to repeat this mask by natoms
197
+ # Leaving for now because it won't fail silently so should be ook.
198
+ seqid_mask = sequence_id[..., None] == sequence_id[..., None, :]
199
+ mask = mask * seqid_mask.type_as(mask)
200
+
201
+ return compute_lddt_from_dmat(
202
+ dmat_pred, dmat_true, mask, cutoff=cutoff, eps=eps, per_residue=per_residue
203
+ )
204
+
205
+
206
+ def compute_lddt_from_dmat(
207
+ dmat_pred: torch.Tensor,
208
+ dmat_true: torch.Tensor,
209
+ pairwise_mask: torch.Tensor,
210
+ cutoff: float | torch.Tensor = 15.0,
211
+ eps: float = 1e-10,
212
+ per_residue: bool = True,
213
+ ):
214
+ """
215
+ Compute LDDT from pre-computed distance matrices.
216
+ This is useful when you want to compute LDDT with multiple different masks or cutoffs, e.g. for different molecule types (protein, nucleic acid, etc.).
217
+
218
+ Args:
219
+ dmat_pred (Tensor[float], [B x L x L]): Predicted distance matrix
220
+ dmat_true (Tensor[float], [B x L x L]): True distance matrix
221
+ pairwise_mask (Tensor[float], [B x L x L]): Pairwise mask indicating which pairs of atoms to consider
222
+ cutoff (float): Max distance to score lddt over. This can either be a float, or a tensor of shape [B, L, L] to allow for per-residue cutoffs, e.g. if you want to use a different cutoff for nucleic acids.
223
+ per_residue (bool): Whether to return per-residue or full-protein lddt.
224
+
225
+ Returns:
226
+ LDDT Tensor:
227
+ if per_residue:
228
+ Tensor[float], [B x L]
229
+ else:
230
+ Tensor[float], [B]
231
+ """
232
+ n = dmat_true.size(-1)
233
+ dists_to_score = (
234
+ (dmat_true < cutoff)
235
+ * pairwise_mask
236
+ * (1.0 - torch.eye(n, device=dmat_true.device))
237
+ )
238
+
239
+ dist_l1 = torch.abs(dmat_true - dmat_pred)
240
+ score = (
241
+ (dist_l1 < 0.5).type(dist_l1.dtype)
242
+ + (dist_l1 < 1.0).type(dist_l1.dtype)
243
+ + (dist_l1 < 2.0).type(dist_l1.dtype)
244
+ + (dist_l1 < 4.0).type(dist_l1.dtype)
245
+ )
246
+ score = score * 0.25
247
+
248
+ dims = (-1,) if per_residue else (-2, -1)
249
+ norm = 1.0 / (eps + torch.sum(dists_to_score, dim=dims))
250
+ score = norm * (eps + torch.sum(dists_to_score * score, dim=dims))
251
+ return score
252
+
253
+
254
+ def compute_lddt_ca(
255
+ all_atom_pred_pos: torch.Tensor,
256
+ all_atom_positions: torch.Tensor,
257
+ all_atom_mask: torch.Tensor,
258
+ cutoff: float = 15.0,
259
+ eps: float = 1e-10,
260
+ per_residue: bool = True,
261
+ sequence_id: torch.Tensor | None = None,
262
+ ) -> torch.Tensor:
263
+ ca_pos = residue_constants.atom_order["CA"]
264
+ if all_atom_pred_pos.dim() != 3:
265
+ all_atom_pred_pos = all_atom_pred_pos[..., ca_pos, :]
266
+ all_atom_positions = all_atom_positions[..., ca_pos, :]
267
+ all_atom_mask = all_atom_mask[..., ca_pos]
268
+
269
+ return compute_lddt(
270
+ all_atom_pred_pos,
271
+ all_atom_positions,
272
+ all_atom_mask,
273
+ cutoff=cutoff,
274
+ eps=eps,
275
+ per_residue=per_residue,
276
+ sequence_id=sequence_id,
277
+ )
278
+
279
+
280
+ # NOTE(roshan): no_grad required for stack_variable_length_tensors apparently... let's revisit if we want to backprop
281
+ @torch.no_grad()
282
+ @autocast("cuda", enabled=False)
283
+ def compute_rmsd(
284
+ mobile: torch.Tensor,
285
+ target: torch.Tensor,
286
+ atom_exists_mask: torch.Tensor | None = None,
287
+ sequence_id: torch.Tensor | None = None,
288
+ reduction: str = "batch",
289
+ ):
290
+ """
291
+ Compute RMSD between two batches of structures with support for masking invalid atoms using PyTorch.
292
+
293
+ Args:
294
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
295
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
296
+ - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
297
+ - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
298
+ - reduction (str): One of "batch", "per_sample", "per_residue".
299
+
300
+ Returns:
301
+ If reduction == "batch":
302
+ (torch.Tensor): 0-dim, Average Root Mean Square Deviation between the structures for each batch
303
+ If reduction == "per_sample":
304
+ (torch.Tensor): (B,)-dim, Root Mean Square Deviation between the structures for each batch
305
+ If reduction == "per_residue":
306
+ (torch.Tensor): (B, N)-dim, Root Mean Square Deviation between the structures for residue in the batch
307
+ """
308
+
309
+ (centered_mobile, _, centered_target, _, rotation_matrix, num_valid_atoms) = (
310
+ compute_alignment_tensors(
311
+ mobile=mobile,
312
+ target=target,
313
+ atom_exists_mask=atom_exists_mask,
314
+ sequence_id=sequence_id,
315
+ )
316
+ )
317
+
318
+ # Apply transformation to centered structure
319
+ rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
320
+
321
+ # Compute rmsd for centered structures
322
+ rmsd = compute_rmsd_no_alignment(
323
+ rotated_mobile, centered_target, num_valid_atoms, reduction=reduction
324
+ )
325
+ if reduction == "per_residue" and sequence_id is not None:
326
+ rmsd = binpack(rmsd, sequence_id, pad_value=0)
327
+ return rmsd
328
+
329
+
330
+ def compute_gdt_ts(
331
+ mobile: torch.Tensor,
332
+ target: torch.Tensor,
333
+ atom_exists_mask: torch.Tensor | None = None,
334
+ sequence_id: torch.Tensor | None = None,
335
+ reduction: str = "per_sample",
336
+ ):
337
+ """
338
+ Compute GDT_TS between two batches of structures with support for masking invalid atoms using PyTorch.
339
+
340
+ Args:
341
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
342
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
343
+ - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
344
+ - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
345
+ - reduction (str): One of "batch", "per_sample", "per_residue".
346
+
347
+ Returns:
348
+ If reduction == "batch":
349
+ (torch.Tensor): 0-dim, GDT_TS between the structures for each batch
350
+ If reduction == "per_sample":
351
+ (torch.Tensor): (B,)-dim, GDT_TS between the structures for each sample in the batch
352
+ """
353
+ if atom_exists_mask is None:
354
+ atom_exists_mask = torch.isfinite(target).all(dim=-1)
355
+ (centered_mobile, _, centered_target, _, rotation_matrix, _) = (
356
+ compute_alignment_tensors(
357
+ mobile=mobile,
358
+ target=target,
359
+ atom_exists_mask=atom_exists_mask,
360
+ sequence_id=sequence_id,
361
+ )
362
+ )
363
+
364
+ # Apply transformation to centered structure
365
+ rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
366
+
367
+ # the coordinate tensors returned by `compute_alignment_tensors` are unbinpacked and contain zeros for invalid positions
368
+ # so `compute_gdt_ts_no_alignment` requires `atom_exists_mask` to be passed and be unbinpacked
369
+ if sequence_id is not None:
370
+ atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=False)
371
+ return compute_gdt_ts_no_alignment(
372
+ rotated_mobile, centered_target, atom_exists_mask, reduction
373
+ )
esmfold2_misc.py CHANGED
@@ -1,504 +1,504 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- from collections import defaultdict
5
- from contextlib import nullcontext
6
- from dataclasses import is_dataclass
7
- from io import BytesIO
8
- from typing import (
9
- Any,
10
- ContextManager,
11
- Generator,
12
- Iterable,
13
- Protocol,
14
- Sequence,
15
- TypeVar,
16
- runtime_checkable,
17
- )
18
- from warnings import warn
19
-
20
- import huggingface_hub
21
- import numpy as np
22
- import torch
23
- import zstd
24
-
25
- from .esmfold2_constants_esm3 import CHAIN_BREAK_STR
26
- from .esmfold2_utils_types import FunctionAnnotation
27
-
28
- MAX_SUPPORTED_DISTANCE = 1e6
29
-
30
-
31
- TSequence = TypeVar("TSequence", bound=Sequence)
32
-
33
-
34
- @runtime_checkable
35
- class Concatable(Protocol):
36
- @classmethod
37
- def concat(cls, objs: list[Concatable]) -> Concatable: ...
38
-
39
-
40
- def slice_python_object_as_numpy(
41
- obj: TSequence, idx: int | list[int] | slice | np.ndarray
42
- ) -> TSequence:
43
- """
44
- Slice a python object (like a list, string, or tuple) as if it was a numpy object.
45
-
46
- Example:
47
- >>> obj = "ABCDE"
48
- >>> slice_python_object_as_numpy(obj, [1, 3, 4])
49
- "BDE"
50
-
51
- >>> obj = [1, 2, 3, 4, 5]
52
- >>> slice_python_object_as_numpy(obj, np.arange(5) < 3)
53
- [1, 2, 3]
54
- """
55
- if np.isscalar(idx):
56
- idx = [int(idx)] # type: ignore
57
-
58
- if isinstance(idx, np.ndarray) and idx.dtype == bool:
59
- sliced_obj = [obj[i] for i in np.where(idx)[0]]
60
- elif isinstance(idx, slice):
61
- sliced_obj = obj[idx]
62
- else:
63
- sliced_obj = [obj[i] for i in idx] # type: ignore
64
-
65
- match obj, sliced_obj:
66
- case str(), list():
67
- sliced_obj = "".join(sliced_obj)
68
- case _:
69
- sliced_obj = obj.__class__(sliced_obj) # type: ignore
70
-
71
- return sliced_obj # type: ignore
72
-
73
-
74
- def slice_any_object(
75
- obj: TSequence, idx: int | list[int] | slice | np.ndarray
76
- ) -> TSequence:
77
- """
78
- Slice a arbitrary object (like a list, string, or tuple) as if it was a numpy object. Similar to `slice_python_object_as_numpy`, but detects if it's a numpy array or Tensor and uses the existing slice method if so.
79
-
80
- If the object is a dataclass, it will simply apply the index to the object, under the assumption that the object has correcty implemented numpy indexing.
81
-
82
- Example:
83
- >>> obj = "ABCDE"
84
- >>> slice_any_object(obj, [1, 3, 4])
85
- "BDE"
86
-
87
- >>> obj = np.array([1, 2, 3, 4, 5])
88
- >>> slice_any_object(obj, np.arange(5) < 3)
89
- np.array([1, 2, 3])
90
-
91
- >>> obj = ProteinChain.from_rcsb("1a3a", "A")
92
- >>> slice_any_object(obj, np.arange(len(obj)) < 10)
93
- # ProteinChain w/ length 10
94
-
95
- """
96
- if isinstance(obj, (np.ndarray, torch.Tensor)):
97
- return obj[idx] # type: ignore
98
- elif is_dataclass(obj):
99
- # if passing a dataclass, assume it implements a custom slice
100
- return obj[idx] # type: ignore
101
- else:
102
- return slice_python_object_as_numpy(obj, idx)
103
-
104
-
105
- def rbf(values, v_min, v_max, n_bins=16):
106
- """
107
- Returns RBF encodings in a new dimension at the end.
108
- """
109
- rbf_centers = torch.linspace(
110
- v_min, v_max, n_bins, device=values.device, dtype=values.dtype
111
- )
112
- rbf_centers = rbf_centers.view([1] * len(values.shape) + [-1])
113
- rbf_std = (v_max - v_min) / n_bins
114
- z = (values.unsqueeze(-1) - rbf_centers) / rbf_std
115
- return torch.exp(-(z**2))
116
-
117
-
118
- def batched_gather(data, inds, dim=0, no_batch_dims=0):
119
- ranges = []
120
- for i, s in enumerate(data.shape[:no_batch_dims]):
121
- r = torch.arange(s)
122
- r = r.view(*(*((1,) * i), -1, *((1,) * (len(inds.shape) - i - 1))))
123
- ranges.append(r)
124
-
125
- remaining_dims = [slice(None) for _ in range(len(data.shape) - no_batch_dims)]
126
- remaining_dims[dim - no_batch_dims if dim >= 0 else dim] = inds
127
- ranges.extend(remaining_dims)
128
- return data[ranges]
129
-
130
-
131
- def node_gather(s: torch.Tensor, edges: torch.Tensor) -> torch.Tensor:
132
- return batched_gather(s.unsqueeze(-3), edges, -2, no_batch_dims=len(s.shape) - 1)
133
-
134
-
135
- def knn_graph(
136
- coords: torch.Tensor,
137
- coord_mask: torch.Tensor,
138
- padding_mask: torch.Tensor,
139
- sequence_id: torch.Tensor,
140
- *,
141
- no_knn: int,
142
- ):
143
- L = coords.shape[-2]
144
- num_by_dist = min(no_knn, L)
145
- device = coords.device
146
-
147
- coords = coords.nan_to_num()
148
- coord_mask = ~(coord_mask[..., None, :] & coord_mask[..., :, None])
149
- padding_pairwise_mask = padding_mask[..., None, :] | padding_mask[..., :, None]
150
- if sequence_id is not None:
151
- padding_pairwise_mask |= torch.unsqueeze(sequence_id, 1) != torch.unsqueeze(
152
- sequence_id, 2
153
- )
154
- dists = (coords.unsqueeze(-2) - coords.unsqueeze(-3)).norm(dim=-1)
155
- arange = torch.arange(L, device=device)
156
- seq_dists = (arange.unsqueeze(-1) - arange.unsqueeze(-2)).abs()
157
- # We only support up to a certain distance, above that, we use sequence distance
158
- # instead. This is so that when a large portion of the structure is masked out,
159
- # the edges are built according to sequence distance.
160
- max_dist = MAX_SUPPORTED_DISTANCE
161
- if not (dists[~coord_mask] < max_dist).all():
162
- raise ValueError(
163
- f"Coordinate pairwise distances exceed max supported distance ({max_dist}). "
164
- )
165
- struct_then_seq_dist = (
166
- seq_dists.to(dists.dtype)
167
- .mul(1e2)
168
- .add(max_dist)
169
- .where(coord_mask, dists)
170
- .masked_fill(padding_pairwise_mask, torch.inf)
171
- )
172
- dists, edges = struct_then_seq_dist.sort(dim=-1, descending=False)
173
- # This is a L x L tensor, where we index by rows first,
174
- # and columns are the edges we should pick.
175
- chosen_edges = edges[..., :num_by_dist]
176
- chosen_mask = dists[..., :num_by_dist].isfinite()
177
- return chosen_edges, chosen_mask
178
-
179
-
180
- def stack_variable_length_tensors(
181
- sequences: Sequence[torch.Tensor],
182
- constant_value: int | float = 0,
183
- dtype: torch.dtype | None = None,
184
- ) -> torch.Tensor:
185
- """Automatically stack tensors together, padding variable lengths with the
186
- value in constant_value. Handles an arbitrary number of dimensions.
187
-
188
- Examples:
189
- >>> tensor1, tensor2 = torch.ones([2]), torch.ones([5])
190
- >>> stack_variable_length_tensors(tensor1, tensor2)
191
- tensor of shape [2, 5]. First row is [1, 1, 0, 0, 0]. Second row is all ones.
192
-
193
- >>> tensor1, tensor2 = torch.ones([2, 4]), torch.ones([5, 3])
194
- >>> stack_variable_length_tensors(tensor1, tensor2)
195
- tensor of shape [2, 5, 4]
196
- """
197
- batch_size = len(sequences)
198
- shape = [batch_size] + np.max([seq.shape for seq in sequences], 0).tolist()
199
-
200
- if dtype is None:
201
- dtype = sequences[0].dtype
202
- device = sequences[0].device
203
-
204
- array = torch.full(shape, constant_value, dtype=dtype, device=device)
205
- for arr, seq in zip(array, sequences):
206
- arrslice = tuple(slice(dim) for dim in seq.shape)
207
- arr[arrslice] = seq
208
-
209
- return array
210
-
211
-
212
- def binpack(
213
- tensor: torch.Tensor, sequence_id: torch.Tensor | None, pad_value: int | float
214
- ):
215
- """
216
- Args:
217
- tensor (Tensor): [B, L, ...]
218
-
219
- Returns:
220
- Tensor: [B_binpacked, L_binpacked, ...]
221
- """
222
- if sequence_id is None:
223
- return tensor
224
-
225
- num_sequences = sequence_id.max(dim=-1).values + 1
226
-
227
- dims = sequence_id.shape + tensor.shape[2:]
228
- output_tensor = torch.full(
229
- dims, fill_value=pad_value, dtype=tensor.dtype, device=tensor.device
230
- )
231
-
232
- idx = 0
233
- for batch_idx, (batch_seqid, batch_num_sequences) in enumerate(
234
- zip(sequence_id, num_sequences)
235
- ):
236
- for seqid in range(batch_num_sequences):
237
- mask = batch_seqid == seqid
238
- output_tensor[batch_idx, mask] = tensor[idx, : mask.sum()]
239
- idx += 1
240
- return output_tensor
241
-
242
-
243
- def unbinpack(
244
- tensor: torch.Tensor, sequence_id: torch.Tensor | None, pad_value: int | float
245
- ):
246
- """
247
- Args:
248
- tensor (Tensor): [B, L, ...]
249
-
250
- Returns:
251
- Tensor: [B_unbinpacked, L_unbinpack, ...]
252
- """
253
- if sequence_id is None:
254
- return tensor
255
-
256
- unpacked_tensors = []
257
- num_sequences = sequence_id.max(dim=-1).values + 1
258
- for batch_idx, (batch_seqid, batch_num_sequences) in enumerate(
259
- zip(sequence_id, num_sequences)
260
- ):
261
- for seqid in range(batch_num_sequences):
262
- mask = batch_seqid == seqid
263
- unpacked = tensor[batch_idx, mask]
264
- unpacked_tensors.append(unpacked)
265
- return stack_variable_length_tensors(unpacked_tensors, pad_value)
266
-
267
-
268
- def fp32_autocast_context(device_type: str) -> ContextManager[Any]: # type: ignore
269
- """
270
- Returns an autocast context manager that disables downcasting by AMP.
271
-
272
- Args:
273
- device_type: The device type ('cpu' or 'cuda')
274
-
275
- Returns:
276
- An autocast context manager with the specified behavior.
277
- """
278
- if device_type == "cpu":
279
- return torch.amp.autocast(device_type, enabled=False) # type: ignore
280
- elif device_type == "mps":
281
- # For MPS, just return a no-op context manager (nullcontext) since MPS does not support autocast.
282
- return nullcontext()
283
- elif device_type == "cuda":
284
- return torch.amp.autocast(device_type, dtype=torch.float32) # type: ignore
285
- else:
286
- raise ValueError(f"Unsupported device type: {device_type}")
287
-
288
-
289
- def merge_ranges(ranges: list[range], merge_gap_max: int | None = None) -> list[range]:
290
- """Merge overlapping ranges into sorted, non-overlapping segments.
291
-
292
- Args:
293
- ranges: collection of ranges to merge.
294
- merge_gap_max: optionally merge neighboring ranges that are separated by a gap
295
- no larger than this size.
296
- Returns:
297
- non-overlapping ranges merged from the inputs, sorted by position.
298
- """
299
- ranges = sorted(ranges, key=lambda r: r.start)
300
- merge_gap_max = merge_gap_max if merge_gap_max is not None else 0
301
- assert merge_gap_max >= 0, f"Invalid merge_gap_max: {merge_gap_max}"
302
-
303
- merged = []
304
- for r in ranges:
305
- if not merged:
306
- merged.append(r)
307
- else:
308
- last = merged[-1]
309
- if last.stop + merge_gap_max >= r.start:
310
- merged[-1] = range(last.start, max(last.stop, r.stop))
311
- else:
312
- merged.append(r)
313
- return merged
314
-
315
-
316
- def merge_annotations(
317
- annotations: list[FunctionAnnotation], merge_gap_max: int | None = None
318
- ) -> list[FunctionAnnotation]:
319
- """Merges annotations into non-overlapping segments.
320
-
321
- Args:
322
- annotations: annotations to merge.
323
- merge_gap_max: optionally merge neighboring ranges that are separated by a gap
324
- no larger than this size.
325
- Returns:
326
- non-overlapping annotations with gaps merged.
327
- """
328
- grouped: dict[str, list[range]] = defaultdict(list)
329
- for a in annotations:
330
- # +1 since FunctionAnnotation.end is inlcusive.
331
- grouped[a.label].append(range(a.start, a.end + 1))
332
-
333
- merged = []
334
- for label, ranges in grouped.items():
335
- merged_ranges = merge_ranges(ranges, merge_gap_max=merge_gap_max)
336
- for range_ in merged_ranges:
337
- annotation = FunctionAnnotation(
338
- label=label,
339
- start=range_.start,
340
- end=range_.stop - 1, # convert range.stop exclusive -> inclusive.
341
- )
342
- merged.append(annotation)
343
- return merged
344
-
345
-
346
- def replace_inf(data):
347
- if data is None:
348
- return None
349
- array = np.asarray(data, dtype=np.float32)
350
- array = np.where(np.isinf(array), 1000, array)
351
- return array.tolist()
352
-
353
-
354
- def maybe_tensor(x, convert_none_to_nan: bool = False) -> torch.Tensor | None:
355
- if x is None:
356
- return None
357
- if isinstance(x, torch.Tensor):
358
- return x
359
- if isinstance(x, list) and all(isinstance(t, torch.Tensor) for t in x):
360
- return torch.stack(x)
361
- if convert_none_to_nan:
362
- x = np.asarray(x, dtype=np.float32)
363
- x = np.where(x is None, np.nan, x)
364
- return torch.tensor(x)
365
-
366
-
367
- def maybe_list(x, convert_nan_to_none: bool = False) -> list | None:
368
- if x is None:
369
- return None
370
- if not convert_nan_to_none:
371
- return x.tolist()
372
-
373
- # Handle both torch.tensor and np.ndarray input.
374
- if isinstance(x, torch.Tensor):
375
- nan_mask = torch.isnan(x).cpu().numpy()
376
- np_arr = x.cpu().numpy().astype(object)
377
- elif isinstance(x, np.ndarray):
378
- nan_mask = np.isnan(x)
379
- np_arr = x.astype(object)
380
- else:
381
- raise TypeError("maybe_list can only work with torch.tensor or np.ndarray.")
382
-
383
- np_arr[nan_mask] = None
384
- return np_arr.tolist()
385
-
386
-
387
- def huggingfacehub_login():
388
- """Authenticates with the Hugging Face Hub using the HF_TOKEN environment
389
- variable, else by prompting the user"""
390
- token = os.environ.get("HF_TOKEN")
391
- huggingface_hub.login(token=token)
392
-
393
-
394
- def get_chainbreak_boundaries_from_sequence(sequence: Sequence[str]) -> np.ndarray:
395
- chain_boundaries = [0]
396
- for i, aa in enumerate(sequence):
397
- if aa == CHAIN_BREAK_STR:
398
- if i == (len(sequence) - 1):
399
- raise ValueError(
400
- "Encountered chain break token at end of sequence, this is unexpected."
401
- )
402
- if i == (len(sequence) - 2):
403
- warn(
404
- "Encountered chain break token at penultimate position, this is unexpected."
405
- )
406
- chain_boundaries.append(i)
407
- chain_boundaries.append(i + 1)
408
- chain_boundaries.append(len(sequence))
409
- assert len(chain_boundaries) % 2 == 0
410
- chain_boundaries = np.array(chain_boundaries).reshape(-1, 2)
411
- return chain_boundaries
412
-
413
-
414
- def deserialize_tensors(b: bytes) -> Any:
415
- buf = BytesIO(zstd.ZSTD_uncompress(b))
416
- d = torch.load(buf, map_location="cpu", weights_only=False)
417
- return d
418
-
419
-
420
- def join_lists(
421
- lists: Sequence[Sequence[Any]], separator: Sequence[Any] | None = None
422
- ) -> list[Any]:
423
- """Joins multiple lists with separator element. Like str.join but for lists.
424
-
425
- Example: [[1, 2], [3], [4]], separator=[0] -> [1, 2, 0, 3, 0, 4]
426
-
427
- Args:
428
- lists: Lists of elements to chain
429
- separator: separators to intsert between chained output.
430
- Returns:
431
- Joined lists.
432
- """
433
- if not lists:
434
- return []
435
- joined = []
436
- joined.extend(lists[0])
437
- for l in lists[1:]:
438
- if separator:
439
- joined.extend(separator)
440
- joined.extend(l)
441
- return joined
442
-
443
-
444
- def iterate_with_intermediate(
445
- lists: Iterable, intermediate
446
- ) -> Generator[Any, None, None]:
447
- """
448
- Iterate over the iterable, yielding the intermediate value between
449
- every element of the intermediate. Useful for joining objects with
450
- separator tokens.
451
- """
452
- it = iter(lists)
453
- yield next(it)
454
- for l in it:
455
- yield intermediate
456
- yield l
457
-
458
-
459
- def concat_objects(objs: Sequence[Any], separator: Any | None = None):
460
- """
461
- Concat objects with each other using a separator token.
462
-
463
- Supports:
464
- - Concatable (objects that implement `concat` classmethod)
465
- - strings
466
- - lists
467
- - numpy arrays
468
- - torch Tensors
469
-
470
- Example:
471
- >>> foo = "abc"
472
- >>> bar = "def"
473
- >>> concat_objects([foo, bar], "|")
474
- "abc|def"
475
- """
476
- match objs[0]:
477
- case Concatable():
478
- return objs[0].__class__.concat(objs) # type: ignore
479
- case str():
480
- assert isinstance(
481
- separator, str
482
- ), "Trying to join strings but separator is not a string"
483
- return separator.join(objs)
484
- case list():
485
- if separator is not None:
486
- return join_lists(objs, [separator])
487
- else:
488
- return join_lists(objs)
489
- case np.ndarray():
490
- if separator is not None:
491
- return np.concatenate(
492
- list(iterate_with_intermediate(objs, np.array([separator])))
493
- )
494
- else:
495
- return np.concatenate(objs)
496
- case torch.Tensor():
497
- if separator is not None:
498
- return torch.cat(
499
- list(iterate_with_intermediate(objs, torch.tensor([separator])))
500
- )
501
- else:
502
- return torch.cat(objs) # type: ignore
503
- case _:
504
- raise TypeError(type(objs[0]))
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections import defaultdict
5
+ from contextlib import nullcontext
6
+ from dataclasses import is_dataclass
7
+ from io import BytesIO
8
+ from typing import (
9
+ Any,
10
+ ContextManager,
11
+ Generator,
12
+ Iterable,
13
+ Protocol,
14
+ Sequence,
15
+ TypeVar,
16
+ runtime_checkable,
17
+ )
18
+ from warnings import warn
19
+
20
+ import huggingface_hub
21
+ import numpy as np
22
+ import torch
23
+ import zstd
24
+
25
+ from .esmfold2_constants_esm3 import CHAIN_BREAK_STR
26
+ from .esmfold2_utils_types import FunctionAnnotation
27
+
28
+ MAX_SUPPORTED_DISTANCE = 1e6
29
+
30
+
31
+ TSequence = TypeVar("TSequence", bound=Sequence)
32
+
33
+
34
+ @runtime_checkable
35
+ class Concatable(Protocol):
36
+ @classmethod
37
+ def concat(cls, objs: list[Concatable]) -> Concatable: ...
38
+
39
+
40
+ def slice_python_object_as_numpy(
41
+ obj: TSequence, idx: int | list[int] | slice | np.ndarray
42
+ ) -> TSequence:
43
+ """
44
+ Slice a python object (like a list, string, or tuple) as if it was a numpy object.
45
+
46
+ Example:
47
+ >>> obj = "ABCDE"
48
+ >>> slice_python_object_as_numpy(obj, [1, 3, 4])
49
+ "BDE"
50
+
51
+ >>> obj = [1, 2, 3, 4, 5]
52
+ >>> slice_python_object_as_numpy(obj, np.arange(5) < 3)
53
+ [1, 2, 3]
54
+ """
55
+ if np.isscalar(idx):
56
+ idx = [int(idx)] # type: ignore
57
+
58
+ if isinstance(idx, np.ndarray) and idx.dtype == bool:
59
+ sliced_obj = [obj[i] for i in np.where(idx)[0]]
60
+ elif isinstance(idx, slice):
61
+ sliced_obj = obj[idx]
62
+ else:
63
+ sliced_obj = [obj[i] for i in idx] # type: ignore
64
+
65
+ match obj, sliced_obj:
66
+ case str(), list():
67
+ sliced_obj = "".join(sliced_obj)
68
+ case _:
69
+ sliced_obj = obj.__class__(sliced_obj) # type: ignore
70
+
71
+ return sliced_obj # type: ignore
72
+
73
+
74
+ def slice_any_object(
75
+ obj: TSequence, idx: int | list[int] | slice | np.ndarray
76
+ ) -> TSequence:
77
+ """
78
+ Slice a arbitrary object (like a list, string, or tuple) as if it was a numpy object. Similar to `slice_python_object_as_numpy`, but detects if it's a numpy array or Tensor and uses the existing slice method if so.
79
+
80
+ If the object is a dataclass, it will simply apply the index to the object, under the assumption that the object has correcty implemented numpy indexing.
81
+
82
+ Example:
83
+ >>> obj = "ABCDE"
84
+ >>> slice_any_object(obj, [1, 3, 4])
85
+ "BDE"
86
+
87
+ >>> obj = np.array([1, 2, 3, 4, 5])
88
+ >>> slice_any_object(obj, np.arange(5) < 3)
89
+ np.array([1, 2, 3])
90
+
91
+ >>> obj = ProteinChain.from_rcsb("1a3a", "A")
92
+ >>> slice_any_object(obj, np.arange(len(obj)) < 10)
93
+ # ProteinChain w/ length 10
94
+
95
+ """
96
+ if isinstance(obj, (np.ndarray, torch.Tensor)):
97
+ return obj[idx] # type: ignore
98
+ elif is_dataclass(obj):
99
+ # if passing a dataclass, assume it implements a custom slice
100
+ return obj[idx] # type: ignore
101
+ else:
102
+ return slice_python_object_as_numpy(obj, idx)
103
+
104
+
105
+ def rbf(values, v_min, v_max, n_bins=16):
106
+ """
107
+ Returns RBF encodings in a new dimension at the end.
108
+ """
109
+ rbf_centers = torch.linspace(
110
+ v_min, v_max, n_bins, device=values.device, dtype=values.dtype
111
+ )
112
+ rbf_centers = rbf_centers.view([1] * len(values.shape) + [-1])
113
+ rbf_std = (v_max - v_min) / n_bins
114
+ z = (values.unsqueeze(-1) - rbf_centers) / rbf_std
115
+ return torch.exp(-(z**2))
116
+
117
+
118
+ def batched_gather(data, inds, dim=0, no_batch_dims=0):
119
+ ranges = []
120
+ for i, s in enumerate(data.shape[:no_batch_dims]):
121
+ r = torch.arange(s)
122
+ r = r.view(*(*((1,) * i), -1, *((1,) * (len(inds.shape) - i - 1))))
123
+ ranges.append(r)
124
+
125
+ remaining_dims = [slice(None) for _ in range(len(data.shape) - no_batch_dims)]
126
+ remaining_dims[dim - no_batch_dims if dim >= 0 else dim] = inds
127
+ ranges.extend(remaining_dims)
128
+ return data[ranges]
129
+
130
+
131
+ def node_gather(s: torch.Tensor, edges: torch.Tensor) -> torch.Tensor:
132
+ return batched_gather(s.unsqueeze(-3), edges, -2, no_batch_dims=len(s.shape) - 1)
133
+
134
+
135
+ def knn_graph(
136
+ coords: torch.Tensor,
137
+ coord_mask: torch.Tensor,
138
+ padding_mask: torch.Tensor,
139
+ sequence_id: torch.Tensor,
140
+ *,
141
+ no_knn: int,
142
+ ):
143
+ L = coords.shape[-2]
144
+ num_by_dist = min(no_knn, L)
145
+ device = coords.device
146
+
147
+ coords = coords.nan_to_num()
148
+ coord_mask = ~(coord_mask[..., None, :] & coord_mask[..., :, None])
149
+ padding_pairwise_mask = padding_mask[..., None, :] | padding_mask[..., :, None]
150
+ if sequence_id is not None:
151
+ padding_pairwise_mask |= torch.unsqueeze(sequence_id, 1) != torch.unsqueeze(
152
+ sequence_id, 2
153
+ )
154
+ dists = (coords.unsqueeze(-2) - coords.unsqueeze(-3)).norm(dim=-1)
155
+ arange = torch.arange(L, device=device)
156
+ seq_dists = (arange.unsqueeze(-1) - arange.unsqueeze(-2)).abs()
157
+ # We only support up to a certain distance, above that, we use sequence distance
158
+ # instead. This is so that when a large portion of the structure is masked out,
159
+ # the edges are built according to sequence distance.
160
+ max_dist = MAX_SUPPORTED_DISTANCE
161
+ if not (dists[~coord_mask] < max_dist).all():
162
+ raise ValueError(
163
+ f"Coordinate pairwise distances exceed max supported distance ({max_dist}). "
164
+ )
165
+ struct_then_seq_dist = (
166
+ seq_dists.to(dists.dtype)
167
+ .mul(1e2)
168
+ .add(max_dist)
169
+ .where(coord_mask, dists)
170
+ .masked_fill(padding_pairwise_mask, torch.inf)
171
+ )
172
+ dists, edges = struct_then_seq_dist.sort(dim=-1, descending=False)
173
+ # This is a L x L tensor, where we index by rows first,
174
+ # and columns are the edges we should pick.
175
+ chosen_edges = edges[..., :num_by_dist]
176
+ chosen_mask = dists[..., :num_by_dist].isfinite()
177
+ return chosen_edges, chosen_mask
178
+
179
+
180
+ def stack_variable_length_tensors(
181
+ sequences: Sequence[torch.Tensor],
182
+ constant_value: int | float = 0,
183
+ dtype: torch.dtype | None = None,
184
+ ) -> torch.Tensor:
185
+ """Automatically stack tensors together, padding variable lengths with the
186
+ value in constant_value. Handles an arbitrary number of dimensions.
187
+
188
+ Examples:
189
+ >>> tensor1, tensor2 = torch.ones([2]), torch.ones([5])
190
+ >>> stack_variable_length_tensors(tensor1, tensor2)
191
+ tensor of shape [2, 5]. First row is [1, 1, 0, 0, 0]. Second row is all ones.
192
+
193
+ >>> tensor1, tensor2 = torch.ones([2, 4]), torch.ones([5, 3])
194
+ >>> stack_variable_length_tensors(tensor1, tensor2)
195
+ tensor of shape [2, 5, 4]
196
+ """
197
+ batch_size = len(sequences)
198
+ shape = [batch_size] + np.max([seq.shape for seq in sequences], 0).tolist()
199
+
200
+ if dtype is None:
201
+ dtype = sequences[0].dtype
202
+ device = sequences[0].device
203
+
204
+ array = torch.full(shape, constant_value, dtype=dtype, device=device)
205
+ for arr, seq in zip(array, sequences):
206
+ arrslice = tuple(slice(dim) for dim in seq.shape)
207
+ arr[arrslice] = seq
208
+
209
+ return array
210
+
211
+
212
+ def binpack(
213
+ tensor: torch.Tensor, sequence_id: torch.Tensor | None, pad_value: int | float
214
+ ):
215
+ """
216
+ Args:
217
+ tensor (Tensor): [B, L, ...]
218
+
219
+ Returns:
220
+ Tensor: [B_binpacked, L_binpacked, ...]
221
+ """
222
+ if sequence_id is None:
223
+ return tensor
224
+
225
+ num_sequences = sequence_id.max(dim=-1).values + 1
226
+
227
+ dims = sequence_id.shape + tensor.shape[2:]
228
+ output_tensor = torch.full(
229
+ dims, fill_value=pad_value, dtype=tensor.dtype, device=tensor.device
230
+ )
231
+
232
+ idx = 0
233
+ for batch_idx, (batch_seqid, batch_num_sequences) in enumerate(
234
+ zip(sequence_id, num_sequences)
235
+ ):
236
+ for seqid in range(batch_num_sequences):
237
+ mask = batch_seqid == seqid
238
+ output_tensor[batch_idx, mask] = tensor[idx, : mask.sum()]
239
+ idx += 1
240
+ return output_tensor
241
+
242
+
243
+ def unbinpack(
244
+ tensor: torch.Tensor, sequence_id: torch.Tensor | None, pad_value: int | float
245
+ ):
246
+ """
247
+ Args:
248
+ tensor (Tensor): [B, L, ...]
249
+
250
+ Returns:
251
+ Tensor: [B_unbinpacked, L_unbinpack, ...]
252
+ """
253
+ if sequence_id is None:
254
+ return tensor
255
+
256
+ unpacked_tensors = []
257
+ num_sequences = sequence_id.max(dim=-1).values + 1
258
+ for batch_idx, (batch_seqid, batch_num_sequences) in enumerate(
259
+ zip(sequence_id, num_sequences)
260
+ ):
261
+ for seqid in range(batch_num_sequences):
262
+ mask = batch_seqid == seqid
263
+ unpacked = tensor[batch_idx, mask]
264
+ unpacked_tensors.append(unpacked)
265
+ return stack_variable_length_tensors(unpacked_tensors, pad_value)
266
+
267
+
268
+ def fp32_autocast_context(device_type: str) -> ContextManager[Any]: # type: ignore
269
+ """
270
+ Returns an autocast context manager that disables downcasting by AMP.
271
+
272
+ Args:
273
+ device_type: The device type ('cpu' or 'cuda')
274
+
275
+ Returns:
276
+ An autocast context manager with the specified behavior.
277
+ """
278
+ if device_type == "cpu":
279
+ return torch.amp.autocast(device_type, enabled=False) # type: ignore
280
+ elif device_type == "mps":
281
+ # For MPS, just return a no-op context manager (nullcontext) since MPS does not support autocast.
282
+ return nullcontext()
283
+ elif device_type == "cuda":
284
+ return torch.amp.autocast(device_type, dtype=torch.float32) # type: ignore
285
+ else:
286
+ raise ValueError(f"Unsupported device type: {device_type}")
287
+
288
+
289
+ def merge_ranges(ranges: list[range], merge_gap_max: int | None = None) -> list[range]:
290
+ """Merge overlapping ranges into sorted, non-overlapping segments.
291
+
292
+ Args:
293
+ ranges: collection of ranges to merge.
294
+ merge_gap_max: optionally merge neighboring ranges that are separated by a gap
295
+ no larger than this size.
296
+ Returns:
297
+ non-overlapping ranges merged from the inputs, sorted by position.
298
+ """
299
+ ranges = sorted(ranges, key=lambda r: r.start)
300
+ merge_gap_max = merge_gap_max if merge_gap_max is not None else 0
301
+ assert merge_gap_max >= 0, f"Invalid merge_gap_max: {merge_gap_max}"
302
+
303
+ merged = []
304
+ for r in ranges:
305
+ if not merged:
306
+ merged.append(r)
307
+ else:
308
+ last = merged[-1]
309
+ if last.stop + merge_gap_max >= r.start:
310
+ merged[-1] = range(last.start, max(last.stop, r.stop))
311
+ else:
312
+ merged.append(r)
313
+ return merged
314
+
315
+
316
+ def merge_annotations(
317
+ annotations: list[FunctionAnnotation], merge_gap_max: int | None = None
318
+ ) -> list[FunctionAnnotation]:
319
+ """Merges annotations into non-overlapping segments.
320
+
321
+ Args:
322
+ annotations: annotations to merge.
323
+ merge_gap_max: optionally merge neighboring ranges that are separated by a gap
324
+ no larger than this size.
325
+ Returns:
326
+ non-overlapping annotations with gaps merged.
327
+ """
328
+ grouped: dict[str, list[range]] = defaultdict(list)
329
+ for a in annotations:
330
+ # +1 since FunctionAnnotation.end is inlcusive.
331
+ grouped[a.label].append(range(a.start, a.end + 1))
332
+
333
+ merged = []
334
+ for label, ranges in grouped.items():
335
+ merged_ranges = merge_ranges(ranges, merge_gap_max=merge_gap_max)
336
+ for range_ in merged_ranges:
337
+ annotation = FunctionAnnotation(
338
+ label=label,
339
+ start=range_.start,
340
+ end=range_.stop - 1, # convert range.stop exclusive -> inclusive.
341
+ )
342
+ merged.append(annotation)
343
+ return merged
344
+
345
+
346
+ def replace_inf(data):
347
+ if data is None:
348
+ return None
349
+ array = np.asarray(data, dtype=np.float32)
350
+ array = np.where(np.isinf(array), 1000, array)
351
+ return array.tolist()
352
+
353
+
354
+ def maybe_tensor(x, convert_none_to_nan: bool = False) -> torch.Tensor | None:
355
+ if x is None:
356
+ return None
357
+ if isinstance(x, torch.Tensor):
358
+ return x
359
+ if isinstance(x, list) and all(isinstance(t, torch.Tensor) for t in x):
360
+ return torch.stack(x)
361
+ if convert_none_to_nan:
362
+ x = np.asarray(x, dtype=np.float32)
363
+ x = np.where(x is None, np.nan, x)
364
+ return torch.tensor(x)
365
+
366
+
367
+ def maybe_list(x, convert_nan_to_none: bool = False) -> list | None:
368
+ if x is None:
369
+ return None
370
+ if not convert_nan_to_none:
371
+ return x.tolist()
372
+
373
+ # Handle both torch.tensor and np.ndarray input.
374
+ if isinstance(x, torch.Tensor):
375
+ nan_mask = torch.isnan(x).cpu().numpy()
376
+ np_arr = x.cpu().numpy().astype(object)
377
+ elif isinstance(x, np.ndarray):
378
+ nan_mask = np.isnan(x)
379
+ np_arr = x.astype(object)
380
+ else:
381
+ raise TypeError("maybe_list can only work with torch.tensor or np.ndarray.")
382
+
383
+ np_arr[nan_mask] = None
384
+ return np_arr.tolist()
385
+
386
+
387
+ def huggingfacehub_login():
388
+ """Authenticates with the Hugging Face Hub using the HF_TOKEN environment
389
+ variable, else by prompting the user"""
390
+ token = os.environ.get("HF_TOKEN")
391
+ huggingface_hub.login(token=token)
392
+
393
+
394
+ def get_chainbreak_boundaries_from_sequence(sequence: Sequence[str]) -> np.ndarray:
395
+ chain_boundaries = [0]
396
+ for i, aa in enumerate(sequence):
397
+ if aa == CHAIN_BREAK_STR:
398
+ if i == (len(sequence) - 1):
399
+ raise ValueError(
400
+ "Encountered chain break token at end of sequence, this is unexpected."
401
+ )
402
+ if i == (len(sequence) - 2):
403
+ warn(
404
+ "Encountered chain break token at penultimate position, this is unexpected."
405
+ )
406
+ chain_boundaries.append(i)
407
+ chain_boundaries.append(i + 1)
408
+ chain_boundaries.append(len(sequence))
409
+ assert len(chain_boundaries) % 2 == 0
410
+ chain_boundaries = np.array(chain_boundaries).reshape(-1, 2)
411
+ return chain_boundaries
412
+
413
+
414
+ def deserialize_tensors(b: bytes) -> Any:
415
+ buf = BytesIO(zstd.ZSTD_uncompress(b))
416
+ d = torch.load(buf, map_location="cpu", weights_only=False)
417
+ return d
418
+
419
+
420
+ def join_lists(
421
+ lists: Sequence[Sequence[Any]], separator: Sequence[Any] | None = None
422
+ ) -> list[Any]:
423
+ """Joins multiple lists with separator element. Like str.join but for lists.
424
+
425
+ Example: [[1, 2], [3], [4]], separator=[0] -> [1, 2, 0, 3, 0, 4]
426
+
427
+ Args:
428
+ lists: Lists of elements to chain
429
+ separator: separators to intsert between chained output.
430
+ Returns:
431
+ Joined lists.
432
+ """
433
+ if not lists:
434
+ return []
435
+ joined = []
436
+ joined.extend(lists[0])
437
+ for l in lists[1:]:
438
+ if separator:
439
+ joined.extend(separator)
440
+ joined.extend(l)
441
+ return joined
442
+
443
+
444
+ def iterate_with_intermediate(
445
+ lists: Iterable, intermediate
446
+ ) -> Generator[Any, None, None]:
447
+ """
448
+ Iterate over the iterable, yielding the intermediate value between
449
+ every element of the intermediate. Useful for joining objects with
450
+ separator tokens.
451
+ """
452
+ it = iter(lists)
453
+ yield next(it)
454
+ for l in it:
455
+ yield intermediate
456
+ yield l
457
+
458
+
459
+ def concat_objects(objs: Sequence[Any], separator: Any | None = None):
460
+ """
461
+ Concat objects with each other using a separator token.
462
+
463
+ Supports:
464
+ - Concatable (objects that implement `concat` classmethod)
465
+ - strings
466
+ - lists
467
+ - numpy arrays
468
+ - torch Tensors
469
+
470
+ Example:
471
+ >>> foo = "abc"
472
+ >>> bar = "def"
473
+ >>> concat_objects([foo, bar], "|")
474
+ "abc|def"
475
+ """
476
+ match objs[0]:
477
+ case Concatable():
478
+ return objs[0].__class__.concat(objs) # type: ignore
479
+ case str():
480
+ assert isinstance(
481
+ separator, str
482
+ ), "Trying to join strings but separator is not a string"
483
+ return separator.join(objs)
484
+ case list():
485
+ if separator is not None:
486
+ return join_lists(objs, [separator])
487
+ else:
488
+ return join_lists(objs)
489
+ case np.ndarray():
490
+ if separator is not None:
491
+ return np.concatenate(
492
+ list(iterate_with_intermediate(objs, np.array([separator])))
493
+ )
494
+ else:
495
+ return np.concatenate(objs)
496
+ case torch.Tensor():
497
+ if separator is not None:
498
+ return torch.cat(
499
+ list(iterate_with_intermediate(objs, torch.tensor([separator])))
500
+ )
501
+ else:
502
+ return torch.cat(objs) # type: ignore
503
+ case _:
504
+ raise TypeError(type(objs[0]))
esmfold2_mmcif_parsing.py CHANGED
@@ -1,469 +1,469 @@
1
- from __future__ import annotations
2
-
3
- import functools
4
- import io
5
- import os
6
- from dataclasses import dataclass
7
- from datetime import datetime
8
- from typing import Union
9
-
10
- import biotite.structure as bs
11
- import biotite.structure.io.pdbx as pdbx
12
-
13
- from . import esmfold2_residue_constants as residue_constants
14
-
15
- # Define PathOrBuffer for the opensource version
16
- PathOrBuffer = Union[str, os.PathLike, io.StringIO]
17
-
18
-
19
- class NoProteinError(Exception):
20
- pass
21
-
22
-
23
- @dataclass
24
- class Residue:
25
- residue_number: int | None = None
26
- insertion_code: str = ""
27
- hetflag: bool = False
28
-
29
-
30
- @dataclass
31
- class MmcifHeader:
32
- release_date: datetime | None = None
33
- resolution: float | None = None
34
- structure_method: str = "UNKNOWN"
35
-
36
-
37
- class MmcifWrapper:
38
- def __init__(self, id: str | None = None):
39
- self.id: str = id or ""
40
- self.raw: pdbx.CIFFile | None = None
41
- self.structure: bs.AtomArray
42
- self.header: MmcifHeader = MmcifHeader()
43
- self.entities: dict[int, list[str]] = {}
44
- self.chain_to_seqres: dict[str, str] = {}
45
- self.seqres_to_structure: dict[str, dict[int, Residue]] = {}
46
-
47
- @classmethod
48
- def read(cls, path: PathOrBuffer, id: str | None = None) -> MmcifWrapper:
49
- obj = cls(id=id)
50
- obj._load(path)
51
- return obj
52
-
53
- def _load(self, path: PathOrBuffer, fileid: str | None = None):
54
- """Load mmCIF data from file."""
55
- self.raw = pdbx.CIFFile.read(path)
56
-
57
- self._parse_structure()
58
- self._parse_header()
59
- self._parse_entities()
60
- self._parse_sequences()
61
-
62
- def _parse_structure(self):
63
- """Parse the atomic structure from mmCIF."""
64
- try:
65
- structure = pdbx.get_structure(self.raw, model=1)
66
- if structure is None or not isinstance(structure, bs.AtomArray):
67
- raise NoProteinError("No structure found in mmCIF file")
68
- if len(structure) == 0:
69
- raise NoProteinError("Empty structure in mmCIF file")
70
- self.structure = structure
71
- except Exception as e:
72
- raise ValueError(f"Failed to parse structure: {e}")
73
-
74
- def _parse_header(self):
75
- """Parse header information from mmCIF."""
76
- if not self.raw:
77
- return
78
-
79
- try:
80
- # Get the first (and usually only) block
81
- block = self.raw.block
82
-
83
- # Parse release date
84
- if "pdbx_database_status" in block:
85
- status_cat = block["pdbx_database_status"]
86
- if "recvd_initial_deposition_date" in status_cat:
87
- date_str = status_cat["recvd_initial_deposition_date"].as_item()
88
- if date_str and date_str != "?":
89
- try:
90
- self.header.release_date = datetime.strptime(
91
- date_str, "%Y-%m-%d"
92
- )
93
- except ValueError:
94
- pass
95
-
96
- # Parse resolution
97
- if "refine" in block:
98
- refine_cat = block["refine"]
99
- if "ls_d_res_high" in refine_cat:
100
- res_str = refine_cat["ls_d_res_high"].as_item()
101
- if res_str and res_str != "?":
102
- try:
103
- self.header.resolution = float(res_str)
104
- except ValueError:
105
- pass
106
-
107
- # Parse structure method
108
- if "exptl" in block:
109
- exptl_cat = block["exptl"]
110
- if "method" in exptl_cat:
111
- method = exptl_cat["method"].as_item()
112
- if method and method != "?":
113
- self.header.structure_method = method.upper()
114
-
115
- except Exception:
116
- # If parsing fails, keep default values
117
- pass
118
-
119
- def _parse_entities(self):
120
- """Parse entity information and map to chains."""
121
- if not self.raw:
122
- return
123
-
124
- try:
125
- block = self.raw.block
126
-
127
- # Parse entity information
128
- if "entity" in block:
129
- entity_cat = block["entity"]
130
- entity_ids = entity_cat["id"].as_array(str)
131
- entity_types = entity_cat["type"].as_array(str)
132
-
133
- # Initialize entities dict with all entities (not just polymers)
134
- for i, (entity_id, entity_type) in enumerate(
135
- zip(entity_ids, entity_types)
136
- ):
137
- self.entities[int(entity_id)] = []
138
-
139
- # Map polymer chains to entities using entity_poly
140
- if "entity_poly" in block:
141
- poly_cat = block["entity_poly"]
142
- entity_ids = poly_cat["entity_id"].as_array(str)
143
- chain_lists = poly_cat["pdbx_strand_id"].as_array(str)
144
-
145
- for entity_id, chain_list in zip(entity_ids, chain_lists):
146
- entity_id = int(entity_id)
147
- # Chain list is comma-separated
148
- chains = [c.strip() for c in chain_list.split(",") if c.strip()]
149
- if entity_id in self.entities:
150
- self.entities[entity_id] = chains
151
-
152
- # Map non-polymer chains using struct_asym for entities not covered by entity_poly
153
- if "struct_asym" in block:
154
- asym_cat = block["struct_asym"]
155
- asym_ids = asym_cat["id"].as_array(str)
156
- entity_ids = asym_cat["entity_id"].as_array(str)
157
-
158
- for asym_id, entity_id in zip(asym_ids, entity_ids):
159
- entity_id = int(entity_id)
160
- # Only add if entity exists but has no chains yet (non-polymer entities)
161
- if entity_id in self.entities and not self.entities[entity_id]:
162
- self.entities[entity_id].append(asym_id)
163
-
164
- except Exception:
165
- # If parsing fails, try to infer from structure
166
- if (
167
- self.structure
168
- and hasattr(self.structure, "chain_id")
169
- and self.structure.chain_id is not None
170
- and hasattr(self.structure.chain_id, "__iter__")
171
- ):
172
- chain_ids = list(set(self.structure.chain_id))
173
- self.entities = {1: chain_ids}
174
-
175
- def _parse_sequences(self):
176
- """Parse sequence information from mmCIF."""
177
- if not self.raw:
178
- return
179
-
180
- block = self.raw.block
181
-
182
- # Parse polymer sequences
183
- if "entity_poly" in block:
184
- poly_cat = block["entity_poly"]
185
- entity_ids = poly_cat["entity_id"].as_array(str)
186
- sequences = poly_cat["pdbx_seq_one_letter_code_can"].as_array(str)
187
- chain_lists = poly_cat["pdbx_strand_id"].as_array(str)
188
-
189
- for entity_id, sequence, chain_list in zip(
190
- entity_ids, sequences, chain_lists
191
- ):
192
- # Clean up sequence (remove whitespace and newlines)
193
- clean_seq = "".join(sequence.split())
194
- chains = [c.strip() for c in chain_list.split(",") if c.strip()]
195
-
196
- for chain_id in chains:
197
- self.chain_to_seqres[chain_id] = clean_seq
198
-
199
- # Parse sequence to structure mapping
200
- if "pdbx_poly_seq_scheme" in block:
201
- seq_cat = block["pdbx_poly_seq_scheme"]
202
- asym_ids = seq_cat["asym_id"].as_array(str) # Internal chain IDs
203
- seq_positions = seq_cat["seq_id"].as_array(str)
204
- auth_seq_nums = seq_cat["auth_seq_num"].as_array(str)
205
- ins_codes = (
206
- seq_cat["pdb_ins_code"].as_array(str)
207
- if "pdb_ins_code" in seq_cat
208
- else [""] * len(asym_ids)
209
- )
210
- hetflags = (
211
- seq_cat["hetflag"].as_array(str)
212
- if "hetflag" in seq_cat
213
- else ["N"] * len(asym_ids)
214
- )
215
-
216
- # Get author chain IDs if available
217
- auth_chain_ids = (
218
- seq_cat["pdb_strand_id"].as_array(str)
219
- if "pdb_strand_id" in seq_cat
220
- else asym_ids # Fallback to internal IDs
221
- )
222
-
223
- # Build mapping from internal chain ID to author chain ID
224
- asym_to_auth_mapping = {}
225
- for asym_id, auth_id in zip(asym_ids, auth_chain_ids):
226
- asym_to_auth_mapping[asym_id] = auth_id
227
-
228
- # Group by internal chain ID first, then map to author chain ID
229
- chain_data = {}
230
- for asym_id, seq_pos, auth_seq, ins_code, hetflag in zip(
231
- asym_ids, seq_positions, auth_seq_nums, ins_codes, hetflags
232
- ):
233
- if asym_id not in chain_data:
234
- chain_data[asym_id] = {}
235
-
236
- try:
237
- seq_index = int(seq_pos) - 1 # Convert to 0-based indexing
238
- res_num = int(auth_seq) if auth_seq != "?" else None
239
- except ValueError:
240
- continue
241
-
242
- if res_num is not None:
243
- # Convert mmCIF "." and "?" to empty string
244
- clean_ins_code = "" if ins_code in [".", "?"] else ins_code
245
- else:
246
- clean_ins_code = ""
247
- res_num = None
248
-
249
- is_het = hetflag.upper() == "Y" # type: ignore
250
- chain_data[asym_id][seq_index] = Residue(
251
- residue_number=res_num,
252
- insertion_code=clean_ins_code, # type: ignore
253
- hetflag=is_het,
254
- )
255
-
256
- # Handle cases where multiple residues have the same auth_seq_num
257
- # by adjusting residue numbers to be unique within each chain
258
- for asym_id, residue_data in chain_data.items():
259
- # Check if there are duplicate residue numbers in this chain
260
- positions_with_same_num = {}
261
- for seq_idx, res_at_pos in residue_data.items():
262
- if res_at_pos.residue_number is not None:
263
- res_num = res_at_pos.residue_number
264
- if res_num not in positions_with_same_num:
265
- positions_with_same_num[res_num] = []
266
- positions_with_same_num[res_num].append(seq_idx)
267
-
268
- # Fix duplicate residue numbers by making them sequential
269
- for res_num, seq_indices in positions_with_same_num.items():
270
- if len(seq_indices) > 1:
271
- # Multiple residues have the same residue number
272
- # Make them sequential starting from the original number
273
- seq_indices.sort() # Ensure consistent ordering
274
- for i, seq_idx in enumerate(seq_indices):
275
- original_pos = residue_data[seq_idx]
276
- new_pos = Residue(
277
- residue_number=res_num + i,
278
- insertion_code=original_pos.insertion_code,
279
- hetflag=original_pos.hetflag,
280
- )
281
- residue_data[seq_idx] = new_pos
282
-
283
- # Create ordered mappings using author chain IDs
284
- for asym_id in chain_data:
285
- auth_chain_id = asym_to_auth_mapping.get(asym_id, asym_id)
286
- if auth_chain_id in self.chain_to_seqres:
287
- seq_len = len(self.chain_to_seqres[auth_chain_id])
288
- ordered_mapping = {}
289
-
290
- for i in range(seq_len):
291
- if i in chain_data[asym_id]:
292
- ordered_mapping[i] = chain_data[asym_id][i]
293
- else:
294
- # Missing residue - no structure coordinates
295
- ordered_mapping[i] = Residue(
296
- residue_number=None, insertion_code="", hetflag=False
297
- )
298
-
299
- self.seqres_to_structure[auth_chain_id] = ordered_mapping
300
- else:
301
- # Handle case where auth_chain_id is not in chain_to_seqres
302
- # This can happen if the chain is not a polymer or if there's a parsing issue
303
- # Create a basic mapping based on the chain_data
304
- if chain_data[asym_id]:
305
- # Sort by sequence index to create ordered mapping
306
- sorted_indices = sorted(chain_data[asym_id].keys())
307
- ordered_mapping = {}
308
- for i, seq_idx in enumerate(sorted_indices):
309
- ordered_mapping[i] = chain_data[asym_id][seq_idx]
310
- self.seqres_to_structure[auth_chain_id] = ordered_mapping
311
-
312
- # Ensure all chains have complete mappings
313
- for chain_id in self.chain_to_seqres:
314
- if chain_id not in self.seqres_to_structure:
315
- seq_len = len(self.chain_to_seqres[chain_id])
316
- self.seqres_to_structure[chain_id] = {
317
- i: Residue(residue_number=None, insertion_code="", hetflag=False)
318
- for i in range(seq_len)
319
- }
320
- else:
321
- # Fill in any missing indices
322
- seq_len = len(self.chain_to_seqres[chain_id])
323
- mapping = self.seqres_to_structure[chain_id]
324
- for i in range(seq_len):
325
- if i not in mapping:
326
- mapping[i] = Residue(
327
- residue_number=None, insertion_code="", hetflag=False
328
- )
329
-
330
- # Fallback: create basic mappings from structure for missing chains
331
- if (
332
- self.structure
333
- and hasattr(self.structure, "chain_id")
334
- and self.structure.chain_id is not None
335
- and hasattr(self.structure.chain_id, "__iter__")
336
- ):
337
- for chain_id in set(self.structure.chain_id):
338
- if chain_id not in self.seqres_to_structure:
339
- chain_structure = self.structure[
340
- self.structure.chain_id == chain_id
341
- ]
342
- if (
343
- hasattr(chain_structure, "res_id")
344
- and chain_structure.res_id is not None
345
- and hasattr(chain_structure.res_id, "__iter__")
346
- ):
347
- residue_ids = list(set(chain_structure.res_id))
348
- residue_ids.sort()
349
-
350
- self.seqres_to_structure[chain_id] = {
351
- i: Residue(
352
- residue_number=res_id, insertion_code="", hetflag=False
353
- )
354
- for i, res_id in enumerate(residue_ids)
355
- }
356
-
357
- def _parse_nonpoly_from_mmcif(self) -> dict[tuple, bs.AtomArray]:
358
- """Parse non-polymer coordinates from mmCIF block data."""
359
- nonpoly_coords = {}
360
-
361
- # Get non-polymer entities from the mmCIF block
362
- assert self.raw is not None
363
- block = self.raw.block
364
- nonpoly_entities = set()
365
-
366
- # Find non-polymer entities
367
- if "entity" in block:
368
- entity_cat = block["entity"]
369
- entity_ids = entity_cat["id"].as_array(str)
370
- entity_types = entity_cat["type"].as_array(str)
371
-
372
- for entity_id, entity_type in zip(entity_ids, entity_types):
373
- if entity_type.upper() in ["NON-POLYMER", "WATER", "BRANCHED"]:
374
- nonpoly_entities.add(entity_id)
375
-
376
- # Map entities to chains for non-polymers
377
- entity_to_chains = {}
378
- if "pdbx_entity_nonpoly" in block:
379
- nonpoly_cat = block["pdbx_entity_nonpoly"]
380
- entity_ids = nonpoly_cat["entity_id"].as_array(str)
381
- comp_ids = nonpoly_cat["comp_id"].as_array(str)
382
-
383
- for entity_id, comp_id in zip(entity_ids, comp_ids):
384
- if entity_id in nonpoly_entities:
385
- entity_to_chains[entity_id] = comp_id
386
-
387
- # Get atom site information for non-polymers
388
- if "atom_site" in block:
389
- atom_cat = block["atom_site"]
390
- atom_chain_ids = atom_cat["label_asym_id"].as_array(str)
391
- atom_entity_ids = atom_cat["label_entity_id"].as_array(str)
392
- atom_comp_ids = atom_cat["label_comp_id"].as_array(str)
393
-
394
- # Group non-polymer atoms by entity and chain
395
- nonpoly_atom_groups = {}
396
- for i, (chain_id, entity_id, comp_id) in enumerate(
397
- zip(atom_chain_ids, atom_entity_ids, atom_comp_ids)
398
- ):
399
- if entity_id in nonpoly_entities:
400
- key = (comp_id, chain_id)
401
- if key not in nonpoly_atom_groups:
402
- nonpoly_atom_groups[key] = []
403
- nonpoly_atom_groups[key].append(i)
404
-
405
- # Extract coordinates for each non-polymer group
406
- for (comp_id, chain_id), atom_indices in nonpoly_atom_groups.items():
407
- # Match atoms by comparing chain_id and residue name
408
- structure_mask = (self.structure.chain_id == chain_id) & (
409
- self.structure.res_name == comp_id
410
- )
411
-
412
- if structure_mask.any():
413
- nonpoly_array = self.structure[structure_mask]
414
- if (
415
- isinstance(nonpoly_array, (bs.AtomArray, bs.AtomArrayStack))
416
- and len(nonpoly_array) > 0
417
- ):
418
- nonpoly_coords[(comp_id, chain_id)] = nonpoly_array
419
-
420
- return nonpoly_coords
421
-
422
- def _parse_nonpoly_fallback(self) -> dict[tuple, bs.AtomArray]:
423
- """Fallback method to extract heteroatoms directly from structure."""
424
- nonpoly_coords = {}
425
-
426
- if not (self.structure and hasattr(self.structure, "chain_id")):
427
- return nonpoly_coords
428
-
429
- # Create set of standard residues from residue_constants
430
- standard_residues = set(residue_constants.resnames[:-1]) # Exclude 'UNK'
431
- standard_residues.update({"A", "C", "G", "T", "U"}) # Add nucleic acids
432
-
433
- if hasattr(self.structure, "chain_id") and self.structure.chain_id is not None:
434
- for chain_id in set(self.structure.chain_id):
435
- chain_structure = self.structure[self.structure.chain_id == chain_id]
436
-
437
- # Find non-standard residues
438
- if (
439
- hasattr(chain_structure, "res_name")
440
- and chain_structure.res_name is not None
441
- and hasattr(chain_structure.res_name, "__iter__")
442
- ):
443
- for res_name in set(chain_structure.res_name):
444
- if res_name not in standard_residues:
445
- res_mask = (chain_structure.chain_id == chain_id) & (
446
- chain_structure.res_name == res_name
447
- )
448
- if res_mask.any() and isinstance(
449
- chain_structure, (bs.AtomArray, bs.AtomArrayStack)
450
- ):
451
- nonpoly_array = chain_structure[res_mask]
452
- nonpoly_coords[(res_name, chain_id)] = nonpoly_array
453
-
454
- return nonpoly_coords
455
-
456
- @functools.cached_property
457
- def non_polymer_coords(self) -> dict[tuple, bs.AtomArray]:
458
- """
459
- Extract non-polymer coordinates (ligands, cofactors, etc.) from mmCIF structure.
460
-
461
- Returns a dictionary mapping (nonpolymer_info, chain_id) tuples to AtomArrays.
462
- """
463
- if not self.structure or not self.raw:
464
- return {}
465
-
466
- try:
467
- return self._parse_nonpoly_from_mmcif()
468
- except Exception:
469
- return self._parse_nonpoly_fallback()
 
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import io
5
+ import os
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from typing import Union
9
+
10
+ import biotite.structure as bs
11
+ import biotite.structure.io.pdbx as pdbx
12
+
13
+ from . import esmfold2_residue_constants as residue_constants
14
+
15
+ # Define PathOrBuffer for the opensource version
16
+ PathOrBuffer = Union[str, os.PathLike, io.StringIO]
17
+
18
+
19
+ class NoProteinError(Exception):
20
+ pass
21
+
22
+
23
+ @dataclass
24
+ class Residue:
25
+ residue_number: int | None = None
26
+ insertion_code: str = ""
27
+ hetflag: bool = False
28
+
29
+
30
+ @dataclass
31
+ class MmcifHeader:
32
+ release_date: datetime | None = None
33
+ resolution: float | None = None
34
+ structure_method: str = "UNKNOWN"
35
+
36
+
37
+ class MmcifWrapper:
38
+ def __init__(self, id: str | None = None):
39
+ self.id: str = id or ""
40
+ self.raw: pdbx.CIFFile | None = None
41
+ self.structure: bs.AtomArray
42
+ self.header: MmcifHeader = MmcifHeader()
43
+ self.entities: dict[int, list[str]] = {}
44
+ self.chain_to_seqres: dict[str, str] = {}
45
+ self.seqres_to_structure: dict[str, dict[int, Residue]] = {}
46
+
47
+ @classmethod
48
+ def read(cls, path: PathOrBuffer, id: str | None = None) -> MmcifWrapper:
49
+ obj = cls(id=id)
50
+ obj._load(path)
51
+ return obj
52
+
53
+ def _load(self, path: PathOrBuffer, fileid: str | None = None):
54
+ """Load mmCIF data from file."""
55
+ self.raw = pdbx.CIFFile.read(path)
56
+
57
+ self._parse_structure()
58
+ self._parse_header()
59
+ self._parse_entities()
60
+ self._parse_sequences()
61
+
62
+ def _parse_structure(self):
63
+ """Parse the atomic structure from mmCIF."""
64
+ try:
65
+ structure = pdbx.get_structure(self.raw, model=1)
66
+ if structure is None or not isinstance(structure, bs.AtomArray):
67
+ raise NoProteinError("No structure found in mmCIF file")
68
+ if len(structure) == 0:
69
+ raise NoProteinError("Empty structure in mmCIF file")
70
+ self.structure = structure
71
+ except Exception as e:
72
+ raise ValueError(f"Failed to parse structure: {e}")
73
+
74
+ def _parse_header(self):
75
+ """Parse header information from mmCIF."""
76
+ if not self.raw:
77
+ return
78
+
79
+ try:
80
+ # Get the first (and usually only) block
81
+ block = self.raw.block
82
+
83
+ # Parse release date
84
+ if "pdbx_database_status" in block:
85
+ status_cat = block["pdbx_database_status"]
86
+ if "recvd_initial_deposition_date" in status_cat:
87
+ date_str = status_cat["recvd_initial_deposition_date"].as_item()
88
+ if date_str and date_str != "?":
89
+ try:
90
+ self.header.release_date = datetime.strptime(
91
+ date_str, "%Y-%m-%d"
92
+ )
93
+ except ValueError:
94
+ pass
95
+
96
+ # Parse resolution
97
+ if "refine" in block:
98
+ refine_cat = block["refine"]
99
+ if "ls_d_res_high" in refine_cat:
100
+ res_str = refine_cat["ls_d_res_high"].as_item()
101
+ if res_str and res_str != "?":
102
+ try:
103
+ self.header.resolution = float(res_str)
104
+ except ValueError:
105
+ pass
106
+
107
+ # Parse structure method
108
+ if "exptl" in block:
109
+ exptl_cat = block["exptl"]
110
+ if "method" in exptl_cat:
111
+ method = exptl_cat["method"].as_item()
112
+ if method and method != "?":
113
+ self.header.structure_method = method.upper()
114
+
115
+ except Exception:
116
+ # If parsing fails, keep default values
117
+ pass
118
+
119
+ def _parse_entities(self):
120
+ """Parse entity information and map to chains."""
121
+ if not self.raw:
122
+ return
123
+
124
+ try:
125
+ block = self.raw.block
126
+
127
+ # Parse entity information
128
+ if "entity" in block:
129
+ entity_cat = block["entity"]
130
+ entity_ids = entity_cat["id"].as_array(str)
131
+ entity_types = entity_cat["type"].as_array(str)
132
+
133
+ # Initialize entities dict with all entities (not just polymers)
134
+ for i, (entity_id, entity_type) in enumerate(
135
+ zip(entity_ids, entity_types)
136
+ ):
137
+ self.entities[int(entity_id)] = []
138
+
139
+ # Map polymer chains to entities using entity_poly
140
+ if "entity_poly" in block:
141
+ poly_cat = block["entity_poly"]
142
+ entity_ids = poly_cat["entity_id"].as_array(str)
143
+ chain_lists = poly_cat["pdbx_strand_id"].as_array(str)
144
+
145
+ for entity_id, chain_list in zip(entity_ids, chain_lists):
146
+ entity_id = int(entity_id)
147
+ # Chain list is comma-separated
148
+ chains = [c.strip() for c in chain_list.split(",") if c.strip()]
149
+ if entity_id in self.entities:
150
+ self.entities[entity_id] = chains
151
+
152
+ # Map non-polymer chains using struct_asym for entities not covered by entity_poly
153
+ if "struct_asym" in block:
154
+ asym_cat = block["struct_asym"]
155
+ asym_ids = asym_cat["id"].as_array(str)
156
+ entity_ids = asym_cat["entity_id"].as_array(str)
157
+
158
+ for asym_id, entity_id in zip(asym_ids, entity_ids):
159
+ entity_id = int(entity_id)
160
+ # Only add if entity exists but has no chains yet (non-polymer entities)
161
+ if entity_id in self.entities and not self.entities[entity_id]:
162
+ self.entities[entity_id].append(asym_id)
163
+
164
+ except Exception:
165
+ # If parsing fails, try to infer from structure
166
+ if (
167
+ self.structure
168
+ and hasattr(self.structure, "chain_id")
169
+ and self.structure.chain_id is not None
170
+ and hasattr(self.structure.chain_id, "__iter__")
171
+ ):
172
+ chain_ids = list(set(self.structure.chain_id))
173
+ self.entities = {1: chain_ids}
174
+
175
+ def _parse_sequences(self):
176
+ """Parse sequence information from mmCIF."""
177
+ if not self.raw:
178
+ return
179
+
180
+ block = self.raw.block
181
+
182
+ # Parse polymer sequences
183
+ if "entity_poly" in block:
184
+ poly_cat = block["entity_poly"]
185
+ entity_ids = poly_cat["entity_id"].as_array(str)
186
+ sequences = poly_cat["pdbx_seq_one_letter_code_can"].as_array(str)
187
+ chain_lists = poly_cat["pdbx_strand_id"].as_array(str)
188
+
189
+ for entity_id, sequence, chain_list in zip(
190
+ entity_ids, sequences, chain_lists
191
+ ):
192
+ # Clean up sequence (remove whitespace and newlines)
193
+ clean_seq = "".join(sequence.split())
194
+ chains = [c.strip() for c in chain_list.split(",") if c.strip()]
195
+
196
+ for chain_id in chains:
197
+ self.chain_to_seqres[chain_id] = clean_seq
198
+
199
+ # Parse sequence to structure mapping
200
+ if "pdbx_poly_seq_scheme" in block:
201
+ seq_cat = block["pdbx_poly_seq_scheme"]
202
+ asym_ids = seq_cat["asym_id"].as_array(str) # Internal chain IDs
203
+ seq_positions = seq_cat["seq_id"].as_array(str)
204
+ auth_seq_nums = seq_cat["auth_seq_num"].as_array(str)
205
+ ins_codes = (
206
+ seq_cat["pdb_ins_code"].as_array(str)
207
+ if "pdb_ins_code" in seq_cat
208
+ else [""] * len(asym_ids)
209
+ )
210
+ hetflags = (
211
+ seq_cat["hetflag"].as_array(str)
212
+ if "hetflag" in seq_cat
213
+ else ["N"] * len(asym_ids)
214
+ )
215
+
216
+ # Get author chain IDs if available
217
+ auth_chain_ids = (
218
+ seq_cat["pdb_strand_id"].as_array(str)
219
+ if "pdb_strand_id" in seq_cat
220
+ else asym_ids # Fallback to internal IDs
221
+ )
222
+
223
+ # Build mapping from internal chain ID to author chain ID
224
+ asym_to_auth_mapping = {}
225
+ for asym_id, auth_id in zip(asym_ids, auth_chain_ids):
226
+ asym_to_auth_mapping[asym_id] = auth_id
227
+
228
+ # Group by internal chain ID first, then map to author chain ID
229
+ chain_data = {}
230
+ for asym_id, seq_pos, auth_seq, ins_code, hetflag in zip(
231
+ asym_ids, seq_positions, auth_seq_nums, ins_codes, hetflags
232
+ ):
233
+ if asym_id not in chain_data:
234
+ chain_data[asym_id] = {}
235
+
236
+ try:
237
+ seq_index = int(seq_pos) - 1 # Convert to 0-based indexing
238
+ res_num = int(auth_seq) if auth_seq != "?" else None
239
+ except ValueError:
240
+ continue
241
+
242
+ if res_num is not None:
243
+ # Convert mmCIF "." and "?" to empty string
244
+ clean_ins_code = "" if ins_code in [".", "?"] else ins_code
245
+ else:
246
+ clean_ins_code = ""
247
+ res_num = None
248
+
249
+ is_het = hetflag.upper() == "Y" # type: ignore
250
+ chain_data[asym_id][seq_index] = Residue(
251
+ residue_number=res_num,
252
+ insertion_code=clean_ins_code, # type: ignore
253
+ hetflag=is_het,
254
+ )
255
+
256
+ # Handle cases where multiple residues have the same auth_seq_num
257
+ # by adjusting residue numbers to be unique within each chain
258
+ for asym_id, residue_data in chain_data.items():
259
+ # Check if there are duplicate residue numbers in this chain
260
+ positions_with_same_num = {}
261
+ for seq_idx, res_at_pos in residue_data.items():
262
+ if res_at_pos.residue_number is not None:
263
+ res_num = res_at_pos.residue_number
264
+ if res_num not in positions_with_same_num:
265
+ positions_with_same_num[res_num] = []
266
+ positions_with_same_num[res_num].append(seq_idx)
267
+
268
+ # Fix duplicate residue numbers by making them sequential
269
+ for res_num, seq_indices in positions_with_same_num.items():
270
+ if len(seq_indices) > 1:
271
+ # Multiple residues have the same residue number
272
+ # Make them sequential starting from the original number
273
+ seq_indices.sort() # Ensure consistent ordering
274
+ for i, seq_idx in enumerate(seq_indices):
275
+ original_pos = residue_data[seq_idx]
276
+ new_pos = Residue(
277
+ residue_number=res_num + i,
278
+ insertion_code=original_pos.insertion_code,
279
+ hetflag=original_pos.hetflag,
280
+ )
281
+ residue_data[seq_idx] = new_pos
282
+
283
+ # Create ordered mappings using author chain IDs
284
+ for asym_id in chain_data:
285
+ auth_chain_id = asym_to_auth_mapping.get(asym_id, asym_id)
286
+ if auth_chain_id in self.chain_to_seqres:
287
+ seq_len = len(self.chain_to_seqres[auth_chain_id])
288
+ ordered_mapping = {}
289
+
290
+ for i in range(seq_len):
291
+ if i in chain_data[asym_id]:
292
+ ordered_mapping[i] = chain_data[asym_id][i]
293
+ else:
294
+ # Missing residue - no structure coordinates
295
+ ordered_mapping[i] = Residue(
296
+ residue_number=None, insertion_code="", hetflag=False
297
+ )
298
+
299
+ self.seqres_to_structure[auth_chain_id] = ordered_mapping
300
+ else:
301
+ # Handle case where auth_chain_id is not in chain_to_seqres
302
+ # This can happen if the chain is not a polymer or if there's a parsing issue
303
+ # Create a basic mapping based on the chain_data
304
+ if chain_data[asym_id]:
305
+ # Sort by sequence index to create ordered mapping
306
+ sorted_indices = sorted(chain_data[asym_id].keys())
307
+ ordered_mapping = {}
308
+ for i, seq_idx in enumerate(sorted_indices):
309
+ ordered_mapping[i] = chain_data[asym_id][seq_idx]
310
+ self.seqres_to_structure[auth_chain_id] = ordered_mapping
311
+
312
+ # Ensure all chains have complete mappings
313
+ for chain_id in self.chain_to_seqres:
314
+ if chain_id not in self.seqres_to_structure:
315
+ seq_len = len(self.chain_to_seqres[chain_id])
316
+ self.seqres_to_structure[chain_id] = {
317
+ i: Residue(residue_number=None, insertion_code="", hetflag=False)
318
+ for i in range(seq_len)
319
+ }
320
+ else:
321
+ # Fill in any missing indices
322
+ seq_len = len(self.chain_to_seqres[chain_id])
323
+ mapping = self.seqres_to_structure[chain_id]
324
+ for i in range(seq_len):
325
+ if i not in mapping:
326
+ mapping[i] = Residue(
327
+ residue_number=None, insertion_code="", hetflag=False
328
+ )
329
+
330
+ # Fallback: create basic mappings from structure for missing chains
331
+ if (
332
+ self.structure
333
+ and hasattr(self.structure, "chain_id")
334
+ and self.structure.chain_id is not None
335
+ and hasattr(self.structure.chain_id, "__iter__")
336
+ ):
337
+ for chain_id in set(self.structure.chain_id):
338
+ if chain_id not in self.seqres_to_structure:
339
+ chain_structure = self.structure[
340
+ self.structure.chain_id == chain_id
341
+ ]
342
+ if (
343
+ hasattr(chain_structure, "res_id")
344
+ and chain_structure.res_id is not None
345
+ and hasattr(chain_structure.res_id, "__iter__")
346
+ ):
347
+ residue_ids = list(set(chain_structure.res_id))
348
+ residue_ids.sort()
349
+
350
+ self.seqres_to_structure[chain_id] = {
351
+ i: Residue(
352
+ residue_number=res_id, insertion_code="", hetflag=False
353
+ )
354
+ for i, res_id in enumerate(residue_ids)
355
+ }
356
+
357
+ def _parse_nonpoly_from_mmcif(self) -> dict[tuple, bs.AtomArray]:
358
+ """Parse non-polymer coordinates from mmCIF block data."""
359
+ nonpoly_coords = {}
360
+
361
+ # Get non-polymer entities from the mmCIF block
362
+ assert self.raw is not None
363
+ block = self.raw.block
364
+ nonpoly_entities = set()
365
+
366
+ # Find non-polymer entities
367
+ if "entity" in block:
368
+ entity_cat = block["entity"]
369
+ entity_ids = entity_cat["id"].as_array(str)
370
+ entity_types = entity_cat["type"].as_array(str)
371
+
372
+ for entity_id, entity_type in zip(entity_ids, entity_types):
373
+ if entity_type.upper() in ["NON-POLYMER", "WATER", "BRANCHED"]:
374
+ nonpoly_entities.add(entity_id)
375
+
376
+ # Map entities to chains for non-polymers
377
+ entity_to_chains = {}
378
+ if "pdbx_entity_nonpoly" in block:
379
+ nonpoly_cat = block["pdbx_entity_nonpoly"]
380
+ entity_ids = nonpoly_cat["entity_id"].as_array(str)
381
+ comp_ids = nonpoly_cat["comp_id"].as_array(str)
382
+
383
+ for entity_id, comp_id in zip(entity_ids, comp_ids):
384
+ if entity_id in nonpoly_entities:
385
+ entity_to_chains[entity_id] = comp_id
386
+
387
+ # Get atom site information for non-polymers
388
+ if "atom_site" in block:
389
+ atom_cat = block["atom_site"]
390
+ atom_chain_ids = atom_cat["label_asym_id"].as_array(str)
391
+ atom_entity_ids = atom_cat["label_entity_id"].as_array(str)
392
+ atom_comp_ids = atom_cat["label_comp_id"].as_array(str)
393
+
394
+ # Group non-polymer atoms by entity and chain
395
+ nonpoly_atom_groups = {}
396
+ for i, (chain_id, entity_id, comp_id) in enumerate(
397
+ zip(atom_chain_ids, atom_entity_ids, atom_comp_ids)
398
+ ):
399
+ if entity_id in nonpoly_entities:
400
+ key = (comp_id, chain_id)
401
+ if key not in nonpoly_atom_groups:
402
+ nonpoly_atom_groups[key] = []
403
+ nonpoly_atom_groups[key].append(i)
404
+
405
+ # Extract coordinates for each non-polymer group
406
+ for (comp_id, chain_id), atom_indices in nonpoly_atom_groups.items():
407
+ # Match atoms by comparing chain_id and residue name
408
+ structure_mask = (self.structure.chain_id == chain_id) & (
409
+ self.structure.res_name == comp_id
410
+ )
411
+
412
+ if structure_mask.any():
413
+ nonpoly_array = self.structure[structure_mask]
414
+ if (
415
+ isinstance(nonpoly_array, (bs.AtomArray, bs.AtomArrayStack))
416
+ and len(nonpoly_array) > 0
417
+ ):
418
+ nonpoly_coords[(comp_id, chain_id)] = nonpoly_array
419
+
420
+ return nonpoly_coords
421
+
422
+ def _parse_nonpoly_fallback(self) -> dict[tuple, bs.AtomArray]:
423
+ """Fallback method to extract heteroatoms directly from structure."""
424
+ nonpoly_coords = {}
425
+
426
+ if not (self.structure and hasattr(self.structure, "chain_id")):
427
+ return nonpoly_coords
428
+
429
+ # Create set of standard residues from residue_constants
430
+ standard_residues = set(residue_constants.resnames[:-1]) # Exclude 'UNK'
431
+ standard_residues.update({"A", "C", "G", "T", "U"}) # Add nucleic acids
432
+
433
+ if hasattr(self.structure, "chain_id") and self.structure.chain_id is not None:
434
+ for chain_id in set(self.structure.chain_id):
435
+ chain_structure = self.structure[self.structure.chain_id == chain_id]
436
+
437
+ # Find non-standard residues
438
+ if (
439
+ hasattr(chain_structure, "res_name")
440
+ and chain_structure.res_name is not None
441
+ and hasattr(chain_structure.res_name, "__iter__")
442
+ ):
443
+ for res_name in set(chain_structure.res_name):
444
+ if res_name not in standard_residues:
445
+ res_mask = (chain_structure.chain_id == chain_id) & (
446
+ chain_structure.res_name == res_name
447
+ )
448
+ if res_mask.any() and isinstance(
449
+ chain_structure, (bs.AtomArray, bs.AtomArrayStack)
450
+ ):
451
+ nonpoly_array = chain_structure[res_mask]
452
+ nonpoly_coords[(res_name, chain_id)] = nonpoly_array
453
+
454
+ return nonpoly_coords
455
+
456
+ @functools.cached_property
457
+ def non_polymer_coords(self) -> dict[tuple, bs.AtomArray]:
458
+ """
459
+ Extract non-polymer coordinates (ligands, cofactors, etc.) from mmCIF structure.
460
+
461
+ Returns a dictionary mapping (nonpolymer_info, chain_id) tuples to AtomArrays.
462
+ """
463
+ if not self.structure or not self.raw:
464
+ return {}
465
+
466
+ try:
467
+ return self._parse_nonpoly_from_mmcif()
468
+ except Exception:
469
+ return self._parse_nonpoly_fallback()
esmfold2_molecular_complex.py CHANGED
The diff for this file is too large to render. See raw diff
 
esmfold2_msa.py CHANGED
@@ -1,506 +1,506 @@
1
- from __future__ import annotations
2
-
3
- import dataclasses
4
- import string
5
- from dataclasses import dataclass
6
- from functools import cached_property
7
- from itertools import islice
8
- from typing import Sequence
9
-
10
- import numpy as np
11
- from Bio import SeqIO
12
- from scipy.spatial.distance import cdist
13
-
14
- from .esmfold2_misc import slice_any_object
15
- from .esmfold2_msa_filter_sequences import greedy_select_indices, hhfilter
16
- from .esmfold2_parsing import FastaEntry, read_sequences, write_sequences
17
- from .esmfold2_sequential_dataclass import SequentialDataclass
18
- from .esmfold2_system import PathOrBuffer
19
-
20
- REMOVE_LOWERCASE_TRANSLATION = str.maketrans(dict.fromkeys(string.ascii_lowercase))
21
-
22
-
23
- def remove_insertions_from_sequence(seq: str) -> str:
24
- return seq.translate(REMOVE_LOWERCASE_TRANSLATION)
25
-
26
-
27
- @dataclass(frozen=True)
28
- class MSA(SequentialDataclass):
29
- """Object-oriented interface to an MSA.
30
-
31
- Args:
32
- sequences (list[str]): List of protein sequences
33
- headers (list[str]): List of headers describing the sequences
34
-
35
- """
36
-
37
- entries: list[FastaEntry]
38
-
39
- @cached_property
40
- def sequences(self) -> list[str]:
41
- return [entry.sequence for entry in self.entries]
42
-
43
- @cached_property
44
- def headers(self) -> list[str]:
45
- return [entry.header for entry in self.entries]
46
-
47
- def __repr__(self):
48
- return (
49
- f"MSA({self.entries[0].header}: Depth={self.depth}, Length={self.seqlen})"
50
- )
51
-
52
- def to_fast_msa(self) -> FastMSA:
53
- return FastMSA(self.array, self.headers)
54
-
55
- @classmethod
56
- def from_a3m(
57
- cls,
58
- path: PathOrBuffer,
59
- remove_insertions: bool = True,
60
- max_sequences: int | None = None,
61
- ) -> MSA:
62
- entries = []
63
- for header, seq in islice(read_sequences(path), max_sequences):
64
- if remove_insertions:
65
- seq = remove_insertions_from_sequence(seq)
66
- if entries:
67
- assert (
68
- len(seq) == len(entries[0].sequence)
69
- ), f"Sequence length mismatch. Expected: {len(entries[0].sequence)}, Received: {len(seq)}"
70
- entries.append(FastaEntry(header, seq))
71
- return cls(entries)
72
-
73
- def to_a3m(self, path: PathOrBuffer) -> None:
74
- write_sequences(self.entries, path)
75
-
76
- @classmethod
77
- def from_stockholm(
78
- cls,
79
- path: PathOrBuffer,
80
- remove_insertions: bool = True,
81
- max_sequences: int | None = None,
82
- ) -> MSA:
83
- entries = []
84
- for record in islice(SeqIO.parse(path, "stockholm"), max_sequences):
85
- header = f"{record.id} {record.description}"
86
- seq = str(record.seq)
87
- if entries:
88
- assert (
89
- len(seq) == len(entries[0].sequence)
90
- ), f"Sequence length mismatch. Expected: {len(entries[0].sequence)}, Received: {len(seq)}"
91
- entries.append(FastaEntry(header, seq))
92
- msa = cls(entries)
93
- if remove_insertions:
94
- keep_inds = [i for i, aa in enumerate(msa.query) if aa != "-"]
95
- msa = msa.select_positions(keep_inds)
96
- return msa
97
-
98
- def to_bytes(self) -> bytes:
99
- version = 1
100
- version_bytes = version.to_bytes(1, "little")
101
- seqlen_bytes = self.seqlen.to_bytes(4, "little")
102
- depth_bytes = self.depth.to_bytes(4, "little")
103
- array_bytes = self.array.tobytes()
104
- header_bytes = "\n".join(entry.header for entry in self.entries).encode()
105
- all_bytes = (
106
- version_bytes + seqlen_bytes + depth_bytes + array_bytes + header_bytes
107
- )
108
- return all_bytes
109
-
110
- @classmethod
111
- def from_bytes(cls, data: bytes) -> MSA:
112
- version_bytes, seqlen_bytes, depth_bytes, data = (
113
- data[:1],
114
- data[1:5],
115
- data[5:9],
116
- data[9:],
117
- )
118
- version = int.from_bytes(version_bytes, "little")
119
- if version != 1:
120
- raise ValueError(f"Unsupported version: {version}")
121
- seqlen = int.from_bytes(seqlen_bytes, "little")
122
- depth = int.from_bytes(depth_bytes, "little")
123
- array_bytes, header_bytes = data[: seqlen * depth], data[seqlen * depth :]
124
- array = np.frombuffer(array_bytes, dtype="|S1")
125
- array = array.reshape(depth, seqlen)
126
- headers = header_bytes.decode().split("\n")
127
- # Sometimes the separation is two newlines, which results in an empty header.
128
- headers = [header for header in headers if header]
129
- # If all headers were empty (e.g., saved from from_sequences), use empty headers
130
- if len(headers) == 0 and depth > 0:
131
- headers = [""] * depth
132
- entries = [
133
- FastaEntry(header, b"".join(row).decode())
134
- for header, row in zip(headers, array)
135
- ]
136
- return cls(entries)
137
-
138
- # TODO(jmaccarl): set remove_insertions to True by default here to match other utils
139
- @classmethod
140
- def from_sequences(
141
- cls, sequences: list[str], remove_insertions: bool = False
142
- ) -> MSA:
143
- if remove_insertions:
144
- entries = [
145
- FastaEntry("", remove_insertions_from_sequence(seq))
146
- for seq in sequences
147
- ]
148
- else:
149
- entries = [FastaEntry("", seq) for seq in sequences]
150
- return cls(entries)
151
-
152
- def to_sequence_bytes(self) -> bytes:
153
- """Stores ONLY SEQUENCES in array format as bytes. Header information will be lost."""
154
- seqlen_bytes = self.seqlen.to_bytes(4, "little")
155
- array_bytes = self.array.tobytes()
156
- all_bytes = seqlen_bytes + array_bytes
157
- return all_bytes
158
-
159
- @classmethod
160
- def from_sequence_bytes(cls, data: bytes) -> MSA:
161
- seqlen_bytes, array_bytes = data[:4], data[4:]
162
- seqlen = int.from_bytes(seqlen_bytes, "little")
163
- array = np.frombuffer(array_bytes, dtype="|S1")
164
- array = array.reshape(-1, seqlen)
165
- entries = [FastaEntry("", b"".join(row).decode()) for row in array]
166
- return cls(entries)
167
-
168
- @property
169
- def depth(self) -> int:
170
- return len(self.entries)
171
-
172
- @property
173
- def seqlen(self) -> int:
174
- return len(self.entries[0].sequence)
175
-
176
- @cached_property
177
- def array(self) -> np.ndarray:
178
- return np.array([list(seq) for seq in self.sequences], dtype="|S1")
179
-
180
- @property
181
- def query(self) -> str:
182
- return self.entries[0].sequence
183
-
184
- def select_sequences(self, indices: Sequence[int] | np.ndarray) -> MSA:
185
- """Subselect rows of the MSA."""
186
- entries = [self.entries[idx] for idx in indices]
187
- return dataclasses.replace(self, entries=entries)
188
-
189
- def select_positions(self, indices: Sequence[int] | np.ndarray) -> MSA:
190
- """Subselect columns of the MSA."""
191
- entries = [
192
- FastaEntry(header, "".join(seq[idx] for idx in indices))
193
- for header, seq in self.entries
194
- ]
195
- return dataclasses.replace(self, entries=entries)
196
-
197
- def __getitem__(self, indices: int | list[int] | slice | np.ndarray):
198
- if isinstance(indices, int):
199
- indices = [indices]
200
-
201
- entries = [
202
- FastaEntry(header, slice_any_object(seq, indices))
203
- for header, seq in self.entries
204
- ]
205
- return dataclasses.replace(self, entries=entries)
206
-
207
- def __len__(self):
208
- return self.seqlen
209
-
210
- def greedy_select(self, num_seqs: int, mode: str = "max") -> MSA:
211
- """Greedily select sequences that either maximize or minimize hamming distance.
212
-
213
- Algorithm proposed in the MSA Transformer paper. Starting from the query sequence,
214
- iteratively add sequences to the list with the maximum (minimum) average Hamming
215
- distance to the existing set of sequences.
216
-
217
- Args:
218
- num_seqs (int): Number of sequences to select.
219
- mode (str): Whether to maximize or minimize diversity. DO NOT pick 'min' unless
220
- you're doing it to prove a point for a paper.
221
-
222
- Returns:
223
- MSA object w/ subselected sequences.
224
- """
225
- assert mode in ("max", "min")
226
- if self.depth <= num_seqs:
227
- return self
228
-
229
- indices = greedy_select_indices(self.array, num_seqs, mode)
230
- return self.select_sequences(indices)
231
-
232
- def hhfilter(
233
- self,
234
- seqid: int = 90,
235
- diff: int = 0,
236
- cov: int = 0,
237
- qid: int = 0,
238
- qsc: float = -20.0,
239
- binary: str = "hhfilter",
240
- ) -> MSA:
241
- """Apply hhfilter to the sequences in the MSA and return a filtered MSA."""
242
-
243
- indices = hhfilter(
244
- self.sequences,
245
- seqid=seqid,
246
- diff=diff,
247
- cov=cov,
248
- qid=qid,
249
- qsc=qsc,
250
- binary=binary,
251
- )
252
- return self.select_sequences(indices)
253
-
254
- def select_random_sequences(self, num_seqs: int) -> MSA:
255
- """Uses random sampling to subselect sequences from the MSA. Always
256
- keeps the query sequence.
257
- """
258
- if num_seqs >= self.depth:
259
- return self
260
-
261
- # Subselect random, always keeping the query sequence.
262
- indices = np.sort(
263
- np.append(
264
- 0, np.random.choice(self.depth - 1, num_seqs - 1, replace=False) + 1
265
- )
266
- )
267
- msa = self.select_sequences(indices) # type: ignore
268
- return msa
269
-
270
- def select_diverse_sequences(self, num_seqs: int) -> MSA:
271
- """Applies hhfilter to select ~num_seqs sequences, then uses random sampling
272
- to subselect if necessary.
273
- """
274
- if num_seqs >= self.depth:
275
- return self
276
-
277
- msa = self.hhfilter(diff=num_seqs)
278
- if num_seqs < msa.depth:
279
- msa = msa.select_random_sequences(num_seqs)
280
- return msa
281
-
282
- def pad_to_depth(self, depth: int) -> MSA:
283
- if depth < self.depth:
284
- raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
285
- elif depth == self.depth:
286
- return self
287
-
288
- num_to_add = depth - self.depth
289
- extra_entries = [FastaEntry("", "-" * self.seqlen) for _ in range(num_to_add)]
290
- return dataclasses.replace(self, entries=self.entries + extra_entries)
291
-
292
- @classmethod
293
- def stack(
294
- cls, msas: Sequence[MSA], remove_query_from_later_msas: bool = True
295
- ) -> MSA:
296
- """Stack a series of MSAs. Optionally remove the query from msas after the first."""
297
- all_entries = []
298
- for i, msa in enumerate(msas):
299
- entries = msa.entries
300
- if i > 0 and remove_query_from_later_msas:
301
- entries = entries[1:]
302
- all_entries.extend(entries)
303
- return cls(entries=all_entries)
304
-
305
- @cached_property
306
- def seqid(self) -> np.ndarray:
307
- array = self.array.view(np.uint8)
308
- seqid = 1 - cdist(array[0][None], array, "hamming")
309
- return seqid[0]
310
-
311
- @classmethod
312
- def concat(
313
- cls,
314
- msas: Sequence[MSA],
315
- join_token: str | None = "|",
316
- allow_depth_mismatch: bool = False,
317
- ) -> MSA:
318
- """Concatenate a series of MSAs horizontally, along the sequence dimension."""
319
- if not msas:
320
- raise ValueError("Cannot concatenate an empty list of MSAs")
321
- msa_depths = [msa.depth for msa in msas]
322
- if len(set(msa_depths)) != 1:
323
- if not allow_depth_mismatch:
324
- raise ValueError("Depth mismatch in concatenating MSAs")
325
- else:
326
- max_depth = max(msa_depths)
327
- msas = [msa.pad_to_depth(max_depth) for msa in msas]
328
- headers = [
329
- "|".join([str(h) for h in headers])
330
- for headers in zip(*(msa.headers for msa in msas))
331
- ]
332
-
333
- if join_token is None:
334
- join_token = ""
335
-
336
- seqs = [join_token.join(vals) for vals in zip(*(msa.sequences for msa in msas))]
337
- entries = [FastaEntry(header, seq) for header, seq in zip(headers, seqs)]
338
- return cls(entries)
339
-
340
-
341
- @dataclass(frozen=True)
342
- class FastMSA(SequentialDataclass):
343
- """Object-oriented interface to an MSA stored as a numpy uint8 array."""
344
-
345
- array: np.ndarray
346
- headers: list[str] | None = None
347
-
348
- def __post_init__(self):
349
- if self.headers is not None:
350
- assert (
351
- len(self.headers) == self.depth
352
- ), "Number of headers must match depth."
353
-
354
- @classmethod
355
- def from_bytes(cls, data: bytes) -> FastMSA:
356
- version_bytes, seqlen_bytes, depth_bytes, data = (
357
- data[:1],
358
- data[1:5],
359
- data[5:9],
360
- data[9:],
361
- )
362
- version = int.from_bytes(version_bytes, "little")
363
- if version != 1:
364
- raise ValueError(f"Unsupported version: {version}")
365
- seqlen = int.from_bytes(seqlen_bytes, "little")
366
- depth = int.from_bytes(depth_bytes, "little")
367
- array_bytes, header_bytes = data[: seqlen * depth], data[seqlen * depth :]
368
- array = np.frombuffer(array_bytes, dtype="|S1")
369
- array = array.reshape(depth, seqlen)
370
- headers = header_bytes.decode().split("\n")
371
- # Sometimes the separation is two newlines, which results in an empty header.
372
- headers = [header for header in headers if header]
373
- # If all headers were empty (e.g., saved from from_sequences), use empty headers
374
- if len(headers) == 0 and depth > 0:
375
- headers = [""] * depth
376
- return cls(array, headers)
377
-
378
- @classmethod
379
- def from_sequence_bytes(cls, data: bytes) -> FastMSA:
380
- seqlen_bytes, array_bytes = data[:4], data[4:]
381
- seqlen = int.from_bytes(seqlen_bytes, "little")
382
- array = np.frombuffer(array_bytes, dtype="|S1")
383
- array = array.reshape(-1, seqlen)
384
- return cls(array)
385
-
386
- @property
387
- def depth(self) -> int:
388
- return self.array.shape[0]
389
-
390
- @property
391
- def seqlen(self) -> int:
392
- return self.array.shape[1]
393
-
394
- def __len__(self):
395
- return self.seqlen
396
-
397
- def __getitem__(self, indices: int | list[int] | slice | np.ndarray):
398
- if isinstance(indices, int):
399
- indices = [indices]
400
-
401
- return dataclasses.replace(self, array=self.array[:, indices])
402
-
403
- def select_sequences(self, indices: Sequence[int] | np.ndarray) -> FastMSA:
404
- """Subselect rows of the MSA."""
405
- array = self.array[indices]
406
- headers = (
407
- [self.headers[idx] for idx in indices] if self.headers is not None else None
408
- )
409
- return dataclasses.replace(self, array=array, headers=headers)
410
-
411
- def select_random_sequences(self, num_seqs: int) -> FastMSA:
412
- """Uses random sampling to subselect sequences from the MSA. Always
413
- keeps the query sequence.
414
- """
415
- if num_seqs >= self.depth:
416
- return self
417
-
418
- # Subselect random, always keeping the query sequence.
419
- indices = np.sort(
420
- np.append(
421
- 0, np.random.choice(self.depth - 1, num_seqs - 1, replace=False) + 1
422
- )
423
- )
424
- msa = self.select_sequences(indices) # type: ignore
425
- return msa
426
-
427
- def pad_to_depth(self, depth: int) -> FastMSA:
428
- if depth < self.depth:
429
- raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
430
- elif depth == self.depth:
431
- return self
432
-
433
- num_to_add = depth - self.depth
434
- array = np.pad(
435
- self.array,
436
- [(0, num_to_add), (0, 0)],
437
- constant_values=ord("-") if self.array.dtype == np.uint8 else b"-",
438
- )
439
- headers = self.headers
440
- if headers is not None:
441
- headers = headers + [""] * num_to_add
442
- return dataclasses.replace(self, array=array, headers=headers)
443
-
444
- @classmethod
445
- def concat(
446
- cls,
447
- msas: Sequence[FastMSA],
448
- join_token: str | None = None,
449
- allow_depth_mismatch: bool = False,
450
- ) -> FastMSA:
451
- """Concatenate a series of MSAs horizontally, along the sequence dimension."""
452
- if not msas:
453
- raise ValueError("Cannot concatenate an empty list of MSAs")
454
- if join_token is not None and join_token != "":
455
- raise NotImplementedError("join_token is not supported for FastMSA")
456
-
457
- msa_depths = [msa.depth for msa in msas]
458
- if len(set(msa_depths)) != 1:
459
- if not allow_depth_mismatch:
460
- raise ValueError("Depth mismatch in concatenating MSAs")
461
- else:
462
- max_depth = max(msa_depths)
463
- msas = [msa.pad_to_depth(max_depth) for msa in msas]
464
- headers = [
465
- "|".join([str(h) for h in headers])
466
- for headers in zip(
467
- *(
468
- msa.headers if msa.headers is not None else [""] * msa.depth
469
- for msa in msas
470
- )
471
- )
472
- ]
473
-
474
- array = np.concatenate([msa.array for msa in msas], axis=1)
475
- return cls(array, headers)
476
-
477
- def to_msa(self) -> MSA:
478
- headers = (
479
- self.headers
480
- if self.headers is not None
481
- else [f"seq{i}" for i in range(self.depth)]
482
- )
483
- entries = [
484
- FastaEntry(header, b"".join(row).decode())
485
- for header, row in zip(headers, self.array)
486
- ]
487
- return MSA(entries)
488
-
489
- @classmethod
490
- def stack(
491
- cls, msas: Sequence[FastMSA], remove_query_from_later_msas: bool = True
492
- ) -> FastMSA:
493
- """Stack a series of MSAs. Optionally remove the query from msas after the first."""
494
- arrays = []
495
- all_headers = []
496
- for i, msa in enumerate(msas):
497
- array = msa.array
498
- headers = msa.headers
499
- if i > 0 and remove_query_from_later_msas:
500
- array = array[1:]
501
- if headers is not None:
502
- headers = headers[1:]
503
- arrays.append(array)
504
- if headers is not None:
505
- all_headers.extend(headers)
506
- return cls(np.concatenate(arrays, axis=0), all_headers)
 
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import string
5
+ from dataclasses import dataclass
6
+ from functools import cached_property
7
+ from itertools import islice
8
+ from typing import Sequence
9
+
10
+ import numpy as np
11
+ from Bio import SeqIO
12
+ from scipy.spatial.distance import cdist
13
+
14
+ from .esmfold2_misc import slice_any_object
15
+ from .esmfold2_msa_filter_sequences import greedy_select_indices, hhfilter
16
+ from .esmfold2_parsing import FastaEntry, read_sequences, write_sequences
17
+ from .esmfold2_sequential_dataclass import SequentialDataclass
18
+ from .esmfold2_system import PathOrBuffer
19
+
20
+ REMOVE_LOWERCASE_TRANSLATION = str.maketrans(dict.fromkeys(string.ascii_lowercase))
21
+
22
+
23
+ def remove_insertions_from_sequence(seq: str) -> str:
24
+ return seq.translate(REMOVE_LOWERCASE_TRANSLATION)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class MSA(SequentialDataclass):
29
+ """Object-oriented interface to an MSA.
30
+
31
+ Args:
32
+ sequences (list[str]): List of protein sequences
33
+ headers (list[str]): List of headers describing the sequences
34
+
35
+ """
36
+
37
+ entries: list[FastaEntry]
38
+
39
+ @cached_property
40
+ def sequences(self) -> list[str]:
41
+ return [entry.sequence for entry in self.entries]
42
+
43
+ @cached_property
44
+ def headers(self) -> list[str]:
45
+ return [entry.header for entry in self.entries]
46
+
47
+ def __repr__(self):
48
+ return (
49
+ f"MSA({self.entries[0].header}: Depth={self.depth}, Length={self.seqlen})"
50
+ )
51
+
52
+ def to_fast_msa(self) -> FastMSA:
53
+ return FastMSA(self.array, self.headers)
54
+
55
+ @classmethod
56
+ def from_a3m(
57
+ cls,
58
+ path: PathOrBuffer,
59
+ remove_insertions: bool = True,
60
+ max_sequences: int | None = None,
61
+ ) -> MSA:
62
+ entries = []
63
+ for header, seq in islice(read_sequences(path), max_sequences):
64
+ if remove_insertions:
65
+ seq = remove_insertions_from_sequence(seq)
66
+ if entries:
67
+ assert (
68
+ len(seq) == len(entries[0].sequence)
69
+ ), f"Sequence length mismatch. Expected: {len(entries[0].sequence)}, Received: {len(seq)}"
70
+ entries.append(FastaEntry(header, seq))
71
+ return cls(entries)
72
+
73
+ def to_a3m(self, path: PathOrBuffer) -> None:
74
+ write_sequences(self.entries, path)
75
+
76
+ @classmethod
77
+ def from_stockholm(
78
+ cls,
79
+ path: PathOrBuffer,
80
+ remove_insertions: bool = True,
81
+ max_sequences: int | None = None,
82
+ ) -> MSA:
83
+ entries = []
84
+ for record in islice(SeqIO.parse(path, "stockholm"), max_sequences):
85
+ header = f"{record.id} {record.description}"
86
+ seq = str(record.seq)
87
+ if entries:
88
+ assert (
89
+ len(seq) == len(entries[0].sequence)
90
+ ), f"Sequence length mismatch. Expected: {len(entries[0].sequence)}, Received: {len(seq)}"
91
+ entries.append(FastaEntry(header, seq))
92
+ msa = cls(entries)
93
+ if remove_insertions:
94
+ keep_inds = [i for i, aa in enumerate(msa.query) if aa != "-"]
95
+ msa = msa.select_positions(keep_inds)
96
+ return msa
97
+
98
+ def to_bytes(self) -> bytes:
99
+ version = 1
100
+ version_bytes = version.to_bytes(1, "little")
101
+ seqlen_bytes = self.seqlen.to_bytes(4, "little")
102
+ depth_bytes = self.depth.to_bytes(4, "little")
103
+ array_bytes = self.array.tobytes()
104
+ header_bytes = "\n".join(entry.header for entry in self.entries).encode()
105
+ all_bytes = (
106
+ version_bytes + seqlen_bytes + depth_bytes + array_bytes + header_bytes
107
+ )
108
+ return all_bytes
109
+
110
+ @classmethod
111
+ def from_bytes(cls, data: bytes) -> MSA:
112
+ version_bytes, seqlen_bytes, depth_bytes, data = (
113
+ data[:1],
114
+ data[1:5],
115
+ data[5:9],
116
+ data[9:],
117
+ )
118
+ version = int.from_bytes(version_bytes, "little")
119
+ if version != 1:
120
+ raise ValueError(f"Unsupported version: {version}")
121
+ seqlen = int.from_bytes(seqlen_bytes, "little")
122
+ depth = int.from_bytes(depth_bytes, "little")
123
+ array_bytes, header_bytes = data[: seqlen * depth], data[seqlen * depth :]
124
+ array = np.frombuffer(array_bytes, dtype="|S1")
125
+ array = array.reshape(depth, seqlen)
126
+ headers = header_bytes.decode().split("\n")
127
+ # Sometimes the separation is two newlines, which results in an empty header.
128
+ headers = [header for header in headers if header]
129
+ # If all headers were empty (e.g., saved from from_sequences), use empty headers
130
+ if len(headers) == 0 and depth > 0:
131
+ headers = [""] * depth
132
+ entries = [
133
+ FastaEntry(header, b"".join(row).decode())
134
+ for header, row in zip(headers, array)
135
+ ]
136
+ return cls(entries)
137
+
138
+ # TODO(jmaccarl): set remove_insertions to True by default here to match other utils
139
+ @classmethod
140
+ def from_sequences(
141
+ cls, sequences: list[str], remove_insertions: bool = False
142
+ ) -> MSA:
143
+ if remove_insertions:
144
+ entries = [
145
+ FastaEntry("", remove_insertions_from_sequence(seq))
146
+ for seq in sequences
147
+ ]
148
+ else:
149
+ entries = [FastaEntry("", seq) for seq in sequences]
150
+ return cls(entries)
151
+
152
+ def to_sequence_bytes(self) -> bytes:
153
+ """Stores ONLY SEQUENCES in array format as bytes. Header information will be lost."""
154
+ seqlen_bytes = self.seqlen.to_bytes(4, "little")
155
+ array_bytes = self.array.tobytes()
156
+ all_bytes = seqlen_bytes + array_bytes
157
+ return all_bytes
158
+
159
+ @classmethod
160
+ def from_sequence_bytes(cls, data: bytes) -> MSA:
161
+ seqlen_bytes, array_bytes = data[:4], data[4:]
162
+ seqlen = int.from_bytes(seqlen_bytes, "little")
163
+ array = np.frombuffer(array_bytes, dtype="|S1")
164
+ array = array.reshape(-1, seqlen)
165
+ entries = [FastaEntry("", b"".join(row).decode()) for row in array]
166
+ return cls(entries)
167
+
168
+ @property
169
+ def depth(self) -> int:
170
+ return len(self.entries)
171
+
172
+ @property
173
+ def seqlen(self) -> int:
174
+ return len(self.entries[0].sequence)
175
+
176
+ @cached_property
177
+ def array(self) -> np.ndarray:
178
+ return np.array([list(seq) for seq in self.sequences], dtype="|S1")
179
+
180
+ @property
181
+ def query(self) -> str:
182
+ return self.entries[0].sequence
183
+
184
+ def select_sequences(self, indices: Sequence[int] | np.ndarray) -> MSA:
185
+ """Subselect rows of the MSA."""
186
+ entries = [self.entries[idx] for idx in indices]
187
+ return dataclasses.replace(self, entries=entries)
188
+
189
+ def select_positions(self, indices: Sequence[int] | np.ndarray) -> MSA:
190
+ """Subselect columns of the MSA."""
191
+ entries = [
192
+ FastaEntry(header, "".join(seq[idx] for idx in indices))
193
+ for header, seq in self.entries
194
+ ]
195
+ return dataclasses.replace(self, entries=entries)
196
+
197
+ def __getitem__(self, indices: int | list[int] | slice | np.ndarray):
198
+ if isinstance(indices, int):
199
+ indices = [indices]
200
+
201
+ entries = [
202
+ FastaEntry(header, slice_any_object(seq, indices))
203
+ for header, seq in self.entries
204
+ ]
205
+ return dataclasses.replace(self, entries=entries)
206
+
207
+ def __len__(self):
208
+ return self.seqlen
209
+
210
+ def greedy_select(self, num_seqs: int, mode: str = "max") -> MSA:
211
+ """Greedily select sequences that either maximize or minimize hamming distance.
212
+
213
+ Algorithm proposed in the MSA Transformer paper. Starting from the query sequence,
214
+ iteratively add sequences to the list with the maximum (minimum) average Hamming
215
+ distance to the existing set of sequences.
216
+
217
+ Args:
218
+ num_seqs (int): Number of sequences to select.
219
+ mode (str): Whether to maximize or minimize diversity. DO NOT pick 'min' unless
220
+ you're doing it to prove a point for a paper.
221
+
222
+ Returns:
223
+ MSA object w/ subselected sequences.
224
+ """
225
+ assert mode in ("max", "min")
226
+ if self.depth <= num_seqs:
227
+ return self
228
+
229
+ indices = greedy_select_indices(self.array, num_seqs, mode)
230
+ return self.select_sequences(indices)
231
+
232
+ def hhfilter(
233
+ self,
234
+ seqid: int = 90,
235
+ diff: int = 0,
236
+ cov: int = 0,
237
+ qid: int = 0,
238
+ qsc: float = -20.0,
239
+ binary: str = "hhfilter",
240
+ ) -> MSA:
241
+ """Apply hhfilter to the sequences in the MSA and return a filtered MSA."""
242
+
243
+ indices = hhfilter(
244
+ self.sequences,
245
+ seqid=seqid,
246
+ diff=diff,
247
+ cov=cov,
248
+ qid=qid,
249
+ qsc=qsc,
250
+ binary=binary,
251
+ )
252
+ return self.select_sequences(indices)
253
+
254
+ def select_random_sequences(self, num_seqs: int) -> MSA:
255
+ """Uses random sampling to subselect sequences from the MSA. Always
256
+ keeps the query sequence.
257
+ """
258
+ if num_seqs >= self.depth:
259
+ return self
260
+
261
+ # Subselect random, always keeping the query sequence.
262
+ indices = np.sort(
263
+ np.append(
264
+ 0, np.random.choice(self.depth - 1, num_seqs - 1, replace=False) + 1
265
+ )
266
+ )
267
+ msa = self.select_sequences(indices) # type: ignore
268
+ return msa
269
+
270
+ def select_diverse_sequences(self, num_seqs: int) -> MSA:
271
+ """Applies hhfilter to select ~num_seqs sequences, then uses random sampling
272
+ to subselect if necessary.
273
+ """
274
+ if num_seqs >= self.depth:
275
+ return self
276
+
277
+ msa = self.hhfilter(diff=num_seqs)
278
+ if num_seqs < msa.depth:
279
+ msa = msa.select_random_sequences(num_seqs)
280
+ return msa
281
+
282
+ def pad_to_depth(self, depth: int) -> MSA:
283
+ if depth < self.depth:
284
+ raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
285
+ elif depth == self.depth:
286
+ return self
287
+
288
+ num_to_add = depth - self.depth
289
+ extra_entries = [FastaEntry("", "-" * self.seqlen) for _ in range(num_to_add)]
290
+ return dataclasses.replace(self, entries=self.entries + extra_entries)
291
+
292
+ @classmethod
293
+ def stack(
294
+ cls, msas: Sequence[MSA], remove_query_from_later_msas: bool = True
295
+ ) -> MSA:
296
+ """Stack a series of MSAs. Optionally remove the query from msas after the first."""
297
+ all_entries = []
298
+ for i, msa in enumerate(msas):
299
+ entries = msa.entries
300
+ if i > 0 and remove_query_from_later_msas:
301
+ entries = entries[1:]
302
+ all_entries.extend(entries)
303
+ return cls(entries=all_entries)
304
+
305
+ @cached_property
306
+ def seqid(self) -> np.ndarray:
307
+ array = self.array.view(np.uint8)
308
+ seqid = 1 - cdist(array[0][None], array, "hamming")
309
+ return seqid[0]
310
+
311
+ @classmethod
312
+ def concat(
313
+ cls,
314
+ msas: Sequence[MSA],
315
+ join_token: str | None = "|",
316
+ allow_depth_mismatch: bool = False,
317
+ ) -> MSA:
318
+ """Concatenate a series of MSAs horizontally, along the sequence dimension."""
319
+ if not msas:
320
+ raise ValueError("Cannot concatenate an empty list of MSAs")
321
+ msa_depths = [msa.depth for msa in msas]
322
+ if len(set(msa_depths)) != 1:
323
+ if not allow_depth_mismatch:
324
+ raise ValueError("Depth mismatch in concatenating MSAs")
325
+ else:
326
+ max_depth = max(msa_depths)
327
+ msas = [msa.pad_to_depth(max_depth) for msa in msas]
328
+ headers = [
329
+ "|".join([str(h) for h in headers])
330
+ for headers in zip(*(msa.headers for msa in msas))
331
+ ]
332
+
333
+ if join_token is None:
334
+ join_token = ""
335
+
336
+ seqs = [join_token.join(vals) for vals in zip(*(msa.sequences for msa in msas))]
337
+ entries = [FastaEntry(header, seq) for header, seq in zip(headers, seqs)]
338
+ return cls(entries)
339
+
340
+
341
+ @dataclass(frozen=True)
342
+ class FastMSA(SequentialDataclass):
343
+ """Object-oriented interface to an MSA stored as a numpy uint8 array."""
344
+
345
+ array: np.ndarray
346
+ headers: list[str] | None = None
347
+
348
+ def __post_init__(self):
349
+ if self.headers is not None:
350
+ assert (
351
+ len(self.headers) == self.depth
352
+ ), "Number of headers must match depth."
353
+
354
+ @classmethod
355
+ def from_bytes(cls, data: bytes) -> FastMSA:
356
+ version_bytes, seqlen_bytes, depth_bytes, data = (
357
+ data[:1],
358
+ data[1:5],
359
+ data[5:9],
360
+ data[9:],
361
+ )
362
+ version = int.from_bytes(version_bytes, "little")
363
+ if version != 1:
364
+ raise ValueError(f"Unsupported version: {version}")
365
+ seqlen = int.from_bytes(seqlen_bytes, "little")
366
+ depth = int.from_bytes(depth_bytes, "little")
367
+ array_bytes, header_bytes = data[: seqlen * depth], data[seqlen * depth :]
368
+ array = np.frombuffer(array_bytes, dtype="|S1")
369
+ array = array.reshape(depth, seqlen)
370
+ headers = header_bytes.decode().split("\n")
371
+ # Sometimes the separation is two newlines, which results in an empty header.
372
+ headers = [header for header in headers if header]
373
+ # If all headers were empty (e.g., saved from from_sequences), use empty headers
374
+ if len(headers) == 0 and depth > 0:
375
+ headers = [""] * depth
376
+ return cls(array, headers)
377
+
378
+ @classmethod
379
+ def from_sequence_bytes(cls, data: bytes) -> FastMSA:
380
+ seqlen_bytes, array_bytes = data[:4], data[4:]
381
+ seqlen = int.from_bytes(seqlen_bytes, "little")
382
+ array = np.frombuffer(array_bytes, dtype="|S1")
383
+ array = array.reshape(-1, seqlen)
384
+ return cls(array)
385
+
386
+ @property
387
+ def depth(self) -> int:
388
+ return self.array.shape[0]
389
+
390
+ @property
391
+ def seqlen(self) -> int:
392
+ return self.array.shape[1]
393
+
394
+ def __len__(self):
395
+ return self.seqlen
396
+
397
+ def __getitem__(self, indices: int | list[int] | slice | np.ndarray):
398
+ if isinstance(indices, int):
399
+ indices = [indices]
400
+
401
+ return dataclasses.replace(self, array=self.array[:, indices])
402
+
403
+ def select_sequences(self, indices: Sequence[int] | np.ndarray) -> FastMSA:
404
+ """Subselect rows of the MSA."""
405
+ array = self.array[indices]
406
+ headers = (
407
+ [self.headers[idx] for idx in indices] if self.headers is not None else None
408
+ )
409
+ return dataclasses.replace(self, array=array, headers=headers)
410
+
411
+ def select_random_sequences(self, num_seqs: int) -> FastMSA:
412
+ """Uses random sampling to subselect sequences from the MSA. Always
413
+ keeps the query sequence.
414
+ """
415
+ if num_seqs >= self.depth:
416
+ return self
417
+
418
+ # Subselect random, always keeping the query sequence.
419
+ indices = np.sort(
420
+ np.append(
421
+ 0, np.random.choice(self.depth - 1, num_seqs - 1, replace=False) + 1
422
+ )
423
+ )
424
+ msa = self.select_sequences(indices) # type: ignore
425
+ return msa
426
+
427
+ def pad_to_depth(self, depth: int) -> FastMSA:
428
+ if depth < self.depth:
429
+ raise ValueError(f"Cannot pad to depth {depth} when depth is {self.depth}")
430
+ elif depth == self.depth:
431
+ return self
432
+
433
+ num_to_add = depth - self.depth
434
+ array = np.pad(
435
+ self.array,
436
+ [(0, num_to_add), (0, 0)],
437
+ constant_values=ord("-") if self.array.dtype == np.uint8 else b"-",
438
+ )
439
+ headers = self.headers
440
+ if headers is not None:
441
+ headers = headers + [""] * num_to_add
442
+ return dataclasses.replace(self, array=array, headers=headers)
443
+
444
+ @classmethod
445
+ def concat(
446
+ cls,
447
+ msas: Sequence[FastMSA],
448
+ join_token: str | None = None,
449
+ allow_depth_mismatch: bool = False,
450
+ ) -> FastMSA:
451
+ """Concatenate a series of MSAs horizontally, along the sequence dimension."""
452
+ if not msas:
453
+ raise ValueError("Cannot concatenate an empty list of MSAs")
454
+ if join_token is not None and join_token != "":
455
+ raise NotImplementedError("join_token is not supported for FastMSA")
456
+
457
+ msa_depths = [msa.depth for msa in msas]
458
+ if len(set(msa_depths)) != 1:
459
+ if not allow_depth_mismatch:
460
+ raise ValueError("Depth mismatch in concatenating MSAs")
461
+ else:
462
+ max_depth = max(msa_depths)
463
+ msas = [msa.pad_to_depth(max_depth) for msa in msas]
464
+ headers = [
465
+ "|".join([str(h) for h in headers])
466
+ for headers in zip(
467
+ *(
468
+ msa.headers if msa.headers is not None else [""] * msa.depth
469
+ for msa in msas
470
+ )
471
+ )
472
+ ]
473
+
474
+ array = np.concatenate([msa.array for msa in msas], axis=1)
475
+ return cls(array, headers)
476
+
477
+ def to_msa(self) -> MSA:
478
+ headers = (
479
+ self.headers
480
+ if self.headers is not None
481
+ else [f"seq{i}" for i in range(self.depth)]
482
+ )
483
+ entries = [
484
+ FastaEntry(header, b"".join(row).decode())
485
+ for header, row in zip(headers, self.array)
486
+ ]
487
+ return MSA(entries)
488
+
489
+ @classmethod
490
+ def stack(
491
+ cls, msas: Sequence[FastMSA], remove_query_from_later_msas: bool = True
492
+ ) -> FastMSA:
493
+ """Stack a series of MSAs. Optionally remove the query from msas after the first."""
494
+ arrays = []
495
+ all_headers = []
496
+ for i, msa in enumerate(msas):
497
+ array = msa.array
498
+ headers = msa.headers
499
+ if i > 0 and remove_query_from_later_msas:
500
+ array = array[1:]
501
+ if headers is not None:
502
+ headers = headers[1:]
503
+ arrays.append(array)
504
+ if headers is not None:
505
+ all_headers.extend(headers)
506
+ return cls(np.concatenate(arrays, axis=0), all_headers)
esmfold2_msa_filter_sequences.py CHANGED
@@ -1,82 +1,82 @@
1
- import os
2
- import tempfile
3
- from pathlib import Path
4
-
5
- import numpy as np
6
- from scipy.spatial.distance import cdist
7
-
8
- from .esmfold2_system import run_subprocess_with_errorcheck
9
-
10
-
11
- def greedy_select_indices(array, num_seqs: int, mode: str = "max") -> list[int]:
12
- """Greedily select sequences that either maximize or minimize hamming distance.
13
-
14
- Algorithm proposed in the MSA Transformer paper. Starting from the query sequence,
15
- iteratively add sequences to the list with the maximum (minimum) average Hamming
16
- distance to the existing set of sequences.
17
-
18
- Args:
19
- array (np.ndarray): Character array representing the sequences in the MSA
20
- num_seqs (int): Number of sequences to select.
21
- mode (str): Whether to maximize or minimize diversity. DO NOT pick 'min' unless
22
- you're doing it to prove a point for a paper.
23
-
24
- Returns:
25
- list[int]: List of indices to select from the array
26
- """
27
- assert mode in ("max", "min")
28
- depth = array.shape[0]
29
- if depth <= num_seqs:
30
- return list(range(depth))
31
- array = array.view(np.uint8)
32
-
33
- optfunc = np.argmax if mode == "max" else np.argmin
34
- all_indices = np.arange(depth)
35
- indices = [0]
36
- pairwise_distances = np.zeros((0, depth))
37
- for _ in range(num_seqs - 1):
38
- dist = cdist(array[indices[-1:]], array, "hamming")
39
- pairwise_distances = np.concatenate([pairwise_distances, dist])
40
- shifted_distance = np.delete(pairwise_distances, indices, axis=1).mean(0)
41
- shifted_index = optfunc(shifted_distance)
42
- index = np.delete(all_indices, indices)[shifted_index]
43
- indices.append(index)
44
- indices = sorted(indices)
45
- return indices
46
-
47
-
48
- def hhfilter(
49
- sequences: list[str],
50
- seqid: int = 90,
51
- diff: int = 0,
52
- cov: int = 0,
53
- qid: int = 0,
54
- qsc: float = -20.0,
55
- binary: str = "hhfilter",
56
- ) -> list[int]:
57
- with tempfile.TemporaryDirectory(
58
- dir="/dev/shm" if os.path.exists("/dev/shm") else None
59
- ) as tempdirname:
60
- tempdir = Path(tempdirname)
61
- fasta_file = tempdir / "input.fasta"
62
- fasta_file.write_text(
63
- "\n".join(f">{i}\n{seq}" for i, seq in enumerate(sequences))
64
- )
65
- output_file = tempdir / "output.fasta"
66
- command = " ".join(
67
- [
68
- f"{binary}",
69
- f"-i {fasta_file}",
70
- "-M a3m",
71
- f"-o {output_file}",
72
- f"-id {seqid}",
73
- f"-diff {diff}",
74
- f"-cov {cov}",
75
- f"-qid {qid}",
76
- f"-qsc {qsc}",
77
- ]
78
- ).split(" ")
79
- run_subprocess_with_errorcheck(command, capture_output=True)
80
- with output_file.open() as f:
81
- indices = [int(line[1:].strip()) for line in f if line.startswith(">")]
82
- return indices
 
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ from scipy.spatial.distance import cdist
7
+
8
+ from .esmfold2_system import run_subprocess_with_errorcheck
9
+
10
+
11
+ def greedy_select_indices(array, num_seqs: int, mode: str = "max") -> list[int]:
12
+ """Greedily select sequences that either maximize or minimize hamming distance.
13
+
14
+ Algorithm proposed in the MSA Transformer paper. Starting from the query sequence,
15
+ iteratively add sequences to the list with the maximum (minimum) average Hamming
16
+ distance to the existing set of sequences.
17
+
18
+ Args:
19
+ array (np.ndarray): Character array representing the sequences in the MSA
20
+ num_seqs (int): Number of sequences to select.
21
+ mode (str): Whether to maximize or minimize diversity. DO NOT pick 'min' unless
22
+ you're doing it to prove a point for a paper.
23
+
24
+ Returns:
25
+ list[int]: List of indices to select from the array
26
+ """
27
+ assert mode in ("max", "min")
28
+ depth = array.shape[0]
29
+ if depth <= num_seqs:
30
+ return list(range(depth))
31
+ array = array.view(np.uint8)
32
+
33
+ optfunc = np.argmax if mode == "max" else np.argmin
34
+ all_indices = np.arange(depth)
35
+ indices = [0]
36
+ pairwise_distances = np.zeros((0, depth))
37
+ for _ in range(num_seqs - 1):
38
+ dist = cdist(array[indices[-1:]], array, "hamming")
39
+ pairwise_distances = np.concatenate([pairwise_distances, dist])
40
+ shifted_distance = np.delete(pairwise_distances, indices, axis=1).mean(0)
41
+ shifted_index = optfunc(shifted_distance)
42
+ index = np.delete(all_indices, indices)[shifted_index]
43
+ indices.append(index)
44
+ indices = sorted(indices)
45
+ return indices
46
+
47
+
48
+ def hhfilter(
49
+ sequences: list[str],
50
+ seqid: int = 90,
51
+ diff: int = 0,
52
+ cov: int = 0,
53
+ qid: int = 0,
54
+ qsc: float = -20.0,
55
+ binary: str = "hhfilter",
56
+ ) -> list[int]:
57
+ with tempfile.TemporaryDirectory(
58
+ dir="/dev/shm" if os.path.exists("/dev/shm") else None
59
+ ) as tempdirname:
60
+ tempdir = Path(tempdirname)
61
+ fasta_file = tempdir / "input.fasta"
62
+ fasta_file.write_text(
63
+ "\n".join(f">{i}\n{seq}" for i, seq in enumerate(sequences))
64
+ )
65
+ output_file = tempdir / "output.fasta"
66
+ command = " ".join(
67
+ [
68
+ f"{binary}",
69
+ f"-i {fasta_file}",
70
+ "-M a3m",
71
+ f"-o {output_file}",
72
+ f"-id {seqid}",
73
+ f"-diff {diff}",
74
+ f"-cov {cov}",
75
+ f"-qid {qid}",
76
+ f"-qsc {qsc}",
77
+ ]
78
+ ).split(" ")
79
+ run_subprocess_with_errorcheck(command, capture_output=True)
80
+ with output_file.open() as f:
81
+ indices = [int(line[1:].strip()) for line in f if line.startswith(">")]
82
+ return indices
esmfold2_normalize_coordinates.py CHANGED
@@ -1,79 +1,79 @@
1
- from typing import TypeVar
2
-
3
- import numpy as np
4
- import torch
5
- from torch import Tensor
6
-
7
- from . import esmfold2_residue_constants as RC
8
- from .esmfold2_affine3d import Affine3D
9
-
10
- ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
11
-
12
-
13
- def atom3_to_backbone_frames(bb_positions: torch.Tensor) -> Affine3D:
14
- N, CA, C = bb_positions.unbind(dim=-2)
15
- return Affine3D.from_graham_schmidt(C, CA, N)
16
-
17
-
18
- def index_by_atom_name(
19
- atom37: ArrayOrTensor, atom_names: str | list[str], dim: int = -2
20
- ) -> ArrayOrTensor:
21
- squeeze = False
22
- if isinstance(atom_names, str):
23
- atom_names = [atom_names]
24
- squeeze = True
25
- indices = [RC.atom_order[atom_name] for atom_name in atom_names]
26
- dim = dim % atom37.ndim
27
- index = tuple(slice(None) if dim != i else indices for i in range(atom37.ndim))
28
- result = atom37[index] # type: ignore
29
- if squeeze:
30
- result = result.squeeze(dim)
31
- return result
32
-
33
-
34
- def get_protein_normalization_frame(coords: Tensor) -> Affine3D:
35
- """Given a set of coordinates for a protein, compute a single frame that can be used to normalize the coordinates.
36
- Specifically, we compute the average position of the N, CA, and C atoms use those 3 points to construct a frame
37
- using the Gram-Schmidt algorithm. The average CA position is used as the origin of the frame.
38
-
39
- Args:
40
- coords (torch.FloatTensor): [L, 37, 3] tensor of coordinates
41
-
42
- Returns:
43
- Affine3D: tensor of Affine3D frame
44
- """
45
- bb_coords = index_by_atom_name(coords, ["N", "CA", "C"], dim=-2)
46
- coord_mask = torch.all(torch.all(torch.isfinite(bb_coords), dim=-1), dim=-1)
47
-
48
- average_position_per_n_ca_c = bb_coords.masked_fill(
49
- ~coord_mask[..., None, None], 0
50
- ).sum(-3) / (coord_mask.sum(-1)[..., None, None] + 1e-8)
51
- frame = atom3_to_backbone_frames(average_position_per_n_ca_c.float())
52
-
53
- return frame
54
-
55
-
56
- def apply_frame_to_coords(coords: Tensor, frame: Affine3D) -> Tensor:
57
- """Given a set of coordinates and a single frame, apply the frame to the coordinates.
58
-
59
- Args:
60
- coords (torch.FloatTensor): [L, 37, 3] tensor of coordinates
61
- frame (Affine3D): Affine3D frame
62
-
63
- Returns:
64
- torch.FloatTensor: [L, 37, 3] tensor of transformed coordinates
65
- """
66
- coords_trans_rot = frame[..., None, None].invert().apply(coords)
67
-
68
- # only transform coordinates with frame that have a valid rotation
69
- valid_frame = frame.trans.norm(dim=-1) > 0
70
-
71
- is_inf = torch.isinf(coords)
72
- coords = coords_trans_rot.where(valid_frame[..., None, None, None], coords)
73
- coords.masked_fill_(is_inf, torch.inf)
74
-
75
- return coords
76
-
77
-
78
- def normalize_coordinates(coords: Tensor) -> Tensor:
79
- return apply_frame_to_coords(coords, get_protein_normalization_frame(coords))
 
1
+ from typing import TypeVar
2
+
3
+ import numpy as np
4
+ import torch
5
+ from torch import Tensor
6
+
7
+ from . import esmfold2_residue_constants as RC
8
+ from .esmfold2_affine3d import Affine3D
9
+
10
+ ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
11
+
12
+
13
+ def atom3_to_backbone_frames(bb_positions: torch.Tensor) -> Affine3D:
14
+ N, CA, C = bb_positions.unbind(dim=-2)
15
+ return Affine3D.from_graham_schmidt(C, CA, N)
16
+
17
+
18
+ def index_by_atom_name(
19
+ atom37: ArrayOrTensor, atom_names: str | list[str], dim: int = -2
20
+ ) -> ArrayOrTensor:
21
+ squeeze = False
22
+ if isinstance(atom_names, str):
23
+ atom_names = [atom_names]
24
+ squeeze = True
25
+ indices = [RC.atom_order[atom_name] for atom_name in atom_names]
26
+ dim = dim % atom37.ndim
27
+ index = tuple(slice(None) if dim != i else indices for i in range(atom37.ndim))
28
+ result = atom37[index] # type: ignore
29
+ if squeeze:
30
+ result = result.squeeze(dim)
31
+ return result
32
+
33
+
34
+ def get_protein_normalization_frame(coords: Tensor) -> Affine3D:
35
+ """Given a set of coordinates for a protein, compute a single frame that can be used to normalize the coordinates.
36
+ Specifically, we compute the average position of the N, CA, and C atoms use those 3 points to construct a frame
37
+ using the Gram-Schmidt algorithm. The average CA position is used as the origin of the frame.
38
+
39
+ Args:
40
+ coords (torch.FloatTensor): [L, 37, 3] tensor of coordinates
41
+
42
+ Returns:
43
+ Affine3D: tensor of Affine3D frame
44
+ """
45
+ bb_coords = index_by_atom_name(coords, ["N", "CA", "C"], dim=-2)
46
+ coord_mask = torch.all(torch.all(torch.isfinite(bb_coords), dim=-1), dim=-1)
47
+
48
+ average_position_per_n_ca_c = bb_coords.masked_fill(
49
+ ~coord_mask[..., None, None], 0
50
+ ).sum(-3) / (coord_mask.sum(-1)[..., None, None] + 1e-8)
51
+ frame = atom3_to_backbone_frames(average_position_per_n_ca_c.float())
52
+
53
+ return frame
54
+
55
+
56
+ def apply_frame_to_coords(coords: Tensor, frame: Affine3D) -> Tensor:
57
+ """Given a set of coordinates and a single frame, apply the frame to the coordinates.
58
+
59
+ Args:
60
+ coords (torch.FloatTensor): [L, 37, 3] tensor of coordinates
61
+ frame (Affine3D): Affine3D frame
62
+
63
+ Returns:
64
+ torch.FloatTensor: [L, 37, 3] tensor of transformed coordinates
65
+ """
66
+ coords_trans_rot = frame[..., None, None].invert().apply(coords)
67
+
68
+ # only transform coordinates with frame that have a valid rotation
69
+ valid_frame = frame.trans.norm(dim=-1) > 0
70
+
71
+ is_inf = torch.isinf(coords)
72
+ coords = coords_trans_rot.where(valid_frame[..., None, None, None], coords)
73
+ coords.masked_fill_(is_inf, torch.inf)
74
+
75
+ return coords
76
+
77
+
78
+ def normalize_coordinates(coords: Tensor) -> Tensor:
79
+ return apply_frame_to_coords(coords, get_protein_normalization_frame(coords))
esmfold2_output.py CHANGED
@@ -1,224 +1,224 @@
1
- from itertools import groupby
2
- from typing import Any
3
-
4
- import numpy as np
5
- import torch
6
-
7
- from .esmfold2_constants import ELEMENT_NUMBER_TO_SYMBOL, MOL_TYPE_NONPOLYMER
8
- from .esmfold2_molecular_complex import (
9
- MolecularComplex,
10
- MolecularComplexMetadata,
11
- )
12
-
13
-
14
- def get_element_symbol(atomic_num: int) -> str:
15
- return ELEMENT_NUMBER_TO_SYMBOL.get(atomic_num, "X")
16
-
17
-
18
- def build_molecular_complex_from_features(
19
- coords: torch.Tensor,
20
- plddt: torch.Tensor,
21
- atom_mask: torch.Tensor,
22
- ref_element: torch.Tensor,
23
- ref_atom_name_chars: torch.Tensor,
24
- chain_infos: list,
25
- complex_id: str,
26
- ) -> MolecularComplex:
27
- """Construct a MolecularComplex from feature-dict tensors and chain metadata.
28
-
29
- Non-polymer chains (ligands) collapse all per-atom tokens into a single
30
- residue token whose pLDDT is the per-token average and whose hetero flag
31
- is True.
32
- """
33
- mask_np = atom_mask.bool().cpu().numpy()
34
- coords_np = coords.float().cpu().numpy()
35
- name_chars_np = ref_atom_name_chars.cpu().numpy()
36
- elements_np = ref_element.cpu().numpy()
37
- plddt_np = plddt.float().cpu().numpy()
38
-
39
- sequence_tokens: list[str] = []
40
- chain_ids_per_token: list[int] = []
41
- token_to_atoms: list[list[int]] = []
42
- confidence: list[float] = []
43
- flat_positions: list[list[float]] = []
44
- flat_elements: list[str] = []
45
- flat_names: list[str] = []
46
- flat_hetero: list[bool] = []
47
-
48
- chain_lookup: dict[int, str] = {}
49
- entity_info: dict[int, str] = {}
50
- out_atom_cursor = 0
51
-
52
- for ci in chain_infos:
53
- chain_lookup[ci.asym_id] = ci.chain_id
54
- is_nonpolymer = ci.mol_type == MOL_TYPE_NONPOLYMER
55
- entity_info[ci.entity_id] = "non-polymer" if is_nonpolymer else "polymer"
56
-
57
- if is_nonpolymer:
58
- residue_name = ci.tokens[0].residue_name if ci.tokens else "LIG"
59
- sequence_tokens.append(residue_name)
60
- chain_ids_per_token.append(ci.asym_id)
61
- avg_plddt = (
62
- float(np.mean([plddt_np[ti.token_index] for ti in ci.tokens]))
63
- if ci.tokens
64
- else 0.0
65
- )
66
- confidence.append(avg_plddt)
67
- token_atom_start = out_atom_cursor
68
- for ti in ci.tokens:
69
- for atom_idx in range(ti.atom_start, ti.atom_start + ti.atom_count):
70
- if not mask_np[atom_idx]:
71
- continue
72
- flat_positions.append(coords_np[atom_idx].tolist())
73
- flat_elements.append(get_element_symbol(int(elements_np[atom_idx])))
74
- chars = name_chars_np[atom_idx]
75
- name = "".join(
76
- chr(int(c) + 32) for c in chars if int(c) != 0
77
- ).strip()
78
- flat_names.append(name)
79
- flat_hetero.append(True)
80
- out_atom_cursor += 1
81
- token_to_atoms.append([token_atom_start, out_atom_cursor])
82
- continue
83
-
84
- # Atom-tokenized modified residues (HYP, MSE, ...) span multiple
85
- # tokens per residue; collapse them back to one mmCIF residue.
86
- for _residue_index, ti_iter in groupby(
87
- ci.tokens, key=lambda t: t.residue_index
88
- ):
89
- ti_group = list(ti_iter)
90
- sequence_tokens.append(ti_group[0].residue_name)
91
- chain_ids_per_token.append(ci.asym_id)
92
- confidence.append(
93
- float(np.mean([plddt_np[ti.token_index] for ti in ti_group]))
94
- )
95
- token_atom_start = out_atom_cursor
96
- for ti in ti_group:
97
- for atom_idx in range(ti.atom_start, ti.atom_start + ti.atom_count):
98
- if not mask_np[atom_idx]:
99
- continue
100
- flat_positions.append(coords_np[atom_idx].tolist())
101
- flat_elements.append(get_element_symbol(int(elements_np[atom_idx])))
102
- chars = name_chars_np[atom_idx]
103
- name = "".join(
104
- chr(int(c) + 32) for c in chars if int(c) != 0
105
- ).strip()
106
- flat_names.append(name)
107
- flat_hetero.append(False)
108
- out_atom_cursor += 1
109
- token_to_atoms.append([token_atom_start, out_atom_cursor])
110
-
111
- return MolecularComplex(
112
- id=complex_id,
113
- sequence=sequence_tokens,
114
- atom_positions=np.array(flat_positions, dtype=np.float32).reshape(-1, 3),
115
- atom_elements=np.array(flat_elements, dtype=object),
116
- token_to_atoms=np.array(token_to_atoms, dtype=np.int32).reshape(-1, 2),
117
- chain_id=np.array(chain_ids_per_token, dtype=np.int64),
118
- plddt=np.array(confidence, dtype=np.float32),
119
- atom_names=np.array(flat_names, dtype=object),
120
- atom_hetero=np.array(flat_hetero, dtype=bool),
121
- metadata=MolecularComplexMetadata(
122
- entity_lookup=entity_info,
123
- chain_lookup=chain_lookup,
124
- assembly_composition=None,
125
- ),
126
- )
127
-
128
-
129
- def build_molecular_complex(
130
- structure: Any, coords: torch.Tensor, plddt: torch.Tensor, complex_id: str
131
- ) -> MolecularComplex:
132
- """Directly constructs a MolecularComplex from model outputs without intermediate files.
133
-
134
- Args:
135
- structure: Object with .chains, .residues, .atoms numpy structured arrays.
136
- coords: [N_atoms, 3] predicted atom coordinates.
137
- plddt: [N_residues] per-residue confidence scores.
138
- complex_id: Identifier string for the resulting complex.
139
- """
140
- flat_positions = []
141
- flat_elements = []
142
- flat_names = []
143
- flat_hetero = []
144
-
145
- sequence_tokens = []
146
- token_to_atoms = []
147
- chain_ids_per_token = []
148
- confidence_scores = []
149
-
150
- chain_lookup = {}
151
- entity_info = {}
152
-
153
- global_atom_cursor = 0
154
- global_res_cursor = 0
155
- atom_array_idx = 0
156
-
157
- for chain in structure.chains:
158
- chain_idx_numeric = chain["asym_id"]
159
- chain_name_str = str(chain["name"])
160
- mol_type = chain["mol_type"]
161
-
162
- chain_lookup[chain_idx_numeric] = chain_name_str
163
- entity_info[chain["entity_id"]] = (
164
- "polymer" if mol_type != MOL_TYPE_NONPOLYMER else "non-polymer"
165
- )
166
-
167
- res_start = chain["res_idx"]
168
- res_end = chain["res_idx"] + chain["res_num"]
169
- residues = structure.residues[res_start:res_end]
170
-
171
- for residue in residues:
172
- res_name = str(residue["name"])
173
-
174
- sequence_tokens.append(res_name)
175
- chain_ids_per_token.append(chain_idx_numeric)
176
-
177
- score = plddt[global_res_cursor].item()
178
- confidence_scores.append(score)
179
- token_start_idx = atom_array_idx
180
-
181
- atom_start = residue["atom_idx"]
182
- atom_end = residue["atom_idx"] + residue["atom_num"]
183
- atoms = structure.atoms[atom_start:atom_end]
184
-
185
- for atom in atoms:
186
- if not atom["is_present"]:
187
- continue
188
-
189
- pos = coords[global_atom_cursor].tolist()
190
- flat_positions.append(pos)
191
-
192
- elem = get_element_symbol(atom["element"].item())
193
- flat_elements.append(elem)
194
-
195
- raw_name = atom["name"]
196
- if hasattr(raw_name, "tolist"):
197
- raw_name = raw_name.tolist()
198
- name_str = "".join([chr(c + 32) for c in raw_name if c != 0])
199
- flat_names.append(name_str)
200
-
201
- flat_hetero.append(mol_type == MOL_TYPE_NONPOLYMER)
202
-
203
- global_atom_cursor += 1
204
- atom_array_idx += 1
205
-
206
- token_to_atoms.append([token_start_idx, atom_array_idx])
207
- global_res_cursor += 1
208
-
209
- return MolecularComplex(
210
- id=complex_id,
211
- sequence=sequence_tokens,
212
- atom_positions=np.array(flat_positions, dtype=np.float32),
213
- atom_elements=np.array(flat_elements, dtype=object),
214
- token_to_atoms=np.array(token_to_atoms, dtype=np.int32),
215
- chain_id=np.array(chain_ids_per_token, dtype=np.int64),
216
- plddt=np.array(confidence_scores, dtype=np.float32),
217
- atom_names=np.array(flat_names, dtype=object),
218
- atom_hetero=np.array(flat_hetero, dtype=bool),
219
- metadata=MolecularComplexMetadata(
220
- entity_lookup=entity_info,
221
- chain_lookup=chain_lookup,
222
- assembly_composition=None,
223
- ),
224
- )
 
1
+ from itertools import groupby
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ import torch
6
+
7
+ from .esmfold2_constants import ELEMENT_NUMBER_TO_SYMBOL, MOL_TYPE_NONPOLYMER
8
+ from .esmfold2_molecular_complex import (
9
+ MolecularComplex,
10
+ MolecularComplexMetadata,
11
+ )
12
+
13
+
14
+ def get_element_symbol(atomic_num: int) -> str:
15
+ return ELEMENT_NUMBER_TO_SYMBOL.get(atomic_num, "X")
16
+
17
+
18
+ def build_molecular_complex_from_features(
19
+ coords: torch.Tensor,
20
+ plddt: torch.Tensor,
21
+ atom_mask: torch.Tensor,
22
+ ref_element: torch.Tensor,
23
+ ref_atom_name_chars: torch.Tensor,
24
+ chain_infos: list,
25
+ complex_id: str,
26
+ ) -> MolecularComplex:
27
+ """Construct a MolecularComplex from feature-dict tensors and chain metadata.
28
+
29
+ Non-polymer chains (ligands) collapse all per-atom tokens into a single
30
+ residue token whose pLDDT is the per-token average and whose hetero flag
31
+ is True.
32
+ """
33
+ mask_np = atom_mask.bool().cpu().numpy()
34
+ coords_np = coords.float().cpu().numpy()
35
+ name_chars_np = ref_atom_name_chars.cpu().numpy()
36
+ elements_np = ref_element.cpu().numpy()
37
+ plddt_np = plddt.float().cpu().numpy()
38
+
39
+ sequence_tokens: list[str] = []
40
+ chain_ids_per_token: list[int] = []
41
+ token_to_atoms: list[list[int]] = []
42
+ confidence: list[float] = []
43
+ flat_positions: list[list[float]] = []
44
+ flat_elements: list[str] = []
45
+ flat_names: list[str] = []
46
+ flat_hetero: list[bool] = []
47
+
48
+ chain_lookup: dict[int, str] = {}
49
+ entity_info: dict[int, str] = {}
50
+ out_atom_cursor = 0
51
+
52
+ for ci in chain_infos:
53
+ chain_lookup[ci.asym_id] = ci.chain_id
54
+ is_nonpolymer = ci.mol_type == MOL_TYPE_NONPOLYMER
55
+ entity_info[ci.entity_id] = "non-polymer" if is_nonpolymer else "polymer"
56
+
57
+ if is_nonpolymer:
58
+ residue_name = ci.tokens[0].residue_name if ci.tokens else "LIG"
59
+ sequence_tokens.append(residue_name)
60
+ chain_ids_per_token.append(ci.asym_id)
61
+ avg_plddt = (
62
+ float(np.mean([plddt_np[ti.token_index] for ti in ci.tokens]))
63
+ if ci.tokens
64
+ else 0.0
65
+ )
66
+ confidence.append(avg_plddt)
67
+ token_atom_start = out_atom_cursor
68
+ for ti in ci.tokens:
69
+ for atom_idx in range(ti.atom_start, ti.atom_start + ti.atom_count):
70
+ if not mask_np[atom_idx]:
71
+ continue
72
+ flat_positions.append(coords_np[atom_idx].tolist())
73
+ flat_elements.append(get_element_symbol(int(elements_np[atom_idx])))
74
+ chars = name_chars_np[atom_idx]
75
+ name = "".join(
76
+ chr(int(c) + 32) for c in chars if int(c) != 0
77
+ ).strip()
78
+ flat_names.append(name)
79
+ flat_hetero.append(True)
80
+ out_atom_cursor += 1
81
+ token_to_atoms.append([token_atom_start, out_atom_cursor])
82
+ continue
83
+
84
+ # Atom-tokenized modified residues (HYP, MSE, ...) span multiple
85
+ # tokens per residue; collapse them back to one mmCIF residue.
86
+ for _residue_index, ti_iter in groupby(
87
+ ci.tokens, key=lambda t: t.residue_index
88
+ ):
89
+ ti_group = list(ti_iter)
90
+ sequence_tokens.append(ti_group[0].residue_name)
91
+ chain_ids_per_token.append(ci.asym_id)
92
+ confidence.append(
93
+ float(np.mean([plddt_np[ti.token_index] for ti in ti_group]))
94
+ )
95
+ token_atom_start = out_atom_cursor
96
+ for ti in ti_group:
97
+ for atom_idx in range(ti.atom_start, ti.atom_start + ti.atom_count):
98
+ if not mask_np[atom_idx]:
99
+ continue
100
+ flat_positions.append(coords_np[atom_idx].tolist())
101
+ flat_elements.append(get_element_symbol(int(elements_np[atom_idx])))
102
+ chars = name_chars_np[atom_idx]
103
+ name = "".join(
104
+ chr(int(c) + 32) for c in chars if int(c) != 0
105
+ ).strip()
106
+ flat_names.append(name)
107
+ flat_hetero.append(False)
108
+ out_atom_cursor += 1
109
+ token_to_atoms.append([token_atom_start, out_atom_cursor])
110
+
111
+ return MolecularComplex(
112
+ id=complex_id,
113
+ sequence=sequence_tokens,
114
+ atom_positions=np.array(flat_positions, dtype=np.float32).reshape(-1, 3),
115
+ atom_elements=np.array(flat_elements, dtype=object),
116
+ token_to_atoms=np.array(token_to_atoms, dtype=np.int32).reshape(-1, 2),
117
+ chain_id=np.array(chain_ids_per_token, dtype=np.int64),
118
+ plddt=np.array(confidence, dtype=np.float32),
119
+ atom_names=np.array(flat_names, dtype=object),
120
+ atom_hetero=np.array(flat_hetero, dtype=bool),
121
+ metadata=MolecularComplexMetadata(
122
+ entity_lookup=entity_info,
123
+ chain_lookup=chain_lookup,
124
+ assembly_composition=None,
125
+ ),
126
+ )
127
+
128
+
129
+ def build_molecular_complex(
130
+ structure: Any, coords: torch.Tensor, plddt: torch.Tensor, complex_id: str
131
+ ) -> MolecularComplex:
132
+ """Directly constructs a MolecularComplex from model outputs without intermediate files.
133
+
134
+ Args:
135
+ structure: Object with .chains, .residues, .atoms numpy structured arrays.
136
+ coords: [N_atoms, 3] predicted atom coordinates.
137
+ plddt: [N_residues] per-residue confidence scores.
138
+ complex_id: Identifier string for the resulting complex.
139
+ """
140
+ flat_positions = []
141
+ flat_elements = []
142
+ flat_names = []
143
+ flat_hetero = []
144
+
145
+ sequence_tokens = []
146
+ token_to_atoms = []
147
+ chain_ids_per_token = []
148
+ confidence_scores = []
149
+
150
+ chain_lookup = {}
151
+ entity_info = {}
152
+
153
+ global_atom_cursor = 0
154
+ global_res_cursor = 0
155
+ atom_array_idx = 0
156
+
157
+ for chain in structure.chains:
158
+ chain_idx_numeric = chain["asym_id"]
159
+ chain_name_str = str(chain["name"])
160
+ mol_type = chain["mol_type"]
161
+
162
+ chain_lookup[chain_idx_numeric] = chain_name_str
163
+ entity_info[chain["entity_id"]] = (
164
+ "polymer" if mol_type != MOL_TYPE_NONPOLYMER else "non-polymer"
165
+ )
166
+
167
+ res_start = chain["res_idx"]
168
+ res_end = chain["res_idx"] + chain["res_num"]
169
+ residues = structure.residues[res_start:res_end]
170
+
171
+ for residue in residues:
172
+ res_name = str(residue["name"])
173
+
174
+ sequence_tokens.append(res_name)
175
+ chain_ids_per_token.append(chain_idx_numeric)
176
+
177
+ score = plddt[global_res_cursor].item()
178
+ confidence_scores.append(score)
179
+ token_start_idx = atom_array_idx
180
+
181
+ atom_start = residue["atom_idx"]
182
+ atom_end = residue["atom_idx"] + residue["atom_num"]
183
+ atoms = structure.atoms[atom_start:atom_end]
184
+
185
+ for atom in atoms:
186
+ if not atom["is_present"]:
187
+ continue
188
+
189
+ pos = coords[global_atom_cursor].tolist()
190
+ flat_positions.append(pos)
191
+
192
+ elem = get_element_symbol(atom["element"].item())
193
+ flat_elements.append(elem)
194
+
195
+ raw_name = atom["name"]
196
+ if hasattr(raw_name, "tolist"):
197
+ raw_name = raw_name.tolist()
198
+ name_str = "".join([chr(c + 32) for c in raw_name if c != 0])
199
+ flat_names.append(name_str)
200
+
201
+ flat_hetero.append(mol_type == MOL_TYPE_NONPOLYMER)
202
+
203
+ global_atom_cursor += 1
204
+ atom_array_idx += 1
205
+
206
+ token_to_atoms.append([token_start_idx, atom_array_idx])
207
+ global_res_cursor += 1
208
+
209
+ return MolecularComplex(
210
+ id=complex_id,
211
+ sequence=sequence_tokens,
212
+ atom_positions=np.array(flat_positions, dtype=np.float32),
213
+ atom_elements=np.array(flat_elements, dtype=object),
214
+ token_to_atoms=np.array(token_to_atoms, dtype=np.int32),
215
+ chain_id=np.array(chain_ids_per_token, dtype=np.int64),
216
+ plddt=np.array(confidence_scores, dtype=np.float32),
217
+ atom_names=np.array(flat_names, dtype=object),
218
+ atom_hetero=np.array(flat_hetero, dtype=bool),
219
+ metadata=MolecularComplexMetadata(
220
+ entity_lookup=entity_info,
221
+ chain_lookup=chain_lookup,
222
+ assembly_composition=None,
223
+ ),
224
+ )
esmfold2_paired_msa.py CHANGED
@@ -1,245 +1,245 @@
1
- """Taxonomy-paired MSA construction for ESMFold2 inference.
2
-
3
- Taxonomy IDs are read from FASTA headers as ``key=N`` tokens. Rows
4
- where any chain has ``key=-1`` (or no ``key=`` at all) are treated as
5
- unpaired and assigned to that chain's block-diagonal section after
6
- the paired rows.
7
- """
8
-
9
- import re
10
-
11
- import numpy as np
12
-
13
- from .esmfold2_constants import (
14
- MSA_GAP_TOKEN_ID,
15
- PROTEIN_3TO1,
16
- PROTEIN_RESIDUE_TO_RES_TYPE,
17
- PROTEIN_UNK_RES_TYPE,
18
- )
19
- from .esmfold2_msa import MSA
20
-
21
- _KEY_RE = re.compile(r"key=(-?\d+)")
22
-
23
-
24
- def protein_letter_to_res_type() -> dict[str, int]:
25
- """Return the protein 1-letter → res_type mapping used by the MSA encoder."""
26
- mapping: dict[str, int] = {}
27
- for three, one in PROTEIN_3TO1.items():
28
- if three in PROTEIN_RESIDUE_TO_RES_TYPE:
29
- mapping[one] = PROTEIN_RESIDUE_TO_RES_TYPE[three]
30
- mapping["-"] = MSA_GAP_TOKEN_ID
31
- mapping["X"] = PROTEIN_UNK_RES_TYPE
32
- return mapping
33
-
34
-
35
- def _taxonomy_from_header(header: str) -> int:
36
- if not header:
37
- return -1
38
- m = _KEY_RE.search(header)
39
- return int(m.group(1)) if m else -1
40
-
41
-
42
- def msa_to_res_type_and_deletions(
43
- msa: MSA, letter_to_res_type: dict[str, int]
44
- ) -> tuple[np.ndarray, np.ndarray]:
45
- """Convert an :class:`MSA` to ``(res_type[M, L], deletion_count[M, L])``.
46
-
47
- Handles a3m insertion convention: lowercase letters and ``.`` are
48
- insertions and are not emitted; their count is accumulated into the
49
- next non-insertion position's deletion value. ``L`` is the query
50
- length after stripping insertions from row 0.
51
- """
52
- query = msa.entries[0].sequence
53
- L = sum(1 for ch in query if not (ch.islower() or ch == "."))
54
- M = msa.depth
55
-
56
- res_type = np.full((M, L), MSA_GAP_TOKEN_ID, dtype=np.int64)
57
- deletions = np.zeros((M, L), dtype=np.float32)
58
-
59
- for r, entry in enumerate(msa.entries):
60
- col = 0
61
- ins = 0
62
- for ch in entry.sequence:
63
- if ch == "." or (ch.islower() and ch != "-"):
64
- ins += 1
65
- continue
66
- if col >= L:
67
- break
68
- if ch == "-":
69
- res_type[r, col] = MSA_GAP_TOKEN_ID
70
- else:
71
- res_type[r, col] = letter_to_res_type.get(
72
- ch.upper(), PROTEIN_UNK_RES_TYPE
73
- )
74
- if ins > 0:
75
- deletions[r, col] = float(ins)
76
- ins = 0
77
- col += 1
78
- return res_type, deletions
79
-
80
-
81
- def _dummy_msa_residues(query_res_types: np.ndarray) -> np.ndarray:
82
- """Single-row 'MSA' for chains without one — just the query."""
83
- return query_res_types[None, :] # [1, L]
84
-
85
-
86
- def construct_paired_msa(
87
- chain_msas: dict[int, MSA | None],
88
- chain_query_res_types: dict[int, np.ndarray],
89
- token_asym_ids: np.ndarray,
90
- token_res_ids: np.ndarray,
91
- letter_to_res_type: dict[str, int] | None = None,
92
- *,
93
- max_pairs: int = 8192,
94
- max_total: int = 16384,
95
- max_seqs: int = 16384,
96
- ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
97
- """Build paired MSA features.
98
-
99
- Parameters
100
- ----------
101
- chain_msas
102
- ``asym_id -> MSA`` (or ``None`` for chains without an MSA).
103
- chain_query_res_types
104
- ``asym_id -> np.ndarray[L_c]`` of res-type ids for the chain's
105
- query. Used to build dummy MSAs when a chain has no MSA.
106
- token_asym_ids
107
- Per-token asym_id, length ``T``. Must be non-decreasing.
108
- token_res_ids
109
- Per-token residue index within chain, length ``T``.
110
- letter_to_res_type
111
- 1-letter → res-type mapping. Defaults to
112
- :func:`protein_letter_to_res_type`.
113
-
114
- Returns
115
- -------
116
- msa_residues : ``np.ndarray[M, T]`` int64
117
- deletion_value : ``np.ndarray[M, T]`` float32 (raw deletion counts; the
118
- ``arctan(/3) * pi/2`` transform is applied by the caller)
119
- is_paired : ``np.ndarray[M, T]`` float32 broadcast of per-row,
120
- per-chain paired flags.
121
- """
122
- if letter_to_res_type is None:
123
- letter_to_res_type = protein_letter_to_res_type()
124
-
125
- chain_ids: list[int] = sorted(chain_msas.keys())
126
-
127
- # Build per-chain (res_type, deletions, taxonomy) tables.
128
- chain_res_type: dict[int, np.ndarray] = {}
129
- chain_deletions: dict[int, np.ndarray] = {}
130
- chain_taxonomies: dict[int, list[int]] = {}
131
- for c in chain_ids:
132
- m = chain_msas.get(c)
133
- if m is None or m.depth == 0:
134
- qres = chain_query_res_types[c]
135
- chain_res_type[c] = _dummy_msa_residues(qres)
136
- chain_deletions[c] = np.zeros((1, qres.shape[0]), dtype=np.float32)
137
- chain_taxonomies[c] = [-1]
138
- continue
139
- rt, dl = msa_to_res_type_and_deletions(m, letter_to_res_type)
140
- chain_res_type[c] = rt
141
- chain_deletions[c] = dl
142
- chain_taxonomies[c] = [_taxonomy_from_header(e.header) for e in m.entries]
143
-
144
- # Group by taxonomy, skip query row and unpaired (-1) entries.
145
- taxonomy_map: dict[int, list[tuple[int, int]]] = {}
146
- for c in chain_ids:
147
- for seq_idx, taxon in enumerate(chain_taxonomies[c]):
148
- if seq_idx == 0 or taxon == -1:
149
- continue
150
- taxonomy_map.setdefault(taxon, []).append((c, seq_idx))
151
- taxonomy_map = {k: v for k, v in taxonomy_map.items() if len(v) > 1}
152
- # Order taxonomies by number of distinct chains, descending.
153
- sorted_taxa = sorted(
154
- taxonomy_map.items(), key=lambda kv: len({c for c, _ in kv[1]}), reverse=True
155
- )
156
-
157
- visited = {s for _, items in taxonomy_map.items() for s in items}
158
- available: dict[int, list[int]] = {
159
- c: [i for i in range(1, len(chain_taxonomies[c])) if (c, i) not in visited]
160
- for c in chain_ids
161
- }
162
-
163
- pairing: list[dict[int, int]] = [{c: 0 for c in chain_ids}]
164
- is_paired: list[dict[int, int]] = [{c: 1 for c in chain_ids}]
165
-
166
- for _, pairs in sorted_taxa:
167
- per_chain: dict[int, list[int]] = {}
168
- for c, seq_idx in pairs:
169
- per_chain.setdefault(c, []).append(seq_idx)
170
- max_occ = max(len(v) for v in per_chain.values())
171
- for i in range(max_occ):
172
- row_pairing: dict[int, int] = {}
173
- row_is_paired: dict[int, int] = {}
174
- for c, seq_idxs in per_chain.items():
175
- row_pairing[c] = seq_idxs[i % len(seq_idxs)]
176
- row_is_paired[c] = 1
177
- for c in chain_ids:
178
- if c in row_pairing:
179
- continue
180
- row_is_paired[c] = 0
181
- if available[c]:
182
- row_pairing[c] = available[c].pop(0)
183
- else:
184
- row_pairing[c] = -1
185
- pairing.append(row_pairing)
186
- is_paired.append(row_is_paired)
187
- if len(pairing) >= max_pairs:
188
- break
189
- if len(pairing) >= max_pairs:
190
- break
191
-
192
- max_left = max((len(v) for v in available.values()), default=0)
193
- for _ in range(min(max_total - len(pairing), max_left)):
194
- row_pairing = {}
195
- row_is_paired = {}
196
- for c in chain_ids:
197
- row_is_paired[c] = 0
198
- if available[c]:
199
- row_pairing[c] = available[c].pop(0)
200
- else:
201
- row_pairing[c] = -1
202
- pairing.append(row_pairing)
203
- is_paired.append(row_is_paired)
204
- if len(pairing) >= max_total:
205
- break
206
-
207
- pairing = pairing[:max_seqs]
208
- is_paired = is_paired[:max_seqs]
209
- M = len(pairing)
210
- T = len(token_asym_ids)
211
-
212
- msa_residues = np.full((M, T), MSA_GAP_TOKEN_ID, dtype=np.int64)
213
- deletion_value = np.zeros((M, T), dtype=np.float32)
214
- paired_mask = np.zeros((M, T), dtype=np.float32)
215
-
216
- # Vectorize per chain: gather chain rows according to pairing[c], then
217
- # index into them by the chain's token residue ids.
218
- for c in chain_ids:
219
- rt = chain_res_type[c]
220
- dl = chain_deletions[c]
221
- Lc = rt.shape[1]
222
- chain_pairing = np.array([row[c] for row in pairing], dtype=np.int64)
223
- chain_paired = np.array([row[c] for row in is_paired], dtype=np.float32)
224
-
225
- token_mask = token_asym_ids == c
226
- if not token_mask.any():
227
- continue
228
- token_res_in_chain = token_res_ids[token_mask]
229
- # Clamp residue indices to the MSA's column range. Modified-residue
230
- # tokens that exceed the query length fall back to the last column.
231
- cols = np.minimum(token_res_in_chain, Lc - 1)
232
-
233
- # Rows where pairing == -1 fall back to gap (already initialized).
234
- valid_rows = chain_pairing >= 0
235
- if valid_rows.any():
236
- gathered_rt = rt[chain_pairing[valid_rows]][:, cols]
237
- gathered_dl = dl[chain_pairing[valid_rows]][:, cols]
238
- valid_idx = np.where(valid_rows)[0]
239
- token_idx = np.where(token_mask)[0]
240
- msa_residues[np.ix_(valid_idx, token_idx)] = gathered_rt
241
- deletion_value[np.ix_(valid_idx, token_idx)] = gathered_dl
242
-
243
- paired_mask[:, token_mask] = chain_paired[:, None]
244
-
245
- return msa_residues, deletion_value, paired_mask
 
1
+ """Taxonomy-paired MSA construction for ESMFold2 inference.
2
+
3
+ Taxonomy IDs are read from FASTA headers as ``key=N`` tokens. Rows
4
+ where any chain has ``key=-1`` (or no ``key=`` at all) are treated as
5
+ unpaired and assigned to that chain's block-diagonal section after
6
+ the paired rows.
7
+ """
8
+
9
+ import re
10
+
11
+ import numpy as np
12
+
13
+ from .esmfold2_constants import (
14
+ MSA_GAP_TOKEN_ID,
15
+ PROTEIN_3TO1,
16
+ PROTEIN_RESIDUE_TO_RES_TYPE,
17
+ PROTEIN_UNK_RES_TYPE,
18
+ )
19
+ from .esmfold2_msa import MSA
20
+
21
+ _KEY_RE = re.compile(r"key=(-?\d+)")
22
+
23
+
24
+ def protein_letter_to_res_type() -> dict[str, int]:
25
+ """Return the protein 1-letter → res_type mapping used by the MSA encoder."""
26
+ mapping: dict[str, int] = {}
27
+ for three, one in PROTEIN_3TO1.items():
28
+ if three in PROTEIN_RESIDUE_TO_RES_TYPE:
29
+ mapping[one] = PROTEIN_RESIDUE_TO_RES_TYPE[three]
30
+ mapping["-"] = MSA_GAP_TOKEN_ID
31
+ mapping["X"] = PROTEIN_UNK_RES_TYPE
32
+ return mapping
33
+
34
+
35
+ def _taxonomy_from_header(header: str) -> int:
36
+ if not header:
37
+ return -1
38
+ m = _KEY_RE.search(header)
39
+ return int(m.group(1)) if m else -1
40
+
41
+
42
+ def msa_to_res_type_and_deletions(
43
+ msa: MSA, letter_to_res_type: dict[str, int]
44
+ ) -> tuple[np.ndarray, np.ndarray]:
45
+ """Convert an :class:`MSA` to ``(res_type[M, L], deletion_count[M, L])``.
46
+
47
+ Handles a3m insertion convention: lowercase letters and ``.`` are
48
+ insertions and are not emitted; their count is accumulated into the
49
+ next non-insertion position's deletion value. ``L`` is the query
50
+ length after stripping insertions from row 0.
51
+ """
52
+ query = msa.entries[0].sequence
53
+ L = sum(1 for ch in query if not (ch.islower() or ch == "."))
54
+ M = msa.depth
55
+
56
+ res_type = np.full((M, L), MSA_GAP_TOKEN_ID, dtype=np.int64)
57
+ deletions = np.zeros((M, L), dtype=np.float32)
58
+
59
+ for r, entry in enumerate(msa.entries):
60
+ col = 0
61
+ ins = 0
62
+ for ch in entry.sequence:
63
+ if ch == "." or (ch.islower() and ch != "-"):
64
+ ins += 1
65
+ continue
66
+ if col >= L:
67
+ break
68
+ if ch == "-":
69
+ res_type[r, col] = MSA_GAP_TOKEN_ID
70
+ else:
71
+ res_type[r, col] = letter_to_res_type.get(
72
+ ch.upper(), PROTEIN_UNK_RES_TYPE
73
+ )
74
+ if ins > 0:
75
+ deletions[r, col] = float(ins)
76
+ ins = 0
77
+ col += 1
78
+ return res_type, deletions
79
+
80
+
81
+ def _dummy_msa_residues(query_res_types: np.ndarray) -> np.ndarray:
82
+ """Single-row 'MSA' for chains without one — just the query."""
83
+ return query_res_types[None, :] # [1, L]
84
+
85
+
86
+ def construct_paired_msa(
87
+ chain_msas: dict[int, MSA | None],
88
+ chain_query_res_types: dict[int, np.ndarray],
89
+ token_asym_ids: np.ndarray,
90
+ token_res_ids: np.ndarray,
91
+ letter_to_res_type: dict[str, int] | None = None,
92
+ *,
93
+ max_pairs: int = 8192,
94
+ max_total: int = 16384,
95
+ max_seqs: int = 16384,
96
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
97
+ """Build paired MSA features.
98
+
99
+ Parameters
100
+ ----------
101
+ chain_msas
102
+ ``asym_id -> MSA`` (or ``None`` for chains without an MSA).
103
+ chain_query_res_types
104
+ ``asym_id -> np.ndarray[L_c]`` of res-type ids for the chain's
105
+ query. Used to build dummy MSAs when a chain has no MSA.
106
+ token_asym_ids
107
+ Per-token asym_id, length ``T``. Must be non-decreasing.
108
+ token_res_ids
109
+ Per-token residue index within chain, length ``T``.
110
+ letter_to_res_type
111
+ 1-letter → res-type mapping. Defaults to
112
+ :func:`protein_letter_to_res_type`.
113
+
114
+ Returns
115
+ -------
116
+ msa_residues : ``np.ndarray[M, T]`` int64
117
+ deletion_value : ``np.ndarray[M, T]`` float32 (raw deletion counts; the
118
+ ``arctan(/3) * pi/2`` transform is applied by the caller)
119
+ is_paired : ``np.ndarray[M, T]`` float32 broadcast of per-row,
120
+ per-chain paired flags.
121
+ """
122
+ if letter_to_res_type is None:
123
+ letter_to_res_type = protein_letter_to_res_type()
124
+
125
+ chain_ids: list[int] = sorted(chain_msas.keys())
126
+
127
+ # Build per-chain (res_type, deletions, taxonomy) tables.
128
+ chain_res_type: dict[int, np.ndarray] = {}
129
+ chain_deletions: dict[int, np.ndarray] = {}
130
+ chain_taxonomies: dict[int, list[int]] = {}
131
+ for c in chain_ids:
132
+ m = chain_msas.get(c)
133
+ if m is None or m.depth == 0:
134
+ qres = chain_query_res_types[c]
135
+ chain_res_type[c] = _dummy_msa_residues(qres)
136
+ chain_deletions[c] = np.zeros((1, qres.shape[0]), dtype=np.float32)
137
+ chain_taxonomies[c] = [-1]
138
+ continue
139
+ rt, dl = msa_to_res_type_and_deletions(m, letter_to_res_type)
140
+ chain_res_type[c] = rt
141
+ chain_deletions[c] = dl
142
+ chain_taxonomies[c] = [_taxonomy_from_header(e.header) for e in m.entries]
143
+
144
+ # Group by taxonomy, skip query row and unpaired (-1) entries.
145
+ taxonomy_map: dict[int, list[tuple[int, int]]] = {}
146
+ for c in chain_ids:
147
+ for seq_idx, taxon in enumerate(chain_taxonomies[c]):
148
+ if seq_idx == 0 or taxon == -1:
149
+ continue
150
+ taxonomy_map.setdefault(taxon, []).append((c, seq_idx))
151
+ taxonomy_map = {k: v for k, v in taxonomy_map.items() if len(v) > 1}
152
+ # Order taxonomies by number of distinct chains, descending.
153
+ sorted_taxa = sorted(
154
+ taxonomy_map.items(), key=lambda kv: len({c for c, _ in kv[1]}), reverse=True
155
+ )
156
+
157
+ visited = {s for _, items in taxonomy_map.items() for s in items}
158
+ available: dict[int, list[int]] = {
159
+ c: [i for i in range(1, len(chain_taxonomies[c])) if (c, i) not in visited]
160
+ for c in chain_ids
161
+ }
162
+
163
+ pairing: list[dict[int, int]] = [{c: 0 for c in chain_ids}]
164
+ is_paired: list[dict[int, int]] = [{c: 1 for c in chain_ids}]
165
+
166
+ for _, pairs in sorted_taxa:
167
+ per_chain: dict[int, list[int]] = {}
168
+ for c, seq_idx in pairs:
169
+ per_chain.setdefault(c, []).append(seq_idx)
170
+ max_occ = max(len(v) for v in per_chain.values())
171
+ for i in range(max_occ):
172
+ row_pairing: dict[int, int] = {}
173
+ row_is_paired: dict[int, int] = {}
174
+ for c, seq_idxs in per_chain.items():
175
+ row_pairing[c] = seq_idxs[i % len(seq_idxs)]
176
+ row_is_paired[c] = 1
177
+ for c in chain_ids:
178
+ if c in row_pairing:
179
+ continue
180
+ row_is_paired[c] = 0
181
+ if available[c]:
182
+ row_pairing[c] = available[c].pop(0)
183
+ else:
184
+ row_pairing[c] = -1
185
+ pairing.append(row_pairing)
186
+ is_paired.append(row_is_paired)
187
+ if len(pairing) >= max_pairs:
188
+ break
189
+ if len(pairing) >= max_pairs:
190
+ break
191
+
192
+ max_left = max((len(v) for v in available.values()), default=0)
193
+ for _ in range(min(max_total - len(pairing), max_left)):
194
+ row_pairing = {}
195
+ row_is_paired = {}
196
+ for c in chain_ids:
197
+ row_is_paired[c] = 0
198
+ if available[c]:
199
+ row_pairing[c] = available[c].pop(0)
200
+ else:
201
+ row_pairing[c] = -1
202
+ pairing.append(row_pairing)
203
+ is_paired.append(row_is_paired)
204
+ if len(pairing) >= max_total:
205
+ break
206
+
207
+ pairing = pairing[:max_seqs]
208
+ is_paired = is_paired[:max_seqs]
209
+ M = len(pairing)
210
+ T = len(token_asym_ids)
211
+
212
+ msa_residues = np.full((M, T), MSA_GAP_TOKEN_ID, dtype=np.int64)
213
+ deletion_value = np.zeros((M, T), dtype=np.float32)
214
+ paired_mask = np.zeros((M, T), dtype=np.float32)
215
+
216
+ # Vectorize per chain: gather chain rows according to pairing[c], then
217
+ # index into them by the chain's token residue ids.
218
+ for c in chain_ids:
219
+ rt = chain_res_type[c]
220
+ dl = chain_deletions[c]
221
+ Lc = rt.shape[1]
222
+ chain_pairing = np.array([row[c] for row in pairing], dtype=np.int64)
223
+ chain_paired = np.array([row[c] for row in is_paired], dtype=np.float32)
224
+
225
+ token_mask = token_asym_ids == c
226
+ if not token_mask.any():
227
+ continue
228
+ token_res_in_chain = token_res_ids[token_mask]
229
+ # Clamp residue indices to the MSA's column range. Modified-residue
230
+ # tokens that exceed the query length fall back to the last column.
231
+ cols = np.minimum(token_res_in_chain, Lc - 1)
232
+
233
+ # Rows where pairing == -1 fall back to gap (already initialized).
234
+ valid_rows = chain_pairing >= 0
235
+ if valid_rows.any():
236
+ gathered_rt = rt[chain_pairing[valid_rows]][:, cols]
237
+ gathered_dl = dl[chain_pairing[valid_rows]][:, cols]
238
+ valid_idx = np.where(valid_rows)[0]
239
+ token_idx = np.where(token_mask)[0]
240
+ msa_residues[np.ix_(valid_idx, token_idx)] = gathered_rt
241
+ deletion_value[np.ix_(valid_idx, token_idx)] = gathered_dl
242
+
243
+ paired_mask[:, token_mask] = chain_paired[:, None]
244
+
245
+ return msa_residues, deletion_value, paired_mask
esmfold2_parsing.py CHANGED
@@ -1,112 +1,112 @@
1
- import io
2
- from pathlib import Path
3
- from typing import Generator, Iterable, NamedTuple
4
-
5
- PathOrBuffer = str | Path | io.TextIOBase
6
- FastaEntry = NamedTuple("FastaEntry", [("header", str), ("sequence", str)])
7
-
8
-
9
- def parse_fasta(fasta_string: str) -> Generator[FastaEntry, None, None]:
10
- """
11
- Parses a fasta file and yields FastaEntry objects
12
-
13
- Args:
14
- fasta_string: The fasta file as a string
15
- Returns:
16
- A generator of FastaEntry objects
17
- """
18
- header = None
19
- seq = []
20
- num_sequences = 0
21
- for line in fasta_string.splitlines():
22
- if not line or line[0] == "#":
23
- continue
24
- if line.startswith(">"):
25
- if header is not None:
26
- yield FastaEntry(header, "".join(seq))
27
- seq = []
28
- header = line[1:].strip()
29
- else:
30
- seq.append(line)
31
- if header is not None:
32
- num_sequences += 1
33
- yield FastaEntry(header, "".join(seq))
34
-
35
- if num_sequences == 0:
36
- raise ValueError("Found no sequences in input")
37
-
38
-
39
- def read_sequences(path: PathOrBuffer) -> Generator[FastaEntry, None, None]:
40
- # Uses duck typing to try and call the right method
41
- # Doesn't use explicit isinstance check to support
42
- # inputs that are not explicitly str/Path/TextIOBase but
43
- # may support similar functionality
44
- data = None # type: ignore
45
- try:
46
- if str(path).endswith(".gz"):
47
- import gzip
48
-
49
- data = gzip.open(path, "rt") # type: ignore
50
- else:
51
- try:
52
- data = open(path) # type: ignore
53
- except TypeError:
54
- data: io.TextIOBase = path # type: ignore
55
-
56
- yield from parse_fasta(data.read())
57
- finally:
58
- if data is not None:
59
- data.close()
60
-
61
-
62
- def read_first_sequence(path: PathOrBuffer) -> FastaEntry:
63
- return next(iter(read_sequences(path)))
64
-
65
-
66
- def count_fasta_sequences(path: str | Path) -> int:
67
- """Count sequences in a FASTA file by counting header lines.
68
-
69
- Faster than parsing the full file — only scans for '>' prefixes.
70
- Returns 0 if the file does not exist.
71
- """
72
- path = Path(path)
73
- if not path.exists():
74
- return 0
75
- with open(path) as f:
76
- return sum(1 for line in f if line.startswith(">"))
77
-
78
-
79
- def append_fasta_sequence(header: str, sequence: str, path: str | Path) -> None:
80
- """Append a single sequence to a FASTA file (creating it if needed)."""
81
- path = Path(path)
82
- path.parent.mkdir(parents=True, exist_ok=True)
83
- # The existing file may not end with a newline (e.g., write_sequences()
84
- # explicitly avoids writing a newline at the end), so we insert one before
85
- # appending to avoid merging with the last line.
86
- needs_newline = (
87
- path.exists() and path.stat().st_size > 0 and path.read_bytes()[-1:] != b"\n"
88
- )
89
- with open(path, "a") as f:
90
- if needs_newline:
91
- f.write("\n")
92
- f.write(f">{header}\n{sequence}\n")
93
-
94
-
95
- def write_sequences(sequences: Iterable[tuple[str, str]], path: PathOrBuffer) -> None:
96
- needs_closing = False
97
- handle = None
98
- try:
99
- try:
100
- handle = open(path, "w") # type: ignore
101
- needs_closing = True
102
- except TypeError:
103
- handle = path
104
- has_prev = False
105
- for header, seq in sequences:
106
- if has_prev:
107
- handle.write("\n") # type: ignore
108
- handle.write(f">{header}\n{seq}") # type: ignore
109
- has_prev = True
110
- finally:
111
- if needs_closing:
112
- handle.close() # type: ignore
 
1
+ import io
2
+ from pathlib import Path
3
+ from typing import Generator, Iterable, NamedTuple
4
+
5
+ PathOrBuffer = str | Path | io.TextIOBase
6
+ FastaEntry = NamedTuple("FastaEntry", [("header", str), ("sequence", str)])
7
+
8
+
9
+ def parse_fasta(fasta_string: str) -> Generator[FastaEntry, None, None]:
10
+ """
11
+ Parses a fasta file and yields FastaEntry objects
12
+
13
+ Args:
14
+ fasta_string: The fasta file as a string
15
+ Returns:
16
+ A generator of FastaEntry objects
17
+ """
18
+ header = None
19
+ seq = []
20
+ num_sequences = 0
21
+ for line in fasta_string.splitlines():
22
+ if not line or line[0] == "#":
23
+ continue
24
+ if line.startswith(">"):
25
+ if header is not None:
26
+ yield FastaEntry(header, "".join(seq))
27
+ seq = []
28
+ header = line[1:].strip()
29
+ else:
30
+ seq.append(line)
31
+ if header is not None:
32
+ num_sequences += 1
33
+ yield FastaEntry(header, "".join(seq))
34
+
35
+ if num_sequences == 0:
36
+ raise ValueError("Found no sequences in input")
37
+
38
+
39
+ def read_sequences(path: PathOrBuffer) -> Generator[FastaEntry, None, None]:
40
+ # Uses duck typing to try and call the right method
41
+ # Doesn't use explicit isinstance check to support
42
+ # inputs that are not explicitly str/Path/TextIOBase but
43
+ # may support similar functionality
44
+ data = None # type: ignore
45
+ try:
46
+ if str(path).endswith(".gz"):
47
+ import gzip
48
+
49
+ data = gzip.open(path, "rt") # type: ignore
50
+ else:
51
+ try:
52
+ data = open(path) # type: ignore
53
+ except TypeError:
54
+ data: io.TextIOBase = path # type: ignore
55
+
56
+ yield from parse_fasta(data.read())
57
+ finally:
58
+ if data is not None:
59
+ data.close()
60
+
61
+
62
+ def read_first_sequence(path: PathOrBuffer) -> FastaEntry:
63
+ return next(iter(read_sequences(path)))
64
+
65
+
66
+ def count_fasta_sequences(path: str | Path) -> int:
67
+ """Count sequences in a FASTA file by counting header lines.
68
+
69
+ Faster than parsing the full file — only scans for '>' prefixes.
70
+ Returns 0 if the file does not exist.
71
+ """
72
+ path = Path(path)
73
+ if not path.exists():
74
+ return 0
75
+ with open(path) as f:
76
+ return sum(1 for line in f if line.startswith(">"))
77
+
78
+
79
+ def append_fasta_sequence(header: str, sequence: str, path: str | Path) -> None:
80
+ """Append a single sequence to a FASTA file (creating it if needed)."""
81
+ path = Path(path)
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ # The existing file may not end with a newline (e.g., write_sequences()
84
+ # explicitly avoids writing a newline at the end), so we insert one before
85
+ # appending to avoid merging with the last line.
86
+ needs_newline = (
87
+ path.exists() and path.stat().st_size > 0 and path.read_bytes()[-1:] != b"\n"
88
+ )
89
+ with open(path, "a") as f:
90
+ if needs_newline:
91
+ f.write("\n")
92
+ f.write(f">{header}\n{sequence}\n")
93
+
94
+
95
+ def write_sequences(sequences: Iterable[tuple[str, str]], path: PathOrBuffer) -> None:
96
+ needs_closing = False
97
+ handle = None
98
+ try:
99
+ try:
100
+ handle = open(path, "w") # type: ignore
101
+ needs_closing = True
102
+ except TypeError:
103
+ handle = path
104
+ has_prev = False
105
+ for header, seq in sequences:
106
+ if has_prev:
107
+ handle.write("\n") # type: ignore
108
+ handle.write(f">{header}\n{seq}") # type: ignore
109
+ has_prev = True
110
+ finally:
111
+ if needs_closing:
112
+ handle.close() # type: ignore
esmfold2_predicted_aligned_error.py CHANGED
@@ -1,104 +1,104 @@
1
- import torch
2
- import torch.nn.functional as F
3
-
4
- from .esmfold2_affine3d import Affine3D
5
-
6
-
7
- def masked_mean(
8
- mask: torch.Tensor,
9
- value: torch.Tensor,
10
- dim: int | None | tuple[int, ...] = None,
11
- eps=1e-10,
12
- ) -> torch.Tensor:
13
- """Compute the mean of `value` where only positions where `mask == true` are
14
- counted.
15
- """
16
- mask = mask.expand(*value.shape)
17
- return torch.sum(mask * value, dim=dim) / (eps + torch.sum(mask, dim=dim))
18
-
19
-
20
- def _pae_bins(
21
- max_bin: float = 31, num_bins: int = 64, device: torch.device = torch.device("cpu")
22
- ):
23
- bins = torch.linspace(0, max_bin, steps=(num_bins - 1), device=device)
24
- step = max_bin / (num_bins - 2)
25
- bin_centers = bins + step / 2
26
- bin_centers = torch.cat(
27
- [bin_centers, (bin_centers[-1] + step).unsqueeze(-1)], dim=0
28
- )
29
- return bin_centers
30
-
31
-
32
- def _compute_pae_masks(mask: torch.Tensor):
33
- square_mask = (mask.unsqueeze(-1) * mask.unsqueeze(-2)).bool()
34
- return square_mask
35
-
36
-
37
- def compute_predicted_aligned_error(
38
- logits: torch.Tensor,
39
- aa_mask: torch.Tensor,
40
- sequence_id: torch.Tensor | None = None,
41
- max_bin: float = 31,
42
- ) -> torch.Tensor:
43
- bins = _pae_bins(max_bin, logits.shape[-1], logits.device)
44
- square_mask = _compute_pae_masks(aa_mask)
45
- min_v = torch.finfo(logits.dtype).min
46
- probs = logits.masked_fill(~square_mask.unsqueeze(-1), min_v).softmax(dim=-1)
47
-
48
- return (probs * bins).sum(dim=-1)
49
-
50
-
51
- @torch.no_grad
52
- def compute_tm(logits: torch.Tensor, aa_mask: torch.Tensor, max_bin: float = 31.0):
53
- square_mask = _compute_pae_masks(aa_mask)
54
- seqlens = aa_mask.sum(-1, keepdim=True)
55
- bins = _pae_bins(max_bin, logits.shape[-1], logits.device)
56
- d0 = 1.24 * (seqlens.clamp_min(19) - 15) ** (1 / 3) - 1.8
57
- f_d = 1.0 / (1 + (bins / d0.unsqueeze(-1)) ** 2)
58
-
59
- min_v = torch.finfo(logits.dtype).min
60
- probs = logits.masked_fill(~square_mask.unsqueeze(-1), min_v).softmax(dim=-1)
61
- # This is the sum over bins
62
- ptm = (probs * f_d.unsqueeze(-2)).sum(dim=-1)
63
- # This is the mean over residues j
64
- ptm = masked_mean(square_mask, ptm, dim=-1)
65
- # The we do a max over residues i
66
- return ptm.max(dim=-1).values
67
-
68
-
69
- def tm_loss(
70
- logits: torch.Tensor,
71
- pred_affine: torch.Tensor,
72
- targ_affine: torch.Tensor,
73
- targ_mask: torch.Tensor,
74
- tm_mask: torch.Tensor | None = None,
75
- sequence_id: torch.Tensor | None = None,
76
- max_bin: float = 31,
77
- ):
78
- pred = Affine3D.from_tensor(pred_affine)
79
- targ = Affine3D.from_tensor(targ_affine)
80
-
81
- def transform(affine: Affine3D):
82
- pts = affine.trans[..., None, :, :]
83
- return affine.invert()[..., None].apply(pts)
84
-
85
- with torch.no_grad():
86
- sq_diff = (transform(pred) - transform(targ)).square().sum(dim=-1)
87
-
88
- num_bins = logits.shape[-1]
89
- sq_bins = torch.linspace(
90
- 0, max_bin, num_bins - 1, device=logits.device
91
- ).square()
92
- # Gets the bin id by using a sum.
93
- true_bins = (sq_diff[..., None] > sq_bins).sum(dim=-1).long()
94
-
95
- errors = F.cross_entropy(logits.movedim(3, 1), true_bins, reduction="none")
96
- square_mask = _compute_pae_masks(targ_mask)
97
- loss = masked_mean(square_mask, errors, dim=(-1, -2))
98
-
99
- if tm_mask is not None:
100
- loss = masked_mean(tm_mask, loss, dim=None)
101
- else:
102
- loss = loss.mean()
103
-
104
- return loss
 
1
+ import torch
2
+ import torch.nn.functional as F
3
+
4
+ from .esmfold2_affine3d import Affine3D
5
+
6
+
7
+ def masked_mean(
8
+ mask: torch.Tensor,
9
+ value: torch.Tensor,
10
+ dim: int | None | tuple[int, ...] = None,
11
+ eps=1e-10,
12
+ ) -> torch.Tensor:
13
+ """Compute the mean of `value` where only positions where `mask == true` are
14
+ counted.
15
+ """
16
+ mask = mask.expand(*value.shape)
17
+ return torch.sum(mask * value, dim=dim) / (eps + torch.sum(mask, dim=dim))
18
+
19
+
20
+ def _pae_bins(
21
+ max_bin: float = 31, num_bins: int = 64, device: torch.device = torch.device("cpu")
22
+ ):
23
+ bins = torch.linspace(0, max_bin, steps=(num_bins - 1), device=device)
24
+ step = max_bin / (num_bins - 2)
25
+ bin_centers = bins + step / 2
26
+ bin_centers = torch.cat(
27
+ [bin_centers, (bin_centers[-1] + step).unsqueeze(-1)], dim=0
28
+ )
29
+ return bin_centers
30
+
31
+
32
+ def _compute_pae_masks(mask: torch.Tensor):
33
+ square_mask = (mask.unsqueeze(-1) * mask.unsqueeze(-2)).bool()
34
+ return square_mask
35
+
36
+
37
+ def compute_predicted_aligned_error(
38
+ logits: torch.Tensor,
39
+ aa_mask: torch.Tensor,
40
+ sequence_id: torch.Tensor | None = None,
41
+ max_bin: float = 31,
42
+ ) -> torch.Tensor:
43
+ bins = _pae_bins(max_bin, logits.shape[-1], logits.device)
44
+ square_mask = _compute_pae_masks(aa_mask)
45
+ min_v = torch.finfo(logits.dtype).min
46
+ probs = logits.masked_fill(~square_mask.unsqueeze(-1), min_v).softmax(dim=-1)
47
+
48
+ return (probs * bins).sum(dim=-1)
49
+
50
+
51
+ @torch.no_grad
52
+ def compute_tm(logits: torch.Tensor, aa_mask: torch.Tensor, max_bin: float = 31.0):
53
+ square_mask = _compute_pae_masks(aa_mask)
54
+ seqlens = aa_mask.sum(-1, keepdim=True)
55
+ bins = _pae_bins(max_bin, logits.shape[-1], logits.device)
56
+ d0 = 1.24 * (seqlens.clamp_min(19) - 15) ** (1 / 3) - 1.8
57
+ f_d = 1.0 / (1 + (bins / d0.unsqueeze(-1)) ** 2)
58
+
59
+ min_v = torch.finfo(logits.dtype).min
60
+ probs = logits.masked_fill(~square_mask.unsqueeze(-1), min_v).softmax(dim=-1)
61
+ # This is the sum over bins
62
+ ptm = (probs * f_d.unsqueeze(-2)).sum(dim=-1)
63
+ # This is the mean over residues j
64
+ ptm = masked_mean(square_mask, ptm, dim=-1)
65
+ # The we do a max over residues i
66
+ return ptm.max(dim=-1).values
67
+
68
+
69
+ def tm_loss(
70
+ logits: torch.Tensor,
71
+ pred_affine: torch.Tensor,
72
+ targ_affine: torch.Tensor,
73
+ targ_mask: torch.Tensor,
74
+ tm_mask: torch.Tensor | None = None,
75
+ sequence_id: torch.Tensor | None = None,
76
+ max_bin: float = 31,
77
+ ):
78
+ pred = Affine3D.from_tensor(pred_affine)
79
+ targ = Affine3D.from_tensor(targ_affine)
80
+
81
+ def transform(affine: Affine3D):
82
+ pts = affine.trans[..., None, :, :]
83
+ return affine.invert()[..., None].apply(pts)
84
+
85
+ with torch.no_grad():
86
+ sq_diff = (transform(pred) - transform(targ)).square().sum(dim=-1)
87
+
88
+ num_bins = logits.shape[-1]
89
+ sq_bins = torch.linspace(
90
+ 0, max_bin, num_bins - 1, device=logits.device
91
+ ).square()
92
+ # Gets the bin id by using a sum.
93
+ true_bins = (sq_diff[..., None] > sq_bins).sum(dim=-1).long()
94
+
95
+ errors = F.cross_entropy(logits.movedim(3, 1), true_bins, reduction="none")
96
+ square_mask = _compute_pae_masks(targ_mask)
97
+ loss = masked_mean(square_mask, errors, dim=(-1, -2))
98
+
99
+ if tm_mask is not None:
100
+ loss = masked_mean(tm_mask, loss, dim=None)
101
+ else:
102
+ loss = loss.mean()
103
+
104
+ return loss
esmfold2_prepare_input.py CHANGED
The diff for this file is too large to render. See raw diff
 
esmfold2_processor.py CHANGED
@@ -1,355 +1,355 @@
1
- import random
2
- from contextlib import contextmanager, nullcontext
3
- from pathlib import Path
4
- from typing import Any
5
-
6
- import numpy as np
7
- import torch
8
-
9
- from .esmfold2_conformers import load_ccd
10
- from .esmfold2_output import build_molecular_complex_from_features
11
- from .esmfold2_prepare_input import ChainInfo, prepare_esmfold2_input
12
- from .esmfold2_types import (
13
- MSA,
14
- Modification,
15
- ProteinInput,
16
- StructurePredictionInput,
17
- )
18
- from .esmfold2_molecular_complex import MolecularComplexResult
19
-
20
-
21
- @contextmanager
22
- def _seed_context(seed: int | None):
23
- if seed is None:
24
- yield
25
- return
26
- py_state = random.getstate()
27
- np_state = np.random.get_state()
28
- torch_state = torch.random.get_rng_state()
29
- cuda_state = torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None
30
- random.seed(seed)
31
- np.random.seed(seed)
32
- torch.manual_seed(seed)
33
- if torch.cuda.is_available():
34
- torch.cuda.manual_seed_all(seed)
35
- try:
36
- yield
37
- finally:
38
- random.setstate(py_state)
39
- np.random.set_state(np_state)
40
- torch.random.set_rng_state(torch_state)
41
- if cuda_state is not None:
42
- torch.cuda.set_rng_state_all(cuda_state)
43
-
44
-
45
- def clean_esmfold2_input(input: StructurePredictionInput) -> StructurePredictionInput:
46
- """Group identical protein sequences into the same ProteinInput with multiple ids.
47
-
48
- Example: Passing a tetramer like [ProteinInput(id=["0"], seq="AAA|AAA|BBB|BBB")]
49
- gets converted into [ProteinInput(id=["0_0", "0_1"], seq="AAA"),
50
- ProteinInput(id=["0_2", "0_3"], seq="BBB")]
51
-
52
- Preserves the original order of unique sequences. Also converts "|" chainbreak
53
- tokens to ":" in the sequence.
54
- """
55
- cleaned_sequences: list = []
56
- chain_to_ids: dict[str, list[str]] = {}
57
- chain_to_modifications: dict[str, list] = {}
58
- chain_to_msa: dict[str, MSA | None] = {}
59
-
60
- for item in input.sequences:
61
- if isinstance(item, ProteinInput):
62
- sequence = ":".join(item.sequence.split("|"))
63
- if ":" not in sequence:
64
- cleaned_sequences.append(item)
65
- continue
66
-
67
- if ":" in sequence and input.covalent_bonds is not None:
68
- raise ValueError(
69
- "Covalent bonds are not supported when using chainbreaks. "
70
- "Chains must be separated into multiple ProteinInput objects."
71
- )
72
-
73
- base_id = item.id[0] if isinstance(item.id, list) else item.id
74
- chain_to_ids = {}
75
- chain_to_modifications = {}
76
- chain_to_msa = {}
77
- chains = sequence.split(":")
78
-
79
- chain_start_positions = []
80
- pos = 0
81
- for chain in chains:
82
- chain_start_positions.append(pos)
83
- pos += len(chain) + 1
84
-
85
- if item.modifications is not None:
86
- for chain_idx, chain in enumerate(chains):
87
- chain_start = chain_start_positions[chain_idx]
88
- chain_end = chain_start + len(chain)
89
- chain_modifications = []
90
- for mod in item.modifications:
91
- if chain_start <= mod.position < chain_end:
92
- adjusted_mod = Modification(
93
- position=mod.position - chain_start, ccd=mod.ccd
94
- )
95
- chain_modifications.append(adjusted_mod)
96
- if chain not in chain_to_modifications:
97
- chain_to_modifications[chain] = chain_modifications
98
- else:
99
- chain_to_modifications[chain].extend(chain_modifications)
100
-
101
- if item.msa is not None:
102
- for chain_idx, chain in enumerate(chains):
103
- if chain not in chain_to_msa:
104
- chain_start = chain_start_positions[chain_idx]
105
- chain_end = chain_start + len(chain)
106
- chain_msa = item.msa.select_positions( # type: ignore
107
- np.arange(chain_start, chain_end)
108
- )
109
- chain_to_msa[chain] = chain_msa
110
-
111
- for i, chain in enumerate(chains):
112
- chain_id = base_id + "_" + str(i)
113
- if chain in chain_to_ids:
114
- chain_to_ids[chain].append(chain_id)
115
- else:
116
- chain_to_ids[chain] = [chain_id]
117
- cleaned_sequences.append((item, chain))
118
- else:
119
- cleaned_sequences.append(item)
120
-
121
- for i in range(len(cleaned_sequences)):
122
- if isinstance(cleaned_sequences[i], tuple):
123
- item, chain = cleaned_sequences[i]
124
- chain_ids = chain_to_ids[chain]
125
- chain_modifications = (
126
- chain_to_modifications.get(chain) if item.modifications else None
127
- )
128
- chain_msa = chain_to_msa.get(chain) if item.msa else None
129
- cleaned_sequences[i] = ProteinInput(
130
- id=chain_ids,
131
- sequence=chain,
132
- msa=chain_msa,
133
- modifications=chain_modifications,
134
- )
135
-
136
- return StructurePredictionInput(
137
- sequences=cleaned_sequences,
138
- distogram_conditioning=input.distogram_conditioning,
139
- covalent_bonds=input.covalent_bonds,
140
- )
141
-
142
-
143
- class ESMFold2InputBuilder:
144
- def __init__(self, ccd_cache: Path | None = None):
145
- load_ccd(ccd_cache)
146
-
147
- def prepare_input(
148
- self,
149
- input: StructurePredictionInput,
150
- seed: int | None = None,
151
- device: torch.device | str | None = None,
152
- ) -> tuple[dict, list[ChainInfo]]:
153
- """Prepare raw input for the folding model.
154
-
155
- Converts user-provided StructurePredictionInput into batched tensors
156
- ready for model inference.
157
-
158
- Parameters
159
- ----------
160
- input : StructurePredictionInput
161
- Input specification (sequences, structures, constraints, etc.).
162
- seed : int, optional
163
- Random seed for reproducibility.
164
- device : torch.device or str, optional
165
- Target device for the returned tensors. Defaults to CPU; pass
166
- ``model.device`` to skip a separate ``.to(...)`` step. ``fold()``
167
- forwards ``model.device`` automatically.
168
-
169
- Returns
170
- -------
171
- tuple[dict, list[ChainInfo]]
172
- Batched input tensors and chain metadata for output processing.
173
- """
174
- structure_prediction_input = clean_esmfold2_input(input)
175
- with _seed_context(seed) if seed is not None else nullcontext():
176
- features, chain_infos = prepare_esmfold2_input(
177
- structure_prediction_input, seed=seed
178
- )
179
- features = {
180
- k: (v[None].to(device) if device is not None else v[None])
181
- if isinstance(v, torch.Tensor)
182
- else v
183
- for k, v in features.items()
184
- }
185
-
186
- return features, chain_infos
187
-
188
- def __call__(
189
- self,
190
- input: StructurePredictionInput,
191
- seed: int | None = None,
192
- device: torch.device | str | None = None,
193
- ) -> tuple[dict, list[ChainInfo]]:
194
- return self.prepare_input(input, seed=seed, device=device)
195
-
196
- def decode(
197
- self,
198
- output: dict[str, torch.Tensor],
199
- features: dict[str, torch.Tensor],
200
- chain_infos: list[ChainInfo],
201
- *,
202
- num_diffusion_samples: int = 1,
203
- complex_id: str = "pred",
204
- ) -> MolecularComplexResult | list[MolecularComplexResult]:
205
- """Convert raw model outputs into one MolecularComplexResult per sample.
206
-
207
- Parameters
208
- ----------
209
- output : dict[str, Tensor]
210
- Output dict returned by ESMFold2Model.forward.
211
- features : dict[str, Tensor]
212
- Feature dict from :meth:`prepare_input` (batched, on the model device).
213
- chain_infos : list[ChainInfo]
214
- Chain metadata returned alongside `features`.
215
- num_diffusion_samples : int
216
- Number of diffusion samples present in the output (Bm = B * num_diffusion_samples).
217
- complex_id : str
218
- Identifier assigned to each MolecularComplex.
219
-
220
- Returns
221
- -------
222
- MolecularComplexResult or list[MolecularComplexResult]
223
- A single result when num_diffusion_samples == 1, otherwise a list of length Bm.
224
- """
225
- atom_mask = features["atom_attention_mask"][0]
226
- ref_element = features["ref_element"][0]
227
- ref_atom_name_chars = features["ref_atom_name_chars"][0]
228
-
229
- sample_coords = output["sample_atom_coords"]
230
- plddts = output["plddt"]
231
- Bm = sample_coords.shape[0]
232
-
233
- ptm_t = output.get("ptm")
234
- iptm_t = output.get("iptm")
235
- pae_t = output.get("pae")
236
- distogram_t = output.get("distogram_logits")
237
- pair_chains_t = output.get("pair_chains_iptm")
238
- residue_index_t = output.get("residue_index")
239
- entity_id_t = output.get("entity_id")
240
-
241
- results: list[MolecularComplexResult] = []
242
- for i in range(Bm):
243
- mc = build_molecular_complex_from_features(
244
- coords=sample_coords[i],
245
- plddt=plddts[i],
246
- atom_mask=atom_mask,
247
- ref_element=ref_element,
248
- ref_atom_name_chars=ref_atom_name_chars,
249
- chain_infos=chain_infos,
250
- complex_id=complex_id,
251
- )
252
- results.append(
253
- MolecularComplexResult(
254
- complex=mc,
255
- plddt=plddts[i].detach().cpu(),
256
- ptm=float(ptm_t[i].item()) if ptm_t is not None else None,
257
- iptm=float(iptm_t[i].item()) if iptm_t is not None else None,
258
- pae=pae_t[i].detach().cpu() if pae_t is not None else None,
259
- distogram=(
260
- distogram_t[0].detach().cpu()
261
- if distogram_t is not None
262
- else None
263
- ),
264
- pair_chains_iptm=(
265
- pair_chains_t[i].detach().cpu()
266
- if pair_chains_t is not None
267
- else None
268
- ),
269
- residue_index=(
270
- residue_index_t[0].detach().cpu()
271
- if residue_index_t is not None
272
- else None
273
- ),
274
- entity_id=(
275
- entity_id_t[0].detach().cpu()
276
- if entity_id_t is not None
277
- else None
278
- ),
279
- )
280
- )
281
-
282
- if num_diffusion_samples == 1 and len(results) == 1:
283
- return results[0]
284
- return results
285
-
286
- def fold(
287
- self,
288
- model: Any,
289
- input: StructurePredictionInput,
290
- *,
291
- num_loops: int = 3,
292
- num_sampling_steps: int = 200,
293
- num_diffusion_samples: int = 1,
294
- seed: int | None = None,
295
- noise_scale: float | None = None,
296
- step_scale: float | None = None,
297
- max_inference_sigma: int | None = None,
298
- early_exit: bool = False,
299
- complex_id: str = "pred",
300
- ) -> MolecularComplexResult | list[MolecularComplexResult]:
301
- """Fold a structure end-to-end: encode → model → decode.
302
-
303
- Parameters
304
- ----------
305
- model : ESMFold2Model
306
- The folding model. Must already be on the target device and in eval mode.
307
- input : StructurePredictionInput
308
- User-facing input specification.
309
- num_loops, num_sampling_steps, num_diffusion_samples : int
310
- Inference knobs forwarded to the model.
311
- seed : int, optional
312
- Seeds both input prep (SMILES conformer generation) and diffusion sampling.
313
- noise_scale, step_scale, max_inference_sigma, early_exit
314
- Optional sampler overrides forwarded to the model when not None.
315
- complex_id : str
316
- Identifier assigned to the predicted MolecularComplex(es).
317
-
318
- Returns
319
- -------
320
- MolecularComplexResult or list[MolecularComplexResult]
321
- A single result when num_diffusion_samples == 1, otherwise a list.
322
- """
323
- features, chain_infos = self.prepare_input(
324
- input, seed=seed, device=model.device
325
- )
326
-
327
- sampler_kwargs: dict[str, Any] = {}
328
- if noise_scale is not None:
329
- sampler_kwargs["noise_scale"] = noise_scale
330
- if step_scale is not None:
331
- sampler_kwargs["step_scale"] = step_scale
332
- if max_inference_sigma is not None:
333
- sampler_kwargs["max_inference_sigma"] = max_inference_sigma
334
-
335
- with torch.no_grad():
336
- with _seed_context(seed) if seed is not None else nullcontext():
337
- output = model(
338
- **features,
339
- num_loops=num_loops,
340
- num_sampling_steps=num_sampling_steps,
341
- num_diffusion_samples=num_diffusion_samples,
342
- early_exit=early_exit,
343
- **sampler_kwargs,
344
- )
345
-
346
- return self.decode(
347
- output,
348
- features,
349
- chain_infos,
350
- num_diffusion_samples=num_diffusion_samples,
351
- complex_id=complex_id,
352
- )
353
-
354
-
355
- __all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input"]
 
1
+ import random
2
+ from contextlib import contextmanager, nullcontext
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+ import torch
8
+
9
+ from .esmfold2_conformers import load_ccd
10
+ from .esmfold2_output import build_molecular_complex_from_features
11
+ from .esmfold2_prepare_input import ChainInfo, prepare_esmfold2_input
12
+ from .esmfold2_types import (
13
+ MSA,
14
+ Modification,
15
+ ProteinInput,
16
+ StructurePredictionInput,
17
+ )
18
+ from .esmfold2_molecular_complex import MolecularComplexResult
19
+
20
+
21
+ @contextmanager
22
+ def _seed_context(seed: int | None):
23
+ if seed is None:
24
+ yield
25
+ return
26
+ py_state = random.getstate()
27
+ np_state = np.random.get_state()
28
+ torch_state = torch.random.get_rng_state()
29
+ cuda_state = torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None
30
+ random.seed(seed)
31
+ np.random.seed(seed)
32
+ torch.manual_seed(seed)
33
+ if torch.cuda.is_available():
34
+ torch.cuda.manual_seed_all(seed)
35
+ try:
36
+ yield
37
+ finally:
38
+ random.setstate(py_state)
39
+ np.random.set_state(np_state)
40
+ torch.random.set_rng_state(torch_state)
41
+ if cuda_state is not None:
42
+ torch.cuda.set_rng_state_all(cuda_state)
43
+
44
+
45
+ def clean_esmfold2_input(input: StructurePredictionInput) -> StructurePredictionInput:
46
+ """Group identical protein sequences into the same ProteinInput with multiple ids.
47
+
48
+ Example: Passing a tetramer like [ProteinInput(id=["0"], seq="AAA|AAA|BBB|BBB")]
49
+ gets converted into [ProteinInput(id=["0_0", "0_1"], seq="AAA"),
50
+ ProteinInput(id=["0_2", "0_3"], seq="BBB")]
51
+
52
+ Preserves the original order of unique sequences. Also converts "|" chainbreak
53
+ tokens to ":" in the sequence.
54
+ """
55
+ cleaned_sequences: list = []
56
+ chain_to_ids: dict[str, list[str]] = {}
57
+ chain_to_modifications: dict[str, list] = {}
58
+ chain_to_msa: dict[str, MSA | None] = {}
59
+
60
+ for item in input.sequences:
61
+ if isinstance(item, ProteinInput):
62
+ sequence = ":".join(item.sequence.split("|"))
63
+ if ":" not in sequence:
64
+ cleaned_sequences.append(item)
65
+ continue
66
+
67
+ if ":" in sequence and input.covalent_bonds is not None:
68
+ raise ValueError(
69
+ "Covalent bonds are not supported when using chainbreaks. "
70
+ "Chains must be separated into multiple ProteinInput objects."
71
+ )
72
+
73
+ base_id = item.id[0] if isinstance(item.id, list) else item.id
74
+ chain_to_ids = {}
75
+ chain_to_modifications = {}
76
+ chain_to_msa = {}
77
+ chains = sequence.split(":")
78
+
79
+ chain_start_positions = []
80
+ pos = 0
81
+ for chain in chains:
82
+ chain_start_positions.append(pos)
83
+ pos += len(chain) + 1
84
+
85
+ if item.modifications is not None:
86
+ for chain_idx, chain in enumerate(chains):
87
+ chain_start = chain_start_positions[chain_idx]
88
+ chain_end = chain_start + len(chain)
89
+ chain_modifications = []
90
+ for mod in item.modifications:
91
+ if chain_start <= mod.position < chain_end:
92
+ adjusted_mod = Modification(
93
+ position=mod.position - chain_start, ccd=mod.ccd
94
+ )
95
+ chain_modifications.append(adjusted_mod)
96
+ if chain not in chain_to_modifications:
97
+ chain_to_modifications[chain] = chain_modifications
98
+ else:
99
+ chain_to_modifications[chain].extend(chain_modifications)
100
+
101
+ if item.msa is not None:
102
+ for chain_idx, chain in enumerate(chains):
103
+ if chain not in chain_to_msa:
104
+ chain_start = chain_start_positions[chain_idx]
105
+ chain_end = chain_start + len(chain)
106
+ chain_msa = item.msa.select_positions( # type: ignore
107
+ np.arange(chain_start, chain_end)
108
+ )
109
+ chain_to_msa[chain] = chain_msa
110
+
111
+ for i, chain in enumerate(chains):
112
+ chain_id = base_id + "_" + str(i)
113
+ if chain in chain_to_ids:
114
+ chain_to_ids[chain].append(chain_id)
115
+ else:
116
+ chain_to_ids[chain] = [chain_id]
117
+ cleaned_sequences.append((item, chain))
118
+ else:
119
+ cleaned_sequences.append(item)
120
+
121
+ for i in range(len(cleaned_sequences)):
122
+ if isinstance(cleaned_sequences[i], tuple):
123
+ item, chain = cleaned_sequences[i]
124
+ chain_ids = chain_to_ids[chain]
125
+ chain_modifications = (
126
+ chain_to_modifications.get(chain) if item.modifications else None
127
+ )
128
+ chain_msa = chain_to_msa.get(chain) if item.msa else None
129
+ cleaned_sequences[i] = ProteinInput(
130
+ id=chain_ids,
131
+ sequence=chain,
132
+ msa=chain_msa,
133
+ modifications=chain_modifications,
134
+ )
135
+
136
+ return StructurePredictionInput(
137
+ sequences=cleaned_sequences,
138
+ distogram_conditioning=input.distogram_conditioning,
139
+ covalent_bonds=input.covalent_bonds,
140
+ )
141
+
142
+
143
+ class ESMFold2InputBuilder:
144
+ def __init__(self, ccd_cache: Path | None = None):
145
+ load_ccd(ccd_cache)
146
+
147
+ def prepare_input(
148
+ self,
149
+ input: StructurePredictionInput,
150
+ seed: int | None = None,
151
+ device: torch.device | str | None = None,
152
+ ) -> tuple[dict, list[ChainInfo]]:
153
+ """Prepare raw input for the folding model.
154
+
155
+ Converts user-provided StructurePredictionInput into batched tensors
156
+ ready for model inference.
157
+
158
+ Parameters
159
+ ----------
160
+ input : StructurePredictionInput
161
+ Input specification (sequences, structures, constraints, etc.).
162
+ seed : int, optional
163
+ Random seed for reproducibility.
164
+ device : torch.device or str, optional
165
+ Target device for the returned tensors. Defaults to CPU; pass
166
+ ``model.device`` to skip a separate ``.to(...)`` step. ``fold()``
167
+ forwards ``model.device`` automatically.
168
+
169
+ Returns
170
+ -------
171
+ tuple[dict, list[ChainInfo]]
172
+ Batched input tensors and chain metadata for output processing.
173
+ """
174
+ structure_prediction_input = clean_esmfold2_input(input)
175
+ with _seed_context(seed) if seed is not None else nullcontext():
176
+ features, chain_infos = prepare_esmfold2_input(
177
+ structure_prediction_input, seed=seed
178
+ )
179
+ features = {
180
+ k: (v[None].to(device) if device is not None else v[None])
181
+ if isinstance(v, torch.Tensor)
182
+ else v
183
+ for k, v in features.items()
184
+ }
185
+
186
+ return features, chain_infos
187
+
188
+ def __call__(
189
+ self,
190
+ input: StructurePredictionInput,
191
+ seed: int | None = None,
192
+ device: torch.device | str | None = None,
193
+ ) -> tuple[dict, list[ChainInfo]]:
194
+ return self.prepare_input(input, seed=seed, device=device)
195
+
196
+ def decode(
197
+ self,
198
+ output: dict[str, torch.Tensor],
199
+ features: dict[str, torch.Tensor],
200
+ chain_infos: list[ChainInfo],
201
+ *,
202
+ num_diffusion_samples: int = 1,
203
+ complex_id: str = "pred",
204
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
205
+ """Convert raw model outputs into one MolecularComplexResult per sample.
206
+
207
+ Parameters
208
+ ----------
209
+ output : dict[str, Tensor]
210
+ Output dict returned by ESMFold2Model.forward.
211
+ features : dict[str, Tensor]
212
+ Feature dict from :meth:`prepare_input` (batched, on the model device).
213
+ chain_infos : list[ChainInfo]
214
+ Chain metadata returned alongside `features`.
215
+ num_diffusion_samples : int
216
+ Number of diffusion samples present in the output (Bm = B * num_diffusion_samples).
217
+ complex_id : str
218
+ Identifier assigned to each MolecularComplex.
219
+
220
+ Returns
221
+ -------
222
+ MolecularComplexResult or list[MolecularComplexResult]
223
+ A single result when num_diffusion_samples == 1, otherwise a list of length Bm.
224
+ """
225
+ atom_mask = features["atom_attention_mask"][0]
226
+ ref_element = features["ref_element"][0]
227
+ ref_atom_name_chars = features["ref_atom_name_chars"][0]
228
+
229
+ sample_coords = output["sample_atom_coords"]
230
+ plddts = output["plddt"]
231
+ Bm = sample_coords.shape[0]
232
+
233
+ ptm_t = output.get("ptm")
234
+ iptm_t = output.get("iptm")
235
+ pae_t = output.get("pae")
236
+ distogram_t = output.get("distogram_logits")
237
+ pair_chains_t = output.get("pair_chains_iptm")
238
+ residue_index_t = output.get("residue_index")
239
+ entity_id_t = output.get("entity_id")
240
+
241
+ results: list[MolecularComplexResult] = []
242
+ for i in range(Bm):
243
+ mc = build_molecular_complex_from_features(
244
+ coords=sample_coords[i],
245
+ plddt=plddts[i],
246
+ atom_mask=atom_mask,
247
+ ref_element=ref_element,
248
+ ref_atom_name_chars=ref_atom_name_chars,
249
+ chain_infos=chain_infos,
250
+ complex_id=complex_id,
251
+ )
252
+ results.append(
253
+ MolecularComplexResult(
254
+ complex=mc,
255
+ plddt=plddts[i].detach().cpu(),
256
+ ptm=float(ptm_t[i].item()) if ptm_t is not None else None,
257
+ iptm=float(iptm_t[i].item()) if iptm_t is not None else None,
258
+ pae=pae_t[i].detach().cpu() if pae_t is not None else None,
259
+ distogram=(
260
+ distogram_t[0].detach().cpu()
261
+ if distogram_t is not None
262
+ else None
263
+ ),
264
+ pair_chains_iptm=(
265
+ pair_chains_t[i].detach().cpu()
266
+ if pair_chains_t is not None
267
+ else None
268
+ ),
269
+ residue_index=(
270
+ residue_index_t[0].detach().cpu()
271
+ if residue_index_t is not None
272
+ else None
273
+ ),
274
+ entity_id=(
275
+ entity_id_t[0].detach().cpu()
276
+ if entity_id_t is not None
277
+ else None
278
+ ),
279
+ )
280
+ )
281
+
282
+ if num_diffusion_samples == 1 and len(results) == 1:
283
+ return results[0]
284
+ return results
285
+
286
+ def fold(
287
+ self,
288
+ model: Any,
289
+ input: StructurePredictionInput,
290
+ *,
291
+ num_loops: int = 3,
292
+ num_sampling_steps: int = 200,
293
+ num_diffusion_samples: int = 1,
294
+ seed: int | None = None,
295
+ noise_scale: float | None = None,
296
+ step_scale: float | None = None,
297
+ max_inference_sigma: int | None = None,
298
+ early_exit: bool = False,
299
+ complex_id: str = "pred",
300
+ ) -> MolecularComplexResult | list[MolecularComplexResult]:
301
+ """Fold a structure end-to-end: encode → model → decode.
302
+
303
+ Parameters
304
+ ----------
305
+ model : ESMFold2Model
306
+ The folding model. Must already be on the target device and in eval mode.
307
+ input : StructurePredictionInput
308
+ User-facing input specification.
309
+ num_loops, num_sampling_steps, num_diffusion_samples : int
310
+ Inference knobs forwarded to the model.
311
+ seed : int, optional
312
+ Seeds both input prep (SMILES conformer generation) and diffusion sampling.
313
+ noise_scale, step_scale, max_inference_sigma, early_exit
314
+ Optional sampler overrides forwarded to the model when not None.
315
+ complex_id : str
316
+ Identifier assigned to the predicted MolecularComplex(es).
317
+
318
+ Returns
319
+ -------
320
+ MolecularComplexResult or list[MolecularComplexResult]
321
+ A single result when num_diffusion_samples == 1, otherwise a list.
322
+ """
323
+ features, chain_infos = self.prepare_input(
324
+ input, seed=seed, device=model.device
325
+ )
326
+
327
+ sampler_kwargs: dict[str, Any] = {}
328
+ if noise_scale is not None:
329
+ sampler_kwargs["noise_scale"] = noise_scale
330
+ if step_scale is not None:
331
+ sampler_kwargs["step_scale"] = step_scale
332
+ if max_inference_sigma is not None:
333
+ sampler_kwargs["max_inference_sigma"] = max_inference_sigma
334
+
335
+ with torch.no_grad():
336
+ with _seed_context(seed) if seed is not None else nullcontext():
337
+ output = model(
338
+ **features,
339
+ num_loops=num_loops,
340
+ num_sampling_steps=num_sampling_steps,
341
+ num_diffusion_samples=num_diffusion_samples,
342
+ early_exit=early_exit,
343
+ **sampler_kwargs,
344
+ )
345
+
346
+ return self.decode(
347
+ output,
348
+ features,
349
+ chain_infos,
350
+ num_diffusion_samples=num_diffusion_samples,
351
+ complex_id=complex_id,
352
+ )
353
+
354
+
355
+ __all__ = ["ESMFold2InputBuilder", "clean_esmfold2_input"]
esmfold2_protein_chain.py CHANGED
The diff for this file is too large to render. See raw diff
 
esmfold2_protein_complex.py CHANGED
The diff for this file is too large to render. See raw diff
 
esmfold2_protein_structure.py CHANGED
@@ -1,306 +1,306 @@
1
- from __future__ import annotations
2
-
3
- from typing import Tuple, TypeVar
4
-
5
- import numpy as np
6
- import torch
7
- import torch.nn.functional as F
8
- from torch import Tensor
9
- from torch.amp import autocast # type: ignore
10
-
11
- from . import esmfold2_residue_constants as residue_constants
12
- from .esmfold2_misc import unbinpack
13
- from .esmfold2_affine3d import Affine3D
14
-
15
- ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
16
-
17
-
18
- def index_by_atom_name(
19
- atom37: ArrayOrTensor, atom_names: str | list[str], dim: int = -2
20
- ) -> ArrayOrTensor:
21
- squeeze = False
22
- if isinstance(atom_names, str):
23
- atom_names = [atom_names]
24
- squeeze = True
25
- indices = [residue_constants.atom_order[atom_name] for atom_name in atom_names]
26
- dim = dim % atom37.ndim
27
- index = tuple(slice(None) if dim != i else indices for i in range(atom37.ndim))
28
- result = atom37[index] # type: ignore
29
- if squeeze:
30
- result = result.squeeze(dim)
31
- return result
32
-
33
-
34
- def infer_cbeta_from_atom37(
35
- atom37: ArrayOrTensor, L: float = 1.522, A: float = 1.927, D: float = -2.143
36
- ):
37
- """
38
- Inspired by a util in trDesign:
39
- https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L92
40
-
41
- input: atom37, (L)ength, (A)ngle, and (D)ihedral
42
- output: 4th coord
43
- """
44
- N = index_by_atom_name(atom37, "N", dim=-2)
45
- CA = index_by_atom_name(atom37, "CA", dim=-2)
46
- C = index_by_atom_name(atom37, "C", dim=-2)
47
-
48
- if isinstance(atom37, np.ndarray):
49
-
50
- def normalize(x: ArrayOrTensor):
51
- return x / np.linalg.norm(x, axis=-1, keepdims=True)
52
-
53
- cross = np.cross
54
- else:
55
- normalize = F.normalize # type: ignore
56
- cross = torch.cross
57
-
58
- with np.errstate(invalid="ignore"): # inf - inf = nan is ok here
59
- vec_nca = N - CA
60
- vec_nc = N - C
61
- nca = normalize(vec_nca)
62
- n = normalize(cross(vec_nc, nca)) # type: ignore
63
- m = [nca, cross(n, nca), n]
64
- d = [L * np.cos(A), L * np.sin(A) * np.cos(D), -L * np.sin(A) * np.sin(D)]
65
- return CA + sum([m * d for m, d in zip(m, d)])
66
-
67
-
68
- @torch.no_grad()
69
- @autocast("cuda", enabled=False)
70
- def compute_alignment_tensors(
71
- mobile: torch.Tensor,
72
- target: torch.Tensor,
73
- atom_exists_mask: torch.Tensor | None = None,
74
- sequence_id: torch.Tensor | None = None,
75
- ):
76
- """
77
- Align two batches of structures with support for masking invalid atoms using PyTorch.
78
-
79
- Args:
80
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
81
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
82
- - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
83
- - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
84
-
85
- Returns:
86
- - centered_mobile (torch.Tensor): Batch of coordinates of structure centered mobile (B, N, 3)
87
- - centroid_mobile (torch.Tensor): Batch of coordinates of mobile centeroid (B, 3)
88
- - centered_target (torch.Tensor): Batch of coordinates of structure centered target (B, N, 3)
89
- - centroid_target (torch.Tensor): Batch of coordinates of target centeroid (B, 3)
90
- - rotation_matrix (torch.Tensor): Batch of coordinates of rotation matrix (B, 3, 3)
91
- - num_valid_atoms (torch.Tensor): Batch of number of valid atoms for alignment (B,)
92
- """
93
-
94
- # Ensure both batches have the same number of structures, atoms, and dimensions
95
- if sequence_id is not None:
96
- mobile = unbinpack(mobile, sequence_id, pad_value=torch.nan)
97
- target = unbinpack(target, sequence_id, pad_value=torch.nan)
98
- if atom_exists_mask is not None:
99
- atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=0)
100
- else:
101
- atom_exists_mask = torch.isfinite(target).all(-1)
102
-
103
- assert mobile.shape == target.shape, "Batch structure shapes do not match!"
104
-
105
- # Number of structures in the batch
106
- batch_size = mobile.shape[0]
107
-
108
- # if [B, Nres, Natom, 3], resize
109
- if mobile.dim() == 4:
110
- mobile = mobile.view(batch_size, -1, 3)
111
- if target.dim() == 4:
112
- target = target.view(batch_size, -1, 3)
113
- if atom_exists_mask is not None and atom_exists_mask.dim() == 3:
114
- atom_exists_mask = atom_exists_mask.view(batch_size, -1)
115
-
116
- # Number of atoms
117
- num_atoms = mobile.shape[1]
118
-
119
- # Apply masks if provided
120
- if atom_exists_mask is not None:
121
- mobile = mobile.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
122
- target = target.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
123
- else:
124
- atom_exists_mask = torch.ones(
125
- batch_size, num_atoms, dtype=torch.bool, device=mobile.device
126
- )
127
-
128
- num_valid_atoms = atom_exists_mask.sum(dim=-1, keepdim=True)
129
- # Compute centroids for each batch
130
- centroid_mobile = mobile.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
131
- centroid_target = target.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
132
-
133
- # Handle potential division by zero if all atoms are invalid in a structure
134
- centroid_mobile[num_valid_atoms == 0] = 0
135
- centroid_target[num_valid_atoms == 0] = 0
136
-
137
- # Center structures by subtracting centroids
138
- centered_mobile = mobile - centroid_mobile
139
- centered_target = target - centroid_target
140
-
141
- centered_mobile = centered_mobile.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
142
- centered_target = centered_target.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
143
-
144
- # Compute covariance matrix for each batch
145
- covariance_matrix = torch.matmul(centered_mobile.transpose(1, 2), centered_target)
146
-
147
- # Singular Value Decomposition for each batch
148
- u, _, v = torch.svd(covariance_matrix)
149
-
150
- # Calculate rotation matrices for each batch
151
- rotation_matrix = torch.matmul(u, v.transpose(1, 2))
152
-
153
- return (
154
- centered_mobile,
155
- centroid_mobile,
156
- centered_target,
157
- centroid_target,
158
- rotation_matrix,
159
- num_valid_atoms,
160
- )
161
-
162
-
163
- @torch.no_grad()
164
- @autocast("cuda", enabled=False)
165
- def compute_rmsd_no_alignment(
166
- aligned: torch.Tensor,
167
- target: torch.Tensor,
168
- num_valid_atoms: torch.Tensor,
169
- reduction: str = "batch",
170
- ) -> torch.Tensor:
171
- """
172
- Compute RMSD between two batches of structures without alignment.
173
-
174
- Args:
175
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
176
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
177
- - num_valid_atoms (torch.Tensor): Batch of number of valid atoms for alignment (B,)
178
- - reduction (str): One of "batch", "per_sample", "per_residue".
179
-
180
- Returns:
181
-
182
- If reduction == "batch":
183
- (torch.Tensor): 0-dim, Average Root Mean Square Deviation between the structures for each batch
184
- If reduction == "per_sample":
185
- (torch.Tensor): (B,)-dim, Root Mean Square Deviation between the structures for each batch
186
- If reduction == "per_residue":
187
- (torch.Tensor): (B, N)-dim, Root Mean Square Deviation between the structures for residue in the batch
188
- """
189
- if reduction not in ("per_residue", "per_sample", "batch"):
190
- raise ValueError("Unrecognized reduction: '{reduction}'")
191
- # Compute RMSD for each batch
192
- diff = aligned - target
193
- if reduction == "per_residue":
194
- mean_squared_error = diff.square().view(diff.size(0), -1, 9).mean(dim=-1)
195
- else:
196
- mean_squared_error = diff.square().sum(dim=(1, 2)) / (
197
- num_valid_atoms.squeeze(-1)
198
- )
199
-
200
- rmsd = torch.sqrt(mean_squared_error)
201
- if reduction in ("per_sample", "per_residue"):
202
- return rmsd
203
- elif reduction == "batch":
204
- avg_rmsd = rmsd.masked_fill(num_valid_atoms.squeeze(-1) == 0, 0).sum() / (
205
- (num_valid_atoms > 0).sum() + 1e-8
206
- )
207
- return avg_rmsd
208
- else:
209
- raise ValueError(reduction)
210
-
211
-
212
- @torch.no_grad()
213
- @autocast("cuda", enabled=False)
214
- def compute_affine_and_rmsd(
215
- mobile: torch.Tensor,
216
- target: torch.Tensor,
217
- atom_exists_mask: torch.Tensor | None = None,
218
- sequence_id: torch.Tensor | None = None,
219
- ) -> Tuple[Affine3D, torch.Tensor]:
220
- """
221
- Compute RMSD between two batches of structures with support for masking invalid atoms using PyTorch.
222
-
223
- Args:
224
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
225
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
226
- - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
227
- - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
228
-
229
- Returns:
230
- - affine (Affine3D): Transformation between mobile and target structure
231
- - avg_rmsd (torch.Tensor): Average Root Mean Square Deviation between the structures for each batch
232
- """
233
-
234
- (
235
- centered_mobile,
236
- centroid_mobile,
237
- centered_target,
238
- centroid_target,
239
- rotation_matrix,
240
- num_valid_atoms,
241
- ) = compute_alignment_tensors(
242
- mobile=mobile,
243
- target=target,
244
- atom_exists_mask=atom_exists_mask,
245
- sequence_id=sequence_id,
246
- )
247
-
248
- # Apply rotation to mobile centroid
249
- translation = torch.matmul(-centroid_mobile, rotation_matrix) + centroid_target
250
- affine = Affine3D.from_tensor_pair(
251
- translation, rotation_matrix.unsqueeze(dim=-3).transpose(-2, -1)
252
- )
253
-
254
- # Apply transformation to centered structure to compute rmsd
255
- rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
256
- avg_rmsd = compute_rmsd_no_alignment(
257
- rotated_mobile, centered_target, num_valid_atoms, reduction="batch"
258
- )
259
-
260
- return affine, avg_rmsd
261
-
262
-
263
- def compute_gdt_ts_no_alignment(
264
- aligned: torch.Tensor,
265
- target: torch.Tensor,
266
- atom_exists_mask: torch.Tensor,
267
- reduction: str = "batch",
268
- ) -> torch.Tensor:
269
- """
270
- Compute GDT_TS between two batches of structures without alignment.
271
-
272
- Args:
273
- - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
274
- - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
275
- - atom_exists_mask (torch.Tensor): Mask for Whether an atom exists of shape (B, N). noo
276
- - reduction (str): One of "batch", "per_sample".
277
-
278
- Returns:
279
- If reduction == "batch":
280
- (torch.Tensor): 0-dim, GDT_TS between the structures for each batch
281
- If reduction == "per_sample":
282
- (torch.Tensor): (B,)-dim, GDT_TS between the structures for each sample in the batch
283
- """
284
- if reduction not in ("per_sample", "batch"):
285
- raise ValueError("Unrecognized reduction: '{reduction}'")
286
-
287
- if atom_exists_mask is None:
288
- atom_exists_mask = torch.isfinite(target).all(dim=-1)
289
-
290
- deviation = torch.linalg.vector_norm(aligned - target, dim=-1)
291
- num_valid_atoms = atom_exists_mask.sum(dim=-1)
292
-
293
- # Compute GDT_TS
294
- score = (
295
- ((deviation < 1) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
296
- + ((deviation < 2) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
297
- + ((deviation < 4) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
298
- + ((deviation < 8) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
299
- ) * 0.25
300
-
301
- if reduction == "batch":
302
- return score.mean()
303
- elif reduction == "per_sample":
304
- return score
305
- else:
306
- raise ValueError("Unrecognized reduction: '{reduction}'")
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Tuple, TypeVar
4
+
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn.functional as F
8
+ from torch import Tensor
9
+ from torch.amp import autocast # type: ignore
10
+
11
+ from . import esmfold2_residue_constants as residue_constants
12
+ from .esmfold2_misc import unbinpack
13
+ from .esmfold2_affine3d import Affine3D
14
+
15
+ ArrayOrTensor = TypeVar("ArrayOrTensor", np.ndarray, Tensor)
16
+
17
+
18
+ def index_by_atom_name(
19
+ atom37: ArrayOrTensor, atom_names: str | list[str], dim: int = -2
20
+ ) -> ArrayOrTensor:
21
+ squeeze = False
22
+ if isinstance(atom_names, str):
23
+ atom_names = [atom_names]
24
+ squeeze = True
25
+ indices = [residue_constants.atom_order[atom_name] for atom_name in atom_names]
26
+ dim = dim % atom37.ndim
27
+ index = tuple(slice(None) if dim != i else indices for i in range(atom37.ndim))
28
+ result = atom37[index] # type: ignore
29
+ if squeeze:
30
+ result = result.squeeze(dim)
31
+ return result
32
+
33
+
34
+ def infer_cbeta_from_atom37(
35
+ atom37: ArrayOrTensor, L: float = 1.522, A: float = 1.927, D: float = -2.143
36
+ ):
37
+ """
38
+ Inspired by a util in trDesign:
39
+ https://github.com/gjoni/trDesign/blob/f2d5930b472e77bfacc2f437b3966e7a708a8d37/02-GD/utils.py#L92
40
+
41
+ input: atom37, (L)ength, (A)ngle, and (D)ihedral
42
+ output: 4th coord
43
+ """
44
+ N = index_by_atom_name(atom37, "N", dim=-2)
45
+ CA = index_by_atom_name(atom37, "CA", dim=-2)
46
+ C = index_by_atom_name(atom37, "C", dim=-2)
47
+
48
+ if isinstance(atom37, np.ndarray):
49
+
50
+ def normalize(x: ArrayOrTensor):
51
+ return x / np.linalg.norm(x, axis=-1, keepdims=True)
52
+
53
+ cross = np.cross
54
+ else:
55
+ normalize = F.normalize # type: ignore
56
+ cross = torch.cross
57
+
58
+ with np.errstate(invalid="ignore"): # inf - inf = nan is ok here
59
+ vec_nca = N - CA
60
+ vec_nc = N - C
61
+ nca = normalize(vec_nca)
62
+ n = normalize(cross(vec_nc, nca)) # type: ignore
63
+ m = [nca, cross(n, nca), n]
64
+ d = [L * np.cos(A), L * np.sin(A) * np.cos(D), -L * np.sin(A) * np.sin(D)]
65
+ return CA + sum([m * d for m, d in zip(m, d)])
66
+
67
+
68
+ @torch.no_grad()
69
+ @autocast("cuda", enabled=False)
70
+ def compute_alignment_tensors(
71
+ mobile: torch.Tensor,
72
+ target: torch.Tensor,
73
+ atom_exists_mask: torch.Tensor | None = None,
74
+ sequence_id: torch.Tensor | None = None,
75
+ ):
76
+ """
77
+ Align two batches of structures with support for masking invalid atoms using PyTorch.
78
+
79
+ Args:
80
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
81
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
82
+ - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
83
+ - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
84
+
85
+ Returns:
86
+ - centered_mobile (torch.Tensor): Batch of coordinates of structure centered mobile (B, N, 3)
87
+ - centroid_mobile (torch.Tensor): Batch of coordinates of mobile centeroid (B, 3)
88
+ - centered_target (torch.Tensor): Batch of coordinates of structure centered target (B, N, 3)
89
+ - centroid_target (torch.Tensor): Batch of coordinates of target centeroid (B, 3)
90
+ - rotation_matrix (torch.Tensor): Batch of coordinates of rotation matrix (B, 3, 3)
91
+ - num_valid_atoms (torch.Tensor): Batch of number of valid atoms for alignment (B,)
92
+ """
93
+
94
+ # Ensure both batches have the same number of structures, atoms, and dimensions
95
+ if sequence_id is not None:
96
+ mobile = unbinpack(mobile, sequence_id, pad_value=torch.nan)
97
+ target = unbinpack(target, sequence_id, pad_value=torch.nan)
98
+ if atom_exists_mask is not None:
99
+ atom_exists_mask = unbinpack(atom_exists_mask, sequence_id, pad_value=0)
100
+ else:
101
+ atom_exists_mask = torch.isfinite(target).all(-1)
102
+
103
+ assert mobile.shape == target.shape, "Batch structure shapes do not match!"
104
+
105
+ # Number of structures in the batch
106
+ batch_size = mobile.shape[0]
107
+
108
+ # if [B, Nres, Natom, 3], resize
109
+ if mobile.dim() == 4:
110
+ mobile = mobile.view(batch_size, -1, 3)
111
+ if target.dim() == 4:
112
+ target = target.view(batch_size, -1, 3)
113
+ if atom_exists_mask is not None and atom_exists_mask.dim() == 3:
114
+ atom_exists_mask = atom_exists_mask.view(batch_size, -1)
115
+
116
+ # Number of atoms
117
+ num_atoms = mobile.shape[1]
118
+
119
+ # Apply masks if provided
120
+ if atom_exists_mask is not None:
121
+ mobile = mobile.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
122
+ target = target.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
123
+ else:
124
+ atom_exists_mask = torch.ones(
125
+ batch_size, num_atoms, dtype=torch.bool, device=mobile.device
126
+ )
127
+
128
+ num_valid_atoms = atom_exists_mask.sum(dim=-1, keepdim=True)
129
+ # Compute centroids for each batch
130
+ centroid_mobile = mobile.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
131
+ centroid_target = target.sum(dim=-2, keepdim=True) / num_valid_atoms.unsqueeze(-1)
132
+
133
+ # Handle potential division by zero if all atoms are invalid in a structure
134
+ centroid_mobile[num_valid_atoms == 0] = 0
135
+ centroid_target[num_valid_atoms == 0] = 0
136
+
137
+ # Center structures by subtracting centroids
138
+ centered_mobile = mobile - centroid_mobile
139
+ centered_target = target - centroid_target
140
+
141
+ centered_mobile = centered_mobile.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
142
+ centered_target = centered_target.masked_fill(~atom_exists_mask.unsqueeze(-1), 0)
143
+
144
+ # Compute covariance matrix for each batch
145
+ covariance_matrix = torch.matmul(centered_mobile.transpose(1, 2), centered_target)
146
+
147
+ # Singular Value Decomposition for each batch
148
+ u, _, v = torch.svd(covariance_matrix)
149
+
150
+ # Calculate rotation matrices for each batch
151
+ rotation_matrix = torch.matmul(u, v.transpose(1, 2))
152
+
153
+ return (
154
+ centered_mobile,
155
+ centroid_mobile,
156
+ centered_target,
157
+ centroid_target,
158
+ rotation_matrix,
159
+ num_valid_atoms,
160
+ )
161
+
162
+
163
+ @torch.no_grad()
164
+ @autocast("cuda", enabled=False)
165
+ def compute_rmsd_no_alignment(
166
+ aligned: torch.Tensor,
167
+ target: torch.Tensor,
168
+ num_valid_atoms: torch.Tensor,
169
+ reduction: str = "batch",
170
+ ) -> torch.Tensor:
171
+ """
172
+ Compute RMSD between two batches of structures without alignment.
173
+
174
+ Args:
175
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
176
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
177
+ - num_valid_atoms (torch.Tensor): Batch of number of valid atoms for alignment (B,)
178
+ - reduction (str): One of "batch", "per_sample", "per_residue".
179
+
180
+ Returns:
181
+
182
+ If reduction == "batch":
183
+ (torch.Tensor): 0-dim, Average Root Mean Square Deviation between the structures for each batch
184
+ If reduction == "per_sample":
185
+ (torch.Tensor): (B,)-dim, Root Mean Square Deviation between the structures for each batch
186
+ If reduction == "per_residue":
187
+ (torch.Tensor): (B, N)-dim, Root Mean Square Deviation between the structures for residue in the batch
188
+ """
189
+ if reduction not in ("per_residue", "per_sample", "batch"):
190
+ raise ValueError("Unrecognized reduction: '{reduction}'")
191
+ # Compute RMSD for each batch
192
+ diff = aligned - target
193
+ if reduction == "per_residue":
194
+ mean_squared_error = diff.square().view(diff.size(0), -1, 9).mean(dim=-1)
195
+ else:
196
+ mean_squared_error = diff.square().sum(dim=(1, 2)) / (
197
+ num_valid_atoms.squeeze(-1)
198
+ )
199
+
200
+ rmsd = torch.sqrt(mean_squared_error)
201
+ if reduction in ("per_sample", "per_residue"):
202
+ return rmsd
203
+ elif reduction == "batch":
204
+ avg_rmsd = rmsd.masked_fill(num_valid_atoms.squeeze(-1) == 0, 0).sum() / (
205
+ (num_valid_atoms > 0).sum() + 1e-8
206
+ )
207
+ return avg_rmsd
208
+ else:
209
+ raise ValueError(reduction)
210
+
211
+
212
+ @torch.no_grad()
213
+ @autocast("cuda", enabled=False)
214
+ def compute_affine_and_rmsd(
215
+ mobile: torch.Tensor,
216
+ target: torch.Tensor,
217
+ atom_exists_mask: torch.Tensor | None = None,
218
+ sequence_id: torch.Tensor | None = None,
219
+ ) -> Tuple[Affine3D, torch.Tensor]:
220
+ """
221
+ Compute RMSD between two batches of structures with support for masking invalid atoms using PyTorch.
222
+
223
+ Args:
224
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
225
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
226
+ - atom_exists_mask (torch.Tensor, optional): Mask for Whether an atom exists of shape (B, N)
227
+ - sequence_id (torch.Tensor, optional): Sequence id tensor for binpacking.
228
+
229
+ Returns:
230
+ - affine (Affine3D): Transformation between mobile and target structure
231
+ - avg_rmsd (torch.Tensor): Average Root Mean Square Deviation between the structures for each batch
232
+ """
233
+
234
+ (
235
+ centered_mobile,
236
+ centroid_mobile,
237
+ centered_target,
238
+ centroid_target,
239
+ rotation_matrix,
240
+ num_valid_atoms,
241
+ ) = compute_alignment_tensors(
242
+ mobile=mobile,
243
+ target=target,
244
+ atom_exists_mask=atom_exists_mask,
245
+ sequence_id=sequence_id,
246
+ )
247
+
248
+ # Apply rotation to mobile centroid
249
+ translation = torch.matmul(-centroid_mobile, rotation_matrix) + centroid_target
250
+ affine = Affine3D.from_tensor_pair(
251
+ translation, rotation_matrix.unsqueeze(dim=-3).transpose(-2, -1)
252
+ )
253
+
254
+ # Apply transformation to centered structure to compute rmsd
255
+ rotated_mobile = torch.matmul(centered_mobile, rotation_matrix)
256
+ avg_rmsd = compute_rmsd_no_alignment(
257
+ rotated_mobile, centered_target, num_valid_atoms, reduction="batch"
258
+ )
259
+
260
+ return affine, avg_rmsd
261
+
262
+
263
+ def compute_gdt_ts_no_alignment(
264
+ aligned: torch.Tensor,
265
+ target: torch.Tensor,
266
+ atom_exists_mask: torch.Tensor,
267
+ reduction: str = "batch",
268
+ ) -> torch.Tensor:
269
+ """
270
+ Compute GDT_TS between two batches of structures without alignment.
271
+
272
+ Args:
273
+ - mobile (torch.Tensor): Batch of coordinates of structure to be superimposed in shape (B, N, 3)
274
+ - target (torch.Tensor): Batch of coordinates of structure that is fixed in shape (B, N, 3)
275
+ - atom_exists_mask (torch.Tensor): Mask for Whether an atom exists of shape (B, N). noo
276
+ - reduction (str): One of "batch", "per_sample".
277
+
278
+ Returns:
279
+ If reduction == "batch":
280
+ (torch.Tensor): 0-dim, GDT_TS between the structures for each batch
281
+ If reduction == "per_sample":
282
+ (torch.Tensor): (B,)-dim, GDT_TS between the structures for each sample in the batch
283
+ """
284
+ if reduction not in ("per_sample", "batch"):
285
+ raise ValueError("Unrecognized reduction: '{reduction}'")
286
+
287
+ if atom_exists_mask is None:
288
+ atom_exists_mask = torch.isfinite(target).all(dim=-1)
289
+
290
+ deviation = torch.linalg.vector_norm(aligned - target, dim=-1)
291
+ num_valid_atoms = atom_exists_mask.sum(dim=-1)
292
+
293
+ # Compute GDT_TS
294
+ score = (
295
+ ((deviation < 1) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
296
+ + ((deviation < 2) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
297
+ + ((deviation < 4) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
298
+ + ((deviation < 8) * atom_exists_mask).sum(dim=-1) / num_valid_atoms
299
+ ) * 0.25
300
+
301
+ if reduction == "batch":
302
+ return score.mean()
303
+ elif reduction == "per_sample":
304
+ return score
305
+ else:
306
+ raise ValueError("Unrecognized reduction: '{reduction}'")
esmfold2_residue_constants.py CHANGED
@@ -1,1223 +1,1223 @@
1
- # Copyright 2025 EvolutionaryScale
2
- # Copyright 2021 AlQuraishi Laboratory
3
- # Copyright 2021 DeepMind Technologies Limited
4
- #
5
- # Licensed under the Apache License, Version 2.0 (the "License");
6
- # you may not use this file except in compliance with the License.
7
- # You may obtain a copy of the License at
8
- #
9
- # http://www.apache.org/licenses/LICENSE-2.0
10
- #
11
- # Unless required by applicable law or agreed to in writing, software
12
- # distributed under the License is distributed on an "AS IS" BASIS,
13
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
- # See the License for the specific language governing permissions and
15
- # limitations under the License.
16
-
17
- """Constants used in AlphaFold."""
18
-
19
- import collections
20
- import functools
21
- from pathlib import Path
22
- from typing import List, Mapping, Tuple
23
-
24
- import numpy as np
25
-
26
- # import tree
27
-
28
- # Internal import (35fd).
29
-
30
-
31
- # Distance from one CA to next CA [trans configuration: omega = 180].
32
- ca_ca = 3.80209737096
33
-
34
- # Format: The list for each AA type contains chi1, chi2, chi3, chi4 in
35
- # this order (or a relevant subset from chi1 onwards). ALA and GLY don't have
36
- # chi angles so their chi angle lists are empty.
37
- chi_angles_atoms = {
38
- "ALA": [],
39
- # Chi5 in arginine is always 0 +- 5 degrees, so ignore it.
40
- "ARG": [
41
- ["N", "CA", "CB", "CG"],
42
- ["CA", "CB", "CG", "CD"],
43
- ["CB", "CG", "CD", "NE"],
44
- ["CG", "CD", "NE", "CZ"],
45
- ],
46
- "ASN": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]],
47
- "ASP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]],
48
- "CYS": [["N", "CA", "CB", "SG"]],
49
- "GLN": [
50
- ["N", "CA", "CB", "CG"],
51
- ["CA", "CB", "CG", "CD"],
52
- ["CB", "CG", "CD", "OE1"],
53
- ],
54
- "GLU": [
55
- ["N", "CA", "CB", "CG"],
56
- ["CA", "CB", "CG", "CD"],
57
- ["CB", "CG", "CD", "OE1"],
58
- ],
59
- "GLY": [],
60
- "HIS": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "ND1"]],
61
- "ILE": [["N", "CA", "CB", "CG1"], ["CA", "CB", "CG1", "CD1"]],
62
- "LEU": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
63
- "LYS": [
64
- ["N", "CA", "CB", "CG"],
65
- ["CA", "CB", "CG", "CD"],
66
- ["CB", "CG", "CD", "CE"],
67
- ["CG", "CD", "CE", "NZ"],
68
- ],
69
- "MET": [
70
- ["N", "CA", "CB", "CG"],
71
- ["CA", "CB", "CG", "SD"],
72
- ["CB", "CG", "SD", "CE"],
73
- ],
74
- "PHE": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
75
- "PRO": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD"]],
76
- "SER": [["N", "CA", "CB", "OG"]],
77
- "THR": [["N", "CA", "CB", "OG1"]],
78
- "TRP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
79
- "TYR": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
80
- "VAL": [["N", "CA", "CB", "CG1"]],
81
- "UNK": [],
82
- }
83
-
84
- # If chi angles given in fixed-length array, this matrix determines how to mask
85
- # them for each AA type. The order is as per restype_order (see below).
86
- chi_angles_mask = [
87
- [0.0, 0.0, 0.0, 0.0], # ALA
88
- [1.0, 1.0, 1.0, 1.0], # ARG
89
- [1.0, 1.0, 0.0, 0.0], # ASN
90
- [1.0, 1.0, 0.0, 0.0], # ASP
91
- [1.0, 0.0, 0.0, 0.0], # CYS
92
- [1.0, 1.0, 1.0, 0.0], # GLN
93
- [1.0, 1.0, 1.0, 0.0], # GLU
94
- [0.0, 0.0, 0.0, 0.0], # GLY
95
- [1.0, 1.0, 0.0, 0.0], # HIS
96
- [1.0, 1.0, 0.0, 0.0], # ILE
97
- [1.0, 1.0, 0.0, 0.0], # LEU
98
- [1.0, 1.0, 1.0, 1.0], # LYS
99
- [1.0, 1.0, 1.0, 0.0], # MET
100
- [1.0, 1.0, 0.0, 0.0], # PHE
101
- [1.0, 1.0, 0.0, 0.0], # PRO
102
- [1.0, 0.0, 0.0, 0.0], # SER
103
- [1.0, 0.0, 0.0, 0.0], # THR
104
- [1.0, 1.0, 0.0, 0.0], # TRP
105
- [1.0, 1.0, 0.0, 0.0], # TYR
106
- [1.0, 0.0, 0.0, 0.0], # VAL
107
- [0.0, 0.0, 0.0, 0.0], # UNK
108
- ]
109
-
110
- # The following chi angles are pi periodic: they can be rotated by a multiple
111
- # of pi without affecting the structure.
112
- chi_pi_periodic = [
113
- [0.0, 0.0, 0.0, 0.0], # ALA
114
- [0.0, 0.0, 0.0, 0.0], # ARG
115
- [0.0, 0.0, 0.0, 0.0], # ASN
116
- [0.0, 1.0, 0.0, 0.0], # ASP
117
- [0.0, 0.0, 0.0, 0.0], # CYS
118
- [0.0, 0.0, 0.0, 0.0], # GLN
119
- [0.0, 0.0, 1.0, 0.0], # GLU
120
- [0.0, 0.0, 0.0, 0.0], # GLY
121
- [0.0, 0.0, 0.0, 0.0], # HIS
122
- [0.0, 0.0, 0.0, 0.0], # ILE
123
- [0.0, 0.0, 0.0, 0.0], # LEU
124
- [0.0, 0.0, 0.0, 0.0], # LYS
125
- [0.0, 0.0, 0.0, 0.0], # MET
126
- [0.0, 1.0, 0.0, 0.0], # PHE
127
- [0.0, 0.0, 0.0, 0.0], # PRO
128
- [0.0, 0.0, 0.0, 0.0], # SER
129
- [0.0, 0.0, 0.0, 0.0], # THR
130
- [0.0, 0.0, 0.0, 0.0], # TRP
131
- [0.0, 1.0, 0.0, 0.0], # TYR
132
- [0.0, 0.0, 0.0, 0.0], # VAL
133
- [0.0, 0.0, 0.0, 0.0], # UNK
134
- ]
135
-
136
- # Atoms positions relative to the 8 rigid groups, defined by the pre-omega, phi,
137
- # psi and chi angles:
138
- # 0: 'backbone group',
139
- # 1: 'pre-omega-group', (empty)
140
- # 2: 'phi-group', (currently empty, because it defines only hydrogens)
141
- # 3: 'psi-group',
142
- # 4,5,6,7: 'chi1,2,3,4-group'
143
- # The atom positions are relative to the axis-end-atom of the corresponding
144
- # rotation axis. The x-axis is in direction of the rotation axis, and the y-axis
145
- # is defined such that the dihedral-angle-definiting atom (the last entry in
146
- # chi_angles_atoms above) is in the xy-plane (with a positive y-coordinate).
147
- # format: [atomname, group_idx, rel_position]
148
- rigid_group_atom_positions = {
149
- "ALA": [
150
- ["N", 0, (-0.525, 1.363, 0.000)],
151
- ["CA", 0, (0.000, 0.000, 0.000)],
152
- ["C", 0, (1.526, -0.000, -0.000)],
153
- ["CB", 0, (-0.529, -0.774, -1.205)],
154
- ["O", 3, (0.627, 1.062, 0.000)],
155
- ],
156
- "ARG": [
157
- ["N", 0, (-0.524, 1.362, -0.000)],
158
- ["CA", 0, (0.000, 0.000, 0.000)],
159
- ["C", 0, (1.525, -0.000, -0.000)],
160
- ["CB", 0, (-0.524, -0.778, -1.209)],
161
- ["O", 3, (0.626, 1.062, 0.000)],
162
- ["CG", 4, (0.616, 1.390, -0.000)],
163
- ["CD", 5, (0.564, 1.414, 0.000)],
164
- ["NE", 6, (0.539, 1.357, -0.000)],
165
- ["NH1", 7, (0.206, 2.301, 0.000)],
166
- ["NH2", 7, (2.078, 0.978, -0.000)],
167
- ["CZ", 7, (0.758, 1.093, -0.000)],
168
- ],
169
- "ASN": [
170
- ["N", 0, (-0.536, 1.357, 0.000)],
171
- ["CA", 0, (0.000, 0.000, 0.000)],
172
- ["C", 0, (1.526, -0.000, -0.000)],
173
- ["CB", 0, (-0.531, -0.787, -1.200)],
174
- ["O", 3, (0.625, 1.062, 0.000)],
175
- ["CG", 4, (0.584, 1.399, 0.000)],
176
- ["ND2", 5, (0.593, -1.188, 0.001)],
177
- ["OD1", 5, (0.633, 1.059, 0.000)],
178
- ],
179
- "ASP": [
180
- ["N", 0, (-0.525, 1.362, -0.000)],
181
- ["CA", 0, (0.000, 0.000, 0.000)],
182
- ["C", 0, (1.527, 0.000, -0.000)],
183
- ["CB", 0, (-0.526, -0.778, -1.208)],
184
- ["O", 3, (0.626, 1.062, -0.000)],
185
- ["CG", 4, (0.593, 1.398, -0.000)],
186
- ["OD1", 5, (0.610, 1.091, 0.000)],
187
- ["OD2", 5, (0.592, -1.101, -0.003)],
188
- ],
189
- "CYS": [
190
- ["N", 0, (-0.522, 1.362, -0.000)],
191
- ["CA", 0, (0.000, 0.000, 0.000)],
192
- ["C", 0, (1.524, 0.000, 0.000)],
193
- ["CB", 0, (-0.519, -0.773, -1.212)],
194
- ["O", 3, (0.625, 1.062, -0.000)],
195
- ["SG", 4, (0.728, 1.653, 0.000)],
196
- ],
197
- "GLN": [
198
- ["N", 0, (-0.526, 1.361, -0.000)],
199
- ["CA", 0, (0.000, 0.000, 0.000)],
200
- ["C", 0, (1.526, 0.000, 0.000)],
201
- ["CB", 0, (-0.525, -0.779, -1.207)],
202
- ["O", 3, (0.626, 1.062, -0.000)],
203
- ["CG", 4, (0.615, 1.393, 0.000)],
204
- ["CD", 5, (0.587, 1.399, -0.000)],
205
- ["NE2", 6, (0.593, -1.189, -0.001)],
206
- ["OE1", 6, (0.634, 1.060, 0.000)],
207
- ],
208
- "GLU": [
209
- ["N", 0, (-0.528, 1.361, 0.000)],
210
- ["CA", 0, (0.000, 0.000, 0.000)],
211
- ["C", 0, (1.526, -0.000, -0.000)],
212
- ["CB", 0, (-0.526, -0.781, -1.207)],
213
- ["O", 3, (0.626, 1.062, 0.000)],
214
- ["CG", 4, (0.615, 1.392, 0.000)],
215
- ["CD", 5, (0.600, 1.397, 0.000)],
216
- ["OE1", 6, (0.607, 1.095, -0.000)],
217
- ["OE2", 6, (0.589, -1.104, -0.001)],
218
- ],
219
- "GLY": [
220
- ["N", 0, (-0.572, 1.337, 0.000)],
221
- ["CA", 0, (0.000, 0.000, 0.000)],
222
- ["C", 0, (1.517, -0.000, -0.000)],
223
- ["O", 3, (0.626, 1.062, -0.000)],
224
- ],
225
- "HIS": [
226
- ["N", 0, (-0.527, 1.360, 0.000)],
227
- ["CA", 0, (0.000, 0.000, 0.000)],
228
- ["C", 0, (1.525, 0.000, 0.000)],
229
- ["CB", 0, (-0.525, -0.778, -1.208)],
230
- ["O", 3, (0.625, 1.063, 0.000)],
231
- ["CG", 4, (0.600, 1.370, -0.000)],
232
- ["CD2", 5, (0.889, -1.021, 0.003)],
233
- ["ND1", 5, (0.744, 1.160, -0.000)],
234
- ["CE1", 5, (2.030, 0.851, 0.002)],
235
- ["NE2", 5, (2.145, -0.466, 0.004)],
236
- ],
237
- "ILE": [
238
- ["N", 0, (-0.493, 1.373, -0.000)],
239
- ["CA", 0, (0.000, 0.000, 0.000)],
240
- ["C", 0, (1.527, -0.000, -0.000)],
241
- ["CB", 0, (-0.536, -0.793, -1.213)],
242
- ["O", 3, (0.627, 1.062, -0.000)],
243
- ["CG1", 4, (0.534, 1.437, -0.000)],
244
- ["CG2", 4, (0.540, -0.785, -1.199)],
245
- ["CD1", 5, (0.619, 1.391, 0.000)],
246
- ],
247
- "LEU": [
248
- ["N", 0, (-0.520, 1.363, 0.000)],
249
- ["CA", 0, (0.000, 0.000, 0.000)],
250
- ["C", 0, (1.525, -0.000, -0.000)],
251
- ["CB", 0, (-0.522, -0.773, -1.214)],
252
- ["O", 3, (0.625, 1.063, -0.000)],
253
- ["CG", 4, (0.678, 1.371, 0.000)],
254
- ["CD1", 5, (0.530, 1.430, -0.000)],
255
- ["CD2", 5, (0.535, -0.774, 1.200)],
256
- ],
257
- "LYS": [
258
- ["N", 0, (-0.526, 1.362, -0.000)],
259
- ["CA", 0, (0.000, 0.000, 0.000)],
260
- ["C", 0, (1.526, 0.000, 0.000)],
261
- ["CB", 0, (-0.524, -0.778, -1.208)],
262
- ["O", 3, (0.626, 1.062, -0.000)],
263
- ["CG", 4, (0.619, 1.390, 0.000)],
264
- ["CD", 5, (0.559, 1.417, 0.000)],
265
- ["CE", 6, (0.560, 1.416, 0.000)],
266
- ["NZ", 7, (0.554, 1.387, 0.000)],
267
- ],
268
- "MET": [
269
- ["N", 0, (-0.521, 1.364, -0.000)],
270
- ["CA", 0, (0.000, 0.000, 0.000)],
271
- ["C", 0, (1.525, 0.000, 0.000)],
272
- ["CB", 0, (-0.523, -0.776, -1.210)],
273
- ["O", 3, (0.625, 1.062, -0.000)],
274
- ["CG", 4, (0.613, 1.391, -0.000)],
275
- ["SD", 5, (0.703, 1.695, 0.000)],
276
- ["CE", 6, (0.320, 1.786, -0.000)],
277
- ],
278
- "PHE": [
279
- ["N", 0, (-0.518, 1.363, 0.000)],
280
- ["CA", 0, (0.000, 0.000, 0.000)],
281
- ["C", 0, (1.524, 0.000, -0.000)],
282
- ["CB", 0, (-0.525, -0.776, -1.212)],
283
- ["O", 3, (0.626, 1.062, -0.000)],
284
- ["CG", 4, (0.607, 1.377, 0.000)],
285
- ["CD1", 5, (0.709, 1.195, -0.000)],
286
- ["CD2", 5, (0.706, -1.196, 0.000)],
287
- ["CE1", 5, (2.102, 1.198, -0.000)],
288
- ["CE2", 5, (2.098, -1.201, -0.000)],
289
- ["CZ", 5, (2.794, -0.003, -0.001)],
290
- ],
291
- "PRO": [
292
- ["N", 0, (-0.566, 1.351, -0.000)],
293
- ["CA", 0, (0.000, 0.000, 0.000)],
294
- ["C", 0, (1.527, -0.000, 0.000)],
295
- ["CB", 0, (-0.546, -0.611, -1.293)],
296
- ["O", 3, (0.621, 1.066, 0.000)],
297
- ["CG", 4, (0.382, 1.445, 0.0)],
298
- # ['CD', 5, (0.427, 1.440, 0.0)],
299
- ["CD", 5, (0.477, 1.424, 0.0)], # manually made angle 2 degrees larger
300
- ],
301
- "SER": [
302
- ["N", 0, (-0.529, 1.360, -0.000)],
303
- ["CA", 0, (0.000, 0.000, 0.000)],
304
- ["C", 0, (1.525, -0.000, -0.000)],
305
- ["CB", 0, (-0.518, -0.777, -1.211)],
306
- ["O", 3, (0.626, 1.062, -0.000)],
307
- ["OG", 4, (0.503, 1.325, 0.000)],
308
- ],
309
- "THR": [
310
- ["N", 0, (-0.517, 1.364, 0.000)],
311
- ["CA", 0, (0.000, 0.000, 0.000)],
312
- ["C", 0, (1.526, 0.000, -0.000)],
313
- ["CB", 0, (-0.516, -0.793, -1.215)],
314
- ["O", 3, (0.626, 1.062, 0.000)],
315
- ["CG2", 4, (0.550, -0.718, -1.228)],
316
- ["OG1", 4, (0.472, 1.353, 0.000)],
317
- ],
318
- "TRP": [
319
- ["N", 0, (-0.521, 1.363, 0.000)],
320
- ["CA", 0, (0.000, 0.000, 0.000)],
321
- ["C", 0, (1.525, -0.000, 0.000)],
322
- ["CB", 0, (-0.523, -0.776, -1.212)],
323
- ["O", 3, (0.627, 1.062, 0.000)],
324
- ["CG", 4, (0.609, 1.370, -0.000)],
325
- ["CD1", 5, (0.824, 1.091, 0.000)],
326
- ["CD2", 5, (0.854, -1.148, -0.005)],
327
- ["CE2", 5, (2.186, -0.678, -0.007)],
328
- ["CE3", 5, (0.622, -2.530, -0.007)],
329
- ["NE1", 5, (2.140, 0.690, -0.004)],
330
- ["CH2", 5, (3.028, -2.890, -0.013)],
331
- ["CZ2", 5, (3.283, -1.543, -0.011)],
332
- ["CZ3", 5, (1.715, -3.389, -0.011)],
333
- ],
334
- "TYR": [
335
- ["N", 0, (-0.522, 1.362, 0.000)],
336
- ["CA", 0, (0.000, 0.000, 0.000)],
337
- ["C", 0, (1.524, -0.000, -0.000)],
338
- ["CB", 0, (-0.522, -0.776, -1.213)],
339
- ["O", 3, (0.627, 1.062, -0.000)],
340
- ["CG", 4, (0.607, 1.382, -0.000)],
341
- ["CD1", 5, (0.716, 1.195, -0.000)],
342
- ["CD2", 5, (0.713, -1.194, -0.001)],
343
- ["CE1", 5, (2.107, 1.200, -0.002)],
344
- ["CE2", 5, (2.104, -1.201, -0.003)],
345
- ["OH", 5, (4.168, -0.002, -0.005)],
346
- ["CZ", 5, (2.791, -0.001, -0.003)],
347
- ],
348
- "VAL": [
349
- ["N", 0, (-0.494, 1.373, -0.000)],
350
- ["CA", 0, (0.000, 0.000, 0.000)],
351
- ["C", 0, (1.527, -0.000, -0.000)],
352
- ["CB", 0, (-0.533, -0.795, -1.213)],
353
- ["O", 3, (0.627, 1.062, -0.000)],
354
- ["CG1", 4, (0.540, 1.429, -0.000)],
355
- ["CG2", 4, (0.533, -0.776, 1.203)],
356
- ],
357
- # Assume alanine positions for unknown AA
358
- "UNK": [
359
- ["N", 0, (-0.525, 1.363, 0.000)],
360
- ["CA", 0, (0.000, 0.000, 0.000)],
361
- ["C", 0, (1.526, -0.000, -0.000)],
362
- ],
363
- }
364
-
365
- # A list of atoms (excluding hydrogen) for each AA type. PDB naming convention.
366
- residue_atoms = {
367
- "ALA": ["C", "CA", "CB", "N", "O"],
368
- "ARG": ["C", "CA", "CB", "CG", "CD", "CZ", "N", "NE", "O", "NH1", "NH2"],
369
- "ASP": ["C", "CA", "CB", "CG", "N", "O", "OD1", "OD2"],
370
- "ASN": ["C", "CA", "CB", "CG", "N", "ND2", "O", "OD1"],
371
- "CYS": ["C", "CA", "CB", "N", "O", "SG"],
372
- "GLU": ["C", "CA", "CB", "CG", "CD", "N", "O", "OE1", "OE2"],
373
- "GLN": ["C", "CA", "CB", "CG", "CD", "N", "NE2", "O", "OE1"],
374
- "GLY": ["C", "CA", "N", "O"],
375
- "HIS": ["C", "CA", "CB", "CG", "CD2", "CE1", "N", "ND1", "NE2", "O"],
376
- "ILE": ["C", "CA", "CB", "CG1", "CG2", "CD1", "N", "O"],
377
- "LEU": ["C", "CA", "CB", "CG", "CD1", "CD2", "N", "O"],
378
- "LYS": ["C", "CA", "CB", "CG", "CD", "CE", "N", "NZ", "O"],
379
- "MET": ["C", "CA", "CB", "CG", "CE", "N", "O", "SD"],
380
- "PHE": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O"],
381
- "PRO": ["C", "CA", "CB", "CG", "CD", "N", "O"],
382
- "SER": ["C", "CA", "CB", "N", "O", "OG"],
383
- "THR": ["C", "CA", "CB", "CG2", "N", "O", "OG1"],
384
- "TRP": [
385
- "C",
386
- "CA",
387
- "CB",
388
- "CG",
389
- "CD1",
390
- "CD2",
391
- "CE2",
392
- "CE3",
393
- "CZ2",
394
- "CZ3",
395
- "CH2",
396
- "N",
397
- "NE1",
398
- "O",
399
- ],
400
- "TYR": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O", "OH"],
401
- "VAL": ["C", "CA", "CB", "CG1", "CG2", "N", "O"],
402
- "UNK": ["C", "CA", "N"],
403
- }
404
-
405
- # Naming swaps for ambiguous atom names.
406
- # Due to symmetries in the amino acids the naming of atoms is ambiguous in
407
- # 4 of the 20 amino acids.
408
- # (The LDDT paper lists 7 amino acids as ambiguous, but the naming ambiguities
409
- # in LEU, VAL and ARG can be resolved by using the 3d constellations of
410
- # the 'ambiguous' atoms and their neighbours)
411
- # TODO: ^ interpret this
412
- residue_atom_renaming_swaps = {
413
- "ASP": {"OD1": "OD2"},
414
- "GLU": {"OE1": "OE2"},
415
- "PHE": {"CD1": "CD2", "CE1": "CE2"},
416
- "TYR": {"CD1": "CD2", "CE1": "CE2"},
417
- }
418
-
419
- # Van der Waals radii [Angstroem] of the atoms (from Wikipedia)
420
- van_der_waals_radius = {"C": 1.7, "N": 1.55, "O": 1.52, "S": 1.8}
421
-
422
- Bond = collections.namedtuple("Bond", ["atom1_name", "atom2_name", "length", "stddev"])
423
- BondAngle = collections.namedtuple(
424
- "BondAngle", ["atom1_name", "atom2_name", "atom3name", "angle_rad", "stddev"]
425
- )
426
-
427
-
428
- @functools.lru_cache(maxsize=None)
429
- def load_stereo_chemical_props() -> (
430
- Tuple[
431
- Mapping[str, List[Bond]],
432
- Mapping[str, List[Bond]],
433
- Mapping[str, List[BondAngle]],
434
- ]
435
- ):
436
- """Load stereo_chemical_props.txt into a nice structure.
437
-
438
- Load literature values for bond lengths and bond angles and translate
439
- bond angles into the length of the opposite edge of the triangle
440
- ("residue_virtual_bonds").
441
-
442
- Returns:
443
- residue_bonds: dict that maps resname --> list of Bond tuples
444
- residue_virtual_bonds: dict that maps resname --> list of Bond tuples
445
- residue_bond_angles: dict that maps resname --> list of BondAngle tuples
446
- """
447
- stereo_chemical_props = Path(
448
- "evolutionaryscale/structure/stereo_chemical_props.txt"
449
- ).read_text()
450
-
451
- lines_iter = iter(stereo_chemical_props.splitlines())
452
- # Load bond lengths.
453
- residue_bonds = {}
454
- next(lines_iter) # Skip header line.
455
- for line in lines_iter:
456
- if line.strip() == "-":
457
- break
458
- bond, resname, length, stddev = line.split()
459
- atom1, atom2 = bond.split("-")
460
- if resname not in residue_bonds:
461
- residue_bonds[resname] = []
462
- residue_bonds[resname].append(Bond(atom1, atom2, float(length), float(stddev)))
463
- residue_bonds["UNK"] = []
464
-
465
- # Load bond angles.
466
- residue_bond_angles = {}
467
- next(lines_iter) # Skip empty line.
468
- next(lines_iter) # Skip header line.
469
- for line in lines_iter:
470
- if line.strip() == "-":
471
- break
472
- bond, resname, angle_degree, stddev_degree = line.split()
473
- atom1, atom2, atom3 = bond.split("-")
474
- if resname not in residue_bond_angles:
475
- residue_bond_angles[resname] = []
476
- residue_bond_angles[resname].append(
477
- BondAngle(
478
- atom1,
479
- atom2,
480
- atom3,
481
- float(angle_degree) / 180.0 * np.pi,
482
- float(stddev_degree) / 180.0 * np.pi,
483
- )
484
- )
485
- residue_bond_angles["UNK"] = []
486
-
487
- def make_bond_key(atom1_name, atom2_name):
488
- """Unique key to lookup bonds."""
489
- return "-".join(sorted([atom1_name, atom2_name]))
490
-
491
- # Translate bond angles into distances ("virtual bonds").
492
- residue_virtual_bonds = {}
493
- for resname, bond_angles in residue_bond_angles.items():
494
- # Create a fast lookup dict for bond lengths.
495
- bond_cache = {}
496
- for b in residue_bonds[resname]:
497
- bond_cache[make_bond_key(b.atom1_name, b.atom2_name)] = b
498
- residue_virtual_bonds[resname] = []
499
- for ba in bond_angles:
500
- bond1 = bond_cache[make_bond_key(ba.atom1_name, ba.atom2_name)]
501
- bond2 = bond_cache[make_bond_key(ba.atom2_name, ba.atom3name)]
502
-
503
- # Compute distance between atom1 and atom3 using the law of cosines
504
- # c^2 = a^2 + b^2 - 2ab*cos(gamma).
505
- gamma = ba.angle_rad
506
- length = np.sqrt(
507
- bond1.length**2
508
- + bond2.length**2
509
- - 2 * bond1.length * bond2.length * np.cos(gamma)
510
- )
511
-
512
- # Propagation of uncertainty assuming uncorrelated errors.
513
- dl_outer = 0.5 / length
514
- dl_dgamma = (2 * bond1.length * bond2.length * np.sin(gamma)) * dl_outer
515
- dl_db1 = (2 * bond1.length - 2 * bond2.length * np.cos(gamma)) * dl_outer
516
- dl_db2 = (2 * bond2.length - 2 * bond1.length * np.cos(gamma)) * dl_outer
517
- stddev = np.sqrt(
518
- (dl_dgamma * ba.stddev) ** 2
519
- + (dl_db1 * bond1.stddev) ** 2
520
- + (dl_db2 * bond2.stddev) ** 2
521
- )
522
- residue_virtual_bonds[resname].append(
523
- Bond(ba.atom1_name, ba.atom3name, length, stddev)
524
- )
525
-
526
- return (residue_bonds, residue_virtual_bonds, residue_bond_angles)
527
-
528
-
529
- # Between-residue bond lengths for general bonds (first element) and for Proline
530
- # (second element).
531
- between_res_bond_length_c_n = [1.329, 1.341]
532
- between_res_bond_length_stddev_c_n = [0.014, 0.016]
533
-
534
- # Between-residue cos_angles.
535
- between_res_cos_angles_c_n_ca = [-0.5203, 0.0353] # degrees: 121.352 +- 2.315
536
- between_res_cos_angles_ca_c_n = [-0.4473, 0.0311] # degrees: 116.568 +- 1.995
537
-
538
- # This mapping is used when we need to store atom data in a format that requires
539
- # fixed atom data size for every residue (e.g. a numpy array).
540
- atom_types = [
541
- "N",
542
- "CA",
543
- "C",
544
- "CB",
545
- "O",
546
- "CG",
547
- "CG1",
548
- "CG2",
549
- "OG",
550
- "OG1",
551
- "SG",
552
- "CD",
553
- "CD1",
554
- "CD2",
555
- "ND1",
556
- "ND2",
557
- "OD1",
558
- "OD2",
559
- "SD",
560
- "CE",
561
- "CE1",
562
- "CE2",
563
- "CE3",
564
- "NE",
565
- "NE1",
566
- "NE2",
567
- "OE1",
568
- "OE2",
569
- "CH2",
570
- "NH1",
571
- "NH2",
572
- "OH",
573
- "CZ",
574
- "CZ2",
575
- "CZ3",
576
- "NZ",
577
- "OXT",
578
- ]
579
- atom_order = {atom_type: i for i, atom_type in enumerate(atom_types)}
580
- atom_type_num = len(atom_types) # := 37.
581
-
582
- # A compact atom encoding with 14 columns
583
- # pylint: disable=line-too-long
584
- # pylint: disable=bad-whitespace
585
- restype_name_to_atom14_names = {
586
- "ALA": ["N", "CA", "C", "O", "CB", "", "", "", "", "", "", "", "", ""],
587
- "ARG": [
588
- "N",
589
- "CA",
590
- "C",
591
- "O",
592
- "CB",
593
- "CG",
594
- "CD",
595
- "NE",
596
- "CZ",
597
- "NH1",
598
- "NH2",
599
- "",
600
- "",
601
- "",
602
- ],
603
- "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2", "", "", "", "", "", ""],
604
- "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2", "", "", "", "", "", ""],
605
- "CYS": ["N", "CA", "C", "O", "CB", "SG", "", "", "", "", "", "", "", ""],
606
- "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2", "", "", "", "", ""],
607
- "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2", "", "", "", "", ""],
608
- "GLY": ["N", "CA", "C", "O", "", "", "", "", "", "", "", "", "", ""],
609
- "HIS": [
610
- "N",
611
- "CA",
612
- "C",
613
- "O",
614
- "CB",
615
- "CG",
616
- "ND1",
617
- "CD2",
618
- "CE1",
619
- "NE2",
620
- "",
621
- "",
622
- "",
623
- "",
624
- ],
625
- "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1", "", "", "", "", "", ""],
626
- "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "", "", "", "", "", ""],
627
- "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ", "", "", "", "", ""],
628
- "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE", "", "", "", "", "", ""],
629
- "PHE": [
630
- "N",
631
- "CA",
632
- "C",
633
- "O",
634
- "CB",
635
- "CG",
636
- "CD1",
637
- "CD2",
638
- "CE1",
639
- "CE2",
640
- "CZ",
641
- "",
642
- "",
643
- "",
644
- ],
645
- "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD", "", "", "", "", "", "", ""],
646
- "SER": ["N", "CA", "C", "O", "CB", "OG", "", "", "", "", "", "", "", ""],
647
- "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2", "", "", "", "", "", "", ""],
648
- "TRP": [
649
- "N",
650
- "CA",
651
- "C",
652
- "O",
653
- "CB",
654
- "CG",
655
- "CD1",
656
- "CD2",
657
- "NE1",
658
- "CE2",
659
- "CE3",
660
- "CZ2",
661
- "CZ3",
662
- "CH2",
663
- ],
664
- "TYR": [
665
- "N",
666
- "CA",
667
- "C",
668
- "O",
669
- "CB",
670
- "CG",
671
- "CD1",
672
- "CD2",
673
- "CE1",
674
- "CE2",
675
- "CZ",
676
- "OH",
677
- "",
678
- "",
679
- ],
680
- "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "", "", "", "", "", "", ""],
681
- "UNK": ["N", "CA", "C", "", "", "", "", "", "", "", "", "", "", ""],
682
- }
683
- # pylint: enable=line-too-long
684
- # pylint: enable=bad-whitespace
685
-
686
-
687
- # This is the standard residue order when coding AA type as a number.
688
- # Reproduce it by taking 3-letter AA codes and sorting them alphabetically.
689
- restypes = [
690
- "A",
691
- "R",
692
- "N",
693
- "D",
694
- "C",
695
- "Q",
696
- "E",
697
- "G",
698
- "H",
699
- "I",
700
- "L",
701
- "K",
702
- "M",
703
- "F",
704
- "P",
705
- "S",
706
- "T",
707
- "W",
708
- "Y",
709
- "V",
710
- ]
711
- restype_order = {restype: i for i, restype in enumerate(restypes)}
712
- restype_num = len(restypes) # := 20.
713
- unk_restype_index = restype_num # Catch-all index for unknown restypes.
714
-
715
- restypes_with_x = restypes + ["X"]
716
- restype_order_with_x = {restype: i for i, restype in enumerate(restypes_with_x)}
717
-
718
- bb_atoms = ["N", "CA", "C", "O"]
719
-
720
- # Hydrophobicity by residue (positive values are hydrophobic). Derived from Black & Mould (1991), normalized by subtracting 0.5.
721
- hydrophobicity = {
722
- "ALA": 0.116,
723
- "ARG": -0.5,
724
- "ASN": -0.264,
725
- "ASP": -0.472,
726
- "CYS": 0.18,
727
- "GLN": -0.249,
728
- "GLU": -0.457,
729
- "GLY": 0.001,
730
- "HIS": -0.335,
731
- "ILE": 0.443,
732
- "LEU": 0.443,
733
- "LYS": -0.217,
734
- "MET": 0.238,
735
- "PHE": 0.5,
736
- "PRO": 0.211,
737
- "SER": -0.141,
738
- "THR": -0.05,
739
- "TRP": 0.378,
740
- "TYR": 0.38,
741
- "VAL": 0.325,
742
- }
743
-
744
- # Side chain max accessible surface area in Ala-X-Ala tripeptide (from Chennamsetty et al. 2010).
745
- side_chain_asa = {
746
- "ALA": 64.7809,
747
- "ARG": 210.02,
748
- "ASN": 113.187,
749
- "ASP": 110.209,
750
- "CYS": 95.2439,
751
- "GLN": 147.855,
752
- "GLU": 143.924,
753
- "GLY": 23.1338,
754
- "HIS": 146.449,
755
- "ILE": 151.242,
756
- "LEU": 139.524,
757
- "LYS": 177.366,
758
- "MET": 164.674,
759
- "PHE": 186.7,
760
- "PRO": 111.533,
761
- "SER": 81.2159,
762
- "THR": 111.597,
763
- "TRP": 229.619,
764
- "TYR": 200.306,
765
- "VAL": 124.237,
766
- }
767
-
768
- # Approximate Volumes of amino acids in cubic angstroms.
769
- # https://www.imgt.org/IMGTeducation/Aide-memoire/_UK/aminoacids/abbreviation.html
770
- amino_acid_volumes = {
771
- "A": 88.6, # Alanine
772
- "R": 173.4, # Arginine
773
- "N": 114.1, # Asparagine
774
- "D": 111.1, # Aspartic acid
775
- "C": 108.5, # Cysteine
776
- "Q": 143.8, # Glutamine
777
- "E": 138.4, # Glutamic acid
778
- "G": 60.1, # Glycine
779
- "H": 153.2, # Histidine
780
- "I": 166.7, # Isoleucine
781
- "L": 166.7, # Leucine
782
- "K": 168.6, # Lysine
783
- "M": 162.9, # Methionine
784
- "F": 189.9, # Phenylalanine
785
- "P": 112.7, # Proline
786
- "S": 89.0, # Serine
787
- "T": 116.1, # Threonine
788
- "W": 227.8, # Tryptophan
789
- "Y": 193.6, # Tyrosine
790
- "V": 140.0, # Valine
791
- "X": 88.6, # Unknown, use Alanine as approximation
792
- }
793
-
794
-
795
- def sequence_to_onehot(
796
- sequence: str, mapping: Mapping[str, int], map_unknown_to_x: bool = False
797
- ) -> np.ndarray:
798
- """Maps the given sequence into a one-hot encoded matrix.
799
-
800
- Args:
801
- sequence: An amino acid sequence.
802
- mapping: A dictionary mapping amino acids to integers.
803
- map_unknown_to_x: If True, any amino acid that is not in the mapping will be
804
- mapped to the unknown amino acid 'X'. If the mapping doesn't contain
805
- amino acid 'X', an error will be thrown. If False, any amino acid not in
806
- the mapping will throw an error.
807
-
808
- Returns:
809
- A numpy array of shape (seq_len, num_unique_aas) with one-hot encoding of
810
- the sequence.
811
-
812
- Raises:
813
- ValueError: If the mapping doesn't contain values from 0 to
814
- num_unique_aas - 1 without any gaps.
815
- """
816
- num_entries = max(mapping.values()) + 1
817
-
818
- if sorted(set(mapping.values())) != list(range(num_entries)):
819
- raise ValueError(
820
- "The mapping must have values from 0 to num_unique_aas-1 "
821
- "without any gaps. Got: %s" % sorted(mapping.values())
822
- )
823
-
824
- one_hot_arr = np.zeros((len(sequence), num_entries), dtype=np.int32)
825
-
826
- for aa_index, aa_type in enumerate(sequence):
827
- if map_unknown_to_x:
828
- if aa_type.isalpha() and aa_type.isupper():
829
- aa_id = mapping.get(aa_type, mapping["X"])
830
- else:
831
- raise ValueError(f"Invalid character in the sequence: {aa_type}")
832
- else:
833
- aa_id = mapping[aa_type]
834
- one_hot_arr[aa_index, aa_id] = 1
835
-
836
- return one_hot_arr
837
-
838
-
839
- restype_1to3 = {
840
- "A": "ALA",
841
- "R": "ARG",
842
- "N": "ASN",
843
- "D": "ASP",
844
- "C": "CYS",
845
- "Q": "GLN",
846
- "E": "GLU",
847
- "G": "GLY",
848
- "H": "HIS",
849
- "I": "ILE",
850
- "L": "LEU",
851
- "K": "LYS",
852
- "M": "MET",
853
- "F": "PHE",
854
- "P": "PRO",
855
- "S": "SER",
856
- "T": "THR",
857
- "W": "TRP",
858
- "Y": "TYR",
859
- "V": "VAL",
860
- "X": "UNK",
861
- }
862
-
863
-
864
- # NB: restype_3to1 differs from Bio.PDB.protein_letters_3to1 by being a simple
865
- # 1-to-1 mapping of 3 letter names to one letter names. The latter contains
866
- # many more, and less common, three letter names as keys and maps many of these
867
- # to the same one letter name (including 'X' and 'U' which we don't use here).
868
- restype_3to1 = {v: k for k, v in restype_1to3.items()}
869
-
870
- # Define a restype name for all unknown residues.
871
- unk_restype = "UNK"
872
-
873
- resnames = [restype_1to3[r] for r in restypes] + [unk_restype]
874
- resname_to_idx = {resname: i for i, resname in enumerate(resnames)}
875
-
876
- hydrophobic_resnames = {"VAL", "ILE", "LEU", "PHE", "MET", "TRP"}
877
-
878
- # The mapping here uses hhblits convention, so that B is mapped to D, J and O
879
- # are mapped to X, U is mapped to C, and Z is mapped to E. Other than that the
880
- # remaining 20 amino acids are kept in alphabetical order.
881
- # There are 2 non-amino acid codes, X (representing any amino acid) and
882
- # "-" representing a missing amino acid in an alignment. The id for these
883
- # codes is put at the end (20 and 21) so that they can easily be ignored if
884
- # desired.
885
- HHBLITS_AA_TO_ID = {
886
- "A": 0,
887
- "B": 2,
888
- "C": 1,
889
- "D": 2,
890
- "E": 3,
891
- "F": 4,
892
- "G": 5,
893
- "H": 6,
894
- "I": 7,
895
- "J": 20,
896
- "K": 8,
897
- "L": 9,
898
- "M": 10,
899
- "N": 11,
900
- "O": 20,
901
- "P": 12,
902
- "Q": 13,
903
- "R": 14,
904
- "S": 15,
905
- "T": 16,
906
- "U": 1,
907
- "V": 17,
908
- "W": 18,
909
- "X": 20,
910
- "Y": 19,
911
- "Z": 3,
912
- "-": 21,
913
- }
914
-
915
- # Partial inversion of HHBLITS_AA_TO_ID.
916
- ID_TO_HHBLITS_AA = {
917
- 0: "A",
918
- 1: "C", # Also U.
919
- 2: "D", # Also B.
920
- 3: "E", # Also Z.
921
- 4: "F",
922
- 5: "G",
923
- 6: "H",
924
- 7: "I",
925
- 8: "K",
926
- 9: "L",
927
- 10: "M",
928
- 11: "N",
929
- 12: "P",
930
- 13: "Q",
931
- 14: "R",
932
- 15: "S",
933
- 16: "T",
934
- 17: "V",
935
- 18: "W",
936
- 19: "Y",
937
- 20: "X", # Includes J and O.
938
- 21: "-",
939
- }
940
-
941
- restypes_with_x_and_gap = restypes + ["X", "-"]
942
- MAP_HHBLITS_AATYPE_TO_OUR_AATYPE = tuple(
943
- restypes_with_x_and_gap.index(ID_TO_HHBLITS_AA[i])
944
- for i in range(len(restypes_with_x_and_gap))
945
- )
946
-
947
-
948
- def _make_standard_atom_mask() -> np.ndarray:
949
- """Returns [num_res_types, num_atom_types] mask array."""
950
- # +1 to account for unknown (all 0s).
951
- mask = np.zeros([restype_num + 1, atom_type_num], dtype=np.int32)
952
- for restype, restype_letter in enumerate(restypes):
953
- restype_name = restype_1to3[restype_letter]
954
- atom_names = residue_atoms[restype_name]
955
- for atom_name in atom_names:
956
- atom_type = atom_order[atom_name]
957
- mask[restype, atom_type] = 1
958
- return mask
959
-
960
-
961
- STANDARD_ATOM_MASK = _make_standard_atom_mask()
962
-
963
-
964
- # A one hot representation for the first and second atoms defining the axis
965
- # of rotation for each chi-angle in each residue.
966
- def chi_angle_atom(atom_index: int) -> np.ndarray:
967
- """Define chi-angle rigid groups via one-hot representations."""
968
- chi_angles_index = {}
969
- one_hots = []
970
-
971
- for k, v in chi_angles_atoms.items():
972
- indices = [atom_types.index(s[atom_index]) for s in v]
973
- indices.extend([-1] * (4 - len(indices)))
974
- chi_angles_index[k] = indices
975
-
976
- for r in restypes:
977
- res3 = restype_1to3[r]
978
- one_hot = np.eye(atom_type_num)[chi_angles_index[res3]]
979
- one_hots.append(one_hot)
980
-
981
- one_hots.append(np.zeros([4, atom_type_num])) # Add zeros for residue `X`.
982
- one_hot = np.stack(one_hots, axis=0)
983
- one_hot = np.transpose(one_hot, [0, 2, 1])
984
-
985
- return one_hot
986
-
987
-
988
- chi_atom_1_one_hot = chi_angle_atom(1)
989
- chi_atom_2_one_hot = chi_angle_atom(2)
990
-
991
- # An array like chi_angles_atoms but using indices rather than names.
992
- chi_angles_atom_indices = [chi_angles_atoms[restype_1to3[r]] for r in restypes]
993
- # chi_angles_atom_indices = tree.map_structure(
994
- # lambda atom_name: atom_order[atom_name], chi_angles_atom_indices
995
- # )
996
- chi_angles_atom_indices = np.array(
997
- [
998
- chi_atoms + ([[0, 0, 0, 0]] * (4 - len(chi_atoms)))
999
- for chi_atoms in chi_angles_atom_indices
1000
- ]
1001
- )
1002
-
1003
- # Mapping from (res_name, atom_name) pairs to the atom's chi group index
1004
- # and atom index within that group.
1005
- chi_groups_for_atom = collections.defaultdict(list)
1006
- for res_name, chi_angle_atoms_for_res in chi_angles_atoms.items():
1007
- for chi_group_i, chi_group in enumerate(chi_angle_atoms_for_res):
1008
- for atom_i, atom in enumerate(chi_group):
1009
- chi_groups_for_atom[(res_name, atom)].append((chi_group_i, atom_i))
1010
- chi_groups_for_atom = dict(chi_groups_for_atom)
1011
-
1012
-
1013
- def _make_rigid_transformation_4x4(ex, ey, translation):
1014
- """Create a rigid 4x4 transformation matrix from two axes and transl."""
1015
- # Normalize ex.
1016
- ex_normalized = ex / np.linalg.norm(ex)
1017
-
1018
- # make ey perpendicular to ex
1019
- ey_normalized = ey - np.dot(ey, ex_normalized) * ex_normalized
1020
- ey_normalized /= np.linalg.norm(ey_normalized)
1021
-
1022
- # compute ez as cross product
1023
- eznorm = np.cross(ex_normalized, ey_normalized)
1024
- m = np.stack([ex_normalized, ey_normalized, eznorm, translation]).transpose()
1025
- m = np.concatenate([m, [[0.0, 0.0, 0.0, 1.0]]], axis=0)
1026
- return m
1027
-
1028
-
1029
- # create an array with (restype, atomtype) --> rigid_group_idx
1030
- # and an array with (restype, atomtype, coord) for the atom positions
1031
- # and compute affine transformation matrices (4,4) from one rigid group to the
1032
- # previous group
1033
- restype_atom37_to_rigid_group = np.zeros([21, 37], dtype=int)
1034
- restype_atom37_mask = np.zeros([21, 37], dtype=np.float32)
1035
- restype_atom37_rigid_group_positions = np.zeros([21, 37, 3], dtype=np.float32)
1036
- restype_atom14_to_rigid_group = np.zeros([21, 14], dtype=int)
1037
- restype_atom14_mask = np.zeros([21, 14], dtype=np.float32)
1038
- restype_atom14_rigid_group_positions = np.zeros([21, 14, 3], dtype=np.float32)
1039
- restype_rigid_group_default_frame = np.zeros([21, 8, 4, 4], dtype=np.float32)
1040
-
1041
-
1042
- def _make_rigid_group_constants():
1043
- """Fill the arrays above."""
1044
- for restype, restype_letter in enumerate(restypes_with_x):
1045
- resname = restype_1to3[restype_letter]
1046
- for atomname, group_idx, atom_position in rigid_group_atom_positions[resname]:
1047
- atomtype = atom_order[atomname]
1048
- restype_atom37_to_rigid_group[restype, atomtype] = group_idx
1049
- restype_atom37_mask[restype, atomtype] = 1
1050
- restype_atom37_rigid_group_positions[restype, atomtype, :] = atom_position
1051
-
1052
- atom14idx = restype_name_to_atom14_names[resname].index(atomname)
1053
- restype_atom14_to_rigid_group[restype, atom14idx] = group_idx
1054
- restype_atom14_mask[restype, atom14idx] = 1
1055
- restype_atom14_rigid_group_positions[restype, atom14idx, :] = atom_position
1056
-
1057
- for restype, restype_letter in enumerate(restypes_with_x):
1058
- resname = restype_1to3[restype_letter]
1059
- atom_positions = {
1060
- name: np.array(pos) for name, _, pos in rigid_group_atom_positions[resname]
1061
- }
1062
-
1063
- # backbone to backbone is the identity transform
1064
- restype_rigid_group_default_frame[restype, 0, :, :] = np.eye(4)
1065
-
1066
- # pre-omega-frame to backbone (currently dummy identity matrix)
1067
- restype_rigid_group_default_frame[restype, 1, :, :] = np.eye(4)
1068
-
1069
- # phi-frame to backbone
1070
- mat = _make_rigid_transformation_4x4(
1071
- ex=atom_positions["N"] - atom_positions["CA"],
1072
- ey=np.array([1.0, 0.0, 0.0]),
1073
- translation=atom_positions["N"],
1074
- )
1075
- restype_rigid_group_default_frame[restype, 2, :, :] = mat
1076
-
1077
- # psi-frame to backbone
1078
- mat = _make_rigid_transformation_4x4(
1079
- ex=atom_positions["C"] - atom_positions["CA"],
1080
- ey=atom_positions["CA"] - atom_positions["N"],
1081
- translation=atom_positions["C"],
1082
- )
1083
- restype_rigid_group_default_frame[restype, 3, :, :] = mat
1084
-
1085
- # chi1-frame to backbone
1086
- if chi_angles_mask[restype][0]:
1087
- base_atom_names = chi_angles_atoms[resname][0]
1088
- base_atom_positions = [atom_positions[name] for name in base_atom_names]
1089
- mat = _make_rigid_transformation_4x4(
1090
- ex=base_atom_positions[2] - base_atom_positions[1],
1091
- ey=base_atom_positions[0] - base_atom_positions[1],
1092
- translation=base_atom_positions[2],
1093
- )
1094
- restype_rigid_group_default_frame[restype, 4, :, :] = mat
1095
-
1096
- # chi2-frame to chi1-frame
1097
- # chi3-frame to chi2-frame
1098
- # chi4-frame to chi3-frame
1099
- # luckily all rotation axes for the next frame start at (0,0,0) of the
1100
- # previous frame
1101
- for chi_idx in range(1, 4):
1102
- if chi_angles_mask[restype][chi_idx]:
1103
- axis_end_atom_name = chi_angles_atoms[resname][chi_idx][2]
1104
- axis_end_atom_position = atom_positions[axis_end_atom_name]
1105
- mat = _make_rigid_transformation_4x4(
1106
- ex=axis_end_atom_position,
1107
- ey=np.array([-1.0, 0.0, 0.0]),
1108
- translation=axis_end_atom_position,
1109
- )
1110
- restype_rigid_group_default_frame[restype, 4 + chi_idx, :, :] = mat
1111
-
1112
-
1113
- _make_rigid_group_constants()
1114
-
1115
-
1116
- def make_atom14_dists_bounds(overlap_tolerance=1.5, bond_length_tolerance_factor=15.0):
1117
- """compute upper and lower bounds for bonds to assess violations."""
1118
- restype_atom14_bond_lower_bound = np.zeros([21, 14, 14], np.float32)
1119
- restype_atom14_bond_upper_bound = np.zeros([21, 14, 14], np.float32)
1120
- restype_atom14_bond_stddev = np.zeros([21, 14, 14], np.float32)
1121
- residue_bonds, residue_virtual_bonds, _ = load_stereo_chemical_props()
1122
- for restype, restype_letter in enumerate(restypes):
1123
- resname = restype_1to3[restype_letter]
1124
- atom_list = restype_name_to_atom14_names[resname]
1125
-
1126
- # create lower and upper bounds for clashes
1127
- for atom1_idx, atom1_name in enumerate(atom_list):
1128
- if not atom1_name:
1129
- continue
1130
- atom1_radius = van_der_waals_radius[atom1_name[0]]
1131
- for atom2_idx, atom2_name in enumerate(atom_list):
1132
- if (not atom2_name) or atom1_idx == atom2_idx:
1133
- continue
1134
- atom2_radius = van_der_waals_radius[atom2_name[0]]
1135
- lower = atom1_radius + atom2_radius - overlap_tolerance
1136
- upper = 1e10
1137
- restype_atom14_bond_lower_bound[restype, atom1_idx, atom2_idx] = lower
1138
- restype_atom14_bond_lower_bound[restype, atom2_idx, atom1_idx] = lower
1139
- restype_atom14_bond_upper_bound[restype, atom1_idx, atom2_idx] = upper
1140
- restype_atom14_bond_upper_bound[restype, atom2_idx, atom1_idx] = upper
1141
-
1142
- # overwrite lower and upper bounds for bonds and angles
1143
- for b in residue_bonds[resname] + residue_virtual_bonds[resname]:
1144
- atom1_idx = atom_list.index(b.atom1_name)
1145
- atom2_idx = atom_list.index(b.atom2_name)
1146
- lower = b.length - bond_length_tolerance_factor * b.stddev
1147
- upper = b.length + bond_length_tolerance_factor * b.stddev
1148
- restype_atom14_bond_lower_bound[restype, atom1_idx, atom2_idx] = lower
1149
- restype_atom14_bond_lower_bound[restype, atom2_idx, atom1_idx] = lower
1150
- restype_atom14_bond_upper_bound[restype, atom1_idx, atom2_idx] = upper
1151
- restype_atom14_bond_upper_bound[restype, atom2_idx, atom1_idx] = upper
1152
- restype_atom14_bond_stddev[restype, atom1_idx, atom2_idx] = b.stddev
1153
- restype_atom14_bond_stddev[restype, atom2_idx, atom1_idx] = b.stddev
1154
- return {
1155
- "lower_bound": restype_atom14_bond_lower_bound, # shape (21,14,14)
1156
- "upper_bound": restype_atom14_bond_upper_bound, # shape (21,14,14)
1157
- "stddev": restype_atom14_bond_stddev, # shape (21,14,14)
1158
- }
1159
-
1160
-
1161
- restype_atom14_ambiguous_atoms = np.zeros((21, 14), dtype=np.float32)
1162
- restype_atom14_ambiguous_atoms_swap_idx = np.tile(np.arange(14, dtype=int), (21, 1))
1163
-
1164
-
1165
- def _make_atom14_ambiguity_feats():
1166
- for res, pairs in residue_atom_renaming_swaps.items():
1167
- res_idx = restype_order[restype_3to1[res]]
1168
- for atom1, atom2 in pairs.items():
1169
- atom1_idx = restype_name_to_atom14_names[res].index(atom1)
1170
- atom2_idx = restype_name_to_atom14_names[res].index(atom2)
1171
- restype_atom14_ambiguous_atoms[res_idx, atom1_idx] = 1
1172
- restype_atom14_ambiguous_atoms[res_idx, atom2_idx] = 1
1173
- restype_atom14_ambiguous_atoms_swap_idx[res_idx, atom1_idx] = atom2_idx
1174
- restype_atom14_ambiguous_atoms_swap_idx[res_idx, atom2_idx] = atom1_idx
1175
-
1176
-
1177
- _make_atom14_ambiguity_feats()
1178
-
1179
-
1180
- def aatype_to_str_sequence(aatype):
1181
- return "".join([restypes_with_x[aatype[i]] for i in range(len(aatype))])
1182
-
1183
-
1184
- # NOTE(thayes): These are computed based on the average CA->C and CA->N norm from rigid_group_atom_positions
1185
- CA_TO_N_NORM = 1.4591
1186
- CA_TO_C_NORM = 1.5252
1187
-
1188
-
1189
- def _make_restype_atom37_to_atom14():
1190
- """Map from atom37 to atom14 per residue type."""
1191
- restype_atom37_to_atom14 = [] # mapping (restype, atom37) --> atom14
1192
- for rt in restypes:
1193
- atom_names = restype_name_to_atom14_names[restype_1to3[rt]]
1194
- atom_name_to_idx14 = {name: i for i, name in enumerate(atom_names)}
1195
- restype_atom37_to_atom14.append(
1196
- [
1197
- (atom_name_to_idx14[name] if name in atom_name_to_idx14 else 0)
1198
- for name in atom_types
1199
- ]
1200
- )
1201
-
1202
- restype_atom37_to_atom14.append([0] * 37)
1203
- restype_atom37_to_atom14 = np.array(restype_atom37_to_atom14, dtype=np.int32)
1204
- return restype_atom37_to_atom14
1205
-
1206
-
1207
- def _make_restype_atom14_to_atom37():
1208
- """Map from atom14 to atom37 per residue type."""
1209
- restype_atom14_to_atom37 = [] # mapping (restype, atom14) --> atom37
1210
- for rt in restypes:
1211
- atom_names = restype_name_to_atom14_names[restype_1to3[rt]]
1212
- restype_atom14_to_atom37.append(
1213
- [(atom_order[name] if name else 0) for name in atom_names]
1214
- )
1215
- # Add dummy mapping for restype 'UNK'
1216
- restype_atom14_to_atom37.append([0] * 14)
1217
- restype_atom14_to_atom37 = np.array(restype_atom14_to_atom37, dtype=np.int32)
1218
- return restype_atom14_to_atom37
1219
-
1220
-
1221
- RESTYPE_ATOM14_TO_ATOM37 = _make_restype_atom14_to_atom37()
1222
- RESTYPE_ATOM37_TO_ATOM14 = _make_restype_atom37_to_atom14()
1223
- CHAIN_BREAK_TOKEN = "|"
 
1
+ # Copyright 2025 EvolutionaryScale
2
+ # Copyright 2021 AlQuraishi Laboratory
3
+ # Copyright 2021 DeepMind Technologies Limited
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ """Constants used in AlphaFold."""
18
+
19
+ import collections
20
+ import functools
21
+ from pathlib import Path
22
+ from typing import List, Mapping, Tuple
23
+
24
+ import numpy as np
25
+
26
+ # import tree
27
+
28
+ # Internal import (35fd).
29
+
30
+
31
+ # Distance from one CA to next CA [trans configuration: omega = 180].
32
+ ca_ca = 3.80209737096
33
+
34
+ # Format: The list for each AA type contains chi1, chi2, chi3, chi4 in
35
+ # this order (or a relevant subset from chi1 onwards). ALA and GLY don't have
36
+ # chi angles so their chi angle lists are empty.
37
+ chi_angles_atoms = {
38
+ "ALA": [],
39
+ # Chi5 in arginine is always 0 +- 5 degrees, so ignore it.
40
+ "ARG": [
41
+ ["N", "CA", "CB", "CG"],
42
+ ["CA", "CB", "CG", "CD"],
43
+ ["CB", "CG", "CD", "NE"],
44
+ ["CG", "CD", "NE", "CZ"],
45
+ ],
46
+ "ASN": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]],
47
+ "ASP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "OD1"]],
48
+ "CYS": [["N", "CA", "CB", "SG"]],
49
+ "GLN": [
50
+ ["N", "CA", "CB", "CG"],
51
+ ["CA", "CB", "CG", "CD"],
52
+ ["CB", "CG", "CD", "OE1"],
53
+ ],
54
+ "GLU": [
55
+ ["N", "CA", "CB", "CG"],
56
+ ["CA", "CB", "CG", "CD"],
57
+ ["CB", "CG", "CD", "OE1"],
58
+ ],
59
+ "GLY": [],
60
+ "HIS": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "ND1"]],
61
+ "ILE": [["N", "CA", "CB", "CG1"], ["CA", "CB", "CG1", "CD1"]],
62
+ "LEU": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
63
+ "LYS": [
64
+ ["N", "CA", "CB", "CG"],
65
+ ["CA", "CB", "CG", "CD"],
66
+ ["CB", "CG", "CD", "CE"],
67
+ ["CG", "CD", "CE", "NZ"],
68
+ ],
69
+ "MET": [
70
+ ["N", "CA", "CB", "CG"],
71
+ ["CA", "CB", "CG", "SD"],
72
+ ["CB", "CG", "SD", "CE"],
73
+ ],
74
+ "PHE": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
75
+ "PRO": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD"]],
76
+ "SER": [["N", "CA", "CB", "OG"]],
77
+ "THR": [["N", "CA", "CB", "OG1"]],
78
+ "TRP": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
79
+ "TYR": [["N", "CA", "CB", "CG"], ["CA", "CB", "CG", "CD1"]],
80
+ "VAL": [["N", "CA", "CB", "CG1"]],
81
+ "UNK": [],
82
+ }
83
+
84
+ # If chi angles given in fixed-length array, this matrix determines how to mask
85
+ # them for each AA type. The order is as per restype_order (see below).
86
+ chi_angles_mask = [
87
+ [0.0, 0.0, 0.0, 0.0], # ALA
88
+ [1.0, 1.0, 1.0, 1.0], # ARG
89
+ [1.0, 1.0, 0.0, 0.0], # ASN
90
+ [1.0, 1.0, 0.0, 0.0], # ASP
91
+ [1.0, 0.0, 0.0, 0.0], # CYS
92
+ [1.0, 1.0, 1.0, 0.0], # GLN
93
+ [1.0, 1.0, 1.0, 0.0], # GLU
94
+ [0.0, 0.0, 0.0, 0.0], # GLY
95
+ [1.0, 1.0, 0.0, 0.0], # HIS
96
+ [1.0, 1.0, 0.0, 0.0], # ILE
97
+ [1.0, 1.0, 0.0, 0.0], # LEU
98
+ [1.0, 1.0, 1.0, 1.0], # LYS
99
+ [1.0, 1.0, 1.0, 0.0], # MET
100
+ [1.0, 1.0, 0.0, 0.0], # PHE
101
+ [1.0, 1.0, 0.0, 0.0], # PRO
102
+ [1.0, 0.0, 0.0, 0.0], # SER
103
+ [1.0, 0.0, 0.0, 0.0], # THR
104
+ [1.0, 1.0, 0.0, 0.0], # TRP
105
+ [1.0, 1.0, 0.0, 0.0], # TYR
106
+ [1.0, 0.0, 0.0, 0.0], # VAL
107
+ [0.0, 0.0, 0.0, 0.0], # UNK
108
+ ]
109
+
110
+ # The following chi angles are pi periodic: they can be rotated by a multiple
111
+ # of pi without affecting the structure.
112
+ chi_pi_periodic = [
113
+ [0.0, 0.0, 0.0, 0.0], # ALA
114
+ [0.0, 0.0, 0.0, 0.0], # ARG
115
+ [0.0, 0.0, 0.0, 0.0], # ASN
116
+ [0.0, 1.0, 0.0, 0.0], # ASP
117
+ [0.0, 0.0, 0.0, 0.0], # CYS
118
+ [0.0, 0.0, 0.0, 0.0], # GLN
119
+ [0.0, 0.0, 1.0, 0.0], # GLU
120
+ [0.0, 0.0, 0.0, 0.0], # GLY
121
+ [0.0, 0.0, 0.0, 0.0], # HIS
122
+ [0.0, 0.0, 0.0, 0.0], # ILE
123
+ [0.0, 0.0, 0.0, 0.0], # LEU
124
+ [0.0, 0.0, 0.0, 0.0], # LYS
125
+ [0.0, 0.0, 0.0, 0.0], # MET
126
+ [0.0, 1.0, 0.0, 0.0], # PHE
127
+ [0.0, 0.0, 0.0, 0.0], # PRO
128
+ [0.0, 0.0, 0.0, 0.0], # SER
129
+ [0.0, 0.0, 0.0, 0.0], # THR
130
+ [0.0, 0.0, 0.0, 0.0], # TRP
131
+ [0.0, 1.0, 0.0, 0.0], # TYR
132
+ [0.0, 0.0, 0.0, 0.0], # VAL
133
+ [0.0, 0.0, 0.0, 0.0], # UNK
134
+ ]
135
+
136
+ # Atoms positions relative to the 8 rigid groups, defined by the pre-omega, phi,
137
+ # psi and chi angles:
138
+ # 0: 'backbone group',
139
+ # 1: 'pre-omega-group', (empty)
140
+ # 2: 'phi-group', (currently empty, because it defines only hydrogens)
141
+ # 3: 'psi-group',
142
+ # 4,5,6,7: 'chi1,2,3,4-group'
143
+ # The atom positions are relative to the axis-end-atom of the corresponding
144
+ # rotation axis. The x-axis is in direction of the rotation axis, and the y-axis
145
+ # is defined such that the dihedral-angle-definiting atom (the last entry in
146
+ # chi_angles_atoms above) is in the xy-plane (with a positive y-coordinate).
147
+ # format: [atomname, group_idx, rel_position]
148
+ rigid_group_atom_positions = {
149
+ "ALA": [
150
+ ["N", 0, (-0.525, 1.363, 0.000)],
151
+ ["CA", 0, (0.000, 0.000, 0.000)],
152
+ ["C", 0, (1.526, -0.000, -0.000)],
153
+ ["CB", 0, (-0.529, -0.774, -1.205)],
154
+ ["O", 3, (0.627, 1.062, 0.000)],
155
+ ],
156
+ "ARG": [
157
+ ["N", 0, (-0.524, 1.362, -0.000)],
158
+ ["CA", 0, (0.000, 0.000, 0.000)],
159
+ ["C", 0, (1.525, -0.000, -0.000)],
160
+ ["CB", 0, (-0.524, -0.778, -1.209)],
161
+ ["O", 3, (0.626, 1.062, 0.000)],
162
+ ["CG", 4, (0.616, 1.390, -0.000)],
163
+ ["CD", 5, (0.564, 1.414, 0.000)],
164
+ ["NE", 6, (0.539, 1.357, -0.000)],
165
+ ["NH1", 7, (0.206, 2.301, 0.000)],
166
+ ["NH2", 7, (2.078, 0.978, -0.000)],
167
+ ["CZ", 7, (0.758, 1.093, -0.000)],
168
+ ],
169
+ "ASN": [
170
+ ["N", 0, (-0.536, 1.357, 0.000)],
171
+ ["CA", 0, (0.000, 0.000, 0.000)],
172
+ ["C", 0, (1.526, -0.000, -0.000)],
173
+ ["CB", 0, (-0.531, -0.787, -1.200)],
174
+ ["O", 3, (0.625, 1.062, 0.000)],
175
+ ["CG", 4, (0.584, 1.399, 0.000)],
176
+ ["ND2", 5, (0.593, -1.188, 0.001)],
177
+ ["OD1", 5, (0.633, 1.059, 0.000)],
178
+ ],
179
+ "ASP": [
180
+ ["N", 0, (-0.525, 1.362, -0.000)],
181
+ ["CA", 0, (0.000, 0.000, 0.000)],
182
+ ["C", 0, (1.527, 0.000, -0.000)],
183
+ ["CB", 0, (-0.526, -0.778, -1.208)],
184
+ ["O", 3, (0.626, 1.062, -0.000)],
185
+ ["CG", 4, (0.593, 1.398, -0.000)],
186
+ ["OD1", 5, (0.610, 1.091, 0.000)],
187
+ ["OD2", 5, (0.592, -1.101, -0.003)],
188
+ ],
189
+ "CYS": [
190
+ ["N", 0, (-0.522, 1.362, -0.000)],
191
+ ["CA", 0, (0.000, 0.000, 0.000)],
192
+ ["C", 0, (1.524, 0.000, 0.000)],
193
+ ["CB", 0, (-0.519, -0.773, -1.212)],
194
+ ["O", 3, (0.625, 1.062, -0.000)],
195
+ ["SG", 4, (0.728, 1.653, 0.000)],
196
+ ],
197
+ "GLN": [
198
+ ["N", 0, (-0.526, 1.361, -0.000)],
199
+ ["CA", 0, (0.000, 0.000, 0.000)],
200
+ ["C", 0, (1.526, 0.000, 0.000)],
201
+ ["CB", 0, (-0.525, -0.779, -1.207)],
202
+ ["O", 3, (0.626, 1.062, -0.000)],
203
+ ["CG", 4, (0.615, 1.393, 0.000)],
204
+ ["CD", 5, (0.587, 1.399, -0.000)],
205
+ ["NE2", 6, (0.593, -1.189, -0.001)],
206
+ ["OE1", 6, (0.634, 1.060, 0.000)],
207
+ ],
208
+ "GLU": [
209
+ ["N", 0, (-0.528, 1.361, 0.000)],
210
+ ["CA", 0, (0.000, 0.000, 0.000)],
211
+ ["C", 0, (1.526, -0.000, -0.000)],
212
+ ["CB", 0, (-0.526, -0.781, -1.207)],
213
+ ["O", 3, (0.626, 1.062, 0.000)],
214
+ ["CG", 4, (0.615, 1.392, 0.000)],
215
+ ["CD", 5, (0.600, 1.397, 0.000)],
216
+ ["OE1", 6, (0.607, 1.095, -0.000)],
217
+ ["OE2", 6, (0.589, -1.104, -0.001)],
218
+ ],
219
+ "GLY": [
220
+ ["N", 0, (-0.572, 1.337, 0.000)],
221
+ ["CA", 0, (0.000, 0.000, 0.000)],
222
+ ["C", 0, (1.517, -0.000, -0.000)],
223
+ ["O", 3, (0.626, 1.062, -0.000)],
224
+ ],
225
+ "HIS": [
226
+ ["N", 0, (-0.527, 1.360, 0.000)],
227
+ ["CA", 0, (0.000, 0.000, 0.000)],
228
+ ["C", 0, (1.525, 0.000, 0.000)],
229
+ ["CB", 0, (-0.525, -0.778, -1.208)],
230
+ ["O", 3, (0.625, 1.063, 0.000)],
231
+ ["CG", 4, (0.600, 1.370, -0.000)],
232
+ ["CD2", 5, (0.889, -1.021, 0.003)],
233
+ ["ND1", 5, (0.744, 1.160, -0.000)],
234
+ ["CE1", 5, (2.030, 0.851, 0.002)],
235
+ ["NE2", 5, (2.145, -0.466, 0.004)],
236
+ ],
237
+ "ILE": [
238
+ ["N", 0, (-0.493, 1.373, -0.000)],
239
+ ["CA", 0, (0.000, 0.000, 0.000)],
240
+ ["C", 0, (1.527, -0.000, -0.000)],
241
+ ["CB", 0, (-0.536, -0.793, -1.213)],
242
+ ["O", 3, (0.627, 1.062, -0.000)],
243
+ ["CG1", 4, (0.534, 1.437, -0.000)],
244
+ ["CG2", 4, (0.540, -0.785, -1.199)],
245
+ ["CD1", 5, (0.619, 1.391, 0.000)],
246
+ ],
247
+ "LEU": [
248
+ ["N", 0, (-0.520, 1.363, 0.000)],
249
+ ["CA", 0, (0.000, 0.000, 0.000)],
250
+ ["C", 0, (1.525, -0.000, -0.000)],
251
+ ["CB", 0, (-0.522, -0.773, -1.214)],
252
+ ["O", 3, (0.625, 1.063, -0.000)],
253
+ ["CG", 4, (0.678, 1.371, 0.000)],
254
+ ["CD1", 5, (0.530, 1.430, -0.000)],
255
+ ["CD2", 5, (0.535, -0.774, 1.200)],
256
+ ],
257
+ "LYS": [
258
+ ["N", 0, (-0.526, 1.362, -0.000)],
259
+ ["CA", 0, (0.000, 0.000, 0.000)],
260
+ ["C", 0, (1.526, 0.000, 0.000)],
261
+ ["CB", 0, (-0.524, -0.778, -1.208)],
262
+ ["O", 3, (0.626, 1.062, -0.000)],
263
+ ["CG", 4, (0.619, 1.390, 0.000)],
264
+ ["CD", 5, (0.559, 1.417, 0.000)],
265
+ ["CE", 6, (0.560, 1.416, 0.000)],
266
+ ["NZ", 7, (0.554, 1.387, 0.000)],
267
+ ],
268
+ "MET": [
269
+ ["N", 0, (-0.521, 1.364, -0.000)],
270
+ ["CA", 0, (0.000, 0.000, 0.000)],
271
+ ["C", 0, (1.525, 0.000, 0.000)],
272
+ ["CB", 0, (-0.523, -0.776, -1.210)],
273
+ ["O", 3, (0.625, 1.062, -0.000)],
274
+ ["CG", 4, (0.613, 1.391, -0.000)],
275
+ ["SD", 5, (0.703, 1.695, 0.000)],
276
+ ["CE", 6, (0.320, 1.786, -0.000)],
277
+ ],
278
+ "PHE": [
279
+ ["N", 0, (-0.518, 1.363, 0.000)],
280
+ ["CA", 0, (0.000, 0.000, 0.000)],
281
+ ["C", 0, (1.524, 0.000, -0.000)],
282
+ ["CB", 0, (-0.525, -0.776, -1.212)],
283
+ ["O", 3, (0.626, 1.062, -0.000)],
284
+ ["CG", 4, (0.607, 1.377, 0.000)],
285
+ ["CD1", 5, (0.709, 1.195, -0.000)],
286
+ ["CD2", 5, (0.706, -1.196, 0.000)],
287
+ ["CE1", 5, (2.102, 1.198, -0.000)],
288
+ ["CE2", 5, (2.098, -1.201, -0.000)],
289
+ ["CZ", 5, (2.794, -0.003, -0.001)],
290
+ ],
291
+ "PRO": [
292
+ ["N", 0, (-0.566, 1.351, -0.000)],
293
+ ["CA", 0, (0.000, 0.000, 0.000)],
294
+ ["C", 0, (1.527, -0.000, 0.000)],
295
+ ["CB", 0, (-0.546, -0.611, -1.293)],
296
+ ["O", 3, (0.621, 1.066, 0.000)],
297
+ ["CG", 4, (0.382, 1.445, 0.0)],
298
+ # ['CD', 5, (0.427, 1.440, 0.0)],
299
+ ["CD", 5, (0.477, 1.424, 0.0)], # manually made angle 2 degrees larger
300
+ ],
301
+ "SER": [
302
+ ["N", 0, (-0.529, 1.360, -0.000)],
303
+ ["CA", 0, (0.000, 0.000, 0.000)],
304
+ ["C", 0, (1.525, -0.000, -0.000)],
305
+ ["CB", 0, (-0.518, -0.777, -1.211)],
306
+ ["O", 3, (0.626, 1.062, -0.000)],
307
+ ["OG", 4, (0.503, 1.325, 0.000)],
308
+ ],
309
+ "THR": [
310
+ ["N", 0, (-0.517, 1.364, 0.000)],
311
+ ["CA", 0, (0.000, 0.000, 0.000)],
312
+ ["C", 0, (1.526, 0.000, -0.000)],
313
+ ["CB", 0, (-0.516, -0.793, -1.215)],
314
+ ["O", 3, (0.626, 1.062, 0.000)],
315
+ ["CG2", 4, (0.550, -0.718, -1.228)],
316
+ ["OG1", 4, (0.472, 1.353, 0.000)],
317
+ ],
318
+ "TRP": [
319
+ ["N", 0, (-0.521, 1.363, 0.000)],
320
+ ["CA", 0, (0.000, 0.000, 0.000)],
321
+ ["C", 0, (1.525, -0.000, 0.000)],
322
+ ["CB", 0, (-0.523, -0.776, -1.212)],
323
+ ["O", 3, (0.627, 1.062, 0.000)],
324
+ ["CG", 4, (0.609, 1.370, -0.000)],
325
+ ["CD1", 5, (0.824, 1.091, 0.000)],
326
+ ["CD2", 5, (0.854, -1.148, -0.005)],
327
+ ["CE2", 5, (2.186, -0.678, -0.007)],
328
+ ["CE3", 5, (0.622, -2.530, -0.007)],
329
+ ["NE1", 5, (2.140, 0.690, -0.004)],
330
+ ["CH2", 5, (3.028, -2.890, -0.013)],
331
+ ["CZ2", 5, (3.283, -1.543, -0.011)],
332
+ ["CZ3", 5, (1.715, -3.389, -0.011)],
333
+ ],
334
+ "TYR": [
335
+ ["N", 0, (-0.522, 1.362, 0.000)],
336
+ ["CA", 0, (0.000, 0.000, 0.000)],
337
+ ["C", 0, (1.524, -0.000, -0.000)],
338
+ ["CB", 0, (-0.522, -0.776, -1.213)],
339
+ ["O", 3, (0.627, 1.062, -0.000)],
340
+ ["CG", 4, (0.607, 1.382, -0.000)],
341
+ ["CD1", 5, (0.716, 1.195, -0.000)],
342
+ ["CD2", 5, (0.713, -1.194, -0.001)],
343
+ ["CE1", 5, (2.107, 1.200, -0.002)],
344
+ ["CE2", 5, (2.104, -1.201, -0.003)],
345
+ ["OH", 5, (4.168, -0.002, -0.005)],
346
+ ["CZ", 5, (2.791, -0.001, -0.003)],
347
+ ],
348
+ "VAL": [
349
+ ["N", 0, (-0.494, 1.373, -0.000)],
350
+ ["CA", 0, (0.000, 0.000, 0.000)],
351
+ ["C", 0, (1.527, -0.000, -0.000)],
352
+ ["CB", 0, (-0.533, -0.795, -1.213)],
353
+ ["O", 3, (0.627, 1.062, -0.000)],
354
+ ["CG1", 4, (0.540, 1.429, -0.000)],
355
+ ["CG2", 4, (0.533, -0.776, 1.203)],
356
+ ],
357
+ # Assume alanine positions for unknown AA
358
+ "UNK": [
359
+ ["N", 0, (-0.525, 1.363, 0.000)],
360
+ ["CA", 0, (0.000, 0.000, 0.000)],
361
+ ["C", 0, (1.526, -0.000, -0.000)],
362
+ ],
363
+ }
364
+
365
+ # A list of atoms (excluding hydrogen) for each AA type. PDB naming convention.
366
+ residue_atoms = {
367
+ "ALA": ["C", "CA", "CB", "N", "O"],
368
+ "ARG": ["C", "CA", "CB", "CG", "CD", "CZ", "N", "NE", "O", "NH1", "NH2"],
369
+ "ASP": ["C", "CA", "CB", "CG", "N", "O", "OD1", "OD2"],
370
+ "ASN": ["C", "CA", "CB", "CG", "N", "ND2", "O", "OD1"],
371
+ "CYS": ["C", "CA", "CB", "N", "O", "SG"],
372
+ "GLU": ["C", "CA", "CB", "CG", "CD", "N", "O", "OE1", "OE2"],
373
+ "GLN": ["C", "CA", "CB", "CG", "CD", "N", "NE2", "O", "OE1"],
374
+ "GLY": ["C", "CA", "N", "O"],
375
+ "HIS": ["C", "CA", "CB", "CG", "CD2", "CE1", "N", "ND1", "NE2", "O"],
376
+ "ILE": ["C", "CA", "CB", "CG1", "CG2", "CD1", "N", "O"],
377
+ "LEU": ["C", "CA", "CB", "CG", "CD1", "CD2", "N", "O"],
378
+ "LYS": ["C", "CA", "CB", "CG", "CD", "CE", "N", "NZ", "O"],
379
+ "MET": ["C", "CA", "CB", "CG", "CE", "N", "O", "SD"],
380
+ "PHE": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O"],
381
+ "PRO": ["C", "CA", "CB", "CG", "CD", "N", "O"],
382
+ "SER": ["C", "CA", "CB", "N", "O", "OG"],
383
+ "THR": ["C", "CA", "CB", "CG2", "N", "O", "OG1"],
384
+ "TRP": [
385
+ "C",
386
+ "CA",
387
+ "CB",
388
+ "CG",
389
+ "CD1",
390
+ "CD2",
391
+ "CE2",
392
+ "CE3",
393
+ "CZ2",
394
+ "CZ3",
395
+ "CH2",
396
+ "N",
397
+ "NE1",
398
+ "O",
399
+ ],
400
+ "TYR": ["C", "CA", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "N", "O", "OH"],
401
+ "VAL": ["C", "CA", "CB", "CG1", "CG2", "N", "O"],
402
+ "UNK": ["C", "CA", "N"],
403
+ }
404
+
405
+ # Naming swaps for ambiguous atom names.
406
+ # Due to symmetries in the amino acids the naming of atoms is ambiguous in
407
+ # 4 of the 20 amino acids.
408
+ # (The LDDT paper lists 7 amino acids as ambiguous, but the naming ambiguities
409
+ # in LEU, VAL and ARG can be resolved by using the 3d constellations of
410
+ # the 'ambiguous' atoms and their neighbours)
411
+ # TODO: ^ interpret this
412
+ residue_atom_renaming_swaps = {
413
+ "ASP": {"OD1": "OD2"},
414
+ "GLU": {"OE1": "OE2"},
415
+ "PHE": {"CD1": "CD2", "CE1": "CE2"},
416
+ "TYR": {"CD1": "CD2", "CE1": "CE2"},
417
+ }
418
+
419
+ # Van der Waals radii [Angstroem] of the atoms (from Wikipedia)
420
+ van_der_waals_radius = {"C": 1.7, "N": 1.55, "O": 1.52, "S": 1.8}
421
+
422
+ Bond = collections.namedtuple("Bond", ["atom1_name", "atom2_name", "length", "stddev"])
423
+ BondAngle = collections.namedtuple(
424
+ "BondAngle", ["atom1_name", "atom2_name", "atom3name", "angle_rad", "stddev"]
425
+ )
426
+
427
+
428
+ @functools.lru_cache(maxsize=None)
429
+ def load_stereo_chemical_props() -> (
430
+ Tuple[
431
+ Mapping[str, List[Bond]],
432
+ Mapping[str, List[Bond]],
433
+ Mapping[str, List[BondAngle]],
434
+ ]
435
+ ):
436
+ """Load stereo_chemical_props.txt into a nice structure.
437
+
438
+ Load literature values for bond lengths and bond angles and translate
439
+ bond angles into the length of the opposite edge of the triangle
440
+ ("residue_virtual_bonds").
441
+
442
+ Returns:
443
+ residue_bonds: dict that maps resname --> list of Bond tuples
444
+ residue_virtual_bonds: dict that maps resname --> list of Bond tuples
445
+ residue_bond_angles: dict that maps resname --> list of BondAngle tuples
446
+ """
447
+ stereo_chemical_props = Path(
448
+ "evolutionaryscale/structure/stereo_chemical_props.txt"
449
+ ).read_text()
450
+
451
+ lines_iter = iter(stereo_chemical_props.splitlines())
452
+ # Load bond lengths.
453
+ residue_bonds = {}
454
+ next(lines_iter) # Skip header line.
455
+ for line in lines_iter:
456
+ if line.strip() == "-":
457
+ break
458
+ bond, resname, length, stddev = line.split()
459
+ atom1, atom2 = bond.split("-")
460
+ if resname not in residue_bonds:
461
+ residue_bonds[resname] = []
462
+ residue_bonds[resname].append(Bond(atom1, atom2, float(length), float(stddev)))
463
+ residue_bonds["UNK"] = []
464
+
465
+ # Load bond angles.
466
+ residue_bond_angles = {}
467
+ next(lines_iter) # Skip empty line.
468
+ next(lines_iter) # Skip header line.
469
+ for line in lines_iter:
470
+ if line.strip() == "-":
471
+ break
472
+ bond, resname, angle_degree, stddev_degree = line.split()
473
+ atom1, atom2, atom3 = bond.split("-")
474
+ if resname not in residue_bond_angles:
475
+ residue_bond_angles[resname] = []
476
+ residue_bond_angles[resname].append(
477
+ BondAngle(
478
+ atom1,
479
+ atom2,
480
+ atom3,
481
+ float(angle_degree) / 180.0 * np.pi,
482
+ float(stddev_degree) / 180.0 * np.pi,
483
+ )
484
+ )
485
+ residue_bond_angles["UNK"] = []
486
+
487
+ def make_bond_key(atom1_name, atom2_name):
488
+ """Unique key to lookup bonds."""
489
+ return "-".join(sorted([atom1_name, atom2_name]))
490
+
491
+ # Translate bond angles into distances ("virtual bonds").
492
+ residue_virtual_bonds = {}
493
+ for resname, bond_angles in residue_bond_angles.items():
494
+ # Create a fast lookup dict for bond lengths.
495
+ bond_cache = {}
496
+ for b in residue_bonds[resname]:
497
+ bond_cache[make_bond_key(b.atom1_name, b.atom2_name)] = b
498
+ residue_virtual_bonds[resname] = []
499
+ for ba in bond_angles:
500
+ bond1 = bond_cache[make_bond_key(ba.atom1_name, ba.atom2_name)]
501
+ bond2 = bond_cache[make_bond_key(ba.atom2_name, ba.atom3name)]
502
+
503
+ # Compute distance between atom1 and atom3 using the law of cosines
504
+ # c^2 = a^2 + b^2 - 2ab*cos(gamma).
505
+ gamma = ba.angle_rad
506
+ length = np.sqrt(
507
+ bond1.length**2
508
+ + bond2.length**2
509
+ - 2 * bond1.length * bond2.length * np.cos(gamma)
510
+ )
511
+
512
+ # Propagation of uncertainty assuming uncorrelated errors.
513
+ dl_outer = 0.5 / length
514
+ dl_dgamma = (2 * bond1.length * bond2.length * np.sin(gamma)) * dl_outer
515
+ dl_db1 = (2 * bond1.length - 2 * bond2.length * np.cos(gamma)) * dl_outer
516
+ dl_db2 = (2 * bond2.length - 2 * bond1.length * np.cos(gamma)) * dl_outer
517
+ stddev = np.sqrt(
518
+ (dl_dgamma * ba.stddev) ** 2
519
+ + (dl_db1 * bond1.stddev) ** 2
520
+ + (dl_db2 * bond2.stddev) ** 2
521
+ )
522
+ residue_virtual_bonds[resname].append(
523
+ Bond(ba.atom1_name, ba.atom3name, length, stddev)
524
+ )
525
+
526
+ return (residue_bonds, residue_virtual_bonds, residue_bond_angles)
527
+
528
+
529
+ # Between-residue bond lengths for general bonds (first element) and for Proline
530
+ # (second element).
531
+ between_res_bond_length_c_n = [1.329, 1.341]
532
+ between_res_bond_length_stddev_c_n = [0.014, 0.016]
533
+
534
+ # Between-residue cos_angles.
535
+ between_res_cos_angles_c_n_ca = [-0.5203, 0.0353] # degrees: 121.352 +- 2.315
536
+ between_res_cos_angles_ca_c_n = [-0.4473, 0.0311] # degrees: 116.568 +- 1.995
537
+
538
+ # This mapping is used when we need to store atom data in a format that requires
539
+ # fixed atom data size for every residue (e.g. a numpy array).
540
+ atom_types = [
541
+ "N",
542
+ "CA",
543
+ "C",
544
+ "CB",
545
+ "O",
546
+ "CG",
547
+ "CG1",
548
+ "CG2",
549
+ "OG",
550
+ "OG1",
551
+ "SG",
552
+ "CD",
553
+ "CD1",
554
+ "CD2",
555
+ "ND1",
556
+ "ND2",
557
+ "OD1",
558
+ "OD2",
559
+ "SD",
560
+ "CE",
561
+ "CE1",
562
+ "CE2",
563
+ "CE3",
564
+ "NE",
565
+ "NE1",
566
+ "NE2",
567
+ "OE1",
568
+ "OE2",
569
+ "CH2",
570
+ "NH1",
571
+ "NH2",
572
+ "OH",
573
+ "CZ",
574
+ "CZ2",
575
+ "CZ3",
576
+ "NZ",
577
+ "OXT",
578
+ ]
579
+ atom_order = {atom_type: i for i, atom_type in enumerate(atom_types)}
580
+ atom_type_num = len(atom_types) # := 37.
581
+
582
+ # A compact atom encoding with 14 columns
583
+ # pylint: disable=line-too-long
584
+ # pylint: disable=bad-whitespace
585
+ restype_name_to_atom14_names = {
586
+ "ALA": ["N", "CA", "C", "O", "CB", "", "", "", "", "", "", "", "", ""],
587
+ "ARG": [
588
+ "N",
589
+ "CA",
590
+ "C",
591
+ "O",
592
+ "CB",
593
+ "CG",
594
+ "CD",
595
+ "NE",
596
+ "CZ",
597
+ "NH1",
598
+ "NH2",
599
+ "",
600
+ "",
601
+ "",
602
+ ],
603
+ "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2", "", "", "", "", "", ""],
604
+ "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2", "", "", "", "", "", ""],
605
+ "CYS": ["N", "CA", "C", "O", "CB", "SG", "", "", "", "", "", "", "", ""],
606
+ "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2", "", "", "", "", ""],
607
+ "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2", "", "", "", "", ""],
608
+ "GLY": ["N", "CA", "C", "O", "", "", "", "", "", "", "", "", "", ""],
609
+ "HIS": [
610
+ "N",
611
+ "CA",
612
+ "C",
613
+ "O",
614
+ "CB",
615
+ "CG",
616
+ "ND1",
617
+ "CD2",
618
+ "CE1",
619
+ "NE2",
620
+ "",
621
+ "",
622
+ "",
623
+ "",
624
+ ],
625
+ "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1", "", "", "", "", "", ""],
626
+ "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "", "", "", "", "", ""],
627
+ "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ", "", "", "", "", ""],
628
+ "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE", "", "", "", "", "", ""],
629
+ "PHE": [
630
+ "N",
631
+ "CA",
632
+ "C",
633
+ "O",
634
+ "CB",
635
+ "CG",
636
+ "CD1",
637
+ "CD2",
638
+ "CE1",
639
+ "CE2",
640
+ "CZ",
641
+ "",
642
+ "",
643
+ "",
644
+ ],
645
+ "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD", "", "", "", "", "", "", ""],
646
+ "SER": ["N", "CA", "C", "O", "CB", "OG", "", "", "", "", "", "", "", ""],
647
+ "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2", "", "", "", "", "", "", ""],
648
+ "TRP": [
649
+ "N",
650
+ "CA",
651
+ "C",
652
+ "O",
653
+ "CB",
654
+ "CG",
655
+ "CD1",
656
+ "CD2",
657
+ "NE1",
658
+ "CE2",
659
+ "CE3",
660
+ "CZ2",
661
+ "CZ3",
662
+ "CH2",
663
+ ],
664
+ "TYR": [
665
+ "N",
666
+ "CA",
667
+ "C",
668
+ "O",
669
+ "CB",
670
+ "CG",
671
+ "CD1",
672
+ "CD2",
673
+ "CE1",
674
+ "CE2",
675
+ "CZ",
676
+ "OH",
677
+ "",
678
+ "",
679
+ ],
680
+ "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "", "", "", "", "", "", ""],
681
+ "UNK": ["N", "CA", "C", "", "", "", "", "", "", "", "", "", "", ""],
682
+ }
683
+ # pylint: enable=line-too-long
684
+ # pylint: enable=bad-whitespace
685
+
686
+
687
+ # This is the standard residue order when coding AA type as a number.
688
+ # Reproduce it by taking 3-letter AA codes and sorting them alphabetically.
689
+ restypes = [
690
+ "A",
691
+ "R",
692
+ "N",
693
+ "D",
694
+ "C",
695
+ "Q",
696
+ "E",
697
+ "G",
698
+ "H",
699
+ "I",
700
+ "L",
701
+ "K",
702
+ "M",
703
+ "F",
704
+ "P",
705
+ "S",
706
+ "T",
707
+ "W",
708
+ "Y",
709
+ "V",
710
+ ]
711
+ restype_order = {restype: i for i, restype in enumerate(restypes)}
712
+ restype_num = len(restypes) # := 20.
713
+ unk_restype_index = restype_num # Catch-all index for unknown restypes.
714
+
715
+ restypes_with_x = restypes + ["X"]
716
+ restype_order_with_x = {restype: i for i, restype in enumerate(restypes_with_x)}
717
+
718
+ bb_atoms = ["N", "CA", "C", "O"]
719
+
720
+ # Hydrophobicity by residue (positive values are hydrophobic). Derived from Black & Mould (1991), normalized by subtracting 0.5.
721
+ hydrophobicity = {
722
+ "ALA": 0.116,
723
+ "ARG": -0.5,
724
+ "ASN": -0.264,
725
+ "ASP": -0.472,
726
+ "CYS": 0.18,
727
+ "GLN": -0.249,
728
+ "GLU": -0.457,
729
+ "GLY": 0.001,
730
+ "HIS": -0.335,
731
+ "ILE": 0.443,
732
+ "LEU": 0.443,
733
+ "LYS": -0.217,
734
+ "MET": 0.238,
735
+ "PHE": 0.5,
736
+ "PRO": 0.211,
737
+ "SER": -0.141,
738
+ "THR": -0.05,
739
+ "TRP": 0.378,
740
+ "TYR": 0.38,
741
+ "VAL": 0.325,
742
+ }
743
+
744
+ # Side chain max accessible surface area in Ala-X-Ala tripeptide (from Chennamsetty et al. 2010).
745
+ side_chain_asa = {
746
+ "ALA": 64.7809,
747
+ "ARG": 210.02,
748
+ "ASN": 113.187,
749
+ "ASP": 110.209,
750
+ "CYS": 95.2439,
751
+ "GLN": 147.855,
752
+ "GLU": 143.924,
753
+ "GLY": 23.1338,
754
+ "HIS": 146.449,
755
+ "ILE": 151.242,
756
+ "LEU": 139.524,
757
+ "LYS": 177.366,
758
+ "MET": 164.674,
759
+ "PHE": 186.7,
760
+ "PRO": 111.533,
761
+ "SER": 81.2159,
762
+ "THR": 111.597,
763
+ "TRP": 229.619,
764
+ "TYR": 200.306,
765
+ "VAL": 124.237,
766
+ }
767
+
768
+ # Approximate Volumes of amino acids in cubic angstroms.
769
+ # https://www.imgt.org/IMGTeducation/Aide-memoire/_UK/aminoacids/abbreviation.html
770
+ amino_acid_volumes = {
771
+ "A": 88.6, # Alanine
772
+ "R": 173.4, # Arginine
773
+ "N": 114.1, # Asparagine
774
+ "D": 111.1, # Aspartic acid
775
+ "C": 108.5, # Cysteine
776
+ "Q": 143.8, # Glutamine
777
+ "E": 138.4, # Glutamic acid
778
+ "G": 60.1, # Glycine
779
+ "H": 153.2, # Histidine
780
+ "I": 166.7, # Isoleucine
781
+ "L": 166.7, # Leucine
782
+ "K": 168.6, # Lysine
783
+ "M": 162.9, # Methionine
784
+ "F": 189.9, # Phenylalanine
785
+ "P": 112.7, # Proline
786
+ "S": 89.0, # Serine
787
+ "T": 116.1, # Threonine
788
+ "W": 227.8, # Tryptophan
789
+ "Y": 193.6, # Tyrosine
790
+ "V": 140.0, # Valine
791
+ "X": 88.6, # Unknown, use Alanine as approximation
792
+ }
793
+
794
+
795
+ def sequence_to_onehot(
796
+ sequence: str, mapping: Mapping[str, int], map_unknown_to_x: bool = False
797
+ ) -> np.ndarray:
798
+ """Maps the given sequence into a one-hot encoded matrix.
799
+
800
+ Args:
801
+ sequence: An amino acid sequence.
802
+ mapping: A dictionary mapping amino acids to integers.
803
+ map_unknown_to_x: If True, any amino acid that is not in the mapping will be
804
+ mapped to the unknown amino acid 'X'. If the mapping doesn't contain
805
+ amino acid 'X', an error will be thrown. If False, any amino acid not in
806
+ the mapping will throw an error.
807
+
808
+ Returns:
809
+ A numpy array of shape (seq_len, num_unique_aas) with one-hot encoding of
810
+ the sequence.
811
+
812
+ Raises:
813
+ ValueError: If the mapping doesn't contain values from 0 to
814
+ num_unique_aas - 1 without any gaps.
815
+ """
816
+ num_entries = max(mapping.values()) + 1
817
+
818
+ if sorted(set(mapping.values())) != list(range(num_entries)):
819
+ raise ValueError(
820
+ "The mapping must have values from 0 to num_unique_aas-1 "
821
+ "without any gaps. Got: %s" % sorted(mapping.values())
822
+ )
823
+
824
+ one_hot_arr = np.zeros((len(sequence), num_entries), dtype=np.int32)
825
+
826
+ for aa_index, aa_type in enumerate(sequence):
827
+ if map_unknown_to_x:
828
+ if aa_type.isalpha() and aa_type.isupper():
829
+ aa_id = mapping.get(aa_type, mapping["X"])
830
+ else:
831
+ raise ValueError(f"Invalid character in the sequence: {aa_type}")
832
+ else:
833
+ aa_id = mapping[aa_type]
834
+ one_hot_arr[aa_index, aa_id] = 1
835
+
836
+ return one_hot_arr
837
+
838
+
839
+ restype_1to3 = {
840
+ "A": "ALA",
841
+ "R": "ARG",
842
+ "N": "ASN",
843
+ "D": "ASP",
844
+ "C": "CYS",
845
+ "Q": "GLN",
846
+ "E": "GLU",
847
+ "G": "GLY",
848
+ "H": "HIS",
849
+ "I": "ILE",
850
+ "L": "LEU",
851
+ "K": "LYS",
852
+ "M": "MET",
853
+ "F": "PHE",
854
+ "P": "PRO",
855
+ "S": "SER",
856
+ "T": "THR",
857
+ "W": "TRP",
858
+ "Y": "TYR",
859
+ "V": "VAL",
860
+ "X": "UNK",
861
+ }
862
+
863
+
864
+ # NB: restype_3to1 differs from Bio.PDB.protein_letters_3to1 by being a simple
865
+ # 1-to-1 mapping of 3 letter names to one letter names. The latter contains
866
+ # many more, and less common, three letter names as keys and maps many of these
867
+ # to the same one letter name (including 'X' and 'U' which we don't use here).
868
+ restype_3to1 = {v: k for k, v in restype_1to3.items()}
869
+
870
+ # Define a restype name for all unknown residues.
871
+ unk_restype = "UNK"
872
+
873
+ resnames = [restype_1to3[r] for r in restypes] + [unk_restype]
874
+ resname_to_idx = {resname: i for i, resname in enumerate(resnames)}
875
+
876
+ hydrophobic_resnames = {"VAL", "ILE", "LEU", "PHE", "MET", "TRP"}
877
+
878
+ # The mapping here uses hhblits convention, so that B is mapped to D, J and O
879
+ # are mapped to X, U is mapped to C, and Z is mapped to E. Other than that the
880
+ # remaining 20 amino acids are kept in alphabetical order.
881
+ # There are 2 non-amino acid codes, X (representing any amino acid) and
882
+ # "-" representing a missing amino acid in an alignment. The id for these
883
+ # codes is put at the end (20 and 21) so that they can easily be ignored if
884
+ # desired.
885
+ HHBLITS_AA_TO_ID = {
886
+ "A": 0,
887
+ "B": 2,
888
+ "C": 1,
889
+ "D": 2,
890
+ "E": 3,
891
+ "F": 4,
892
+ "G": 5,
893
+ "H": 6,
894
+ "I": 7,
895
+ "J": 20,
896
+ "K": 8,
897
+ "L": 9,
898
+ "M": 10,
899
+ "N": 11,
900
+ "O": 20,
901
+ "P": 12,
902
+ "Q": 13,
903
+ "R": 14,
904
+ "S": 15,
905
+ "T": 16,
906
+ "U": 1,
907
+ "V": 17,
908
+ "W": 18,
909
+ "X": 20,
910
+ "Y": 19,
911
+ "Z": 3,
912
+ "-": 21,
913
+ }
914
+
915
+ # Partial inversion of HHBLITS_AA_TO_ID.
916
+ ID_TO_HHBLITS_AA = {
917
+ 0: "A",
918
+ 1: "C", # Also U.
919
+ 2: "D", # Also B.
920
+ 3: "E", # Also Z.
921
+ 4: "F",
922
+ 5: "G",
923
+ 6: "H",
924
+ 7: "I",
925
+ 8: "K",
926
+ 9: "L",
927
+ 10: "M",
928
+ 11: "N",
929
+ 12: "P",
930
+ 13: "Q",
931
+ 14: "R",
932
+ 15: "S",
933
+ 16: "T",
934
+ 17: "V",
935
+ 18: "W",
936
+ 19: "Y",
937
+ 20: "X", # Includes J and O.
938
+ 21: "-",
939
+ }
940
+
941
+ restypes_with_x_and_gap = restypes + ["X", "-"]
942
+ MAP_HHBLITS_AATYPE_TO_OUR_AATYPE = tuple(
943
+ restypes_with_x_and_gap.index(ID_TO_HHBLITS_AA[i])
944
+ for i in range(len(restypes_with_x_and_gap))
945
+ )
946
+
947
+
948
+ def _make_standard_atom_mask() -> np.ndarray:
949
+ """Returns [num_res_types, num_atom_types] mask array."""
950
+ # +1 to account for unknown (all 0s).
951
+ mask = np.zeros([restype_num + 1, atom_type_num], dtype=np.int32)
952
+ for restype, restype_letter in enumerate(restypes):
953
+ restype_name = restype_1to3[restype_letter]
954
+ atom_names = residue_atoms[restype_name]
955
+ for atom_name in atom_names:
956
+ atom_type = atom_order[atom_name]
957
+ mask[restype, atom_type] = 1
958
+ return mask
959
+
960
+
961
+ STANDARD_ATOM_MASK = _make_standard_atom_mask()
962
+
963
+
964
+ # A one hot representation for the first and second atoms defining the axis
965
+ # of rotation for each chi-angle in each residue.
966
+ def chi_angle_atom(atom_index: int) -> np.ndarray:
967
+ """Define chi-angle rigid groups via one-hot representations."""
968
+ chi_angles_index = {}
969
+ one_hots = []
970
+
971
+ for k, v in chi_angles_atoms.items():
972
+ indices = [atom_types.index(s[atom_index]) for s in v]
973
+ indices.extend([-1] * (4 - len(indices)))
974
+ chi_angles_index[k] = indices
975
+
976
+ for r in restypes:
977
+ res3 = restype_1to3[r]
978
+ one_hot = np.eye(atom_type_num)[chi_angles_index[res3]]
979
+ one_hots.append(one_hot)
980
+
981
+ one_hots.append(np.zeros([4, atom_type_num])) # Add zeros for residue `X`.
982
+ one_hot = np.stack(one_hots, axis=0)
983
+ one_hot = np.transpose(one_hot, [0, 2, 1])
984
+
985
+ return one_hot
986
+
987
+
988
+ chi_atom_1_one_hot = chi_angle_atom(1)
989
+ chi_atom_2_one_hot = chi_angle_atom(2)
990
+
991
+ # An array like chi_angles_atoms but using indices rather than names.
992
+ chi_angles_atom_indices = [chi_angles_atoms[restype_1to3[r]] for r in restypes]
993
+ # chi_angles_atom_indices = tree.map_structure(
994
+ # lambda atom_name: atom_order[atom_name], chi_angles_atom_indices
995
+ # )
996
+ chi_angles_atom_indices = np.array(
997
+ [
998
+ chi_atoms + ([[0, 0, 0, 0]] * (4 - len(chi_atoms)))
999
+ for chi_atoms in chi_angles_atom_indices
1000
+ ]
1001
+ )
1002
+
1003
+ # Mapping from (res_name, atom_name) pairs to the atom's chi group index
1004
+ # and atom index within that group.
1005
+ chi_groups_for_atom = collections.defaultdict(list)
1006
+ for res_name, chi_angle_atoms_for_res in chi_angles_atoms.items():
1007
+ for chi_group_i, chi_group in enumerate(chi_angle_atoms_for_res):
1008
+ for atom_i, atom in enumerate(chi_group):
1009
+ chi_groups_for_atom[(res_name, atom)].append((chi_group_i, atom_i))
1010
+ chi_groups_for_atom = dict(chi_groups_for_atom)
1011
+
1012
+
1013
+ def _make_rigid_transformation_4x4(ex, ey, translation):
1014
+ """Create a rigid 4x4 transformation matrix from two axes and transl."""
1015
+ # Normalize ex.
1016
+ ex_normalized = ex / np.linalg.norm(ex)
1017
+
1018
+ # make ey perpendicular to ex
1019
+ ey_normalized = ey - np.dot(ey, ex_normalized) * ex_normalized
1020
+ ey_normalized /= np.linalg.norm(ey_normalized)
1021
+
1022
+ # compute ez as cross product
1023
+ eznorm = np.cross(ex_normalized, ey_normalized)
1024
+ m = np.stack([ex_normalized, ey_normalized, eznorm, translation]).transpose()
1025
+ m = np.concatenate([m, [[0.0, 0.0, 0.0, 1.0]]], axis=0)
1026
+ return m
1027
+
1028
+
1029
+ # create an array with (restype, atomtype) --> rigid_group_idx
1030
+ # and an array with (restype, atomtype, coord) for the atom positions
1031
+ # and compute affine transformation matrices (4,4) from one rigid group to the
1032
+ # previous group
1033
+ restype_atom37_to_rigid_group = np.zeros([21, 37], dtype=int)
1034
+ restype_atom37_mask = np.zeros([21, 37], dtype=np.float32)
1035
+ restype_atom37_rigid_group_positions = np.zeros([21, 37, 3], dtype=np.float32)
1036
+ restype_atom14_to_rigid_group = np.zeros([21, 14], dtype=int)
1037
+ restype_atom14_mask = np.zeros([21, 14], dtype=np.float32)
1038
+ restype_atom14_rigid_group_positions = np.zeros([21, 14, 3], dtype=np.float32)
1039
+ restype_rigid_group_default_frame = np.zeros([21, 8, 4, 4], dtype=np.float32)
1040
+
1041
+
1042
+ def _make_rigid_group_constants():
1043
+ """Fill the arrays above."""
1044
+ for restype, restype_letter in enumerate(restypes_with_x):
1045
+ resname = restype_1to3[restype_letter]
1046
+ for atomname, group_idx, atom_position in rigid_group_atom_positions[resname]:
1047
+ atomtype = atom_order[atomname]
1048
+ restype_atom37_to_rigid_group[restype, atomtype] = group_idx
1049
+ restype_atom37_mask[restype, atomtype] = 1
1050
+ restype_atom37_rigid_group_positions[restype, atomtype, :] = atom_position
1051
+
1052
+ atom14idx = restype_name_to_atom14_names[resname].index(atomname)
1053
+ restype_atom14_to_rigid_group[restype, atom14idx] = group_idx
1054
+ restype_atom14_mask[restype, atom14idx] = 1
1055
+ restype_atom14_rigid_group_positions[restype, atom14idx, :] = atom_position
1056
+
1057
+ for restype, restype_letter in enumerate(restypes_with_x):
1058
+ resname = restype_1to3[restype_letter]
1059
+ atom_positions = {
1060
+ name: np.array(pos) for name, _, pos in rigid_group_atom_positions[resname]
1061
+ }
1062
+
1063
+ # backbone to backbone is the identity transform
1064
+ restype_rigid_group_default_frame[restype, 0, :, :] = np.eye(4)
1065
+
1066
+ # pre-omega-frame to backbone (currently dummy identity matrix)
1067
+ restype_rigid_group_default_frame[restype, 1, :, :] = np.eye(4)
1068
+
1069
+ # phi-frame to backbone
1070
+ mat = _make_rigid_transformation_4x4(
1071
+ ex=atom_positions["N"] - atom_positions["CA"],
1072
+ ey=np.array([1.0, 0.0, 0.0]),
1073
+ translation=atom_positions["N"],
1074
+ )
1075
+ restype_rigid_group_default_frame[restype, 2, :, :] = mat
1076
+
1077
+ # psi-frame to backbone
1078
+ mat = _make_rigid_transformation_4x4(
1079
+ ex=atom_positions["C"] - atom_positions["CA"],
1080
+ ey=atom_positions["CA"] - atom_positions["N"],
1081
+ translation=atom_positions["C"],
1082
+ )
1083
+ restype_rigid_group_default_frame[restype, 3, :, :] = mat
1084
+
1085
+ # chi1-frame to backbone
1086
+ if chi_angles_mask[restype][0]:
1087
+ base_atom_names = chi_angles_atoms[resname][0]
1088
+ base_atom_positions = [atom_positions[name] for name in base_atom_names]
1089
+ mat = _make_rigid_transformation_4x4(
1090
+ ex=base_atom_positions[2] - base_atom_positions[1],
1091
+ ey=base_atom_positions[0] - base_atom_positions[1],
1092
+ translation=base_atom_positions[2],
1093
+ )
1094
+ restype_rigid_group_default_frame[restype, 4, :, :] = mat
1095
+
1096
+ # chi2-frame to chi1-frame
1097
+ # chi3-frame to chi2-frame
1098
+ # chi4-frame to chi3-frame
1099
+ # luckily all rotation axes for the next frame start at (0,0,0) of the
1100
+ # previous frame
1101
+ for chi_idx in range(1, 4):
1102
+ if chi_angles_mask[restype][chi_idx]:
1103
+ axis_end_atom_name = chi_angles_atoms[resname][chi_idx][2]
1104
+ axis_end_atom_position = atom_positions[axis_end_atom_name]
1105
+ mat = _make_rigid_transformation_4x4(
1106
+ ex=axis_end_atom_position,
1107
+ ey=np.array([-1.0, 0.0, 0.0]),
1108
+ translation=axis_end_atom_position,
1109
+ )
1110
+ restype_rigid_group_default_frame[restype, 4 + chi_idx, :, :] = mat
1111
+
1112
+
1113
+ _make_rigid_group_constants()
1114
+
1115
+
1116
+ def make_atom14_dists_bounds(overlap_tolerance=1.5, bond_length_tolerance_factor=15.0):
1117
+ """compute upper and lower bounds for bonds to assess violations."""
1118
+ restype_atom14_bond_lower_bound = np.zeros([21, 14, 14], np.float32)
1119
+ restype_atom14_bond_upper_bound = np.zeros([21, 14, 14], np.float32)
1120
+ restype_atom14_bond_stddev = np.zeros([21, 14, 14], np.float32)
1121
+ residue_bonds, residue_virtual_bonds, _ = load_stereo_chemical_props()
1122
+ for restype, restype_letter in enumerate(restypes):
1123
+ resname = restype_1to3[restype_letter]
1124
+ atom_list = restype_name_to_atom14_names[resname]
1125
+
1126
+ # create lower and upper bounds for clashes
1127
+ for atom1_idx, atom1_name in enumerate(atom_list):
1128
+ if not atom1_name:
1129
+ continue
1130
+ atom1_radius = van_der_waals_radius[atom1_name[0]]
1131
+ for atom2_idx, atom2_name in enumerate(atom_list):
1132
+ if (not atom2_name) or atom1_idx == atom2_idx:
1133
+ continue
1134
+ atom2_radius = van_der_waals_radius[atom2_name[0]]
1135
+ lower = atom1_radius + atom2_radius - overlap_tolerance
1136
+ upper = 1e10
1137
+ restype_atom14_bond_lower_bound[restype, atom1_idx, atom2_idx] = lower
1138
+ restype_atom14_bond_lower_bound[restype, atom2_idx, atom1_idx] = lower
1139
+ restype_atom14_bond_upper_bound[restype, atom1_idx, atom2_idx] = upper
1140
+ restype_atom14_bond_upper_bound[restype, atom2_idx, atom1_idx] = upper
1141
+
1142
+ # overwrite lower and upper bounds for bonds and angles
1143
+ for b in residue_bonds[resname] + residue_virtual_bonds[resname]:
1144
+ atom1_idx = atom_list.index(b.atom1_name)
1145
+ atom2_idx = atom_list.index(b.atom2_name)
1146
+ lower = b.length - bond_length_tolerance_factor * b.stddev
1147
+ upper = b.length + bond_length_tolerance_factor * b.stddev
1148
+ restype_atom14_bond_lower_bound[restype, atom1_idx, atom2_idx] = lower
1149
+ restype_atom14_bond_lower_bound[restype, atom2_idx, atom1_idx] = lower
1150
+ restype_atom14_bond_upper_bound[restype, atom1_idx, atom2_idx] = upper
1151
+ restype_atom14_bond_upper_bound[restype, atom2_idx, atom1_idx] = upper
1152
+ restype_atom14_bond_stddev[restype, atom1_idx, atom2_idx] = b.stddev
1153
+ restype_atom14_bond_stddev[restype, atom2_idx, atom1_idx] = b.stddev
1154
+ return {
1155
+ "lower_bound": restype_atom14_bond_lower_bound, # shape (21,14,14)
1156
+ "upper_bound": restype_atom14_bond_upper_bound, # shape (21,14,14)
1157
+ "stddev": restype_atom14_bond_stddev, # shape (21,14,14)
1158
+ }
1159
+
1160
+
1161
+ restype_atom14_ambiguous_atoms = np.zeros((21, 14), dtype=np.float32)
1162
+ restype_atom14_ambiguous_atoms_swap_idx = np.tile(np.arange(14, dtype=int), (21, 1))
1163
+
1164
+
1165
+ def _make_atom14_ambiguity_feats():
1166
+ for res, pairs in residue_atom_renaming_swaps.items():
1167
+ res_idx = restype_order[restype_3to1[res]]
1168
+ for atom1, atom2 in pairs.items():
1169
+ atom1_idx = restype_name_to_atom14_names[res].index(atom1)
1170
+ atom2_idx = restype_name_to_atom14_names[res].index(atom2)
1171
+ restype_atom14_ambiguous_atoms[res_idx, atom1_idx] = 1
1172
+ restype_atom14_ambiguous_atoms[res_idx, atom2_idx] = 1
1173
+ restype_atom14_ambiguous_atoms_swap_idx[res_idx, atom1_idx] = atom2_idx
1174
+ restype_atom14_ambiguous_atoms_swap_idx[res_idx, atom2_idx] = atom1_idx
1175
+
1176
+
1177
+ _make_atom14_ambiguity_feats()
1178
+
1179
+
1180
+ def aatype_to_str_sequence(aatype):
1181
+ return "".join([restypes_with_x[aatype[i]] for i in range(len(aatype))])
1182
+
1183
+
1184
+ # NOTE(thayes): These are computed based on the average CA->C and CA->N norm from rigid_group_atom_positions
1185
+ CA_TO_N_NORM = 1.4591
1186
+ CA_TO_C_NORM = 1.5252
1187
+
1188
+
1189
+ def _make_restype_atom37_to_atom14():
1190
+ """Map from atom37 to atom14 per residue type."""
1191
+ restype_atom37_to_atom14 = [] # mapping (restype, atom37) --> atom14
1192
+ for rt in restypes:
1193
+ atom_names = restype_name_to_atom14_names[restype_1to3[rt]]
1194
+ atom_name_to_idx14 = {name: i for i, name in enumerate(atom_names)}
1195
+ restype_atom37_to_atom14.append(
1196
+ [
1197
+ (atom_name_to_idx14[name] if name in atom_name_to_idx14 else 0)
1198
+ for name in atom_types
1199
+ ]
1200
+ )
1201
+
1202
+ restype_atom37_to_atom14.append([0] * 37)
1203
+ restype_atom37_to_atom14 = np.array(restype_atom37_to_atom14, dtype=np.int32)
1204
+ return restype_atom37_to_atom14
1205
+
1206
+
1207
+ def _make_restype_atom14_to_atom37():
1208
+ """Map from atom14 to atom37 per residue type."""
1209
+ restype_atom14_to_atom37 = [] # mapping (restype, atom14) --> atom37
1210
+ for rt in restypes:
1211
+ atom_names = restype_name_to_atom14_names[restype_1to3[rt]]
1212
+ restype_atom14_to_atom37.append(
1213
+ [(atom_order[name] if name else 0) for name in atom_names]
1214
+ )
1215
+ # Add dummy mapping for restype 'UNK'
1216
+ restype_atom14_to_atom37.append([0] * 14)
1217
+ restype_atom14_to_atom37 = np.array(restype_atom14_to_atom37, dtype=np.int32)
1218
+ return restype_atom14_to_atom37
1219
+
1220
+
1221
+ RESTYPE_ATOM14_TO_ATOM37 = _make_restype_atom14_to_atom37()
1222
+ RESTYPE_ATOM37_TO_ATOM14 = _make_restype_atom37_to_atom14()
1223
+ CHAIN_BREAK_TOKEN = "|"
esmfold2_sequential_dataclass.py CHANGED
@@ -1,157 +1,157 @@
1
- from abc import ABC, abstractmethod
2
- from dataclasses import dataclass, fields, replace
3
- from typing import TypeVar
4
-
5
- import numpy as np
6
-
7
- from .esmfold2_misc import concat_objects, slice_any_object
8
-
9
- T = TypeVar("T")
10
-
11
-
12
- @dataclass(frozen=True)
13
- class SequentialDataclass(ABC):
14
- """
15
- This is a builder on a dataclass that allows for automatic slicing and concatenation.
16
-
17
- When representing multimodal data, we often have multiple datatypes which have sequence dimensions that are the same (e.g. the length of the protein).
18
-
19
- When applying a transformation like a crop, we want to apply this to all tensors at the same time (e.g. crop the sequence, structure, and function).
20
-
21
- We also have some fields that are not sequential (like an id, or data source), which we don't want to crop.
22
-
23
- The SequentialDataclass abstracts this cropping away, allowing you to define dataclasses that implement `__len__`, `__getitem__` and `concat` automatically.
24
-
25
- This is done through the `metadata` field, which can take 3 values:
26
- `sequence` (bool): True or False, tells the dataclass whether this field is a sequential type. Default: False.
27
- `sequence_dim` (int): Which dimension is the sequential dimension (e.g. for a list of inverse folded sequences, we want to index each sequence in the list, not the list itself). Default: 0.
28
- `join_token` (Any): What token to use to join when concatenating elements. Default: None.
29
-
30
-
31
- Example:
32
-
33
- @dataclass(frozen=True)
34
- class Foo(SequentialDataclass):
35
- id: str
36
- sequence: str = field(metadata={"sequence": True, "join_token": "|"})
37
- tensor: torch.Tensor = field(metadata={"sequence": True, "join_token": torch.nan})
38
-
39
- def __len__(self):
40
- # Must implement the __len__ method
41
- return len(self.sequence)
42
-
43
- >>> foo = Foo(id="foo", sequence="ABCDE", tensor=torch.randn(5))
44
- Foo(id='foo', sequence='ABCDE', tensor=tensor([ 0.0252, -0.3335, -0.5143, 0.0251, -1.0717]))
45
-
46
- >>> foo[1:4]
47
- Foo(id='foo', sequence='BCD', tensor=tensor([-0.3335, -0.5143, 0.0251]))
48
-
49
- >>> foo[np.arange(5) < 3]
50
- Foo(id='foo', sequence='ABC', tensor=tensor([ 0.0252, -0.3335, -0.5143]))
51
-
52
- >>> Foo.concat([foo[:2], foo[3:]])
53
- Foo(id='foo', sequence='AB|DE', tensor=tensor([ 0.0252, -0.3335, nan, 0.0251, -1.0717]))
54
-
55
- # Trying to create a type where the sequence lengths do not match raises an error
56
- >>> foo = Foo(id="foo", sequence="ABCDE", tensor=torch.randn(6))
57
- ValueError: Mismatch in sequence length for field: tensor. Expected 5, received 6
58
-
59
- """
60
-
61
- def __post_init__(self):
62
- self._check_sequence_lengths_match()
63
-
64
- @abstractmethod
65
- def __len__(self):
66
- raise NotImplementedError
67
-
68
- def __getitem__(self, idx: int | list[int] | slice | np.ndarray):
69
- updated_fields = {}
70
- if isinstance(idx, int):
71
- # make it so that things remain sequential
72
- idx = [idx]
73
-
74
- for fld in fields(self):
75
- if fld.metadata.get("sequence", False):
76
- # this is a sequence, should be the same length as all other sequences
77
- sequence_dim = fld.metadata.get("sequence_dim", 0)
78
- value = getattr(self, fld.name)
79
- if value is None:
80
- continue
81
- match sequence_dim:
82
- case 0:
83
- # sequence is first dimension
84
- value = getattr(self, fld.name)
85
- value = slice_any_object(value, idx)
86
- updated_fields[fld.name] = value
87
- case 1:
88
- new_value = [slice_any_object(item, idx) for item in value]
89
- updated_fields[fld.name] = value.__class__(new_value)
90
- case _:
91
- raise NotImplementedError(
92
- "Arbitrary slicing for different sequence length fields is not implemented"
93
- )
94
-
95
- return replace(self, **updated_fields)
96
-
97
- def _check_sequence_lengths_match(self):
98
- """Checks if sequence lengths of all "sequence" fields match."""
99
- for fld in fields(self):
100
- if fld.metadata.get("sequence", False) and fld.name != "complex":
101
- # this is a sequence, should be the same length as all other sequences
102
- sequence_dim = fld.metadata.get("sequence_dim", 0)
103
- value = getattr(self, fld.name)
104
- if value is None:
105
- continue
106
- match sequence_dim:
107
- case 0:
108
- # sequence is first dimension
109
- value = getattr(self, fld.name)
110
- if len(value) != len(self):
111
- raise ValueError(
112
- f"Mismatch in sequence length for field: {fld.name}. Expected {len(self)}, received {len(value)}"
113
- )
114
- case 1:
115
- for item in value:
116
- if len(item) != len(self):
117
- raise ValueError(
118
- f"Mismatch in sequence length for field: {fld.name}. Expected {len(self)}, received {len(item)}"
119
- )
120
- case _:
121
- raise NotImplementedError(
122
- "Arbitrary matching for different sequence length fields is not implemented"
123
- )
124
-
125
- @classmethod
126
- def concat(cls, items: list[T], **kwargs) -> T:
127
- updated_fields = {}
128
- for fld in fields(cls):
129
- if fld.metadata.get("sequence", False):
130
- # this is a sequence, should be the same length as all other sequences
131
- sequence_dim = fld.metadata.get("sequence_dim", 0)
132
- join_value = fld.metadata.get("join_token", None)
133
- if getattr(items[0], fld.name) is None:
134
- continue
135
- values = [getattr(item, fld.name) for item in items]
136
- match sequence_dim:
137
- case 0:
138
- # sequence is first dimension
139
- value = concat_objects(values, join_value)
140
- updated_fields[fld.name] = value
141
- case 1:
142
- new_value = [
143
- concat_objects(item, join_value) for item in zip(*values)
144
- ]
145
- updated_fields[fld.name] = getattr(
146
- items[0], fld.name
147
- ).__class__(new_value)
148
- case _:
149
- raise NotImplementedError(
150
- "Arbitrary joining for different sequence length fields is not implemented"
151
- )
152
- updated_fields.update(kwargs)
153
-
154
- return replace(
155
- items[0], # type: ignore
156
- **updated_fields,
157
- )
 
1
+ from abc import ABC, abstractmethod
2
+ from dataclasses import dataclass, fields, replace
3
+ from typing import TypeVar
4
+
5
+ import numpy as np
6
+
7
+ from .esmfold2_misc import concat_objects, slice_any_object
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class SequentialDataclass(ABC):
14
+ """
15
+ This is a builder on a dataclass that allows for automatic slicing and concatenation.
16
+
17
+ When representing multimodal data, we often have multiple datatypes which have sequence dimensions that are the same (e.g. the length of the protein).
18
+
19
+ When applying a transformation like a crop, we want to apply this to all tensors at the same time (e.g. crop the sequence, structure, and function).
20
+
21
+ We also have some fields that are not sequential (like an id, or data source), which we don't want to crop.
22
+
23
+ The SequentialDataclass abstracts this cropping away, allowing you to define dataclasses that implement `__len__`, `__getitem__` and `concat` automatically.
24
+
25
+ This is done through the `metadata` field, which can take 3 values:
26
+ `sequence` (bool): True or False, tells the dataclass whether this field is a sequential type. Default: False.
27
+ `sequence_dim` (int): Which dimension is the sequential dimension (e.g. for a list of inverse folded sequences, we want to index each sequence in the list, not the list itself). Default: 0.
28
+ `join_token` (Any): What token to use to join when concatenating elements. Default: None.
29
+
30
+
31
+ Example:
32
+
33
+ @dataclass(frozen=True)
34
+ class Foo(SequentialDataclass):
35
+ id: str
36
+ sequence: str = field(metadata={"sequence": True, "join_token": "|"})
37
+ tensor: torch.Tensor = field(metadata={"sequence": True, "join_token": torch.nan})
38
+
39
+ def __len__(self):
40
+ # Must implement the __len__ method
41
+ return len(self.sequence)
42
+
43
+ >>> foo = Foo(id="foo", sequence="ABCDE", tensor=torch.randn(5))
44
+ Foo(id='foo', sequence='ABCDE', tensor=tensor([ 0.0252, -0.3335, -0.5143, 0.0251, -1.0717]))
45
+
46
+ >>> foo[1:4]
47
+ Foo(id='foo', sequence='BCD', tensor=tensor([-0.3335, -0.5143, 0.0251]))
48
+
49
+ >>> foo[np.arange(5) < 3]
50
+ Foo(id='foo', sequence='ABC', tensor=tensor([ 0.0252, -0.3335, -0.5143]))
51
+
52
+ >>> Foo.concat([foo[:2], foo[3:]])
53
+ Foo(id='foo', sequence='AB|DE', tensor=tensor([ 0.0252, -0.3335, nan, 0.0251, -1.0717]))
54
+
55
+ # Trying to create a type where the sequence lengths do not match raises an error
56
+ >>> foo = Foo(id="foo", sequence="ABCDE", tensor=torch.randn(6))
57
+ ValueError: Mismatch in sequence length for field: tensor. Expected 5, received 6
58
+
59
+ """
60
+
61
+ def __post_init__(self):
62
+ self._check_sequence_lengths_match()
63
+
64
+ @abstractmethod
65
+ def __len__(self):
66
+ raise NotImplementedError
67
+
68
+ def __getitem__(self, idx: int | list[int] | slice | np.ndarray):
69
+ updated_fields = {}
70
+ if isinstance(idx, int):
71
+ # make it so that things remain sequential
72
+ idx = [idx]
73
+
74
+ for fld in fields(self):
75
+ if fld.metadata.get("sequence", False):
76
+ # this is a sequence, should be the same length as all other sequences
77
+ sequence_dim = fld.metadata.get("sequence_dim", 0)
78
+ value = getattr(self, fld.name)
79
+ if value is None:
80
+ continue
81
+ match sequence_dim:
82
+ case 0:
83
+ # sequence is first dimension
84
+ value = getattr(self, fld.name)
85
+ value = slice_any_object(value, idx)
86
+ updated_fields[fld.name] = value
87
+ case 1:
88
+ new_value = [slice_any_object(item, idx) for item in value]
89
+ updated_fields[fld.name] = value.__class__(new_value)
90
+ case _:
91
+ raise NotImplementedError(
92
+ "Arbitrary slicing for different sequence length fields is not implemented"
93
+ )
94
+
95
+ return replace(self, **updated_fields)
96
+
97
+ def _check_sequence_lengths_match(self):
98
+ """Checks if sequence lengths of all "sequence" fields match."""
99
+ for fld in fields(self):
100
+ if fld.metadata.get("sequence", False) and fld.name != "complex":
101
+ # this is a sequence, should be the same length as all other sequences
102
+ sequence_dim = fld.metadata.get("sequence_dim", 0)
103
+ value = getattr(self, fld.name)
104
+ if value is None:
105
+ continue
106
+ match sequence_dim:
107
+ case 0:
108
+ # sequence is first dimension
109
+ value = getattr(self, fld.name)
110
+ if len(value) != len(self):
111
+ raise ValueError(
112
+ f"Mismatch in sequence length for field: {fld.name}. Expected {len(self)}, received {len(value)}"
113
+ )
114
+ case 1:
115
+ for item in value:
116
+ if len(item) != len(self):
117
+ raise ValueError(
118
+ f"Mismatch in sequence length for field: {fld.name}. Expected {len(self)}, received {len(item)}"
119
+ )
120
+ case _:
121
+ raise NotImplementedError(
122
+ "Arbitrary matching for different sequence length fields is not implemented"
123
+ )
124
+
125
+ @classmethod
126
+ def concat(cls, items: list[T], **kwargs) -> T:
127
+ updated_fields = {}
128
+ for fld in fields(cls):
129
+ if fld.metadata.get("sequence", False):
130
+ # this is a sequence, should be the same length as all other sequences
131
+ sequence_dim = fld.metadata.get("sequence_dim", 0)
132
+ join_value = fld.metadata.get("join_token", None)
133
+ if getattr(items[0], fld.name) is None:
134
+ continue
135
+ values = [getattr(item, fld.name) for item in items]
136
+ match sequence_dim:
137
+ case 0:
138
+ # sequence is first dimension
139
+ value = concat_objects(values, join_value)
140
+ updated_fields[fld.name] = value
141
+ case 1:
142
+ new_value = [
143
+ concat_objects(item, join_value) for item in zip(*values)
144
+ ]
145
+ updated_fields[fld.name] = getattr(
146
+ items[0], fld.name
147
+ ).__class__(new_value)
148
+ case _:
149
+ raise NotImplementedError(
150
+ "Arbitrary joining for different sequence length fields is not implemented"
151
+ )
152
+ updated_fields.update(kwargs)
153
+
154
+ return replace(
155
+ items[0], # type: ignore
156
+ **updated_fields,
157
+ )
esmfold2_system.py CHANGED
@@ -1,45 +1,45 @@
1
- import io
2
- import subprocess
3
- import typing as T
4
- from pathlib import Path
5
-
6
- PathLike = T.Union[str, Path]
7
- PathOrBuffer = T.Union[PathLike, io.StringIO]
8
-
9
-
10
- def run_subprocess_with_errorcheck(
11
- *popenargs,
12
- capture_output: bool = False,
13
- quiet: bool = False,
14
- env: dict[str, str] | None = None,
15
- shell: bool = False,
16
- executable: str | None = None,
17
- **kws,
18
- ) -> subprocess.CompletedProcess:
19
- """A command similar to subprocess.run, however the errormessage will
20
- contain the stderr when using this function. This makes it significantly
21
- easier to diagnose issues.
22
- """
23
- try:
24
- if capture_output:
25
- stdout = subprocess.PIPE
26
- elif quiet:
27
- stdout = subprocess.DEVNULL
28
- else:
29
- stdout = None
30
-
31
- p = subprocess.run(
32
- *popenargs,
33
- stderr=subprocess.PIPE,
34
- stdout=stdout,
35
- check=True,
36
- env=env,
37
- shell=shell,
38
- executable=executable,
39
- **kws,
40
- )
41
- except subprocess.CalledProcessError as e:
42
- raise RuntimeError(
43
- f"Command failed with errorcode {e.returncode}." f"\n\n{e.stderr.decode()}"
44
- )
45
- return p
 
1
+ import io
2
+ import subprocess
3
+ import typing as T
4
+ from pathlib import Path
5
+
6
+ PathLike = T.Union[str, Path]
7
+ PathOrBuffer = T.Union[PathLike, io.StringIO]
8
+
9
+
10
+ def run_subprocess_with_errorcheck(
11
+ *popenargs,
12
+ capture_output: bool = False,
13
+ quiet: bool = False,
14
+ env: dict[str, str] | None = None,
15
+ shell: bool = False,
16
+ executable: str | None = None,
17
+ **kws,
18
+ ) -> subprocess.CompletedProcess:
19
+ """A command similar to subprocess.run, however the errormessage will
20
+ contain the stderr when using this function. This makes it significantly
21
+ easier to diagnose issues.
22
+ """
23
+ try:
24
+ if capture_output:
25
+ stdout = subprocess.PIPE
26
+ elif quiet:
27
+ stdout = subprocess.DEVNULL
28
+ else:
29
+ stdout = None
30
+
31
+ p = subprocess.run(
32
+ *popenargs,
33
+ stderr=subprocess.PIPE,
34
+ stdout=stdout,
35
+ check=True,
36
+ env=env,
37
+ shell=shell,
38
+ executable=executable,
39
+ **kws,
40
+ )
41
+ except subprocess.CalledProcessError as e:
42
+ raise RuntimeError(
43
+ f"Command failed with errorcode {e.returncode}." f"\n\n{e.stderr.decode()}"
44
+ )
45
+ return p
esmfold2_types.py CHANGED
@@ -1,33 +1,33 @@
1
- """Re-exports of the canonical SPI dataclasses from input_builder.
2
-
3
- This module exists so the HF processor and downstream code can import the
4
- ESMFold2 input types from a single namespace without picking up internal-only
5
- sibling utilities. The actual definitions live in
6
- ``esm.utils.structure.input_builder``.
7
- """
8
-
9
- from .esmfold2_msa import MSA
10
- from .esmfold2_parsing import FastaEntry
11
- from .esmfold2_input_builder import (
12
- CovalentBond,
13
- DistogramConditioning,
14
- DNAInput,
15
- LigandInput,
16
- Modification,
17
- ProteinInput,
18
- RNAInput,
19
- StructurePredictionInput,
20
- )
21
-
22
- __all__ = [
23
- "FastaEntry",
24
- "MSA",
25
- "Modification",
26
- "ProteinInput",
27
- "RNAInput",
28
- "DNAInput",
29
- "LigandInput",
30
- "DistogramConditioning",
31
- "CovalentBond",
32
- "StructurePredictionInput",
33
- ]
 
1
+ """Re-exports of the canonical SPI dataclasses from input_builder.
2
+
3
+ This module exists so the HF processor and downstream code can import the
4
+ ESMFold2 input types from a single namespace without picking up internal-only
5
+ sibling utilities. The actual definitions live in
6
+ ``esm.utils.structure.input_builder``.
7
+ """
8
+
9
+ from .esmfold2_msa import MSA
10
+ from .esmfold2_parsing import FastaEntry
11
+ from .esmfold2_input_builder import (
12
+ CovalentBond,
13
+ DistogramConditioning,
14
+ DNAInput,
15
+ LigandInput,
16
+ Modification,
17
+ ProteinInput,
18
+ RNAInput,
19
+ StructurePredictionInput,
20
+ )
21
+
22
+ __all__ = [
23
+ "FastaEntry",
24
+ "MSA",
25
+ "Modification",
26
+ "ProteinInput",
27
+ "RNAInput",
28
+ "DNAInput",
29
+ "LigandInput",
30
+ "DistogramConditioning",
31
+ "CovalentBond",
32
+ "StructurePredictionInput",
33
+ ]
esmfold2_utils_types.py CHANGED
@@ -1,33 +1,33 @@
1
- from __future__ import annotations
2
-
3
- import io
4
- from dataclasses import dataclass
5
- from pathlib import Path
6
- from typing import Union
7
-
8
- from cloudpathlib import CloudPath
9
-
10
- PathLike = Union[str, Path, CloudPath]
11
- PathOrBuffer = Union[PathLike, io.StringIO]
12
-
13
-
14
- @dataclass
15
- class FunctionAnnotation:
16
- """Represents an annotation of a protein's function over a range of residues.
17
-
18
- Fields:
19
- label (str): An entry in either the function_tokens or residue_annotations tokenizer vocabs
20
- start (int): Start index of this annotation. 1-indexed, inclusive.
21
- end (int): End index of this annotation. 1-indexed, inclusive.
22
- """
23
-
24
- label: str
25
- start: int
26
- end: int
27
-
28
- def to_tuple(self) -> tuple[str, int, int]:
29
- return self.label, self.start, self.end
30
-
31
- def __len__(self) -> int:
32
- """Length of the annotation."""
33
- return self.end - self.start + 1
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Union
7
+
8
+ from cloudpathlib import CloudPath
9
+
10
+ PathLike = Union[str, Path, CloudPath]
11
+ PathOrBuffer = Union[PathLike, io.StringIO]
12
+
13
+
14
+ @dataclass
15
+ class FunctionAnnotation:
16
+ """Represents an annotation of a protein's function over a range of residues.
17
+
18
+ Fields:
19
+ label (str): An entry in either the function_tokens or residue_annotations tokenizer vocabs
20
+ start (int): Start index of this annotation. 1-indexed, inclusive.
21
+ end (int): End index of this annotation. 1-indexed, inclusive.
22
+ """
23
+
24
+ label: str
25
+ start: int
26
+ end: int
27
+
28
+ def to_tuple(self) -> tuple[str, int, int]:
29
+ return self.label, self.start, self.end
30
+
31
+ def __len__(self) -> int:
32
+ """Length of the annotation."""
33
+ return self.end - self.start + 1
modeling_esmfold2.py CHANGED
The diff for this file is too large to render. See raw diff
 
modeling_esmfold2_common.py CHANGED
The diff for this file is too large to render. See raw diff
 
modeling_esmfold2_experimental.py CHANGED
@@ -1,997 +1,1001 @@
1
- """FastPLMs ESMFold2 experimental architecture.
2
-
3
- This module supports Biohub's experimental binder-design checkpoints. The
4
- released ESMFold2 architecture in ``modeling_esmfold2.py`` intentionally
5
- rejects those configs because the experimental trunk uses explicit pair-loop
6
- re-injection and a different confidence/MSA stack.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import math
12
- from pathlib import Path
13
- from typing import Any, cast
14
-
15
- import torch
16
- import torch.nn as nn
17
- import torch.nn.functional as F
18
- from torch import Tensor
19
- from transformers.modeling_utils import PreTrainedModel
20
-
21
- from .configuration_esmfold2 import ESMFold2Config
22
- from .modeling_esmfold2 import (
23
- _convert_te_modules_to_fp8_inplace,
24
- _lm_precision_context,
25
- )
26
- from .modeling_esmfold2_common import (
27
- CHAR_VOCAB_SIZE,
28
- MAX_ATOMIC_NUMBER,
29
- NUM_RES_TYPES,
30
- DiffusionModule,
31
- DiffusionStructureHead,
32
- DiffusionTransformer,
33
- FoldingTrunk,
34
- InputsEmbedder,
35
- LanguageModelShim,
36
- MSAPairWeightedAveraging,
37
- OuterProductMean,
38
- PairUpdateBlock,
39
- ResIdxAsymIdSymIdEntityIdEncoding,
40
- RowAttentionPooling,
41
- SwiGLUMLP,
42
- TriangleMultiplicativeUpdate,
43
- _categorical_mean,
44
- _compute_intra_token_idx,
45
- _seed_context,
46
- compute_lm_hidden_states,
47
- gather_rep_atom_coords,
48
- gather_token_to_atom,
49
- )
50
-
51
- _EPS = 1e-5
52
- _NONPOLYMER_ID = 3
53
-
54
-
55
- class ConfidenceHead(nn.Module):
56
- """Experimental confidence head predicting pLDDT, PAE, pTM, and ipTM."""
57
-
58
- boundaries: Tensor
59
-
60
- def __init__(self, config: ESMFold2Config) -> None:
61
- super().__init__()
62
- ch = config.confidence_head
63
- d_single = config.d_single
64
- d_pair = config.d_pair
65
- d_inputs = config.inputs.d_inputs
66
-
67
- boundaries = torch.linspace(ch.min_dist, ch.max_dist, ch.distogram_bins - 1)
68
- self.register_buffer("boundaries", boundaries)
69
- self.dist_bin_pairwise_embed = nn.Embedding(ch.distogram_bins, d_pair)
70
-
71
- self.s_norm = nn.LayerNorm(d_single)
72
- self.s_inputs_to_single = nn.Linear(d_inputs, d_single, bias=False)
73
- self.s_to_z = nn.Linear(d_inputs, d_pair, bias=False)
74
- self.s_to_z_transpose = nn.Linear(d_inputs, d_pair, bias=False)
75
- self.s_to_z_prod_in1 = nn.Linear(d_inputs, d_pair, bias=False)
76
- self.s_to_z_prod_in2 = nn.Linear(d_inputs, d_pair, bias=False)
77
- self.s_to_z_prod_out = nn.Linear(d_pair, d_pair, bias=False)
78
- self.s_input_to_s = nn.Linear(d_inputs, d_single, bias=False)
79
- self.s_inputs_norm = nn.LayerNorm(d_inputs)
80
- self.z_norm = nn.LayerNorm(d_pair)
81
- self.row_attention_pooling = RowAttentionPooling(
82
- d_pair=d_pair, d_single=d_single
83
- )
84
-
85
- pf = ch.folding_trunk
86
- self.folding_trunk = FoldingTrunk(
87
- n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4
88
- )
89
-
90
- self.plddt_ln = nn.LayerNorm(d_single)
91
- max_atoms_per_token = 23
92
- self.plddt_weight = nn.Parameter(
93
- torch.zeros(max_atoms_per_token, d_single, ch.num_plddt_bins)
94
- )
95
- self.pae_head = nn.Linear(d_pair, ch.num_pae_bins, bias=False)
96
-
97
- def set_kernel_backend(self, backend: str | None) -> None:
98
- self.folding_trunk.set_kernel_backend(backend)
99
-
100
- def set_chunk_size(self, chunk_size: int | None) -> None:
101
- self.folding_trunk.set_chunk_size(chunk_size)
102
-
103
- @staticmethod
104
- def _repeat_batch(x: Tensor, num_diffusion_samples: int) -> Tensor:
105
- if num_diffusion_samples == 1:
106
- return x
107
- return x.repeat_interleave(num_diffusion_samples, 0)
108
-
109
- @staticmethod
110
- def _flatten_sample_axis(x: Tensor) -> Tensor:
111
- if x.ndim == 4:
112
- b, mult, n, c = x.shape
113
- return x.reshape(b * mult, n, c)
114
- return x
115
-
116
- def forward(
117
- self,
118
- s_inputs: Tensor,
119
- z: Tensor,
120
- x_pred: Tensor,
121
- distogram_atom_idx: Tensor,
122
- token_attention_mask: Tensor,
123
- atom_to_token: Tensor,
124
- atom_attention_mask: Tensor,
125
- asym_id: Tensor,
126
- mol_type: Tensor,
127
- num_diffusion_samples: int = 1,
128
- relative_position_encoding: Tensor | None = None,
129
- token_bonds_encoding: Tensor | None = None,
130
- ) -> dict[str, Tensor]:
131
- s_inputs_normed = self.s_inputs_norm(s_inputs)
132
- z_base = self.z_norm(z)
133
- if relative_position_encoding is not None:
134
- z_base = z_base + relative_position_encoding
135
- if token_bonds_encoding is not None:
136
- z_base = z_base + token_bonds_encoding
137
- z_base = z_base + self.s_to_z(s_inputs_normed).unsqueeze(2)
138
- z_base = z_base + self.s_to_z_transpose(s_inputs_normed).unsqueeze(1)
139
- z_base = z_base + self.s_to_z_prod_out(
140
- self.s_to_z_prod_in1(s_inputs_normed)[:, :, None, :]
141
- * self.s_to_z_prod_in2(s_inputs_normed)[:, None, :, :]
142
- )
143
-
144
- pair = self._repeat_batch(z_base, num_diffusion_samples)
145
- x_pred_flat = self._flatten_sample_axis(x_pred)
146
- atom_to_token_m = self._repeat_batch(atom_to_token, num_diffusion_samples)
147
- atom_mask_m = self._repeat_batch(atom_attention_mask, num_diffusion_samples)
148
- rep_idx_m = self._repeat_batch(distogram_atom_idx, num_diffusion_samples).long()
149
- mask = self._repeat_batch(token_attention_mask, num_diffusion_samples)
150
- batch_mult = pair.shape[0]
151
-
152
- rep_coords = gather_rep_atom_coords(x_pred_flat, rep_idx_m)
153
- rep_distances = torch.cdist(
154
- rep_coords, rep_coords, compute_mode="donot_use_mm_for_euclid_dist"
155
- )
156
- distogram_bins = (
157
- (rep_distances.unsqueeze(-1) > self.boundaries).sum(dim=-1).long()
158
- )
159
- pair = pair + self.dist_bin_pairwise_embed(distogram_bins)
160
-
161
- pair_mask = mask[:, :, None].float() * mask[:, None, :].float()
162
- pair = pair + self.folding_trunk(pair, pair_attention_mask=pair_mask)
163
- single = self.row_attention_pooling(pair, mask)
164
-
165
- atom_mask_f = atom_mask_m.float()
166
- s_at_atoms = gather_token_to_atom(single, atom_to_token_m)
167
- s_at_atoms = self.plddt_ln(s_at_atoms)
168
- intra_idx = _compute_intra_token_idx(atom_to_token_m)
169
- intra_idx = intra_idx.clamp(max=self.plddt_weight.shape[0] - 1)
170
- plddt_weight = self.plddt_weight[intra_idx]
171
- plddt_logits = torch.einsum("...c,...cb->...b", s_at_atoms, plddt_weight)
172
- plddt_per_atom = _categorical_mean(plddt_logits, start=0.0, end=1.0)
173
-
174
- length = single.shape[1]
175
- plddt_sum = torch.zeros(
176
- batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype
177
- )
178
- atom_count = torch.zeros(
179
- batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype
180
- )
181
- atom_mask_t = atom_mask_f.to(plddt_per_atom.dtype)
182
- plddt_sum.scatter_add_(1, atom_to_token_m, plddt_per_atom * atom_mask_t)
183
- atom_count.scatter_add_(1, atom_to_token_m, atom_mask_t)
184
- plddt = plddt_sum / atom_count.clamp(min=1e-6)
185
-
186
- complex_plddt = (plddt_per_atom * atom_mask_f).sum(dim=-1) / (
187
- atom_mask_f.sum(dim=-1) + _EPS
188
- )
189
-
190
- expanded_type = self._repeat_batch(mol_type, num_diffusion_samples)
191
- expanded_asym = self._repeat_batch(asym_id, num_diffusion_samples)
192
- is_ligand = (expanded_type == _NONPOLYMER_ID).float()
193
- inter_chain = (
194
- expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)
195
- ).float()
196
- near_contact = (rep_distances < 8).float()
197
- interface_per_token = (
198
- near_contact * inter_chain * (1.0 - is_ligand).unsqueeze(-1)
199
- ).amax(dim=-1)
200
- iplddt_weight = torch.where(
201
- is_ligand.bool(),
202
- torch.full_like(interface_per_token, 2.0),
203
- interface_per_token,
204
- )
205
- iplddt_weight_atoms = gather_token_to_atom(
206
- iplddt_weight.unsqueeze(-1), atom_to_token_m
207
- ).squeeze(-1)
208
- atom_iplddt_w = atom_mask_f * iplddt_weight_atoms
209
- complex_iplddt = (plddt_per_atom * atom_iplddt_w).sum(dim=-1) / (
210
- atom_iplddt_w.sum(dim=-1) + _EPS
211
- )
212
- plddt_ca = plddt_per_atom.gather(1, rep_idx_m)
213
-
214
- pae_logits = self.pae_head(pair)
215
- pae = _categorical_mean(pae_logits, start=0.0, end=32.0).detach()
216
-
217
- n_bins = pae_logits.shape[-1]
218
- bin_width = 32.0 / n_bins
219
- bin_centers = torch.arange(
220
- 0.5 * bin_width, 32.0, bin_width, device=pae_logits.device
221
- )
222
- mask_f = mask.float()
223
- n_res = mask_f.sum(dim=-1, keepdim=True)
224
- d0 = 1.24 * (n_res.clamp(min=19) - 15) ** (1 / 3) - 1.8
225
- tm_per_bin = 1 / (1 + (bin_centers / d0) ** 2)
226
- pae_probs = F.softmax(pae_logits, dim=-1)
227
- tm_expected = (pae_probs * tm_per_bin[:, None, None, :]).sum(dim=-1)
228
-
229
- pair_mask_2d = mask_f.unsqueeze(-1) * mask_f.unsqueeze(-2)
230
- ptm_per_row = (tm_expected * pair_mask_2d).sum(dim=-1) / (
231
- pair_mask_2d.sum(dim=-1) + _EPS
232
- )
233
- ptm = ptm_per_row.max(dim=-1).values
234
-
235
- inter_chain_mask = (
236
- expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)
237
- ).float() * pair_mask_2d
238
- iptm_per_row = (tm_expected * inter_chain_mask).sum(dim=-1) / (
239
- inter_chain_mask.sum(dim=-1) + _EPS
240
- )
241
- iptm = iptm_per_row.max(dim=-1).values
242
-
243
- max_chain_id = int(expanded_asym.max().item()) if batch_mult > 0 else 0
244
- n_chains = max_chain_id + 1
245
- pair_chains_iptm = torch.zeros(
246
- batch_mult,
247
- n_chains,
248
- n_chains,
249
- device=tm_expected.device,
250
- dtype=tm_expected.dtype,
251
- )
252
- for c1 in range(n_chains):
253
- chain_c1 = (expanded_asym == c1).float() * mask_f
254
- if chain_c1.sum() == 0:
255
- continue
256
- for c2 in range(n_chains):
257
- chain_c2 = (expanded_asym == c2).float() * mask_f
258
- pair_m = chain_c1.unsqueeze(-1) * chain_c2.unsqueeze(-2)
259
- denom = pair_m.sum(dim=(-1, -2)) + _EPS
260
- pair_chains_iptm[:, c1, c2] = (tm_expected * pair_m).sum(
261
- dim=(-1, -2)
262
- ) / denom
263
-
264
- return {
265
- "plddt_logits": plddt_logits,
266
- "plddt": plddt.detach(),
267
- "plddt_per_atom": plddt_per_atom.detach(),
268
- "plddt_ca": plddt_ca.detach(),
269
- "complex_plddt": complex_plddt.detach(),
270
- "complex_iplddt": complex_iplddt.detach(),
271
- "pae_logits": pae_logits,
272
- "pae": pae,
273
- "ptm": ptm.detach(),
274
- "iptm": iptm.detach(),
275
- "pair_chains_iptm": pair_chains_iptm.detach(),
276
- }
277
-
278
-
279
- class _TransitionFFN(nn.Module):
280
- def __init__(self, d_model: int, expansion_ratio: int = 4) -> None:
281
- super().__init__()
282
- self.norm = nn.LayerNorm(d_model)
283
- self.ffn = SwiGLUMLP(d_model, expansion_ratio=expansion_ratio, bias=False)
284
-
285
- def forward(self, x: Tensor) -> Tensor:
286
- return self.ffn(self.norm(x))
287
-
288
-
289
- class MSAEncoderBlock(nn.Module):
290
- """One experimental MSA update block."""
291
-
292
- def __init__(
293
- self,
294
- d_msa: int,
295
- d_pair: int,
296
- d_hidden: int = 32,
297
- n_heads_msa: int = 8,
298
- msa_head_width: int = 32,
299
- ) -> None:
300
- super().__init__()
301
- self.outer_product_mean = OuterProductMean(
302
- d_msa, d_hidden, d_pair, divide_outer_before_proj=True
303
- )
304
- self.msa_pair_weighted_averaging = MSAPairWeightedAveraging(
305
- d_msa, d_pair, n_heads_msa, msa_head_width
306
- )
307
- self.msa_transition = _TransitionFFN(d_msa, expansion_ratio=4)
308
- self.tri_mul_out = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=True)
309
- self.tri_mul_in = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=False)
310
- self.pair_transition = _TransitionFFN(d_pair, expansion_ratio=4)
311
-
312
- def set_chunk_size(self, chunk_size: int | None) -> None:
313
- self.outer_product_mean.set_chunk_size(chunk_size)
314
- self.tri_mul_out.set_chunk_size(chunk_size)
315
- self.tri_mul_in.set_chunk_size(chunk_size)
316
-
317
- def forward(
318
- self,
319
- msa_repr: Tensor,
320
- pair_repr: Tensor,
321
- msa_attention_mask: Tensor,
322
- pair_attention_mask: Tensor,
323
- msa_track_mask: Tensor | None = None,
324
- ) -> tuple[Tensor, Tensor]:
325
- mask4d = (
326
- msa_track_mask[:, None, None, None].to(dtype=msa_repr.dtype)
327
- if msa_track_mask is not None
328
- else None
329
- )
330
-
331
- pair_mask4d = mask4d[:, :, :1] if mask4d is not None else None
332
-
333
- msa_update = self.msa_pair_weighted_averaging(
334
- msa_repr, pair_repr, pair_attention_mask
335
- )
336
- if mask4d is not None:
337
- msa_update = msa_update * mask4d
338
- msa_repr = msa_repr + msa_update
339
-
340
- msa_transition = self.msa_transition(msa_repr)
341
- if mask4d is not None:
342
- msa_transition = msa_transition * mask4d
343
- msa_repr = msa_repr + msa_transition
344
-
345
- pair_opm = self.outer_product_mean(msa_repr, msa_attention_mask)
346
- if pair_mask4d is not None:
347
- pair_opm = pair_opm * pair_mask4d
348
- pair_repr = pair_repr + pair_opm
349
-
350
- pair_out = self.tri_mul_out(pair_repr, mask=pair_attention_mask)
351
- if pair_mask4d is not None:
352
- pair_out = pair_out * pair_mask4d
353
- pair_repr = pair_repr + pair_out
354
-
355
- pair_in = self.tri_mul_in(pair_repr, mask=pair_attention_mask)
356
- if pair_mask4d is not None:
357
- pair_in = pair_in * pair_mask4d
358
- pair_repr = pair_repr + pair_in
359
-
360
- pair_transition = self.pair_transition(pair_repr)
361
- if pair_mask4d is not None:
362
- pair_transition = pair_transition * pair_mask4d
363
- pair_repr = pair_repr + pair_transition
364
- return msa_repr, pair_repr
365
-
366
-
367
- class MSAEncoder(nn.Module):
368
- def __init__(
369
- self,
370
- d_msa: int,
371
- d_pair: int,
372
- d_inputs: int,
373
- d_hidden: int = 32,
374
- n_layers: int = 4,
375
- n_heads_msa: int = 8,
376
- msa_head_width: int = 32,
377
- ) -> None:
378
- super().__init__()
379
- self.embed = nn.Linear(35, d_msa, bias=False)
380
- self.project_inputs = nn.Linear(d_inputs, d_msa, bias=False)
381
- self.blocks = nn.ModuleList(
382
- [
383
- MSAEncoderBlock(
384
- d_msa=d_msa,
385
- d_pair=d_pair,
386
- d_hidden=d_hidden,
387
- n_heads_msa=n_heads_msa,
388
- msa_head_width=msa_head_width,
389
- )
390
- for _ in range(n_layers)
391
- ]
392
- )
393
-
394
- def set_chunk_size(self, chunk_size: int | None) -> None:
395
- for block in self.blocks:
396
- cast(MSAEncoderBlock, block).set_chunk_size(chunk_size)
397
-
398
- def forward(
399
- self,
400
- x_pair: Tensor,
401
- x_inputs: Tensor,
402
- msa_oh: Tensor,
403
- has_deletion: Tensor,
404
- deletion_value: Tensor,
405
- msa_attention_mask: Tensor,
406
- ) -> Tensor:
407
- batch_size, _, depth = msa_attention_mask.shape
408
- m_feat = torch.cat(
409
- [msa_oh, has_deletion.unsqueeze(-1), deletion_value.unsqueeze(-1)],
410
- dim=-1,
411
- )
412
- m = self.embed(m_feat) + self.project_inputs(x_inputs).unsqueeze(2)
413
- if depth > 1:
414
- msa_track_mask = msa_attention_mask[:, :, 1:].any(dim=(1, 2))
415
- else:
416
- msa_track_mask = torch.zeros(
417
- batch_size, dtype=torch.bool, device=x_pair.device
418
- )
419
- tok_mask = msa_attention_mask[:, :, 0]
420
- pair_attention_mask = tok_mask.unsqueeze(2) * tok_mask.unsqueeze(1)
421
- for block in self.blocks:
422
- m, x_pair = cast(MSAEncoderBlock, block)(
423
- m,
424
- x_pair,
425
- msa_attention_mask,
426
- pair_attention_mask,
427
- msa_track_mask,
428
- )
429
- return x_pair * msa_track_mask[:, None, None, None].to(dtype=x_pair.dtype)
430
-
431
-
432
- class ESMFold2ExperimentalModel(PreTrainedModel):
433
- """Experimental ESMFold2 architecture used by binder-design checkpoints."""
434
-
435
- config_class = ESMFold2Config
436
- _keys_to_ignore_on_load_unexpected = [r"\._extra_state$"]
437
-
438
- def __init__(self, config: ESMFold2Config) -> None:
439
- super().__init__(config)
440
- d_inputs = config.inputs.d_inputs
441
- d_pair = config.d_pair
442
-
443
- self.inputs_embedder = InputsEmbedder(config)
444
- self.z_init_1 = nn.Linear(d_inputs, d_pair, bias=False)
445
- self.z_init_2 = nn.Linear(d_inputs, d_pair, bias=False)
446
- self.rel_pos = ResIdxAsymIdSymIdEntityIdEncoding(
447
- n_relative_residx_bins=config.n_relative_residx_bins,
448
- n_relative_chain_bins=config.n_relative_chain_bins,
449
- d_pair=d_pair,
450
- )
451
- self.token_bonds = nn.Linear(1, d_pair, bias=False)
452
- self.language_model = LanguageModelShim(
453
- d_z=d_pair, d_model=config.lm_d_model, num_layers=config.lm_num_layers
454
- )
455
- self._esmc: nn.Module | None = None
456
- self._esmc_fp8 = False
457
- self._esmfold2_input_builder: Any | None = None
458
-
459
- pf = config.folding_trunk
460
- self.folding_trunk = FoldingTrunk(
461
- n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4
462
- )
463
- self.pair_loop_proj = nn.Sequential(
464
- nn.LayerNorm(d_pair), nn.Linear(d_pair, d_pair, bias=False)
465
- )
466
- nn.init.zeros_(cast(nn.Linear, self.pair_loop_proj[1]).weight)
467
-
468
- self.structure_head = DiffusionStructureHead(config)
469
- self.distogram_head = nn.Linear(
470
- d_pair, config.structure_head.distogram_bins, bias=True
471
- )
472
- self.confidence_head: ConfidenceHead | None = (
473
- ConfidenceHead(config) if config.confidence_head.enabled else None
474
- )
475
-
476
- msa_cfg = config.msa_encoder
477
- self.msa_encoder: MSAEncoder | None = None
478
- if msa_cfg.enabled:
479
- self.msa_encoder = MSAEncoder(
480
- d_msa=msa_cfg.d_msa,
481
- d_pair=d_pair,
482
- d_inputs=d_inputs,
483
- d_hidden=msa_cfg.d_hidden,
484
- n_layers=msa_cfg.n_layers,
485
- n_heads_msa=msa_cfg.n_heads_msa,
486
- msa_head_width=msa_cfg.msa_head_width,
487
- )
488
-
489
- self.post_init()
490
-
491
- @property
492
- def device(self) -> torch.device:
493
- return next(self.parameters()).device
494
-
495
- def set_kernel_backend(self, backend: str | None) -> None:
496
- self.folding_trunk.set_kernel_backend(backend)
497
- if self.confidence_head is not None:
498
- self.confidence_head.set_kernel_backend(backend)
499
- self.structure_head.set_kernel_backend(backend)
500
-
501
- def set_chunk_size(self, chunk_size: int | None) -> None:
502
- self.folding_trunk.set_chunk_size(chunk_size)
503
- if self.confidence_head is not None:
504
- self.confidence_head.set_chunk_size(chunk_size)
505
- if self.msa_encoder is not None:
506
- self.msa_encoder.set_chunk_size(chunk_size)
507
-
508
- def configure_lm_dropout(
509
- self,
510
- lm_dropout: float,
511
- *,
512
- force_lm_dropout_during_inference: bool = True,
513
- ) -> None:
514
- self.config.lm_dropout = lm_dropout
515
- self.config.force_lm_dropout_during_inference = (
516
- force_lm_dropout_during_inference
517
- )
518
-
519
- def load_esmc(self, esmc_model_path: str, precision: str = "bf16") -> None:
520
- from .modeling_esmc import ESMCModel
521
-
522
- dtype_map = {
523
- "bf16": torch.bfloat16,
524
- "fp32": torch.float32,
525
- "fp8": torch.bfloat16,
526
- }
527
  if precision not in dtype_map:
528
- raise ValueError(
529
- f"precision must be one of {list(dtype_map)}, got {precision!r}"
530
- )
531
- esmc = (
532
- ESMCModel.from_pretrained(esmc_model_path)
533
- .to(device=self.device, dtype=dtype_map[precision])
534
- .eval()
535
- )
536
- for parameter in esmc.parameters():
537
- parameter.requires_grad_(False)
538
- if precision == "fp8":
539
- with torch.no_grad():
540
- _convert_te_modules_to_fp8_inplace(esmc)
541
- self._esmc_fp8 = True
542
- else:
543
- self._esmc_fp8 = False
544
- self._esmc = esmc
545
-
546
- @classmethod
547
- def from_pretrained(
548
- cls,
549
- pretrained_model_name_or_path,
550
- *model_args,
551
- load_esmc: bool = True,
552
- **kwargs,
553
- ):
554
- if "config" not in kwargs:
555
- kwargs["config"] = ESMFold2Config.from_pretrained(
556
- pretrained_model_name_or_path, **kwargs
557
- )
558
- esmc_precision = kwargs.pop("esmc_precision", "bf16")
559
- model = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
560
- if load_esmc:
561
- model.load_esmc(model.config.esmc_id, precision=esmc_precision)
562
- return model
563
-
564
- def apply_torch_compile(
565
- self, mode: str = "fixed_seqlen", dynamic: bool | None = None
566
- ) -> None:
567
- import torch._dynamo
568
-
569
- torch._dynamo.config.cache_size_limit = 512
570
- torch._dynamo.config.accumulated_cache_size_limit = 512
571
- torch._dynamo.config.capture_scalar_outputs = True
572
-
573
- if dynamic is None:
574
- dynamic = mode == "dynamic_seqlen"
575
- compile_kwargs: dict[str, bool] = {"dynamic": dynamic}
576
- compile_targets = (
577
- PairUpdateBlock,
578
- DiffusionTransformer,
579
- DiffusionModule,
580
- MSAEncoderBlock,
581
- )
582
-
583
- def _maybe_compile(module: nn.Module) -> None:
584
- if isinstance(module, compile_targets):
585
- module.forward = torch.compile(module.forward, **compile_kwargs)
586
-
587
- self.apply(_maybe_compile)
588
-
589
- def _compute_lm_hidden_states(
590
- self,
591
- input_ids: Tensor,
592
- asym_id: Tensor,
593
- residue_index: Tensor,
594
- mol_type: Tensor,
595
- tok_mask: Tensor,
596
- ) -> Tensor:
597
- assert self._esmc is not None
598
- pad_to = 8 if self._esmc_fp8 else None
599
- with _lm_precision_context(self._esmc_fp8):
600
- return compute_lm_hidden_states(
601
- self._esmc,
602
- input_ids,
603
- asym_id,
604
- residue_index,
605
- mol_type,
606
- tok_mask,
607
- pad_to_multiple=pad_to,
608
- )
609
-
610
- def forward(
611
- self,
612
- token_index: Tensor,
613
- residue_index: Tensor,
614
- asym_id: Tensor,
615
- sym_id: Tensor,
616
- entity_id: Tensor,
617
- mol_type: Tensor,
618
- res_type: Tensor,
619
- token_bonds: Tensor,
620
- token_attention_mask: Tensor,
621
- ref_pos: Tensor,
622
- ref_element: Tensor,
623
- ref_charge: Tensor,
624
- ref_atom_name_chars: Tensor,
625
- ref_space_uid: Tensor,
626
- atom_attention_mask: Tensor,
627
- atom_to_token: Tensor,
628
- distogram_atom_idx: Tensor,
629
- deletion_mean: Tensor | None = None,
630
- msa: Tensor | None = None,
631
- has_deletion: Tensor | None = None,
632
- deletion_value: Tensor | None = None,
633
- msa_attention_mask: Tensor | None = None,
634
- input_ids: Tensor | None = None,
635
- lm_hidden_states: Tensor | None = None,
636
- res_type_soft: Tensor | None = None,
637
- num_loops: int | None = None,
638
- num_diffusion_samples: int | None = None,
639
- num_sampling_steps: int | None = None,
640
- early_exit: bool = False,
641
- seed: int | None = None,
642
- calculate_confidence: bool = True,
643
- provide_soft_sequence_to_msa_and_profile: bool = True,
644
- noise_scale: float | None = None,
645
- step_scale: float | None = None,
646
- max_inference_sigma: int | None = None,
647
- ) -> dict[str, Tensor]:
648
- del noise_scale, step_scale, max_inference_sigma
649
- tok_mask = token_attention_mask
650
- atm_mask = atom_attention_mask
651
- n_loops = num_loops if num_loops is not None else self.config.num_loops
652
- n_samples = (
653
- num_diffusion_samples
654
- if num_diffusion_samples is not None
655
- else self.config.num_diffusion_samples
656
- )
657
-
658
- if res_type.dim() == 2:
659
- res_type_oh = F.one_hot(res_type.long(), num_classes=NUM_RES_TYPES).float()
660
- res_type_oh = res_type_oh * tok_mask.unsqueeze(-1).float()
661
- else:
662
- res_type_oh = res_type.float()
663
-
664
- if msa is not None:
665
- msa_oh_profile = F.one_hot(msa.long(), num_classes=NUM_RES_TYPES).float()
666
- if msa_attention_mask is not None:
667
- mask_f = msa_attention_mask.float().unsqueeze(-1)
668
- msa_oh_profile = msa_oh_profile * mask_f
669
- valid_seq_count = msa_attention_mask.float().sum(dim=1).clamp(min=1)
670
- profile = msa_oh_profile.sum(dim=1) / valid_seq_count.unsqueeze(-1)
671
- else:
672
- profile = msa_oh_profile.mean(dim=1)
673
- else:
674
- profile = res_type_oh
675
-
676
- if res_type_soft is not None:
677
- res_type_oh = res_type_soft.float()
678
- if (
679
- not self.config.disable_msa_features
680
- and provide_soft_sequence_to_msa_and_profile
681
- ):
682
- profile = res_type_oh
683
- msa = res_type_oh.unsqueeze(1)
684
- msa_attention_mask = tok_mask.unsqueeze(1)
685
-
686
- if deletion_mean is None:
687
- deletion_mean = torch.zeros(
688
- res_type.shape[0], res_type.shape[1], device=res_type.device
689
- )
690
- if self.config.disable_msa_features:
691
- profile = torch.zeros_like(profile)
692
- deletion_mean = torch.zeros_like(deletion_mean)
693
-
694
- ref_element_oh = F.one_hot(
695
- ref_element.long(), num_classes=MAX_ATOMIC_NUMBER
696
- ).float()
697
- ref_atom_name_chars_oh = F.one_hot(
698
- ref_atom_name_chars.long(), num_classes=CHAR_VOCAB_SIZE
699
- ).float()
700
- atm_mask_f = atm_mask.float()
701
- ref_element_oh = ref_element_oh * atm_mask_f.unsqueeze(-1)
702
- ref_atom_name_chars_oh = ref_atom_name_chars_oh * atm_mask_f.unsqueeze(
703
- -1
704
- ).unsqueeze(-1)
705
- atom_to_token = atom_to_token * atm_mask.long()
706
-
707
- use_amp = ref_pos.device.type == "cuda"
708
- with (
709
- torch.set_grad_enabled(res_type_soft is not None),
710
- torch.amp.autocast("cuda", enabled=use_amp, dtype=torch.bfloat16),
711
- ):
712
- x_inputs = self.inputs_embedder(
713
- aatype=res_type_oh,
714
- profile=profile.float(),
715
- deletion_mean=deletion_mean.float(),
716
- ref_pos=ref_pos,
717
- atom_attention_mask=atm_mask,
718
- ref_space_uid=ref_space_uid,
719
- ref_charge=ref_charge,
720
- ref_element=ref_element_oh,
721
- ref_atom_name_chars=ref_atom_name_chars_oh,
722
- atom_to_token=atom_to_token,
723
- )
724
-
725
- z_init = self.z_init_1(x_inputs).unsqueeze(2) + self.z_init_2(
726
- x_inputs
727
- ).unsqueeze(1)
728
- relative_position_encoding = self.rel_pos(
729
- residue_index=residue_index,
730
- asym_id=asym_id,
731
- sym_id=sym_id,
732
- entity_id=entity_id,
733
- token_index=token_index,
734
- )
735
- token_bonds_encoding = self.token_bonds(token_bonds.float())
736
- z_init = z_init + relative_position_encoding + token_bonds_encoding
737
-
738
- if (
739
- lm_hidden_states is None
740
- and input_ids is not None
741
- and self._esmc is not None
742
- ):
743
- lm_hidden_states = self._compute_lm_hidden_states(
744
- input_ids, asym_id, residue_index, mol_type, tok_mask
745
- )
746
- if lm_hidden_states is not None:
747
- lm_dropout = (
748
- self.config.lm_dropout
749
- if self.config.force_lm_dropout_during_inference or self.training
750
- else 0.0
751
- )
752
- lm_z = self.language_model(
753
- lm_hidden_states.detach(), lm_dropout=lm_dropout
754
- )
755
- z_init = z_init + lm_z.to(z_init.dtype)
756
-
757
- msa_kwargs: dict[str, Tensor] | None = None
758
- if self.msa_encoder is not None and msa is not None:
759
- if msa.dim() == 4:
760
- batch_msa, depth, length_msa, _ = msa.shape
761
- msa_oh = msa.permute(0, 2, 1, 3).float()
762
- else:
763
- batch_msa, depth, length_msa = msa.shape
764
- msa_oh = F.one_hot(
765
- msa.permute(0, 2, 1).long(), num_classes=NUM_RES_TYPES
766
- ).float()
767
- msa_attn = (
768
- msa_attention_mask.permute(0, 2, 1).float()
769
- if msa_attention_mask is not None
770
- else tok_mask[:, :, None].expand(-1, -1, depth).float()
771
  )
772
- msa_oh = msa_oh * msa_attn.unsqueeze(-1)
773
- hd = (
774
- has_deletion.permute(0, 2, 1).float()
775
- if has_deletion is not None
776
- else torch.zeros(batch_msa, length_msa, depth, device=msa.device)
777
- )
778
- dv = (
779
- deletion_value.permute(0, 2, 1).float()
780
- if deletion_value is not None
781
- else torch.zeros(batch_msa, length_msa, depth, device=msa.device)
782
- )
783
- msa_kwargs = {
784
- "x_inputs": x_inputs,
785
- "msa_oh": msa_oh,
786
- "has_deletion": hd,
787
- "deletion_value": dv,
788
- "msa_attention_mask": msa_attn,
789
- }
790
-
791
- pair_mask = tok_mask[:, :, None].float() * tok_mask[:, None, :].float()
792
- z = torch.zeros_like(z_init)
793
- prev_pair: Tensor | None = None
794
- prev_disto_probs: Tensor | None = None
795
- for loop_num in range(n_loops + 1):
796
- z = z_init + self.pair_loop_proj(z)
797
- if msa_kwargs is not None and self.msa_encoder is not None:
798
- z = z + self.msa_encoder(x_pair=z, **msa_kwargs).to(z.dtype)
799
- z = self.folding_trunk(z, pair_attention_mask=pair_mask)
800
-
801
- if early_exit and loop_num < n_loops:
802
- l2_converged = False
803
- if prev_pair is not None and loop_num > 0:
804
- rel_l2 = (z.float() - prev_pair.float()).norm() / prev_pair.float().norm().clamp(
805
- min=1e-8
806
- )
807
- l2_converged = rel_l2.item() < 0.25
808
- prev_pair = z.detach().clone()
809
- sym_z = z.float() + z.float().transpose(-2, -3)
810
- cur_probs = F.softmax(self.distogram_head(sym_z).float(), dim=-1)
811
- if prev_disto_probs is not None and loop_num > 0:
812
- kl_per_pair = (
813
- cur_probs
814
- * (
815
- cur_probs.clamp(min=1e-8)
816
- / prev_disto_probs.clamp(min=1e-8)
817
- ).log()
818
- ).sum(-1)
819
- kl = (kl_per_pair + kl_per_pair.transpose(-1, -2)).mean() / 2
820
- if l2_converged or kl.item() < 0.05:
821
- break
822
- prev_disto_probs = cur_probs.detach()
823
-
824
- distogram_logits = self.distogram_head(z + z.transpose(-2, -3))
825
-
826
- with torch.no_grad(), _seed_context(seed):
827
- structure_output = self.structure_head.sample(
828
- z_trunk=z.float(),
829
- s_inputs=x_inputs,
830
- s_trunk=None,
831
- relative_position_encoding=relative_position_encoding,
832
- ref_pos=ref_pos,
833
- ref_charge=ref_charge,
834
- ref_mask=atm_mask,
835
- ref_element=ref_element_oh,
836
- ref_atom_name_chars=ref_atom_name_chars_oh,
837
- ref_space_uid=ref_space_uid,
838
- tok_idx=atom_to_token,
839
- asym_id=asym_id,
840
- residue_index=residue_index,
841
- entity_id=entity_id,
842
- token_index=token_index,
843
- sym_id=sym_id,
844
- token_attention_mask=tok_mask,
845
- num_diffusion_samples=n_samples,
846
- num_sampling_steps=num_sampling_steps,
847
- return_atom_repr=False,
848
- denoising_early_exit_rmsd=(0.10 if early_exit else None),
849
- )
850
- sample_coords = structure_output["sample_atom_coords"]
851
- assert sample_coords is not None
852
-
853
- output: dict[str, Tensor] = {
854
- "distogram_logits": distogram_logits,
855
- "sample_atom_coords": sample_coords,
856
- }
857
- if calculate_confidence and self.confidence_head is not None:
858
- confidence_output = self.confidence_head(
859
- s_inputs=x_inputs.detach(),
860
- z=z.detach().float(),
861
- x_pred=sample_coords.detach(),
862
- distogram_atom_idx=distogram_atom_idx,
863
- token_attention_mask=tok_mask,
864
- atom_to_token=atom_to_token,
865
- atom_attention_mask=atm_mask,
866
- asym_id=asym_id,
867
- mol_type=mol_type,
868
- num_diffusion_samples=n_samples,
869
- relative_position_encoding=relative_position_encoding.detach(),
870
- token_bonds_encoding=token_bonds_encoding.detach(),
871
- )
872
- output.update(confidence_output)
873
- output["atom_pad_mask"] = (
874
- atm_mask.unsqueeze(0) if atm_mask.dim() == 1 else atm_mask
875
- )
876
- output["residue_index"] = residue_index
877
- output["entity_id"] = entity_id
878
- return output
879
-
880
- @property
881
- def input_builder(self):
882
- if self._esmfold2_input_builder is None:
883
- from .esmfold2_processor import ESMFold2InputBuilder
884
-
885
- self._esmfold2_input_builder = ESMFold2InputBuilder()
886
- return self._esmfold2_input_builder
887
-
888
- @property
889
- def input_types(self):
890
- from . import esmfold2_types
891
-
892
- return esmfold2_types
893
-
894
- def prepare_structure_input(self, input, seed: int | None = None):
895
- return self.input_builder.prepare_input(input, seed=seed, device=self.device)
896
-
897
- @torch.no_grad()
898
- def infer_protein(self, seq: str, **forward_kwargs) -> dict[str, Tensor]:
899
- from .protein_utils import prepare_protein_features
900
-
901
- features = prepare_protein_features(seq)
902
- features = {name: tensor.to(self.device) for name, tensor in features.items()}
903
- output = self(**features, **forward_kwargs)
904
- for name in (
905
- "res_type",
906
- "atom_to_token",
907
- "ref_atom_name_chars",
908
- "atom_attention_mask",
909
- "token_attention_mask",
910
- "residue_index",
911
- ):
912
- output[name] = features[name]
913
- return output
914
-
915
- def fold(
916
- self,
917
- input,
918
- *,
919
- num_loops: int = 3,
920
- num_sampling_steps: int = 50,
921
- num_diffusion_samples: int = 1,
922
- seed: int | None = None,
923
- noise_scale: float | None = None,
924
- step_scale: float | None = None,
925
- max_inference_sigma: int | None = None,
926
- early_exit: bool = False,
927
- complex_id: str = "pred",
928
- ):
929
- return self.input_builder.fold(
930
- self,
931
- input,
932
- num_loops=num_loops,
933
- num_sampling_steps=num_sampling_steps,
934
- num_diffusion_samples=num_diffusion_samples,
935
- seed=seed,
936
- noise_scale=noise_scale,
937
- step_scale=step_scale,
938
- max_inference_sigma=max_inference_sigma,
939
- early_exit=early_exit,
940
- complex_id=complex_id,
941
- )
942
-
943
- def fold_protein(
944
- self,
945
- sequence: str,
946
- *,
947
- chain_id: str = "A",
948
- num_loops: int = 3,
949
- num_sampling_steps: int = 50,
950
- num_diffusion_samples: int = 1,
951
- seed: int | None = None,
952
- complex_id: str = "pred",
953
- ):
954
- from .esmfold2_types import ProteinInput, StructurePredictionInput
955
-
956
- input = StructurePredictionInput(
957
- sequences=[ProteinInput(id=chain_id, sequence=sequence)]
958
- )
959
- return self.fold(
960
- input,
961
- num_loops=num_loops,
962
- num_sampling_steps=num_sampling_steps,
963
- num_diffusion_samples=num_diffusion_samples,
964
- seed=seed,
965
- complex_id=complex_id,
966
- )
967
-
968
- @staticmethod
969
- def result_to_cif(result) -> str:
970
- assert not isinstance(result, list), "Pass one MolecularComplexResult at a time."
971
- return result.complex.to_mmcif()
972
-
973
- @staticmethod
974
- def result_to_pdb(result) -> str:
975
- assert not isinstance(result, list), "Pass one MolecularComplexResult at a time."
976
- return result.complex.to_protein_complex().to_pdb_string()
977
-
978
- def save_as_cif(self, result, output_path: str | Path) -> None:
979
- Path(output_path).write_text(self.result_to_cif(result))
980
-
981
- def save_as_pdb(self, result, output_path: str | Path) -> None:
982
- Path(output_path).write_text(self.result_to_pdb(result))
983
-
984
- def infer_protein_as_cif(self, seq: str, **forward_kwargs) -> str:
985
- return self.result_to_cif(self.fold_protein(seq, **forward_kwargs))
986
-
987
- def infer_protein_as_pdb(self, seq: str, **forward_kwargs) -> str:
988
- return self.result_to_pdb(self.fold_protein(seq, **forward_kwargs))
989
-
990
-
991
- __all__ = [
992
- "ConfidenceHead",
993
- "MSAEncoder",
994
- "MSAEncoderBlock",
995
- "ESMFold2ExperimentalModel",
996
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
 
 
 
 
 
 
 
 
1
+ """FastPLMs ESMFold2 experimental architecture.
2
+
3
+ This module supports Biohub's experimental binder-design checkpoints. The
4
+ released ESMFold2 architecture in ``modeling_esmfold2.py`` intentionally
5
+ rejects those configs because the experimental trunk uses explicit pair-loop
6
+ re-injection and a different confidence/MSA stack.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from pathlib import Path
13
+ from typing import Any, cast
14
+
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ from torch import Tensor
19
+ from transformers.modeling_utils import PreTrainedModel
20
+
21
+ from .configuration_esmfold2 import ESMFold2Config
22
+ from .modeling_esmfold2 import (
23
+ _load_fastplms_esmplusplus_for_esmfold2,
24
+ _lm_precision_context,
25
+ )
26
+ from .modeling_esmfold2_common import (
27
+ CHAR_VOCAB_SIZE,
28
+ MAX_ATOMIC_NUMBER,
29
+ NUM_RES_TYPES,
30
+ DiffusionModule,
31
+ DiffusionStructureHead,
32
+ DiffusionTransformer,
33
+ FoldingTrunk,
34
+ InputsEmbedder,
35
+ LanguageModelShim,
36
+ MSAPairWeightedAveraging,
37
+ OuterProductMean,
38
+ PairUpdateBlock,
39
+ ResIdxAsymIdSymIdEntityIdEncoding,
40
+ RowAttentionPooling,
41
+ SwiGLUMLP,
42
+ TriangleMultiplicativeUpdate,
43
+ _categorical_mean,
44
+ _compute_intra_token_idx,
45
+ _seed_context,
46
+ compute_lm_hidden_states,
47
+ gather_rep_atom_coords,
48
+ gather_token_to_atom,
49
+ )
50
+
51
+ _EPS = 1e-5
52
+ _NONPOLYMER_ID = 3
53
+
54
+
55
+ class ConfidenceHead(nn.Module):
56
+ """Experimental confidence head predicting pLDDT, PAE, pTM, and ipTM."""
57
+
58
+ boundaries: Tensor
59
+
60
+ def __init__(self, config: ESMFold2Config) -> None:
61
+ super().__init__()
62
+ ch = config.confidence_head
63
+ d_single = config.d_single
64
+ d_pair = config.d_pair
65
+ d_inputs = config.inputs.d_inputs
66
+
67
+ boundaries = torch.linspace(ch.min_dist, ch.max_dist, ch.distogram_bins - 1)
68
+ self.register_buffer("boundaries", boundaries)
69
+ self.dist_bin_pairwise_embed = nn.Embedding(ch.distogram_bins, d_pair)
70
+
71
+ self.s_norm = nn.LayerNorm(d_single)
72
+ self.s_inputs_to_single = nn.Linear(d_inputs, d_single, bias=False)
73
+ self.s_to_z = nn.Linear(d_inputs, d_pair, bias=False)
74
+ self.s_to_z_transpose = nn.Linear(d_inputs, d_pair, bias=False)
75
+ self.s_to_z_prod_in1 = nn.Linear(d_inputs, d_pair, bias=False)
76
+ self.s_to_z_prod_in2 = nn.Linear(d_inputs, d_pair, bias=False)
77
+ self.s_to_z_prod_out = nn.Linear(d_pair, d_pair, bias=False)
78
+ self.s_input_to_s = nn.Linear(d_inputs, d_single, bias=False)
79
+ self.s_inputs_norm = nn.LayerNorm(d_inputs)
80
+ self.z_norm = nn.LayerNorm(d_pair)
81
+ self.row_attention_pooling = RowAttentionPooling(
82
+ d_pair=d_pair, d_single=d_single
83
+ )
84
+
85
+ pf = ch.folding_trunk
86
+ self.folding_trunk = FoldingTrunk(
87
+ n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4
88
+ )
89
+
90
+ self.plddt_ln = nn.LayerNorm(d_single)
91
+ max_atoms_per_token = 23
92
+ self.plddt_weight = nn.Parameter(
93
+ torch.zeros(max_atoms_per_token, d_single, ch.num_plddt_bins)
94
+ )
95
+ self.pae_head = nn.Linear(d_pair, ch.num_pae_bins, bias=False)
96
+
97
+ def set_kernel_backend(self, backend: str | None) -> None:
98
+ self.folding_trunk.set_kernel_backend(backend)
99
+
100
+ def set_chunk_size(self, chunk_size: int | None) -> None:
101
+ self.folding_trunk.set_chunk_size(chunk_size)
102
+
103
+ @staticmethod
104
+ def _repeat_batch(x: Tensor, num_diffusion_samples: int) -> Tensor:
105
+ if num_diffusion_samples == 1:
106
+ return x
107
+ return x.repeat_interleave(num_diffusion_samples, 0)
108
+
109
+ @staticmethod
110
+ def _flatten_sample_axis(x: Tensor) -> Tensor:
111
+ if x.ndim == 4:
112
+ b, mult, n, c = x.shape
113
+ return x.reshape(b * mult, n, c)
114
+ return x
115
+
116
+ def forward(
117
+ self,
118
+ s_inputs: Tensor,
119
+ z: Tensor,
120
+ x_pred: Tensor,
121
+ distogram_atom_idx: Tensor,
122
+ token_attention_mask: Tensor,
123
+ atom_to_token: Tensor,
124
+ atom_attention_mask: Tensor,
125
+ asym_id: Tensor,
126
+ mol_type: Tensor,
127
+ num_diffusion_samples: int = 1,
128
+ relative_position_encoding: Tensor | None = None,
129
+ token_bonds_encoding: Tensor | None = None,
130
+ ) -> dict[str, Tensor]:
131
+ s_inputs_normed = self.s_inputs_norm(s_inputs)
132
+ z_base = self.z_norm(z)
133
+ if relative_position_encoding is not None:
134
+ z_base = z_base + relative_position_encoding
135
+ if token_bonds_encoding is not None:
136
+ z_base = z_base + token_bonds_encoding
137
+ z_base = z_base + self.s_to_z(s_inputs_normed).unsqueeze(2)
138
+ z_base = z_base + self.s_to_z_transpose(s_inputs_normed).unsqueeze(1)
139
+ z_base = z_base + self.s_to_z_prod_out(
140
+ self.s_to_z_prod_in1(s_inputs_normed)[:, :, None, :]
141
+ * self.s_to_z_prod_in2(s_inputs_normed)[:, None, :, :]
142
+ )
143
+
144
+ pair = self._repeat_batch(z_base, num_diffusion_samples)
145
+ x_pred_flat = self._flatten_sample_axis(x_pred)
146
+ atom_to_token_m = self._repeat_batch(atom_to_token, num_diffusion_samples)
147
+ atom_mask_m = self._repeat_batch(atom_attention_mask, num_diffusion_samples)
148
+ rep_idx_m = self._repeat_batch(distogram_atom_idx, num_diffusion_samples).long()
149
+ mask = self._repeat_batch(token_attention_mask, num_diffusion_samples)
150
+ batch_mult = pair.shape[0]
151
+
152
+ rep_coords = gather_rep_atom_coords(x_pred_flat, rep_idx_m)
153
+ rep_distances = torch.cdist(
154
+ rep_coords, rep_coords, compute_mode="donot_use_mm_for_euclid_dist"
155
+ )
156
+ distogram_bins = (
157
+ (rep_distances.unsqueeze(-1) > self.boundaries).sum(dim=-1).long()
158
+ )
159
+ pair = pair + self.dist_bin_pairwise_embed(distogram_bins)
160
+
161
+ pair_mask = mask[:, :, None].float() * mask[:, None, :].float()
162
+ pair = pair + self.folding_trunk(pair, pair_attention_mask=pair_mask)
163
+ single = self.row_attention_pooling(pair, mask)
164
+
165
+ atom_mask_f = atom_mask_m.float()
166
+ s_at_atoms = gather_token_to_atom(single, atom_to_token_m)
167
+ s_at_atoms = self.plddt_ln(s_at_atoms)
168
+ intra_idx = _compute_intra_token_idx(atom_to_token_m)
169
+ intra_idx = intra_idx.clamp(max=self.plddt_weight.shape[0] - 1)
170
+ plddt_weight = self.plddt_weight[intra_idx]
171
+ plddt_logits = torch.einsum("...c,...cb->...b", s_at_atoms, plddt_weight)
172
+ plddt_per_atom = _categorical_mean(plddt_logits, start=0.0, end=1.0)
173
+
174
+ length = single.shape[1]
175
+ plddt_sum = torch.zeros(
176
+ batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype
177
+ )
178
+ atom_count = torch.zeros(
179
+ batch_mult, length, device=single.device, dtype=plddt_per_atom.dtype
180
+ )
181
+ atom_mask_t = atom_mask_f.to(plddt_per_atom.dtype)
182
+ plddt_sum.scatter_add_(1, atom_to_token_m, plddt_per_atom * atom_mask_t)
183
+ atom_count.scatter_add_(1, atom_to_token_m, atom_mask_t)
184
+ plddt = plddt_sum / atom_count.clamp(min=1e-6)
185
+
186
+ complex_plddt = (plddt_per_atom * atom_mask_f).sum(dim=-1) / (
187
+ atom_mask_f.sum(dim=-1) + _EPS
188
+ )
189
+
190
+ expanded_type = self._repeat_batch(mol_type, num_diffusion_samples)
191
+ expanded_asym = self._repeat_batch(asym_id, num_diffusion_samples)
192
+ is_ligand = (expanded_type == _NONPOLYMER_ID).float()
193
+ inter_chain = (
194
+ expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)
195
+ ).float()
196
+ near_contact = (rep_distances < 8).float()
197
+ interface_per_token = (
198
+ near_contact * inter_chain * (1.0 - is_ligand).unsqueeze(-1)
199
+ ).amax(dim=-1)
200
+ iplddt_weight = torch.where(
201
+ is_ligand.bool(),
202
+ torch.full_like(interface_per_token, 2.0),
203
+ interface_per_token,
204
+ )
205
+ iplddt_weight_atoms = gather_token_to_atom(
206
+ iplddt_weight.unsqueeze(-1), atom_to_token_m
207
+ ).squeeze(-1)
208
+ atom_iplddt_w = atom_mask_f * iplddt_weight_atoms
209
+ complex_iplddt = (plddt_per_atom * atom_iplddt_w).sum(dim=-1) / (
210
+ atom_iplddt_w.sum(dim=-1) + _EPS
211
+ )
212
+ plddt_ca = plddt_per_atom.gather(1, rep_idx_m)
213
+
214
+ pae_logits = self.pae_head(pair)
215
+ pae = _categorical_mean(pae_logits, start=0.0, end=32.0).detach()
216
+
217
+ n_bins = pae_logits.shape[-1]
218
+ bin_width = 32.0 / n_bins
219
+ bin_centers = torch.arange(
220
+ 0.5 * bin_width, 32.0, bin_width, device=pae_logits.device
221
+ )
222
+ mask_f = mask.float()
223
+ n_res = mask_f.sum(dim=-1, keepdim=True)
224
+ d0 = 1.24 * (n_res.clamp(min=19) - 15) ** (1 / 3) - 1.8
225
+ tm_per_bin = 1 / (1 + (bin_centers / d0) ** 2)
226
+ pae_probs = F.softmax(pae_logits, dim=-1)
227
+ tm_expected = (pae_probs * tm_per_bin[:, None, None, :]).sum(dim=-1)
228
+
229
+ pair_mask_2d = mask_f.unsqueeze(-1) * mask_f.unsqueeze(-2)
230
+ ptm_per_row = (tm_expected * pair_mask_2d).sum(dim=-1) / (
231
+ pair_mask_2d.sum(dim=-1) + _EPS
232
+ )
233
+ ptm = ptm_per_row.max(dim=-1).values
234
+
235
+ inter_chain_mask = (
236
+ expanded_asym.unsqueeze(-1) != expanded_asym.unsqueeze(-2)
237
+ ).float() * pair_mask_2d
238
+ iptm_per_row = (tm_expected * inter_chain_mask).sum(dim=-1) / (
239
+ inter_chain_mask.sum(dim=-1) + _EPS
240
+ )
241
+ iptm = iptm_per_row.max(dim=-1).values
242
+
243
+ max_chain_id = int(expanded_asym.max().item()) if batch_mult > 0 else 0
244
+ n_chains = max_chain_id + 1
245
+ pair_chains_iptm = torch.zeros(
246
+ batch_mult,
247
+ n_chains,
248
+ n_chains,
249
+ device=tm_expected.device,
250
+ dtype=tm_expected.dtype,
251
+ )
252
+ for c1 in range(n_chains):
253
+ chain_c1 = (expanded_asym == c1).float() * mask_f
254
+ if chain_c1.sum() == 0:
255
+ continue
256
+ for c2 in range(n_chains):
257
+ chain_c2 = (expanded_asym == c2).float() * mask_f
258
+ pair_m = chain_c1.unsqueeze(-1) * chain_c2.unsqueeze(-2)
259
+ denom = pair_m.sum(dim=(-1, -2)) + _EPS
260
+ pair_chains_iptm[:, c1, c2] = (tm_expected * pair_m).sum(
261
+ dim=(-1, -2)
262
+ ) / denom
263
+
264
+ return {
265
+ "plddt_logits": plddt_logits,
266
+ "plddt": plddt.detach(),
267
+ "plddt_per_atom": plddt_per_atom.detach(),
268
+ "plddt_ca": plddt_ca.detach(),
269
+ "complex_plddt": complex_plddt.detach(),
270
+ "complex_iplddt": complex_iplddt.detach(),
271
+ "pae_logits": pae_logits,
272
+ "pae": pae,
273
+ "ptm": ptm.detach(),
274
+ "iptm": iptm.detach(),
275
+ "pair_chains_iptm": pair_chains_iptm.detach(),
276
+ }
277
+
278
+
279
+ class _TransitionFFN(nn.Module):
280
+ def __init__(self, d_model: int, expansion_ratio: int = 4) -> None:
281
+ super().__init__()
282
+ self.norm = nn.LayerNorm(d_model)
283
+ self.ffn = SwiGLUMLP(d_model, expansion_ratio=expansion_ratio, bias=False)
284
+
285
+ def forward(self, x: Tensor) -> Tensor:
286
+ return self.ffn(self.norm(x))
287
+
288
+
289
+ class MSAEncoderBlock(nn.Module):
290
+ """One experimental MSA update block."""
291
+
292
+ def __init__(
293
+ self,
294
+ d_msa: int,
295
+ d_pair: int,
296
+ d_hidden: int = 32,
297
+ n_heads_msa: int = 8,
298
+ msa_head_width: int = 32,
299
+ ) -> None:
300
+ super().__init__()
301
+ self.outer_product_mean = OuterProductMean(
302
+ d_msa, d_hidden, d_pair, divide_outer_before_proj=True
303
+ )
304
+ self.msa_pair_weighted_averaging = MSAPairWeightedAveraging(
305
+ d_msa, d_pair, n_heads_msa, msa_head_width
306
+ )
307
+ self.msa_transition = _TransitionFFN(d_msa, expansion_ratio=4)
308
+ self.tri_mul_out = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=True)
309
+ self.tri_mul_in = TriangleMultiplicativeUpdate(dim=d_pair, _outgoing=False)
310
+ self.pair_transition = _TransitionFFN(d_pair, expansion_ratio=4)
311
+
312
+ def set_chunk_size(self, chunk_size: int | None) -> None:
313
+ self.outer_product_mean.set_chunk_size(chunk_size)
314
+ self.tri_mul_out.set_chunk_size(chunk_size)
315
+ self.tri_mul_in.set_chunk_size(chunk_size)
316
+
317
+ def forward(
318
+ self,
319
+ msa_repr: Tensor,
320
+ pair_repr: Tensor,
321
+ msa_attention_mask: Tensor,
322
+ pair_attention_mask: Tensor,
323
+ msa_track_mask: Tensor | None = None,
324
+ ) -> tuple[Tensor, Tensor]:
325
+ mask4d = (
326
+ msa_track_mask[:, None, None, None].to(dtype=msa_repr.dtype)
327
+ if msa_track_mask is not None
328
+ else None
329
+ )
330
+
331
+ pair_mask4d = mask4d[:, :, :1] if mask4d is not None else None
332
+
333
+ msa_update = self.msa_pair_weighted_averaging(
334
+ msa_repr, pair_repr, pair_attention_mask
335
+ )
336
+ if mask4d is not None:
337
+ msa_update = msa_update * mask4d
338
+ msa_repr = msa_repr + msa_update
339
+
340
+ msa_transition = self.msa_transition(msa_repr)
341
+ if mask4d is not None:
342
+ msa_transition = msa_transition * mask4d
343
+ msa_repr = msa_repr + msa_transition
344
+
345
+ pair_opm = self.outer_product_mean(msa_repr, msa_attention_mask)
346
+ if pair_mask4d is not None:
347
+ pair_opm = pair_opm * pair_mask4d
348
+ pair_repr = pair_repr + pair_opm
349
+
350
+ pair_out = self.tri_mul_out(pair_repr, mask=pair_attention_mask)
351
+ if pair_mask4d is not None:
352
+ pair_out = pair_out * pair_mask4d
353
+ pair_repr = pair_repr + pair_out
354
+
355
+ pair_in = self.tri_mul_in(pair_repr, mask=pair_attention_mask)
356
+ if pair_mask4d is not None:
357
+ pair_in = pair_in * pair_mask4d
358
+ pair_repr = pair_repr + pair_in
359
+
360
+ pair_transition = self.pair_transition(pair_repr)
361
+ if pair_mask4d is not None:
362
+ pair_transition = pair_transition * pair_mask4d
363
+ pair_repr = pair_repr + pair_transition
364
+ return msa_repr, pair_repr
365
+
366
+
367
+ class MSAEncoder(nn.Module):
368
+ def __init__(
369
+ self,
370
+ d_msa: int,
371
+ d_pair: int,
372
+ d_inputs: int,
373
+ d_hidden: int = 32,
374
+ n_layers: int = 4,
375
+ n_heads_msa: int = 8,
376
+ msa_head_width: int = 32,
377
+ ) -> None:
378
+ super().__init__()
379
+ self.embed = nn.Linear(35, d_msa, bias=False)
380
+ self.project_inputs = nn.Linear(d_inputs, d_msa, bias=False)
381
+ self.blocks = nn.ModuleList(
382
+ [
383
+ MSAEncoderBlock(
384
+ d_msa=d_msa,
385
+ d_pair=d_pair,
386
+ d_hidden=d_hidden,
387
+ n_heads_msa=n_heads_msa,
388
+ msa_head_width=msa_head_width,
389
+ )
390
+ for _ in range(n_layers)
391
+ ]
392
+ )
393
+
394
+ def set_chunk_size(self, chunk_size: int | None) -> None:
395
+ for block in self.blocks:
396
+ cast(MSAEncoderBlock, block).set_chunk_size(chunk_size)
397
+
398
+ def forward(
399
+ self,
400
+ x_pair: Tensor,
401
+ x_inputs: Tensor,
402
+ msa_oh: Tensor,
403
+ has_deletion: Tensor,
404
+ deletion_value: Tensor,
405
+ msa_attention_mask: Tensor,
406
+ ) -> Tensor:
407
+ batch_size, _, depth = msa_attention_mask.shape
408
+ m_feat = torch.cat(
409
+ [msa_oh, has_deletion.unsqueeze(-1), deletion_value.unsqueeze(-1)],
410
+ dim=-1,
411
+ )
412
+ m = self.embed(m_feat) + self.project_inputs(x_inputs).unsqueeze(2)
413
+ if depth > 1:
414
+ msa_track_mask = msa_attention_mask[:, :, 1:].any(dim=(1, 2))
415
+ else:
416
+ msa_track_mask = torch.zeros(
417
+ batch_size, dtype=torch.bool, device=x_pair.device
418
+ )
419
+ tok_mask = msa_attention_mask[:, :, 0]
420
+ pair_attention_mask = tok_mask.unsqueeze(2) * tok_mask.unsqueeze(1)
421
+ for block in self.blocks:
422
+ m, x_pair = cast(MSAEncoderBlock, block)(
423
+ m,
424
+ x_pair,
425
+ msa_attention_mask,
426
+ pair_attention_mask,
427
+ msa_track_mask,
428
+ )
429
+ return x_pair * msa_track_mask[:, None, None, None].to(dtype=x_pair.dtype)
430
+
431
+
432
+ class ESMFold2ExperimentalModel(PreTrainedModel):
433
+ """Experimental ESMFold2 architecture used by binder-design checkpoints."""
434
+
435
+ config_class = ESMFold2Config
436
+ _keys_to_ignore_on_load_unexpected = [r"\._extra_state$"]
437
+
438
+ def __init__(self, config: ESMFold2Config) -> None:
439
+ super().__init__(config)
440
+ d_inputs = config.inputs.d_inputs
441
+ d_pair = config.d_pair
442
+
443
+ self.inputs_embedder = InputsEmbedder(config)
444
+ self.z_init_1 = nn.Linear(d_inputs, d_pair, bias=False)
445
+ self.z_init_2 = nn.Linear(d_inputs, d_pair, bias=False)
446
+ self.rel_pos = ResIdxAsymIdSymIdEntityIdEncoding(
447
+ n_relative_residx_bins=config.n_relative_residx_bins,
448
+ n_relative_chain_bins=config.n_relative_chain_bins,
449
+ d_pair=d_pair,
450
+ )
451
+ self.token_bonds = nn.Linear(1, d_pair, bias=False)
452
+ self.language_model = LanguageModelShim(
453
+ d_z=d_pair, d_model=config.lm_d_model, num_layers=config.lm_num_layers
454
+ )
455
+ self._esmc: nn.Module | None = None
456
+ self._esmc_fp8 = False
457
+ self._esmfold2_input_builder: Any | None = None
458
+
459
+ pf = config.folding_trunk
460
+ self.folding_trunk = FoldingTrunk(
461
+ n_layers=pf.n_layers, d_pair=d_pair, expansion_ratio=4
462
+ )
463
+ self.pair_loop_proj = nn.Sequential(
464
+ nn.LayerNorm(d_pair), nn.Linear(d_pair, d_pair, bias=False)
465
+ )
466
+ nn.init.zeros_(cast(nn.Linear, self.pair_loop_proj[1]).weight)
467
+
468
+ self.structure_head = DiffusionStructureHead(config)
469
+ self.distogram_head = nn.Linear(
470
+ d_pair, config.structure_head.distogram_bins, bias=True
471
+ )
472
+ self.confidence_head: ConfidenceHead | None = (
473
+ ConfidenceHead(config) if config.confidence_head.enabled else None
474
+ )
475
+
476
+ msa_cfg = config.msa_encoder
477
+ self.msa_encoder: MSAEncoder | None = None
478
+ if msa_cfg.enabled:
479
+ self.msa_encoder = MSAEncoder(
480
+ d_msa=msa_cfg.d_msa,
481
+ d_pair=d_pair,
482
+ d_inputs=d_inputs,
483
+ d_hidden=msa_cfg.d_hidden,
484
+ n_layers=msa_cfg.n_layers,
485
+ n_heads_msa=msa_cfg.n_heads_msa,
486
+ msa_head_width=msa_cfg.msa_head_width,
487
+ )
488
+
489
+ self.post_init()
490
+
491
+ @property
492
+ def device(self) -> torch.device:
493
+ return next(self.parameters()).device
494
+
495
+ def set_kernel_backend(self, backend: str | None) -> None:
496
+ self.folding_trunk.set_kernel_backend(backend)
497
+ if self.confidence_head is not None:
498
+ self.confidence_head.set_kernel_backend(backend)
499
+ self.structure_head.set_kernel_backend(backend)
500
+
501
+ def set_chunk_size(self, chunk_size: int | None) -> None:
502
+ self.folding_trunk.set_chunk_size(chunk_size)
503
+ if self.confidence_head is not None:
504
+ self.confidence_head.set_chunk_size(chunk_size)
505
+ if self.msa_encoder is not None:
506
+ self.msa_encoder.set_chunk_size(chunk_size)
507
+
508
+ def configure_lm_dropout(
509
+ self,
510
+ lm_dropout: float,
511
+ *,
512
+ force_lm_dropout_during_inference: bool = True,
513
+ ) -> None:
514
+ self.config.lm_dropout = lm_dropout
515
+ self.config.force_lm_dropout_during_inference = (
516
+ force_lm_dropout_during_inference
517
+ )
518
+
519
+ def load_esmc(self, esmc_model_path: str, precision: str = "bf16") -> None:
520
+ dtype_map = {
521
+ "bf16": torch.bfloat16,
522
+ "fp32": torch.float32,
523
+ }
 
 
 
524
  if precision not in dtype_map:
525
+ if precision == "fp8":
526
+ raise RuntimeError(
527
+ "esmc_precision='fp8' is supported only by the standard "
528
+ "released ESMFold2 model. The experimental binder-design "
529
+ "model keeps the FastPLMs ESM++ backbone in bf16 or fp32."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  )
531
+ raise ValueError(f"precision must be one of {list(dtype_map)}, got {precision!r}")
532
+ esmc = _load_fastplms_esmplusplus_for_esmfold2(
533
+ esmc_model_path=esmc_model_path,
534
+ attn_backend=self.config.esmc_attn_backend,
535
+ device=self.device,
536
+ dtype=dtype_map[precision],
537
+ )
538
+ assert esmc.config.hidden_size == self.config.lm_d_model, (
539
+ f"ESMFold2 expected lm_d_model={self.config.lm_d_model}, "
540
+ f"but loaded ESM++ hidden_size={esmc.config.hidden_size}."
541
+ )
542
+ assert esmc.config.num_hidden_layers == self.config.lm_num_layers, (
543
+ f"ESMFold2 expected lm_num_layers={self.config.lm_num_layers}, "
544
+ f"but loaded ESM++ num_hidden_layers={esmc.config.num_hidden_layers}."
545
+ )
546
+ for parameter in esmc.parameters():
547
+ parameter.requires_grad_(False)
548
+ self._esmc_fp8 = False
549
+ self._esmc = esmc
550
+
551
+ @classmethod
552
+ def from_pretrained(
553
+ cls,
554
+ pretrained_model_name_or_path,
555
+ *model_args,
556
+ load_esmc: bool = True,
557
+ **kwargs,
558
+ ):
559
+ if "config" not in kwargs:
560
+ kwargs["config"] = ESMFold2Config.from_pretrained(
561
+ pretrained_model_name_or_path, **kwargs
562
+ )
563
+ esmc_precision = kwargs.pop("esmc_precision", "bf16")
564
+ model = super().from_pretrained(pretrained_model_name_or_path, *model_args, **kwargs)
565
+ if load_esmc:
566
+ model.load_esmc(model.config.esmc_id, precision=esmc_precision)
567
+ return model
568
+
569
+ def apply_torch_compile(
570
+ self, mode: str = "fixed_seqlen", dynamic: bool | None = None
571
+ ) -> None:
572
+ import torch._dynamo
573
+
574
+ torch._dynamo.config.cache_size_limit = 512
575
+ torch._dynamo.config.accumulated_cache_size_limit = 512
576
+ torch._dynamo.config.capture_scalar_outputs = True
577
+
578
+ if dynamic is None:
579
+ dynamic = mode == "dynamic_seqlen"
580
+ compile_kwargs: dict[str, bool] = {"dynamic": dynamic}
581
+ compile_targets = (
582
+ PairUpdateBlock,
583
+ DiffusionTransformer,
584
+ DiffusionModule,
585
+ MSAEncoderBlock,
586
+ )
587
+
588
+ def _maybe_compile(module: nn.Module) -> None:
589
+ if isinstance(module, compile_targets):
590
+ module.forward = torch.compile(module.forward, **compile_kwargs)
591
+
592
+ self.apply(_maybe_compile)
593
+
594
+ def _compute_lm_hidden_states(
595
+ self,
596
+ input_ids: Tensor,
597
+ asym_id: Tensor,
598
+ residue_index: Tensor,
599
+ mol_type: Tensor,
600
+ tok_mask: Tensor,
601
+ ) -> Tensor:
602
+ assert self._esmc is not None
603
+ pad_to = 8 if self._esmc_fp8 else None
604
+ with _lm_precision_context(self._esmc_fp8):
605
+ return compute_lm_hidden_states(
606
+ self._esmc,
607
+ input_ids,
608
+ asym_id,
609
+ residue_index,
610
+ mol_type,
611
+ tok_mask,
612
+ pad_to_multiple=pad_to,
613
+ )
614
+
615
+ def forward(
616
+ self,
617
+ token_index: Tensor,
618
+ residue_index: Tensor,
619
+ asym_id: Tensor,
620
+ sym_id: Tensor,
621
+ entity_id: Tensor,
622
+ mol_type: Tensor,
623
+ res_type: Tensor,
624
+ token_bonds: Tensor,
625
+ token_attention_mask: Tensor,
626
+ ref_pos: Tensor,
627
+ ref_element: Tensor,
628
+ ref_charge: Tensor,
629
+ ref_atom_name_chars: Tensor,
630
+ ref_space_uid: Tensor,
631
+ atom_attention_mask: Tensor,
632
+ atom_to_token: Tensor,
633
+ distogram_atom_idx: Tensor,
634
+ deletion_mean: Tensor | None = None,
635
+ msa: Tensor | None = None,
636
+ has_deletion: Tensor | None = None,
637
+ deletion_value: Tensor | None = None,
638
+ msa_attention_mask: Tensor | None = None,
639
+ input_ids: Tensor | None = None,
640
+ lm_hidden_states: Tensor | None = None,
641
+ res_type_soft: Tensor | None = None,
642
+ num_loops: int | None = None,
643
+ num_diffusion_samples: int | None = None,
644
+ num_sampling_steps: int | None = None,
645
+ early_exit: bool = False,
646
+ seed: int | None = None,
647
+ calculate_confidence: bool = True,
648
+ provide_soft_sequence_to_msa_and_profile: bool = True,
649
+ noise_scale: float | None = None,
650
+ step_scale: float | None = None,
651
+ max_inference_sigma: int | None = None,
652
+ ) -> dict[str, Tensor]:
653
+ del noise_scale, step_scale, max_inference_sigma
654
+ tok_mask = token_attention_mask
655
+ atm_mask = atom_attention_mask
656
+ n_loops = num_loops if num_loops is not None else self.config.num_loops
657
+ n_samples = (
658
+ num_diffusion_samples
659
+ if num_diffusion_samples is not None
660
+ else self.config.num_diffusion_samples
661
+ )
662
+
663
+ if res_type.dim() == 2:
664
+ res_type_oh = F.one_hot(res_type.long(), num_classes=NUM_RES_TYPES).float()
665
+ res_type_oh = res_type_oh * tok_mask.unsqueeze(-1).float()
666
+ else:
667
+ res_type_oh = res_type.float()
668
+
669
+ if msa is not None:
670
+ msa_oh_profile = F.one_hot(msa.long(), num_classes=NUM_RES_TYPES).float()
671
+ if msa_attention_mask is not None:
672
+ mask_f = msa_attention_mask.float().unsqueeze(-1)
673
+ msa_oh_profile = msa_oh_profile * mask_f
674
+ valid_seq_count = msa_attention_mask.float().sum(dim=1).clamp(min=1)
675
+ profile = msa_oh_profile.sum(dim=1) / valid_seq_count.unsqueeze(-1)
676
+ else:
677
+ profile = msa_oh_profile.mean(dim=1)
678
+ else:
679
+ profile = res_type_oh
680
+
681
+ if res_type_soft is not None:
682
+ res_type_oh = res_type_soft.float()
683
+ if (
684
+ not self.config.disable_msa_features
685
+ and provide_soft_sequence_to_msa_and_profile
686
+ ):
687
+ profile = res_type_oh
688
+ msa = res_type_oh.unsqueeze(1)
689
+ msa_attention_mask = tok_mask.unsqueeze(1)
690
+
691
+ if deletion_mean is None:
692
+ deletion_mean = torch.zeros(
693
+ res_type.shape[0], res_type.shape[1], device=res_type.device
694
+ )
695
+ if self.config.disable_msa_features:
696
+ profile = torch.zeros_like(profile)
697
+ deletion_mean = torch.zeros_like(deletion_mean)
698
+
699
+ ref_element_oh = F.one_hot(
700
+ ref_element.long(), num_classes=MAX_ATOMIC_NUMBER
701
+ ).float()
702
+ ref_atom_name_chars_oh = F.one_hot(
703
+ ref_atom_name_chars.long(), num_classes=CHAR_VOCAB_SIZE
704
+ ).float()
705
+ atm_mask_f = atm_mask.float()
706
+ ref_element_oh = ref_element_oh * atm_mask_f.unsqueeze(-1)
707
+ ref_atom_name_chars_oh = ref_atom_name_chars_oh * atm_mask_f.unsqueeze(
708
+ -1
709
+ ).unsqueeze(-1)
710
+ atom_to_token = atom_to_token * atm_mask.long()
711
+
712
+ use_amp = ref_pos.device.type == "cuda"
713
+ with (
714
+ torch.set_grad_enabled(res_type_soft is not None),
715
+ torch.amp.autocast("cuda", enabled=use_amp, dtype=torch.bfloat16),
716
+ ):
717
+ x_inputs = self.inputs_embedder(
718
+ aatype=res_type_oh,
719
+ profile=profile.float(),
720
+ deletion_mean=deletion_mean.float(),
721
+ ref_pos=ref_pos,
722
+ atom_attention_mask=atm_mask,
723
+ ref_space_uid=ref_space_uid,
724
+ ref_charge=ref_charge,
725
+ ref_element=ref_element_oh,
726
+ ref_atom_name_chars=ref_atom_name_chars_oh,
727
+ atom_to_token=atom_to_token,
728
+ )
729
+
730
+ z_init = self.z_init_1(x_inputs).unsqueeze(2) + self.z_init_2(
731
+ x_inputs
732
+ ).unsqueeze(1)
733
+ relative_position_encoding = self.rel_pos(
734
+ residue_index=residue_index,
735
+ asym_id=asym_id,
736
+ sym_id=sym_id,
737
+ entity_id=entity_id,
738
+ token_index=token_index,
739
+ )
740
+ token_bonds_encoding = self.token_bonds(token_bonds.float())
741
+ z_init = z_init + relative_position_encoding + token_bonds_encoding
742
+
743
+ if (
744
+ lm_hidden_states is None
745
+ and input_ids is not None
746
+ and self._esmc is not None
747
+ ):
748
+ lm_hidden_states = self._compute_lm_hidden_states(
749
+ input_ids, asym_id, residue_index, mol_type, tok_mask
750
+ )
751
+ if lm_hidden_states is not None:
752
+ lm_dropout = (
753
+ self.config.lm_dropout
754
+ if self.config.force_lm_dropout_during_inference or self.training
755
+ else 0.0
756
+ )
757
+ lm_z = self.language_model(
758
+ lm_hidden_states.detach(), lm_dropout=lm_dropout
759
+ )
760
+ z_init = z_init + lm_z.to(z_init.dtype)
761
+
762
+ msa_kwargs: dict[str, Tensor] | None = None
763
+ if self.msa_encoder is not None and msa is not None:
764
+ if msa.dim() == 4:
765
+ batch_msa, depth, length_msa, _ = msa.shape
766
+ msa_oh = msa.permute(0, 2, 1, 3).float()
767
+ else:
768
+ batch_msa, depth, length_msa = msa.shape
769
+ msa_oh = F.one_hot(
770
+ msa.permute(0, 2, 1).long(), num_classes=NUM_RES_TYPES
771
+ ).float()
772
+ msa_attn = (
773
+ msa_attention_mask.permute(0, 2, 1).float()
774
+ if msa_attention_mask is not None
775
+ else tok_mask[:, :, None].expand(-1, -1, depth).float()
776
+ )
777
+ msa_oh = msa_oh * msa_attn.unsqueeze(-1)
778
+ hd = (
779
+ has_deletion.permute(0, 2, 1).float()
780
+ if has_deletion is not None
781
+ else torch.zeros(batch_msa, length_msa, depth, device=msa.device)
782
+ )
783
+ dv = (
784
+ deletion_value.permute(0, 2, 1).float()
785
+ if deletion_value is not None
786
+ else torch.zeros(batch_msa, length_msa, depth, device=msa.device)
787
+ )
788
+ msa_kwargs = {
789
+ "x_inputs": x_inputs,
790
+ "msa_oh": msa_oh,
791
+ "has_deletion": hd,
792
+ "deletion_value": dv,
793
+ "msa_attention_mask": msa_attn,
794
+ }
795
+
796
+ pair_mask = tok_mask[:, :, None].float() * tok_mask[:, None, :].float()
797
+ z = torch.zeros_like(z_init)
798
+ prev_pair: Tensor | None = None
799
+ prev_disto_probs: Tensor | None = None
800
+ for loop_num in range(n_loops + 1):
801
+ z = z_init + self.pair_loop_proj(z)
802
+ if msa_kwargs is not None and self.msa_encoder is not None:
803
+ z = z + self.msa_encoder(x_pair=z, **msa_kwargs).to(z.dtype)
804
+ z = self.folding_trunk(z, pair_attention_mask=pair_mask)
805
+
806
+ if early_exit and loop_num < n_loops:
807
+ l2_converged = False
808
+ if prev_pair is not None and loop_num > 0:
809
+ rel_l2 = (z.float() - prev_pair.float()).norm() / prev_pair.float().norm().clamp(
810
+ min=1e-8
811
+ )
812
+ l2_converged = rel_l2.item() < 0.25
813
+ prev_pair = z.detach().clone()
814
+ sym_z = z.float() + z.float().transpose(-2, -3)
815
+ cur_probs = F.softmax(self.distogram_head(sym_z).float(), dim=-1)
816
+ if prev_disto_probs is not None and loop_num > 0:
817
+ kl_per_pair = (
818
+ cur_probs
819
+ * (
820
+ cur_probs.clamp(min=1e-8)
821
+ / prev_disto_probs.clamp(min=1e-8)
822
+ ).log()
823
+ ).sum(-1)
824
+ kl = (kl_per_pair + kl_per_pair.transpose(-1, -2)).mean() / 2
825
+ if l2_converged or kl.item() < 0.05:
826
+ break
827
+ prev_disto_probs = cur_probs.detach()
828
+
829
+ distogram_logits = self.distogram_head(z + z.transpose(-2, -3))
830
+
831
+ with torch.no_grad(), _seed_context(seed):
832
+ structure_output = self.structure_head.sample(
833
+ z_trunk=z.float(),
834
+ s_inputs=x_inputs,
835
+ s_trunk=None,
836
+ relative_position_encoding=relative_position_encoding,
837
+ ref_pos=ref_pos,
838
+ ref_charge=ref_charge,
839
+ ref_mask=atm_mask,
840
+ ref_element=ref_element_oh,
841
+ ref_atom_name_chars=ref_atom_name_chars_oh,
842
+ ref_space_uid=ref_space_uid,
843
+ tok_idx=atom_to_token,
844
+ asym_id=asym_id,
845
+ residue_index=residue_index,
846
+ entity_id=entity_id,
847
+ token_index=token_index,
848
+ sym_id=sym_id,
849
+ token_attention_mask=tok_mask,
850
+ num_diffusion_samples=n_samples,
851
+ num_sampling_steps=num_sampling_steps,
852
+ return_atom_repr=False,
853
+ denoising_early_exit_rmsd=(0.10 if early_exit else None),
854
+ )
855
+ sample_coords = structure_output["sample_atom_coords"]
856
+ assert sample_coords is not None
857
+
858
+ output: dict[str, Tensor] = {
859
+ "distogram_logits": distogram_logits,
860
+ "sample_atom_coords": sample_coords,
861
+ }
862
+ if calculate_confidence and self.confidence_head is not None:
863
+ confidence_output = self.confidence_head(
864
+ s_inputs=x_inputs.detach(),
865
+ z=z.detach().float(),
866
+ x_pred=sample_coords.detach(),
867
+ distogram_atom_idx=distogram_atom_idx,
868
+ token_attention_mask=tok_mask,
869
+ atom_to_token=atom_to_token,
870
+ atom_attention_mask=atm_mask,
871
+ asym_id=asym_id,
872
+ mol_type=mol_type,
873
+ num_diffusion_samples=n_samples,
874
+ relative_position_encoding=relative_position_encoding.detach(),
875
+ token_bonds_encoding=token_bonds_encoding.detach(),
876
+ )
877
+ output.update(confidence_output)
878
+ output["atom_pad_mask"] = (
879
+ atm_mask.unsqueeze(0) if atm_mask.dim() == 1 else atm_mask
880
+ )
881
+ output["residue_index"] = residue_index
882
+ output["entity_id"] = entity_id
883
+ return output
884
+
885
+ @property
886
+ def input_builder(self):
887
+ if self._esmfold2_input_builder is None:
888
+ from .esmfold2_processor import ESMFold2InputBuilder
889
+
890
+ self._esmfold2_input_builder = ESMFold2InputBuilder()
891
+ return self._esmfold2_input_builder
892
+
893
+ @property
894
+ def input_types(self):
895
+ from . import esmfold2_types
896
+
897
+ return esmfold2_types
898
+
899
+ def prepare_structure_input(self, input, seed: int | None = None):
900
+ return self.input_builder.prepare_input(input, seed=seed, device=self.device)
901
+
902
+ @torch.no_grad()
903
+ def infer_protein(self, seq: str, **forward_kwargs) -> dict[str, Tensor]:
904
+ from .protein_utils import prepare_protein_features
905
+
906
+ features = prepare_protein_features(seq)
907
+ features = {name: tensor.to(self.device) for name, tensor in features.items()}
908
+ output = self(**features, **forward_kwargs)
909
+ for name in (
910
+ "res_type",
911
+ "atom_to_token",
912
+ "ref_atom_name_chars",
913
+ "atom_attention_mask",
914
+ "token_attention_mask",
915
+ "residue_index",
916
+ ):
917
+ output[name] = features[name]
918
+ return output
919
+
920
+ def fold(
921
+ self,
922
+ input,
923
+ *,
924
+ num_loops: int = 3,
925
+ num_sampling_steps: int = 50,
926
+ num_diffusion_samples: int = 1,
927
+ seed: int | None = None,
928
+ noise_scale: float | None = None,
929
+ step_scale: float | None = None,
930
+ max_inference_sigma: int | None = None,
931
+ early_exit: bool = False,
932
+ complex_id: str = "pred",
933
+ ):
934
+ return self.input_builder.fold(
935
+ self,
936
+ input,
937
+ num_loops=num_loops,
938
+ num_sampling_steps=num_sampling_steps,
939
+ num_diffusion_samples=num_diffusion_samples,
940
+ seed=seed,
941
+ noise_scale=noise_scale,
942
+ step_scale=step_scale,
943
+ max_inference_sigma=max_inference_sigma,
944
+ early_exit=early_exit,
945
+ complex_id=complex_id,
946
+ )
947
+
948
+ def fold_protein(
949
+ self,
950
+ sequence: str,
951
+ *,
952
+ chain_id: str = "A",
953
+ num_loops: int = 3,
954
+ num_sampling_steps: int = 50,
955
+ num_diffusion_samples: int = 1,
956
+ seed: int | None = None,
957
+ complex_id: str = "pred",
958
+ ):
959
+ from .esmfold2_types import ProteinInput, StructurePredictionInput
960
+
961
+ input = StructurePredictionInput(
962
+ sequences=[ProteinInput(id=chain_id, sequence=sequence)]
963
+ )
964
+ return self.fold(
965
+ input,
966
+ num_loops=num_loops,
967
+ num_sampling_steps=num_sampling_steps,
968
+ num_diffusion_samples=num_diffusion_samples,
969
+ seed=seed,
970
+ complex_id=complex_id,
971
+ )
972
+
973
+ @staticmethod
974
+ def result_to_cif(result) -> str:
975
+ assert not isinstance(result, list), "Pass one MolecularComplexResult at a time."
976
+ return result.complex.to_mmcif()
977
+
978
+ @staticmethod
979
+ def result_to_pdb(result) -> str:
980
+ assert not isinstance(result, list), "Pass one MolecularComplexResult at a time."
981
+ return result.complex.to_protein_complex().to_pdb_string()
982
+
983
+ def save_as_cif(self, result, output_path: str | Path) -> None:
984
+ Path(output_path).write_text(self.result_to_cif(result))
985
+
986
+ def save_as_pdb(self, result, output_path: str | Path) -> None:
987
+ Path(output_path).write_text(self.result_to_pdb(result))
988
+
989
+ def infer_protein_as_cif(self, seq: str, **forward_kwargs) -> str:
990
+ return self.result_to_cif(self.fold_protein(seq, **forward_kwargs))
991
+
992
+ def infer_protein_as_pdb(self, seq: str, **forward_kwargs) -> str:
993
+ return self.result_to_pdb(self.fold_protein(seq, **forward_kwargs))
994
+
995
 
996
+ __all__ = [
997
+ "ConfidenceHead",
998
+ "MSAEncoder",
999
+ "MSAEncoderBlock",
1000
+ "ESMFold2ExperimentalModel",
1001
+ ]
protein_utils.py CHANGED
@@ -1,488 +1,488 @@
1
- # coding=utf-8
2
- # Copyright 2026 Biohub. All rights reserved.
3
- #
4
- # Licensed under the Apache License, Version 2.0 (the "License");
5
- # you may not use this file except in compliance with the License.
6
- # You may obtain a copy of the License at
7
- #
8
- # http://www.apache.org/licenses/LICENSE-2.0
9
- #
10
- # Unless required by applicable law or agreed to in writing, software
11
- # distributed under the License is distributed on an "AS IS" BASIS,
12
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- # See the License for the specific language governing permissions and
14
- # limitations under the License.
15
- """Self-contained protein featurization for ESMFold2 inference.
16
-
17
- Lets ``ESMFold2ExperimentalModel.infer_protein_as_pdb`` fold a protein sequence
18
- ESMFold-style without the ``esm`` companion package. The featurization
19
- mirrors ``ESMFold2InputBuilder.prepare_input`` for the protein-only path —
20
- ``test_prepare_protein_features.py`` enforces tensor-exact parity.
21
- """
22
-
23
- from __future__ import annotations
24
-
25
- import math
26
-
27
- import torch
28
- from torch import Tensor
29
-
30
- MOL_TYPE_PROTEIN = 0
31
- PROTEIN_UNK_RES_TYPE = 22
32
- MSA_GAP_TOKEN_ID = 1
33
-
34
- PROTEIN_RESIDUE_TO_RES_TYPE: dict[str, int] = {
35
- "ALA": 2,
36
- "ARG": 3,
37
- "ASN": 4,
38
- "ASP": 5,
39
- "CYS": 6,
40
- "GLN": 7,
41
- "GLU": 8,
42
- "GLY": 9,
43
- "HIS": 10,
44
- "ILE": 11,
45
- "LEU": 12,
46
- "LYS": 13,
47
- "MET": 14,
48
- "PHE": 15,
49
- "PRO": 16,
50
- "SER": 17,
51
- "THR": 18,
52
- "TRP": 19,
53
- "TYR": 20,
54
- "VAL": 21,
55
- }
56
-
57
- PROTEIN_1TO3: dict[str, str] = {
58
- "A": "ALA",
59
- "R": "ARG",
60
- "N": "ASN",
61
- "D": "ASP",
62
- "C": "CYS",
63
- "Q": "GLN",
64
- "E": "GLU",
65
- "G": "GLY",
66
- "H": "HIS",
67
- "I": "ILE",
68
- "L": "LEU",
69
- "K": "LYS",
70
- "M": "MET",
71
- "F": "PHE",
72
- "P": "PRO",
73
- "S": "SER",
74
- "T": "THR",
75
- "W": "TRP",
76
- "Y": "TYR",
77
- "V": "VAL",
78
- "X": "UNK",
79
- }
80
-
81
- ESM_PROTEIN_VOCAB: dict[str, int] = {
82
- "L": 4,
83
- "A": 5,
84
- "G": 6,
85
- "V": 7,
86
- "S": 8,
87
- "E": 9,
88
- "R": 10,
89
- "T": 11,
90
- "I": 12,
91
- "D": 13,
92
- "P": 14,
93
- "K": 15,
94
- "Q": 16,
95
- "N": 17,
96
- "F": 18,
97
- "Y": 19,
98
- "M": 20,
99
- "H": 21,
100
- "W": 22,
101
- "C": 23,
102
- "X": 3,
103
- }
104
-
105
- # Heavy atoms per canonical residue, in training-time order.
106
- PROTEIN_HEAVY_ATOMS: dict[str, list[str]] = {
107
- "ALA": ["N", "CA", "C", "O", "CB"],
108
- "ARG": ["N", "CA", "C", "O", "CB", "CG", "CD", "NE", "CZ", "NH1", "NH2"],
109
- "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2"],
110
- "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2"],
111
- "CYS": ["N", "CA", "C", "O", "CB", "SG"],
112
- "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2"],
113
- "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2"],
114
- "GLY": ["N", "CA", "C", "O"],
115
- "HIS": ["N", "CA", "C", "O", "CB", "CG", "ND1", "CD2", "CE1", "NE2"],
116
- "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1"],
117
- "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2"],
118
- "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ"],
119
- "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
120
- "PHE": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
121
- "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD"],
122
- "SER": ["N", "CA", "C", "O", "CB", "OG"],
123
- "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2"],
124
- "TRP": [
125
- "N",
126
- "CA",
127
- "C",
128
- "O",
129
- "CB",
130
- "CG",
131
- "CD1",
132
- "CD2",
133
- "NE1",
134
- "CE2",
135
- "CE3",
136
- "CZ2",
137
- "CZ3",
138
- "CH2",
139
- ],
140
- "TYR": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "OH"],
141
- "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2"],
142
- "UNK": ["N", "CA", "C", "O"],
143
- }
144
-
145
- PROTEIN_REF_POS: dict[str, dict[str, tuple[float, float, float]]] = {
146
- "ALA": {
147
- "N": (-0.01003183238208294, -1.2073018550872803, -1.0555061101913452),
148
- "CA": (-0.04190138354897499, 0.17447763681411743, -0.5729365348815918),
149
- "C": (1.2127548456192017, 0.4737588167190552, 0.19521640241146088),
150
- "O": (1.9390329122543335, 1.4484562873840332, -0.13759790360927582),
151
- "CB": (-1.276943325996399, 0.4288230538368225, 0.29937705397605896),
152
- },
153
- "ARG": {
154
- "N": (-2.0170421600341797, 0.6717798113822937, -1.1794233322143555),
155
- "CA": (-2.0503084659576416, -0.5735036730766296, -0.4097220301628113),
156
- "C": (-3.469440460205078, -1.0612813234329224, -0.2755832374095917),
157
- "O": (-3.8218462467193604, -2.1369943618774414, -0.8294969797134399),
158
- "CB": (-1.4193516969680786, -0.3735991418361664, 0.9852858781814575),
159
- "CG": (0.11878877878189087, -0.3112654983997345, 0.963895857334137),
160
- "CD": (0.6643245816230774, 1.0068185329437256, 0.3963329493999481),
161
- "NE": (2.1090238094329834, 1.0977025032043457, 0.6120952367782593),
162
- "CZ": (3.098905324935913, 0.3215920031070709, -0.09047172218561172),
163
- "NH1": (4.461230278015137, 0.3844667971134186, 0.34141138195991516),
164
- "NH2": (2.7856509685516357, -0.4166366159915924, -1.1148239374160767),
165
- },
166
- "ASN": {
167
- "N": (-0.7595629096031189, 0.7503494620323181, 1.1369825601577759),
168
- "CA": (-0.76087886095047, 0.23876343667507172, -0.23573364317417145),
169
- "C": (-1.9211044311523438, -0.6982439160346985, -0.42196929454803467),
170
- "O": (-2.677666187286377, -0.5753439664840698, -1.4223182201385498),
171
- "CB": (0.5504899024963379, -0.5078350305557251, -0.5390339493751526),
172
- "CG": (1.7250099182128906, 0.4264017939567566, -0.5778228640556335),
173
- "OD1": (1.9470350742340088, 1.1086392402648926, -1.613560438156128),
174
- "ND2": (2.57365345954895, 0.5730618834495544, 0.5608599781990051),
175
- },
176
- "ASP": {
177
- "N": (-1.8452696800231934, -1.2169504165649414, 0.19437327980995178),
178
- "CA": (-0.6379959583282471, -0.41974392533302307, 0.41681644320487976),
179
- "C": (-0.9431572556495667, 1.0356197357177734, 0.18555717170238495),
180
- "O": (-1.5183608531951904, 1.4045922756195068, -0.8739855885505676),
181
- "CB": (0.48594576120376587, -0.8970447778701782, -0.5209363698959351),
182
- "CG": (1.780342936515808, -0.19918935000896454, -0.2310730367898941),
183
- "OD1": (2.5202910900115967, -0.6044584512710571, 0.7049641013145447),
184
- "OD2": (2.1454880237579346, 0.9208861589431763, -0.9712985157966614),
185
- },
186
- "CYS": {
187
- "N": (0.0469963513314724, 1.190075159072876, -1.1607273817062378),
188
- "CA": (0.11344368755817413, -0.09400428831577301, -0.45952197909355164),
189
- "C": (-1.2652032375335693, -0.6832379698753357, -0.3594406247138977),
190
- "O": (-1.4631439447402954, -1.8851220607757568, -0.6826791763305664),
191
- "CB": (0.6919880509376526, 0.09034398198127747, 0.952482283115387),
192
- "SG": (2.4619927406311035, 0.5235707759857178, 0.9020372629165649),
193
- },
194
- "GLN": {
195
- "N": (-2.370004653930664, -0.9637529850006104, -0.7942749261856079),
196
- "CA": (-1.370002269744873, -0.6000258922576904, 0.2103111445903778),
197
- "C": (-1.7545503377914429, 0.7091967463493347, 0.8433493971824646),
198
- "O": (-1.8520662784576416, 0.7999289631843567, 2.0964975357055664),
199
- "CB": (0.02040259726345539, -0.5004461407661438, -0.44764479994773865),
200
- "CG": (1.1377512216567993, -0.28680720925331116, 0.582992434501648),
201
- "CD": (2.4745187759399414, -0.24800164997577667, -0.09364881366491318),
202
- "OE1": (3.1685523986816406, -1.2966246604919434, -0.1717153936624527),
203
- "NE2": (2.947425603866577, 0.9601329565048218, -0.6888364553451538),
204
- },
205
- "GLU": {
206
- "N": (-1.5850872993469238, -1.337684154510498, 0.9490851163864136),
207
- "CA": (-1.0560977458953857, 0.027459044009447098, 1.0306966304779053),
208
- "C": (-1.7741456031799316, 0.9664392471313477, 0.09259600937366486),
209
- "O": (-1.9012441635131836, 2.181349992752075, 0.402479350566864),
210
- "CB": (0.4706551432609558, 0.048803869634866714, 0.8114414811134338),
211
- "CG": (0.9133604764938354, -0.4219329059123993, -0.5830985307693481),
212
- "CD": (2.398822069168091, -0.3097084164619446, -0.7210537791252136),
213
- "OE1": (3.1389315128326416, -1.274524450302124, -0.39029765129089355),
214
- "OE2": (2.9647817611694336, 0.8781346082687378, -1.1732689142227173),
215
- },
216
- "GLY": {
217
- "N": (-1.3942985534667969, -0.39875128865242004, -0.3370324671268463),
218
- "CA": (-0.39974430203437805, 0.5488945245742798, 0.15242962539196014),
219
- "C": (0.9440054893493652, -0.10314033925533295, 0.19859643280506134),
220
- "O": (1.3352899551391602, -0.669218122959137, 1.2541258335113525),
221
- },
222
- "HIS": {
223
- "N": (-1.4532867670059204, -1.0689626932144165, 0.881072461605072),
224
- "CA": (-1.3396095037460327, 0.24797579646110535, 0.24960045516490936),
225
- "C": (-2.675257921218872, 0.6571555733680725, -0.30441102385520935),
226
- "O": (-3.1311378479003906, 1.8079776763916016, -0.06785715371370316),
227
- "CB": (-0.3041955828666687, 0.21721023321151733, -0.8885309100151062),
228
- "CG": (1.0887513160705566, 0.028941065073013306, -0.36419469118118286),
229
- "ND1": (1.840459942817688, 1.0411773920059204, 0.29804590344429016),
230
- "CD2": (1.780855417251587, -1.1011489629745483, -0.3814258575439453),
231
- "CE1": (2.9566943645477295, 0.4924798905849457, 0.6477115750312805),
232
- "NE2": (3.0280203819274902, -0.8751969337463379, 0.26084381341934204),
233
- },
234
- "ILE": {
235
- "N": (-0.7167549729347229, -1.5426139831542969, -0.9983330368995667),
236
- "CA": (-1.0636085271835327, -0.35169270634651184, -0.21393552422523499),
237
- "C": (-1.3896740674972534, 0.8142145276069641, -1.1164065599441528),
238
- "O": (-1.2377792596817017, 0.7302915453910828, -2.3656840324401855),
239
- "CB": (0.061667006462812424, 0.01599610224366188, 0.8057394623756409),
240
- "CG1": (1.502519965171814, -0.08899776637554169, 0.24154816567897797),
241
- "CG2": (-0.053174979984760284, -0.8521055579185486, 2.0702083110809326),
242
- "CD1": (1.7929610013961792, 0.899773120880127, -0.8863027691841125),
243
- },
244
- "LEU": {
245
- "N": (1.9657520055770874, -1.9763224124908447, -0.18391533195972443),
246
- "CA": (1.3077669143676758, -0.6677430868148804, -0.19492436945438385),
247
- "C": (1.9905058145523071, 0.24182087182998657, 0.7879968285560608),
248
- "O": (2.06896710395813, -0.07880014181137085, 2.0048046112060547),
249
- "CB": (-0.20306941866874695, -0.8093230128288269, 0.11243502795696259),
250
- "CG": (-0.9916267395019531, 0.5234957337379456, 0.06723011285066605),
251
- "CD1": (-2.4228057861328125, 0.29949337244033813, 0.573042094707489),
252
- "CD2": (-1.0282856225967407, 1.1250264644622803, -1.346014380455017),
253
- },
254
- "LYS": {
255
- "N": (2.4221372604370117, -0.6473312377929688, 0.6370573043823242),
256
- "CA": (2.0314927101135254, 0.2786507308483124, -0.4298512041568756),
257
- "C": (2.7168593406677246, 1.595757246017456, -0.20924785733222961),
258
- "O": (3.397681713104248, 2.116427421569824, -1.1332510709762573),
259
- "CB": (0.5018402934074402, 0.4873858690261841, -0.49062973260879517),
260
- "CG": (-0.25062066316604614, -0.7894009947776794, -0.9055535793304443),
261
- "CD": (-1.769762635231018, -0.5552700161933899, -1.040329933166504),
262
- "CE": (-2.576533555984497, -1.0221366882324219, 0.18493641912937164),
263
- "NZ": (-2.269151210784912, -0.24293844401836395, 1.3849012851715088),
264
- },
265
- "MET": {
266
- "N": (1.8903918266296387, -1.5252995491027832, -0.42638593912124634),
267
- "CA": (1.2630571126937866, -0.24417810142040253, -0.7626462578773499),
268
- "C": (2.30391001701355, 0.8367712497711182, -0.7254616618156433),
269
- "O": (2.465414524078369, 1.5928632020950317, -1.7207728624343872),
270
- "CB": (0.10567972809076309, 0.10861825942993164, 0.19741646945476532),
271
- "CG": (-1.0658042430877686, -0.8736631274223328, 0.08811883628368378),
272
- "SD": (-2.4557132720947266, -0.3332225978374481, 1.1461700201034546),
273
- "CE": (-3.265165090560913, 0.7033554911613464, -0.11588376015424728),
274
- },
275
- "PHE": {
276
- "N": (-2.8484435081481934, -1.525790810585022, 0.01789816841483116),
277
- "CA": (-1.591969609260559, -0.8545162677764893, 0.35214468836784363),
278
- "C": (-1.8900631666183472, 0.45833414793014526, 1.0232222080230713),
279
- "O": (-1.3424992561340332, 0.74432373046875, 2.121629476547241),
280
- "CB": (-0.760358452796936, -0.6342853307723999, -0.9257160425186157),
281
- "CG": (0.604112982749939, -0.07200468331575394, -0.6148118376731873),
282
- "CD1": (0.8468314409255981, 1.2480632066726685, -0.7146694660186768),
283
- "CD2": (1.6827683448791504, -0.9758077263832092, -0.1423054188489914),
284
- "CE1": (2.1801748275756836, 1.7875733375549316, -0.3744623064994812),
285
- "CE2": (2.888307809829712, -0.48277512192726135, 0.16804970800876617),
286
- "CZ": (3.149812936782837, 0.9656873941421509, 0.04440271109342575),
287
- },
288
- "PRO": {
289
- "N": (-0.836250364780426, -0.9899801015853882, 0.5561304688453674),
290
- "CA": (0.32722190022468567, -0.6164458394050598, -0.25072571635246277),
291
- "C": (1.6121541261672974, -1.1711241006851196, 0.31082412600517273),
292
- "O": (1.6127740144729614, -2.2771971225738525, 0.9156193733215332),
293
- "CB": (0.3248198926448822, 0.9028244018554688, -0.33368146419525146),
294
- "CG": (-1.1425083875656128, 1.2730128765106201, -0.2590600252151489),
295
- "CD": (-1.8495968580245972, 0.026575811207294464, 0.2681289613246918),
296
- },
297
- "SER": {
298
- "N": (0.674650251865387, 1.5018702745437622, -0.5367295145988464),
299
- "CA": (0.00013792862591799349, 0.4966467022895813, 0.28510504961013794),
300
- "C": (0.9941009879112244, -0.5374617576599121, 0.73505038022995),
301
- "O": (1.0545241832733154, -0.8683545589447021, 1.9495396614074707),
302
- "CB": (-1.1279288530349731, -0.1659376323223114, -0.5160963535308838),
303
- "OG": (-1.8135979175567627, -1.085249662399292, 0.28947514295578003),
304
- },
305
- "THR": {
306
- "N": (-1.325830340385437, -1.3728225231170654, 0.6882233023643494),
307
- "CA": (-0.5433306097984314, -0.16364754736423492, 0.41697052121162415),
308
- "C": (-1.294381856918335, 0.7077372074127197, -0.5549946427345276),
309
- "O": (-1.6939635276794434, 0.23654410243034363, -1.6540418863296509),
310
- "CB": (0.853203296661377, -0.5363803505897522, -0.14109353721141815),
311
- "OG1": (1.5220820903778076, -1.379003643989563, 0.7635167837142944),
312
- "CG2": (1.7225933074951172, 0.7054727077484131, -0.3651331067085266),
313
- },
314
- "TRP": {
315
- "N": (3.686030864715576, 0.7599999904632568, 0.496155709028244),
316
- "CA": (2.384092092514038, 0.09079249948263168, 0.5325262546539307),
317
- "C": (2.1113572120666504, -0.6121063232421875, -0.7733646035194397),
318
- "O": (1.796526312828064, -1.8323148488998413, -0.7775964140892029),
319
- "CB": (1.281521201133728, 1.1139036417007446, 0.8559791445732117),
320
- "CG": (-0.04292375594377518, 0.44645074009895325, 1.0942792892456055),
321
- "CD1": (-0.42329534888267517, -0.15470874309539795, 2.2227554321289062),
322
- "CD2": (-1.1023900508880615, 0.2158389836549759, 0.11529432237148285),
323
- "NE1": (-1.7030320167541504, -0.7665823101997375, 2.0595016479492188),
324
- "CE2": (-2.045644998550415, -0.4881173074245453, 0.710669219493866),
325
- "CE3": (-1.2173502445220947, 0.6102271676063538, -1.300106406211853),
326
- "CZ2": (-3.256009340286255, -0.9164394736289978, -0.00984987337142229),
327
- "CZ3": (-2.315925121307373, 0.2306906282901764, -1.9776310920715332),
328
- "CH2": (-3.3817875385284424, -0.5677337646484375, -1.3032053709030151),
329
- },
330
- "TYR": {
331
- "N": (-1.7900604009628296, -0.8409399390220642, 1.3180142641067505),
332
- "CA": (-1.913882851600647, 0.23552845418453217, 0.330669641494751),
333
- "C": (-3.347280740737915, 0.3588399887084961, -0.09830684959888458),
334
- "O": (-3.967811346054077, -0.6449354290962219, -0.5423302054405212),
335
- "CB": (-1.0093992948532104, 0.0004731413209810853, -0.8981552124023438),
336
- "CG": (0.4520410895347595, 0.021162061020731926, -0.5305932760238647),
337
- "CD1": (1.0992432832717896, 1.1877919435501099, -0.3579142987728119),
338
- "CD2": (1.1803174018859863, -1.253401279449463, -0.31122180819511414),
339
- "CE1": (2.5253450870513916, 1.1990256309509277, 0.029804613441228867),
340
- "CE2": (2.471151113510132, -1.240687608718872, 0.043534230440855026),
341
- "CZ": (3.180687665939331, 0.04672492295503616, 0.2214856892824173),
342
- "OH": (4.523719787597656, 0.0671030730009079, 0.5877485871315002),
343
- },
344
- "VAL": {
345
- "N": (0.5987519025802612, -1.569443702697754, -0.7379124760627747),
346
- "CA": (0.6014357209205627, -0.10503966361284256, -0.6336286664009094),
347
- "C": (1.8391697406768799, 0.4067850410938263, 0.06351757049560547),
348
- "O": (2.3952062129974365, -0.2666190266609192, 0.9731166958808899),
349
- "CB": (-0.694736897945404, 0.4259096384048462, 0.03581475466489792),
350
- "CG1": (-1.9276031255722046, 0.09515828639268875, -0.8172357082366943),
351
- "CG2": (-0.8938426971435547, -0.08640842139720917, 1.472349762916565),
352
- },
353
- "UNK": {
354
- "N": (0.0, 0.0, 0.0),
355
- "CA": (0.0, 0.0, 0.0),
356
- "C": (0.0, 0.0, 0.0),
357
- "O": (0.0, 0.0, 0.0),
358
- },
359
- }
360
-
361
- # Protonated nitrogens at physiological pH (matches CHARGED_ATOMS in the
362
- # opensource constants for the protein subset).
363
- PROTEIN_CHARGED_ATOMS: dict[tuple[str, str], int] = {
364
- ("LYS", "NZ"): 1,
365
- ("ARG", "NH2"): 1,
366
- ("HIS", "ND1"): 1,
367
- }
368
-
369
- # Only the elements that appear in canonical protein heavy atoms.
370
- _PROTEIN_ELEMENT_TO_ATOMIC_NUM: dict[str, int] = {"C": 6, "N": 7, "O": 8, "S": 16}
371
-
372
-
373
- def _encode_atom_name(name: str) -> list[int]:
374
- padded = name.ljust(4)[:4]
375
- return [ord(c) - 32 if c != " " else 0 for c in padded]
376
-
377
-
378
- def prepare_protein_features(sequence: str) -> dict[str, Tensor]:
379
- """Featurize a single protein sequence for ESMFold2ExperimentalModel.forward.
380
-
381
- Returns the same keys with the same dtypes/shapes as
382
- ``ESMFold2InputBuilder.prepare_input(StructurePredictionInput(...))``
383
- restricted to a single-chain protein with no MSA, modifications,
384
- distogram conditioning, or covalent bonds. All tensors have a
385
- leading batch dim of 1; the caller is responsible for moving them
386
- to the model device.
387
- """
388
- if not sequence:
389
- raise ValueError("sequence must be non-empty")
390
-
391
- res_3letter = [PROTEIN_1TO3.get(c, "UNK") for c in sequence]
392
- L = len(sequence)
393
-
394
- token_atom_starts: list[int] = []
395
- atom_records: list[tuple[int, str, str, int, tuple[float, float, float]]] = []
396
- res_type_vals: list[int] = []
397
- input_id_vals: list[int] = []
398
- distogram_rep_atom_idx: list[int] = []
399
-
400
- atom_cursor = 0
401
- for t_idx, (letter, res_3) in enumerate(zip(sequence, res_3letter)):
402
- atom_names = PROTEIN_HEAVY_ATOMS[res_3]
403
- res_type = PROTEIN_RESIDUE_TO_RES_TYPE.get(res_3, PROTEIN_UNK_RES_TYPE)
404
- input_id = ESM_PROTEIN_VOCAB.get(letter, ESM_PROTEIN_VOCAB["X"])
405
-
406
- token_atom_starts.append(atom_cursor)
407
- for name in atom_names:
408
- charge = PROTEIN_CHARGED_ATOMS.get((res_3, name), 0)
409
- element = name[0] # protein heavy atoms are all single-letter C/N/O/S
410
- ref_pos = PROTEIN_REF_POS[res_3][name]
411
- atom_records.append((t_idx, name, element, charge, ref_pos))
412
- atom_cursor += 1
413
-
414
- rep_name = "CB" if "CB" in atom_names else "CA"
415
- distogram_rep_atom_idx.append(
416
- token_atom_starts[t_idx] + atom_names.index(rep_name)
417
- )
418
-
419
- res_type_vals.append(res_type)
420
- input_id_vals.append(input_id)
421
-
422
- n_real_atoms = len(atom_records)
423
- n_atoms = math.ceil(n_real_atoms / 32) * 32 if n_real_atoms > 0 else 32
424
-
425
- ref_pos = torch.zeros(n_atoms, 3, dtype=torch.float32)
426
- ref_element = torch.zeros(n_atoms, dtype=torch.int64)
427
- ref_charge = torch.zeros(n_atoms, dtype=torch.int8)
428
- ref_atom_name_chars = torch.zeros(n_atoms, 4, dtype=torch.int64)
429
- ref_space_uid = torch.zeros(n_atoms, dtype=torch.int64)
430
- atom_attention_mask = torch.zeros(n_atoms, dtype=torch.bool)
431
- atom_to_token = torch.zeros(n_atoms, dtype=torch.int64)
432
-
433
- for i, (t_idx, name, element, charge, pos) in enumerate(atom_records):
434
- ref_pos[i] = torch.tensor(pos, dtype=torch.float32)
435
- ref_element[i] = _PROTEIN_ELEMENT_TO_ATOMIC_NUM[element]
436
- ref_charge[i] = charge
437
- ref_atom_name_chars[i] = torch.tensor(
438
- _encode_atom_name(name), dtype=torch.int64
439
- )
440
- ref_space_uid[i] = t_idx
441
- atom_attention_mask[i] = True
442
- atom_to_token[i] = t_idx
443
-
444
- token_index = torch.arange(L, dtype=torch.int64)
445
- residue_index = torch.arange(L, dtype=torch.int64)
446
- asym_id = torch.zeros(L, dtype=torch.int64)
447
- sym_id = torch.zeros(L, dtype=torch.int64)
448
- entity_id = torch.ones(L, dtype=torch.int64)
449
- mol_type = torch.full((L,), MOL_TYPE_PROTEIN, dtype=torch.int64)
450
- res_type = torch.tensor(res_type_vals, dtype=torch.int64)
451
- input_ids = torch.tensor(input_id_vals, dtype=torch.int64)
452
- token_bonds = torch.zeros(L, L, 1, dtype=torch.float32)
453
- token_attention_mask = torch.ones(L, dtype=torch.bool)
454
- distogram_atom_idx = torch.tensor(distogram_rep_atom_idx, dtype=torch.int64)
455
-
456
- # Single-sequence MSA: depth 1, row 0 is the sequence itself.
457
- msa = res_type.unsqueeze(0)
458
- msa_attention_mask = torch.ones(1, L, dtype=torch.bool)
459
- has_deletion = torch.zeros(1, L, dtype=torch.bool)
460
- deletion_value = torch.zeros(1, L, dtype=torch.float32)
461
- deletion_mean = torch.zeros(L, dtype=torch.float32)
462
-
463
- features = {
464
- "token_index": token_index,
465
- "residue_index": residue_index,
466
- "asym_id": asym_id,
467
- "sym_id": sym_id,
468
- "entity_id": entity_id,
469
- "mol_type": mol_type,
470
- "res_type": res_type,
471
- "input_ids": input_ids,
472
- "token_bonds": token_bonds,
473
- "token_attention_mask": token_attention_mask,
474
- "ref_pos": ref_pos,
475
- "ref_element": ref_element,
476
- "ref_charge": ref_charge,
477
- "ref_atom_name_chars": ref_atom_name_chars,
478
- "ref_space_uid": ref_space_uid,
479
- "atom_attention_mask": atom_attention_mask,
480
- "atom_to_token": atom_to_token,
481
- "distogram_atom_idx": distogram_atom_idx,
482
- "msa": msa,
483
- "msa_attention_mask": msa_attention_mask,
484
- "has_deletion": has_deletion,
485
- "deletion_value": deletion_value,
486
- "deletion_mean": deletion_mean,
487
- }
488
- return {k: v.unsqueeze(0) for k, v in features.items()}
 
1
+ # coding=utf-8
2
+ # Copyright 2026 Biohub. All rights reserved.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ """Self-contained protein featurization for ESMFold2 inference.
16
+
17
+ Lets ``ESMFold2ExperimentalModel.infer_protein_as_pdb`` fold a protein sequence
18
+ ESMFold-style without the ``esm`` companion package. The featurization
19
+ mirrors ``ESMFold2InputBuilder.prepare_input`` for the protein-only path —
20
+ ``test_prepare_protein_features.py`` enforces tensor-exact parity.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+
27
+ import torch
28
+ from torch import Tensor
29
+
30
+ MOL_TYPE_PROTEIN = 0
31
+ PROTEIN_UNK_RES_TYPE = 22
32
+ MSA_GAP_TOKEN_ID = 1
33
+
34
+ PROTEIN_RESIDUE_TO_RES_TYPE: dict[str, int] = {
35
+ "ALA": 2,
36
+ "ARG": 3,
37
+ "ASN": 4,
38
+ "ASP": 5,
39
+ "CYS": 6,
40
+ "GLN": 7,
41
+ "GLU": 8,
42
+ "GLY": 9,
43
+ "HIS": 10,
44
+ "ILE": 11,
45
+ "LEU": 12,
46
+ "LYS": 13,
47
+ "MET": 14,
48
+ "PHE": 15,
49
+ "PRO": 16,
50
+ "SER": 17,
51
+ "THR": 18,
52
+ "TRP": 19,
53
+ "TYR": 20,
54
+ "VAL": 21,
55
+ }
56
+
57
+ PROTEIN_1TO3: dict[str, str] = {
58
+ "A": "ALA",
59
+ "R": "ARG",
60
+ "N": "ASN",
61
+ "D": "ASP",
62
+ "C": "CYS",
63
+ "Q": "GLN",
64
+ "E": "GLU",
65
+ "G": "GLY",
66
+ "H": "HIS",
67
+ "I": "ILE",
68
+ "L": "LEU",
69
+ "K": "LYS",
70
+ "M": "MET",
71
+ "F": "PHE",
72
+ "P": "PRO",
73
+ "S": "SER",
74
+ "T": "THR",
75
+ "W": "TRP",
76
+ "Y": "TYR",
77
+ "V": "VAL",
78
+ "X": "UNK",
79
+ }
80
+
81
+ ESM_PROTEIN_VOCAB: dict[str, int] = {
82
+ "L": 4,
83
+ "A": 5,
84
+ "G": 6,
85
+ "V": 7,
86
+ "S": 8,
87
+ "E": 9,
88
+ "R": 10,
89
+ "T": 11,
90
+ "I": 12,
91
+ "D": 13,
92
+ "P": 14,
93
+ "K": 15,
94
+ "Q": 16,
95
+ "N": 17,
96
+ "F": 18,
97
+ "Y": 19,
98
+ "M": 20,
99
+ "H": 21,
100
+ "W": 22,
101
+ "C": 23,
102
+ "X": 3,
103
+ }
104
+
105
+ # Heavy atoms per canonical residue, in training-time order.
106
+ PROTEIN_HEAVY_ATOMS: dict[str, list[str]] = {
107
+ "ALA": ["N", "CA", "C", "O", "CB"],
108
+ "ARG": ["N", "CA", "C", "O", "CB", "CG", "CD", "NE", "CZ", "NH1", "NH2"],
109
+ "ASN": ["N", "CA", "C", "O", "CB", "CG", "OD1", "ND2"],
110
+ "ASP": ["N", "CA", "C", "O", "CB", "CG", "OD1", "OD2"],
111
+ "CYS": ["N", "CA", "C", "O", "CB", "SG"],
112
+ "GLN": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "NE2"],
113
+ "GLU": ["N", "CA", "C", "O", "CB", "CG", "CD", "OE1", "OE2"],
114
+ "GLY": ["N", "CA", "C", "O"],
115
+ "HIS": ["N", "CA", "C", "O", "CB", "CG", "ND1", "CD2", "CE1", "NE2"],
116
+ "ILE": ["N", "CA", "C", "O", "CB", "CG1", "CG2", "CD1"],
117
+ "LEU": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2"],
118
+ "LYS": ["N", "CA", "C", "O", "CB", "CG", "CD", "CE", "NZ"],
119
+ "MET": ["N", "CA", "C", "O", "CB", "CG", "SD", "CE"],
120
+ "PHE": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ"],
121
+ "PRO": ["N", "CA", "C", "O", "CB", "CG", "CD"],
122
+ "SER": ["N", "CA", "C", "O", "CB", "OG"],
123
+ "THR": ["N", "CA", "C", "O", "CB", "OG1", "CG2"],
124
+ "TRP": [
125
+ "N",
126
+ "CA",
127
+ "C",
128
+ "O",
129
+ "CB",
130
+ "CG",
131
+ "CD1",
132
+ "CD2",
133
+ "NE1",
134
+ "CE2",
135
+ "CE3",
136
+ "CZ2",
137
+ "CZ3",
138
+ "CH2",
139
+ ],
140
+ "TYR": ["N", "CA", "C", "O", "CB", "CG", "CD1", "CD2", "CE1", "CE2", "CZ", "OH"],
141
+ "VAL": ["N", "CA", "C", "O", "CB", "CG1", "CG2"],
142
+ "UNK": ["N", "CA", "C", "O"],
143
+ }
144
+
145
+ PROTEIN_REF_POS: dict[str, dict[str, tuple[float, float, float]]] = {
146
+ "ALA": {
147
+ "N": (-0.01003183238208294, -1.2073018550872803, -1.0555061101913452),
148
+ "CA": (-0.04190138354897499, 0.17447763681411743, -0.5729365348815918),
149
+ "C": (1.2127548456192017, 0.4737588167190552, 0.19521640241146088),
150
+ "O": (1.9390329122543335, 1.4484562873840332, -0.13759790360927582),
151
+ "CB": (-1.276943325996399, 0.4288230538368225, 0.29937705397605896),
152
+ },
153
+ "ARG": {
154
+ "N": (-2.0170421600341797, 0.6717798113822937, -1.1794233322143555),
155
+ "CA": (-2.0503084659576416, -0.5735036730766296, -0.4097220301628113),
156
+ "C": (-3.469440460205078, -1.0612813234329224, -0.2755832374095917),
157
+ "O": (-3.8218462467193604, -2.1369943618774414, -0.8294969797134399),
158
+ "CB": (-1.4193516969680786, -0.3735991418361664, 0.9852858781814575),
159
+ "CG": (0.11878877878189087, -0.3112654983997345, 0.963895857334137),
160
+ "CD": (0.6643245816230774, 1.0068185329437256, 0.3963329493999481),
161
+ "NE": (2.1090238094329834, 1.0977025032043457, 0.6120952367782593),
162
+ "CZ": (3.098905324935913, 0.3215920031070709, -0.09047172218561172),
163
+ "NH1": (4.461230278015137, 0.3844667971134186, 0.34141138195991516),
164
+ "NH2": (2.7856509685516357, -0.4166366159915924, -1.1148239374160767),
165
+ },
166
+ "ASN": {
167
+ "N": (-0.7595629096031189, 0.7503494620323181, 1.1369825601577759),
168
+ "CA": (-0.76087886095047, 0.23876343667507172, -0.23573364317417145),
169
+ "C": (-1.9211044311523438, -0.6982439160346985, -0.42196929454803467),
170
+ "O": (-2.677666187286377, -0.5753439664840698, -1.4223182201385498),
171
+ "CB": (0.5504899024963379, -0.5078350305557251, -0.5390339493751526),
172
+ "CG": (1.7250099182128906, 0.4264017939567566, -0.5778228640556335),
173
+ "OD1": (1.9470350742340088, 1.1086392402648926, -1.613560438156128),
174
+ "ND2": (2.57365345954895, 0.5730618834495544, 0.5608599781990051),
175
+ },
176
+ "ASP": {
177
+ "N": (-1.8452696800231934, -1.2169504165649414, 0.19437327980995178),
178
+ "CA": (-0.6379959583282471, -0.41974392533302307, 0.41681644320487976),
179
+ "C": (-0.9431572556495667, 1.0356197357177734, 0.18555717170238495),
180
+ "O": (-1.5183608531951904, 1.4045922756195068, -0.8739855885505676),
181
+ "CB": (0.48594576120376587, -0.8970447778701782, -0.5209363698959351),
182
+ "CG": (1.780342936515808, -0.19918935000896454, -0.2310730367898941),
183
+ "OD1": (2.5202910900115967, -0.6044584512710571, 0.7049641013145447),
184
+ "OD2": (2.1454880237579346, 0.9208861589431763, -0.9712985157966614),
185
+ },
186
+ "CYS": {
187
+ "N": (0.0469963513314724, 1.190075159072876, -1.1607273817062378),
188
+ "CA": (0.11344368755817413, -0.09400428831577301, -0.45952197909355164),
189
+ "C": (-1.2652032375335693, -0.6832379698753357, -0.3594406247138977),
190
+ "O": (-1.4631439447402954, -1.8851220607757568, -0.6826791763305664),
191
+ "CB": (0.6919880509376526, 0.09034398198127747, 0.952482283115387),
192
+ "SG": (2.4619927406311035, 0.5235707759857178, 0.9020372629165649),
193
+ },
194
+ "GLN": {
195
+ "N": (-2.370004653930664, -0.9637529850006104, -0.7942749261856079),
196
+ "CA": (-1.370002269744873, -0.6000258922576904, 0.2103111445903778),
197
+ "C": (-1.7545503377914429, 0.7091967463493347, 0.8433493971824646),
198
+ "O": (-1.8520662784576416, 0.7999289631843567, 2.0964975357055664),
199
+ "CB": (0.02040259726345539, -0.5004461407661438, -0.44764479994773865),
200
+ "CG": (1.1377512216567993, -0.28680720925331116, 0.582992434501648),
201
+ "CD": (2.4745187759399414, -0.24800164997577667, -0.09364881366491318),
202
+ "OE1": (3.1685523986816406, -1.2966246604919434, -0.1717153936624527),
203
+ "NE2": (2.947425603866577, 0.9601329565048218, -0.6888364553451538),
204
+ },
205
+ "GLU": {
206
+ "N": (-1.5850872993469238, -1.337684154510498, 0.9490851163864136),
207
+ "CA": (-1.0560977458953857, 0.027459044009447098, 1.0306966304779053),
208
+ "C": (-1.7741456031799316, 0.9664392471313477, 0.09259600937366486),
209
+ "O": (-1.9012441635131836, 2.181349992752075, 0.402479350566864),
210
+ "CB": (0.4706551432609558, 0.048803869634866714, 0.8114414811134338),
211
+ "CG": (0.9133604764938354, -0.4219329059123993, -0.5830985307693481),
212
+ "CD": (2.398822069168091, -0.3097084164619446, -0.7210537791252136),
213
+ "OE1": (3.1389315128326416, -1.274524450302124, -0.39029765129089355),
214
+ "OE2": (2.9647817611694336, 0.8781346082687378, -1.1732689142227173),
215
+ },
216
+ "GLY": {
217
+ "N": (-1.3942985534667969, -0.39875128865242004, -0.3370324671268463),
218
+ "CA": (-0.39974430203437805, 0.5488945245742798, 0.15242962539196014),
219
+ "C": (0.9440054893493652, -0.10314033925533295, 0.19859643280506134),
220
+ "O": (1.3352899551391602, -0.669218122959137, 1.2541258335113525),
221
+ },
222
+ "HIS": {
223
+ "N": (-1.4532867670059204, -1.0689626932144165, 0.881072461605072),
224
+ "CA": (-1.3396095037460327, 0.24797579646110535, 0.24960045516490936),
225
+ "C": (-2.675257921218872, 0.6571555733680725, -0.30441102385520935),
226
+ "O": (-3.1311378479003906, 1.8079776763916016, -0.06785715371370316),
227
+ "CB": (-0.3041955828666687, 0.21721023321151733, -0.8885309100151062),
228
+ "CG": (1.0887513160705566, 0.028941065073013306, -0.36419469118118286),
229
+ "ND1": (1.840459942817688, 1.0411773920059204, 0.29804590344429016),
230
+ "CD2": (1.780855417251587, -1.1011489629745483, -0.3814258575439453),
231
+ "CE1": (2.9566943645477295, 0.4924798905849457, 0.6477115750312805),
232
+ "NE2": (3.0280203819274902, -0.8751969337463379, 0.26084381341934204),
233
+ },
234
+ "ILE": {
235
+ "N": (-0.7167549729347229, -1.5426139831542969, -0.9983330368995667),
236
+ "CA": (-1.0636085271835327, -0.35169270634651184, -0.21393552422523499),
237
+ "C": (-1.3896740674972534, 0.8142145276069641, -1.1164065599441528),
238
+ "O": (-1.2377792596817017, 0.7302915453910828, -2.3656840324401855),
239
+ "CB": (0.061667006462812424, 0.01599610224366188, 0.8057394623756409),
240
+ "CG1": (1.502519965171814, -0.08899776637554169, 0.24154816567897797),
241
+ "CG2": (-0.053174979984760284, -0.8521055579185486, 2.0702083110809326),
242
+ "CD1": (1.7929610013961792, 0.899773120880127, -0.8863027691841125),
243
+ },
244
+ "LEU": {
245
+ "N": (1.9657520055770874, -1.9763224124908447, -0.18391533195972443),
246
+ "CA": (1.3077669143676758, -0.6677430868148804, -0.19492436945438385),
247
+ "C": (1.9905058145523071, 0.24182087182998657, 0.7879968285560608),
248
+ "O": (2.06896710395813, -0.07880014181137085, 2.0048046112060547),
249
+ "CB": (-0.20306941866874695, -0.8093230128288269, 0.11243502795696259),
250
+ "CG": (-0.9916267395019531, 0.5234957337379456, 0.06723011285066605),
251
+ "CD1": (-2.4228057861328125, 0.29949337244033813, 0.573042094707489),
252
+ "CD2": (-1.0282856225967407, 1.1250264644622803, -1.346014380455017),
253
+ },
254
+ "LYS": {
255
+ "N": (2.4221372604370117, -0.6473312377929688, 0.6370573043823242),
256
+ "CA": (2.0314927101135254, 0.2786507308483124, -0.4298512041568756),
257
+ "C": (2.7168593406677246, 1.595757246017456, -0.20924785733222961),
258
+ "O": (3.397681713104248, 2.116427421569824, -1.1332510709762573),
259
+ "CB": (0.5018402934074402, 0.4873858690261841, -0.49062973260879517),
260
+ "CG": (-0.25062066316604614, -0.7894009947776794, -0.9055535793304443),
261
+ "CD": (-1.769762635231018, -0.5552700161933899, -1.040329933166504),
262
+ "CE": (-2.576533555984497, -1.0221366882324219, 0.18493641912937164),
263
+ "NZ": (-2.269151210784912, -0.24293844401836395, 1.3849012851715088),
264
+ },
265
+ "MET": {
266
+ "N": (1.8903918266296387, -1.5252995491027832, -0.42638593912124634),
267
+ "CA": (1.2630571126937866, -0.24417810142040253, -0.7626462578773499),
268
+ "C": (2.30391001701355, 0.8367712497711182, -0.7254616618156433),
269
+ "O": (2.465414524078369, 1.5928632020950317, -1.7207728624343872),
270
+ "CB": (0.10567972809076309, 0.10861825942993164, 0.19741646945476532),
271
+ "CG": (-1.0658042430877686, -0.8736631274223328, 0.08811883628368378),
272
+ "SD": (-2.4557132720947266, -0.3332225978374481, 1.1461700201034546),
273
+ "CE": (-3.265165090560913, 0.7033554911613464, -0.11588376015424728),
274
+ },
275
+ "PHE": {
276
+ "N": (-2.8484435081481934, -1.525790810585022, 0.01789816841483116),
277
+ "CA": (-1.591969609260559, -0.8545162677764893, 0.35214468836784363),
278
+ "C": (-1.8900631666183472, 0.45833414793014526, 1.0232222080230713),
279
+ "O": (-1.3424992561340332, 0.74432373046875, 2.121629476547241),
280
+ "CB": (-0.760358452796936, -0.6342853307723999, -0.9257160425186157),
281
+ "CG": (0.604112982749939, -0.07200468331575394, -0.6148118376731873),
282
+ "CD1": (0.8468314409255981, 1.2480632066726685, -0.7146694660186768),
283
+ "CD2": (1.6827683448791504, -0.9758077263832092, -0.1423054188489914),
284
+ "CE1": (2.1801748275756836, 1.7875733375549316, -0.3744623064994812),
285
+ "CE2": (2.888307809829712, -0.48277512192726135, 0.16804970800876617),
286
+ "CZ": (3.149812936782837, 0.9656873941421509, 0.04440271109342575),
287
+ },
288
+ "PRO": {
289
+ "N": (-0.836250364780426, -0.9899801015853882, 0.5561304688453674),
290
+ "CA": (0.32722190022468567, -0.6164458394050598, -0.25072571635246277),
291
+ "C": (1.6121541261672974, -1.1711241006851196, 0.31082412600517273),
292
+ "O": (1.6127740144729614, -2.2771971225738525, 0.9156193733215332),
293
+ "CB": (0.3248198926448822, 0.9028244018554688, -0.33368146419525146),
294
+ "CG": (-1.1425083875656128, 1.2730128765106201, -0.2590600252151489),
295
+ "CD": (-1.8495968580245972, 0.026575811207294464, 0.2681289613246918),
296
+ },
297
+ "SER": {
298
+ "N": (0.674650251865387, 1.5018702745437622, -0.5367295145988464),
299
+ "CA": (0.00013792862591799349, 0.4966467022895813, 0.28510504961013794),
300
+ "C": (0.9941009879112244, -0.5374617576599121, 0.73505038022995),
301
+ "O": (1.0545241832733154, -0.8683545589447021, 1.9495396614074707),
302
+ "CB": (-1.1279288530349731, -0.1659376323223114, -0.5160963535308838),
303
+ "OG": (-1.8135979175567627, -1.085249662399292, 0.28947514295578003),
304
+ },
305
+ "THR": {
306
+ "N": (-1.325830340385437, -1.3728225231170654, 0.6882233023643494),
307
+ "CA": (-0.5433306097984314, -0.16364754736423492, 0.41697052121162415),
308
+ "C": (-1.294381856918335, 0.7077372074127197, -0.5549946427345276),
309
+ "O": (-1.6939635276794434, 0.23654410243034363, -1.6540418863296509),
310
+ "CB": (0.853203296661377, -0.5363803505897522, -0.14109353721141815),
311
+ "OG1": (1.5220820903778076, -1.379003643989563, 0.7635167837142944),
312
+ "CG2": (1.7225933074951172, 0.7054727077484131, -0.3651331067085266),
313
+ },
314
+ "TRP": {
315
+ "N": (3.686030864715576, 0.7599999904632568, 0.496155709028244),
316
+ "CA": (2.384092092514038, 0.09079249948263168, 0.5325262546539307),
317
+ "C": (2.1113572120666504, -0.6121063232421875, -0.7733646035194397),
318
+ "O": (1.796526312828064, -1.8323148488998413, -0.7775964140892029),
319
+ "CB": (1.281521201133728, 1.1139036417007446, 0.8559791445732117),
320
+ "CG": (-0.04292375594377518, 0.44645074009895325, 1.0942792892456055),
321
+ "CD1": (-0.42329534888267517, -0.15470874309539795, 2.2227554321289062),
322
+ "CD2": (-1.1023900508880615, 0.2158389836549759, 0.11529432237148285),
323
+ "NE1": (-1.7030320167541504, -0.7665823101997375, 2.0595016479492188),
324
+ "CE2": (-2.045644998550415, -0.4881173074245453, 0.710669219493866),
325
+ "CE3": (-1.2173502445220947, 0.6102271676063538, -1.300106406211853),
326
+ "CZ2": (-3.256009340286255, -0.9164394736289978, -0.00984987337142229),
327
+ "CZ3": (-2.315925121307373, 0.2306906282901764, -1.9776310920715332),
328
+ "CH2": (-3.3817875385284424, -0.5677337646484375, -1.3032053709030151),
329
+ },
330
+ "TYR": {
331
+ "N": (-1.7900604009628296, -0.8409399390220642, 1.3180142641067505),
332
+ "CA": (-1.913882851600647, 0.23552845418453217, 0.330669641494751),
333
+ "C": (-3.347280740737915, 0.3588399887084961, -0.09830684959888458),
334
+ "O": (-3.967811346054077, -0.6449354290962219, -0.5423302054405212),
335
+ "CB": (-1.0093992948532104, 0.0004731413209810853, -0.8981552124023438),
336
+ "CG": (0.4520410895347595, 0.021162061020731926, -0.5305932760238647),
337
+ "CD1": (1.0992432832717896, 1.1877919435501099, -0.3579142987728119),
338
+ "CD2": (1.1803174018859863, -1.253401279449463, -0.31122180819511414),
339
+ "CE1": (2.5253450870513916, 1.1990256309509277, 0.029804613441228867),
340
+ "CE2": (2.471151113510132, -1.240687608718872, 0.043534230440855026),
341
+ "CZ": (3.180687665939331, 0.04672492295503616, 0.2214856892824173),
342
+ "OH": (4.523719787597656, 0.0671030730009079, 0.5877485871315002),
343
+ },
344
+ "VAL": {
345
+ "N": (0.5987519025802612, -1.569443702697754, -0.7379124760627747),
346
+ "CA": (0.6014357209205627, -0.10503966361284256, -0.6336286664009094),
347
+ "C": (1.8391697406768799, 0.4067850410938263, 0.06351757049560547),
348
+ "O": (2.3952062129974365, -0.2666190266609192, 0.9731166958808899),
349
+ "CB": (-0.694736897945404, 0.4259096384048462, 0.03581475466489792),
350
+ "CG1": (-1.9276031255722046, 0.09515828639268875, -0.8172357082366943),
351
+ "CG2": (-0.8938426971435547, -0.08640842139720917, 1.472349762916565),
352
+ },
353
+ "UNK": {
354
+ "N": (0.0, 0.0, 0.0),
355
+ "CA": (0.0, 0.0, 0.0),
356
+ "C": (0.0, 0.0, 0.0),
357
+ "O": (0.0, 0.0, 0.0),
358
+ },
359
+ }
360
+
361
+ # Protonated nitrogens at physiological pH (matches CHARGED_ATOMS in the
362
+ # opensource constants for the protein subset).
363
+ PROTEIN_CHARGED_ATOMS: dict[tuple[str, str], int] = {
364
+ ("LYS", "NZ"): 1,
365
+ ("ARG", "NH2"): 1,
366
+ ("HIS", "ND1"): 1,
367
+ }
368
+
369
+ # Only the elements that appear in canonical protein heavy atoms.
370
+ _PROTEIN_ELEMENT_TO_ATOMIC_NUM: dict[str, int] = {"C": 6, "N": 7, "O": 8, "S": 16}
371
+
372
+
373
+ def _encode_atom_name(name: str) -> list[int]:
374
+ padded = name.ljust(4)[:4]
375
+ return [ord(c) - 32 if c != " " else 0 for c in padded]
376
+
377
+
378
+ def prepare_protein_features(sequence: str) -> dict[str, Tensor]:
379
+ """Featurize a single protein sequence for ESMFold2ExperimentalModel.forward.
380
+
381
+ Returns the same keys with the same dtypes/shapes as
382
+ ``ESMFold2InputBuilder.prepare_input(StructurePredictionInput(...))``
383
+ restricted to a single-chain protein with no MSA, modifications,
384
+ distogram conditioning, or covalent bonds. All tensors have a
385
+ leading batch dim of 1; the caller is responsible for moving them
386
+ to the model device.
387
+ """
388
+ if not sequence:
389
+ raise ValueError("sequence must be non-empty")
390
+
391
+ res_3letter = [PROTEIN_1TO3.get(c, "UNK") for c in sequence]
392
+ L = len(sequence)
393
+
394
+ token_atom_starts: list[int] = []
395
+ atom_records: list[tuple[int, str, str, int, tuple[float, float, float]]] = []
396
+ res_type_vals: list[int] = []
397
+ input_id_vals: list[int] = []
398
+ distogram_rep_atom_idx: list[int] = []
399
+
400
+ atom_cursor = 0
401
+ for t_idx, (letter, res_3) in enumerate(zip(sequence, res_3letter)):
402
+ atom_names = PROTEIN_HEAVY_ATOMS[res_3]
403
+ res_type = PROTEIN_RESIDUE_TO_RES_TYPE.get(res_3, PROTEIN_UNK_RES_TYPE)
404
+ input_id = ESM_PROTEIN_VOCAB.get(letter, ESM_PROTEIN_VOCAB["X"])
405
+
406
+ token_atom_starts.append(atom_cursor)
407
+ for name in atom_names:
408
+ charge = PROTEIN_CHARGED_ATOMS.get((res_3, name), 0)
409
+ element = name[0] # protein heavy atoms are all single-letter C/N/O/S
410
+ ref_pos = PROTEIN_REF_POS[res_3][name]
411
+ atom_records.append((t_idx, name, element, charge, ref_pos))
412
+ atom_cursor += 1
413
+
414
+ rep_name = "CB" if "CB" in atom_names else "CA"
415
+ distogram_rep_atom_idx.append(
416
+ token_atom_starts[t_idx] + atom_names.index(rep_name)
417
+ )
418
+
419
+ res_type_vals.append(res_type)
420
+ input_id_vals.append(input_id)
421
+
422
+ n_real_atoms = len(atom_records)
423
+ n_atoms = math.ceil(n_real_atoms / 32) * 32 if n_real_atoms > 0 else 32
424
+
425
+ ref_pos = torch.zeros(n_atoms, 3, dtype=torch.float32)
426
+ ref_element = torch.zeros(n_atoms, dtype=torch.int64)
427
+ ref_charge = torch.zeros(n_atoms, dtype=torch.int8)
428
+ ref_atom_name_chars = torch.zeros(n_atoms, 4, dtype=torch.int64)
429
+ ref_space_uid = torch.zeros(n_atoms, dtype=torch.int64)
430
+ atom_attention_mask = torch.zeros(n_atoms, dtype=torch.bool)
431
+ atom_to_token = torch.zeros(n_atoms, dtype=torch.int64)
432
+
433
+ for i, (t_idx, name, element, charge, pos) in enumerate(atom_records):
434
+ ref_pos[i] = torch.tensor(pos, dtype=torch.float32)
435
+ ref_element[i] = _PROTEIN_ELEMENT_TO_ATOMIC_NUM[element]
436
+ ref_charge[i] = charge
437
+ ref_atom_name_chars[i] = torch.tensor(
438
+ _encode_atom_name(name), dtype=torch.int64
439
+ )
440
+ ref_space_uid[i] = t_idx
441
+ atom_attention_mask[i] = True
442
+ atom_to_token[i] = t_idx
443
+
444
+ token_index = torch.arange(L, dtype=torch.int64)
445
+ residue_index = torch.arange(L, dtype=torch.int64)
446
+ asym_id = torch.zeros(L, dtype=torch.int64)
447
+ sym_id = torch.zeros(L, dtype=torch.int64)
448
+ entity_id = torch.ones(L, dtype=torch.int64)
449
+ mol_type = torch.full((L,), MOL_TYPE_PROTEIN, dtype=torch.int64)
450
+ res_type = torch.tensor(res_type_vals, dtype=torch.int64)
451
+ input_ids = torch.tensor(input_id_vals, dtype=torch.int64)
452
+ token_bonds = torch.zeros(L, L, 1, dtype=torch.float32)
453
+ token_attention_mask = torch.ones(L, dtype=torch.bool)
454
+ distogram_atom_idx = torch.tensor(distogram_rep_atom_idx, dtype=torch.int64)
455
+
456
+ # Single-sequence MSA: depth 1, row 0 is the sequence itself.
457
+ msa = res_type.unsqueeze(0)
458
+ msa_attention_mask = torch.ones(1, L, dtype=torch.bool)
459
+ has_deletion = torch.zeros(1, L, dtype=torch.bool)
460
+ deletion_value = torch.zeros(1, L, dtype=torch.float32)
461
+ deletion_mean = torch.zeros(L, dtype=torch.float32)
462
+
463
+ features = {
464
+ "token_index": token_index,
465
+ "residue_index": residue_index,
466
+ "asym_id": asym_id,
467
+ "sym_id": sym_id,
468
+ "entity_id": entity_id,
469
+ "mol_type": mol_type,
470
+ "res_type": res_type,
471
+ "input_ids": input_ids,
472
+ "token_bonds": token_bonds,
473
+ "token_attention_mask": token_attention_mask,
474
+ "ref_pos": ref_pos,
475
+ "ref_element": ref_element,
476
+ "ref_charge": ref_charge,
477
+ "ref_atom_name_chars": ref_atom_name_chars,
478
+ "ref_space_uid": ref_space_uid,
479
+ "atom_attention_mask": atom_attention_mask,
480
+ "atom_to_token": atom_to_token,
481
+ "distogram_atom_idx": distogram_atom_idx,
482
+ "msa": msa,
483
+ "msa_attention_mask": msa_attention_mask,
484
+ "has_deletion": has_deletion,
485
+ "deletion_value": deletion_value,
486
+ "deletion_mean": deletion_mean,
487
+ }
488
+ return {k: v.unsqueeze(0) for k, v in features.items()}