lhallee commited on
Commit
f256290
·
verified ·
1 Parent(s): a81063d

Update FastPLMs files from Synthyra/FastPLMs 32d9951 (PR 52: ESMFold2 noise cap, DPLM2 parity)

Browse files
Files changed (39) hide show
  1. README.md +2 -2
  2. fastplms/atomic_files.py +65 -0
  3. fastplms/digests.py +35 -0
  4. fastplms/json_files.py +45 -0
  5. fastplms/models.toml +366 -39
  6. fastplms/models/boltz/__init__.py +16 -16
  7. fastplms/models/boltz/_pair_attention.py +51 -51
  8. fastplms/models/boltz/cif_writer.py +150 -150
  9. fastplms/models/boltz/minimal_featurizer.py +853 -853
  10. fastplms/models/boltz/minimal_structures.py +21 -21
  11. fastplms/models/boltz/modeling_boltz2.py +1066 -1061
  12. fastplms/models/boltz/vb_const.py +353 -353
  13. fastplms/models/boltz/vb_layers_attention.py +103 -103
  14. fastplms/models/boltz/vb_layers_attentionv2.py +76 -76
  15. fastplms/models/boltz/vb_layers_confidence_utils.py +273 -273
  16. fastplms/models/boltz/vb_layers_dropout.py +43 -43
  17. fastplms/models/boltz/vb_layers_initialize.py +110 -110
  18. fastplms/models/boltz/vb_layers_outer_product_mean.py +105 -105
  19. fastplms/models/boltz/vb_layers_pair_averaging.py +112 -112
  20. fastplms/models/boltz/vb_layers_pairformer.py +350 -350
  21. fastplms/models/boltz/vb_layers_transition.py +79 -79
  22. fastplms/models/boltz/vb_layers_triangular_mult.py +132 -132
  23. fastplms/models/boltz/vb_loss_diffusionv2.py +186 -186
  24. fastplms/models/boltz/vb_modules_confidencev2.py +534 -534
  25. fastplms/models/boltz/vb_modules_diffusion_conditioning.py +147 -147
  26. fastplms/models/boltz/vb_modules_diffusionv2.py +675 -675
  27. fastplms/models/boltz/vb_modules_encodersv2.py +653 -653
  28. fastplms/models/boltz/vb_modules_transformersv2.py +276 -276
  29. fastplms/models/boltz/vb_modules_trunkv2.py +624 -624
  30. fastplms/models/boltz/vb_modules_utils.py +283 -283
  31. fastplms/models/boltz/vb_potentials_potentials.py +896 -896
  32. fastplms/models/boltz/vb_potentials_schedules.py +68 -68
  33. fastplms/models/boltz/vb_tri_attn_attention.py +151 -151
  34. fastplms/models/boltz/vb_tri_attn_primitives.py +266 -266
  35. fastplms/models/boltz/vb_tri_attn_utils.py +341 -341
  36. fastplms/registry.py +192 -2
  37. fastplms_bundle.py +0 -0
  38. modeling_fastplms.py +1 -1
  39. requirements.txt +18 -18
README.md CHANGED
@@ -32,7 +32,7 @@ python -m pip install -r \
32
  The FastPLMs implementation itself is embedded in the model repository.
33
  Transformers loads it through `trust_remote_code=True`.
34
 
35
- This model requires Python 3.11-3.14, PyTorch 2.13, and Transformers 5.13.
36
 
37
  The artifact requirements include the structure dependencies.
38
 
@@ -74,7 +74,7 @@ materialize attention tensors. The configured backend does not change.
74
  Install the training dependencies. Then attach LoRA to the loaded checkpoint:
75
 
76
  ```bash
77
- python -m pip install "datasets>=4.8,<5" "peft>=0.19,<0.20"
78
  ```
79
 
80
  ```python
 
32
  The FastPLMs implementation itself is embedded in the model repository.
33
  Transformers loads it through `trust_remote_code=True`.
34
 
35
+ This model requires Python 3.12-3.14, PyTorch 2.14, and Transformers 5.17.
36
 
37
  The artifact requirements include the structure dependencies.
38
 
 
74
  Install the training dependencies. Then attach LoRA to the loaded checkpoint:
75
 
76
  ```bash
77
+ python -m pip install "datasets>=5.0" "peft>=0.21"
78
  ```
79
 
80
  ```python
fastplms/atomic_files.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Replace a file in one step: readers see the old bytes or the new bytes, never a partial write."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import IO, Any
12
+
13
+
14
+ def write_bytes_atomically(path: Path, payload: bytes, *, create_parent: bool = False) -> None:
15
+ """Write ``payload`` to a temporary file beside ``path``, flush it to disk, then rename it over ``path``.
16
+
17
+ A failed write removes the temporary file and leaves ``path`` untouched. The parent directory must
18
+ exist unless ``create_parent`` is true. The temporary file sits in the same directory so the rename
19
+ never crosses a file system.
20
+ """
21
+
22
+ with _staged_handle(path, "wb", create_parent=create_parent) as handle:
23
+ handle.write(payload)
24
+
25
+
26
+ def write_text_atomically(
27
+ path: Path,
28
+ text: str,
29
+ *,
30
+ encoding: str = "utf-8",
31
+ newline: str | None = None,
32
+ create_parent: bool = False,
33
+ ) -> None:
34
+ """Write ``text`` as ``write_bytes_atomically`` does.
35
+
36
+ ``newline`` is the argument of ``open``: ``None`` translates ``"\\n"`` to the platform separator and
37
+ ``"\\n"`` writes it unchanged.
38
+ """
39
+
40
+ with _staged_handle(
41
+ path, "w", create_parent=create_parent, encoding=encoding, newline=newline
42
+ ) as handle:
43
+ handle.write(text)
44
+
45
+
46
+ @contextmanager
47
+ def _staged_handle(
48
+ path: Path, mode: str, *, create_parent: bool, **open_arguments: Any
49
+ ) -> Iterator[IO[Any]]:
50
+ """Yield a handle to a temporary file; on a clean exit flush it and rename it over ``path``."""
51
+
52
+ if create_parent:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ descriptor, temporary_name = tempfile.mkstemp(
55
+ prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
56
+ )
57
+ try:
58
+ with os.fdopen(descriptor, mode, **open_arguments) as handle:
59
+ yield handle
60
+ handle.flush()
61
+ os.fsync(handle.fileno())
62
+ os.replace(temporary_name, path)
63
+ except BaseException:
64
+ Path(temporary_name).unlink(missing_ok=True)
65
+ raise
fastplms/digests.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SHA-256 digests of files and JSON values, the identities FastPLMs records and compares.
2
+
3
+ This file exists twice, byte for byte: here and as ``features/digests.py``. ``features`` loads as a standalone
4
+ package (``foundry.embedding.private_store``) and imports nothing outside itself, so it cannot reach this
5
+ module. ``tests/tier1_unit/test_features_package_is_self_contained.py`` fails when the two differ.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .json_files import compact_json
16
+
17
+
18
+ FILE_READ_BYTES = 1024 * 1024
19
+
20
+
21
+ def file_sha256(path: str | Path) -> str:
22
+ """Return the SHA-256 of a file's bytes, read in blocks so a checkpoint never sits in memory."""
23
+
24
+ digest = hashlib.sha256()
25
+ with Path(path).open("rb") as handle:
26
+ while block := handle.read(FILE_READ_BYTES):
27
+ digest.update(block)
28
+ return digest.hexdigest()
29
+
30
+
31
+ def json_sha256(value: Any, *, ensure_ascii: bool = True, allow_nan: bool = True) -> str:
32
+ """Return the SHA-256 of ``value`` in its compact, key-sorted JSON form (``compact_json``)."""
33
+
34
+ encoded = compact_json(value, ensure_ascii=ensure_ascii, allow_nan=allow_nan).encode("utf-8")
35
+ return hashlib.sha256(encoded).hexdigest()
fastplms/json_files.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two JSON text forms FastPLMs writes: compact for hashing, indented for files people read.
2
+
3
+ This file exists twice, byte for byte: here and as ``features/json_files.py``. ``features`` loads as a
4
+ standalone package (``foundry.embedding.private_store``) and imports nothing outside itself, so it cannot
5
+ reach this module. ``tests/tier1_unit/test_features_package_is_self_contained.py`` fails when the two differ.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from typing import Any
13
+
14
+
15
+ def compact_json(value: Any, *, ensure_ascii: bool = True, allow_nan: bool = True) -> str:
16
+ """Serialize with sorted keys and no whitespace, the form a digest or an identity is taken over."""
17
+
18
+ return json.dumps(
19
+ value,
20
+ sort_keys=True,
21
+ separators=(",", ":"),
22
+ ensure_ascii=ensure_ascii,
23
+ allow_nan=allow_nan,
24
+ )
25
+
26
+
27
+ def indented_json(
28
+ value: Any,
29
+ *,
30
+ ensure_ascii: bool = True,
31
+ allow_nan: bool = True,
32
+ sort_keys: bool = True,
33
+ ) -> str:
34
+ """Serialize with two-space indentation and one trailing newline, the form of a stored JSON file."""
35
+
36
+ return (
37
+ json.dumps(
38
+ value,
39
+ indent=2,
40
+ sort_keys=sort_keys,
41
+ ensure_ascii=ensure_ascii,
42
+ allow_nan=allow_nan,
43
+ )
44
+ + "\n"
45
+ )
fastplms/models.toml CHANGED
@@ -4,11 +4,58 @@ legal_files = [
4
  "THIRD_PARTY_NOTICES.md=sha256:d35e506b728868b52290672d54a89c6aade09529af99dbc8a4db72d9e9ca3460",
5
  ]
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  [[attention_kernels]]
8
  implementation = "flash_attention_2"
9
  repository = "kernels-community/flash-attn2"
10
- revision = "db6b51744f0cd7061386442c09df890fc6d9f47e"
11
- version = 2
12
  expected_variant = "flash_attn2"
13
  dtypes = ["bfloat16"]
14
  min_cuda_capability = [8, 0]
@@ -64,6 +111,9 @@ distribution_files = [
64
  id = "biohub-transformers"
65
  path = "vendor/upstream/biohub-transformers"
66
  url = "https://github.com/Biohub/transformers.git"
 
 
 
67
  revision = "3a8956fb4d4ea16b0ec8e71deef2c2909b6a5cbf"
68
  license = "Apache-2.0"
69
  license_files = ["LICENSE"]
@@ -177,7 +227,7 @@ conversion_provenance = "Input: the pinned official ESM2 state dictionary. Trans
177
  representative = "esm2_8m"
178
  documentation = "docs/models.md#esm2"
179
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
180
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/esm2", "models/ttt.py"]
181
  auto_map = { AutoConfig = "fastplms.models.esm2.modeling_fastesm.FastEsmConfig", AutoModel = "fastplms.models.esm2.modeling_fastesm.FastEsmModel", AutoModelForMaskedLM = "fastplms.models.esm2.modeling_fastesm.FastEsmForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForTokenClassification" }
182
 
183
  [families.esm_plusplus]
@@ -204,7 +254,7 @@ conversion_provenance = "Input: the pinned Biohub ESMC checkpoint. Transformatio
204
  representative = "esmc_small"
205
  documentation = "docs/models.md#esm-and-esmc"
206
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
207
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm_plusplus", "models/ttt.py"]
208
  auto_map = { AutoConfig = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusConfig", AutoModel = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusModel", AutoModelForMaskedLM = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForTokenClassification" }
209
 
210
  [families.esm3]
@@ -229,7 +279,7 @@ conversion_provenance = "Input: the pinned Biohub ESM3 checkpoint. Transformatio
229
  representative = "esm3_small"
230
  documentation = "docs/models.md#esm3"
231
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
232
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/esm3", "models/ttt.py"]
233
  auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model", AutoModelForSequenceClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForTokenClassification" }
234
 
235
  [families.e1]
@@ -256,7 +306,7 @@ conversion_provenance = "Input: the pinned Profluent-E1 checkpoint and tokenizer
256
  representative = "e1_150m"
257
  documentation = "docs/models.md#e1"
258
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
259
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/e1", "models/ttt.py"]
260
  auto_map = { AutoConfig = "fastplms.models.e1.modeling_e1.E1Config", AutoModel = "fastplms.models.e1.modeling_e1.E1Model", AutoModelForMaskedLM = "fastplms.models.e1.modeling_e1.E1ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.e1.modeling_e1.E1ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.e1.modeling_e1.E1ForTokenClassification" }
261
 
262
  [families.dplm]
@@ -282,7 +332,7 @@ conversion_provenance = "Input: the pinned official DPLM1 checkpoint. Transforma
282
  representative = "dplm_150m"
283
  documentation = "docs/models.md#dplm"
284
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
285
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm", "models/ttt.py"]
286
  auto_map = { AutoConfig = "fastplms.models.dplm.modeling_dplm.DPLMConfig", AutoModel = "fastplms.models.dplm.modeling_dplm.DPLMModel", AutoModelForMaskedLM = "fastplms.models.dplm.modeling_dplm.DPLMForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm.modeling_dplm.DPLMForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm.modeling_dplm.DPLMForTokenClassification" }
287
 
288
  [families.dplm2]
@@ -307,7 +357,7 @@ conversion_provenance = "Input: the pinned official DPLM2 checkpoint. Transforma
307
  representative = "dplm2_150m"
308
  documentation = "docs/models.md#dplm2"
309
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
310
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm2", "models/ttt.py"]
311
  auto_map = { AutoConfig = "fastplms.models.dplm2.modeling_dplm2.DPLM2Config", AutoModel = "fastplms.models.dplm2.modeling_dplm2.DPLM2Model", AutoModelForMaskedLM = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForTokenClassification" }
312
  tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer"
313
 
@@ -334,7 +384,7 @@ representative = "ankh_base"
334
  documentation = "docs/models.md#ankh"
335
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
336
  requires_complete_weight_publication = false
337
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/ankh", "models/ttt.py"]
338
  auto_map = { AutoConfig = "fastplms.models.ankh.modeling_ankh.FastAnkhConfig", AutoModel = "fastplms.models.ankh.modeling_ankh.FastAnkhModel", AutoModelForMaskedLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForMaskedLMExtension", AutoModelForSeq2SeqLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForConditionalGeneration", AutoModelForSequenceClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForTokenClassification" }
339
 
340
  [families.boltz2]
@@ -358,7 +408,7 @@ conversion_provenance = "Input: the pinned official Boltz2 checkpoint. Transform
358
  representative = "boltz2"
359
  documentation = "docs/models.md#boltz2"
360
  test_tiers = ["structure", "artifact", "benchmark"]
361
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "models/boltz"]
362
  auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" }
363
 
364
  [families.esmfold]
@@ -383,7 +433,7 @@ conversion_provenance = "Input: the pinned native Meta ESMFold checkpoint plus i
383
  representative = "esmfold"
384
  documentation = "docs/models.md#esmfold"
385
  test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
386
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/_esm_rotary.py", "models/classification_probe.py", "models/esmfold"]
387
  auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding", AutoModelForSequenceClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForTokenClassification" }
388
 
389
  [families.esmfold2]
@@ -410,7 +460,7 @@ conversion_provenance = "Input: each pinned Biohub ESMFold2 checkpoint and its s
410
  representative = "esmfold2"
411
  documentation = "docs/esmfold2.md"
412
  test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
413
- runtime_paths = ["__init__.py", "registry.py", "runtime.py", "models.toml", "models/__init__.py", "attention", "embeddings", "models/classification_probe.py", "models/_esm_rotary.py", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"]
414
  auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForTokenClassification" }
415
 
416
  [[models]]
@@ -418,7 +468,7 @@ id = "esm2_8m"
418
  family = "esm2"
419
  size_category = "small"
420
  generation_contract = "not_applicable"
421
- official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:6975e86d1d8f27488bf2a676551feaa48cc19254c9d24b6acb09198122745609", tensors = "tests/goldens/esm2_8m.safetensors=sha256:b40217566c33c71988d28869de353be54a3b3ebfc21fdfd29056e88cf7e99f4c" }
422
  fast_repo = "Synthyra/ESM2-8M"
423
  fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0"
424
  fast_files = [
@@ -457,7 +507,7 @@ id = "esm2_35m"
457
  family = "esm2"
458
  size_category = "small"
459
  generation_contract = "not_applicable"
460
- official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:e919d3ce6d20b6a942d27d92323814ae7594a0129dc9c4de27c5053e96675bcd", tensors = "tests/goldens/esm2_35m.safetensors=sha256:c9b8bb616cf884fb7744521a2fcc6eed23586342d11241e6c9ef16454ec31e17" }
461
  fast_repo = "Synthyra/ESM2-35M"
462
  fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0"
463
  fast_files = [
@@ -496,7 +546,7 @@ id = "esm2_150m"
496
  family = "esm2"
497
  size_category = "medium"
498
  generation_contract = "not_applicable"
499
- official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:c04c93486024ba0fa1c81fbfbe92ee79d1d4c7f1cfcc2c9886728522f752feab", tensors = "tests/goldens/esm2_150m.safetensors=sha256:c03fe9916dba137b452a6bbe944c7dc414db4019a6f0921e87b92d4bb6a8a42f" }
500
  fast_repo = "Synthyra/ESM2-150M"
501
  fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a"
502
  fast_files = [
@@ -535,7 +585,7 @@ id = "esm2_650m"
535
  family = "esm2"
536
  size_category = "large"
537
  generation_contract = "not_applicable"
538
- official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:f18332172fcb3abf5dd2485fd55f5b0d193ad3b93a44cc744e0d02817c927477", tensors = "tests/goldens/esm2_650m.safetensors=sha256:c3a66b75add03628e62e238cb63da6a9e4d321f8160e84bdf2a131c096977f86" }
539
  fast_repo = "Synthyra/ESM2-650M"
540
  fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a"
541
  fast_files = [
@@ -574,7 +624,7 @@ id = "esm2_3b"
574
  family = "esm2"
575
  size_category = "xlarge"
576
  generation_contract = "not_applicable"
577
- official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:5043b2333c57a34d54fac53916722d1acb4b6fd50395b9abafa805435b184a48", tensors = "tests/goldens/esm2_3b.safetensors=sha256:dfd5a8cb05d3e814a080185c4808c8e7ec2277f070f395562fcfbe4376789e4e" }
578
  notes = "The pinned default SDPA BF16 path uses a checkpoint-specific numeric calibration: relative L2 target/hard limit 0.06/0.07, relative Q99.9 0.15/0.18, first-percentile residue cosine 0.994/0.992, and pooled cosine 0.998/0.997. Exact state identity and the global logits-distribution contract remain required."
579
  fast_repo = "Synthyra/ESM2-3B"
580
  fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456"
@@ -617,7 +667,7 @@ id = "esmc_small"
617
  family = "esm_plusplus"
618
  size_category = "medium"
619
  generation_contract = "not_applicable"
620
- official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:bb02652cf3cc484756b98ffa4ba55ed4c55870d2cea3342adb1d920ba9dfe10a", tensors = "tests/goldens/esmc_small.safetensors=sha256:03378d0f0fdd8161178ebb2c1f0da1b9776a726c8e8d3a10c009808a24de5654" }
621
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
622
  fast_repo = "Synthyra/ESMplusplus_small"
623
  fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb"
@@ -643,7 +693,7 @@ id = "esmc_large"
643
  family = "esm_plusplus"
644
  size_category = "large"
645
  generation_contract = "not_applicable"
646
- official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:7a4d614f67b6fde417f3fd89f61e7ec442ae284769734b2b73e14945a816a8fd", tensors = "tests/goldens/esmc_large.safetensors=sha256:e13302df4cf7e8381552f1043a8fd0f31f3e0d50b2ab6009fb86b7940ae8ff79" }
647
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
648
  fast_repo = "Synthyra/ESMplusplus_large"
649
  fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208"
@@ -669,7 +719,7 @@ id = "esmc_6b"
669
  family = "esm_plusplus"
670
  size_category = "xlarge"
671
  generation_contract = "not_applicable"
672
- official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:e229d938719782f280fab22dfc4c43e86109fdb0cc523631168c5a491afaace3", tensors = "tests/goldens/esmc_6b.safetensors=sha256:a948945e985c7deaca7be8b7eed09c0a9521a2af3f2b10fc2ec7a7d2a0f99ada" }
673
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
674
  fast_repo = "Synthyra/ESMplusplus_6B"
675
  fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8"
@@ -681,6 +731,7 @@ fast_files = [
681
  "model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758",
682
  "model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14",
683
  "model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3",
 
684
  "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
685
  "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
686
  "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
@@ -706,7 +757,7 @@ family = "esm3"
706
  tokenizer_source = "esmc_small"
707
  size_category = "large"
708
  generation_contract = "not_applicable"
709
- official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:5470e8596cbba0e2882647eccbc53c36d8b48b0f3947d1fe0bcea68da1078c32", tensors = "tests/goldens/esm3_small.safetensors=sha256:d957922f810c9ab4c557d80d5aaaf6a3aab79a5a45e4638012a634a4134803b1" }
710
  fast_repo = "Synthyra/ESM3_small"
711
  fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1"
712
  fast_files = [
@@ -732,7 +783,7 @@ id = "e1_150m"
732
  family = "e1"
733
  size_category = "small"
734
  generation_contract = "not_applicable"
735
- official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:701a64a6ab1a2fec5a427555b6af96232526c15cb3d5b4dc7fb253ac8f20b922", tensors = "tests/goldens/e1_150m.safetensors=sha256:6558bc8f1a7b20629eaaaa6f72601d0c2cdb859a5dc13595549b1773b6e2de41" }
736
  fast_repo = "Synthyra/Profluent-E1-150M"
737
  fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907"
738
  fast_files = [
@@ -751,7 +802,7 @@ id = "e1_300m"
751
  family = "e1"
752
  size_category = "medium"
753
  generation_contract = "not_applicable"
754
- official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d3478f3f5957a0e0377864074dde0107de890019f96cb63548ee17ffb8f3ec3a", tensors = "tests/goldens/e1_300m.safetensors=sha256:92778b9ef95a803ddc84b3e3ca764c59e045872a94bcff0eb0cd47647732c188" }
755
  fast_repo = "Synthyra/Profluent-E1-300M"
756
  fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26"
757
  fast_files = [
@@ -770,7 +821,7 @@ id = "e1_600m"
770
  family = "e1"
771
  size_category = "large"
772
  generation_contract = "not_applicable"
773
- official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:914be191c28141c1f84535cdb69ead0588a2057bb19d46c5bc7f3891a3d6739e", tensors = "tests/goldens/e1_600m.safetensors=sha256:22ed8417a4651ded255099f6d15c63c2c40552e700d2b0470d1adfde3a39c513" }
774
  fast_repo = "Synthyra/Profluent-E1-600M"
775
  fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742"
776
  fast_files = [
@@ -789,7 +840,7 @@ id = "dplm_150m"
789
  family = "dplm"
790
  size_category = "small"
791
  generation_contract = "required"
792
- official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:3228551fe3bed951db9ec97347143ec4462ce7c221ac240b7ce7730948c1dc1f", tensors = "tests/goldens/dplm_150m.safetensors=sha256:392992235195beed97ab8359b90a2e11e52f4326606f99a471447bed81d146bd" }
793
  fast_repo = "Synthyra/DPLM-150M"
794
  fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d"
795
  fast_files = [
@@ -814,7 +865,7 @@ id = "dplm_650m"
814
  family = "dplm"
815
  size_category = "large"
816
  generation_contract = "required"
817
- official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:bf58d0ce73aaac7e6fb1923ef3d9adad67122df2a3dd414c3229488ef9587a6d", tensors = "tests/goldens/dplm_650m.safetensors=sha256:073f0a6abea7e48f28c2d921ff8329a28e22627f01979277cb324908a01b3378" }
818
  fast_repo = "Synthyra/DPLM-650M"
819
  fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760"
820
  fast_files = [
@@ -839,7 +890,7 @@ id = "dplm_3b"
839
  family = "dplm"
840
  size_category = "xlarge"
841
  generation_contract = "required"
842
- official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:a5b6df8b9c7b371976892ec1d6c45581a32ad3a6325c6c0a0b3267012848c8ed", tensors = "tests/goldens/dplm_3b.safetensors=sha256:75b0a0854fc391133920b0feaaeb8f69ab7568a88b3759627aca1556c4338c1e" }
843
  fast_repo = "Synthyra/DPLM-3B"
844
  fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65"
845
  fast_files = [
@@ -869,7 +920,7 @@ id = "dplm2_150m"
869
  family = "dplm2"
870
  size_category = "small"
871
  generation_contract = "required"
872
- official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:d269de779ea1503de72c77e7b2e6224afc9797bd945b40c571ff6faec782e4aa", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:17fc26600938ba5364b8ecb96750786d33e9f92bcd4ea4df3e12a389340748eb" }
873
  artifact_source = "official"
874
  canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001"
875
  fast_repo = "Synthyra/DPLM2-150M"
@@ -896,7 +947,7 @@ id = "dplm2_650m"
896
  family = "dplm2"
897
  size_category = "large"
898
  generation_contract = "required"
899
- official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:d9a7548f9af657a72d441ca70f27379863724fcce8ddd3da4f672104b7bfb772", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c4e0e467c252c3ac813363d2d4b17a5e3bd99e75fad315e76d97689b4655ddac" }
900
  artifact_source = "official"
901
  canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4"
902
  fast_repo = "Synthyra/DPLM2-650M"
@@ -925,7 +976,7 @@ size_category = "xlarge"
925
  # The pinned public sampler fails before generation because cls_token_id is None.
926
  # State, tokenizer, and inference parity remain required for this checkpoint.
927
  generation_contract = "official_unavailable"
928
- official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:d6e0e02af53b13cb129192f06e264758aa21c9ebf4ee82411cf67037082d2329", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:838b11824d08f83bcb0c0b3268e579f3a87dbfb965370cfe5c3f8793b96b1964" }
929
  notes = "The pinned official DPLM2-3B sampler fails before generation, so live generation equivalence cannot be established for this checkpoint. State, tokenizer, and inference parity remain required."
930
  artifact_source = "official"
931
  canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890"
@@ -958,7 +1009,7 @@ id = "ankh_base"
958
  family = "ankh"
959
  size_category = "medium"
960
  generation_contract = "required"
961
- official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:ebce8d7de821827ee995789c9b38d79252d3b2f76888130b0a8a7eedafaefe2b", tensors = "tests/goldens/ankh_base.safetensors=sha256:f0e78aa15d11749e0c64ff57f9e88c51cec6538a0adf8951f839df70cc708b65" }
962
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
963
  artifact_source = "official"
964
  canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f"
@@ -987,7 +1038,7 @@ id = "ankh_large"
987
  family = "ankh"
988
  size_category = "large"
989
  generation_contract = "required"
990
- official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:59492518b021de5cfaea87d672c9448c8558e99a3443ba2cc7ab544963196ecb", tensors = "tests/goldens/ankh_large.safetensors=sha256:3fb8d3ac27716d15a9ea92aeef6acf2b977bcc887d9b535000539e523673459b" }
991
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
992
  artifact_source = "official"
993
  canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483"
@@ -1017,7 +1068,7 @@ id = "ankh2_large"
1017
  family = "ankh"
1018
  size_category = "large"
1019
  generation_contract = "required"
1020
- official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:e8df38994ca1a1e0c598ace34a0b257b264937e4fdbb01bc41544985116b02a4", tensors = "tests/goldens/ankh2_large.safetensors=sha256:25fe1569f55c635fab8fa49c1d62a889a35a2a738bad921f5764a85b58fd4b5d" }
1021
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1022
  artifact_source = "official"
1023
  canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3"
@@ -1049,7 +1100,7 @@ id = "ankh3_large"
1049
  family = "ankh"
1050
  size_category = "large"
1051
  generation_contract = "required"
1052
- official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:2e5bb05b3baa5baa78f61fef7d2a2c669b0da5dbfaf6b50b12abd3e17253a961", tensors = "tests/goldens/ankh3_large.safetensors=sha256:e5c494ac418e0a2fe7bdad1376676d48960d58ec9e044d19bfffccb8c3288513" }
1053
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1054
  artifact_source = "official"
1055
  canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a"
@@ -1083,7 +1134,7 @@ id = "ankh3_xl"
1083
  family = "ankh"
1084
  size_category = "xlarge"
1085
  generation_contract = "required"
1086
- official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:66bb12e033e4163be225d636108a479393228a4f5061015c8af114e766c3c486", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:72d34567d0228cb6f1ee701c578ed4039fead4346e3f161a52e0e74df28dc8ae" }
1087
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head. The official PyTorch shard index is deliberately excluded: the builder verifies every declared source shard directly and writes a new canonical safetensors index."
1088
  artifact_source = "official"
1089
  canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7"
@@ -1176,7 +1227,7 @@ family = "esmfold2"
1176
  size_category = "structure"
1177
  generation_contract = "not_applicable"
1178
  msa_conditioning = true
1179
- official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:f6e0ed1ec400b9a0fcc817db51774be968dc454b7a32645a07c479e42423ab20", tensors = "tests/goldens/esmfold2.safetensors=sha256:e4d6be4344c528e26b13f79a9303549e3de7e582da195c0078db3ce957fad420" }
1180
  fast_repo = "Synthyra/ESMFold2"
1181
  fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e"
1182
  fast_files = [
@@ -1196,7 +1247,7 @@ family = "esmfold2"
1196
  size_category = "structure"
1197
  generation_contract = "not_applicable"
1198
  msa_conditioning = false
1199
- official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:091b004c0b330217b59c12acd6da3d6edaf91e48d95f6d5f40fc20399cef9478", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:6e2e1cd07401538b4d9df994f82abe7a5b38a01e8d1ee26681e1216d44a81990" }
1200
  fast_repo = "Synthyra/ESMFold2-Fast"
1201
  fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170"
1202
  fast_files = [
@@ -1216,7 +1267,7 @@ family = "esmfold2"
1216
  size_category = "structure"
1217
  generation_contract = "not_applicable"
1218
  msa_conditioning = true
1219
- official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:cfd0e35b2bc468a0dc4f614d3acfa2fce004f96e9ae2433256ed095b829d55cc", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:9347466bbe803b6f5dc82e3356ca6cbbf2c2edd8765f9fd273385bda255019f6" }
1220
  fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025"
1221
  fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d"
1222
  fast_files = [
@@ -1237,7 +1288,7 @@ family = "esmfold2"
1237
  size_category = "structure"
1238
  generation_contract = "not_applicable"
1239
  msa_conditioning = false
1240
- official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:1d0b2da4f1579243f37ae04bd4b834b747005cd8e8e7665e00d088123c43afd9", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:516e216d05d7e6bee59e77126d3e595e2bb7821929433f00c259c5d5241964bb" }
1241
  fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025"
1242
  fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac"
1243
  fast_files = [
@@ -1254,6 +1305,7 @@ auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFo
1254
 
1255
  [[models]]
1256
  id = "esmfold2_300"
 
1257
  confidence_adaptation = { release = "v1", head_sha256 = "40fd7f3d82fcefe8ad20ab2b32a37a68a84b54a527a4bce6eb9437bad2b77e31", base_weight_sha256 = "44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd", donor_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025", donor_revision = "74b88548bf19688b8727432db0d698cb2e1d8783", donor_weight_sha256 = "4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", training_url = "https://wandb.ai/lhallee/fastplms-confidence/runs/9558b6d23daf", evaluation_url = "https://huggingface.co/datasets/Synthyra/FastPLMs-artifacts/tree/309f353b07e0e46de4d77a5266d4eddd695538e3/confidence-v2/v2-reproduction-20260922/public/evaluation/esmfold2_300", evidence_path = "docs/evidence/confidence/esmfold2_300-v1.json", frozen_base = { repo = "Synthyra/ESMFold2-300", revision = "a38a62ae930d157484b331c2bf4241684573adba", files = ["config.json=git-sha1:47ec20cf8b234c3b41d6f3ae1bdfe95d4eb4849e", "model.safetensors=sha256:44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd"] } }
1258
  family = "esmfold2"
1259
  size_category = "structure"
@@ -1273,6 +1325,7 @@ backbone = { repo = "biohub/ESMC-300M-1500000", revision = "56803b6378b82e16c3b2
1273
 
1274
  [[models]]
1275
  id = "esmfold2_600"
 
1276
  confidence_adaptation = { release = "v1", head_sha256 = "e84726a050722e1b722712c87d17a5388bd3699e2520e4a59abb1d828dfb8de7", base_weight_sha256 = "11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602", donor_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025", donor_revision = "74b88548bf19688b8727432db0d698cb2e1d8783", donor_weight_sha256 = "4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", training_url = "https://wandb.ai/lhallee/fastplms-confidence/runs/820d2cfa56c0", evaluation_url = "https://huggingface.co/datasets/Synthyra/FastPLMs-artifacts/tree/6e62186cd36b9047cc4691980076be9f76482192/confidence-v2/v2-reproduction-20260922/public/evaluation/esmfold2_600", evidence_path = "docs/evidence/confidence/esmfold2_600-v1.json", frozen_base = { repo = "Synthyra/ESMFold2-600", revision = "71c67d0b2b73dc245ea7c3cc0d0476439a882d08", files = ["config.json=git-sha1:8e271837cbdada96c4974c8e543f84065e0f06f1", "model.safetensors=sha256:11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602"] } }
1277
  family = "esmfold2"
1278
  size_category = "structure"
@@ -1289,3 +1342,277 @@ notes = "Experimental Fast model with a frozen 600M ESM++ backbone, 24 folding b
1289
  auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1290
  backbone_model = "esmc_large"
1291
  backbone = { repo = "biohub/ESMC-600M-1500000", revision = "21af9cc429af76ebda6c48074fb624db4735aaaf", files = ["config.json=git-sha1:ec29f6009b21d710f64bf1c058f3a9710833d692", "model.safetensors=sha256:d6869f5ae0f11e5dc829b195e062e87cfcc2f851a08a5edbaf5d1083ae7f76cc", "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", "tokenizer_config.json=git-sha1:f49f57b24a1c93bd544974811e8ecbd61b7fae89", "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b"] }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  "THIRD_PARTY_NOTICES.md=sha256:d35e506b728868b52290672d54a89c6aade09529af99dbc8a4db72d9e9ca3460",
5
  ]
6
 
7
+ # Released hidden-state SAEs used by the canonical suite. The training-backbone revision
8
+ # is not reported upstream; base_model names the selected inference base, not that history.
9
+ [[sparse_autoencoders]]
10
+ id = "esmc_small_sae_depth"
11
+ base_model = "esmc_small"
12
+ layer = 23
13
+ input_width = 960
14
+ k = 64
15
+ codebook_dim = 16384
16
+ input_kind = "hidden_state"
17
+ checkpoint_repo = "biohub/ESMC-300M-sae-layer23-k64-codebook16384"
18
+ checkpoint_revision = "71b054fc726a9f11153a601118f03657ac6e6e80"
19
+ checkpoint_files = [
20
+ "config.json=sha256:e2864c5f9756c052f343310bafeddafc3edc9514ad85981a51a1a3411186434d",
21
+ "layer_23.safetensors=sha256:825257f785600a7a9d462882d5cf3431ebbbba94761434e33b0c4814a7cc6270",
22
+ ]
23
+
24
+ [[sparse_autoencoders]]
25
+ id = "esmc_large_sae_depth"
26
+ base_model = "esmc_large"
27
+ layer = 27
28
+ input_width = 1152
29
+ k = 64
30
+ codebook_dim = 16384
31
+ input_kind = "hidden_state"
32
+ checkpoint_repo = "biohub/ESMC-600M-sae-layer27-k64-codebook16384"
33
+ checkpoint_revision = "b96480999aca49b7684e093f1adfcb09b55a1720"
34
+ checkpoint_files = [
35
+ "config.json=sha256:0e74c8af3ff4ac644f26be308e0fe6da6c75a4a52f0d3f05b0cbbdda27efc9ce",
36
+ "layer_27.safetensors=sha256:5e93c230aa200876cb300377c8e22a141a7e2e316c6a3b962b2a78c4518535db",
37
+ ]
38
+
39
+ [[sparse_autoencoders]]
40
+ id = "esmc_6b_sae_depth"
41
+ base_model = "esmc_6b"
42
+ layer = 60
43
+ input_width = 2560
44
+ k = 64
45
+ codebook_dim = 16384
46
+ input_kind = "hidden_state"
47
+ checkpoint_repo = "biohub/ESMC-6B-sae-layer60-k64-codebook16384"
48
+ checkpoint_revision = "99752fe6e4d25fbb26f887db2d9225e7a577da73"
49
+ checkpoint_files = [
50
+ "config.json=sha256:42567d3f757bea5c6b618abf55ed905ce4304d1eed891a2c1feae8e1ef393fd0",
51
+ "layer_60.safetensors=sha256:ddc1417b42cffe2d7fc2ec31783a04ac88800e6a8f8c8542f1f92d8f4f16094c",
52
+ ]
53
+
54
  [[attention_kernels]]
55
  implementation = "flash_attention_2"
56
  repository = "kernels-community/flash-attn2"
57
+ revision = "81fb77c12b2ad5d69380669b46739d5868614502"
58
+ version = 3
59
  expected_variant = "flash_attn2"
60
  dtypes = ["bfloat16"]
61
  min_cuda_capability = [8, 0]
 
111
  id = "biohub-transformers"
112
  path = "vendor/upstream/biohub-transformers"
113
  url = "https://github.com/Biohub/transformers.git"
114
+ # Biohub/transformers left GitHub by 2026-10-08. Its pinned commit stays reachable through the
115
+ # fork network of huggingface/transformers, which serves the clone and the source archive.
116
+ fetch_url = "https://github.com/huggingface/transformers.git"
117
  revision = "3a8956fb4d4ea16b0ec8e71deef2c2909b6a5cbf"
118
  license = "Apache-2.0"
119
  license_files = ["LICENSE"]
 
227
  representative = "esm2_8m"
228
  documentation = "docs/models.md#esm2"
229
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
230
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/_esm_rotary.py", "models/esm2", "models/ttt.py"]
231
  auto_map = { AutoConfig = "fastplms.models.esm2.modeling_fastesm.FastEsmConfig", AutoModel = "fastplms.models.esm2.modeling_fastesm.FastEsmModel", AutoModelForMaskedLM = "fastplms.models.esm2.modeling_fastesm.FastEsmForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm2.modeling_fastesm.FastEsmForTokenClassification" }
232
 
233
  [families.esm_plusplus]
 
254
  representative = "esmc_small"
255
  documentation = "docs/models.md#esm-and-esmc"
256
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
257
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/esm_plusplus", "models/ttt.py"]
258
  auto_map = { AutoConfig = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusConfig", AutoModel = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusModel", AutoModelForMaskedLM = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm_plusplus.modeling_esm_plusplus.ESMplusplusForTokenClassification" }
259
 
260
  [families.esm3]
 
279
  representative = "esm3_small"
280
  documentation = "docs/models.md#esm3"
281
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
282
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/esm3", "models/ttt.py"]
283
  auto_map = { AutoConfig = "fastplms.models.esm3.modeling_esm3.FastESM3Config", AutoModel = "fastplms.models.esm3.modeling_esm3.FastESM3Model", AutoModelForSequenceClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esm3.modeling_esm3.FastESM3ForTokenClassification" }
284
 
285
  [families.e1]
 
306
  representative = "e1_150m"
307
  documentation = "docs/models.md#e1"
308
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
309
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/e1", "models/ttt.py"]
310
  auto_map = { AutoConfig = "fastplms.models.e1.modeling_e1.E1Config", AutoModel = "fastplms.models.e1.modeling_e1.E1Model", AutoModelForMaskedLM = "fastplms.models.e1.modeling_e1.E1ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.e1.modeling_e1.E1ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.e1.modeling_e1.E1ForTokenClassification" }
311
 
312
  [families.dplm]
 
332
  representative = "dplm_150m"
333
  documentation = "docs/models.md#dplm"
334
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
335
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm", "models/ttt.py"]
336
  auto_map = { AutoConfig = "fastplms.models.dplm.modeling_dplm.DPLMConfig", AutoModel = "fastplms.models.dplm.modeling_dplm.DPLMModel", AutoModelForMaskedLM = "fastplms.models.dplm.modeling_dplm.DPLMForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm.modeling_dplm.DPLMForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm.modeling_dplm.DPLMForTokenClassification" }
337
 
338
  [families.dplm2]
 
357
  representative = "dplm2_150m"
358
  documentation = "docs/models.md#dplm2"
359
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
360
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/_diffusion_generation.py", "models/_esm_rotary.py", "models/dplm2", "models/ttt.py"]
361
  auto_map = { AutoConfig = "fastplms.models.dplm2.modeling_dplm2.DPLM2Config", AutoModel = "fastplms.models.dplm2.modeling_dplm2.DPLM2Model", AutoModelForMaskedLM = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForMaskedLM", AutoModelForSequenceClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.dplm2.modeling_dplm2.DPLM2ForTokenClassification" }
362
  tokenizer_class = "fastplms.models.dplm2.tokenization_dplm2.DPLM2Tokenizer"
363
 
 
384
  documentation = "docs/models.md#ankh"
385
  test_tiers = ["check", "compliance", "feature", "artifact", "benchmark"]
386
  requires_complete_weight_publication = false
387
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/ankh", "models/ttt.py"]
388
  auto_map = { AutoConfig = "fastplms.models.ankh.modeling_ankh.FastAnkhConfig", AutoModel = "fastplms.models.ankh.modeling_ankh.FastAnkhModel", AutoModelForMaskedLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForMaskedLMExtension", AutoModelForSeq2SeqLM = "fastplms.models.ankh.modeling_ankh.FastAnkhForConditionalGeneration", AutoModelForSequenceClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.ankh.modeling_ankh.FastAnkhForTokenClassification" }
389
 
390
  [families.boltz2]
 
408
  representative = "boltz2"
409
  documentation = "docs/models.md#boltz2"
410
  test_tiers = ["structure", "artifact", "benchmark"]
411
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "models/boltz"]
412
  auto_map = { AutoConfig = "fastplms.models.boltz.modeling_boltz2.Boltz2Config", AutoModel = "fastplms.models.boltz.modeling_boltz2.Boltz2Model" }
413
 
414
  [families.esmfold]
 
433
  representative = "esmfold"
434
  documentation = "docs/models.md#esmfold"
435
  test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
436
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/_esm_rotary.py", "models/classification_probe.py", "models/esmfold"]
437
  auto_map = { AutoConfig = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmFoldConfig", AutoModel = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForProteinFolding", AutoModelForSequenceClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold.modeling_fast_esmfold.FastEsmForTokenClassification" }
438
 
439
  [families.esmfold2]
 
460
  representative = "esmfold2"
461
  documentation = "docs/esmfold2.md"
462
  test_tiers = ["check", "compliance", "structure", "feature", "artifact", "benchmark"]
463
+ runtime_paths = ["__init__.py", "registry.py", "runtime.py", "atomic_files.py", "digests.py", "json_files.py", "models.toml", "models/__init__.py", "attention", "embeddings", "features","models/classification_probe.py", "models/_esm_rotary.py", "models/esmfold2", "models/esm_plusplus", "models/ttt.py"]
464
  auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2.ESMFold2Model", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ForTokenClassification" }
465
 
466
  [[models]]
 
468
  family = "esm2"
469
  size_category = "small"
470
  generation_contract = "not_applicable"
471
+ official_golden = { metadata = "tests/goldens/esm2_8m.json=sha256:06b3ccbf45a46a7aed3833b49503567f7534ffe60e6b3e49fcfcf17d04b7237e", tensors = "tests/goldens/esm2_8m.safetensors=sha256:d08a7572cbef20b8b19b545bcb0427b7e9ae19b986d015c559cd5a3a2cfc8aa4" }
472
  fast_repo = "Synthyra/ESM2-8M"
473
  fast_revision = "185ecbd45665d050a8dae326d91886d330c5f9d0"
474
  fast_files = [
 
507
  family = "esm2"
508
  size_category = "small"
509
  generation_contract = "not_applicable"
510
+ official_golden = { metadata = "tests/goldens/esm2_35m.json=sha256:61cd24fd91ef2dc49cbc0f97b14c0d5c97849eca6f36ed8c294295147909cb38", tensors = "tests/goldens/esm2_35m.safetensors=sha256:4f82d10286e16041c2f23365dfdd8508b911864633287dd76580425527c2d922" }
511
  fast_repo = "Synthyra/ESM2-35M"
512
  fast_revision = "37ab9f56b41e365b3bd9e25d6fefe9150fd910f0"
513
  fast_files = [
 
546
  family = "esm2"
547
  size_category = "medium"
548
  generation_contract = "not_applicable"
549
+ official_golden = { metadata = "tests/goldens/esm2_150m.json=sha256:864e1f9c1d8e939e8da050b8617b8a7d6983fbb11a5911275d1571d08e460a79", tensors = "tests/goldens/esm2_150m.safetensors=sha256:20ad986b8e6e09f36d0158f5939d1914e0809b84329695028d767aab93338621" }
550
  fast_repo = "Synthyra/ESM2-150M"
551
  fast_revision = "979e0880dfc9e0c0080839b83d9d2dc05b92786a"
552
  fast_files = [
 
585
  family = "esm2"
586
  size_category = "large"
587
  generation_contract = "not_applicable"
588
+ official_golden = { metadata = "tests/goldens/esm2_650m.json=sha256:b57f18c8803e68e6c621f479fd7a70596fbc416fc4a17d2ab91c639b480006c4", tensors = "tests/goldens/esm2_650m.safetensors=sha256:261a8b71c4b7c90b1f294031558ee0bf00d77a98352021eb78771200c8d40548" }
589
  fast_repo = "Synthyra/ESM2-650M"
590
  fast_revision = "ca0718a5d52b80d5c60dd76860e55e061a95fb0a"
591
  fast_files = [
 
624
  family = "esm2"
625
  size_category = "xlarge"
626
  generation_contract = "not_applicable"
627
+ official_golden = { metadata = "tests/goldens/esm2_3b.json=sha256:aa441786329889d43983811cac155cabd37b38cfd47bc90d415732a295c33c6d", tensors = "tests/goldens/esm2_3b.safetensors=sha256:61080e55e07db4a562a19e9b6b662e71a9fd3d8fee357aa3df00697d2fa58e33" }
628
  notes = "The pinned default SDPA BF16 path uses a checkpoint-specific numeric calibration: relative L2 target/hard limit 0.06/0.07, relative Q99.9 0.15/0.18, first-percentile residue cosine 0.994/0.992, and pooled cosine 0.998/0.997. Exact state identity and the global logits-distribution contract remain required."
629
  fast_repo = "Synthyra/ESM2-3B"
630
  fast_revision = "ff89d0180f414ab9c677219a25da79bf09185456"
 
667
  family = "esm_plusplus"
668
  size_category = "medium"
669
  generation_contract = "not_applicable"
670
+ official_golden = { metadata = "tests/goldens/esmc_small.json=sha256:15cbbf909a12b5b81ff47b5f142babb75ebf9b3d85673905f2320c2a119ae347", tensors = "tests/goldens/esmc_small.safetensors=sha256:98219356e2845cbd10a80715b19c95632956d40567a7a993409c426b56040b06" }
671
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
672
  fast_repo = "Synthyra/ESMplusplus_small"
673
  fast_revision = "46c5f7d562e47d4c14165b424c71ab7db008e6fb"
 
693
  family = "esm_plusplus"
694
  size_category = "large"
695
  generation_contract = "not_applicable"
696
+ official_golden = { metadata = "tests/goldens/esmc_large.json=sha256:86ffe179a9feafd067793f440d25e3606f184f0d32063aeaeb1842b14aa55d3b", tensors = "tests/goldens/esmc_large.safetensors=sha256:2b1ce3de5a7a27f171055f5c7aec4e7e50f0e809a1dc46508aaa0b472365015d" }
697
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
698
  fast_repo = "Synthyra/ESMplusplus_large"
699
  fast_revision = "f813401638b3fddab09748aec1ad2bf537aa4208"
 
719
  family = "esm_plusplus"
720
  size_category = "xlarge"
721
  generation_contract = "not_applicable"
722
+ official_golden = { metadata = "tests/goldens/esmc_6b.json=sha256:7e044e7e8d106d43169c4148521c54d0d09bb989624543d648247a110f1907d4", tensors = "tests/goldens/esmc_6b.safetensors=sha256:46139de6b28ab4f9e3244fb6ef3f259fc5e6518a3825f0fb4fd4a4fece6da8e6" }
723
  notes = "Release contract: SDPA must match the pinned Biohub implementation bit-for-bit across every hidden state, last hidden state, logits, special token, and padding position. Eager and FlashAttention 2 are release-gated in BF16 against the pinned boundary-length and biological panels with a relative-L2 engineering target of 0.029, hard limit of 0.03, relative-Q99.9 target of 0.049, first-percentile residue-cosine target of 0.997, and Jensen-Shannon target of 0.0004. The global pooled-cosine and top-1 thresholds remain unchanged. Flex Attention and FlashAttention 3 remain selectable as opt-in alternatives, but they are not strict-parity choices: on the locked H100 BF16 generated-boundary panel, ESMC-6B Flex Attention exceeds the 0.03 relative-L2 hard limit and FlashAttention 3 falls below the 0.995 residue-cosine hard limit. The deviation is consistent with backend-specific BF16 kernel arithmetic; it is not a weight-conversion difference or silent fallback. Use SDPA for exact Biohub parity or FlashAttention 2 for release-gated acceleration."
724
  fast_repo = "Synthyra/ESMplusplus_6B"
725
  fast_revision = "0d579cce3b0f09efa6b3baddf6cc3fd8c9b616c8"
 
731
  "model-00004-of-00006.safetensors=sha256:e46c6113c89c6f3e9b072c1bef02d763a625c37bcd8f9da2ed9363891c9a0758",
732
  "model-00005-of-00006.safetensors=sha256:6d92cb2bf9791de644de2ae86f8523d802ac3b4aaabfff0716ab6c2b97f6fb14",
733
  "model-00006-of-00006.safetensors=sha256:5fc1a8632490bb34162823c35d0d591337b9e4195b22cc0560741397a6e9d0b3",
734
+ "model.safetensors.index.json=git-sha1:f30f0b6b35e11d09a516c078b0847ee023bd1817",
735
  "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b",
736
  "tokenizer.json=git-sha1:f49735e56cebab0e791aeaae777757b7fd114f71",
737
  "tokenizer_config.json=git-sha1:2985ed2b8aa8ecfb1d12f53f47d2b8a44cc21756",
 
757
  tokenizer_source = "esmc_small"
758
  size_category = "large"
759
  generation_contract = "not_applicable"
760
+ official_golden = { metadata = "tests/goldens/esm3_small.json=sha256:e476bad7ccdfa2f908261a03e20116c0854eece292a553196f940b594088204d", tensors = "tests/goldens/esm3_small.safetensors=sha256:251e050926a1f2426401bac6b93b4cb00041d0b0a5c8a458d0ef0e6a5d0d87c3" }
761
  fast_repo = "Synthyra/ESM3_small"
762
  fast_revision = "7ddb5a740f9e5f93933eb6410c0ee8684bc63ec1"
763
  fast_files = [
 
783
  family = "e1"
784
  size_category = "small"
785
  generation_contract = "not_applicable"
786
+ official_golden = { metadata = "tests/goldens/e1_150m.json=sha256:e7520331135fd4c98f52f21c470dbba5cb5f4defb418494c63199fb000621e65", tensors = "tests/goldens/e1_150m.safetensors=sha256:03a2e93e7b3e54b12f99eea7678ff80828922f92c3683d4909346a05d74815bc" }
787
  fast_repo = "Synthyra/Profluent-E1-150M"
788
  fast_revision = "7c5f3bbf697226a2e0900db7a100f9201774a907"
789
  fast_files = [
 
802
  family = "e1"
803
  size_category = "medium"
804
  generation_contract = "not_applicable"
805
+ official_golden = { metadata = "tests/goldens/e1_300m.json=sha256:d97637bf789fed61b4ad575f904f37befcea42e9436e8d841e8f8f2cff1d1b17", tensors = "tests/goldens/e1_300m.safetensors=sha256:d125ea866899d2788330d6877d77c48c69febbf7f01d629f4107f9647cfa13ff" }
806
  fast_repo = "Synthyra/Profluent-E1-300M"
807
  fast_revision = "5ef52c0ad2ae2578f40622696b763523810e8e26"
808
  fast_files = [
 
821
  family = "e1"
822
  size_category = "large"
823
  generation_contract = "not_applicable"
824
+ official_golden = { metadata = "tests/goldens/e1_600m.json=sha256:0c9afc83b96ed8ad7f339a26df4ba5853e7994ad54dd3cd837deb9869c0cd2a9", tensors = "tests/goldens/e1_600m.safetensors=sha256:d470d6833f0b38bec070aa37e9f3c49717ea82d31c794af843550bb7e56253a6" }
825
  fast_repo = "Synthyra/Profluent-E1-600M"
826
  fast_revision = "6c8bf0ec83b0e0178677c528b101efffd0677742"
827
  fast_files = [
 
840
  family = "dplm"
841
  size_category = "small"
842
  generation_contract = "required"
843
+ official_golden = { metadata = "tests/goldens/dplm_150m.json=sha256:541167ffcd12b2d7e101046b2d17e800c70ba5db1b561212575301f2d1c407f8", tensors = "tests/goldens/dplm_150m.safetensors=sha256:f8e4ddd580d4708dad2d7da002735b2a01d69ece478c46c4932eb2f3933dc836" }
844
  fast_repo = "Synthyra/DPLM-150M"
845
  fast_revision = "90ba742754151a774f3b7ed580170d0a76b3e69d"
846
  fast_files = [
 
865
  family = "dplm"
866
  size_category = "large"
867
  generation_contract = "required"
868
+ official_golden = { metadata = "tests/goldens/dplm_650m.json=sha256:2bd482c94b1b6b3ac901b5ff9b06e2d189cda1a8fe2f8ff32e5d52b64c2c446b", tensors = "tests/goldens/dplm_650m.safetensors=sha256:70e2c46ed722942d3b628b556b92c9994688cb4853b278eb1e40a3dfbbd9dfc4" }
869
  fast_repo = "Synthyra/DPLM-650M"
870
  fast_revision = "05dc16d97c5c028aed924c9ed681cee4ab609760"
871
  fast_files = [
 
890
  family = "dplm"
891
  size_category = "xlarge"
892
  generation_contract = "required"
893
+ official_golden = { metadata = "tests/goldens/dplm_3b.json=sha256:0f8d8fb7df562c30abf730abe55db4a8c13d40877dd21772375fe1f34030159a", tensors = "tests/goldens/dplm_3b.safetensors=sha256:6942218079232a8185b1ae6e1578474c7784722e28f528b76ec89ca77777e2b9" }
894
  fast_repo = "Synthyra/DPLM-3B"
895
  fast_revision = "7d764dd3d70ecf1ac0e64693de64a0064aacac65"
896
  fast_files = [
 
920
  family = "dplm2"
921
  size_category = "small"
922
  generation_contract = "required"
923
+ official_golden = { metadata = "tests/goldens/dplm2_150m.json=sha256:5f2c496e4b557faf70bdde03b531f753fc7f20c5868bab79dcf378ee358781a1", tensors = "tests/goldens/dplm2_150m.safetensors=sha256:7390559bc54f27f08972c8f0adcf17954065450d937b3984303c1ae1ec003855" }
924
  artifact_source = "official"
925
  canonical_state_sha256 = "82e1751f59052b8de72b082517557db47947e8d9b4ac2f11278369e6c0cbf001"
926
  fast_repo = "Synthyra/DPLM2-150M"
 
947
  family = "dplm2"
948
  size_category = "large"
949
  generation_contract = "required"
950
+ official_golden = { metadata = "tests/goldens/dplm2_650m.json=sha256:af7b8c4fab48773e1ddd2cfd941cf3d518ea27ed8519f467ddf0da788a0aa6be", tensors = "tests/goldens/dplm2_650m.safetensors=sha256:c45f4ca267183b11dd06244ed3c3f39cf5b6139f778733fa63a6e2b5a46b0678" }
951
  artifact_source = "official"
952
  canonical_state_sha256 = "cba76b6602d2258de9fffff953b608d93cb8ef4a9e89b0bbd27e160c81e78bb4"
953
  fast_repo = "Synthyra/DPLM2-650M"
 
976
  # The pinned public sampler fails before generation because cls_token_id is None.
977
  # State, tokenizer, and inference parity remain required for this checkpoint.
978
  generation_contract = "official_unavailable"
979
+ official_golden = { metadata = "tests/goldens/dplm2_3b.json=sha256:ded7978cf84da1d56d8b4693418030ec3b0086bf6466be2242fb0ce424296b9f", tensors = "tests/goldens/dplm2_3b.safetensors=sha256:7490f771f8b8f8137b333f98d6b7dbd2cb48708c8649d4459279a6711b9e5870" }
980
  notes = "The pinned official DPLM2-3B sampler fails before generation, so live generation equivalence cannot be established for this checkpoint. State, tokenizer, and inference parity remain required."
981
  artifact_source = "official"
982
  canonical_state_sha256 = "8c46ec09115dbe6cbfb91d94ab5e906369d57e27fe620a7741c6f8cb1b6ca890"
 
1009
  family = "ankh"
1010
  size_category = "medium"
1011
  generation_contract = "required"
1012
+ official_golden = { metadata = "tests/goldens/ankh_base.json=sha256:21f129b7b71c026cbd3b3fdb712af06d5b6f256b487ef8b01fcc506d2bc6c7b6", tensors = "tests/goldens/ankh_base.safetensors=sha256:449db3638117d5b6027380fdf4b05698bb156024b2b3f7e0ceb295a86e5ec756" }
1013
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1014
  artifact_source = "official"
1015
  canonical_state_sha256 = "cdd8d30d88e5bf41f44e1eef4470d8e46607aba5f7c7c805b06c035b89c8c16f"
 
1038
  family = "ankh"
1039
  size_category = "large"
1040
  generation_contract = "required"
1041
+ official_golden = { metadata = "tests/goldens/ankh_large.json=sha256:8f269215ee29887661abb6c139139dd1782f5112b94eb63be968282a7f050e1b", tensors = "tests/goldens/ankh_large.safetensors=sha256:4a7aea66e91cf880b0b839495d7704831725b672fe169f8e7258b5643d77aff9" }
1042
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1043
  artifact_source = "official"
1044
  canonical_state_sha256 = "e498a2e9aea76ef784cbe3e596c6b3f5e9a40e209ad837f7e3207099e4d74483"
 
1068
  family = "ankh"
1069
  size_category = "large"
1070
  generation_contract = "required"
1071
+ official_golden = { metadata = "tests/goldens/ankh2_large.json=sha256:bdafcd179bc055ea223de43228801b1b6c35196741ac4cc4cca0571e3291e6dc", tensors = "tests/goldens/ankh2_large.safetensors=sha256:44d5fc5c74a0d166ceac54d12505a109f8338cb7090af2f37e39bd78545a3c52" }
1072
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1073
  artifact_source = "official"
1074
  canonical_state_sha256 = "597c4fe2fa8711f11a25317905f1d62fa92905e55fdd5c0a79614cd9c9d2bca3"
 
1100
  family = "ankh"
1101
  size_category = "large"
1102
  generation_contract = "required"
1103
+ official_golden = { metadata = "tests/goldens/ankh3_large.json=sha256:ab3a671260bdad481635d4a4be1b8072d5a2e02e3df1178202713c9a7e56af49", tensors = "tests/goldens/ankh3_large.safetensors=sha256:cc55274281e87d22cf7828117678df2e37a1ece543f5b401b65a0cd6f8b6fa27" }
1104
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head."
1105
  artifact_source = "official"
1106
  canonical_state_sha256 = "60acb7ef86e85dc0c51fc1edf4c8e69a0480049723b6b2c95e6e9faa720c112a"
 
1134
  family = "ankh"
1135
  size_category = "xlarge"
1136
  generation_contract = "required"
1137
+ official_golden = { metadata = "tests/goldens/ankh3_xl.json=sha256:a2d302227ee616698af18502d03e2b6b136d589134058d82b018520f18498431", tensors = "tests/goldens/ankh3_xl.safetensors=sha256:d18c086a4849b220761019f219590fe0c1c6f17698c365ccb0a5a8804446286c" }
1138
  notes = "ANKH parity covers the official encoder and sequence-to-sequence heads. AutoModelForMaskedLM exposes the separately named FastPLMs synthesized masked-LM extension and is not an official ANKH head. The official PyTorch shard index is deliberately excluded: the builder verifies every declared source shard directly and writes a new canonical safetensors index."
1139
  artifact_source = "official"
1140
  canonical_state_sha256 = "dd2188e0d2ca65232135714eef6de394239734d843ddae4928c7398685d858e7"
 
1227
  size_category = "structure"
1228
  generation_contract = "not_applicable"
1229
  msa_conditioning = true
1230
+ official_golden = { metadata = "tests/goldens/esmfold2.json=sha256:60b9e8827615a73acd89ac23f13e3d0a412c17879fbf3308444546e6bbb4ff03", tensors = "tests/goldens/esmfold2.safetensors=sha256:a1a9f3b7a9f7e36ef6a7077d76fa1b1c8b5529ed42ac2cfa485797162ce866fb" }
1231
  fast_repo = "Synthyra/ESMFold2"
1232
  fast_revision = "cd5a0927cec585a778d983b99a8db23d2e9b281e"
1233
  fast_files = [
 
1247
  size_category = "structure"
1248
  generation_contract = "not_applicable"
1249
  msa_conditioning = false
1250
+ official_golden = { metadata = "tests/goldens/esmfold2_fast.json=sha256:659bb338be4584787faff619d1cbcb8766aece7b5facf8a84470e850299b662f", tensors = "tests/goldens/esmfold2_fast.safetensors=sha256:8da91024a2cc63984585267a18f595892ebe489b2a05c4f79a994c3ac5de2f40" }
1251
  fast_repo = "Synthyra/ESMFold2-Fast"
1252
  fast_revision = "407875bfcaa42552bfcb25acd67ee1888b790170"
1253
  fast_files = [
 
1267
  size_category = "structure"
1268
  generation_contract = "not_applicable"
1269
  msa_conditioning = true
1270
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_cutoff2025.json=sha256:2e42d958f7a99ca8edaf0d9fac8e1a58b78200df652116b7bc102ac799123633", tensors = "tests/goldens/esmfold2_experimental_cutoff2025.safetensors=sha256:8168f8692c4932e1160e733b15210836120223615f6190fb0ecfa2f940c2420f" }
1271
  fast_repo = "Synthyra/ESMFold2-Experimental-Cutoff2025"
1272
  fast_revision = "632ff4a9e68f1de78ee956a613267bdcdb5b354d"
1273
  fast_files = [
 
1288
  size_category = "structure"
1289
  generation_contract = "not_applicable"
1290
  msa_conditioning = false
1291
+ official_golden = { metadata = "tests/goldens/esmfold2_experimental_fast_cutoff2025.json=sha256:d41c54ca28270fc1c1b95485c5009990bd2c215d35ec5cc2c2fa611d096bad21", tensors = "tests/goldens/esmfold2_experimental_fast_cutoff2025.safetensors=sha256:97b58525aefd21c23ec6cb2defa3b3b41982c872dfe70d94306648edbe40a5b9" }
1292
  fast_repo = "Synthyra/ESMFold2-Experimental-Fast-Cutoff2025"
1293
  fast_revision = "8f022c2514a6c32692aaca078a8391d6bc6c4bac"
1294
  fast_files = [
 
1305
 
1306
  [[models]]
1307
  id = "esmfold2_300"
1308
+ official_golden = { metadata = "tests/goldens/esmfold2_300.json=sha256:d4fd12f27352c53582bb40a8d85eab76d6d98f647ce7660a6f891a1dfe68c039", tensors = "tests/goldens/esmfold2_300.safetensors=sha256:40a954ff14edc7c2bff95a252241f08db4cae3821ca9b92ae216775cceed8e8b" }
1309
  confidence_adaptation = { release = "v1", head_sha256 = "40fd7f3d82fcefe8ad20ab2b32a37a68a84b54a527a4bce6eb9437bad2b77e31", base_weight_sha256 = "44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd", donor_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025", donor_revision = "74b88548bf19688b8727432db0d698cb2e1d8783", donor_weight_sha256 = "4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", training_url = "https://wandb.ai/lhallee/fastplms-confidence/runs/9558b6d23daf", evaluation_url = "https://huggingface.co/datasets/Synthyra/FastPLMs-artifacts/tree/309f353b07e0e46de4d77a5266d4eddd695538e3/confidence-v2/v2-reproduction-20260922/public/evaluation/esmfold2_300", evidence_path = "docs/evidence/confidence/esmfold2_300-v1.json", frozen_base = { repo = "Synthyra/ESMFold2-300", revision = "a38a62ae930d157484b331c2bf4241684573adba", files = ["config.json=git-sha1:47ec20cf8b234c3b41d6f3ae1bdfe95d4eb4849e", "model.safetensors=sha256:44d6797c5efebf24753d502b40950e0874871c96ceea14f2d7f7e39cebac67fd"] } }
1310
  family = "esmfold2"
1311
  size_category = "structure"
 
1325
 
1326
  [[models]]
1327
  id = "esmfold2_600"
1328
+ official_golden = { metadata = "tests/goldens/esmfold2_600.json=sha256:ea249ff118975f29979143ea081cfa1c0fb60cb1fa393dd99a12caf413524230", tensors = "tests/goldens/esmfold2_600.safetensors=sha256:69bbb8d53a816b29e979e4469728df0a6b662e4d0be81a76068b36974d2b909e" }
1329
  confidence_adaptation = { release = "v1", head_sha256 = "e84726a050722e1b722712c87d17a5388bd3699e2520e4a59abb1d828dfb8de7", base_weight_sha256 = "11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602", donor_repo = "biohub/ESMFold2-Experimental-Fast-Cutoff2025", donor_revision = "74b88548bf19688b8727432db0d698cb2e1d8783", donor_weight_sha256 = "4e903b740ad6ad704ec60881bfd593e0d6c874a630ffa0f0838276e0b665088f", training_url = "https://wandb.ai/lhallee/fastplms-confidence/runs/820d2cfa56c0", evaluation_url = "https://huggingface.co/datasets/Synthyra/FastPLMs-artifacts/tree/6e62186cd36b9047cc4691980076be9f76482192/confidence-v2/v2-reproduction-20260922/public/evaluation/esmfold2_600", evidence_path = "docs/evidence/confidence/esmfold2_600-v1.json", frozen_base = { repo = "Synthyra/ESMFold2-600", revision = "71c67d0b2b73dc245ea7c3cc0d0476439a882d08", files = ["config.json=git-sha1:8e271837cbdada96c4974c8e543f84065e0f06f1", "model.safetensors=sha256:11a53c1b4700b6c62a5a464fc3ec7076160c19e8584157f88e13275116cfd602"] } }
1330
  family = "esmfold2"
1331
  size_category = "structure"
 
1342
  auto_map = { AutoConfig = "fastplms.models.esmfold2.configuration_esmfold2.ESMFold2Config", AutoModel = "fastplms.models.esmfold2.modeling_esmfold2_experimental.ESMFold2ExperimentalModel", AutoModelForSequenceClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForSequenceClassification", AutoModelForTokenClassification = "fastplms.models.esmfold2.modeling_esmfold2_classification.ESMFold2ExperimentalForTokenClassification" }
1343
  backbone_model = "esmc_large"
1344
  backbone = { repo = "biohub/ESMC-600M-1500000", revision = "21af9cc429af76ebda6c48074fb624db4735aaaf", files = ["config.json=git-sha1:ec29f6009b21d710f64bf1c058f3a9710833d692", "model.safetensors=sha256:d6869f5ae0f11e5dc829b195e062e87cfcc2f851a08a5edbaf5d1083ae7f76cc", "tokenizer.json=git-sha1:81c797f56768b22dec0301fa771f018b7e43e98c", "tokenizer_config.json=git-sha1:f49f57b24a1c93bd544974811e8ecbd61b7fae89", "special_tokens_map.json=git-sha1:c907ee1dc19b24241749b32d665c291c7e6e8e4b"] }
1345
+
1346
+ # Parity goldens, uploaded 2026-09-22 so the reference tensors live somewhere durable
1347
+ # rather than only inside the GitHub repository. Each sha256 is the one already recorded
1348
+ # in that model's official_golden.tensors, so the existing integrity check is unchanged.
1349
+
1350
+ [[golden_artifacts]]
1351
+ id = "esm2_8m"
1352
+ repository = "Synthyra/fastplms_parity_goldens"
1353
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1354
+ path = "goldens/esm2_8m.safetensors"
1355
+ sha256 = "d08a7572cbef20b8b19b545bcb0427b7e9ae19b986d015c559cd5a3a2cfc8aa4"
1356
+ size = 537589
1357
+ offline_behavior = "requires_cached_verified_file"
1358
+
1359
+ [[golden_artifacts]]
1360
+ id = "esm2_35m"
1361
+ repository = "Synthyra/fastplms_parity_goldens"
1362
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1363
+ path = "goldens/esm2_35m.safetensors"
1364
+ sha256 = "4f82d10286e16041c2f23365dfdd8508b911864633287dd76580425527c2d922"
1365
+ size = 779509
1366
+ offline_behavior = "requires_cached_verified_file"
1367
+
1368
+ [[golden_artifacts]]
1369
+ id = "esm2_150m"
1370
+ repository = "Synthyra/fastplms_parity_goldens"
1371
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1372
+ path = "goldens/esm2_150m.safetensors"
1373
+ sha256 = "20ad986b8e6e09f36d0158f5939d1914e0809b84329695028d767aab93338621"
1374
+ size = 1021437
1375
+ offline_behavior = "requires_cached_verified_file"
1376
+
1377
+ [[golden_artifacts]]
1378
+ id = "esm2_650m"
1379
+ repository = "Synthyra/fastplms_parity_goldens"
1380
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1381
+ path = "goldens/esm2_650m.safetensors"
1382
+ sha256 = "261a8b71c4b7c90b1f294031558ee0bf00d77a98352021eb78771200c8d40548"
1383
+ size = 1989117
1384
+ offline_behavior = "requires_cached_verified_file"
1385
+
1386
+ [[golden_artifacts]]
1387
+ id = "esm2_3b"
1388
+ repository = "Synthyra/fastplms_parity_goldens"
1389
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1390
+ path = "goldens/esm2_3b.safetensors"
1391
+ sha256 = "61080e55e07db4a562a19e9b6b662e71a9fd3d8fee357aa3df00697d2fa58e33"
1392
+ size = 3924485
1393
+ offline_behavior = "requires_cached_verified_file"
1394
+
1395
+ [[golden_artifacts]]
1396
+ id = "esmc_small"
1397
+ repository = "Synthyra/fastplms_parity_goldens"
1398
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1399
+ path = "goldens/esmc_small.safetensors"
1400
+ sha256 = "98219356e2845cbd10a80715b19c95632956d40567a7a993409c426b56040b06"
1401
+ size = 1165354
1402
+ offline_behavior = "requires_cached_verified_file"
1403
+
1404
+ [[golden_artifacts]]
1405
+ id = "esmc_large"
1406
+ repository = "Synthyra/fastplms_parity_goldens"
1407
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1408
+ path = "goldens/esmc_large.safetensors"
1409
+ sha256 = "2b1ce3de5a7a27f171055f5c7aec4e7e50f0e809a1dc46508aaa0b472365015d"
1410
+ size = 1383082
1411
+ offline_behavior = "requires_cached_verified_file"
1412
+
1413
+ [[golden_artifacts]]
1414
+ id = "esmc_6b"
1415
+ repository = "Synthyra/fastplms_parity_goldens"
1416
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1417
+ path = "goldens/esmc_6b.safetensors"
1418
+ sha256 = "46139de6b28ab4f9e3244fb6ef3f259fc5e6518a3825f0fb4fd4a4fece6da8e6"
1419
+ size = 2979762
1420
+ offline_behavior = "requires_cached_verified_file"
1421
+
1422
+ [[golden_artifacts]]
1423
+ id = "esm3_small"
1424
+ repository = "Synthyra/fastplms_parity_goldens"
1425
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1426
+ path = "goldens/esm3_small.safetensors"
1427
+ sha256 = "251e050926a1f2426401bac6b93b4cb00041d0b0a5c8a458d0ef0e6a5d0d87c3"
1428
+ size = 2398877
1429
+ offline_behavior = "requires_cached_verified_file"
1430
+
1431
+ [[golden_artifacts]]
1432
+ id = "e1_150m"
1433
+ repository = "Synthyra/fastplms_parity_goldens"
1434
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1435
+ path = "goldens/e1_150m.safetensors"
1436
+ sha256 = "03a2e93e7b3e54b12f99eea7678ff80828922f92c3683d4909346a05d74815bc"
1437
+ size = 958851
1438
+ offline_behavior = "requires_cached_verified_file"
1439
+
1440
+ [[golden_artifacts]]
1441
+ id = "e1_300m"
1442
+ repository = "Synthyra/fastplms_parity_goldens"
1443
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1444
+ path = "goldens/e1_300m.safetensors"
1445
+ sha256 = "d125ea866899d2788330d6877d77c48c69febbf7f01d629f4107f9647cfa13ff"
1446
+ size = 1258379
1447
+ offline_behavior = "requires_cached_verified_file"
1448
+
1449
+ [[golden_artifacts]]
1450
+ id = "e1_600m"
1451
+ repository = "Synthyra/fastplms_parity_goldens"
1452
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1453
+ path = "goldens/e1_600m.safetensors"
1454
+ sha256 = "d470d6833f0b38bec070aa37e9f3c49717ea82d31c794af843550bb7e56253a6"
1455
+ size = 1557899
1456
+ offline_behavior = "requires_cached_verified_file"
1457
+
1458
+ [[golden_artifacts]]
1459
+ id = "dplm_150m"
1460
+ repository = "Synthyra/fastplms_parity_goldens"
1461
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1462
+ path = "goldens/dplm_150m.safetensors"
1463
+ sha256 = "f8e4ddd580d4708dad2d7da002735b2a01d69ece478c46c4932eb2f3933dc836"
1464
+ size = 1021437
1465
+ offline_behavior = "requires_cached_verified_file"
1466
+
1467
+ [[golden_artifacts]]
1468
+ id = "dplm_650m"
1469
+ repository = "Synthyra/fastplms_parity_goldens"
1470
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1471
+ path = "goldens/dplm_650m.safetensors"
1472
+ sha256 = "70e2c46ed722942d3b628b556b92c9994688cb4853b278eb1e40a3dfbbd9dfc4"
1473
+ size = 1989117
1474
+ offline_behavior = "requires_cached_verified_file"
1475
+
1476
+ [[golden_artifacts]]
1477
+ id = "dplm_3b"
1478
+ repository = "Synthyra/fastplms_parity_goldens"
1479
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1480
+ path = "goldens/dplm_3b.safetensors"
1481
+ sha256 = "6942218079232a8185b1ae6e1578474c7784722e28f528b76ec89ca77777e2b9"
1482
+ size = 3924485
1483
+ offline_behavior = "requires_cached_verified_file"
1484
+
1485
+ [[golden_artifacts]]
1486
+ id = "dplm2_150m"
1487
+ repository = "Synthyra/fastplms_parity_goldens"
1488
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1489
+ path = "goldens/dplm2_150m.safetensors"
1490
+ sha256 = "7390559bc54f27f08972c8f0adcf17954065450d937b3984303c1ae1ec003855"
1491
+ size = 26826946
1492
+ offline_behavior = "requires_cached_verified_file"
1493
+
1494
+ [[golden_artifacts]]
1495
+ id = "dplm2_650m"
1496
+ repository = "Synthyra/fastplms_parity_goldens"
1497
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1498
+ path = "goldens/dplm2_650m.safetensors"
1499
+ sha256 = "c45f4ca267183b11dd06244ed3c3f39cf5b6139f778733fa63a6e2b5a46b0678"
1500
+ size = 28762314
1501
+ offline_behavior = "requires_cached_verified_file"
1502
+
1503
+ [[golden_artifacts]]
1504
+ id = "dplm2_3b"
1505
+ repository = "Synthyra/fastplms_parity_goldens"
1506
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1507
+ path = "goldens/dplm2_3b.safetensors"
1508
+ sha256 = "7490f771f8b8f8137b333f98d6b7dbd2cb48708c8649d4459279a6711b9e5870"
1509
+ size = 32633034
1510
+ offline_behavior = "requires_cached_verified_file"
1511
+
1512
+ [[golden_artifacts]]
1513
+ id = "ankh_base"
1514
+ repository = "Synthyra/fastplms_parity_goldens"
1515
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1516
+ path = "goldens/ankh_base.safetensors"
1517
+ sha256 = "449db3638117d5b6027380fdf4b05698bb156024b2b3f7e0ceb295a86e5ec756"
1518
+ size = 860722
1519
+ offline_behavior = "requires_cached_verified_file"
1520
+
1521
+ [[golden_artifacts]]
1522
+ id = "ankh_large"
1523
+ repository = "Synthyra/fastplms_parity_goldens"
1524
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1525
+ path = "goldens/ankh_large.safetensors"
1526
+ sha256 = "4a7aea66e91cf880b0b839495d7704831725b672fe169f8e7258b5643d77aff9"
1527
+ size = 1717818
1528
+ offline_behavior = "requires_cached_verified_file"
1529
+
1530
+ [[golden_artifacts]]
1531
+ id = "ankh2_large"
1532
+ repository = "Synthyra/fastplms_parity_goldens"
1533
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1534
+ path = "goldens/ankh2_large.safetensors"
1535
+ sha256 = "44d5fc5c74a0d166ceac54d12505a109f8338cb7090af2f37e39bd78545a3c52"
1536
+ size = 1717818
1537
+ offline_behavior = "requires_cached_verified_file"
1538
+
1539
+ [[golden_artifacts]]
1540
+ id = "ankh3_large"
1541
+ repository = "Synthyra/fastplms_parity_goldens"
1542
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1543
+ path = "goldens/ankh3_large.safetensors"
1544
+ sha256 = "cc55274281e87d22cf7828117678df2e37a1ece543f5b401b65a0cd6f8b6fa27"
1545
+ size = 1717818
1546
+ offline_behavior = "requires_cached_verified_file"
1547
+
1548
+ [[golden_artifacts]]
1549
+ id = "ankh3_xl"
1550
+ repository = "Synthyra/fastplms_parity_goldens"
1551
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1552
+ path = "goldens/ankh3_xl.safetensors"
1553
+ sha256 = "d18c086a4849b220761019f219590fe0c1c6f17698c365ccb0a5a8804446286c"
1554
+ size = 2860602
1555
+ offline_behavior = "requires_cached_verified_file"
1556
+
1557
+ [[golden_artifacts]]
1558
+ id = "esmfold"
1559
+ repository = "Synthyra/fastplms_parity_goldens"
1560
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1561
+ path = "goldens/esmfold.safetensors"
1562
+ sha256 = "873b1b325a43d8e0f35f355c8914a2a9fe611cc48763875e9e6a22e09ec9ebcb"
1563
+ size = 179144
1564
+ offline_behavior = "requires_cached_verified_file"
1565
+
1566
+ [[golden_artifacts]]
1567
+ id = "esmfold2"
1568
+ repository = "Synthyra/fastplms_parity_goldens"
1569
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1570
+ path = "goldens/esmfold2.safetensors"
1571
+ sha256 = "a1a9f3b7a9f7e36ef6a7077d76fa1b1c8b5529ed42ac2cfa485797162ce866fb"
1572
+ size = 2573976
1573
+ offline_behavior = "requires_cached_verified_file"
1574
+
1575
+ [[golden_artifacts]]
1576
+ id = "esmfold2_fast"
1577
+ repository = "Synthyra/fastplms_parity_goldens"
1578
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1579
+ path = "goldens/esmfold2_fast.safetensors"
1580
+ sha256 = "8da91024a2cc63984585267a18f595892ebe489b2a05c4f79a994c3ac5de2f40"
1581
+ size = 2573976
1582
+ offline_behavior = "requires_cached_verified_file"
1583
+
1584
+ [[golden_artifacts]]
1585
+ id = "esmfold2_experimental_cutoff2025"
1586
+ repository = "Synthyra/fastplms_parity_goldens"
1587
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1588
+ path = "goldens/esmfold2_experimental_cutoff2025.safetensors"
1589
+ sha256 = "8168f8692c4932e1160e733b15210836120223615f6190fb0ecfa2f940c2420f"
1590
+ size = 1771064
1591
+ offline_behavior = "requires_cached_verified_file"
1592
+
1593
+ [[golden_artifacts]]
1594
+ id = "esmfold2_experimental_fast_cutoff2025"
1595
+ repository = "Synthyra/fastplms_parity_goldens"
1596
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1597
+ path = "goldens/esmfold2_experimental_fast_cutoff2025.safetensors"
1598
+ sha256 = "97b58525aefd21c23ec6cb2defa3b3b41982c872dfe70d94306648edbe40a5b9"
1599
+ size = 1771064
1600
+ offline_behavior = "requires_cached_verified_file"
1601
+
1602
+ [[golden_artifacts]]
1603
+ id = "esmfold2_300"
1604
+ repository = "Synthyra/fastplms_parity_goldens"
1605
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1606
+ path = "goldens/esmfold2_300.safetensors"
1607
+ sha256 = "40a954ff14edc7c2bff95a252241f08db4cae3821ca9b92ae216775cceed8e8b"
1608
+ size = 865376
1609
+ offline_behavior = "requires_cached_verified_file"
1610
+
1611
+ [[golden_artifacts]]
1612
+ id = "esmfold2_600"
1613
+ repository = "Synthyra/fastplms_parity_goldens"
1614
+ revision = "50b93a0fc2be21a36c2522118754c74c4a9631ad"
1615
+ path = "goldens/esmfold2_600.safetensors"
1616
+ sha256 = "69bbb8d53a816b29e979e4469728df0a6b662e4d0be81a76068b36974d2b909e"
1617
+ size = 865376
1618
+ offline_behavior = "requires_cached_verified_file"
fastplms/models/boltz/__init__.py CHANGED
@@ -1,16 +1,16 @@
1
- """Transformers-compatible Boltz2 configuration, model, and structure outputs."""
2
-
3
- from fastplms.models.boltz.modeling_boltz2 import (
4
- Boltz2Config,
5
- Boltz2Model,
6
- Boltz2ModelOutput,
7
- Boltz2StructureOutput,
8
- )
9
-
10
-
11
- __all__ = [
12
- "Boltz2Config",
13
- "Boltz2Model",
14
- "Boltz2ModelOutput",
15
- "Boltz2StructureOutput",
16
- ]
 
1
+ """Transformers-compatible Boltz2 configuration, model, and structure outputs."""
2
+
3
+ from fastplms.models.boltz.modeling_boltz2 import (
4
+ Boltz2Config,
5
+ Boltz2Model,
6
+ Boltz2ModelOutput,
7
+ Boltz2StructureOutput,
8
+ )
9
+
10
+
11
+ __all__ = [
12
+ "Boltz2Config",
13
+ "Boltz2Model",
14
+ "Boltz2ModelOutput",
15
+ "Boltz2StructureOutput",
16
+ ]
fastplms/models/boltz/_pair_attention.py CHANGED
@@ -1,51 +1,51 @@
1
- """Shared tensor operations for Boltz2 pair-biased attention layers."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- import torch
7
-
8
- from torch import Tensor
9
-
10
-
11
- def reshape_heads(states: Tensor, num_heads: int) -> Tensor:
12
- """Reshape X from ``(b, l, d)`` to ``(b, l, h, d_head)``."""
13
-
14
- # states: (b, l, d); num_heads: h
15
- batch_size, sequence_length, width = states.shape # b, l, d
16
- if width % num_heads:
17
- raise ValueError(f"width {width} is not divisible by {num_heads} heads")
18
- # states.view(...): (b, l, h, d_h)
19
- return states.view(batch_size, sequence_length, num_heads, width // num_heads)
20
-
21
-
22
- def pair_biased_attention(
23
- query_states: Tensor,
24
- key_states: Tensor,
25
- value_states: Tensor,
26
- pair_bias: Tensor,
27
- key_mask: Tensor,
28
- mask_value: float,
29
- ) -> Tensor:
30
- """Return pair-biased attention values with shape ``(b, l_q, h, d_head)``."""
31
-
32
- # query_states: (b, l_q, h, d_h); key_states, value_states: (b, l_k, h, d_h)
33
- # pair_bias: (b, h, l_q, l_k); key_mask: (b, l_k)
34
- head_dim = query_states.shape[-1] # d_h
35
- with torch.autocast("cuda", enabled=False):
36
- scores = torch.einsum(
37
- "bihd,bjhd->bhij",
38
- query_states.float(),
39
- key_states.float(),
40
- ) # (b, h, l_q, l_k)
41
- scores = scores / math.sqrt(head_dim) + pair_bias.float() # (b, h, l_q, l_k)
42
- scores = (
43
- scores + (1 - key_mask[:, None, None].float()) * -mask_value
44
- ) # (b, h, l_q, l_k)
45
- probabilities = scores.softmax(dim=-1) # (b, h, l_q, l_k)
46
- output = torch.einsum(
47
- "bhij,bjhd->bihd",
48
- probabilities,
49
- value_states.float(),
50
- ) # (b, l_q, h, d_h)
51
- return output.to(value_states.dtype) # (b, l_q, h, d_h)
 
1
+ """Shared tensor operations for Boltz2 pair-biased attention layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import torch
7
+
8
+ from torch import Tensor
9
+
10
+
11
+ def reshape_heads(states: Tensor, num_heads: int) -> Tensor:
12
+ """Reshape X from ``(b, l, d)`` to ``(b, l, h, d_head)``."""
13
+
14
+ # states: (b, l, d); num_heads: h
15
+ batch_size, sequence_length, width = states.shape # b, l, d
16
+ if width % num_heads:
17
+ raise ValueError(f"width {width} is not divisible by {num_heads} heads")
18
+ # states.view(...): (b, l, h, d_h)
19
+ return states.view(batch_size, sequence_length, num_heads, width // num_heads)
20
+
21
+
22
+ def pair_biased_attention(
23
+ query_states: Tensor,
24
+ key_states: Tensor,
25
+ value_states: Tensor,
26
+ pair_bias: Tensor,
27
+ key_mask: Tensor,
28
+ mask_value: float,
29
+ ) -> Tensor:
30
+ """Return pair-biased attention values with shape ``(b, l_q, h, d_head)``."""
31
+
32
+ # query_states: (b, l_q, h, d_h); key_states, value_states: (b, l_k, h, d_h)
33
+ # pair_bias: (b, h, l_q, l_k); key_mask: (b, l_k)
34
+ head_dim = query_states.shape[-1] # d_h
35
+ with torch.autocast("cuda", enabled=False):
36
+ scores = torch.einsum(
37
+ "bihd,bjhd->bhij",
38
+ query_states.float(),
39
+ key_states.float(),
40
+ ) # (b, h, l_q, l_k)
41
+ scores = scores / math.sqrt(head_dim) + pair_bias.float() # (b, h, l_q, l_k)
42
+ scores = (
43
+ scores + (1 - key_mask[:, None, None].float()) * -mask_value
44
+ ) # (b, h, l_q, l_k)
45
+ probabilities = scores.softmax(dim=-1) # (b, h, l_q, l_k)
46
+ output = torch.einsum(
47
+ "bhij,bjhd->bihd",
48
+ probabilities,
49
+ value_states.float(),
50
+ ) # (b, l_q, h, d_h)
51
+ return output.to(value_states.dtype) # (b, l_q, h, d_h)
fastplms/models/boltz/cif_writer.py CHANGED
@@ -1,150 +1,150 @@
1
- """Write Boltz2 coordinates and confidence values as an mmCIF atom table."""
2
-
3
- import numpy as np
4
- import torch
5
-
6
- from pathlib import Path
7
-
8
- from .minimal_structures import ProteinStructureTemplate
9
-
10
-
11
- def _confidence_per_atom(
12
- plddt: torch.Tensor | None,
13
- atom_to_residue: list[int],
14
- num_atoms: int,
15
- sample_index: int,
16
- ) -> np.ndarray:
17
- # plddt: (n_i,) or (n_s, n_i); atom_to_residue: length n_a; num_atoms: n_a
18
- if plddt is None:
19
- return np.ones((num_atoms,), dtype=np.float32) * 100.0 # (n_a,)
20
-
21
- values = plddt.detach().cpu() # (n_i,) or (n_s, n_i)
22
- if values.ndim == 1:
23
- values = values.unsqueeze(0) # (1, n_i)
24
- if values.ndim != 2:
25
- raise ValueError("Expected pLDDT tensor S with shape (n_samples, n_items).")
26
- # values: (n_s, n_i) beyond this point
27
- if not 0 <= sample_index < values.shape[0]:
28
- raise IndexError("sample_index out of range for pLDDT.")
29
-
30
- selected = values[sample_index] # (n_i,)
31
- if selected.shape[0] == num_atoms:
32
- return (selected.numpy() * 100.0).astype(np.float32) # (n_a,)
33
-
34
- num_residues = max(atom_to_residue) + 1 # n_r
35
- if selected.shape[0] == num_residues:
36
- expanded = np.zeros((num_atoms,), dtype=np.float32) # (n_a,)
37
- selected_np = selected.numpy() # (n_r,)
38
- for atom_idx, residue_idx in enumerate(atom_to_residue):
39
- expanded[atom_idx] = selected_np[residue_idx] * 100.0 # () -> ()
40
- return expanded # (n_a,)
41
-
42
- raise ValueError(
43
- "pLDDT item count must match either atoms or residues: "
44
- f"received {selected.shape[0]}, expected {num_atoms} atoms or "
45
- f"{num_residues} residues."
46
- )
47
-
48
-
49
- def write_cif(
50
- structure_template: ProteinStructureTemplate,
51
- atom_coords: torch.Tensor,
52
- atom_mask: torch.Tensor,
53
- output_path: str,
54
- plddt: torch.Tensor | None = None,
55
- sample_index: int = 0,
56
- ) -> str:
57
- # atom_coords: (n_a, 3) or (n_s, n_a, 3)
58
- # atom_mask: (n_a,) or (n_m, n_a); plddt: (n_i,) or (n_s, n_i)
59
- coords = atom_coords.detach().cpu() # (n_a, 3) or (n_s, n_a, 3)
60
- if coords.ndim == 2:
61
- coords = coords.unsqueeze(0) # (1, n_a, 3)
62
- if coords.ndim != 3 or coords.shape[-1] != 3:
63
- raise ValueError(
64
- "Expected coordinate tensor X with shape (n_samples, n_atoms, 3)."
65
- )
66
- # coords: (n_s, n_a, 3) beyond this point
67
- if not 0 <= sample_index < coords.shape[0]:
68
- raise IndexError("sample_index out of range.")
69
- selected_coords_tensor = coords[sample_index] # (n_a, 3)
70
- all_non_finite = torch.logical_not(torch.isfinite(selected_coords_tensor)) # (n_a, 3)
71
- if torch.any(all_non_finite):
72
- raise ValueError(
73
- "CIF export received non-finite coordinates. "
74
- f"Non-finite count: {int(all_non_finite.sum().item())}"
75
- )
76
- selected_coords = selected_coords_tensor.numpy() # (n_a, 3)
77
-
78
- mask = atom_mask.detach().cpu() # (n_a,) or (n_m, n_a)
79
- if mask.ndim == 2:
80
- mask = mask[0] # (n_a,)
81
- if mask.ndim != 1:
82
- raise ValueError("Expected atom mask M with shape (n_atoms,).")
83
- # mask: (n_a,) beyond this point
84
- if mask.shape[0] != selected_coords.shape[0]:
85
- raise ValueError("Atom mask/coord size mismatch.")
86
- if not torch.any(mask > 0):
87
- raise ValueError("Atom mask has no valid atoms for CIF export.")
88
- valid_non_finite = torch.logical_not(torch.isfinite(selected_coords_tensor[mask > 0]))
89
- # valid_non_finite: (n_v, 3), where n_v is the valid-atom count
90
- if torch.any(valid_non_finite):
91
- raise ValueError(
92
- "CIF export has non-finite coordinates in unmasked atoms. "
93
- f"Non-finite count: {int(valid_non_finite.sum().item())}"
94
- )
95
-
96
- b_iso = _confidence_per_atom(
97
- plddt=plddt,
98
- atom_to_residue=structure_template.atom_residue_index,
99
- num_atoms=structure_template.num_atoms,
100
- sample_index=sample_index,
101
- ) # (n_a,)
102
- if b_iso.shape[0] != structure_template.num_atoms:
103
- raise RuntimeError("CIF confidence values do not match the structure atom count.")
104
-
105
- lines = [
106
- "data_boltz2_prediction",
107
- "#",
108
- "loop_",
109
- "_atom_site.group_PDB",
110
- "_atom_site.id",
111
- "_atom_site.type_symbol",
112
- "_atom_site.label_atom_id",
113
- "_atom_site.label_comp_id",
114
- "_atom_site.label_asym_id",
115
- "_atom_site.label_seq_id",
116
- "_atom_site.Cartn_x",
117
- "_atom_site.Cartn_y",
118
- "_atom_site.Cartn_z",
119
- "_atom_site.occupancy",
120
- "_atom_site.B_iso_or_equiv",
121
- "_atom_site.pdbx_PDB_model_num",
122
- ]
123
-
124
- atom_id = 1
125
- for idx in range(structure_template.num_atoms):
126
- if mask[idx] <= 0:
127
- continue
128
-
129
- residue_idx = structure_template.atom_residue_index[idx]
130
- residue_name = structure_template.residue_names[residue_idx]
131
- atom_name = structure_template.atom_names[idx]
132
- element = structure_template.atom_elements[idx]
133
- chain_id = structure_template.atom_chain_id[idx]
134
- x_val, y_val, z_val = selected_coords[idx].tolist() # (3,) -> three scalars
135
- b_factor = float(b_iso[idx]) # () -> Python float
136
-
137
- line = (
138
- f"ATOM {atom_id} {element} {atom_name} {residue_name} {chain_id} "
139
- f"{residue_idx + 1} {x_val:.3f} {y_val:.3f} {z_val:.3f} 1.00 {b_factor:.2f} 1"
140
- )
141
- lines.append(line)
142
- atom_id += 1
143
-
144
- lines.append("#")
145
- text = "\n".join(lines) + "\n"
146
-
147
- out_path = Path(output_path)
148
- out_path.parent.mkdir(parents=True, exist_ok=True)
149
- out_path.write_text(text, encoding="utf-8")
150
- return str(out_path)
 
1
+ """Write Boltz2 coordinates and confidence values as an mmCIF atom table."""
2
+
3
+ import numpy as np
4
+ import torch
5
+
6
+ from pathlib import Path
7
+
8
+ from .minimal_structures import ProteinStructureTemplate
9
+
10
+
11
+ def _confidence_per_atom(
12
+ plddt: torch.Tensor | None,
13
+ atom_to_residue: list[int],
14
+ num_atoms: int,
15
+ sample_index: int,
16
+ ) -> np.ndarray:
17
+ # plddt: (n_i,) or (n_s, n_i); atom_to_residue: length n_a; num_atoms: n_a
18
+ if plddt is None:
19
+ return np.ones((num_atoms,), dtype=np.float32) * 100.0 # (n_a,)
20
+
21
+ values = plddt.detach().cpu() # (n_i,) or (n_s, n_i)
22
+ if values.ndim == 1:
23
+ values = values.unsqueeze(0) # (1, n_i)
24
+ if values.ndim != 2:
25
+ raise ValueError("Expected pLDDT tensor S with shape (n_samples, n_items).")
26
+ # values: (n_s, n_i) beyond this point
27
+ if not 0 <= sample_index < values.shape[0]:
28
+ raise IndexError("sample_index out of range for pLDDT.")
29
+
30
+ selected = values[sample_index] # (n_i,)
31
+ if selected.shape[0] == num_atoms:
32
+ return (selected.numpy() * 100.0).astype(np.float32) # (n_a,)
33
+
34
+ num_residues = max(atom_to_residue) + 1 # n_r
35
+ if selected.shape[0] == num_residues:
36
+ expanded = np.zeros((num_atoms,), dtype=np.float32) # (n_a,)
37
+ selected_np = selected.numpy() # (n_r,)
38
+ for atom_idx, residue_idx in enumerate(atom_to_residue):
39
+ expanded[atom_idx] = selected_np[residue_idx] * 100.0 # () -> ()
40
+ return expanded # (n_a,)
41
+
42
+ raise ValueError(
43
+ "pLDDT item count must match either atoms or residues: "
44
+ f"received {selected.shape[0]}, expected {num_atoms} atoms or "
45
+ f"{num_residues} residues."
46
+ )
47
+
48
+
49
+ def write_cif(
50
+ structure_template: ProteinStructureTemplate,
51
+ atom_coords: torch.Tensor,
52
+ atom_mask: torch.Tensor,
53
+ output_path: str,
54
+ plddt: torch.Tensor | None = None,
55
+ sample_index: int = 0,
56
+ ) -> str:
57
+ # atom_coords: (n_a, 3) or (n_s, n_a, 3)
58
+ # atom_mask: (n_a,) or (n_m, n_a); plddt: (n_i,) or (n_s, n_i)
59
+ coords = atom_coords.detach().cpu() # (n_a, 3) or (n_s, n_a, 3)
60
+ if coords.ndim == 2:
61
+ coords = coords.unsqueeze(0) # (1, n_a, 3)
62
+ if coords.ndim != 3 or coords.shape[-1] != 3:
63
+ raise ValueError(
64
+ "Expected coordinate tensor X with shape (n_samples, n_atoms, 3)."
65
+ )
66
+ # coords: (n_s, n_a, 3) beyond this point
67
+ if not 0 <= sample_index < coords.shape[0]:
68
+ raise IndexError("sample_index out of range.")
69
+ selected_coords_tensor = coords[sample_index] # (n_a, 3)
70
+ all_non_finite = torch.logical_not(torch.isfinite(selected_coords_tensor)) # (n_a, 3)
71
+ if torch.any(all_non_finite):
72
+ raise ValueError(
73
+ "CIF export received non-finite coordinates. "
74
+ f"Non-finite count: {int(all_non_finite.sum().item())}"
75
+ )
76
+ selected_coords = selected_coords_tensor.numpy() # (n_a, 3)
77
+
78
+ mask = atom_mask.detach().cpu() # (n_a,) or (n_m, n_a)
79
+ if mask.ndim == 2:
80
+ mask = mask[0] # (n_a,)
81
+ if mask.ndim != 1:
82
+ raise ValueError("Expected atom mask M with shape (n_atoms,).")
83
+ # mask: (n_a,) beyond this point
84
+ if mask.shape[0] != selected_coords.shape[0]:
85
+ raise ValueError("Atom mask/coord size mismatch.")
86
+ if not torch.any(mask > 0):
87
+ raise ValueError("Atom mask has no valid atoms for CIF export.")
88
+ valid_non_finite = torch.logical_not(torch.isfinite(selected_coords_tensor[mask > 0]))
89
+ # valid_non_finite: (n_v, 3), where n_v is the valid-atom count
90
+ if torch.any(valid_non_finite):
91
+ raise ValueError(
92
+ "CIF export has non-finite coordinates in unmasked atoms. "
93
+ f"Non-finite count: {int(valid_non_finite.sum().item())}"
94
+ )
95
+
96
+ b_iso = _confidence_per_atom(
97
+ plddt=plddt,
98
+ atom_to_residue=structure_template.atom_residue_index,
99
+ num_atoms=structure_template.num_atoms,
100
+ sample_index=sample_index,
101
+ ) # (n_a,)
102
+ if b_iso.shape[0] != structure_template.num_atoms:
103
+ raise RuntimeError("CIF confidence values do not match the structure atom count.")
104
+
105
+ lines = [
106
+ "data_boltz2_prediction",
107
+ "#",
108
+ "loop_",
109
+ "_atom_site.group_PDB",
110
+ "_atom_site.id",
111
+ "_atom_site.type_symbol",
112
+ "_atom_site.label_atom_id",
113
+ "_atom_site.label_comp_id",
114
+ "_atom_site.label_asym_id",
115
+ "_atom_site.label_seq_id",
116
+ "_atom_site.Cartn_x",
117
+ "_atom_site.Cartn_y",
118
+ "_atom_site.Cartn_z",
119
+ "_atom_site.occupancy",
120
+ "_atom_site.B_iso_or_equiv",
121
+ "_atom_site.pdbx_PDB_model_num",
122
+ ]
123
+
124
+ atom_id = 1
125
+ for idx in range(structure_template.num_atoms):
126
+ if mask[idx] <= 0:
127
+ continue
128
+
129
+ residue_idx = structure_template.atom_residue_index[idx]
130
+ residue_name = structure_template.residue_names[residue_idx]
131
+ atom_name = structure_template.atom_names[idx]
132
+ element = structure_template.atom_elements[idx]
133
+ chain_id = structure_template.atom_chain_id[idx]
134
+ x_val, y_val, z_val = selected_coords[idx].tolist() # (3,) -> three scalars
135
+ b_factor = float(b_iso[idx]) # () -> Python float
136
+
137
+ line = (
138
+ f"ATOM {atom_id} {element} {atom_name} {residue_name} {chain_id} "
139
+ f"{residue_idx + 1} {x_val:.3f} {y_val:.3f} {z_val:.3f} 1.00 {b_factor:.2f} 1"
140
+ )
141
+ lines.append(line)
142
+ atom_id += 1
143
+
144
+ lines.append("#")
145
+ text = "\n".join(lines) + "\n"
146
+
147
+ out_path = Path(output_path)
148
+ out_path.parent.mkdir(parents=True, exist_ok=True)
149
+ out_path.write_text(text, encoding="utf-8")
150
+ return str(out_path)
fastplms/models/boltz/minimal_featurizer.py CHANGED
@@ -1,853 +1,853 @@
1
- """Construct Boltz2 protein features from canonical atom tables and conformers."""
2
-
3
- import math
4
- import numpy as np
5
- import torch
6
-
7
- from torch.nn.functional import one_hot
8
-
9
- from . import vb_const as const
10
- from .minimal_structures import ProteinStructureTemplate
11
-
12
-
13
- _ELEMENT_TO_Z = {
14
- "H": 1,
15
- "C": 6,
16
- "N": 7,
17
- "O": 8,
18
- "P": 15,
19
- "S": 16,
20
- }
21
-
22
- # Canonical formal charges and tetrahedral chirality values extracted from the
23
- # pinned Boltz2 molecule archive. Values use Boltz2's public feature encoding.
24
- _FORMAL_CHARGES = {
25
- ("ARG", "NH1"): 1.0,
26
- ("HIS", "ND1"): 1.0,
27
- ("LYS", "NZ"): 1.0,
28
- }
29
- _CHIRAL_ATOMS = {
30
- "ALA": frozenset({"CA"}),
31
- "ARG": frozenset({"CA"}),
32
- "ASN": frozenset({"CA"}),
33
- "ASP": frozenset({"CA"}),
34
- "CYS": frozenset({"CA"}),
35
- "GLN": frozenset({"CA"}),
36
- "GLU": frozenset({"CA"}),
37
- "HIS": frozenset({"CA"}),
38
- "ILE": frozenset({"CA", "CB"}),
39
- "LEU": frozenset({"CA"}),
40
- "LYS": frozenset({"CA"}),
41
- "MET": frozenset({"CA"}),
42
- "PHE": frozenset({"CA"}),
43
- "PRO": frozenset({"CA"}),
44
- "SER": frozenset({"CA"}),
45
- "THR": frozenset({"CA", "CB"}),
46
- "TRP": frozenset({"CA"}),
47
- "TYR": frozenset({"CA"}),
48
- "UNK": frozenset({"CA"}),
49
- "VAL": frozenset({"CA"}),
50
- }
51
-
52
-
53
- def _normalize_sequence(sequence: str) -> str:
54
- if not isinstance(sequence, str):
55
- raise TypeError("Amino acid sequence must be a string.")
56
- seq = sequence.strip().upper()
57
- if not seq:
58
- raise ValueError("Amino acid sequence must be non-empty.")
59
- for aa in seq:
60
- if aa not in const.prot_letter_to_token:
61
- raise ValueError(f"Unsupported residue code '{aa}'.")
62
- return seq
63
-
64
-
65
- def _atom_name_to_element(atom_name: str) -> str:
66
- name = atom_name.strip().upper()
67
- if len(name) == 0:
68
- return "C"
69
- if name[0].isdigit():
70
- name = name[1:]
71
- if len(name) >= 2 and name[0:2] in ("CL", "BR", "FE", "MG", "ZN", "NA", "CA"):
72
- return name[0]
73
- return name[0]
74
-
75
-
76
- def _atom_name_to_codes(atom_name: str) -> torch.Tensor:
77
- clipped = atom_name.strip()[:4]
78
- vals = [ord(ch) - 32 for ch in clipped]
79
- while len(vals) < 4:
80
- vals.append(0)
81
- out = torch.tensor(vals, dtype=torch.long) # (4,)
82
- if not (torch.all(out >= 0) and torch.all(out < 64)):
83
- raise ValueError(f"Invalid atom-name encoding for '{atom_name}'.")
84
- return out
85
-
86
-
87
- # Raw first-conformer coordinates extracted at full float32 precision from the
88
- # hash-pinned Boltz2 molecule archive. The feature builder applies the official
89
- # seeded centering, rotation, and translation policy below.
90
- _RDKIT_CONFORMERS: dict[str, dict[str, list[float]]] = {
91
- "ALA": {
92
- "N": [-0.9241582155227661, 1.1821246147155762, 0.712748110294342],
93
- "CA": [-0.2663755416870117, -0.08827890455722809, 0.4008508622646332],
94
- "C": [1.1188693046569824, 0.1387452930212021, -0.14366498589515686],
95
- "O": [1.2882297039031982, 0.8058497309684753, -1.2001261711120605],
96
- "CB": [-1.1134333610534668, -0.8915302753448486, -0.5876986384391785],
97
- },
98
- "ARG": {
99
- "N": [3.253326416015625, -1.699564814567566, -0.9852627515792847],
100
- "CA": [2.191511392593384, -0.692007839679718, -0.9506462812423706],
101
- "C": [2.80021333694458, 0.672307550907135, -1.1160222291946411],
102
- "O": [2.465440034866333, 1.3987600803375244, -2.0899009704589844],
103
- "CB": [1.3811039924621582, -0.7949594855308533, 0.36316436529159546],
104
- "CG": [0.20344306528568268, 0.19287171959877014, 0.457282155752182],
105
- "CD": [-0.902410626411438, -0.09953747689723969, -0.5671625137329102],
106
- "NE": [-2.0867979526519775, 0.7146661281585693, -0.2911669909954071],
107
- "CZ": [-3.018158197402954, 0.4804283082485199, 0.7831193804740906],
108
- "NH1": [-4.080137729644775, 1.4154621362686157, 0.9938872456550598],
109
- "NH2": [-2.9226150512695312, -0.5637124180793762, 1.5533623695373535],
110
- },
111
- "ASN": {
112
- "N": [-1.5767980813980103, -1.7227835655212402, 0.2427234649658203],
113
- "CA": [-0.7041534781455994, -0.5622008442878723, 0.42767149209976196],
114
- "C": [-1.246688961982727, 0.6072975993156433, -0.3483567237854004],
115
- "O": [-1.554918885231018, 0.47644123435020447, -1.5638835430145264],
116
- "CB": [0.7263866662979126, -0.9032993316650391, -0.028588544577360153],
117
- "CG": [1.6871562004089355, 0.20810098946094513, 0.28412675857543945],
118
- "OD1": [2.19687557220459, 0.28916797041893005, 1.433493971824646],
119
- "ND2": [1.996717095375061, 1.196706771850586, -0.6981719136238098],
120
- },
121
- "ASP": {
122
- "N": [-0.11774874478578568, -1.6310220956802368, 0.374360054731369],
123
- "CA": [-0.3847021758556366, -0.19290897250175476, 0.28121331334114075],
124
- "C": [-1.806337594985962, 0.059408850967884064, -0.1472136378288269],
125
- "O": [-2.2237648963928223, -0.36817988753318787, -1.257502555847168],
126
- "CB": [0.5981687307357788, 0.4785478413105011, -0.6920451521873474],
127
- "CG": [2.0088446140289307, 0.36018264293670654, -0.20304732024669647],
128
- "OD1": [2.747267961502075, -0.5711832046508789, -0.6216948628425598],
129
- "OD2": [2.49090313911438, 1.2577818632125854, 0.7443997859954834],
130
- },
131
- "CYS": {
132
- "N": [-0.058702241629362106, 1.771048665046692, 0.2434082329273224],
133
- "CA": [-0.06546340137720108, 0.47663936018943787, -0.4456723928451538],
134
- "C": [-1.273919701576233, -0.3504071533679962, -0.08361759781837463],
135
- "O": [-1.7324607372283936, -0.34313708543777466, 1.0909277200698853],
136
- "CB": [1.2432398796081543, -0.28917407989501953, -0.19254730641841888],
137
- "SG": [1.484184980392456, -0.7191365361213684, 1.5649693012237549],
138
- },
139
- "GLN": {
140
- "N": [-1.8543237447738647, -1.0024770498275757, -1.6278940439224243],
141
- "CA": [-1.292184591293335, -0.6786512732505798, -0.3153715133666992],
142
- "C": [-2.2264492511749268, 0.25171151757240295, 0.4067918062210083],
143
- "O": [-2.7255098819732666, -0.08834805339574814, 1.5129321813583374],
144
- "CB": [0.11662524193525314, -0.06213820353150368, -0.45663803815841675],
145
- "CG": [0.8140791058540344, 0.10461423546075821, 0.9002283811569214],
146
- "CD": [2.195122003555298, 0.6569428443908691, 0.7154991030693054],
147
- "OE1": [2.3870291709899902, 1.9003654718399048, 0.7825236916542053],
148
- "NE2": [3.287371873855591, -0.21307705342769623, 0.41959717869758606],
149
- },
150
- "GLU": {
151
- "N": [-1.3492857217788696, -1.114271640777588, -1.3739068508148193],
152
- "CA": [-1.2765676975250244, -0.506334662437439, -0.04287439212203026],
153
- "C": [-1.9195666313171387, 0.8547031879425049, -0.04214814677834511],
154
- "O": [-1.9091347455978394, 1.5701956748962402, -1.080504298210144],
155
- "CB": [0.18272103369235992, -0.43209096789360046, 0.45213374495506287],
156
- "CG": [1.1058456897735596, 0.3659400939941406, -0.481784850358963],
157
- "CD": [2.5090205669403076, 0.35135790705680847, 0.03646872192621231],
158
- "OE1": [2.896414041519165, 1.2445733547210693, 0.8365193605422974],
159
- "OE2": [3.3727896213531494, -0.6754258275032043, -0.33141785860061646],
160
- },
161
- "GLY": {
162
- "N": [-1.291549801826477, 0.6080796122550964, -0.4228580892086029],
163
- "CA": [-0.4895951449871063, -0.2882237136363983, 0.40191715955734253],
164
- "C": [0.9350062608718872, -0.2543502449989319, -0.04786944016814232],
165
- "O": [1.3473576307296753, -1.0836502313613892, -0.9022219777107239],
166
- },
167
- "HIS": {
168
- "N": [1.0371263027191162, -1.5621215105056763, 0.4178937077522278],
169
- "CA": [1.1980191469192505, -0.40241551399230957, -0.46312251687049866],
170
- "C": [2.6561269760131836, -0.07123222947120667, -0.6407948136329651],
171
- "O": [3.1795547008514404, -0.12371637672185898, -1.7859554290771484],
172
- "CB": [0.44051393866539, 0.8116919994354248, 0.09850303083658218],
173
- "CG": [-1.0356801748275757, 0.5519806742668152, 0.15254715085029602],
174
- "ND1": [-1.9089158773422241, 0.6490958333015442, -0.968245267868042],
175
- "CD2": [-1.7095766067504883, 0.1138235554099083, 1.2061808109283447],
176
- "CE1": [-3.0751633644104004, 0.28129827976226807, -0.5511555075645447],
177
- "NE2": [-3.0650453567504883, -0.07846951484680176, 0.8257263898849487],
178
- },
179
- "ILE": {
180
- "N": [-1.2378733158111572, -1.8146690130233765, -0.16437499225139618],
181
- "CA": [-1.2780015468597412, -0.3994033634662628, 0.22437156736850739],
182
- "C": [-1.9879958629608154, 0.4497596025466919, -0.80372154712677],
183
- "O": [-2.0291709899902344, 0.10288607329130173, -2.015406608581543],
184
- "CB": [0.14225825667381287, 0.146738663315773, 0.5415176153182983],
185
- "CG1": [1.1062071323394775, 0.04519597440958023, -0.6694174408912659],
186
- "CG2": [0.7215384244918823, -0.5685611963272095, 1.7750537395477295],
187
- "CD1": [2.371805191040039, 0.8852734565734863, -0.49045464396476746],
188
- },
189
- "LEU": {
190
- "N": [1.6452248096466064, -1.017622470855713, -1.0626215934753418],
191
- "CA": [1.3129304647445679, 0.08074887096881866, -0.15053576231002808],
192
- "C": [2.431154727935791, 0.28310757875442505, 0.8357383608818054],
193
- "O": [2.9231173992156982, -0.7013152837753296, 1.451209306716919],
194
- "CB": [0.006050171796232462, -0.2141101360321045, 0.6198461651802063],
195
- "CG": [-1.2534235715866089, -0.3772832751274109, -0.26637399196624756],
196
- "CD1": [-2.455688714981079, -0.7604416012763977, 0.6070646643638611],
197
- "CD2": [-1.5742294788360596, 0.8985711932182312, -1.059889554977417],
198
- },
199
- "LYS": {
200
- "N": [-2.3938536643981934, -1.4751482009887695, -0.9336304068565369],
201
- "CA": [-2.2094509601593018, -0.6203567981719971, 0.2421264797449112],
202
- "C": [-3.4513463973999023, 0.1961250752210617, 0.4710204303264618],
203
- "O": [-3.9688456058502197, 0.2503468096256256, 1.6188287734985352],
204
- "CB": [-1.008726716041565, 0.3269270658493042, 0.0479682981967926],
205
- "CG": [0.336457222700119, -0.41559070348739624, 0.047045741230249405],
206
- "CD": [1.5088860988616943, 0.5716065168380737, -0.00632853340357542],
207
- "CE": [2.853410005569458, -0.1678532361984253, -0.020898228511214256],
208
- "NZ": [3.9690465927124023, 0.7765668630599976, -0.07007527351379395],
209
- },
210
- "MET": {
211
- "N": [-1.743452548980713, -0.44039490818977356, 1.851780652999878],
212
- "CA": [-1.2168668508529663, 0.4041755199432373, 0.7779996991157532],
213
- "C": [-2.3118340969085693, 0.683431088924408, -0.2134547382593155],
214
- "O": [-2.6660380363464355, 1.8705639839172363, -0.44476020336151123],
215
- "CB": [-0.0005482881097123027, -0.262584388256073, 0.09933014959096909],
216
- "CG": [0.6952047944068909, 0.6768104434013367, -0.8930339813232422],
217
- "SD": [2.136805772781372, -0.14913176000118256, -1.6563159227371216],
218
- "CE": [3.3404407501220703, 0.348065584897995, -0.38104408979415894],
219
- },
220
- "PHE": {
221
- "N": [2.7976438999176025, -1.6209964752197266, -0.4779351055622101],
222
- "CA": [1.5799994468688965, -0.8289614915847778, -0.6608061194419861],
223
- "C": [1.9535551071166992, 0.5758142471313477, -1.0445665121078491],
224
- "O": [1.5211102962493896, 1.072593331336975, -2.1190123558044434],
225
- "CB": [0.7137221097946167, -0.8474348187446594, 0.6163292527198792],
226
- "CG": [-0.6229045987129211, -0.18666480481624603, 0.3912639319896698],
227
- "CD1": [-0.8259273767471313, 1.0978055000305176, 0.7381877303123474],
228
- "CD2": [-1.7160141468048096, -0.9425657391548157, -0.2700555920600891],
229
- "CE1": [-2.131312131881714, 1.741182565689087, 0.4800238013267517],
230
- "CE2": [-2.8960046768188477, -0.3551671802997589, -0.505269467830658],
231
- "CZ": [-3.11456298828125, 1.0511738061904907, -0.11009909957647324],
232
- },
233
- "PRO": {
234
- "N": [-0.577339768409729, -0.5123927593231201, -1.150407314300537],
235
- "CA": [0.5119893550872803, 0.23565764725208282, -0.5195383429527283],
236
- "C": [1.8393173217773438, -0.46712079644203186, -0.6550371646881104],
237
- "O": [2.0836586952209473, -1.166333556175232, -1.6751221418380737],
238
- "CB": [0.1029478907585144, 0.45346882939338684, 0.9297074675559998],
239
- "CG": [-1.4098080396652222, 0.5215330123901367, 0.8787456750869751],
240
- "CD": [-1.7971025705337524, -0.06041542813181877, -0.47876954078674316],
241
- },
242
- "SER": {
243
- "N": [0.8959873914718628, -1.423298716545105, -0.2692987024784088],
244
- "CA": [-0.018787339329719543, -0.28751760721206665, -0.3863537311553955],
245
- "C": [0.767615795135498, 0.9929561018943787, -0.42147380113601685],
246
- "O": [0.5327092409133911, 1.8535151481628418, -1.3116248846054077],
247
- "CB": [-0.9973848462104797, -0.2769756019115448, 0.7988247871398926],
248
- "OG": [-1.8999865055084229, 0.7915746569633484, 0.691534698009491],
249
- },
250
- "THR": {
251
- "N": [0.005045319441705942, 1.7655576467514038, 0.0639338344335556],
252
- "CA": [0.4231603145599365, 0.3755212128162384, 0.2734847664833069],
253
- "C": [1.7184523344039917, 0.11012311279773712, -0.4465082287788391],
254
- "O": [1.9252793788909912, 0.5967074036598206, -1.5912789106369019],
255
- "CB": [-0.6634359359741211, -0.615561306476593, -0.21173778176307678],
256
- "OG1": [-1.0762907266616821, -0.29800114035606384, -1.5171915292739868],
257
- "CG2": [-1.8869022130966187, -0.6176311373710632, 0.7084044218063354],
258
- },
259
- "TRP": {
260
- "N": [-3.0439648628234863, 1.0628471374511719, 0.015692168846726418],
261
- "CA": [-2.463679552078247, 0.024657616391777992, -0.8414031863212585],
262
- "C": [-2.186711072921753, -1.2228965759277344, -0.04691409692168236],
263
- "O": [-2.3364906311035156, -2.3535354137420654, -0.5830773115158081],
264
- "CB": [-1.2062920331954956, 0.5195542573928833, -1.592403531074524],
265
- "CG": [-0.06450498104095459, 0.884079098701477, -0.6835198402404785],
266
- "CD1": [0.16109998524188995, 2.085163116455078, -0.14801783859729767],
267
- "CD2": [0.9781660437583923, -0.005544683896005154, -0.17733941972255707],
268
- "NE1": [1.3106837272644043, 2.0506503582000732, 0.698072075843811],
269
- "CE2": [1.7528777122497559, 0.7044392228126526, 0.6196392774581909],
270
- "CE3": [1.236093521118164, -1.4355946779251099, -0.4235689342021942],
271
- "CZ2": [2.9070138931274414, 0.10196790844202042, 1.3070299625396729],
272
- "CZ3": [2.2851555347442627, -2.007185697555542, 0.1949695348739624],
273
- "CH2": [3.1585631370544434, -1.2034809589385986, 1.1002938747406006],
274
- },
275
- "TYR": {
276
- "N": [-1.7950150966644287, 0.49119046330451965, -1.3951144218444824],
277
- "CA": [-1.8436503410339355, -0.2694968581199646, -0.14338290691375732],
278
- "C": [-3.240288019180298, -0.28215137124061584, 0.41974759101867676],
279
- "O": [-3.8150112628936768, 0.7977103590965271, 0.7253130674362183],
280
- "CB": [-0.8541494011878967, 0.30990728735923767, 0.8830214738845825],
281
- "CG": [0.5672639012336731, 0.2125871181488037, 0.3916495442390442],
282
- "CD1": [1.2694358825683594, -0.924761176109314, 0.5431669354438782],
283
- "CD2": [1.1872504949569702, 1.3605931997299194, -0.31554359197616577],
284
- "CE1": [2.6519992351531982, -1.0213464498519897, 0.029911600053310394],
285
- "CE2": [2.4384164810180664, 1.2693941593170166, -0.7831515073776245],
286
- "CZ": [3.211331367492676, 0.022552935406565666, -0.6021429300308228],
287
- "OH": [4.513507843017578, -0.05260590463876724, -1.0929937362670898],
288
- },
289
- "VAL": {
290
- "N": [0.9408224821090698, -1.2608877420425415, 0.652370810508728],
291
- "CA": [0.7287879586219788, -0.3937721848487854, -0.5120788216590881],
292
- "C": [1.7670493125915527, 0.6979415416717529, -0.5508439540863037],
293
- "O": [2.2449657917022705, 1.1683598756790161, 0.5171348452568054],
294
- "CB": [-0.7015937566757202, 0.21480272710323334, -0.5226467847824097],
295
- "CG1": [-1.7662651538848877, -0.845522403717041, -0.8411717414855957],
296
- "CG2": [-1.0500152111053467, 0.9374979138374329, 0.7892321944236755],
297
- },
298
- "UNK": {
299
- "N": [1.6134154796600342, -1.304404377937317, -0.35241976380348206],
300
- "CA": [0.6100443601608276, -0.23816797137260437, -0.3852081298828125],
301
- "C": [1.0861350297927856, 0.9232651591300964, 0.4423142373561859],
302
- "O": [1.374688982963562, 0.7629808187484741, 1.6591525077819824],
303
- "CB": [-0.7607048153877258, -0.7552146315574646, 0.10266945511102676],
304
- },
305
- }
306
-
307
-
308
- def _get_atom_position(res_name: str, atom_name: str, atom_idx: int) -> np.ndarray:
309
- """Get the canonical RDKit conformer position for an atom.
310
-
311
- Uses pre-extracted positions from official Boltz2 mol files. Falls back
312
- to a simple geometric placement for unknown residue/atom combinations.
313
- """
314
- if res_name in _RDKIT_CONFORMERS and atom_name in _RDKIT_CONFORMERS[res_name]:
315
- return np.array(_RDKIT_CONFORMERS[res_name][atom_name], dtype=np.float32) # (3,)
316
- # Fallback for unknown atoms (should not happen for canonical AAs)
317
- angle = (atom_idx + 1) * 0.7
318
- radius = 1.4 + 0.03 * atom_idx
319
- return np.array(
320
- [
321
- radius * math.cos(angle),
322
- radius * math.sin(angle),
323
- 0.1 * ((atom_idx % 5) - 2),
324
- ],
325
- dtype=np.float32,
326
- ) # (3,)
327
-
328
-
329
- def _build_template(
330
- sequence: str,
331
- ) -> tuple[
332
- ProteinStructureTemplate,
333
- list[str],
334
- list[int],
335
- list[int],
336
- list[int],
337
- list[np.ndarray],
338
- list[int],
339
- ]:
340
- residue_names: list[str] = []
341
- residue_token_ids: list[int] = []
342
- atom_names: list[str] = []
343
- atom_elements: list[str] = []
344
- atom_residue_index: list[int] = []
345
- atom_chain_id: list[str] = []
346
- atom_positions: list[np.ndarray] = [] # each: (3,)
347
- residue_center_atom_idx: list[int] = []
348
- residue_disto_atom_idx: list[int] = []
349
- residue_frame_atom_idx: list[int] = []
350
-
351
- global_atom_idx = 0
352
- for res_idx, aa in enumerate(sequence):
353
- token_name = const.prot_letter_to_token[aa]
354
- residue_names.append(token_name)
355
- residue_token_ids.append(const.token_ids[token_name])
356
-
357
- residue_atoms = const.ref_atoms[token_name]
358
- if not residue_atoms:
359
- raise RuntimeError(f"No reference atoms for residue {token_name}.")
360
- center_atom_name = const.res_to_center_atom[token_name]
361
- disto_atom_name = const.res_to_disto_atom[token_name]
362
-
363
- center_idx = -1
364
- disto_idx = -1
365
- n_idx = -1
366
- ca_idx = -1
367
- c_idx = -1
368
-
369
- for local_idx, atom_name in enumerate(residue_atoms):
370
- atom_names.append(atom_name)
371
- element = _atom_name_to_element(atom_name)
372
- atom_elements.append(element)
373
- atom_residue_index.append(res_idx)
374
- atom_chain_id.append("A")
375
-
376
- atom_pos = _get_atom_position(token_name, atom_name, local_idx) # (3,)
377
- atom_positions.append(atom_pos)
378
-
379
- if atom_name == center_atom_name:
380
- center_idx = global_atom_idx
381
- if atom_name == disto_atom_name:
382
- disto_idx = global_atom_idx
383
- if atom_name == "N":
384
- n_idx = global_atom_idx
385
- if atom_name == "CA":
386
- ca_idx = global_atom_idx
387
- if atom_name == "C":
388
- c_idx = global_atom_idx
389
- global_atom_idx += 1
390
-
391
- if center_idx == -1:
392
- center_idx = global_atom_idx - len(residue_atoms)
393
- if disto_idx == -1:
394
- disto_idx = center_idx
395
- if n_idx == -1:
396
- n_idx = center_idx
397
- if ca_idx == -1:
398
- ca_idx = center_idx
399
- if c_idx == -1:
400
- c_idx = center_idx
401
-
402
- residue_center_atom_idx.append(center_idx)
403
- residue_disto_atom_idx.append(disto_idx)
404
- residue_frame_atom_idx.extend([n_idx, ca_idx, c_idx])
405
-
406
- template = ProteinStructureTemplate(
407
- sequence=sequence,
408
- residue_names=residue_names,
409
- atom_names=atom_names,
410
- atom_elements=atom_elements,
411
- atom_residue_index=atom_residue_index,
412
- atom_chain_id=atom_chain_id,
413
- )
414
-
415
- return (
416
- template,
417
- residue_names,
418
- residue_token_ids,
419
- residue_center_atom_idx,
420
- residue_disto_atom_idx,
421
- atom_positions,
422
- residue_frame_atom_idx,
423
- )
424
-
425
-
426
- def _random_rotation_matrix() -> torch.Tensor:
427
- """Sample a uniform random 3x3 rotation matrix (Algorithm 19 from AF2/Boltz)."""
428
- quaternion = torch.randn((1, 4), dtype=torch.float32) # (1, 4)
429
- squared_norm = (quaternion * quaternion).sum(dim=1) # (1,)
430
- norm = torch.sqrt(squared_norm) # (1,)
431
- signed_norm = torch.where(quaternion[:, 0] < 0, -norm, norm) # (1,)
432
- quaternion = quaternion / signed_norm[:, None] # (1, 4)
433
-
434
- real, i, j, k = torch.unbind(quaternion, dim=-1) # each: (1,)
435
- two_s = 2.0 / (quaternion * quaternion).sum(dim=-1) # (1,)
436
- rotation = torch.stack(
437
- (
438
- 1 - two_s * (j * j + k * k),
439
- two_s * (i * j - k * real),
440
- two_s * (i * k + j * real),
441
- two_s * (i * j + k * real),
442
- 1 - two_s * (i * i + k * k),
443
- two_s * (j * k - i * real),
444
- two_s * (i * k - j * real),
445
- two_s * (j * k + i * real),
446
- 1 - two_s * (i * i + j * j),
447
- ),
448
- dim=-1,
449
- ) # (1, 9)
450
- return rotation.reshape(1, 3, 3)[0] # (3, 3)
451
-
452
-
453
- def _center_and_augment_atoms_per_residue(
454
- atom_positions: torch.Tensor,
455
- atom_residue_index: list[int],
456
- num_residues: int,
457
- ) -> torch.Tensor:
458
- """Apply Boltz2's seeded conformer augmentation per residue.
459
-
460
- The pinned implementation intentionally excludes the final residue because
461
- it iterates to the maximum reference-space identifier rather than through it.
462
- """
463
- # atom_positions: (a, 3), where a is the unpadded atom count.
464
- result = atom_positions.clone() # (a, 3)
465
- residue_index_tensor = torch.tensor(atom_residue_index, dtype=torch.long) # (a,)
466
- for residue_idx in range(max(num_residues - 1, 0)):
467
- residue_mask = residue_index_tensor == residue_idx # (a,)
468
- if not torch.any(residue_mask):
469
- raise RuntimeError(f"Residue index {residue_idx} has no atoms.")
470
- residue_coords = result[residue_mask][None] # (1, a_r, 3)
471
- resolved_mask = torch.ones(
472
- residue_coords.shape[:2], dtype=torch.bool, device=residue_coords.device
473
- ) # (1, a_r)
474
- residue_center = torch.sum(
475
- residue_coords * resolved_mask[:, :, None], dim=1, keepdim=True
476
- ) / torch.sum(resolved_mask[:, :, None], dim=1, keepdim=True) # (1, 1, 3)
477
- residue_coords = residue_coords - residue_center # (1, a_r, 3)
478
- rotation = _random_rotation_matrix()[None] # (1, 3, 3)
479
- residue_coords = torch.einsum(
480
- "bmd,bds->bms", residue_coords, rotation
481
- ) # (1, a_r, 3)
482
- residue_coords = (
483
- residue_coords + torch.randn_like(residue_coords[:, 0:1, :])
484
- ) # (1, a_r, 3)
485
- result[residue_mask] = residue_coords[0] # (a_r, 3)
486
- return result # (a, 3)
487
-
488
-
489
- def build_boltz2_features(
490
- amino_acid_sequence: str,
491
- num_bins: int = 64,
492
- atoms_per_window_queries: int = 32,
493
- ) -> tuple[dict[str, torch.Tensor], ProteinStructureTemplate]:
494
- sequence = _normalize_sequence(amino_acid_sequence)
495
- (
496
- template,
497
- residue_names,
498
- residue_token_ids,
499
- residue_center_atom_idx,
500
- residue_disto_atom_idx,
501
- atom_positions_np,
502
- residue_frame_atom_idx_flat,
503
- ) = _build_template(sequence)
504
-
505
- num_tokens = len(residue_names)
506
- num_atoms = len(atom_positions_np)
507
- if num_tokens <= 0 or num_atoms <= 0:
508
- raise RuntimeError("Boltz2 feature construction produced an empty protein template.")
509
-
510
- # t is the token count, a is the unpadded atom count, and a_p is the padded atom count.
511
- atom_positions = torch.tensor(
512
- np.asarray(atom_positions_np), dtype=torch.float32
513
- ) # (a, 3)
514
- atom_positions = _center_and_augment_atoms_per_residue(
515
- atom_positions=atom_positions,
516
- atom_residue_index=template.atom_residue_index,
517
- num_residues=num_tokens,
518
- ) # (a, 3)
519
-
520
- token_index = torch.arange(num_tokens, dtype=torch.long).unsqueeze(0) # (1, t)
521
- residue_index = torch.arange(num_tokens, dtype=torch.long).unsqueeze(0) # (1, t)
522
- asym_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
523
- entity_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
524
- sym_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
525
- mol_type = torch.full(
526
- (1, num_tokens),
527
- fill_value=const.chain_type_ids["PROTEIN"],
528
- dtype=torch.long,
529
- ) # (1, t)
530
-
531
- res_type_ids = torch.tensor(residue_token_ids, dtype=torch.long) # (t,)
532
- res_type = one_hot(
533
- res_type_ids, num_classes=const.num_tokens
534
- ).unsqueeze(0) # (1, t, n_res_type)
535
-
536
- # token_bonds encodes explicit covalent cross-links from structure bonds,
537
- # NOT backbone peptide bonds (those are implicit via residue_index + asym_id).
538
- # For a standard single-chain protein without cross-links, this is all zeros.
539
- # This matches the official Boltz2 featurizer (featurizerv2.py lines 696-705).
540
- token_bonds = torch.zeros((num_tokens, num_tokens), dtype=torch.float32) # (t, t)
541
- type_bonds = torch.zeros((num_tokens, num_tokens), dtype=torch.long) # (t, t)
542
- token_bonds = token_bonds.unsqueeze(0).unsqueeze(-1) # (1, t, t, 1)
543
- type_bonds = type_bonds.unsqueeze(0) # (1, t, t)
544
-
545
- token_pad_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
546
- token_resolved_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
547
- token_disto_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
548
-
549
- num_contact_classes = len(const.contact_conditioning_info)
550
- unspecified_id = const.contact_conditioning_info["UNSPECIFIED"]
551
- contact_ids = torch.full(
552
- (num_tokens, num_tokens),
553
- fill_value=unspecified_id,
554
- dtype=torch.long,
555
- ) # (t, t)
556
- contact_conditioning = one_hot(
557
- contact_ids,
558
- num_classes=num_contact_classes,
559
- ).unsqueeze(0) # (1, t, t, n_contact)
560
- contact_threshold = torch.zeros(
561
- (1, num_tokens, num_tokens), dtype=torch.float32
562
- ) # (1, t, t)
563
-
564
- if "x-ray diffraction" not in const.method_types_ids:
565
- raise RuntimeError("Boltz2 method metadata omits x-ray diffraction.")
566
- method_feature = torch.full(
567
- (1, num_tokens),
568
- fill_value=const.method_types_ids["x-ray diffraction"],
569
- dtype=torch.long,
570
- ) # (1, t)
571
- modified = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
572
- cyclic_period = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
573
- affinity_token_mask = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
574
-
575
- ref_pos = atom_positions.unsqueeze(0) # (1, a, 3)
576
- atom_pad_mask = torch.ones((1, num_atoms), dtype=torch.float32) # (1, a)
577
- atom_resolved_mask = torch.ones((1, num_atoms), dtype=torch.bool) # (1, a)
578
-
579
- atom_name_codes = torch.stack(
580
- [_atom_name_to_codes(atom_name) for atom_name in template.atom_names],
581
- dim=0,
582
- ) # (a, 4)
583
- ref_atom_name_chars = one_hot(
584
- atom_name_codes, num_classes=64
585
- ).unsqueeze(0) # (1, a, 4, 64)
586
-
587
- atomic_numbers = []
588
- for element in template.atom_elements:
589
- z_value = _ELEMENT_TO_Z[element] if element in _ELEMENT_TO_Z else _ELEMENT_TO_Z["C"]
590
- if z_value >= const.num_elements:
591
- raise RuntimeError(
592
- f"Atomic number {z_value} exceeds the Boltz2 element vocabulary."
593
- )
594
- atomic_numbers.append(z_value)
595
- ref_element = one_hot(
596
- torch.tensor(atomic_numbers, dtype=torch.long),
597
- num_classes=const.num_elements,
598
- ).unsqueeze(0) # (1, a, n_element)
599
-
600
- ref_charge_values = []
601
- ref_chirality_values = []
602
- for atom_name, residue_idx in zip(
603
- template.atom_names,
604
- template.atom_residue_index,
605
- strict=True,
606
- ):
607
- residue_name = residue_names[residue_idx]
608
- ref_charge_values.append(_FORMAL_CHARGES.get((residue_name, atom_name), 0.0))
609
- ref_chirality_values.append(
610
- 2 if atom_name in _CHIRAL_ATOMS.get(residue_name, frozenset()) else 0
611
- )
612
- ref_charge = torch.tensor(ref_charge_values, dtype=torch.float32).unsqueeze(0) # (1, a)
613
- ref_chirality = torch.tensor(
614
- ref_chirality_values, dtype=torch.long
615
- ).unsqueeze(0) # (1, a)
616
- ref_space_uid = torch.tensor(
617
- template.atom_residue_index, dtype=torch.long
618
- ).unsqueeze(0) # (1, a)
619
-
620
- atom_to_token = one_hot(
621
- torch.tensor(template.atom_residue_index, dtype=torch.long),
622
- num_classes=num_tokens,
623
- ).unsqueeze(0) # (1, a, t)
624
- token_to_rep_atom = one_hot(
625
- torch.tensor(residue_disto_atom_idx, dtype=torch.long),
626
- num_classes=num_atoms,
627
- ).unsqueeze(0) # (1, t, a)
628
- token_to_center_atom = one_hot(
629
- torch.tensor(residue_center_atom_idx, dtype=torch.long),
630
- num_classes=num_atoms,
631
- ).unsqueeze(0) # (1, t, a)
632
- r_set_to_rep_atom = token_to_center_atom.clone() # (1, t, a)
633
-
634
- num_backbone_classes = (
635
- 1 + len(const.protein_backbone_atom_index) + len(const.nucleic_backbone_atom_index)
636
- )
637
- backbone_ids = []
638
- for atom_name in template.atom_names:
639
- if atom_name in const.protein_backbone_atom_index:
640
- backbone_ids.append(const.protein_backbone_atom_index[atom_name] + 1)
641
- else:
642
- backbone_ids.append(0)
643
- atom_backbone_feat = one_hot(
644
- torch.tensor(backbone_ids, dtype=torch.long),
645
- num_classes=num_backbone_classes,
646
- ).unsqueeze(0) # (1, a, n_backbone)
647
-
648
- # X contains no observed coordinates for sequence-only inference.
649
- coords = torch.zeros((1, 1, num_atoms, 3), dtype=torch.float32) # (1, 1, a, 3)
650
- disto_coords_ensemble = torch.zeros(
651
- (1, 1, num_tokens, 3),
652
- dtype=torch.float32,
653
- ) # (1, 1, t, 3)
654
-
655
- bfactor = torch.zeros((1, num_atoms), dtype=torch.float32) # (1, a)
656
- atom_plddt = torch.ones((1, num_atoms), dtype=torch.float32) # (1, a)
657
-
658
- if atoms_per_window_queries <= 0:
659
- raise ValueError("atoms_per_window_queries must be positive.")
660
- pad_atoms = (
661
- (num_atoms - 1) // atoms_per_window_queries + 1
662
- ) * atoms_per_window_queries - num_atoms
663
- if pad_atoms > 0:
664
- ref_pos = torch.nn.functional.pad(
665
- ref_pos, (0, 0, 0, pad_atoms), value=0.0
666
- ) # (1, a_p, 3)
667
- atom_pad_mask = torch.nn.functional.pad(
668
- atom_pad_mask, (0, pad_atoms), value=0.0
669
- ) # (1, a_p)
670
- atom_resolved_mask = torch.nn.functional.pad(
671
- atom_resolved_mask,
672
- (0, pad_atoms),
673
- value=0.0,
674
- ) # (1, a_p)
675
- ref_atom_name_chars = torch.nn.functional.pad(
676
- ref_atom_name_chars,
677
- (0, 0, 0, 0, 0, pad_atoms),
678
- value=0.0,
679
- ) # (1, a_p, 4, 64)
680
- ref_element = torch.nn.functional.pad(
681
- ref_element, (0, 0, 0, pad_atoms), value=0.0
682
- ) # (1, a_p, n_element)
683
- ref_charge = torch.nn.functional.pad(
684
- ref_charge, (0, pad_atoms), value=0.0
685
- ) # (1, a_p)
686
- ref_chirality = torch.nn.functional.pad(
687
- ref_chirality, (0, pad_atoms), value=0
688
- ) # (1, a_p)
689
- atom_backbone_feat = torch.nn.functional.pad(
690
- atom_backbone_feat,
691
- (0, 0, 0, pad_atoms),
692
- value=0.0,
693
- ) # (1, a_p, n_backbone)
694
- ref_space_uid = torch.nn.functional.pad(
695
- ref_space_uid, (0, pad_atoms), value=0
696
- ) # (1, a_p)
697
- coords = torch.nn.functional.pad(
698
- coords, (0, 0, 0, pad_atoms), value=0.0
699
- ) # (1, 1, a_p, 3)
700
- atom_to_token = torch.nn.functional.pad(
701
- atom_to_token, (0, 0, 0, pad_atoms), value=0.0
702
- ) # (1, a_p, t)
703
- token_to_rep_atom = torch.nn.functional.pad(
704
- token_to_rep_atom,
705
- (0, pad_atoms),
706
- value=0.0,
707
- ) # (1, t, a_p)
708
- token_to_center_atom = torch.nn.functional.pad(
709
- token_to_center_atom,
710
- (0, pad_atoms),
711
- value=0.0,
712
- ) # (1, t, a_p)
713
- r_set_to_rep_atom = torch.nn.functional.pad(
714
- r_set_to_rep_atom,
715
- (0, pad_atoms),
716
- value=0.0,
717
- ) # (1, t, a_p)
718
- bfactor = torch.nn.functional.pad(
719
- bfactor, (0, pad_atoms), value=0.0
720
- ) # (1, a_p)
721
- atom_plddt = torch.nn.functional.pad(
722
- atom_plddt, (0, pad_atoms), value=0.0
723
- ) # (1, a_p)
724
-
725
- frames_idx = torch.tensor(
726
- residue_frame_atom_idx_flat,
727
- dtype=torch.long,
728
- ).reshape(num_tokens, 3) # (t, 3)
729
- frames_idx = frames_idx.unsqueeze(0).unsqueeze(1) # (1, 1, t, 3)
730
- frame_resolved_mask = torch.zeros(
731
- (1, 1, num_tokens), dtype=torch.bool
732
- ) # (1, 1, t)
733
-
734
- msa = (
735
- torch.tensor(residue_token_ids, dtype=torch.long).unsqueeze(0).unsqueeze(0)
736
- ) # (1, 1, t)
737
- msa_paired = torch.ones((1, 1, num_tokens), dtype=torch.float32) # (1, 1, t)
738
- deletion_value = torch.zeros(
739
- (1, 1, num_tokens), dtype=torch.float32
740
- ) # (1, 1, t)
741
- has_deletion = torch.zeros((1, 1, num_tokens), dtype=torch.bool) # (1, 1, t)
742
- msa_mask = torch.ones((1, 1, num_tokens), dtype=torch.long) # (1, 1, t)
743
- deletion_mean = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
744
- profile = (
745
- one_hot(
746
- torch.tensor(residue_token_ids, dtype=torch.long),
747
- num_classes=const.num_tokens,
748
- )
749
- .float()
750
- .unsqueeze(0)
751
- ) # (1, t, n_res_type)
752
-
753
- template_restype = one_hot(
754
- torch.zeros((1, 1, num_tokens), dtype=torch.long),
755
- num_classes=const.num_tokens,
756
- ) # (1, 1, t, n_res_type)
757
- template_frame_rot = torch.zeros(
758
- (1, 1, num_tokens, 3, 3), dtype=torch.float32
759
- ) # (1, 1, t, 3, 3)
760
- template_frame_t = torch.zeros(
761
- (1, 1, num_tokens, 3), dtype=torch.float32
762
- ) # (1, 1, t, 3)
763
- template_cb = torch.zeros(
764
- (1, 1, num_tokens, 3), dtype=torch.float32
765
- ) # (1, 1, t, 3)
766
- template_ca = torch.zeros(
767
- (1, 1, num_tokens, 3), dtype=torch.float32
768
- ) # (1, 1, t, 3)
769
- template_mask_cb = torch.zeros(
770
- (1, 1, num_tokens), dtype=torch.float32
771
- ) # (1, 1, t)
772
- template_mask_frame = torch.zeros(
773
- (1, 1, num_tokens), dtype=torch.float32
774
- ) # (1, 1, t)
775
- template_mask = torch.zeros(
776
- (1, 1, num_tokens), dtype=torch.float32
777
- ) # (1, 1, t)
778
- query_to_template = torch.zeros(
779
- (1, 1, num_tokens), dtype=torch.long
780
- ) # (1, 1, t)
781
- visibility_ids = torch.zeros(
782
- (1, 1, num_tokens), dtype=torch.float32
783
- ) # (1, 1, t)
784
-
785
- disto_target = torch.zeros(
786
- (1, num_tokens, num_tokens, 1, num_bins),
787
- dtype=torch.float32,
788
- ) # (1, t, t, 1, n_bin)
789
- disto_target[..., 0] = 1.0 # (1, t, t, 1)
790
- disto_center = torch.zeros(
791
- (1, num_tokens, 3), dtype=torch.float32
792
- ) # (1, t, 3)
793
-
794
- features: dict[str, torch.Tensor] = {
795
- "token_index": token_index,
796
- "residue_index": residue_index,
797
- "asym_id": asym_id,
798
- "entity_id": entity_id,
799
- "sym_id": sym_id,
800
- "mol_type": mol_type,
801
- "res_type": res_type,
802
- "disto_center": disto_center,
803
- "token_bonds": token_bonds,
804
- "type_bonds": type_bonds,
805
- "token_pad_mask": token_pad_mask,
806
- "token_resolved_mask": token_resolved_mask,
807
- "token_disto_mask": token_disto_mask,
808
- "contact_conditioning": contact_conditioning,
809
- "contact_threshold": contact_threshold,
810
- "method_feature": method_feature,
811
- "modified": modified,
812
- "cyclic_period": cyclic_period,
813
- "affinity_token_mask": affinity_token_mask,
814
- "ref_pos": ref_pos,
815
- "atom_resolved_mask": atom_resolved_mask,
816
- "ref_atom_name_chars": ref_atom_name_chars,
817
- "ref_element": ref_element,
818
- "ref_charge": ref_charge,
819
- "ref_chirality": ref_chirality,
820
- "atom_backbone_feat": atom_backbone_feat,
821
- "ref_space_uid": ref_space_uid,
822
- "coords": coords,
823
- "atom_pad_mask": atom_pad_mask,
824
- "atom_to_token": atom_to_token,
825
- "token_to_rep_atom": token_to_rep_atom,
826
- "r_set_to_rep_atom": r_set_to_rep_atom,
827
- "token_to_center_atom": token_to_center_atom,
828
- "disto_target": disto_target,
829
- "disto_coords_ensemble": disto_coords_ensemble,
830
- "bfactor": bfactor,
831
- "plddt": atom_plddt,
832
- "frames_idx": frames_idx,
833
- "frame_resolved_mask": frame_resolved_mask,
834
- "msa": msa,
835
- "msa_paired": msa_paired,
836
- "deletion_value": deletion_value,
837
- "has_deletion": has_deletion,
838
- "deletion_mean": deletion_mean,
839
- "profile": profile,
840
- "msa_mask": msa_mask,
841
- "template_restype": template_restype,
842
- "template_frame_rot": template_frame_rot,
843
- "template_frame_t": template_frame_t,
844
- "template_cb": template_cb,
845
- "template_ca": template_ca,
846
- "template_mask_cb": template_mask_cb,
847
- "template_mask_frame": template_mask_frame,
848
- "template_mask": template_mask,
849
- "query_to_template": query_to_template,
850
- "visibility_ids": visibility_ids,
851
- }
852
-
853
- return features, template # each feature shape is traced at its binding above
 
1
+ """Construct Boltz2 protein features from canonical atom tables and conformers."""
2
+
3
+ import math
4
+ import numpy as np
5
+ import torch
6
+
7
+ from torch.nn.functional import one_hot
8
+
9
+ from . import vb_const as const
10
+ from .minimal_structures import ProteinStructureTemplate
11
+
12
+
13
+ _ELEMENT_TO_Z = {
14
+ "H": 1,
15
+ "C": 6,
16
+ "N": 7,
17
+ "O": 8,
18
+ "P": 15,
19
+ "S": 16,
20
+ }
21
+
22
+ # Canonical formal charges and tetrahedral chirality values extracted from the
23
+ # pinned Boltz2 molecule archive. Values use Boltz2's public feature encoding.
24
+ _FORMAL_CHARGES = {
25
+ ("ARG", "NH1"): 1.0,
26
+ ("HIS", "ND1"): 1.0,
27
+ ("LYS", "NZ"): 1.0,
28
+ }
29
+ _CHIRAL_ATOMS = {
30
+ "ALA": frozenset({"CA"}),
31
+ "ARG": frozenset({"CA"}),
32
+ "ASN": frozenset({"CA"}),
33
+ "ASP": frozenset({"CA"}),
34
+ "CYS": frozenset({"CA"}),
35
+ "GLN": frozenset({"CA"}),
36
+ "GLU": frozenset({"CA"}),
37
+ "HIS": frozenset({"CA"}),
38
+ "ILE": frozenset({"CA", "CB"}),
39
+ "LEU": frozenset({"CA"}),
40
+ "LYS": frozenset({"CA"}),
41
+ "MET": frozenset({"CA"}),
42
+ "PHE": frozenset({"CA"}),
43
+ "PRO": frozenset({"CA"}),
44
+ "SER": frozenset({"CA"}),
45
+ "THR": frozenset({"CA", "CB"}),
46
+ "TRP": frozenset({"CA"}),
47
+ "TYR": frozenset({"CA"}),
48
+ "UNK": frozenset({"CA"}),
49
+ "VAL": frozenset({"CA"}),
50
+ }
51
+
52
+
53
+ def _normalize_sequence(sequence: str) -> str:
54
+ if not isinstance(sequence, str):
55
+ raise TypeError("Amino acid sequence must be a string.")
56
+ seq = sequence.strip().upper()
57
+ if not seq:
58
+ raise ValueError("Amino acid sequence must be non-empty.")
59
+ for aa in seq:
60
+ if aa not in const.prot_letter_to_token:
61
+ raise ValueError(f"Unsupported residue code '{aa}'.")
62
+ return seq
63
+
64
+
65
+ def _atom_name_to_element(atom_name: str) -> str:
66
+ name = atom_name.strip().upper()
67
+ if len(name) == 0:
68
+ return "C"
69
+ if name[0].isdigit():
70
+ name = name[1:]
71
+ if len(name) >= 2 and name[0:2] in ("CL", "BR", "FE", "MG", "ZN", "NA", "CA"):
72
+ return name[0]
73
+ return name[0]
74
+
75
+
76
+ def _atom_name_to_codes(atom_name: str) -> torch.Tensor:
77
+ clipped = atom_name.strip()[:4]
78
+ vals = [ord(ch) - 32 for ch in clipped]
79
+ while len(vals) < 4:
80
+ vals.append(0)
81
+ out = torch.tensor(vals, dtype=torch.long) # (4,)
82
+ if not (torch.all(out >= 0) and torch.all(out < 64)):
83
+ raise ValueError(f"Invalid atom-name encoding for '{atom_name}'.")
84
+ return out
85
+
86
+
87
+ # Raw first-conformer coordinates extracted at full float32 precision from the
88
+ # hash-pinned Boltz2 molecule archive. The feature builder applies the official
89
+ # seeded centering, rotation, and translation policy below.
90
+ _RDKIT_CONFORMERS: dict[str, dict[str, list[float]]] = {
91
+ "ALA": {
92
+ "N": [-0.9241582155227661, 1.1821246147155762, 0.712748110294342],
93
+ "CA": [-0.2663755416870117, -0.08827890455722809, 0.4008508622646332],
94
+ "C": [1.1188693046569824, 0.1387452930212021, -0.14366498589515686],
95
+ "O": [1.2882297039031982, 0.8058497309684753, -1.2001261711120605],
96
+ "CB": [-1.1134333610534668, -0.8915302753448486, -0.5876986384391785],
97
+ },
98
+ "ARG": {
99
+ "N": [3.253326416015625, -1.699564814567566, -0.9852627515792847],
100
+ "CA": [2.191511392593384, -0.692007839679718, -0.9506462812423706],
101
+ "C": [2.80021333694458, 0.672307550907135, -1.1160222291946411],
102
+ "O": [2.465440034866333, 1.3987600803375244, -2.0899009704589844],
103
+ "CB": [1.3811039924621582, -0.7949594855308533, 0.36316436529159546],
104
+ "CG": [0.20344306528568268, 0.19287171959877014, 0.457282155752182],
105
+ "CD": [-0.902410626411438, -0.09953747689723969, -0.5671625137329102],
106
+ "NE": [-2.0867979526519775, 0.7146661281585693, -0.2911669909954071],
107
+ "CZ": [-3.018158197402954, 0.4804283082485199, 0.7831193804740906],
108
+ "NH1": [-4.080137729644775, 1.4154621362686157, 0.9938872456550598],
109
+ "NH2": [-2.9226150512695312, -0.5637124180793762, 1.5533623695373535],
110
+ },
111
+ "ASN": {
112
+ "N": [-1.5767980813980103, -1.7227835655212402, 0.2427234649658203],
113
+ "CA": [-0.7041534781455994, -0.5622008442878723, 0.42767149209976196],
114
+ "C": [-1.246688961982727, 0.6072975993156433, -0.3483567237854004],
115
+ "O": [-1.554918885231018, 0.47644123435020447, -1.5638835430145264],
116
+ "CB": [0.7263866662979126, -0.9032993316650391, -0.028588544577360153],
117
+ "CG": [1.6871562004089355, 0.20810098946094513, 0.28412675857543945],
118
+ "OD1": [2.19687557220459, 0.28916797041893005, 1.433493971824646],
119
+ "ND2": [1.996717095375061, 1.196706771850586, -0.6981719136238098],
120
+ },
121
+ "ASP": {
122
+ "N": [-0.11774874478578568, -1.6310220956802368, 0.374360054731369],
123
+ "CA": [-0.3847021758556366, -0.19290897250175476, 0.28121331334114075],
124
+ "C": [-1.806337594985962, 0.059408850967884064, -0.1472136378288269],
125
+ "O": [-2.2237648963928223, -0.36817988753318787, -1.257502555847168],
126
+ "CB": [0.5981687307357788, 0.4785478413105011, -0.6920451521873474],
127
+ "CG": [2.0088446140289307, 0.36018264293670654, -0.20304732024669647],
128
+ "OD1": [2.747267961502075, -0.5711832046508789, -0.6216948628425598],
129
+ "OD2": [2.49090313911438, 1.2577818632125854, 0.7443997859954834],
130
+ },
131
+ "CYS": {
132
+ "N": [-0.058702241629362106, 1.771048665046692, 0.2434082329273224],
133
+ "CA": [-0.06546340137720108, 0.47663936018943787, -0.4456723928451538],
134
+ "C": [-1.273919701576233, -0.3504071533679962, -0.08361759781837463],
135
+ "O": [-1.7324607372283936, -0.34313708543777466, 1.0909277200698853],
136
+ "CB": [1.2432398796081543, -0.28917407989501953, -0.19254730641841888],
137
+ "SG": [1.484184980392456, -0.7191365361213684, 1.5649693012237549],
138
+ },
139
+ "GLN": {
140
+ "N": [-1.8543237447738647, -1.0024770498275757, -1.6278940439224243],
141
+ "CA": [-1.292184591293335, -0.6786512732505798, -0.3153715133666992],
142
+ "C": [-2.2264492511749268, 0.25171151757240295, 0.4067918062210083],
143
+ "O": [-2.7255098819732666, -0.08834805339574814, 1.5129321813583374],
144
+ "CB": [0.11662524193525314, -0.06213820353150368, -0.45663803815841675],
145
+ "CG": [0.8140791058540344, 0.10461423546075821, 0.9002283811569214],
146
+ "CD": [2.195122003555298, 0.6569428443908691, 0.7154991030693054],
147
+ "OE1": [2.3870291709899902, 1.9003654718399048, 0.7825236916542053],
148
+ "NE2": [3.287371873855591, -0.21307705342769623, 0.41959717869758606],
149
+ },
150
+ "GLU": {
151
+ "N": [-1.3492857217788696, -1.114271640777588, -1.3739068508148193],
152
+ "CA": [-1.2765676975250244, -0.506334662437439, -0.04287439212203026],
153
+ "C": [-1.9195666313171387, 0.8547031879425049, -0.04214814677834511],
154
+ "O": [-1.9091347455978394, 1.5701956748962402, -1.080504298210144],
155
+ "CB": [0.18272103369235992, -0.43209096789360046, 0.45213374495506287],
156
+ "CG": [1.1058456897735596, 0.3659400939941406, -0.481784850358963],
157
+ "CD": [2.5090205669403076, 0.35135790705680847, 0.03646872192621231],
158
+ "OE1": [2.896414041519165, 1.2445733547210693, 0.8365193605422974],
159
+ "OE2": [3.3727896213531494, -0.6754258275032043, -0.33141785860061646],
160
+ },
161
+ "GLY": {
162
+ "N": [-1.291549801826477, 0.6080796122550964, -0.4228580892086029],
163
+ "CA": [-0.4895951449871063, -0.2882237136363983, 0.40191715955734253],
164
+ "C": [0.9350062608718872, -0.2543502449989319, -0.04786944016814232],
165
+ "O": [1.3473576307296753, -1.0836502313613892, -0.9022219777107239],
166
+ },
167
+ "HIS": {
168
+ "N": [1.0371263027191162, -1.5621215105056763, 0.4178937077522278],
169
+ "CA": [1.1980191469192505, -0.40241551399230957, -0.46312251687049866],
170
+ "C": [2.6561269760131836, -0.07123222947120667, -0.6407948136329651],
171
+ "O": [3.1795547008514404, -0.12371637672185898, -1.7859554290771484],
172
+ "CB": [0.44051393866539, 0.8116919994354248, 0.09850303083658218],
173
+ "CG": [-1.0356801748275757, 0.5519806742668152, 0.15254715085029602],
174
+ "ND1": [-1.9089158773422241, 0.6490958333015442, -0.968245267868042],
175
+ "CD2": [-1.7095766067504883, 0.1138235554099083, 1.2061808109283447],
176
+ "CE1": [-3.0751633644104004, 0.28129827976226807, -0.5511555075645447],
177
+ "NE2": [-3.0650453567504883, -0.07846951484680176, 0.8257263898849487],
178
+ },
179
+ "ILE": {
180
+ "N": [-1.2378733158111572, -1.8146690130233765, -0.16437499225139618],
181
+ "CA": [-1.2780015468597412, -0.3994033634662628, 0.22437156736850739],
182
+ "C": [-1.9879958629608154, 0.4497596025466919, -0.80372154712677],
183
+ "O": [-2.0291709899902344, 0.10288607329130173, -2.015406608581543],
184
+ "CB": [0.14225825667381287, 0.146738663315773, 0.5415176153182983],
185
+ "CG1": [1.1062071323394775, 0.04519597440958023, -0.6694174408912659],
186
+ "CG2": [0.7215384244918823, -0.5685611963272095, 1.7750537395477295],
187
+ "CD1": [2.371805191040039, 0.8852734565734863, -0.49045464396476746],
188
+ },
189
+ "LEU": {
190
+ "N": [1.6452248096466064, -1.017622470855713, -1.0626215934753418],
191
+ "CA": [1.3129304647445679, 0.08074887096881866, -0.15053576231002808],
192
+ "C": [2.431154727935791, 0.28310757875442505, 0.8357383608818054],
193
+ "O": [2.9231173992156982, -0.7013152837753296, 1.451209306716919],
194
+ "CB": [0.006050171796232462, -0.2141101360321045, 0.6198461651802063],
195
+ "CG": [-1.2534235715866089, -0.3772832751274109, -0.26637399196624756],
196
+ "CD1": [-2.455688714981079, -0.7604416012763977, 0.6070646643638611],
197
+ "CD2": [-1.5742294788360596, 0.8985711932182312, -1.059889554977417],
198
+ },
199
+ "LYS": {
200
+ "N": [-2.3938536643981934, -1.4751482009887695, -0.9336304068565369],
201
+ "CA": [-2.2094509601593018, -0.6203567981719971, 0.2421264797449112],
202
+ "C": [-3.4513463973999023, 0.1961250752210617, 0.4710204303264618],
203
+ "O": [-3.9688456058502197, 0.2503468096256256, 1.6188287734985352],
204
+ "CB": [-1.008726716041565, 0.3269270658493042, 0.0479682981967926],
205
+ "CG": [0.336457222700119, -0.41559070348739624, 0.047045741230249405],
206
+ "CD": [1.5088860988616943, 0.5716065168380737, -0.00632853340357542],
207
+ "CE": [2.853410005569458, -0.1678532361984253, -0.020898228511214256],
208
+ "NZ": [3.9690465927124023, 0.7765668630599976, -0.07007527351379395],
209
+ },
210
+ "MET": {
211
+ "N": [-1.743452548980713, -0.44039490818977356, 1.851780652999878],
212
+ "CA": [-1.2168668508529663, 0.4041755199432373, 0.7779996991157532],
213
+ "C": [-2.3118340969085693, 0.683431088924408, -0.2134547382593155],
214
+ "O": [-2.6660380363464355, 1.8705639839172363, -0.44476020336151123],
215
+ "CB": [-0.0005482881097123027, -0.262584388256073, 0.09933014959096909],
216
+ "CG": [0.6952047944068909, 0.6768104434013367, -0.8930339813232422],
217
+ "SD": [2.136805772781372, -0.14913176000118256, -1.6563159227371216],
218
+ "CE": [3.3404407501220703, 0.348065584897995, -0.38104408979415894],
219
+ },
220
+ "PHE": {
221
+ "N": [2.7976438999176025, -1.6209964752197266, -0.4779351055622101],
222
+ "CA": [1.5799994468688965, -0.8289614915847778, -0.6608061194419861],
223
+ "C": [1.9535551071166992, 0.5758142471313477, -1.0445665121078491],
224
+ "O": [1.5211102962493896, 1.072593331336975, -2.1190123558044434],
225
+ "CB": [0.7137221097946167, -0.8474348187446594, 0.6163292527198792],
226
+ "CG": [-0.6229045987129211, -0.18666480481624603, 0.3912639319896698],
227
+ "CD1": [-0.8259273767471313, 1.0978055000305176, 0.7381877303123474],
228
+ "CD2": [-1.7160141468048096, -0.9425657391548157, -0.2700555920600891],
229
+ "CE1": [-2.131312131881714, 1.741182565689087, 0.4800238013267517],
230
+ "CE2": [-2.8960046768188477, -0.3551671802997589, -0.505269467830658],
231
+ "CZ": [-3.11456298828125, 1.0511738061904907, -0.11009909957647324],
232
+ },
233
+ "PRO": {
234
+ "N": [-0.577339768409729, -0.5123927593231201, -1.150407314300537],
235
+ "CA": [0.5119893550872803, 0.23565764725208282, -0.5195383429527283],
236
+ "C": [1.8393173217773438, -0.46712079644203186, -0.6550371646881104],
237
+ "O": [2.0836586952209473, -1.166333556175232, -1.6751221418380737],
238
+ "CB": [0.1029478907585144, 0.45346882939338684, 0.9297074675559998],
239
+ "CG": [-1.4098080396652222, 0.5215330123901367, 0.8787456750869751],
240
+ "CD": [-1.7971025705337524, -0.06041542813181877, -0.47876954078674316],
241
+ },
242
+ "SER": {
243
+ "N": [0.8959873914718628, -1.423298716545105, -0.2692987024784088],
244
+ "CA": [-0.018787339329719543, -0.28751760721206665, -0.3863537311553955],
245
+ "C": [0.767615795135498, 0.9929561018943787, -0.42147380113601685],
246
+ "O": [0.5327092409133911, 1.8535151481628418, -1.3116248846054077],
247
+ "CB": [-0.9973848462104797, -0.2769756019115448, 0.7988247871398926],
248
+ "OG": [-1.8999865055084229, 0.7915746569633484, 0.691534698009491],
249
+ },
250
+ "THR": {
251
+ "N": [0.005045319441705942, 1.7655576467514038, 0.0639338344335556],
252
+ "CA": [0.4231603145599365, 0.3755212128162384, 0.2734847664833069],
253
+ "C": [1.7184523344039917, 0.11012311279773712, -0.4465082287788391],
254
+ "O": [1.9252793788909912, 0.5967074036598206, -1.5912789106369019],
255
+ "CB": [-0.6634359359741211, -0.615561306476593, -0.21173778176307678],
256
+ "OG1": [-1.0762907266616821, -0.29800114035606384, -1.5171915292739868],
257
+ "CG2": [-1.8869022130966187, -0.6176311373710632, 0.7084044218063354],
258
+ },
259
+ "TRP": {
260
+ "N": [-3.0439648628234863, 1.0628471374511719, 0.015692168846726418],
261
+ "CA": [-2.463679552078247, 0.024657616391777992, -0.8414031863212585],
262
+ "C": [-2.186711072921753, -1.2228965759277344, -0.04691409692168236],
263
+ "O": [-2.3364906311035156, -2.3535354137420654, -0.5830773115158081],
264
+ "CB": [-1.2062920331954956, 0.5195542573928833, -1.592403531074524],
265
+ "CG": [-0.06450498104095459, 0.884079098701477, -0.6835198402404785],
266
+ "CD1": [0.16109998524188995, 2.085163116455078, -0.14801783859729767],
267
+ "CD2": [0.9781660437583923, -0.005544683896005154, -0.17733941972255707],
268
+ "NE1": [1.3106837272644043, 2.0506503582000732, 0.698072075843811],
269
+ "CE2": [1.7528777122497559, 0.7044392228126526, 0.6196392774581909],
270
+ "CE3": [1.236093521118164, -1.4355946779251099, -0.4235689342021942],
271
+ "CZ2": [2.9070138931274414, 0.10196790844202042, 1.3070299625396729],
272
+ "CZ3": [2.2851555347442627, -2.007185697555542, 0.1949695348739624],
273
+ "CH2": [3.1585631370544434, -1.2034809589385986, 1.1002938747406006],
274
+ },
275
+ "TYR": {
276
+ "N": [-1.7950150966644287, 0.49119046330451965, -1.3951144218444824],
277
+ "CA": [-1.8436503410339355, -0.2694968581199646, -0.14338290691375732],
278
+ "C": [-3.240288019180298, -0.28215137124061584, 0.41974759101867676],
279
+ "O": [-3.8150112628936768, 0.7977103590965271, 0.7253130674362183],
280
+ "CB": [-0.8541494011878967, 0.30990728735923767, 0.8830214738845825],
281
+ "CG": [0.5672639012336731, 0.2125871181488037, 0.3916495442390442],
282
+ "CD1": [1.2694358825683594, -0.924761176109314, 0.5431669354438782],
283
+ "CD2": [1.1872504949569702, 1.3605931997299194, -0.31554359197616577],
284
+ "CE1": [2.6519992351531982, -1.0213464498519897, 0.029911600053310394],
285
+ "CE2": [2.4384164810180664, 1.2693941593170166, -0.7831515073776245],
286
+ "CZ": [3.211331367492676, 0.022552935406565666, -0.6021429300308228],
287
+ "OH": [4.513507843017578, -0.05260590463876724, -1.0929937362670898],
288
+ },
289
+ "VAL": {
290
+ "N": [0.9408224821090698, -1.2608877420425415, 0.652370810508728],
291
+ "CA": [0.7287879586219788, -0.3937721848487854, -0.5120788216590881],
292
+ "C": [1.7670493125915527, 0.6979415416717529, -0.5508439540863037],
293
+ "O": [2.2449657917022705, 1.1683598756790161, 0.5171348452568054],
294
+ "CB": [-0.7015937566757202, 0.21480272710323334, -0.5226467847824097],
295
+ "CG1": [-1.7662651538848877, -0.845522403717041, -0.8411717414855957],
296
+ "CG2": [-1.0500152111053467, 0.9374979138374329, 0.7892321944236755],
297
+ },
298
+ "UNK": {
299
+ "N": [1.6134154796600342, -1.304404377937317, -0.35241976380348206],
300
+ "CA": [0.6100443601608276, -0.23816797137260437, -0.3852081298828125],
301
+ "C": [1.0861350297927856, 0.9232651591300964, 0.4423142373561859],
302
+ "O": [1.374688982963562, 0.7629808187484741, 1.6591525077819824],
303
+ "CB": [-0.7607048153877258, -0.7552146315574646, 0.10266945511102676],
304
+ },
305
+ }
306
+
307
+
308
+ def _get_atom_position(res_name: str, atom_name: str, atom_idx: int) -> np.ndarray:
309
+ """Get the canonical RDKit conformer position for an atom.
310
+
311
+ Uses pre-extracted positions from official Boltz2 mol files. Falls back
312
+ to a simple geometric placement for unknown residue/atom combinations.
313
+ """
314
+ if res_name in _RDKIT_CONFORMERS and atom_name in _RDKIT_CONFORMERS[res_name]:
315
+ return np.array(_RDKIT_CONFORMERS[res_name][atom_name], dtype=np.float32) # (3,)
316
+ # Fallback for unknown atoms (should not happen for canonical AAs)
317
+ angle = (atom_idx + 1) * 0.7
318
+ radius = 1.4 + 0.03 * atom_idx
319
+ return np.array(
320
+ [
321
+ radius * math.cos(angle),
322
+ radius * math.sin(angle),
323
+ 0.1 * ((atom_idx % 5) - 2),
324
+ ],
325
+ dtype=np.float32,
326
+ ) # (3,)
327
+
328
+
329
+ def _build_template(
330
+ sequence: str,
331
+ ) -> tuple[
332
+ ProteinStructureTemplate,
333
+ list[str],
334
+ list[int],
335
+ list[int],
336
+ list[int],
337
+ list[np.ndarray],
338
+ list[int],
339
+ ]:
340
+ residue_names: list[str] = []
341
+ residue_token_ids: list[int] = []
342
+ atom_names: list[str] = []
343
+ atom_elements: list[str] = []
344
+ atom_residue_index: list[int] = []
345
+ atom_chain_id: list[str] = []
346
+ atom_positions: list[np.ndarray] = [] # each: (3,)
347
+ residue_center_atom_idx: list[int] = []
348
+ residue_disto_atom_idx: list[int] = []
349
+ residue_frame_atom_idx: list[int] = []
350
+
351
+ global_atom_idx = 0
352
+ for res_idx, aa in enumerate(sequence):
353
+ token_name = const.prot_letter_to_token[aa]
354
+ residue_names.append(token_name)
355
+ residue_token_ids.append(const.token_ids[token_name])
356
+
357
+ residue_atoms = const.ref_atoms[token_name]
358
+ if not residue_atoms:
359
+ raise RuntimeError(f"No reference atoms for residue {token_name}.")
360
+ center_atom_name = const.res_to_center_atom[token_name]
361
+ disto_atom_name = const.res_to_disto_atom[token_name]
362
+
363
+ center_idx = -1
364
+ disto_idx = -1
365
+ n_idx = -1
366
+ ca_idx = -1
367
+ c_idx = -1
368
+
369
+ for local_idx, atom_name in enumerate(residue_atoms):
370
+ atom_names.append(atom_name)
371
+ element = _atom_name_to_element(atom_name)
372
+ atom_elements.append(element)
373
+ atom_residue_index.append(res_idx)
374
+ atom_chain_id.append("A")
375
+
376
+ atom_pos = _get_atom_position(token_name, atom_name, local_idx) # (3,)
377
+ atom_positions.append(atom_pos)
378
+
379
+ if atom_name == center_atom_name:
380
+ center_idx = global_atom_idx
381
+ if atom_name == disto_atom_name:
382
+ disto_idx = global_atom_idx
383
+ if atom_name == "N":
384
+ n_idx = global_atom_idx
385
+ if atom_name == "CA":
386
+ ca_idx = global_atom_idx
387
+ if atom_name == "C":
388
+ c_idx = global_atom_idx
389
+ global_atom_idx += 1
390
+
391
+ if center_idx == -1:
392
+ center_idx = global_atom_idx - len(residue_atoms)
393
+ if disto_idx == -1:
394
+ disto_idx = center_idx
395
+ if n_idx == -1:
396
+ n_idx = center_idx
397
+ if ca_idx == -1:
398
+ ca_idx = center_idx
399
+ if c_idx == -1:
400
+ c_idx = center_idx
401
+
402
+ residue_center_atom_idx.append(center_idx)
403
+ residue_disto_atom_idx.append(disto_idx)
404
+ residue_frame_atom_idx.extend([n_idx, ca_idx, c_idx])
405
+
406
+ template = ProteinStructureTemplate(
407
+ sequence=sequence,
408
+ residue_names=residue_names,
409
+ atom_names=atom_names,
410
+ atom_elements=atom_elements,
411
+ atom_residue_index=atom_residue_index,
412
+ atom_chain_id=atom_chain_id,
413
+ )
414
+
415
+ return (
416
+ template,
417
+ residue_names,
418
+ residue_token_ids,
419
+ residue_center_atom_idx,
420
+ residue_disto_atom_idx,
421
+ atom_positions,
422
+ residue_frame_atom_idx,
423
+ )
424
+
425
+
426
+ def _random_rotation_matrix() -> torch.Tensor:
427
+ """Sample a uniform random 3x3 rotation matrix (Algorithm 19 from AF2/Boltz)."""
428
+ quaternion = torch.randn((1, 4), dtype=torch.float32) # (1, 4)
429
+ squared_norm = (quaternion * quaternion).sum(dim=1) # (1,)
430
+ norm = torch.sqrt(squared_norm) # (1,)
431
+ signed_norm = torch.where(quaternion[:, 0] < 0, -norm, norm) # (1,)
432
+ quaternion = quaternion / signed_norm[:, None] # (1, 4)
433
+
434
+ real, i, j, k = torch.unbind(quaternion, dim=-1) # each: (1,)
435
+ two_s = 2.0 / (quaternion * quaternion).sum(dim=-1) # (1,)
436
+ rotation = torch.stack(
437
+ (
438
+ 1 - two_s * (j * j + k * k),
439
+ two_s * (i * j - k * real),
440
+ two_s * (i * k + j * real),
441
+ two_s * (i * j + k * real),
442
+ 1 - two_s * (i * i + k * k),
443
+ two_s * (j * k - i * real),
444
+ two_s * (i * k - j * real),
445
+ two_s * (j * k + i * real),
446
+ 1 - two_s * (i * i + j * j),
447
+ ),
448
+ dim=-1,
449
+ ) # (1, 9)
450
+ return rotation.reshape(1, 3, 3)[0] # (3, 3)
451
+
452
+
453
+ def _center_and_augment_atoms_per_residue(
454
+ atom_positions: torch.Tensor,
455
+ atom_residue_index: list[int],
456
+ num_residues: int,
457
+ ) -> torch.Tensor:
458
+ """Apply Boltz2's seeded conformer augmentation per residue.
459
+
460
+ The pinned implementation intentionally excludes the final residue because
461
+ it iterates to the maximum reference-space identifier rather than through it.
462
+ """
463
+ # atom_positions: (a, 3), where a is the unpadded atom count.
464
+ result = atom_positions.clone() # (a, 3)
465
+ residue_index_tensor = torch.tensor(atom_residue_index, dtype=torch.long) # (a,)
466
+ for residue_idx in range(max(num_residues - 1, 0)):
467
+ residue_mask = residue_index_tensor == residue_idx # (a,)
468
+ if not torch.any(residue_mask):
469
+ raise RuntimeError(f"Residue index {residue_idx} has no atoms.")
470
+ residue_coords = result[residue_mask][None] # (1, a_r, 3)
471
+ resolved_mask = torch.ones(
472
+ residue_coords.shape[:2], dtype=torch.bool, device=residue_coords.device
473
+ ) # (1, a_r)
474
+ residue_center = torch.sum(
475
+ residue_coords * resolved_mask[:, :, None], dim=1, keepdim=True
476
+ ) / torch.sum(resolved_mask[:, :, None], dim=1, keepdim=True) # (1, 1, 3)
477
+ residue_coords = residue_coords - residue_center # (1, a_r, 3)
478
+ rotation = _random_rotation_matrix()[None] # (1, 3, 3)
479
+ residue_coords = torch.einsum(
480
+ "bmd,bds->bms", residue_coords, rotation
481
+ ) # (1, a_r, 3)
482
+ residue_coords = (
483
+ residue_coords + torch.randn_like(residue_coords[:, 0:1, :])
484
+ ) # (1, a_r, 3)
485
+ result[residue_mask] = residue_coords[0] # (a_r, 3)
486
+ return result # (a, 3)
487
+
488
+
489
+ def build_boltz2_features(
490
+ amino_acid_sequence: str,
491
+ num_bins: int = 64,
492
+ atoms_per_window_queries: int = 32,
493
+ ) -> tuple[dict[str, torch.Tensor], ProteinStructureTemplate]:
494
+ sequence = _normalize_sequence(amino_acid_sequence)
495
+ (
496
+ template,
497
+ residue_names,
498
+ residue_token_ids,
499
+ residue_center_atom_idx,
500
+ residue_disto_atom_idx,
501
+ atom_positions_np,
502
+ residue_frame_atom_idx_flat,
503
+ ) = _build_template(sequence)
504
+
505
+ num_tokens = len(residue_names)
506
+ num_atoms = len(atom_positions_np)
507
+ if num_tokens <= 0 or num_atoms <= 0:
508
+ raise RuntimeError("Boltz2 feature construction produced an empty protein template.")
509
+
510
+ # t is the token count, a is the unpadded atom count, and a_p is the padded atom count.
511
+ atom_positions = torch.tensor(
512
+ np.asarray(atom_positions_np), dtype=torch.float32
513
+ ) # (a, 3)
514
+ atom_positions = _center_and_augment_atoms_per_residue(
515
+ atom_positions=atom_positions,
516
+ atom_residue_index=template.atom_residue_index,
517
+ num_residues=num_tokens,
518
+ ) # (a, 3)
519
+
520
+ token_index = torch.arange(num_tokens, dtype=torch.long).unsqueeze(0) # (1, t)
521
+ residue_index = torch.arange(num_tokens, dtype=torch.long).unsqueeze(0) # (1, t)
522
+ asym_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
523
+ entity_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
524
+ sym_id = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
525
+ mol_type = torch.full(
526
+ (1, num_tokens),
527
+ fill_value=const.chain_type_ids["PROTEIN"],
528
+ dtype=torch.long,
529
+ ) # (1, t)
530
+
531
+ res_type_ids = torch.tensor(residue_token_ids, dtype=torch.long) # (t,)
532
+ res_type = one_hot(
533
+ res_type_ids, num_classes=const.num_tokens
534
+ ).unsqueeze(0) # (1, t, n_res_type)
535
+
536
+ # token_bonds encodes explicit covalent cross-links from structure bonds,
537
+ # NOT backbone peptide bonds (those are implicit via residue_index + asym_id).
538
+ # For a standard single-chain protein without cross-links, this is all zeros.
539
+ # This matches the official Boltz2 featurizer (featurizerv2.py lines 696-705).
540
+ token_bonds = torch.zeros((num_tokens, num_tokens), dtype=torch.float32) # (t, t)
541
+ type_bonds = torch.zeros((num_tokens, num_tokens), dtype=torch.long) # (t, t)
542
+ token_bonds = token_bonds.unsqueeze(0).unsqueeze(-1) # (1, t, t, 1)
543
+ type_bonds = type_bonds.unsqueeze(0) # (1, t, t)
544
+
545
+ token_pad_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
546
+ token_resolved_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
547
+ token_disto_mask = torch.ones((1, num_tokens), dtype=torch.float32) # (1, t)
548
+
549
+ num_contact_classes = len(const.contact_conditioning_info)
550
+ unspecified_id = const.contact_conditioning_info["UNSPECIFIED"]
551
+ contact_ids = torch.full(
552
+ (num_tokens, num_tokens),
553
+ fill_value=unspecified_id,
554
+ dtype=torch.long,
555
+ ) # (t, t)
556
+ contact_conditioning = one_hot(
557
+ contact_ids,
558
+ num_classes=num_contact_classes,
559
+ ).unsqueeze(0) # (1, t, t, n_contact)
560
+ contact_threshold = torch.zeros(
561
+ (1, num_tokens, num_tokens), dtype=torch.float32
562
+ ) # (1, t, t)
563
+
564
+ if "x-ray diffraction" not in const.method_types_ids:
565
+ raise RuntimeError("Boltz2 method metadata omits x-ray diffraction.")
566
+ method_feature = torch.full(
567
+ (1, num_tokens),
568
+ fill_value=const.method_types_ids["x-ray diffraction"],
569
+ dtype=torch.long,
570
+ ) # (1, t)
571
+ modified = torch.zeros((1, num_tokens), dtype=torch.long) # (1, t)
572
+ cyclic_period = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
573
+ affinity_token_mask = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
574
+
575
+ ref_pos = atom_positions.unsqueeze(0) # (1, a, 3)
576
+ atom_pad_mask = torch.ones((1, num_atoms), dtype=torch.float32) # (1, a)
577
+ atom_resolved_mask = torch.ones((1, num_atoms), dtype=torch.bool) # (1, a)
578
+
579
+ atom_name_codes = torch.stack(
580
+ [_atom_name_to_codes(atom_name) for atom_name in template.atom_names],
581
+ dim=0,
582
+ ) # (a, 4)
583
+ ref_atom_name_chars = one_hot(
584
+ atom_name_codes, num_classes=64
585
+ ).unsqueeze(0) # (1, a, 4, 64)
586
+
587
+ atomic_numbers = []
588
+ for element in template.atom_elements:
589
+ z_value = _ELEMENT_TO_Z[element] if element in _ELEMENT_TO_Z else _ELEMENT_TO_Z["C"]
590
+ if z_value >= const.num_elements:
591
+ raise RuntimeError(
592
+ f"Atomic number {z_value} exceeds the Boltz2 element vocabulary."
593
+ )
594
+ atomic_numbers.append(z_value)
595
+ ref_element = one_hot(
596
+ torch.tensor(atomic_numbers, dtype=torch.long),
597
+ num_classes=const.num_elements,
598
+ ).unsqueeze(0) # (1, a, n_element)
599
+
600
+ ref_charge_values = []
601
+ ref_chirality_values = []
602
+ for atom_name, residue_idx in zip(
603
+ template.atom_names,
604
+ template.atom_residue_index,
605
+ strict=True,
606
+ ):
607
+ residue_name = residue_names[residue_idx]
608
+ ref_charge_values.append(_FORMAL_CHARGES.get((residue_name, atom_name), 0.0))
609
+ ref_chirality_values.append(
610
+ 2 if atom_name in _CHIRAL_ATOMS.get(residue_name, frozenset()) else 0
611
+ )
612
+ ref_charge = torch.tensor(ref_charge_values, dtype=torch.float32).unsqueeze(0) # (1, a)
613
+ ref_chirality = torch.tensor(
614
+ ref_chirality_values, dtype=torch.long
615
+ ).unsqueeze(0) # (1, a)
616
+ ref_space_uid = torch.tensor(
617
+ template.atom_residue_index, dtype=torch.long
618
+ ).unsqueeze(0) # (1, a)
619
+
620
+ atom_to_token = one_hot(
621
+ torch.tensor(template.atom_residue_index, dtype=torch.long),
622
+ num_classes=num_tokens,
623
+ ).unsqueeze(0) # (1, a, t)
624
+ token_to_rep_atom = one_hot(
625
+ torch.tensor(residue_disto_atom_idx, dtype=torch.long),
626
+ num_classes=num_atoms,
627
+ ).unsqueeze(0) # (1, t, a)
628
+ token_to_center_atom = one_hot(
629
+ torch.tensor(residue_center_atom_idx, dtype=torch.long),
630
+ num_classes=num_atoms,
631
+ ).unsqueeze(0) # (1, t, a)
632
+ r_set_to_rep_atom = token_to_center_atom.clone() # (1, t, a)
633
+
634
+ num_backbone_classes = (
635
+ 1 + len(const.protein_backbone_atom_index) + len(const.nucleic_backbone_atom_index)
636
+ )
637
+ backbone_ids = []
638
+ for atom_name in template.atom_names:
639
+ if atom_name in const.protein_backbone_atom_index:
640
+ backbone_ids.append(const.protein_backbone_atom_index[atom_name] + 1)
641
+ else:
642
+ backbone_ids.append(0)
643
+ atom_backbone_feat = one_hot(
644
+ torch.tensor(backbone_ids, dtype=torch.long),
645
+ num_classes=num_backbone_classes,
646
+ ).unsqueeze(0) # (1, a, n_backbone)
647
+
648
+ # X contains no observed coordinates for sequence-only inference.
649
+ coords = torch.zeros((1, 1, num_atoms, 3), dtype=torch.float32) # (1, 1, a, 3)
650
+ disto_coords_ensemble = torch.zeros(
651
+ (1, 1, num_tokens, 3),
652
+ dtype=torch.float32,
653
+ ) # (1, 1, t, 3)
654
+
655
+ bfactor = torch.zeros((1, num_atoms), dtype=torch.float32) # (1, a)
656
+ atom_plddt = torch.ones((1, num_atoms), dtype=torch.float32) # (1, a)
657
+
658
+ if atoms_per_window_queries <= 0:
659
+ raise ValueError("atoms_per_window_queries must be positive.")
660
+ pad_atoms = (
661
+ (num_atoms - 1) // atoms_per_window_queries + 1
662
+ ) * atoms_per_window_queries - num_atoms
663
+ if pad_atoms > 0:
664
+ ref_pos = torch.nn.functional.pad(
665
+ ref_pos, (0, 0, 0, pad_atoms), value=0.0
666
+ ) # (1, a_p, 3)
667
+ atom_pad_mask = torch.nn.functional.pad(
668
+ atom_pad_mask, (0, pad_atoms), value=0.0
669
+ ) # (1, a_p)
670
+ atom_resolved_mask = torch.nn.functional.pad(
671
+ atom_resolved_mask,
672
+ (0, pad_atoms),
673
+ value=0.0,
674
+ ) # (1, a_p)
675
+ ref_atom_name_chars = torch.nn.functional.pad(
676
+ ref_atom_name_chars,
677
+ (0, 0, 0, 0, 0, pad_atoms),
678
+ value=0.0,
679
+ ) # (1, a_p, 4, 64)
680
+ ref_element = torch.nn.functional.pad(
681
+ ref_element, (0, 0, 0, pad_atoms), value=0.0
682
+ ) # (1, a_p, n_element)
683
+ ref_charge = torch.nn.functional.pad(
684
+ ref_charge, (0, pad_atoms), value=0.0
685
+ ) # (1, a_p)
686
+ ref_chirality = torch.nn.functional.pad(
687
+ ref_chirality, (0, pad_atoms), value=0
688
+ ) # (1, a_p)
689
+ atom_backbone_feat = torch.nn.functional.pad(
690
+ atom_backbone_feat,
691
+ (0, 0, 0, pad_atoms),
692
+ value=0.0,
693
+ ) # (1, a_p, n_backbone)
694
+ ref_space_uid = torch.nn.functional.pad(
695
+ ref_space_uid, (0, pad_atoms), value=0
696
+ ) # (1, a_p)
697
+ coords = torch.nn.functional.pad(
698
+ coords, (0, 0, 0, pad_atoms), value=0.0
699
+ ) # (1, 1, a_p, 3)
700
+ atom_to_token = torch.nn.functional.pad(
701
+ atom_to_token, (0, 0, 0, pad_atoms), value=0.0
702
+ ) # (1, a_p, t)
703
+ token_to_rep_atom = torch.nn.functional.pad(
704
+ token_to_rep_atom,
705
+ (0, pad_atoms),
706
+ value=0.0,
707
+ ) # (1, t, a_p)
708
+ token_to_center_atom = torch.nn.functional.pad(
709
+ token_to_center_atom,
710
+ (0, pad_atoms),
711
+ value=0.0,
712
+ ) # (1, t, a_p)
713
+ r_set_to_rep_atom = torch.nn.functional.pad(
714
+ r_set_to_rep_atom,
715
+ (0, pad_atoms),
716
+ value=0.0,
717
+ ) # (1, t, a_p)
718
+ bfactor = torch.nn.functional.pad(
719
+ bfactor, (0, pad_atoms), value=0.0
720
+ ) # (1, a_p)
721
+ atom_plddt = torch.nn.functional.pad(
722
+ atom_plddt, (0, pad_atoms), value=0.0
723
+ ) # (1, a_p)
724
+
725
+ frames_idx = torch.tensor(
726
+ residue_frame_atom_idx_flat,
727
+ dtype=torch.long,
728
+ ).reshape(num_tokens, 3) # (t, 3)
729
+ frames_idx = frames_idx.unsqueeze(0).unsqueeze(1) # (1, 1, t, 3)
730
+ frame_resolved_mask = torch.zeros(
731
+ (1, 1, num_tokens), dtype=torch.bool
732
+ ) # (1, 1, t)
733
+
734
+ msa = (
735
+ torch.tensor(residue_token_ids, dtype=torch.long).unsqueeze(0).unsqueeze(0)
736
+ ) # (1, 1, t)
737
+ msa_paired = torch.ones((1, 1, num_tokens), dtype=torch.float32) # (1, 1, t)
738
+ deletion_value = torch.zeros(
739
+ (1, 1, num_tokens), dtype=torch.float32
740
+ ) # (1, 1, t)
741
+ has_deletion = torch.zeros((1, 1, num_tokens), dtype=torch.bool) # (1, 1, t)
742
+ msa_mask = torch.ones((1, 1, num_tokens), dtype=torch.long) # (1, 1, t)
743
+ deletion_mean = torch.zeros((1, num_tokens), dtype=torch.float32) # (1, t)
744
+ profile = (
745
+ one_hot(
746
+ torch.tensor(residue_token_ids, dtype=torch.long),
747
+ num_classes=const.num_tokens,
748
+ )
749
+ .float()
750
+ .unsqueeze(0)
751
+ ) # (1, t, n_res_type)
752
+
753
+ template_restype = one_hot(
754
+ torch.zeros((1, 1, num_tokens), dtype=torch.long),
755
+ num_classes=const.num_tokens,
756
+ ) # (1, 1, t, n_res_type)
757
+ template_frame_rot = torch.zeros(
758
+ (1, 1, num_tokens, 3, 3), dtype=torch.float32
759
+ ) # (1, 1, t, 3, 3)
760
+ template_frame_t = torch.zeros(
761
+ (1, 1, num_tokens, 3), dtype=torch.float32
762
+ ) # (1, 1, t, 3)
763
+ template_cb = torch.zeros(
764
+ (1, 1, num_tokens, 3), dtype=torch.float32
765
+ ) # (1, 1, t, 3)
766
+ template_ca = torch.zeros(
767
+ (1, 1, num_tokens, 3), dtype=torch.float32
768
+ ) # (1, 1, t, 3)
769
+ template_mask_cb = torch.zeros(
770
+ (1, 1, num_tokens), dtype=torch.float32
771
+ ) # (1, 1, t)
772
+ template_mask_frame = torch.zeros(
773
+ (1, 1, num_tokens), dtype=torch.float32
774
+ ) # (1, 1, t)
775
+ template_mask = torch.zeros(
776
+ (1, 1, num_tokens), dtype=torch.float32
777
+ ) # (1, 1, t)
778
+ query_to_template = torch.zeros(
779
+ (1, 1, num_tokens), dtype=torch.long
780
+ ) # (1, 1, t)
781
+ visibility_ids = torch.zeros(
782
+ (1, 1, num_tokens), dtype=torch.float32
783
+ ) # (1, 1, t)
784
+
785
+ disto_target = torch.zeros(
786
+ (1, num_tokens, num_tokens, 1, num_bins),
787
+ dtype=torch.float32,
788
+ ) # (1, t, t, 1, n_bin)
789
+ disto_target[..., 0] = 1.0 # (1, t, t, 1)
790
+ disto_center = torch.zeros(
791
+ (1, num_tokens, 3), dtype=torch.float32
792
+ ) # (1, t, 3)
793
+
794
+ features: dict[str, torch.Tensor] = {
795
+ "token_index": token_index,
796
+ "residue_index": residue_index,
797
+ "asym_id": asym_id,
798
+ "entity_id": entity_id,
799
+ "sym_id": sym_id,
800
+ "mol_type": mol_type,
801
+ "res_type": res_type,
802
+ "disto_center": disto_center,
803
+ "token_bonds": token_bonds,
804
+ "type_bonds": type_bonds,
805
+ "token_pad_mask": token_pad_mask,
806
+ "token_resolved_mask": token_resolved_mask,
807
+ "token_disto_mask": token_disto_mask,
808
+ "contact_conditioning": contact_conditioning,
809
+ "contact_threshold": contact_threshold,
810
+ "method_feature": method_feature,
811
+ "modified": modified,
812
+ "cyclic_period": cyclic_period,
813
+ "affinity_token_mask": affinity_token_mask,
814
+ "ref_pos": ref_pos,
815
+ "atom_resolved_mask": atom_resolved_mask,
816
+ "ref_atom_name_chars": ref_atom_name_chars,
817
+ "ref_element": ref_element,
818
+ "ref_charge": ref_charge,
819
+ "ref_chirality": ref_chirality,
820
+ "atom_backbone_feat": atom_backbone_feat,
821
+ "ref_space_uid": ref_space_uid,
822
+ "coords": coords,
823
+ "atom_pad_mask": atom_pad_mask,
824
+ "atom_to_token": atom_to_token,
825
+ "token_to_rep_atom": token_to_rep_atom,
826
+ "r_set_to_rep_atom": r_set_to_rep_atom,
827
+ "token_to_center_atom": token_to_center_atom,
828
+ "disto_target": disto_target,
829
+ "disto_coords_ensemble": disto_coords_ensemble,
830
+ "bfactor": bfactor,
831
+ "plddt": atom_plddt,
832
+ "frames_idx": frames_idx,
833
+ "frame_resolved_mask": frame_resolved_mask,
834
+ "msa": msa,
835
+ "msa_paired": msa_paired,
836
+ "deletion_value": deletion_value,
837
+ "has_deletion": has_deletion,
838
+ "deletion_mean": deletion_mean,
839
+ "profile": profile,
840
+ "msa_mask": msa_mask,
841
+ "template_restype": template_restype,
842
+ "template_frame_rot": template_frame_rot,
843
+ "template_frame_t": template_frame_t,
844
+ "template_cb": template_cb,
845
+ "template_ca": template_ca,
846
+ "template_mask_cb": template_mask_cb,
847
+ "template_mask_frame": template_mask_frame,
848
+ "template_mask": template_mask,
849
+ "query_to_template": query_to_template,
850
+ "visibility_ids": visibility_ids,
851
+ }
852
+
853
+ return features, template # each feature shape is traced at its binding above
fastplms/models/boltz/minimal_structures.py CHANGED
@@ -1,21 +1,21 @@
1
- """Atom and residue metadata for sequence-only Boltz2 structure exports."""
2
-
3
- from dataclasses import dataclass
4
-
5
-
6
- @dataclass
7
- class ProteinStructureTemplate:
8
- sequence: str
9
- residue_names: list[str]
10
- atom_names: list[str]
11
- atom_elements: list[str]
12
- atom_residue_index: list[int]
13
- atom_chain_id: list[str]
14
-
15
- @property
16
- def num_atoms(self) -> int:
17
- return len(self.atom_names)
18
-
19
- @property
20
- def num_residues(self) -> int:
21
- return len(self.residue_names)
 
1
+ """Atom and residue metadata for sequence-only Boltz2 structure exports."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class ProteinStructureTemplate:
8
+ sequence: str
9
+ residue_names: list[str]
10
+ atom_names: list[str]
11
+ atom_elements: list[str]
12
+ atom_residue_index: list[int]
13
+ atom_chain_id: list[str]
14
+
15
+ @property
16
+ def num_atoms(self) -> int:
17
+ return len(self.atom_names)
18
+
19
+ @property
20
+ def num_residues(self) -> int:
21
+ return len(self.residue_names)
fastplms/models/boltz/modeling_boltz2.py CHANGED
@@ -1,1061 +1,1066 @@
1
- """Transformers interfaces and checkpoint conversion for the Boltz2 runtime."""
2
-
3
- import copy
4
- import inspect
5
- import random
6
- import numpy as np
7
- import torch
8
- import torch.nn as nn
9
-
10
- from collections.abc import Iterator, Mapping, Sequence
11
- from contextlib import contextmanager, nullcontext
12
- from dataclasses import dataclass
13
- from typing import Any, ClassVar
14
- from torch import Tensor
15
- from tqdm.auto import tqdm
16
- from transformers import PretrainedConfig, PreTrainedModel
17
- from transformers.modeling_outputs import ModelOutput
18
-
19
- from . import vb_const as const
20
- from . import vb_layers_initialize as init
21
- from .cif_writer import write_cif
22
- from .minimal_featurizer import build_boltz2_features
23
- from .minimal_structures import ProteinStructureTemplate
24
- from .vb_const import bond_types as _vb_const_bond_types # noqa: F401
25
- from .vb_layers_attention import AttentionPairBias as _vb_layers_attention_marker # noqa: F401
26
- from .vb_layers_attentionv2 import AttentionPairBias as _vb_layers_attentionv2_marker # noqa: F401
27
- from .vb_layers_confidence_utils import (
28
- compute_ptms as _vb_layers_confidence_utils_marker, # noqa: F401
29
- )
30
- from .vb_layers_dropout import get_dropout_mask as _vb_layers_dropout_marker # noqa: F401
31
- from .vb_layers_initialize import gating_init_ as _vb_layers_initialize_marker # noqa: F401
32
- from .vb_layers_outer_product_mean import (
33
- OuterProductMean as _vb_layers_outer_product_mean_marker, # noqa: F401
34
- )
35
- from .vb_layers_pair_averaging import (
36
- PairWeightedAveraging as _vb_layers_pair_averaging_marker, # noqa: F401
37
- )
38
- from .vb_layers_pairformer import PairformerModule
39
- from .vb_layers_transition import Transition as _vb_layers_transition_marker # noqa: F401
40
- from .vb_layers_triangular_mult import (
41
- TriangleMultiplicationIncoming as _vb_layers_triangular_mult_marker, # noqa: F401
42
- )
43
- from .vb_loss_diffusionv2 import weighted_rigid_align as _vb_loss_diffusionv2_marker # noqa: F401
44
- from .vb_modules_confidencev2 import ConfidenceModule
45
- from .vb_modules_diffusion_conditioning import DiffusionConditioning
46
- from .vb_modules_diffusionv2 import AtomDiffusion, DiffusionModule
47
- from .vb_modules_encodersv2 import RelativePositionEncoder
48
- from .vb_modules_transformersv2 import (
49
- DiffusionTransformer as _vb_modules_transformersv2_marker, # noqa: F401
50
- )
51
- from .vb_modules_trunkv2 import (
52
- ContactConditioning,
53
- DistogramModule,
54
- InputEmbedder,
55
- MSAModule,
56
- )
57
- from .vb_modules_utils import LinearNoBias as _vb_modules_utils_marker # noqa: F401
58
- from .vb_potentials_potentials import (
59
- get_potentials as _vb_potentials_potentials_marker, # noqa: F401
60
- )
61
- from .vb_potentials_schedules import (
62
- ParameterSchedule as _vb_potentials_schedules_marker, # noqa: F401
63
- )
64
- from .vb_tri_attn_attention import (
65
- TriangleAttentionStartingNode as _vb_tri_attn_attention_marker, # noqa: F401
66
- )
67
- from .vb_tri_attn_primitives import Attention as _vb_tri_attn_primitives_marker # noqa: F401
68
- from .vb_tri_attn_utils import permute_final_dims as _vb_tri_attn_utils_marker # noqa: F401
69
-
70
-
71
- def _default_steering_args() -> dict[str, Any]:
72
- return {
73
- "fk_steering": False,
74
- "num_particles": 3,
75
- "fk_lambda": 4.0,
76
- "fk_resampling_interval": 3,
77
- "physical_guidance_update": False,
78
- "contact_guidance_update": False,
79
- "num_gd_steps": 16,
80
- }
81
-
82
-
83
- def _boltz2_reference_diffusion_overrides() -> dict[str, Any]:
84
- # Match Boltz2 CLI inference defaults from boltz.main/Boltz2DiffusionParams.
85
- return {
86
- "gamma_0": 0.8,
87
- "gamma_min": 1.0,
88
- "noise_scale": 1.003,
89
- "rho": 7,
90
- "step_scale": 1.5,
91
- "sigma_min": 0.0001,
92
- "sigma_max": 160.0,
93
- "sigma_data": 16.0,
94
- "P_mean": -1.2,
95
- "P_std": 1.5,
96
- "coordinate_augmentation": True,
97
- "alignment_reverse_diff": True,
98
- "synchronize_sigmas": True,
99
- }
100
-
101
-
102
- def _enforce_pairformer_v2(pairformer_args: Mapping[str, Any], context: str) -> dict[str, Any]:
103
- if not isinstance(pairformer_args, Mapping):
104
- raise TypeError(f"Expected {context} pairformer_args to be a dictionary.")
105
- out = _to_plain_python(copy.deepcopy(pairformer_args))
106
- if "v2" in out and not out["v2"]:
107
- raise ValueError(f"{context} pairformer_args['v2'] must be True for Boltz2.")
108
- out["v2"] = True
109
- return out
110
-
111
-
112
- def _require_key(mapping: dict[str, Any], key: str) -> Any:
113
- if key not in mapping:
114
- raise KeyError(f"Missing required key '{key}' in checkpoint hyperparameters.")
115
- return mapping[key]
116
-
117
-
118
- def _state_dict_without_wrappers(state_dict: dict[str, Tensor]) -> dict[str, Tensor]:
119
- # Every state tensor has its parameter-defined shape (...).
120
- cleaned: dict[str, Tensor] = {}
121
- for key, value in state_dict.items():
122
- if key.startswith("ema."):
123
- continue
124
- new_key = key
125
- if new_key.startswith("model."):
126
- new_key = new_key[len("model.") :]
127
- if new_key.startswith("module."):
128
- new_key = new_key[len("module.") :]
129
- cleaned[new_key] = value # (...)
130
- return cleaned
131
-
132
-
133
- def _to_cpu_detached(value: Any) -> Any:
134
- if torch.is_tensor(value):
135
- return value.detach().cpu() # same shape (...)
136
- if isinstance(value, dict):
137
- out: dict[Any, Any] = {}
138
- for key, nested_value in value.items():
139
- out[key] = _to_cpu_detached(nested_value)
140
- return out
141
- if isinstance(value, list):
142
- return [_to_cpu_detached(item) for item in value]
143
- if isinstance(value, tuple):
144
- return tuple(_to_cpu_detached(item) for item in value)
145
- return value
146
-
147
-
148
- @dataclass(frozen=True)
149
- class _RandomState:
150
- python: object
151
- numpy: tuple[Any, ...]
152
- torch_cpu: Tensor # (n_cpu_rng_state,)
153
- torch_cuda: list[Tensor] | None # each: (n_cuda_rng_state,)
154
-
155
-
156
- @contextmanager
157
- def _seed_context(seed: int | None) -> Iterator[None]:
158
- """Temporarily seed every RNG used by Boltz2 and restore caller state."""
159
-
160
- if seed is None:
161
- yield
162
- return
163
- if isinstance(seed, bool) or not isinstance(seed, int):
164
- raise TypeError(
165
- "Boltz2 seed must be an int or None; bool and coerced values are not accepted."
166
- )
167
- state = _RandomState(
168
- python=random.getstate(),
169
- numpy=np.random.get_state(),
170
- torch_cpu=torch.random.get_rng_state(),
171
- torch_cuda=torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
172
- )
173
- normalized_seed = seed % (2**32)
174
- random.seed(normalized_seed)
175
- np.random.seed(normalized_seed)
176
- torch.manual_seed(normalized_seed)
177
- if torch.cuda.is_available():
178
- torch.cuda.manual_seed_all(normalized_seed)
179
- try:
180
- yield
181
- finally:
182
- random.setstate(state.python)
183
- np.random.set_state(state.numpy)
184
- torch.random.set_rng_state(state.torch_cpu)
185
- if state.torch_cuda is not None:
186
- torch.cuda.set_rng_state_all(state.torch_cuda)
187
-
188
-
189
- def _to_plain_python(value: Any) -> Any:
190
- if isinstance(value, Mapping):
191
- out: dict[Any, Any] = {}
192
- for key, nested_value in value.items():
193
- out[key] = _to_plain_python(nested_value)
194
- return out
195
- if isinstance(value, list):
196
- return [_to_plain_python(item) for item in value]
197
- if isinstance(value, tuple):
198
- return [_to_plain_python(item) for item in value]
199
- if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
200
- return [_to_plain_python(item) for item in value]
201
- return value
202
-
203
-
204
- def _filtered_kwargs(target: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
205
- signature = inspect.signature(target.__init__)
206
- allowed = set(signature.parameters.keys())
207
- allowed.discard("self")
208
- filtered: dict[str, Any] = {}
209
- for key, value in kwargs.items():
210
- if key in allowed:
211
- filtered[key] = value
212
- return filtered
213
-
214
-
215
- @dataclass
216
- class Boltz2ModelOutput(ModelOutput):
217
- """Raw Boltz2 inference output with standard AutoModel controls."""
218
-
219
- last_hidden_state: torch.Tensor | None = None # (b, t, d_s) or None
220
- hidden_states: tuple[torch.Tensor, ...] | None = None # each shape traced at source
221
- attentions: tuple[torch.Tensor, ...] | None = None # unsupported; always None
222
- pdistogram: torch.Tensor | None = None # (b, t, t, n_distogram, n_bin) or None
223
- s: torch.Tensor | None = None # (b, t, d_s) or None
224
- z: torch.Tensor | None = None # (b, t, t, d_z) or None
225
- sample_atom_coords: torch.Tensor | None = None # (b * m, a, 3) or None
226
- diff_token_repr: torch.Tensor | None = None # (...) or None
227
- s_conf: torch.Tensor | None = None # (...) or None
228
- z_conf: torch.Tensor | None = None # (...) or None
229
- pde_logits: torch.Tensor | None = None # (...) or None
230
- plddt_logits: torch.Tensor | None = None # (...) or None
231
- resolved_logits: torch.Tensor | None = None # (...) or None
232
- pde: torch.Tensor | None = None # (...) or None
233
- plddt: torch.Tensor | None = None # (...) or None
234
- complex_plddt: torch.Tensor | None = None # (...) or None
235
- complex_iplddt: torch.Tensor | None = None # (...) or None
236
- complex_pde: torch.Tensor | None = None # (...) or None
237
- complex_ipde: torch.Tensor | None = None # (...) or None
238
- pae_logits: torch.Tensor | None = None # (...) or None
239
- pae: torch.Tensor | None = None # (...) or None
240
- ptm: torch.Tensor | None = None # (...) or None
241
- iptm: torch.Tensor | None = None # (...) or None
242
- ligand_iptm: torch.Tensor | None = None # (...) or None
243
- protein_iptm: torch.Tensor | None = None # (...) or None
244
- pair_chains_iptm: Any = None
245
-
246
-
247
- @dataclass
248
- class Boltz2StructureOutput(ModelOutput):
249
- sample_atom_coords: torch.Tensor | None = None # (m, a_p, 3) or None
250
- atom_pad_mask: torch.Tensor | None = None # (a_p,) or None
251
- plddt: torch.Tensor | None = None # (m, t) or (m, a_p), depending on confidence resolution
252
- confidence_score: torch.Tensor | None = None # (m,) or None
253
- complex_plddt: torch.Tensor | None = None # (m,) or None
254
- iptm: torch.Tensor | None = None # (m,) or None
255
- ptm: torch.Tensor | None = None # (m,) or None
256
- sequence: str | None = None
257
- structure_template: ProteinStructureTemplate | None = None
258
- raw_output: dict[str, torch.Tensor] | None = None # values keep model-output shapes
259
- seed: int | None = None
260
-
261
-
262
- class Boltz2Config(PretrainedConfig):
263
- model_type = "boltz2_automodel"
264
-
265
- def __init__(
266
- self,
267
- core_kwargs: dict[str, Any] | None = None,
268
- num_bins: int = 64,
269
- default_recycling_steps: int = 3,
270
- default_sampling_steps: int = 200,
271
- default_diffusion_samples: int = 1,
272
- **kwargs,
273
- ) -> None:
274
- super().__init__(**kwargs)
275
- if core_kwargs is None:
276
- core_kwargs = {}
277
- self.core_kwargs = core_kwargs
278
- self.num_bins = num_bins
279
- self.default_recycling_steps = default_recycling_steps
280
- self.default_sampling_steps = default_sampling_steps
281
- self.default_diffusion_samples = default_diffusion_samples
282
-
283
- @classmethod
284
- def from_hyperparameters(
285
- cls,
286
- hparams: dict[str, Any],
287
- use_kernels: bool = False,
288
- default_recycling_steps: int | None = None,
289
- default_sampling_steps: int | None = None,
290
- default_diffusion_samples: int | None = None,
291
- ) -> "Boltz2Config":
292
- if not isinstance(hparams, dict):
293
- raise TypeError("Expected checkpoint hyperparameters as a dictionary.")
294
- required = [
295
- "atom_s",
296
- "atom_z",
297
- "token_s",
298
- "token_z",
299
- "num_bins",
300
- "embedder_args",
301
- "msa_args",
302
- "pairformer_args",
303
- "score_model_args",
304
- "diffusion_process_args",
305
- ]
306
- for key in required:
307
- _require_key(hparams, key)
308
-
309
- pairformer_args = _enforce_pairformer_v2(
310
- hparams["pairformer_args"],
311
- context="checkpoint",
312
- )
313
- diffusion_process_args = _to_plain_python(copy.deepcopy(hparams["diffusion_process_args"]))
314
- diffusion_overrides = _boltz2_reference_diffusion_overrides()
315
- for key in diffusion_overrides:
316
- diffusion_process_args[key] = diffusion_overrides[key]
317
-
318
- core_kwargs: dict[str, Any] = {
319
- "atom_s": hparams["atom_s"],
320
- "atom_z": hparams["atom_z"],
321
- "token_s": hparams["token_s"],
322
- "token_z": hparams["token_z"],
323
- "num_bins": hparams["num_bins"],
324
- "embedder_args": _to_plain_python(copy.deepcopy(hparams["embedder_args"])),
325
- "msa_args": _to_plain_python(copy.deepcopy(hparams["msa_args"])),
326
- "pairformer_args": pairformer_args,
327
- "score_model_args": _to_plain_python(copy.deepcopy(hparams["score_model_args"])),
328
- "diffusion_process_args": diffusion_process_args,
329
- "use_kernels": use_kernels,
330
- }
331
-
332
- if "confidence_model_args" in hparams:
333
- confidence_model_args = _to_plain_python(
334
- copy.deepcopy(hparams["confidence_model_args"])
335
- )
336
- if "pairformer_args" in confidence_model_args:
337
- confidence_model_args["pairformer_args"] = _enforce_pairformer_v2(
338
- confidence_model_args["pairformer_args"],
339
- context="confidence",
340
- )
341
- core_kwargs["confidence_model_args"] = confidence_model_args
342
- else:
343
- core_kwargs["confidence_model_args"] = None
344
-
345
- core_kwargs["confidence_prediction"] = hparams.get("confidence_prediction", True)
346
- core_kwargs["token_level_confidence"] = hparams.get("token_level_confidence", True)
347
- core_kwargs["alpha_pae"] = hparams.get("alpha_pae", 0.0)
348
- core_kwargs["atoms_per_window_queries"] = hparams.get("atoms_per_window_queries", 32)
349
- core_kwargs["atoms_per_window_keys"] = hparams.get("atoms_per_window_keys", 128)
350
- core_kwargs["atom_feature_dim"] = hparams.get("atom_feature_dim", 128)
351
- core_kwargs["bond_type_feature"] = hparams.get("bond_type_feature", False)
352
- core_kwargs["run_trunk_and_structure"] = hparams.get("run_trunk_and_structure", True)
353
- core_kwargs["skip_run_structure"] = hparams.get("skip_run_structure", False)
354
- core_kwargs["fix_sym_check"] = hparams.get("fix_sym_check", False)
355
- core_kwargs["cyclic_pos_enc"] = hparams.get("cyclic_pos_enc", False)
356
- core_kwargs["use_no_atom_char"] = hparams.get("use_no_atom_char", False)
357
- core_kwargs["use_atom_backbone_feat"] = hparams.get("use_atom_backbone_feat", False)
358
- core_kwargs["use_residue_feats_atoms"] = hparams.get("use_residue_feats_atoms", False)
359
- core_kwargs["conditioning_cutoff_min"] = hparams.get("conditioning_cutoff_min", 4.0)
360
- core_kwargs["conditioning_cutoff_max"] = hparams.get("conditioning_cutoff_max", 20.0)
361
-
362
- if "steering_args" in hparams and hparams["steering_args"] is not None:
363
- core_kwargs["steering_args"] = _to_plain_python(copy.deepcopy(hparams["steering_args"]))
364
- else:
365
- core_kwargs["steering_args"] = _default_steering_args()
366
-
367
- if "validation_args" in hparams:
368
- validation_args = hparams["validation_args"]
369
- if not isinstance(validation_args, Mapping):
370
- raise TypeError(
371
- "Expected 'validation_args' in checkpoint hyperparameters to be a mapping."
372
- )
373
- if default_recycling_steps is None and "recycling_steps" in validation_args:
374
- default_recycling_steps = validation_args["recycling_steps"]
375
- if default_sampling_steps is None and "sampling_steps" in validation_args:
376
- default_sampling_steps = validation_args["sampling_steps"]
377
- if default_diffusion_samples is None and "diffusion_samples" in validation_args:
378
- default_diffusion_samples = validation_args["diffusion_samples"]
379
-
380
- if default_recycling_steps is None:
381
- default_recycling_steps = 3
382
- if default_sampling_steps is None:
383
- default_sampling_steps = 200
384
- if default_diffusion_samples is None:
385
- default_diffusion_samples = 1
386
-
387
- return cls(
388
- core_kwargs=core_kwargs,
389
- num_bins=hparams["num_bins"],
390
- default_recycling_steps=default_recycling_steps,
391
- default_sampling_steps=default_sampling_steps,
392
- default_diffusion_samples=default_diffusion_samples,
393
- )
394
-
395
-
396
- class Boltz2InferenceCore(nn.Module):
397
- def __init__(
398
- self,
399
- atom_s: int,
400
- atom_z: int,
401
- token_s: int,
402
- token_z: int,
403
- num_bins: int,
404
- embedder_args: dict[str, Any],
405
- msa_args: dict[str, Any],
406
- pairformer_args: dict[str, Any],
407
- score_model_args: dict[str, Any],
408
- diffusion_process_args: dict[str, Any],
409
- confidence_model_args: dict[str, Any] | None = None,
410
- atom_feature_dim: int = 128,
411
- confidence_prediction: bool = True,
412
- token_level_confidence: bool = True,
413
- alpha_pae: float = 0.0,
414
- atoms_per_window_queries: int = 32,
415
- atoms_per_window_keys: int = 128,
416
- run_trunk_and_structure: bool = True,
417
- skip_run_structure: bool = False,
418
- bond_type_feature: bool = False,
419
- fix_sym_check: bool = False,
420
- cyclic_pos_enc: bool = False,
421
- use_no_atom_char: bool = False,
422
- use_atom_backbone_feat: bool = False,
423
- use_residue_feats_atoms: bool = False,
424
- conditioning_cutoff_min: float = 4.0,
425
- conditioning_cutoff_max: float = 20.0,
426
- use_kernels: bool = False,
427
- steering_args: dict[str, Any] | None = None,
428
- ) -> None:
429
- super().__init__()
430
- self.use_kernels = use_kernels
431
- self.confidence_prediction = confidence_prediction
432
- self.token_level_confidence = token_level_confidence
433
- self.alpha_pae = alpha_pae
434
- self.run_trunk_and_structure = run_trunk_and_structure
435
- self.skip_run_structure = skip_run_structure
436
- self.bond_type_feature = bond_type_feature
437
- self.steering_args = (
438
- steering_args if steering_args is not None else _default_steering_args()
439
- )
440
- if "v2" not in pairformer_args:
441
- raise ValueError("Boltz2 requires pairformer_args['v2'].")
442
- if not pairformer_args["v2"]:
443
- raise ValueError("Boltz2 requires pairformer_args['v2']=True.")
444
-
445
- full_embedder_args = {
446
- "atom_s": atom_s,
447
- "atom_z": atom_z,
448
- "token_s": token_s,
449
- "token_z": token_z,
450
- "atoms_per_window_queries": atoms_per_window_queries,
451
- "atoms_per_window_keys": atoms_per_window_keys,
452
- "atom_feature_dim": atom_feature_dim,
453
- "use_no_atom_char": use_no_atom_char,
454
- "use_atom_backbone_feat": use_atom_backbone_feat,
455
- "use_residue_feats_atoms": use_residue_feats_atoms,
456
- **embedder_args,
457
- }
458
- full_embedder_args = _filtered_kwargs(InputEmbedder, full_embedder_args)
459
- self.input_embedder = InputEmbedder(**full_embedder_args)
460
-
461
- self.s_init = nn.Linear(token_s, token_s, bias=False)
462
- self.z_init_1 = nn.Linear(token_s, token_z, bias=False)
463
- self.z_init_2 = nn.Linear(token_s, token_z, bias=False)
464
- self.rel_pos = RelativePositionEncoder(
465
- token_z,
466
- fix_sym_check=fix_sym_check,
467
- cyclic_pos_enc=cyclic_pos_enc,
468
- )
469
- self.token_bonds = nn.Linear(1, token_z, bias=False)
470
- if self.bond_type_feature:
471
- self.token_bonds_type = nn.Embedding(len(const.bond_types) + 1, token_z)
472
-
473
- self.contact_conditioning = ContactConditioning(
474
- token_z=token_z,
475
- cutoff_min=conditioning_cutoff_min,
476
- cutoff_max=conditioning_cutoff_max,
477
- )
478
- self.s_norm = nn.LayerNorm(token_s)
479
- self.z_norm = nn.LayerNorm(token_z)
480
-
481
- self.s_recycle = nn.Linear(token_s, token_s, bias=False)
482
- self.z_recycle = nn.Linear(token_z, token_z, bias=False)
483
- init.gating_init_(self.s_recycle.weight)
484
- init.gating_init_(self.z_recycle.weight)
485
-
486
- msa_kwargs = _filtered_kwargs(
487
- MSAModule,
488
- {"token_z": token_z, "token_s": token_s, **msa_args},
489
- )
490
- self.msa_module = MSAModule(**msa_kwargs)
491
-
492
- pairformer_kwargs = _filtered_kwargs(
493
- PairformerModule,
494
- {"token_s": token_s, "token_z": token_z, **pairformer_args},
495
- )
496
- if "token_s" not in pairformer_kwargs or "token_z" not in pairformer_kwargs:
497
- raise RuntimeError("Boltz2 pairformer construction lost its token dimensions.")
498
- pairformer_token_s = pairformer_kwargs.pop("token_s")
499
- pairformer_token_z = pairformer_kwargs.pop("token_z")
500
- self.pairformer_module = PairformerModule(
501
- pairformer_token_s,
502
- pairformer_token_z,
503
- **pairformer_kwargs,
504
- )
505
-
506
- diffusion_conditioning_kwargs = {
507
- "token_s": token_s,
508
- "token_z": token_z,
509
- "atom_s": atom_s,
510
- "atom_z": atom_z,
511
- "atoms_per_window_queries": atoms_per_window_queries,
512
- "atoms_per_window_keys": atoms_per_window_keys,
513
- "atom_encoder_depth": score_model_args["atom_encoder_depth"],
514
- "atom_encoder_heads": score_model_args["atom_encoder_heads"],
515
- "token_transformer_depth": score_model_args["token_transformer_depth"],
516
- "token_transformer_heads": score_model_args["token_transformer_heads"],
517
- "atom_decoder_depth": score_model_args["atom_decoder_depth"],
518
- "atom_decoder_heads": score_model_args["atom_decoder_heads"],
519
- "atom_feature_dim": atom_feature_dim,
520
- "conditioning_transition_layers": score_model_args["conditioning_transition_layers"],
521
- "use_no_atom_char": use_no_atom_char,
522
- "use_atom_backbone_feat": use_atom_backbone_feat,
523
- "use_residue_feats_atoms": use_residue_feats_atoms,
524
- }
525
- diffusion_conditioning_kwargs = _filtered_kwargs(
526
- DiffusionConditioning,
527
- diffusion_conditioning_kwargs,
528
- )
529
- self.diffusion_conditioning = DiffusionConditioning(**diffusion_conditioning_kwargs)
530
-
531
- structure_score_model_args = {
532
- "token_s": token_s,
533
- "atom_s": atom_s,
534
- "atoms_per_window_queries": atoms_per_window_queries,
535
- "atoms_per_window_keys": atoms_per_window_keys,
536
- **score_model_args,
537
- }
538
- structure_score_model_args = _filtered_kwargs(
539
- DiffusionModule,
540
- structure_score_model_args,
541
- )
542
- structure_module_kwargs = {
543
- "score_model_args": structure_score_model_args,
544
- "compile_score": False,
545
- **diffusion_process_args,
546
- }
547
- structure_module_kwargs = _filtered_kwargs(AtomDiffusion, structure_module_kwargs)
548
- self.structure_module = AtomDiffusion(**structure_module_kwargs)
549
- self.distogram_module = DistogramModule(token_z, num_bins)
550
-
551
- if self.confidence_prediction:
552
- if confidence_model_args is None:
553
- raise ValueError(
554
- "confidence_prediction=True requires confidence_model_args in config."
555
- )
556
- confidence_kwargs = {
557
- "token_s": token_s,
558
- "token_z": token_z,
559
- "token_level_confidence": token_level_confidence,
560
- "bond_type_feature": bond_type_feature,
561
- "fix_sym_check": fix_sym_check,
562
- "cyclic_pos_enc": cyclic_pos_enc,
563
- "conditioning_cutoff_min": conditioning_cutoff_min,
564
- "conditioning_cutoff_max": conditioning_cutoff_max,
565
- **confidence_model_args,
566
- }
567
- confidence_kwargs = _filtered_kwargs(ConfidenceModule, confidence_kwargs)
568
- self.confidence_module = ConfidenceModule(**confidence_kwargs)
569
-
570
- def forward(
571
- self,
572
- feats: dict[str, Tensor],
573
- recycling_steps: int = 3,
574
- num_sampling_steps: int | None = None,
575
- diffusion_samples: int = 1,
576
- max_parallel_samples: int | None = None,
577
- run_confidence_sequentially: bool = True,
578
- detach_confidence: bool = True,
579
- verbose: bool = False,
580
- ) -> dict[str, Tensor]:
581
- # b is the batch size, t the token count, a the padded atom count,
582
- # d_s the token width, d_z the pair width, and m the diffusion multiplicity.
583
- with tqdm(
584
- total=1,
585
- desc="Boltz2: Input embeddings",
586
- disable=not verbose,
587
- dynamic_ncols=True,
588
- leave=False,
589
- ) as progress:
590
- s_inputs = self.input_embedder(feats) # (b, t, d_s)
591
- progress.update()
592
- s_init = self.s_init(s_inputs) # (b, t, d_s)
593
-
594
- z_init = (
595
- self.z_init_1(s_inputs)[:, :, None] + self.z_init_2(s_inputs)[:, None, :]
596
- ) # (b, t, t, d_z)
597
- relative_position_encoding = self.rel_pos(feats) # (b, t, t, d_z)
598
- z_init = z_init + relative_position_encoding # (b, t, t, d_z)
599
- z_init = z_init + self.token_bonds(
600
- feats["token_bonds"].float()
601
- ) # (b, t, t, d_z)
602
- if self.bond_type_feature:
603
- z_init = z_init + self.token_bonds_type(
604
- feats["type_bonds"].long()
605
- ) # (b, t, t, d_z)
606
- z_init = z_init + self.contact_conditioning(feats) # (b, t, t, d_z)
607
-
608
- s = torch.zeros_like(s_init) # (b, t, d_s)
609
- z = torch.zeros_like(z_init) # (b, t, t, d_z)
610
- mask = feats["token_pad_mask"].float() # (b, t)
611
- pair_mask = mask[:, :, None] * mask[:, None, :] # (b, t, t)
612
-
613
- if self.run_trunk_and_structure:
614
- for _ in tqdm(
615
- range(recycling_steps + 1),
616
- desc="Boltz2: Recycling",
617
- unit="cycle",
618
- disable=not verbose,
619
- dynamic_ncols=True,
620
- leave=False,
621
- ):
622
- s = s_init + self.s_recycle(self.s_norm(s)) # (b, t, d_s)
623
- z = z_init + self.z_recycle(self.z_norm(z)) # (b, t, t, d_z)
624
- z = z + self.msa_module(
625
- z,
626
- s_inputs,
627
- feats,
628
- use_kernels=self.use_kernels,
629
- ) # (b, t, t, d_z)
630
- s, z = self.pairformer_module(
631
- s,
632
- z,
633
- mask=mask,
634
- pair_mask=pair_mask,
635
- use_kernels=self.use_kernels,
636
- ) # (b, t, d_s), (b, t, t, d_z)
637
-
638
- pdistogram = self.distogram_module(z) # (b, t, t, n_distogram, n_bin)
639
- output: dict[str, Tensor] = {
640
- "pdistogram": pdistogram,
641
- "s": s,
642
- "z": z,
643
- }
644
-
645
- if self.run_trunk_and_structure and (not self.skip_run_structure):
646
- q, c, to_keys, atom_enc_bias, atom_dec_bias, token_trans_bias = (
647
- self.diffusion_conditioning(
648
- s_trunk=s,
649
- z_trunk=z,
650
- relative_position_encoding=relative_position_encoding,
651
- feats=feats,
652
- )
653
- ) # q/c: (b, a, d_a); biases retain their attention-specific axes
654
- diffusion_conditioning = {
655
- "q": q,
656
- "c": c,
657
- "to_keys": to_keys,
658
- "atom_enc_bias": atom_enc_bias,
659
- "atom_dec_bias": atom_dec_bias,
660
- "token_trans_bias": token_trans_bias,
661
- }
662
- with torch.autocast("cuda", enabled=False):
663
- struct_out = self.structure_module.sample(
664
- s_trunk=s.float(),
665
- s_inputs=s_inputs.float(),
666
- feats=feats,
667
- num_sampling_steps=num_sampling_steps,
668
- atom_mask=feats["atom_pad_mask"].float(),
669
- multiplicity=diffusion_samples,
670
- max_parallel_samples=max_parallel_samples,
671
- steering_args=self.steering_args,
672
- diffusion_conditioning=diffusion_conditioning,
673
- verbose=verbose,
674
- ) # tensor values include sample_atom_coords: (b * m, a, 3)
675
- output.update(struct_out)
676
-
677
- if self.confidence_prediction:
678
- if self.skip_run_structure:
679
- x_pred = feats["coords"].repeat_interleave(
680
- diffusion_samples, 0
681
- ) # (b * m, ..., a, 3)
682
- else:
683
- if "sample_atom_coords" not in output:
684
- raise RuntimeError("Structure sampling did not produce sample_atom_coords.")
685
- x_pred = output["sample_atom_coords"] # (b * m, a, 3)
686
-
687
- if detach_confidence:
688
- s_inputs_c = s_inputs.detach() # (b, t, d_s)
689
- s_c = s.detach() # (b, t, d_s)
690
- z_c = z.detach() # (b, t, t, d_z)
691
- x_pred_c = x_pred.detach() # (b * m, ..., a, 3)
692
- pdist_c = output["pdistogram"][:, :, :, 0].detach() # (b, t, t, n_bin)
693
- else:
694
- s_inputs_c = s_inputs # (b, t, d_s)
695
- s_c = s # (b, t, d_s)
696
- z_c = z # (b, t, t, d_z)
697
- x_pred_c = x_pred # (b * m, ..., a, 3)
698
- pdist_c = output["pdistogram"][:, :, :, 0] # (b, t, t, n_bin)
699
-
700
- with tqdm(
701
- total=1,
702
- desc="Boltz2: Confidence",
703
- disable=not verbose,
704
- dynamic_ncols=True,
705
- leave=False,
706
- ) as progress:
707
- output.update(
708
- self.confidence_module(
709
- s_inputs=s_inputs_c,
710
- s=s_c,
711
- z=z_c,
712
- x_pred=x_pred_c,
713
- feats=feats,
714
- pred_distogram_logits=pdist_c,
715
- multiplicity=diffusion_samples,
716
- run_sequentially=run_confidence_sequentially,
717
- use_kernels=self.use_kernels,
718
- )
719
- )
720
- progress.update()
721
-
722
- return output # named tensors retain the shapes traced above
723
-
724
-
725
- class Boltz2Model(PreTrainedModel):
726
- config_class = Boltz2Config
727
- base_model_prefix = "core"
728
- all_tied_weights_keys: ClassVar[dict[str, str]] = {}
729
-
730
- def __init__(self, config: Boltz2Config) -> None:
731
- super().__init__(config)
732
- if not isinstance(config.core_kwargs, dict):
733
- raise TypeError("config.core_kwargs must be a dictionary.")
734
- self.core = Boltz2InferenceCore(**config.core_kwargs)
735
-
736
- def _init_weights(self, module: nn.Module) -> None:
737
- return
738
-
739
- def _detied_state_dict(self) -> dict[str, Tensor]:
740
- # Every state tensor has its parameter-defined shape (...).
741
- raw_state = self.state_dict()
742
- seen_ptrs: dict[int, str] = {}
743
- out: dict[str, Tensor] = {}
744
- for key, tensor in raw_state.items():
745
- if torch.is_tensor(tensor):
746
- ptr = tensor.untyped_storage().data_ptr()
747
- if ptr in seen_ptrs:
748
- out[key] = tensor.clone() # (...)
749
- else:
750
- seen_ptrs[ptr] = key
751
- out[key] = tensor # (...)
752
- else:
753
- out[key] = tensor # (...)
754
- return out # each tensor: (...)
755
-
756
- def save_pretrained(self, save_directory: str, **kwargs: Any) -> None:
757
- if "safe_serialization" not in kwargs:
758
- kwargs["safe_serialization"] = True
759
- if "state_dict" not in kwargs:
760
- kwargs["state_dict"] = self._detied_state_dict()
761
- super().save_pretrained(save_directory, **kwargs)
762
-
763
- @property
764
- def device(self) -> torch.device:
765
- return next(self.parameters()).device
766
-
767
- @classmethod
768
- def from_boltz_checkpoint(
769
- cls,
770
- checkpoint_path: str,
771
- map_location: str | torch.device = "cpu",
772
- use_kernels: bool = False,
773
- default_recycling_steps: int | None = None,
774
- default_sampling_steps: int | None = None,
775
- default_diffusion_samples: int | None = None,
776
- *,
777
- allow_unsafe_pickle: bool = False,
778
- ) -> "Boltz2Model":
779
- """Import an official Boltz Lightning checkpoint into the inference model.
780
-
781
- Lightning checkpoints require Python pickle deserialization. The caller
782
- must opt in with the literal value ``True`` after independently verifying
783
- that the checkpoint comes from a trusted source and matches its expected
784
- cryptographic hash.
785
- """
786
-
787
- if allow_unsafe_pickle is not True:
788
- raise ValueError(
789
- "Boltz Lightning checkpoints contain Python pickle and cannot be loaded "
790
- "safely. Pass allow_unsafe_pickle=True only for a trusted, hash-verified "
791
- "checkpoint."
792
- )
793
-
794
- # The official Lightning checkpoints contain OmegaConf objects, so this explicit
795
- # trusted-checkpoint boundary requires full Python unpickling.
796
- checkpoint = torch.load(
797
- checkpoint_path,
798
- map_location=map_location,
799
- weights_only=False,
800
- ) # mapping values include state tensors with parameter-defined shapes (...)
801
- if not isinstance(checkpoint, dict):
802
- raise TypeError("Checkpoint must deserialize to a dictionary.")
803
- _require_key(checkpoint, "hyper_parameters")
804
- _require_key(checkpoint, "state_dict")
805
-
806
- hparams = checkpoint["hyper_parameters"]
807
- if not isinstance(hparams, dict):
808
- raise TypeError("Checkpoint hyper_parameters must be a dictionary.")
809
- state_dict = checkpoint["state_dict"] # each tensor: (...)
810
- if not isinstance(state_dict, dict):
811
- raise TypeError("Checkpoint state_dict must be a dictionary.")
812
-
813
- config = Boltz2Config.from_hyperparameters(
814
- hparams,
815
- use_kernels=use_kernels,
816
- default_recycling_steps=default_recycling_steps,
817
- default_sampling_steps=default_sampling_steps,
818
- default_diffusion_samples=default_diffusion_samples,
819
- )
820
- model = cls(config)
821
- cleaned = _state_dict_without_wrappers(state_dict) # each tensor: (...)
822
- target_keys = set(model.core.state_dict().keys())
823
- for key in target_keys:
824
- if ".attention.norm_s." in key:
825
- raise RuntimeError(
826
- "Boltz2 inference core unexpectedly uses v1 attention parameters. "
827
- "Expected pairformer v2 architecture."
828
- )
829
- filtered: dict[str, Tensor] = {}
830
- for key, value in cleaned.items():
831
- if key in target_keys:
832
- filtered[key] = value # (...)
833
-
834
- missing = sorted(target_keys.difference(filtered.keys()))
835
- if missing:
836
- raise RuntimeError(
837
- "Checkpoint is missing required parameters for Boltz2 inference core. "
838
- f"Missing keys (first 20): {missing[:20]}"
839
- )
840
-
841
- load_result = model.core.load_state_dict(filtered, strict=False)
842
- loaded_missing = sorted(load_result.missing_keys)
843
- if loaded_missing:
844
- raise RuntimeError(
845
- "Model has unexpected missing keys after load_state_dict. "
846
- f"Missing keys (first 20): {loaded_missing[:20]}"
847
- )
848
- if load_result.unexpected_keys:
849
- raise RuntimeError(
850
- "Model has unexpected checkpoint keys after load_state_dict: "
851
- f"{sorted(load_result.unexpected_keys)[:20]}"
852
- )
853
- model.eval()
854
- return model
855
-
856
- def forward(
857
- self,
858
- feats: dict[str, Tensor],
859
- recycling_steps: int | None = None,
860
- num_sampling_steps: int | None = None,
861
- diffusion_samples: int | None = None,
862
- max_parallel_samples: int | None = None,
863
- run_confidence_sequentially: bool = True,
864
- detach_confidence: bool = True,
865
- output_attentions: bool | None = None,
866
- output_hidden_states: bool | None = None,
867
- return_dict: bool | None = None,
868
- verbose: bool = False,
869
- ) -> Boltz2ModelOutput | tuple[Any, ...]:
870
- # Feature shapes follow build_boltz2_features; b and t are batch and token counts.
871
- output_attentions = (
872
- self.config.output_attentions if output_attentions is None else output_attentions
873
- )
874
- if output_attentions:
875
- raise NotImplementedError(
876
- "Boltz2 does not expose normalized attention tensors from its structure "
877
- "modules. output_attentions=True is unsupported."
878
- )
879
- output_hidden_states = (
880
- self.config.output_hidden_states
881
- if output_hidden_states is None
882
- else output_hidden_states
883
- )
884
- return_dict = self.config.use_return_dict if return_dict is None else return_dict
885
- if recycling_steps is None:
886
- recycling_steps = self.config.default_recycling_steps
887
- if num_sampling_steps is None:
888
- num_sampling_steps = self.config.default_sampling_steps
889
- if diffusion_samples is None:
890
- diffusion_samples = self.config.default_diffusion_samples
891
- raw_output = self.core(
892
- feats=feats,
893
- recycling_steps=recycling_steps,
894
- num_sampling_steps=num_sampling_steps,
895
- diffusion_samples=diffusion_samples,
896
- max_parallel_samples=max_parallel_samples,
897
- run_confidence_sequentially=run_confidence_sequentially,
898
- detach_confidence=detach_confidence,
899
- verbose=verbose,
900
- ) # named tensors use the Boltz2InferenceCore.forward shapes
901
- token_state = raw_output.get("s") # (b, t, d_s) or None
902
- pair_state = raw_output.get("z") # (b, t, t, d_z) or None
903
- model_output = Boltz2ModelOutput(
904
- last_hidden_state=token_state,
905
- hidden_states=(token_state, pair_state)
906
- if output_hidden_states and token_state is not None and pair_state is not None
907
- else None,
908
- **raw_output,
909
- )
910
- return model_output if return_dict else model_output.to_tuple()
911
-
912
- def _to_model_device(
913
- self,
914
- feats: dict[str, Tensor],
915
- float_dtype: torch.dtype = torch.float32,
916
- ) -> dict[str, Tensor]:
917
- # Each feature tensor keeps its existing shape (...).
918
- moved: dict[str, Tensor] = {}
919
- for key, value in feats.items():
920
- if torch.is_tensor(value):
921
- if value.is_floating_point():
922
- moved[key] = value.to(
923
- device=self.device, dtype=float_dtype
924
- ) # same shape (...)
925
- else:
926
- moved[key] = value.to(device=self.device) # same shape (...)
927
- else:
928
- moved[key] = value # same shape (...)
929
- return moved # each tensor: same shape (...)
930
-
931
- def predict_structure(
932
- self,
933
- amino_acid_sequence: str,
934
- recycling_steps: int | None = None,
935
- num_sampling_steps: int | None = None,
936
- diffusion_samples: int | None = None,
937
- max_parallel_samples: int | None = None,
938
- run_confidence_sequentially: bool = True,
939
- float_dtype: torch.dtype = torch.float32,
940
- seed: int | None = None,
941
- verbose: bool = False,
942
- ) -> Boltz2StructureOutput:
943
- if float_dtype != torch.float32:
944
- raise ValueError(
945
- "Boltz2 predict_structure requires FP32 features and parameter storage; "
946
- "CUDA compute runs under the declared BF16 autocast policy."
947
- )
948
- parameter_dtypes = {
949
- parameter.dtype for parameter in self.parameters() if parameter.is_floating_point()
950
- }
951
- if parameter_dtypes and parameter_dtypes != {torch.float32}:
952
- raise ValueError(
953
- "Boltz2 predict_structure requires FP32 parameter storage before CUDA "
954
- f"BF16 autocast; found {sorted(map(str, parameter_dtypes))}."
955
- )
956
-
957
- with _seed_context(seed):
958
- with tqdm(
959
- total=1,
960
- desc="Boltz2: Prepare features",
961
- disable=not verbose,
962
- dynamic_ncols=True,
963
- leave=False,
964
- ) as progress:
965
- feats, template = build_boltz2_features(
966
- amino_acid_sequence=amino_acid_sequence,
967
- num_bins=self.config.num_bins,
968
- atoms_per_window_queries=(
969
- self.core.input_embedder.atom_encoder.atoms_per_window_queries
970
- ),
971
- ) # feature shapes are traced in build_boltz2_features
972
- feats = self._to_model_device(
973
- feats, float_dtype=torch.float32
974
- ) # unchanged feature shapes
975
- progress.update()
976
- autocast_context = (
977
- torch.autocast(device_type="cuda", dtype=torch.bfloat16)
978
- if self.device.type == "cuda"
979
- else nullcontext()
980
- )
981
- with torch.no_grad(), autocast_context:
982
- output = self.forward(
983
- feats=feats,
984
- recycling_steps=recycling_steps,
985
- num_sampling_steps=num_sampling_steps,
986
- diffusion_samples=diffusion_samples,
987
- max_parallel_samples=max_parallel_samples,
988
- run_confidence_sequentially=run_confidence_sequentially,
989
- return_dict=True,
990
- verbose=verbose,
991
- ) # named tensors use Boltz2InferenceCore.forward shapes
992
-
993
- sample_atom_coords_value = output.get(
994
- "sample_atom_coords"
995
- ) # (m, a_p, 3) or absent
996
- if not isinstance(sample_atom_coords_value, torch.Tensor):
997
- raise RuntimeError("Boltz2 structure sampling did not return coordinate tensors.")
998
- sample_atom_coords = sample_atom_coords_value.detach().cpu() # (m, a_p, 3)
999
- non_finite_mask = torch.logical_not(
1000
- torch.isfinite(sample_atom_coords)
1001
- ) # (m, a_p, 3)
1002
- if torch.any(non_finite_mask):
1003
- raise RuntimeError(
1004
- "sample_atom_coords contains non-finite values. "
1005
- f"Non-finite count: {int(non_finite_mask.sum().item())}"
1006
- )
1007
- atom_pad_mask = feats["atom_pad_mask"][0].detach().cpu() # (a_p,)
1008
- plddt = (
1009
- output["plddt"].detach().cpu() if "plddt" in output else None
1010
- ) # (m, t) or (m, a_p), depending on confidence resolution
1011
- complex_plddt = (
1012
- output["complex_plddt"].detach().cpu() if "complex_plddt" in output else None
1013
- ) # (m,) or None
1014
- iptm = (
1015
- output["iptm"].detach().cpu() if "iptm" in output else None
1016
- ) # (m,) or None
1017
- ptm = (
1018
- output["ptm"].detach().cpu() if "ptm" in output else None
1019
- ) # (m,) or None
1020
-
1021
- confidence_score = None # (m,) or None
1022
- if (complex_plddt is not None) and (iptm is not None) and (ptm is not None):
1023
- if torch.allclose(iptm, torch.zeros_like(iptm)):
1024
- confidence_score = (4 * complex_plddt + ptm) / 5 # (m,)
1025
- else:
1026
- confidence_score = (4 * complex_plddt + iptm) / 5 # (m,)
1027
-
1028
- return Boltz2StructureOutput(
1029
- sample_atom_coords=sample_atom_coords,
1030
- atom_pad_mask=atom_pad_mask,
1031
- plddt=plddt,
1032
- confidence_score=confidence_score,
1033
- complex_plddt=complex_plddt,
1034
- iptm=iptm,
1035
- ptm=ptm,
1036
- sequence=template.sequence,
1037
- structure_template=template,
1038
- raw_output={key: _to_cpu_detached(val) for key, val in output.items()},
1039
- seed=seed,
1040
- )
1041
-
1042
- def save_as_cif(
1043
- self,
1044
- structure_output: Boltz2StructureOutput,
1045
- output_path: str,
1046
- sample_index: int = 0,
1047
- ) -> str:
1048
- if structure_output.structure_template is None:
1049
- raise ValueError("structure_output.structure_template is required for CIF export.")
1050
- if structure_output.sample_atom_coords is None:
1051
- raise ValueError("structure_output.sample_atom_coords is required for CIF export.")
1052
- if structure_output.atom_pad_mask is None:
1053
- raise ValueError("structure_output.atom_pad_mask is required for CIF export.")
1054
- return write_cif(
1055
- structure_template=structure_output.structure_template,
1056
- atom_coords=structure_output.sample_atom_coords,
1057
- atom_mask=structure_output.atom_pad_mask,
1058
- output_path=output_path,
1059
- plddt=structure_output.plddt,
1060
- sample_index=sample_index,
1061
- )
 
 
 
 
 
 
1
+ """Transformers interfaces and checkpoint conversion for the Boltz2 runtime."""
2
+
3
+ import copy
4
+ import inspect
5
+ import random
6
+ import numpy as np
7
+ import torch
8
+ import torch.nn as nn
9
+
10
+ from collections.abc import Iterator, Mapping, Sequence
11
+ from contextlib import contextmanager, nullcontext
12
+ from dataclasses import dataclass
13
+ from typing import Any, ClassVar
14
+ from torch import Tensor
15
+ from tqdm.auto import tqdm
16
+ from transformers import PretrainedConfig, PreTrainedModel
17
+ from transformers.modeling_outputs import ModelOutput
18
+
19
+ from . import vb_const as const
20
+ from . import vb_layers_initialize as init
21
+ from .cif_writer import write_cif
22
+ from .minimal_featurizer import build_boltz2_features
23
+ from .minimal_structures import ProteinStructureTemplate
24
+ from .vb_const import bond_types as _vb_const_bond_types # noqa: F401
25
+ from .vb_layers_attention import AttentionPairBias as _vb_layers_attention_marker # noqa: F401
26
+ from .vb_layers_attentionv2 import AttentionPairBias as _vb_layers_attentionv2_marker # noqa: F401
27
+ from .vb_layers_confidence_utils import (
28
+ compute_ptms as _vb_layers_confidence_utils_marker, # noqa: F401
29
+ )
30
+ from .vb_layers_dropout import get_dropout_mask as _vb_layers_dropout_marker # noqa: F401
31
+ from .vb_layers_initialize import gating_init_ as _vb_layers_initialize_marker # noqa: F401
32
+ from .vb_layers_outer_product_mean import (
33
+ OuterProductMean as _vb_layers_outer_product_mean_marker, # noqa: F401
34
+ )
35
+ from .vb_layers_pair_averaging import (
36
+ PairWeightedAveraging as _vb_layers_pair_averaging_marker, # noqa: F401
37
+ )
38
+ from .vb_layers_pairformer import PairformerModule
39
+ from .vb_layers_transition import Transition as _vb_layers_transition_marker # noqa: F401
40
+ from .vb_layers_triangular_mult import (
41
+ TriangleMultiplicationIncoming as _vb_layers_triangular_mult_marker, # noqa: F401
42
+ )
43
+ from .vb_loss_diffusionv2 import weighted_rigid_align as _vb_loss_diffusionv2_marker # noqa: F401
44
+ from .vb_modules_confidencev2 import ConfidenceModule
45
+ from .vb_modules_diffusion_conditioning import DiffusionConditioning
46
+ from .vb_modules_diffusionv2 import AtomDiffusion, DiffusionModule
47
+ from .vb_modules_encodersv2 import RelativePositionEncoder
48
+ from .vb_modules_transformersv2 import (
49
+ DiffusionTransformer as _vb_modules_transformersv2_marker, # noqa: F401
50
+ )
51
+ from .vb_modules_trunkv2 import (
52
+ ContactConditioning,
53
+ DistogramModule,
54
+ InputEmbedder,
55
+ MSAModule,
56
+ )
57
+ from .vb_modules_utils import LinearNoBias as _vb_modules_utils_marker # noqa: F401
58
+ from .vb_potentials_potentials import (
59
+ get_potentials as _vb_potentials_potentials_marker, # noqa: F401
60
+ )
61
+ from .vb_potentials_schedules import (
62
+ ParameterSchedule as _vb_potentials_schedules_marker, # noqa: F401
63
+ )
64
+ from .vb_tri_attn_attention import (
65
+ TriangleAttentionStartingNode as _vb_tri_attn_attention_marker, # noqa: F401
66
+ )
67
+ from .vb_tri_attn_primitives import Attention as _vb_tri_attn_primitives_marker # noqa: F401
68
+ from .vb_tri_attn_utils import permute_final_dims as _vb_tri_attn_utils_marker # noqa: F401
69
+
70
+
71
+ def _default_steering_args() -> dict[str, Any]:
72
+ return {
73
+ "fk_steering": False,
74
+ "num_particles": 3,
75
+ "fk_lambda": 4.0,
76
+ "fk_resampling_interval": 3,
77
+ "physical_guidance_update": False,
78
+ "contact_guidance_update": False,
79
+ "num_gd_steps": 16,
80
+ }
81
+
82
+
83
+ def _boltz2_reference_diffusion_overrides() -> dict[str, Any]:
84
+ # Match Boltz2 CLI inference defaults from boltz.main/Boltz2DiffusionParams.
85
+ return {
86
+ "gamma_0": 0.8,
87
+ "gamma_min": 1.0,
88
+ "noise_scale": 1.003,
89
+ "rho": 7,
90
+ "step_scale": 1.5,
91
+ "sigma_min": 0.0001,
92
+ "sigma_max": 160.0,
93
+ "sigma_data": 16.0,
94
+ "P_mean": -1.2,
95
+ "P_std": 1.5,
96
+ "coordinate_augmentation": True,
97
+ "alignment_reverse_diff": True,
98
+ "synchronize_sigmas": True,
99
+ }
100
+
101
+
102
+ def _enforce_pairformer_v2(pairformer_args: Mapping[str, Any], context: str) -> dict[str, Any]:
103
+ if not isinstance(pairformer_args, Mapping):
104
+ raise TypeError(f"Expected {context} pairformer_args to be a dictionary.")
105
+ out = _to_plain_python(copy.deepcopy(pairformer_args))
106
+ if "v2" in out and not out["v2"]:
107
+ raise ValueError(f"{context} pairformer_args['v2'] must be True for Boltz2.")
108
+ out["v2"] = True
109
+ return out
110
+
111
+
112
+ def _require_key(mapping: dict[str, Any], key: str) -> Any:
113
+ if key not in mapping:
114
+ raise KeyError(f"Missing required key '{key}' in checkpoint hyperparameters.")
115
+ return mapping[key]
116
+
117
+
118
+ def _state_dict_without_wrappers(state_dict: dict[str, Tensor]) -> dict[str, Tensor]:
119
+ # Every state tensor has its parameter-defined shape (...).
120
+ cleaned: dict[str, Tensor] = {}
121
+ for key, value in state_dict.items():
122
+ if key.startswith("ema."):
123
+ continue
124
+ new_key = key
125
+ if new_key.startswith("model."):
126
+ new_key = new_key[len("model.") :]
127
+ if new_key.startswith("module."):
128
+ new_key = new_key[len("module.") :]
129
+ cleaned[new_key] = value # (...)
130
+ return cleaned
131
+
132
+
133
+ def _to_cpu_detached(value: Any) -> Any:
134
+ if torch.is_tensor(value):
135
+ return value.detach().cpu() # same shape (...)
136
+ if isinstance(value, dict):
137
+ out: dict[Any, Any] = {}
138
+ for key, nested_value in value.items():
139
+ out[key] = _to_cpu_detached(nested_value)
140
+ return out
141
+ if isinstance(value, list):
142
+ return [_to_cpu_detached(item) for item in value]
143
+ if isinstance(value, tuple):
144
+ return tuple(_to_cpu_detached(item) for item in value)
145
+ return value
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class _RandomState:
150
+ python: object
151
+ numpy: tuple[Any, ...]
152
+ torch_cpu: Tensor # (n_cpu_rng_state,)
153
+ torch_cuda: list[Tensor] | None # each: (n_cuda_rng_state,)
154
+
155
+
156
+ @contextmanager
157
+ def _seed_context(seed: int | None) -> Iterator[None]:
158
+ """Temporarily seed every RNG used by Boltz2 and restore caller state."""
159
+
160
+ if seed is None:
161
+ yield
162
+ return
163
+ if isinstance(seed, bool) or not isinstance(seed, int):
164
+ raise TypeError(
165
+ "Boltz2 seed must be an int or None; bool and coerced values are not accepted."
166
+ )
167
+ state = _RandomState(
168
+ python=random.getstate(),
169
+ numpy=np.random.get_state(),
170
+ torch_cpu=torch.random.get_rng_state(),
171
+ torch_cuda=torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None,
172
+ )
173
+ normalized_seed = seed % (2**32)
174
+ random.seed(normalized_seed)
175
+ np.random.seed(normalized_seed)
176
+ torch.manual_seed(normalized_seed)
177
+ if torch.cuda.is_available():
178
+ torch.cuda.manual_seed_all(normalized_seed)
179
+ try:
180
+ yield
181
+ finally:
182
+ random.setstate(state.python)
183
+ np.random.set_state(state.numpy)
184
+ torch.random.set_rng_state(state.torch_cpu)
185
+ if state.torch_cuda is not None:
186
+ torch.cuda.set_rng_state_all(state.torch_cuda)
187
+
188
+
189
+ def _to_plain_python(value: Any) -> Any:
190
+ if isinstance(value, Mapping):
191
+ out: dict[Any, Any] = {}
192
+ for key, nested_value in value.items():
193
+ out[key] = _to_plain_python(nested_value)
194
+ return out
195
+ if isinstance(value, list):
196
+ return [_to_plain_python(item) for item in value]
197
+ if isinstance(value, tuple):
198
+ return [_to_plain_python(item) for item in value]
199
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
200
+ return [_to_plain_python(item) for item in value]
201
+ return value
202
+
203
+
204
+ def _filtered_kwargs(target: Any, kwargs: dict[str, Any]) -> dict[str, Any]:
205
+ signature = inspect.signature(target.__init__)
206
+ allowed = set(signature.parameters.keys())
207
+ allowed.discard("self")
208
+ filtered: dict[str, Any] = {}
209
+ for key, value in kwargs.items():
210
+ if key in allowed:
211
+ filtered[key] = value
212
+ return filtered
213
+
214
+
215
+ @dataclass
216
+ class Boltz2ModelOutput(ModelOutput):
217
+ """Raw Boltz2 inference output with standard AutoModel controls."""
218
+
219
+ last_hidden_state: torch.Tensor | None = None # (b, t, d_s) or None
220
+ hidden_states: tuple[torch.Tensor, ...] | None = None # each shape traced at source
221
+ attentions: tuple[torch.Tensor, ...] | None = None # unsupported; always None
222
+ pdistogram: torch.Tensor | None = None # (b, t, t, n_distogram, n_bin) or None
223
+ s: torch.Tensor | None = None # (b, t, d_s) or None
224
+ z: torch.Tensor | None = None # (b, t, t, d_z) or None
225
+ sample_atom_coords: torch.Tensor | None = None # (b * m, a, 3) or None
226
+ diff_token_repr: torch.Tensor | None = None # (...) or None
227
+ s_conf: torch.Tensor | None = None # (...) or None
228
+ z_conf: torch.Tensor | None = None # (...) or None
229
+ pde_logits: torch.Tensor | None = None # (...) or None
230
+ plddt_logits: torch.Tensor | None = None # (...) or None
231
+ resolved_logits: torch.Tensor | None = None # (...) or None
232
+ pde: torch.Tensor | None = None # (...) or None
233
+ plddt: torch.Tensor | None = None # (...) or None
234
+ complex_plddt: torch.Tensor | None = None # (...) or None
235
+ complex_iplddt: torch.Tensor | None = None # (...) or None
236
+ complex_pde: torch.Tensor | None = None # (...) or None
237
+ complex_ipde: torch.Tensor | None = None # (...) or None
238
+ pae_logits: torch.Tensor | None = None # (...) or None
239
+ pae: torch.Tensor | None = None # (...) or None
240
+ ptm: torch.Tensor | None = None # (...) or None
241
+ iptm: torch.Tensor | None = None # (...) or None
242
+ ligand_iptm: torch.Tensor | None = None # (...) or None
243
+ protein_iptm: torch.Tensor | None = None # (...) or None
244
+ pair_chains_iptm: Any = None
245
+
246
+
247
+ @dataclass
248
+ class Boltz2StructureOutput(ModelOutput):
249
+ sample_atom_coords: torch.Tensor | None = None # (m, a_p, 3) or None
250
+ atom_pad_mask: torch.Tensor | None = None # (a_p,) or None
251
+ plddt: torch.Tensor | None = None # (m, t) or (m, a_p), depending on confidence resolution
252
+ confidence_score: torch.Tensor | None = None # (m,) or None
253
+ complex_plddt: torch.Tensor | None = None # (m,) or None
254
+ iptm: torch.Tensor | None = None # (m,) or None
255
+ ptm: torch.Tensor | None = None # (m,) or None
256
+ sequence: str | None = None
257
+ structure_template: ProteinStructureTemplate | None = None
258
+ raw_output: dict[str, torch.Tensor] | None = None # values keep model-output shapes
259
+ seed: int | None = None
260
+
261
+
262
+ class Boltz2Config(PretrainedConfig):
263
+ model_type = "boltz2_automodel"
264
+
265
+ def __init__(
266
+ self,
267
+ core_kwargs: dict[str, Any] | None = None,
268
+ num_bins: int = 64,
269
+ default_recycling_steps: int = 3,
270
+ default_sampling_steps: int = 200,
271
+ default_diffusion_samples: int = 1,
272
+ **kwargs,
273
+ ) -> None:
274
+ super().__init__(**kwargs)
275
+ if core_kwargs is None:
276
+ core_kwargs = {}
277
+ self.core_kwargs = core_kwargs
278
+ self.num_bins = num_bins
279
+ self.default_recycling_steps = default_recycling_steps
280
+ self.default_sampling_steps = default_sampling_steps
281
+ self.default_diffusion_samples = default_diffusion_samples
282
+
283
+ @classmethod
284
+ def from_hyperparameters(
285
+ cls,
286
+ hparams: dict[str, Any],
287
+ use_kernels: bool = False,
288
+ default_recycling_steps: int | None = None,
289
+ default_sampling_steps: int | None = None,
290
+ default_diffusion_samples: int | None = None,
291
+ ) -> "Boltz2Config":
292
+ if not isinstance(hparams, dict):
293
+ raise TypeError("Expected checkpoint hyperparameters as a dictionary.")
294
+ required = [
295
+ "atom_s",
296
+ "atom_z",
297
+ "token_s",
298
+ "token_z",
299
+ "num_bins",
300
+ "embedder_args",
301
+ "msa_args",
302
+ "pairformer_args",
303
+ "score_model_args",
304
+ "diffusion_process_args",
305
+ ]
306
+ for key in required:
307
+ _require_key(hparams, key)
308
+
309
+ pairformer_args = _enforce_pairformer_v2(
310
+ hparams["pairformer_args"],
311
+ context="checkpoint",
312
+ )
313
+ diffusion_process_args = _to_plain_python(copy.deepcopy(hparams["diffusion_process_args"]))
314
+ diffusion_overrides = _boltz2_reference_diffusion_overrides()
315
+ for key in diffusion_overrides:
316
+ diffusion_process_args[key] = diffusion_overrides[key]
317
+
318
+ core_kwargs: dict[str, Any] = {
319
+ "atom_s": hparams["atom_s"],
320
+ "atom_z": hparams["atom_z"],
321
+ "token_s": hparams["token_s"],
322
+ "token_z": hparams["token_z"],
323
+ "num_bins": hparams["num_bins"],
324
+ "embedder_args": _to_plain_python(copy.deepcopy(hparams["embedder_args"])),
325
+ "msa_args": _to_plain_python(copy.deepcopy(hparams["msa_args"])),
326
+ "pairformer_args": pairformer_args,
327
+ "score_model_args": _to_plain_python(copy.deepcopy(hparams["score_model_args"])),
328
+ "diffusion_process_args": diffusion_process_args,
329
+ "use_kernels": use_kernels,
330
+ }
331
+
332
+ if "confidence_model_args" in hparams:
333
+ confidence_model_args = _to_plain_python(
334
+ copy.deepcopy(hparams["confidence_model_args"])
335
+ )
336
+ if "pairformer_args" in confidence_model_args:
337
+ confidence_model_args["pairformer_args"] = _enforce_pairformer_v2(
338
+ confidence_model_args["pairformer_args"],
339
+ context="confidence",
340
+ )
341
+ core_kwargs["confidence_model_args"] = confidence_model_args
342
+ else:
343
+ core_kwargs["confidence_model_args"] = None
344
+
345
+ core_kwargs["confidence_prediction"] = hparams.get("confidence_prediction", True)
346
+ core_kwargs["token_level_confidence"] = hparams.get("token_level_confidence", True)
347
+ core_kwargs["alpha_pae"] = hparams.get("alpha_pae", 0.0)
348
+ core_kwargs["atoms_per_window_queries"] = hparams.get("atoms_per_window_queries", 32)
349
+ core_kwargs["atoms_per_window_keys"] = hparams.get("atoms_per_window_keys", 128)
350
+ core_kwargs["atom_feature_dim"] = hparams.get("atom_feature_dim", 128)
351
+ core_kwargs["bond_type_feature"] = hparams.get("bond_type_feature", False)
352
+ core_kwargs["run_trunk_and_structure"] = hparams.get("run_trunk_and_structure", True)
353
+ core_kwargs["skip_run_structure"] = hparams.get("skip_run_structure", False)
354
+ core_kwargs["fix_sym_check"] = hparams.get("fix_sym_check", False)
355
+ core_kwargs["cyclic_pos_enc"] = hparams.get("cyclic_pos_enc", False)
356
+ core_kwargs["use_no_atom_char"] = hparams.get("use_no_atom_char", False)
357
+ core_kwargs["use_atom_backbone_feat"] = hparams.get("use_atom_backbone_feat", False)
358
+ core_kwargs["use_residue_feats_atoms"] = hparams.get("use_residue_feats_atoms", False)
359
+ core_kwargs["conditioning_cutoff_min"] = hparams.get("conditioning_cutoff_min", 4.0)
360
+ core_kwargs["conditioning_cutoff_max"] = hparams.get("conditioning_cutoff_max", 20.0)
361
+
362
+ if "steering_args" in hparams and hparams["steering_args"] is not None:
363
+ core_kwargs["steering_args"] = _to_plain_python(copy.deepcopy(hparams["steering_args"]))
364
+ else:
365
+ core_kwargs["steering_args"] = _default_steering_args()
366
+
367
+ if "validation_args" in hparams:
368
+ validation_args = hparams["validation_args"]
369
+ if not isinstance(validation_args, Mapping):
370
+ raise TypeError(
371
+ "Expected 'validation_args' in checkpoint hyperparameters to be a mapping."
372
+ )
373
+ if default_recycling_steps is None and "recycling_steps" in validation_args:
374
+ default_recycling_steps = validation_args["recycling_steps"]
375
+ if default_sampling_steps is None and "sampling_steps" in validation_args:
376
+ default_sampling_steps = validation_args["sampling_steps"]
377
+ if default_diffusion_samples is None and "diffusion_samples" in validation_args:
378
+ default_diffusion_samples = validation_args["diffusion_samples"]
379
+
380
+ if default_recycling_steps is None:
381
+ default_recycling_steps = 3
382
+ if default_sampling_steps is None:
383
+ default_sampling_steps = 200
384
+ if default_diffusion_samples is None:
385
+ default_diffusion_samples = 1
386
+
387
+ return cls(
388
+ core_kwargs=core_kwargs,
389
+ num_bins=hparams["num_bins"],
390
+ default_recycling_steps=default_recycling_steps,
391
+ default_sampling_steps=default_sampling_steps,
392
+ default_diffusion_samples=default_diffusion_samples,
393
+ )
394
+
395
+
396
+ class Boltz2InferenceCore(nn.Module):
397
+ def __init__(
398
+ self,
399
+ atom_s: int,
400
+ atom_z: int,
401
+ token_s: int,
402
+ token_z: int,
403
+ num_bins: int,
404
+ embedder_args: dict[str, Any],
405
+ msa_args: dict[str, Any],
406
+ pairformer_args: dict[str, Any],
407
+ score_model_args: dict[str, Any],
408
+ diffusion_process_args: dict[str, Any],
409
+ confidence_model_args: dict[str, Any] | None = None,
410
+ atom_feature_dim: int = 128,
411
+ confidence_prediction: bool = True,
412
+ token_level_confidence: bool = True,
413
+ alpha_pae: float = 0.0,
414
+ atoms_per_window_queries: int = 32,
415
+ atoms_per_window_keys: int = 128,
416
+ run_trunk_and_structure: bool = True,
417
+ skip_run_structure: bool = False,
418
+ bond_type_feature: bool = False,
419
+ fix_sym_check: bool = False,
420
+ cyclic_pos_enc: bool = False,
421
+ use_no_atom_char: bool = False,
422
+ use_atom_backbone_feat: bool = False,
423
+ use_residue_feats_atoms: bool = False,
424
+ conditioning_cutoff_min: float = 4.0,
425
+ conditioning_cutoff_max: float = 20.0,
426
+ use_kernels: bool = False,
427
+ steering_args: dict[str, Any] | None = None,
428
+ ) -> None:
429
+ super().__init__()
430
+ self.use_kernels = use_kernels
431
+ self.confidence_prediction = confidence_prediction
432
+ self.token_level_confidence = token_level_confidence
433
+ self.alpha_pae = alpha_pae
434
+ self.run_trunk_and_structure = run_trunk_and_structure
435
+ self.skip_run_structure = skip_run_structure
436
+ self.bond_type_feature = bond_type_feature
437
+ self.steering_args = (
438
+ steering_args if steering_args is not None else _default_steering_args()
439
+ )
440
+ if "v2" not in pairformer_args:
441
+ raise ValueError("Boltz2 requires pairformer_args['v2'].")
442
+ if not pairformer_args["v2"]:
443
+ raise ValueError("Boltz2 requires pairformer_args['v2']=True.")
444
+
445
+ full_embedder_args = {
446
+ "atom_s": atom_s,
447
+ "atom_z": atom_z,
448
+ "token_s": token_s,
449
+ "token_z": token_z,
450
+ "atoms_per_window_queries": atoms_per_window_queries,
451
+ "atoms_per_window_keys": atoms_per_window_keys,
452
+ "atom_feature_dim": atom_feature_dim,
453
+ "use_no_atom_char": use_no_atom_char,
454
+ "use_atom_backbone_feat": use_atom_backbone_feat,
455
+ "use_residue_feats_atoms": use_residue_feats_atoms,
456
+ **embedder_args,
457
+ }
458
+ full_embedder_args = _filtered_kwargs(InputEmbedder, full_embedder_args)
459
+ self.input_embedder = InputEmbedder(**full_embedder_args)
460
+
461
+ self.s_init = nn.Linear(token_s, token_s, bias=False)
462
+ self.z_init_1 = nn.Linear(token_s, token_z, bias=False)
463
+ self.z_init_2 = nn.Linear(token_s, token_z, bias=False)
464
+ self.rel_pos = RelativePositionEncoder(
465
+ token_z,
466
+ fix_sym_check=fix_sym_check,
467
+ cyclic_pos_enc=cyclic_pos_enc,
468
+ )
469
+ self.token_bonds = nn.Linear(1, token_z, bias=False)
470
+ if self.bond_type_feature:
471
+ self.token_bonds_type = nn.Embedding(len(const.bond_types) + 1, token_z)
472
+
473
+ self.contact_conditioning = ContactConditioning(
474
+ token_z=token_z,
475
+ cutoff_min=conditioning_cutoff_min,
476
+ cutoff_max=conditioning_cutoff_max,
477
+ )
478
+ self.s_norm = nn.LayerNorm(token_s)
479
+ self.z_norm = nn.LayerNorm(token_z)
480
+
481
+ self.s_recycle = nn.Linear(token_s, token_s, bias=False)
482
+ self.z_recycle = nn.Linear(token_z, token_z, bias=False)
483
+ init.gating_init_(self.s_recycle.weight)
484
+ init.gating_init_(self.z_recycle.weight)
485
+
486
+ msa_kwargs = _filtered_kwargs(
487
+ MSAModule,
488
+ {"token_z": token_z, "token_s": token_s, **msa_args},
489
+ )
490
+ self.msa_module = MSAModule(**msa_kwargs)
491
+
492
+ pairformer_kwargs = _filtered_kwargs(
493
+ PairformerModule,
494
+ {"token_s": token_s, "token_z": token_z, **pairformer_args},
495
+ )
496
+ if "token_s" not in pairformer_kwargs or "token_z" not in pairformer_kwargs:
497
+ raise RuntimeError("Boltz2 pairformer construction lost its token dimensions.")
498
+ pairformer_token_s = pairformer_kwargs.pop("token_s")
499
+ pairformer_token_z = pairformer_kwargs.pop("token_z")
500
+ self.pairformer_module = PairformerModule(
501
+ pairformer_token_s,
502
+ pairformer_token_z,
503
+ **pairformer_kwargs,
504
+ )
505
+
506
+ diffusion_conditioning_kwargs = {
507
+ "token_s": token_s,
508
+ "token_z": token_z,
509
+ "atom_s": atom_s,
510
+ "atom_z": atom_z,
511
+ "atoms_per_window_queries": atoms_per_window_queries,
512
+ "atoms_per_window_keys": atoms_per_window_keys,
513
+ "atom_encoder_depth": score_model_args["atom_encoder_depth"],
514
+ "atom_encoder_heads": score_model_args["atom_encoder_heads"],
515
+ "token_transformer_depth": score_model_args["token_transformer_depth"],
516
+ "token_transformer_heads": score_model_args["token_transformer_heads"],
517
+ "atom_decoder_depth": score_model_args["atom_decoder_depth"],
518
+ "atom_decoder_heads": score_model_args["atom_decoder_heads"],
519
+ "atom_feature_dim": atom_feature_dim,
520
+ "conditioning_transition_layers": score_model_args["conditioning_transition_layers"],
521
+ "use_no_atom_char": use_no_atom_char,
522
+ "use_atom_backbone_feat": use_atom_backbone_feat,
523
+ "use_residue_feats_atoms": use_residue_feats_atoms,
524
+ }
525
+ diffusion_conditioning_kwargs = _filtered_kwargs(
526
+ DiffusionConditioning,
527
+ diffusion_conditioning_kwargs,
528
+ )
529
+ self.diffusion_conditioning = DiffusionConditioning(**diffusion_conditioning_kwargs)
530
+
531
+ structure_score_model_args = {
532
+ "token_s": token_s,
533
+ "atom_s": atom_s,
534
+ "atoms_per_window_queries": atoms_per_window_queries,
535
+ "atoms_per_window_keys": atoms_per_window_keys,
536
+ **score_model_args,
537
+ }
538
+ structure_score_model_args = _filtered_kwargs(
539
+ DiffusionModule,
540
+ structure_score_model_args,
541
+ )
542
+ structure_module_kwargs = {
543
+ "score_model_args": structure_score_model_args,
544
+ "compile_score": False,
545
+ **diffusion_process_args,
546
+ }
547
+ structure_module_kwargs = _filtered_kwargs(AtomDiffusion, structure_module_kwargs)
548
+ self.structure_module = AtomDiffusion(**structure_module_kwargs)
549
+ self.distogram_module = DistogramModule(token_z, num_bins)
550
+
551
+ if self.confidence_prediction:
552
+ if confidence_model_args is None:
553
+ raise ValueError(
554
+ "confidence_prediction=True requires confidence_model_args in config."
555
+ )
556
+ confidence_kwargs = {
557
+ "token_s": token_s,
558
+ "token_z": token_z,
559
+ "token_level_confidence": token_level_confidence,
560
+ "bond_type_feature": bond_type_feature,
561
+ "fix_sym_check": fix_sym_check,
562
+ "cyclic_pos_enc": cyclic_pos_enc,
563
+ "conditioning_cutoff_min": conditioning_cutoff_min,
564
+ "conditioning_cutoff_max": conditioning_cutoff_max,
565
+ **confidence_model_args,
566
+ }
567
+ confidence_kwargs = _filtered_kwargs(ConfidenceModule, confidence_kwargs)
568
+ self.confidence_module = ConfidenceModule(**confidence_kwargs)
569
+
570
+ def forward(
571
+ self,
572
+ feats: dict[str, Tensor],
573
+ recycling_steps: int = 3,
574
+ num_sampling_steps: int | None = None,
575
+ diffusion_samples: int = 1,
576
+ max_parallel_samples: int | None = None,
577
+ run_confidence_sequentially: bool = True,
578
+ detach_confidence: bool = True,
579
+ verbose: bool = False,
580
+ ) -> dict[str, Tensor]:
581
+ # b is the batch size, t the token count, a the padded atom count,
582
+ # d_s the token width, d_z the pair width, and m the diffusion multiplicity.
583
+ with tqdm(
584
+ total=1,
585
+ desc="Boltz2: Input embeddings",
586
+ disable=not verbose,
587
+ dynamic_ncols=True,
588
+ leave=False,
589
+ ) as progress:
590
+ s_inputs = self.input_embedder(feats) # (b, t, d_s)
591
+ progress.update()
592
+ s_init = self.s_init(s_inputs) # (b, t, d_s)
593
+
594
+ z_init = (
595
+ self.z_init_1(s_inputs)[:, :, None] + self.z_init_2(s_inputs)[:, None, :]
596
+ ) # (b, t, t, d_z)
597
+ relative_position_encoding = self.rel_pos(feats) # (b, t, t, d_z)
598
+ z_init = z_init + relative_position_encoding # (b, t, t, d_z)
599
+ z_init = z_init + self.token_bonds(
600
+ feats["token_bonds"].float()
601
+ ) # (b, t, t, d_z)
602
+ if self.bond_type_feature:
603
+ z_init = z_init + self.token_bonds_type(
604
+ feats["type_bonds"].long()
605
+ ) # (b, t, t, d_z)
606
+ z_init = z_init + self.contact_conditioning(feats) # (b, t, t, d_z)
607
+
608
+ s = torch.zeros_like(s_init) # (b, t, d_s)
609
+ z = torch.zeros_like(z_init) # (b, t, t, d_z)
610
+ mask = feats["token_pad_mask"].float() # (b, t)
611
+ pair_mask = mask[:, :, None] * mask[:, None, :] # (b, t, t)
612
+
613
+ if self.run_trunk_and_structure:
614
+ for _ in tqdm(
615
+ range(recycling_steps + 1),
616
+ desc="Boltz2: Recycling",
617
+ unit="cycle",
618
+ disable=not verbose,
619
+ dynamic_ncols=True,
620
+ leave=False,
621
+ ):
622
+ s = s_init + self.s_recycle(self.s_norm(s)) # (b, t, d_s)
623
+ z = z_init + self.z_recycle(self.z_norm(z)) # (b, t, t, d_z)
624
+ z = z + self.msa_module(
625
+ z,
626
+ s_inputs,
627
+ feats,
628
+ use_kernels=self.use_kernels,
629
+ ) # (b, t, t, d_z)
630
+ s, z = self.pairformer_module(
631
+ s,
632
+ z,
633
+ mask=mask,
634
+ pair_mask=pair_mask,
635
+ use_kernels=self.use_kernels,
636
+ ) # (b, t, d_s), (b, t, t, d_z)
637
+
638
+ pdistogram = self.distogram_module(z) # (b, t, t, n_distogram, n_bin)
639
+ output: dict[str, Tensor] = {
640
+ "pdistogram": pdistogram,
641
+ "s": s,
642
+ "z": z,
643
+ }
644
+
645
+ if self.run_trunk_and_structure and (not self.skip_run_structure):
646
+ q, c, to_keys, atom_enc_bias, atom_dec_bias, token_trans_bias = (
647
+ self.diffusion_conditioning(
648
+ s_trunk=s,
649
+ z_trunk=z,
650
+ relative_position_encoding=relative_position_encoding,
651
+ feats=feats,
652
+ )
653
+ ) # q/c: (b, a, d_a); biases retain their attention-specific axes
654
+ diffusion_conditioning = {
655
+ "q": q,
656
+ "c": c,
657
+ "to_keys": to_keys,
658
+ "atom_enc_bias": atom_enc_bias,
659
+ "atom_dec_bias": atom_dec_bias,
660
+ "token_trans_bias": token_trans_bias,
661
+ }
662
+ with torch.autocast("cuda", enabled=False):
663
+ struct_out = self.structure_module.sample(
664
+ s_trunk=s.float(),
665
+ s_inputs=s_inputs.float(),
666
+ feats=feats,
667
+ num_sampling_steps=num_sampling_steps,
668
+ atom_mask=feats["atom_pad_mask"].float(),
669
+ multiplicity=diffusion_samples,
670
+ max_parallel_samples=max_parallel_samples,
671
+ steering_args=self.steering_args,
672
+ diffusion_conditioning=diffusion_conditioning,
673
+ verbose=verbose,
674
+ ) # tensor values include sample_atom_coords: (b * m, a, 3)
675
+ output.update(struct_out)
676
+
677
+ if self.confidence_prediction:
678
+ if self.skip_run_structure:
679
+ x_pred = feats["coords"].repeat_interleave(
680
+ diffusion_samples, 0
681
+ ) # (b * m, ..., a, 3)
682
+ else:
683
+ if "sample_atom_coords" not in output:
684
+ raise RuntimeError("Structure sampling did not produce sample_atom_coords.")
685
+ x_pred = output["sample_atom_coords"] # (b * m, a, 3)
686
+
687
+ if detach_confidence:
688
+ s_inputs_c = s_inputs.detach() # (b, t, d_s)
689
+ s_c = s.detach() # (b, t, d_s)
690
+ z_c = z.detach() # (b, t, t, d_z)
691
+ x_pred_c = x_pred.detach() # (b * m, ..., a, 3)
692
+ pdist_c = output["pdistogram"][:, :, :, 0].detach() # (b, t, t, n_bin)
693
+ else:
694
+ s_inputs_c = s_inputs # (b, t, d_s)
695
+ s_c = s # (b, t, d_s)
696
+ z_c = z # (b, t, t, d_z)
697
+ x_pred_c = x_pred # (b * m, ..., a, 3)
698
+ pdist_c = output["pdistogram"][:, :, :, 0] # (b, t, t, n_bin)
699
+
700
+ with tqdm(
701
+ total=1,
702
+ desc="Boltz2: Confidence",
703
+ disable=not verbose,
704
+ dynamic_ncols=True,
705
+ leave=False,
706
+ ) as progress:
707
+ output.update(
708
+ self.confidence_module(
709
+ s_inputs=s_inputs_c,
710
+ s=s_c,
711
+ z=z_c,
712
+ x_pred=x_pred_c,
713
+ feats=feats,
714
+ pred_distogram_logits=pdist_c,
715
+ multiplicity=diffusion_samples,
716
+ run_sequentially=run_confidence_sequentially,
717
+ use_kernels=self.use_kernels,
718
+ )
719
+ )
720
+ progress.update()
721
+
722
+ return output # named tensors retain the shapes traced above
723
+
724
+
725
+ class Boltz2Model(PreTrainedModel):
726
+ config_class = Boltz2Config
727
+ base_model_prefix = "core"
728
+ all_tied_weights_keys: ClassVar[dict[str, str]] = {}
729
+
730
+ def __init__(self, config: Boltz2Config) -> None:
731
+ super().__init__(config)
732
+ if not isinstance(config.core_kwargs, dict):
733
+ raise TypeError("config.core_kwargs must be a dictionary.")
734
+ self.core = Boltz2InferenceCore(**config.core_kwargs)
735
+
736
+ def _init_weights(self, module: nn.Module) -> None:
737
+ # Parameters keep their constructed or loaded values. Transformers 5 builds the
738
+ # model on the meta device and leaves a buffer no checkpoint holds uninitialized,
739
+ # so the diffusion module's non-persistent zero, which the loss breakdown
740
+ # reports when the smooth lDDT loss is off, is refilled here.
741
+ if isinstance(module, AtomDiffusion):
742
+ module.zero.zero_()
743
+
744
+ def _detied_state_dict(self) -> dict[str, Tensor]:
745
+ # Every state tensor has its parameter-defined shape (...).
746
+ raw_state = self.state_dict()
747
+ seen_ptrs: dict[int, str] = {}
748
+ out: dict[str, Tensor] = {}
749
+ for key, tensor in raw_state.items():
750
+ if torch.is_tensor(tensor):
751
+ ptr = tensor.untyped_storage().data_ptr()
752
+ if ptr in seen_ptrs:
753
+ out[key] = tensor.clone() # (...)
754
+ else:
755
+ seen_ptrs[ptr] = key
756
+ out[key] = tensor # (...)
757
+ else:
758
+ out[key] = tensor # (...)
759
+ return out # each tensor: (...)
760
+
761
+ def save_pretrained(self, save_directory: str, **kwargs: Any) -> None:
762
+ if "safe_serialization" not in kwargs:
763
+ kwargs["safe_serialization"] = True
764
+ if "state_dict" not in kwargs:
765
+ kwargs["state_dict"] = self._detied_state_dict()
766
+ super().save_pretrained(save_directory, **kwargs)
767
+
768
+ @property
769
+ def device(self) -> torch.device:
770
+ return next(self.parameters()).device
771
+
772
+ @classmethod
773
+ def from_boltz_checkpoint(
774
+ cls,
775
+ checkpoint_path: str,
776
+ map_location: str | torch.device = "cpu",
777
+ use_kernels: bool = False,
778
+ default_recycling_steps: int | None = None,
779
+ default_sampling_steps: int | None = None,
780
+ default_diffusion_samples: int | None = None,
781
+ *,
782
+ allow_unsafe_pickle: bool = False,
783
+ ) -> "Boltz2Model":
784
+ """Import an official Boltz Lightning checkpoint into the inference model.
785
+
786
+ Lightning checkpoints require Python pickle deserialization. The caller
787
+ must opt in with the literal value ``True`` after independently verifying
788
+ that the checkpoint comes from a trusted source and matches its expected
789
+ cryptographic hash.
790
+ """
791
+
792
+ if allow_unsafe_pickle is not True:
793
+ raise ValueError(
794
+ "Boltz Lightning checkpoints contain Python pickle and cannot be loaded "
795
+ "safely. Pass allow_unsafe_pickle=True only for a trusted, hash-verified "
796
+ "checkpoint."
797
+ )
798
+
799
+ # The official Lightning checkpoints contain OmegaConf objects, so this explicit
800
+ # trusted-checkpoint boundary requires full Python unpickling.
801
+ checkpoint = torch.load(
802
+ checkpoint_path,
803
+ map_location=map_location,
804
+ weights_only=False,
805
+ ) # mapping values include state tensors with parameter-defined shapes (...)
806
+ if not isinstance(checkpoint, dict):
807
+ raise TypeError("Checkpoint must deserialize to a dictionary.")
808
+ _require_key(checkpoint, "hyper_parameters")
809
+ _require_key(checkpoint, "state_dict")
810
+
811
+ hparams = checkpoint["hyper_parameters"]
812
+ if not isinstance(hparams, dict):
813
+ raise TypeError("Checkpoint hyper_parameters must be a dictionary.")
814
+ state_dict = checkpoint["state_dict"] # each tensor: (...)
815
+ if not isinstance(state_dict, dict):
816
+ raise TypeError("Checkpoint state_dict must be a dictionary.")
817
+
818
+ config = Boltz2Config.from_hyperparameters(
819
+ hparams,
820
+ use_kernels=use_kernels,
821
+ default_recycling_steps=default_recycling_steps,
822
+ default_sampling_steps=default_sampling_steps,
823
+ default_diffusion_samples=default_diffusion_samples,
824
+ )
825
+ model = cls(config)
826
+ cleaned = _state_dict_without_wrappers(state_dict) # each tensor: (...)
827
+ target_keys = set(model.core.state_dict().keys())
828
+ for key in target_keys:
829
+ if ".attention.norm_s." in key:
830
+ raise RuntimeError(
831
+ "Boltz2 inference core unexpectedly uses v1 attention parameters. "
832
+ "Expected pairformer v2 architecture."
833
+ )
834
+ filtered: dict[str, Tensor] = {}
835
+ for key, value in cleaned.items():
836
+ if key in target_keys:
837
+ filtered[key] = value # (...)
838
+
839
+ missing = sorted(target_keys.difference(filtered.keys()))
840
+ if missing:
841
+ raise RuntimeError(
842
+ "Checkpoint is missing required parameters for Boltz2 inference core. "
843
+ f"Missing keys (first 20): {missing[:20]}"
844
+ )
845
+
846
+ load_result = model.core.load_state_dict(filtered, strict=False)
847
+ loaded_missing = sorted(load_result.missing_keys)
848
+ if loaded_missing:
849
+ raise RuntimeError(
850
+ "Model has unexpected missing keys after load_state_dict. "
851
+ f"Missing keys (first 20): {loaded_missing[:20]}"
852
+ )
853
+ if load_result.unexpected_keys:
854
+ raise RuntimeError(
855
+ "Model has unexpected checkpoint keys after load_state_dict: "
856
+ f"{sorted(load_result.unexpected_keys)[:20]}"
857
+ )
858
+ model.eval()
859
+ return model
860
+
861
+ def forward(
862
+ self,
863
+ feats: dict[str, Tensor],
864
+ recycling_steps: int | None = None,
865
+ num_sampling_steps: int | None = None,
866
+ diffusion_samples: int | None = None,
867
+ max_parallel_samples: int | None = None,
868
+ run_confidence_sequentially: bool = True,
869
+ detach_confidence: bool = True,
870
+ output_attentions: bool | None = None,
871
+ output_hidden_states: bool | None = None,
872
+ return_dict: bool | None = None,
873
+ verbose: bool = False,
874
+ ) -> Boltz2ModelOutput | tuple[Any, ...]:
875
+ # Feature shapes follow build_boltz2_features; b and t are batch and token counts.
876
+ output_attentions = (
877
+ self.config.output_attentions if output_attentions is None else output_attentions
878
+ )
879
+ if output_attentions:
880
+ raise NotImplementedError(
881
+ "Boltz2 does not expose normalized attention tensors from its structure "
882
+ "modules. output_attentions=True is unsupported."
883
+ )
884
+ output_hidden_states = (
885
+ self.config.output_hidden_states
886
+ if output_hidden_states is None
887
+ else output_hidden_states
888
+ )
889
+ return_dict = self.config.use_return_dict if return_dict is None else return_dict
890
+ if recycling_steps is None:
891
+ recycling_steps = self.config.default_recycling_steps
892
+ if num_sampling_steps is None:
893
+ num_sampling_steps = self.config.default_sampling_steps
894
+ if diffusion_samples is None:
895
+ diffusion_samples = self.config.default_diffusion_samples
896
+ raw_output = self.core(
897
+ feats=feats,
898
+ recycling_steps=recycling_steps,
899
+ num_sampling_steps=num_sampling_steps,
900
+ diffusion_samples=diffusion_samples,
901
+ max_parallel_samples=max_parallel_samples,
902
+ run_confidence_sequentially=run_confidence_sequentially,
903
+ detach_confidence=detach_confidence,
904
+ verbose=verbose,
905
+ ) # named tensors use the Boltz2InferenceCore.forward shapes
906
+ token_state = raw_output.get("s") # (b, t, d_s) or None
907
+ pair_state = raw_output.get("z") # (b, t, t, d_z) or None
908
+ model_output = Boltz2ModelOutput(
909
+ last_hidden_state=token_state,
910
+ hidden_states=(token_state, pair_state)
911
+ if output_hidden_states and token_state is not None and pair_state is not None
912
+ else None,
913
+ **raw_output,
914
+ )
915
+ return model_output if return_dict else model_output.to_tuple()
916
+
917
+ def _to_model_device(
918
+ self,
919
+ feats: dict[str, Tensor],
920
+ float_dtype: torch.dtype = torch.float32,
921
+ ) -> dict[str, Tensor]:
922
+ # Each feature tensor keeps its existing shape (...).
923
+ moved: dict[str, Tensor] = {}
924
+ for key, value in feats.items():
925
+ if torch.is_tensor(value):
926
+ if value.is_floating_point():
927
+ moved[key] = value.to(
928
+ device=self.device, dtype=float_dtype
929
+ ) # same shape (...)
930
+ else:
931
+ moved[key] = value.to(device=self.device) # same shape (...)
932
+ else:
933
+ moved[key] = value # same shape (...)
934
+ return moved # each tensor: same shape (...)
935
+
936
+ def predict_structure(
937
+ self,
938
+ amino_acid_sequence: str,
939
+ recycling_steps: int | None = None,
940
+ num_sampling_steps: int | None = None,
941
+ diffusion_samples: int | None = None,
942
+ max_parallel_samples: int | None = None,
943
+ run_confidence_sequentially: bool = True,
944
+ float_dtype: torch.dtype = torch.float32,
945
+ seed: int | None = None,
946
+ verbose: bool = False,
947
+ ) -> Boltz2StructureOutput:
948
+ if float_dtype != torch.float32:
949
+ raise ValueError(
950
+ "Boltz2 predict_structure requires FP32 features and parameter storage; "
951
+ "CUDA compute runs under the declared BF16 autocast policy."
952
+ )
953
+ parameter_dtypes = {
954
+ parameter.dtype for parameter in self.parameters() if parameter.is_floating_point()
955
+ }
956
+ if parameter_dtypes and parameter_dtypes != {torch.float32}:
957
+ raise ValueError(
958
+ "Boltz2 predict_structure requires FP32 parameter storage before CUDA "
959
+ f"BF16 autocast; found {sorted(map(str, parameter_dtypes))}."
960
+ )
961
+
962
+ with _seed_context(seed):
963
+ with tqdm(
964
+ total=1,
965
+ desc="Boltz2: Prepare features",
966
+ disable=not verbose,
967
+ dynamic_ncols=True,
968
+ leave=False,
969
+ ) as progress:
970
+ feats, template = build_boltz2_features(
971
+ amino_acid_sequence=amino_acid_sequence,
972
+ num_bins=self.config.num_bins,
973
+ atoms_per_window_queries=(
974
+ self.core.input_embedder.atom_encoder.atoms_per_window_queries
975
+ ),
976
+ ) # feature shapes are traced in build_boltz2_features
977
+ feats = self._to_model_device(
978
+ feats, float_dtype=torch.float32
979
+ ) # unchanged feature shapes
980
+ progress.update()
981
+ autocast_context = (
982
+ torch.autocast(device_type="cuda", dtype=torch.bfloat16)
983
+ if self.device.type == "cuda"
984
+ else nullcontext()
985
+ )
986
+ with torch.no_grad(), autocast_context:
987
+ output = self.forward(
988
+ feats=feats,
989
+ recycling_steps=recycling_steps,
990
+ num_sampling_steps=num_sampling_steps,
991
+ diffusion_samples=diffusion_samples,
992
+ max_parallel_samples=max_parallel_samples,
993
+ run_confidence_sequentially=run_confidence_sequentially,
994
+ return_dict=True,
995
+ verbose=verbose,
996
+ ) # named tensors use Boltz2InferenceCore.forward shapes
997
+
998
+ sample_atom_coords_value = output.get(
999
+ "sample_atom_coords"
1000
+ ) # (m, a_p, 3) or absent
1001
+ if not isinstance(sample_atom_coords_value, torch.Tensor):
1002
+ raise RuntimeError("Boltz2 structure sampling did not return coordinate tensors.")
1003
+ sample_atom_coords = sample_atom_coords_value.detach().cpu() # (m, a_p, 3)
1004
+ non_finite_mask = torch.logical_not(
1005
+ torch.isfinite(sample_atom_coords)
1006
+ ) # (m, a_p, 3)
1007
+ if torch.any(non_finite_mask):
1008
+ raise RuntimeError(
1009
+ "sample_atom_coords contains non-finite values. "
1010
+ f"Non-finite count: {int(non_finite_mask.sum().item())}"
1011
+ )
1012
+ atom_pad_mask = feats["atom_pad_mask"][0].detach().cpu() # (a_p,)
1013
+ plddt = (
1014
+ output["plddt"].detach().cpu() if "plddt" in output else None
1015
+ ) # (m, t) or (m, a_p), depending on confidence resolution
1016
+ complex_plddt = (
1017
+ output["complex_plddt"].detach().cpu() if "complex_plddt" in output else None
1018
+ ) # (m,) or None
1019
+ iptm = (
1020
+ output["iptm"].detach().cpu() if "iptm" in output else None
1021
+ ) # (m,) or None
1022
+ ptm = (
1023
+ output["ptm"].detach().cpu() if "ptm" in output else None
1024
+ ) # (m,) or None
1025
+
1026
+ confidence_score = None # (m,) or None
1027
+ if (complex_plddt is not None) and (iptm is not None) and (ptm is not None):
1028
+ if torch.allclose(iptm, torch.zeros_like(iptm)):
1029
+ confidence_score = (4 * complex_plddt + ptm) / 5 # (m,)
1030
+ else:
1031
+ confidence_score = (4 * complex_plddt + iptm) / 5 # (m,)
1032
+
1033
+ return Boltz2StructureOutput(
1034
+ sample_atom_coords=sample_atom_coords,
1035
+ atom_pad_mask=atom_pad_mask,
1036
+ plddt=plddt,
1037
+ confidence_score=confidence_score,
1038
+ complex_plddt=complex_plddt,
1039
+ iptm=iptm,
1040
+ ptm=ptm,
1041
+ sequence=template.sequence,
1042
+ structure_template=template,
1043
+ raw_output={key: _to_cpu_detached(val) for key, val in output.items()},
1044
+ seed=seed,
1045
+ )
1046
+
1047
+ def save_as_cif(
1048
+ self,
1049
+ structure_output: Boltz2StructureOutput,
1050
+ output_path: str,
1051
+ sample_index: int = 0,
1052
+ ) -> str:
1053
+ if structure_output.structure_template is None:
1054
+ raise ValueError("structure_output.structure_template is required for CIF export.")
1055
+ if structure_output.sample_atom_coords is None:
1056
+ raise ValueError("structure_output.sample_atom_coords is required for CIF export.")
1057
+ if structure_output.atom_pad_mask is None:
1058
+ raise ValueError("structure_output.atom_pad_mask is required for CIF export.")
1059
+ return write_cif(
1060
+ structure_template=structure_output.structure_template,
1061
+ atom_coords=structure_output.sample_atom_coords,
1062
+ atom_mask=structure_output.atom_pad_mask,
1063
+ output_path=output_path,
1064
+ plddt=structure_output.plddt,
1065
+ sample_index=sample_index,
1066
+ )
fastplms/models/boltz/vb_const.py CHANGED
@@ -1,353 +1,353 @@
1
- """Minimal biological constants required by the local Boltz2 runtime.
2
-
3
- The upstream project contains additional training-time curation tables. They
4
- are intentionally excluded here because FastPLMs neither trains Boltz2 nor
5
- uses upstream data-processing code. Keeping only runtime inputs makes this
6
- module auditable and prevents parity-oracle data from becoming a package
7
- dependency.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
-
13
- def _index(values: list[str]) -> dict[str, int]:
14
- return {value: index for index, value in enumerate(values)}
15
-
16
-
17
- chain_types = ["PROTEIN", "DNA", "RNA", "NONPOLYMER"]
18
- chain_type_ids = _index(chain_types)
19
-
20
-
21
- canonical_tokens = [
22
- "ALA",
23
- "ARG",
24
- "ASN",
25
- "ASP",
26
- "CYS",
27
- "GLN",
28
- "GLU",
29
- "GLY",
30
- "HIS",
31
- "ILE",
32
- "LEU",
33
- "LYS",
34
- "MET",
35
- "PHE",
36
- "PRO",
37
- "SER",
38
- "THR",
39
- "TRP",
40
- "TYR",
41
- "VAL",
42
- "UNK",
43
- ]
44
- tokens = [
45
- "<pad>",
46
- "-",
47
- *canonical_tokens,
48
- "A",
49
- "G",
50
- "C",
51
- "U",
52
- "N",
53
- "DA",
54
- "DG",
55
- "DC",
56
- "DT",
57
- "DN",
58
- ]
59
- token_ids = _index(tokens)
60
- num_tokens = len(tokens)
61
-
62
- prot_letter_to_token = dict(
63
- zip(
64
- "ARNDCEQGHILKMFPSTWYV",
65
- [
66
- "ALA",
67
- "ARG",
68
- "ASN",
69
- "ASP",
70
- "CYS",
71
- "GLU",
72
- "GLN",
73
- "GLY",
74
- "HIS",
75
- "ILE",
76
- "LEU",
77
- "LYS",
78
- "MET",
79
- "PHE",
80
- "PRO",
81
- "SER",
82
- "THR",
83
- "TRP",
84
- "TYR",
85
- "VAL",
86
- ],
87
- strict=True,
88
- )
89
- )
90
- prot_letter_to_token.update({letter: "UNK" for letter in "XJBZOU"} | {"-": "-"})
91
-
92
-
93
- def _parse_atom_rows(rows: str) -> dict[str, list[str]]:
94
- table: dict[str, list[str]] = {}
95
- for row in rows.strip().splitlines():
96
- residue, *atom_names = row.split()
97
- table[residue] = atom_names
98
- return table
99
-
100
-
101
- ref_atoms = {"PAD": [], "-": []}
102
- ref_atoms.update(
103
- _parse_atom_rows(
104
- """
105
- UNK N CA C O CB
106
- ALA N CA C O CB
107
- ARG N CA C O CB CG CD NE CZ NH1 NH2
108
- ASN N CA C O CB CG OD1 ND2
109
- ASP N CA C O CB CG OD1 OD2
110
- CYS N CA C O CB SG
111
- GLN N CA C O CB CG CD OE1 NE2
112
- GLU N CA C O CB CG CD OE1 OE2
113
- GLY N CA C O
114
- HIS N CA C O CB CG ND1 CD2 CE1 NE2
115
- ILE N CA C O CB CG1 CG2 CD1
116
- LEU N CA C O CB CG CD1 CD2
117
- LYS N CA C O CB CG CD CE NZ
118
- MET N CA C O CB CG SD CE
119
- PHE N CA C O CB CG CD1 CD2 CE1 CE2 CZ
120
- PRO N CA C O CB CG CD
121
- SER N CA C O CB OG
122
- THR N CA C O CB OG1 CG2
123
- TRP N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2
124
- TYR N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH
125
- VAL N CA C O CB CG1 CG2
126
- """
127
- )
128
- )
129
-
130
- protein_backbone_atom_names = ["N", "CA", "C", "O"]
131
- nucleic_backbone_atom_names = [
132
- "P",
133
- "OP1",
134
- "OP2",
135
- "O5'",
136
- "C5'",
137
- "C4'",
138
- "O4'",
139
- "C3'",
140
- "O3'",
141
- "C2'",
142
- "O2'",
143
- "C1'",
144
- ]
145
- protein_backbone_atom_index = _index(protein_backbone_atom_names)
146
- nucleic_backbone_atom_index = _index(nucleic_backbone_atom_names)
147
-
148
- _rna_backbone = nucleic_backbone_atom_names
149
- _dna_backbone = [atom for atom in _rna_backbone if atom != "O2'"]
150
- ref_atoms.update(
151
- {
152
- "A": [*_rna_backbone, "N9", "C8", "N7", "C5", "C6", "N6", "N1", "C2", "N3", "C4"],
153
- "G": [*_rna_backbone, "N9", "C8", "N7", "C5", "C6", "O6", "N1", "C2", "N2", "N3", "C4"],
154
- "C": [*_rna_backbone, "N1", "C2", "O2", "N3", "C4", "N4", "C5", "C6"],
155
- "U": [*_rna_backbone, "N1", "C2", "O2", "N3", "C4", "O4", "C5", "C6"],
156
- "N": list(_rna_backbone),
157
- "DA": [*_dna_backbone, "N9", "C8", "N7", "C5", "C6", "N6", "N1", "C2", "N3", "C4"],
158
- "DG": [*_dna_backbone, "N9", "C8", "N7", "C5", "C6", "O6", "N1", "C2", "N2", "N3", "C4"],
159
- "DC": [*_dna_backbone, "N1", "C2", "O2", "N3", "C4", "N4", "C5", "C6"],
160
- "DT": [*_dna_backbone, "N1", "C2", "O2", "N3", "C4", "O4", "C5", "C7", "C6"],
161
- "DN": list(_dna_backbone),
162
- }
163
- )
164
-
165
- _protein_tokens = ["UNK", *canonical_tokens[:-1]]
166
- _nucleic_tokens = ["A", "G", "C", "U", "N", "DA", "DG", "DC", "DT", "DN"]
167
- res_to_center_atom = {
168
- **dict.fromkeys(_protein_tokens, "CA"),
169
- **dict.fromkeys(_nucleic_tokens, "C1'"),
170
- }
171
- res_to_disto_atom = {
172
- **dict.fromkeys(_protein_tokens, "CB"),
173
- "GLY": "CA",
174
- "A": "C4",
175
- "G": "C4",
176
- "C": "C2",
177
- "U": "C2",
178
- "N": "C1'",
179
- "DA": "C4",
180
- "DG": "C4",
181
- "DC": "C2",
182
- "DT": "C2",
183
- "DN": "C1'",
184
- }
185
-
186
-
187
- num_elements = 128
188
- bond_types = ["OTHER", "SINGLE", "DOUBLE", "TRIPLE", "AROMATIC", "COVALENT"]
189
- contact_conditioning_info = {
190
- "UNSPECIFIED": 0,
191
- "UNSELECTED": 1,
192
- "POCKET>BINDER": 2,
193
- "BINDER>POCKET": 3,
194
- "CONTACT": 4,
195
- }
196
- chunk_size_threshold = 384
197
-
198
- _method_groups = {
199
- 0: ("MD",),
200
- 1: ("X-RAY DIFFRACTION",),
201
- 2: ("ELECTRON MICROSCOPY",),
202
- 3: ("SOLUTION NMR",),
203
- 4: (
204
- "SOLID-STATE NMR",
205
- "NEUTRON DIFFRACTION",
206
- "ELECTRON CRYSTALLOGRAPHY",
207
- "FIBER DIFFRACTION",
208
- "POWDER DIFFRACTION",
209
- "INFRARED SPECTROSCOPY",
210
- "FLUORESCENCE TRANSFER",
211
- "EPR",
212
- "THEORETICAL MODEL",
213
- "SOLUTION SCATTERING",
214
- "OTHER",
215
- ),
216
- 5: ("AFDB",),
217
- 6: ("BOLTZ-1",),
218
- 7: ("FUTURE1",),
219
- 8: ("FUTURE2",),
220
- 9: ("FUTURE3",),
221
- 10: ("FUTURE4",),
222
- 11: ("FUTURE5",),
223
- }
224
- method_types_ids = {
225
- method.lower(): identifier
226
- for identifier, methods in _method_groups.items()
227
- for method in methods
228
- }
229
- num_method_types = len(_method_groups)
230
-
231
- vdw_radii = [
232
- float(radius)
233
- for radius in [
234
- "1.2",
235
- "1.4",
236
- "2.2",
237
- "1.9",
238
- "1.8",
239
- "1.7",
240
- "1.6",
241
- "1.55",
242
- "1.5",
243
- "1.54",
244
- "2.4",
245
- "2.2",
246
- "2.1",
247
- "2.1",
248
- "1.95",
249
- "1.8",
250
- "1.8",
251
- "1.88",
252
- "2.8",
253
- "2.4",
254
- "2.3",
255
- "2.15",
256
- "2.05",
257
- "2.05",
258
- "2.05",
259
- "2.05",
260
- "2.0",
261
- "2.0",
262
- "2.0",
263
- "2.1",
264
- "2.1",
265
- "2.1",
266
- "2.05",
267
- "1.9",
268
- "1.9",
269
- "2.02",
270
- "2.9",
271
- "2.55",
272
- "2.4",
273
- "2.3",
274
- "2.15",
275
- "2.1",
276
- "2.05",
277
- "2.05",
278
- "2.0",
279
- "2.05",
280
- "2.1",
281
- "2.2",
282
- "2.2",
283
- "2.25",
284
- "2.2",
285
- "2.1",
286
- "2.1",
287
- "2.16",
288
- "3.0",
289
- "2.7",
290
- "2.5",
291
- "2.48",
292
- "2.47",
293
- "2.45",
294
- "2.43",
295
- "2.42",
296
- "2.4",
297
- "2.38",
298
- "2.37",
299
- "2.35",
300
- "2.33",
301
- "2.32",
302
- "2.3",
303
- "2.28",
304
- "2.27",
305
- "2.25",
306
- "2.2",
307
- "2.1",
308
- "2.05",
309
- "2.0",
310
- "2.0",
311
- "2.05",
312
- "2.1",
313
- "2.05",
314
- "2.2",
315
- "2.3",
316
- "2.3",
317
- "2.0",
318
- "2.0",
319
- "2.0",
320
- "2.0",
321
- "2.0",
322
- "2.0",
323
- "2.4",
324
- "2.0",
325
- "2.3",
326
- "2.0",
327
- "2.0",
328
- "2.0",
329
- "2.0",
330
- "2.0",
331
- "2.0",
332
- "2.0",
333
- "2.0",
334
- "2.0",
335
- "2.0",
336
- "2.0",
337
- "2.0",
338
- "2.0",
339
- "2.0",
340
- "2.0",
341
- "2.0",
342
- "2.0",
343
- "2.0",
344
- "2.0",
345
- "2.0",
346
- "2.0",
347
- "2.0",
348
- "2.0",
349
- "2.0",
350
- "2.0",
351
- "2.0",
352
- ]
353
- ]
 
1
+ """Minimal biological constants required by the local Boltz2 runtime.
2
+
3
+ The upstream project contains additional training-time curation tables. They
4
+ are intentionally excluded here because FastPLMs neither trains Boltz2 nor
5
+ uses upstream data-processing code. Keeping only runtime inputs makes this
6
+ module auditable and prevents parity-oracle data from becoming a package
7
+ dependency.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def _index(values: list[str]) -> dict[str, int]:
14
+ return {value: index for index, value in enumerate(values)}
15
+
16
+
17
+ chain_types = ["PROTEIN", "DNA", "RNA", "NONPOLYMER"]
18
+ chain_type_ids = _index(chain_types)
19
+
20
+
21
+ canonical_tokens = [
22
+ "ALA",
23
+ "ARG",
24
+ "ASN",
25
+ "ASP",
26
+ "CYS",
27
+ "GLN",
28
+ "GLU",
29
+ "GLY",
30
+ "HIS",
31
+ "ILE",
32
+ "LEU",
33
+ "LYS",
34
+ "MET",
35
+ "PHE",
36
+ "PRO",
37
+ "SER",
38
+ "THR",
39
+ "TRP",
40
+ "TYR",
41
+ "VAL",
42
+ "UNK",
43
+ ]
44
+ tokens = [
45
+ "<pad>",
46
+ "-",
47
+ *canonical_tokens,
48
+ "A",
49
+ "G",
50
+ "C",
51
+ "U",
52
+ "N",
53
+ "DA",
54
+ "DG",
55
+ "DC",
56
+ "DT",
57
+ "DN",
58
+ ]
59
+ token_ids = _index(tokens)
60
+ num_tokens = len(tokens)
61
+
62
+ prot_letter_to_token = dict(
63
+ zip(
64
+ "ARNDCEQGHILKMFPSTWYV",
65
+ [
66
+ "ALA",
67
+ "ARG",
68
+ "ASN",
69
+ "ASP",
70
+ "CYS",
71
+ "GLU",
72
+ "GLN",
73
+ "GLY",
74
+ "HIS",
75
+ "ILE",
76
+ "LEU",
77
+ "LYS",
78
+ "MET",
79
+ "PHE",
80
+ "PRO",
81
+ "SER",
82
+ "THR",
83
+ "TRP",
84
+ "TYR",
85
+ "VAL",
86
+ ],
87
+ strict=True,
88
+ )
89
+ )
90
+ prot_letter_to_token.update({letter: "UNK" for letter in "XJBZOU"} | {"-": "-"})
91
+
92
+
93
+ def _parse_atom_rows(rows: str) -> dict[str, list[str]]:
94
+ table: dict[str, list[str]] = {}
95
+ for row in rows.strip().splitlines():
96
+ residue, *atom_names = row.split()
97
+ table[residue] = atom_names
98
+ return table
99
+
100
+
101
+ ref_atoms = {"PAD": [], "-": []}
102
+ ref_atoms.update(
103
+ _parse_atom_rows(
104
+ """
105
+ UNK N CA C O CB
106
+ ALA N CA C O CB
107
+ ARG N CA C O CB CG CD NE CZ NH1 NH2
108
+ ASN N CA C O CB CG OD1 ND2
109
+ ASP N CA C O CB CG OD1 OD2
110
+ CYS N CA C O CB SG
111
+ GLN N CA C O CB CG CD OE1 NE2
112
+ GLU N CA C O CB CG CD OE1 OE2
113
+ GLY N CA C O
114
+ HIS N CA C O CB CG ND1 CD2 CE1 NE2
115
+ ILE N CA C O CB CG1 CG2 CD1
116
+ LEU N CA C O CB CG CD1 CD2
117
+ LYS N CA C O CB CG CD CE NZ
118
+ MET N CA C O CB CG SD CE
119
+ PHE N CA C O CB CG CD1 CD2 CE1 CE2 CZ
120
+ PRO N CA C O CB CG CD
121
+ SER N CA C O CB OG
122
+ THR N CA C O CB OG1 CG2
123
+ TRP N CA C O CB CG CD1 CD2 NE1 CE2 CE3 CZ2 CZ3 CH2
124
+ TYR N CA C O CB CG CD1 CD2 CE1 CE2 CZ OH
125
+ VAL N CA C O CB CG1 CG2
126
+ """
127
+ )
128
+ )
129
+
130
+ protein_backbone_atom_names = ["N", "CA", "C", "O"]
131
+ nucleic_backbone_atom_names = [
132
+ "P",
133
+ "OP1",
134
+ "OP2",
135
+ "O5'",
136
+ "C5'",
137
+ "C4'",
138
+ "O4'",
139
+ "C3'",
140
+ "O3'",
141
+ "C2'",
142
+ "O2'",
143
+ "C1'",
144
+ ]
145
+ protein_backbone_atom_index = _index(protein_backbone_atom_names)
146
+ nucleic_backbone_atom_index = _index(nucleic_backbone_atom_names)
147
+
148
+ _rna_backbone = nucleic_backbone_atom_names
149
+ _dna_backbone = [atom for atom in _rna_backbone if atom != "O2'"]
150
+ ref_atoms.update(
151
+ {
152
+ "A": [*_rna_backbone, "N9", "C8", "N7", "C5", "C6", "N6", "N1", "C2", "N3", "C4"],
153
+ "G": [*_rna_backbone, "N9", "C8", "N7", "C5", "C6", "O6", "N1", "C2", "N2", "N3", "C4"],
154
+ "C": [*_rna_backbone, "N1", "C2", "O2", "N3", "C4", "N4", "C5", "C6"],
155
+ "U": [*_rna_backbone, "N1", "C2", "O2", "N3", "C4", "O4", "C5", "C6"],
156
+ "N": list(_rna_backbone),
157
+ "DA": [*_dna_backbone, "N9", "C8", "N7", "C5", "C6", "N6", "N1", "C2", "N3", "C4"],
158
+ "DG": [*_dna_backbone, "N9", "C8", "N7", "C5", "C6", "O6", "N1", "C2", "N2", "N3", "C4"],
159
+ "DC": [*_dna_backbone, "N1", "C2", "O2", "N3", "C4", "N4", "C5", "C6"],
160
+ "DT": [*_dna_backbone, "N1", "C2", "O2", "N3", "C4", "O4", "C5", "C7", "C6"],
161
+ "DN": list(_dna_backbone),
162
+ }
163
+ )
164
+
165
+ _protein_tokens = ["UNK", *canonical_tokens[:-1]]
166
+ _nucleic_tokens = ["A", "G", "C", "U", "N", "DA", "DG", "DC", "DT", "DN"]
167
+ res_to_center_atom = {
168
+ **dict.fromkeys(_protein_tokens, "CA"),
169
+ **dict.fromkeys(_nucleic_tokens, "C1'"),
170
+ }
171
+ res_to_disto_atom = {
172
+ **dict.fromkeys(_protein_tokens, "CB"),
173
+ "GLY": "CA",
174
+ "A": "C4",
175
+ "G": "C4",
176
+ "C": "C2",
177
+ "U": "C2",
178
+ "N": "C1'",
179
+ "DA": "C4",
180
+ "DG": "C4",
181
+ "DC": "C2",
182
+ "DT": "C2",
183
+ "DN": "C1'",
184
+ }
185
+
186
+
187
+ num_elements = 128
188
+ bond_types = ["OTHER", "SINGLE", "DOUBLE", "TRIPLE", "AROMATIC", "COVALENT"]
189
+ contact_conditioning_info = {
190
+ "UNSPECIFIED": 0,
191
+ "UNSELECTED": 1,
192
+ "POCKET>BINDER": 2,
193
+ "BINDER>POCKET": 3,
194
+ "CONTACT": 4,
195
+ }
196
+ chunk_size_threshold = 384
197
+
198
+ _method_groups = {
199
+ 0: ("MD",),
200
+ 1: ("X-RAY DIFFRACTION",),
201
+ 2: ("ELECTRON MICROSCOPY",),
202
+ 3: ("SOLUTION NMR",),
203
+ 4: (
204
+ "SOLID-STATE NMR",
205
+ "NEUTRON DIFFRACTION",
206
+ "ELECTRON CRYSTALLOGRAPHY",
207
+ "FIBER DIFFRACTION",
208
+ "POWDER DIFFRACTION",
209
+ "INFRARED SPECTROSCOPY",
210
+ "FLUORESCENCE TRANSFER",
211
+ "EPR",
212
+ "THEORETICAL MODEL",
213
+ "SOLUTION SCATTERING",
214
+ "OTHER",
215
+ ),
216
+ 5: ("AFDB",),
217
+ 6: ("BOLTZ-1",),
218
+ 7: ("FUTURE1",),
219
+ 8: ("FUTURE2",),
220
+ 9: ("FUTURE3",),
221
+ 10: ("FUTURE4",),
222
+ 11: ("FUTURE5",),
223
+ }
224
+ method_types_ids = {
225
+ method.lower(): identifier
226
+ for identifier, methods in _method_groups.items()
227
+ for method in methods
228
+ }
229
+ num_method_types = len(_method_groups)
230
+
231
+ vdw_radii = [
232
+ float(radius)
233
+ for radius in [
234
+ "1.2",
235
+ "1.4",
236
+ "2.2",
237
+ "1.9",
238
+ "1.8",
239
+ "1.7",
240
+ "1.6",
241
+ "1.55",
242
+ "1.5",
243
+ "1.54",
244
+ "2.4",
245
+ "2.2",
246
+ "2.1",
247
+ "2.1",
248
+ "1.95",
249
+ "1.8",
250
+ "1.8",
251
+ "1.88",
252
+ "2.8",
253
+ "2.4",
254
+ "2.3",
255
+ "2.15",
256
+ "2.05",
257
+ "2.05",
258
+ "2.05",
259
+ "2.05",
260
+ "2.0",
261
+ "2.0",
262
+ "2.0",
263
+ "2.1",
264
+ "2.1",
265
+ "2.1",
266
+ "2.05",
267
+ "1.9",
268
+ "1.9",
269
+ "2.02",
270
+ "2.9",
271
+ "2.55",
272
+ "2.4",
273
+ "2.3",
274
+ "2.15",
275
+ "2.1",
276
+ "2.05",
277
+ "2.05",
278
+ "2.0",
279
+ "2.05",
280
+ "2.1",
281
+ "2.2",
282
+ "2.2",
283
+ "2.25",
284
+ "2.2",
285
+ "2.1",
286
+ "2.1",
287
+ "2.16",
288
+ "3.0",
289
+ "2.7",
290
+ "2.5",
291
+ "2.48",
292
+ "2.47",
293
+ "2.45",
294
+ "2.43",
295
+ "2.42",
296
+ "2.4",
297
+ "2.38",
298
+ "2.37",
299
+ "2.35",
300
+ "2.33",
301
+ "2.32",
302
+ "2.3",
303
+ "2.28",
304
+ "2.27",
305
+ "2.25",
306
+ "2.2",
307
+ "2.1",
308
+ "2.05",
309
+ "2.0",
310
+ "2.0",
311
+ "2.05",
312
+ "2.1",
313
+ "2.05",
314
+ "2.2",
315
+ "2.3",
316
+ "2.3",
317
+ "2.0",
318
+ "2.0",
319
+ "2.0",
320
+ "2.0",
321
+ "2.0",
322
+ "2.0",
323
+ "2.4",
324
+ "2.0",
325
+ "2.3",
326
+ "2.0",
327
+ "2.0",
328
+ "2.0",
329
+ "2.0",
330
+ "2.0",
331
+ "2.0",
332
+ "2.0",
333
+ "2.0",
334
+ "2.0",
335
+ "2.0",
336
+ "2.0",
337
+ "2.0",
338
+ "2.0",
339
+ "2.0",
340
+ "2.0",
341
+ "2.0",
342
+ "2.0",
343
+ "2.0",
344
+ "2.0",
345
+ "2.0",
346
+ "2.0",
347
+ "2.0",
348
+ "2.0",
349
+ "2.0",
350
+ "2.0",
351
+ "2.0",
352
+ ]
353
+ ]
fastplms/models/boltz/vb_layers_attention.py CHANGED
@@ -1,103 +1,103 @@
1
- """Pair-biased self-attention used in the Boltz2 diffusion stack."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable, MutableMapping
6
- from einops.layers.torch import Rearrange
7
- from torch import Tensor, nn
8
-
9
- from . import vb_layers_initialize as init
10
- from ._pair_attention import pair_biased_attention, reshape_heads
11
-
12
-
13
- class AttentionPairBias(nn.Module):
14
- """Attend over sequence states while adding a learned pair representation."""
15
-
16
- def __init__(
17
- self,
18
- c_s: int,
19
- c_z: int,
20
- num_heads: int,
21
- inf: float = 1e6,
22
- initial_norm: bool = True,
23
- ) -> None:
24
- super().__init__()
25
- if c_s % num_heads:
26
- raise ValueError(f"c_s={c_s} must be divisible by num_heads={num_heads}")
27
-
28
- self.c_s = c_s
29
- self.num_heads = num_heads
30
- self.head_dim = c_s // num_heads
31
- self.inf = inf
32
- self.initial_norm = initial_norm
33
- if initial_norm:
34
- self.norm_s = nn.LayerNorm(c_s)
35
-
36
- self.proj_q = nn.Linear(c_s, c_s)
37
- self.proj_k = nn.Linear(c_s, c_s, bias=False)
38
- self.proj_v = nn.Linear(c_s, c_s, bias=False)
39
- self.proj_g = nn.Linear(c_s, c_s, bias=False)
40
- self.proj_z = nn.Sequential(
41
- nn.LayerNorm(c_z),
42
- nn.Linear(c_z, num_heads, bias=False),
43
- Rearrange("b ... h -> b h ..."),
44
- )
45
- self.proj_o = nn.Linear(c_s, c_s, bias=False)
46
- init.final_init_(self.proj_o.weight)
47
-
48
- def _resolve_pair_bias(
49
- self,
50
- pair_states: Tensor,
51
- model_cache: MutableMapping[str, Tensor] | None,
52
- ) -> Tensor:
53
- # pair_states: (b, l_q, l_k, d_z)
54
- if model_cache is not None and "z" in model_cache:
55
- return model_cache["z"] # (b, h, l_q, l_k)
56
- pair_bias = self.proj_z(pair_states) # (b, h, l_q, l_k)
57
- if model_cache is not None:
58
- model_cache["z"] = pair_bias
59
- return pair_bias # (b, h, l_q, l_k)
60
-
61
- def forward(
62
- self,
63
- s: Tensor,
64
- z: Tensor,
65
- mask: Tensor,
66
- k_in: Tensor | None = None,
67
- multiplicity: int = 1,
68
- to_keys: Callable[[Tensor], Tensor] | None = None,
69
- model_cache: MutableMapping[str, Tensor] | None = None,
70
- ) -> Tensor:
71
- """Transform S with shapes ``S: (b, l_q, d)`` and ``Z: (b, l, l, d_z)``."""
72
-
73
- # s: (b, l_q, d); z: (b_z, l_q, l_k, d_z); mask: (b_k, l_k).
74
- sequence_states = self.norm_s(s) if self.initial_norm else s # (b, l_q, d)
75
- if to_keys is not None:
76
- key_states = to_keys(sequence_states) # (b, l_k, d)
77
- key_mask = to_keys(mask.unsqueeze(-1)).squeeze(-1) # (b_k, l_k)
78
- else:
79
- key_states = sequence_states if k_in is None else k_in # (b, l_k, d)
80
- key_mask = mask # (b_k, l_k)
81
-
82
- query = reshape_heads(
83
- self.proj_q(sequence_states), self.num_heads
84
- ) # (b, l_q, h, d_h)
85
- key = reshape_heads(self.proj_k(key_states), self.num_heads) # (b, l_k, h, d_h)
86
- value = reshape_heads(
87
- self.proj_v(key_states), self.num_heads
88
- ) # (b, l_k, h, d_h)
89
- pair_bias = self._resolve_pair_bias(z, model_cache).repeat_interleave(
90
- multiplicity,
91
- dim=0,
92
- ) # (b, h, l_q, l_k)
93
- attended = pair_biased_attention(
94
- query,
95
- key,
96
- value,
97
- pair_bias,
98
- key_mask,
99
- self.inf,
100
- ) # (b, l_q, h, d_h)
101
- attended = attended.reshape(s.shape[0], -1, self.c_s) # (b, l_q, d)
102
- gate = self.proj_g(sequence_states).sigmoid() # (b, l_q, d)
103
- return self.proj_o(gate * attended) # (b, l_q, d)
 
1
+ """Pair-biased self-attention used in the Boltz2 diffusion stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, MutableMapping
6
+ from einops.layers.torch import Rearrange
7
+ from torch import Tensor, nn
8
+
9
+ from . import vb_layers_initialize as init
10
+ from ._pair_attention import pair_biased_attention, reshape_heads
11
+
12
+
13
+ class AttentionPairBias(nn.Module):
14
+ """Attend over sequence states while adding a learned pair representation."""
15
+
16
+ def __init__(
17
+ self,
18
+ c_s: int,
19
+ c_z: int,
20
+ num_heads: int,
21
+ inf: float = 1e6,
22
+ initial_norm: bool = True,
23
+ ) -> None:
24
+ super().__init__()
25
+ if c_s % num_heads:
26
+ raise ValueError(f"c_s={c_s} must be divisible by num_heads={num_heads}")
27
+
28
+ self.c_s = c_s
29
+ self.num_heads = num_heads
30
+ self.head_dim = c_s // num_heads
31
+ self.inf = inf
32
+ self.initial_norm = initial_norm
33
+ if initial_norm:
34
+ self.norm_s = nn.LayerNorm(c_s)
35
+
36
+ self.proj_q = nn.Linear(c_s, c_s)
37
+ self.proj_k = nn.Linear(c_s, c_s, bias=False)
38
+ self.proj_v = nn.Linear(c_s, c_s, bias=False)
39
+ self.proj_g = nn.Linear(c_s, c_s, bias=False)
40
+ self.proj_z = nn.Sequential(
41
+ nn.LayerNorm(c_z),
42
+ nn.Linear(c_z, num_heads, bias=False),
43
+ Rearrange("b ... h -> b h ..."),
44
+ )
45
+ self.proj_o = nn.Linear(c_s, c_s, bias=False)
46
+ init.final_init_(self.proj_o.weight)
47
+
48
+ def _resolve_pair_bias(
49
+ self,
50
+ pair_states: Tensor,
51
+ model_cache: MutableMapping[str, Tensor] | None,
52
+ ) -> Tensor:
53
+ # pair_states: (b, l_q, l_k, d_z)
54
+ if model_cache is not None and "z" in model_cache:
55
+ return model_cache["z"] # (b, h, l_q, l_k)
56
+ pair_bias = self.proj_z(pair_states) # (b, h, l_q, l_k)
57
+ if model_cache is not None:
58
+ model_cache["z"] = pair_bias
59
+ return pair_bias # (b, h, l_q, l_k)
60
+
61
+ def forward(
62
+ self,
63
+ s: Tensor,
64
+ z: Tensor,
65
+ mask: Tensor,
66
+ k_in: Tensor | None = None,
67
+ multiplicity: int = 1,
68
+ to_keys: Callable[[Tensor], Tensor] | None = None,
69
+ model_cache: MutableMapping[str, Tensor] | None = None,
70
+ ) -> Tensor:
71
+ """Transform S with shapes ``S: (b, l_q, d)`` and ``Z: (b, l, l, d_z)``."""
72
+
73
+ # s: (b, l_q, d); z: (b_z, l_q, l_k, d_z); mask: (b_k, l_k).
74
+ sequence_states = self.norm_s(s) if self.initial_norm else s # (b, l_q, d)
75
+ if to_keys is not None:
76
+ key_states = to_keys(sequence_states) # (b, l_k, d)
77
+ key_mask = to_keys(mask.unsqueeze(-1)).squeeze(-1) # (b_k, l_k)
78
+ else:
79
+ key_states = sequence_states if k_in is None else k_in # (b, l_k, d)
80
+ key_mask = mask # (b_k, l_k)
81
+
82
+ query = reshape_heads(
83
+ self.proj_q(sequence_states), self.num_heads
84
+ ) # (b, l_q, h, d_h)
85
+ key = reshape_heads(self.proj_k(key_states), self.num_heads) # (b, l_k, h, d_h)
86
+ value = reshape_heads(
87
+ self.proj_v(key_states), self.num_heads
88
+ ) # (b, l_k, h, d_h)
89
+ pair_bias = self._resolve_pair_bias(z, model_cache).repeat_interleave(
90
+ multiplicity,
91
+ dim=0,
92
+ ) # (b, h, l_q, l_k)
93
+ attended = pair_biased_attention(
94
+ query,
95
+ key,
96
+ value,
97
+ pair_bias,
98
+ key_mask,
99
+ self.inf,
100
+ ) # (b, l_q, h, d_h)
101
+ attended = attended.reshape(s.shape[0], -1, self.c_s) # (b, l_q, d)
102
+ gate = self.proj_g(sequence_states).sigmoid() # (b, l_q, d)
103
+ return self.proj_o(gate * attended) # (b, l_q, d)
fastplms/models/boltz/vb_layers_attentionv2.py CHANGED
@@ -1,76 +1,76 @@
1
- """Cross-attention variant of Boltz2 pair-biased attention."""
2
-
3
- from __future__ import annotations
4
-
5
- from einops.layers.torch import Rearrange
6
- from torch import Tensor, nn
7
-
8
- from . import vb_layers_initialize as init
9
- from ._pair_attention import pair_biased_attention, reshape_heads
10
-
11
-
12
- class AttentionPairBias(nn.Module):
13
- """Attend from S to K while adding either learned or precomputed pair bias."""
14
-
15
- def __init__(
16
- self,
17
- c_s: int,
18
- c_z: int | None = None,
19
- num_heads: int | None = None,
20
- inf: float = 1e6,
21
- compute_pair_bias: bool = True,
22
- ) -> None:
23
- super().__init__()
24
- if num_heads is None or c_s % num_heads:
25
- raise ValueError("num_heads must divide c_s")
26
- if compute_pair_bias and c_z is None:
27
- raise ValueError("c_z is required when pair bias is learned")
28
-
29
- self.c_s = c_s
30
- self.num_heads = num_heads
31
- self.head_dim = c_s // num_heads
32
- self.inf = inf
33
- self.proj_q = nn.Linear(c_s, c_s)
34
- self.proj_k = nn.Linear(c_s, c_s, bias=False)
35
- self.proj_v = nn.Linear(c_s, c_s, bias=False)
36
- self.proj_g = nn.Linear(c_s, c_s, bias=False)
37
- self.compute_pair_bias = compute_pair_bias
38
- if compute_pair_bias:
39
- self.proj_z = nn.Sequential(
40
- nn.LayerNorm(c_z),
41
- nn.Linear(c_z, num_heads, bias=False),
42
- Rearrange("b ... h -> b h ..."),
43
- )
44
- else:
45
- self.proj_z = Rearrange("b ... h -> b h ...")
46
- self.proj_o = nn.Linear(c_s, c_s, bias=False)
47
- init.final_init_(self.proj_o.weight)
48
-
49
- def forward(
50
- self,
51
- s: Tensor,
52
- z: Tensor,
53
- mask: Tensor,
54
- k_in: Tensor,
55
- multiplicity: int = 1,
56
- ) -> Tensor:
57
- """Transform S with K and pair input Z, returning shape ``(b, l_q, d)``."""
58
-
59
- # s: (b, l_q, d); k_in: (b, l_k, d); z: (b_z, l_q, l_k, d_z or h).
60
- query = reshape_heads(self.proj_q(s), self.num_heads) # (b, l_q, h, d_h)
61
- key = reshape_heads(self.proj_k(k_in), self.num_heads) # (b, l_k, h, d_h)
62
- value = reshape_heads(self.proj_v(k_in), self.num_heads) # (b, l_k, h, d_h)
63
- pair_bias = self.proj_z(z).repeat_interleave(
64
- multiplicity, dim=0
65
- ) # (b, h, l_q, l_k)
66
- attended = pair_biased_attention(
67
- query,
68
- key,
69
- value,
70
- pair_bias,
71
- mask,
72
- self.inf,
73
- ) # (b, l_q, h, d_h)
74
- attended = attended.reshape(s.shape[0], -1, self.c_s) # (b, l_q, d)
75
- gate = self.proj_g(s).sigmoid() # (b, l_q, d)
76
- return self.proj_o(gate * attended) # (b, l_q, d)
 
1
+ """Cross-attention variant of Boltz2 pair-biased attention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from einops.layers.torch import Rearrange
6
+ from torch import Tensor, nn
7
+
8
+ from . import vb_layers_initialize as init
9
+ from ._pair_attention import pair_biased_attention, reshape_heads
10
+
11
+
12
+ class AttentionPairBias(nn.Module):
13
+ """Attend from S to K while adding either learned or precomputed pair bias."""
14
+
15
+ def __init__(
16
+ self,
17
+ c_s: int,
18
+ c_z: int | None = None,
19
+ num_heads: int | None = None,
20
+ inf: float = 1e6,
21
+ compute_pair_bias: bool = True,
22
+ ) -> None:
23
+ super().__init__()
24
+ if num_heads is None or c_s % num_heads:
25
+ raise ValueError("num_heads must divide c_s")
26
+ if compute_pair_bias and c_z is None:
27
+ raise ValueError("c_z is required when pair bias is learned")
28
+
29
+ self.c_s = c_s
30
+ self.num_heads = num_heads
31
+ self.head_dim = c_s // num_heads
32
+ self.inf = inf
33
+ self.proj_q = nn.Linear(c_s, c_s)
34
+ self.proj_k = nn.Linear(c_s, c_s, bias=False)
35
+ self.proj_v = nn.Linear(c_s, c_s, bias=False)
36
+ self.proj_g = nn.Linear(c_s, c_s, bias=False)
37
+ self.compute_pair_bias = compute_pair_bias
38
+ if compute_pair_bias:
39
+ self.proj_z = nn.Sequential(
40
+ nn.LayerNorm(c_z),
41
+ nn.Linear(c_z, num_heads, bias=False),
42
+ Rearrange("b ... h -> b h ..."),
43
+ )
44
+ else:
45
+ self.proj_z = Rearrange("b ... h -> b h ...")
46
+ self.proj_o = nn.Linear(c_s, c_s, bias=False)
47
+ init.final_init_(self.proj_o.weight)
48
+
49
+ def forward(
50
+ self,
51
+ s: Tensor,
52
+ z: Tensor,
53
+ mask: Tensor,
54
+ k_in: Tensor,
55
+ multiplicity: int = 1,
56
+ ) -> Tensor:
57
+ """Transform S with K and pair input Z, returning shape ``(b, l_q, d)``."""
58
+
59
+ # s: (b, l_q, d); k_in: (b, l_k, d); z: (b_z, l_q, l_k, d_z or h).
60
+ query = reshape_heads(self.proj_q(s), self.num_heads) # (b, l_q, h, d_h)
61
+ key = reshape_heads(self.proj_k(k_in), self.num_heads) # (b, l_k, h, d_h)
62
+ value = reshape_heads(self.proj_v(k_in), self.num_heads) # (b, l_k, h, d_h)
63
+ pair_bias = self.proj_z(z).repeat_interleave(
64
+ multiplicity, dim=0
65
+ ) # (b, h, l_q, l_k)
66
+ attended = pair_biased_attention(
67
+ query,
68
+ key,
69
+ value,
70
+ pair_bias,
71
+ mask,
72
+ self.inf,
73
+ ) # (b, l_q, h, d_h)
74
+ attended = attended.reshape(s.shape[0], -1, self.c_s) # (b, l_q, d)
75
+ gate = self.proj_g(s).sigmoid() # (b, l_q, d)
76
+ return self.proj_o(gate * attended) # (b, l_q, d)
fastplms/models/boltz/vb_layers_confidence_utils.py CHANGED
@@ -1,273 +1,273 @@
1
- """Confidence geometry and aggregate metrics for Boltz2 outputs."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from torch import nn
8
-
9
- from . import vb_const as const
10
-
11
-
12
- def compute_collinear_mask(v1: torch.Tensor, v2: torch.Tensor) -> torch.Tensor:
13
- """Mark nondegenerate vector pairs whose angle defines a stable frame."""
14
-
15
- # v1/v2: (n, 3)
16
- norm1 = torch.norm(v1, dim=1, keepdim=True) # (n, 1)
17
- norm2 = torch.norm(v2, dim=1, keepdim=True) # (n, 1)
18
- unit1 = v1 / (norm1 + 1e-6) # (n, 3)
19
- unit2 = v2 / (norm2 + 1e-6) # (n, 3)
20
- separated = torch.abs(torch.sum(unit1 * unit2, dim=1)) < 0.9063 # (n,)
21
- return (
22
- separated & (norm1.reshape(-1) > 1e-2) & (norm2.reshape(-1) > 1e-2)
23
- ) # (n,)
24
-
25
-
26
- def _atom_chain_ids(feats: dict[str, torch.Tensor]) -> torch.Tensor:
27
- with torch.amp.autocast("cuda", enabled=False):
28
- return torch.bmm(
29
- feats["atom_to_token"].float(),
30
- feats["asym_id"].unsqueeze(-1).float(),
31
- ).squeeze(-1) # (b, a)
32
-
33
-
34
- def _replace_nonpolymer_frames(
35
- coordinates: torch.Tensor,
36
- frame_indices: torch.Tensor,
37
- feats: dict[str, torch.Tensor],
38
- atom_chain_ids: torch.Tensor,
39
- resolved_mask: torch.Tensor | None,
40
- *,
41
- inference: bool,
42
- ) -> None:
43
- # coordinates: (b, m, a, 3); frame_indices: (b, m, t_or_a, 3).
44
- token_chain_ids = feats["asym_id"] # (b, t)
45
- for batch_index, sample_coordinates in enumerate(coordinates):
46
- # sample_coordinates: (m, a, 3)
47
- token_offset = 0
48
- atom_offset = 0
49
- for chain_id in torch.unique(token_chain_ids[batch_index]):
50
- token_mask = (token_chain_ids[batch_index] == chain_id) * feats[
51
- "token_pad_mask"
52
- ][batch_index] # (t,)
53
- atom_mask = (atom_chain_ids[batch_index] == chain_id) * feats[
54
- "atom_pad_mask"
55
- ][batch_index] # (a,)
56
- token_count = int(token_mask.sum().item())
57
- atom_count = int(atom_mask.sum().item())
58
- is_nonpolymer = (
59
- feats["mol_type"][batch_index, token_offset] == const.chain_type_ids["NONPOLYMER"]
60
- )
61
- if is_nonpolymer and atom_count >= 3:
62
- chain_resolved = (
63
- feats["atom_pad_mask"][batch_index]
64
- if inference
65
- else (
66
- feats["atom_resolved_mask"][batch_index]
67
- if resolved_mask is None
68
- else resolved_mask[batch_index]
69
- )
70
- )
71
- chain_atom_mask = atom_mask.bool() # (a,)
72
- chain_coordinates = sample_coordinates[:, chain_atom_mask] # (m, a_c, 3)
73
- differences = (
74
- chain_coordinates[:, None, :, :] - chain_coordinates[:, :, None, :]
75
- ) # (m, a_c, a_c, 3)
76
- distances = differences.square().sum(dim=-1) ** 0.5 # (m, a_c, a_c)
77
- valid = chain_resolved[chain_atom_mask] # (a_c,)
78
- invalid_pairs = 1 - (valid[None, :] * valid[:, None]).to(
79
- torch.float32
80
- ) # (a_c, a_c)
81
- invalid_pairs[invalid_pairs == 1] = torch.inf # (a_c, a_c)
82
- nearest = torch.sort(
83
- distances + invalid_pairs, dim=2
84
- ).indices # (m, a_c, a_c)
85
- frames = (
86
- torch.cat(
87
- (nearest[:, :, 1:2], nearest[:, :, 0:1], nearest[:, :, 2:3]),
88
- dim=2,
89
- )
90
- + atom_offset
91
- ) # (m, a_c, 3)
92
- frame_indices[
93
- batch_index,
94
- :,
95
- token_offset : token_offset + atom_count,
96
- :,
97
- ] = frames
98
- token_offset += token_count
99
- atom_offset += atom_count
100
-
101
-
102
- def compute_frame_pred(
103
- pred_atom_coords: torch.Tensor,
104
- frames_idx_true: torch.Tensor,
105
- feats: dict[str, torch.Tensor],
106
- multiplicity: int,
107
- resolved_mask: torch.Tensor | None = None,
108
- inference: bool = False,
109
- ) -> tuple[torch.Tensor, torch.Tensor]:
110
- """Construct predicted local frames and their validity mask.
111
-
112
- ``pred_atom_coords`` has shape ``(b * multiplicity, a, 3)``. Polymer
113
- frames retain the supplied atom indices; nonpolymer frames use each
114
- atom's three nearest resolved neighbors.
115
- """
116
-
117
- atom_chain_ids = _atom_chain_ids(feats) # (b, a)
118
- expanded_batch, _, _ = pred_atom_coords.shape
119
- base_batch = expanded_batch // multiplicity
120
- coordinates = pred_atom_coords.reshape(
121
- base_batch, multiplicity, -1, 3
122
- ) # (b, m, a, 3)
123
- frame_indices = frames_idx_true.clone().repeat_interleave(
124
- multiplicity, 0
125
- ) # (b * m, 1, t, 3) or (b * m, t, 3)
126
- frame_indices = frame_indices.reshape(
127
- base_batch, multiplicity, -1, 3
128
- ) # (b, m, t, 3)
129
- _replace_nonpolymer_frames(
130
- coordinates,
131
- frame_indices,
132
- feats,
133
- atom_chain_ids,
134
- resolved_mask,
135
- inference=inference,
136
- )
137
-
138
- batch_indices = torch.arange(
139
- base_batch, device=frame_indices.device
140
- )[:, None, None, None] # (b, 1, 1, 1)
141
- sample_indices = torch.arange(
142
- multiplicity, device=frame_indices.device
143
- )[None, :, None, None] # (1, m, 1, 1)
144
- frame_coordinates = coordinates[
145
- batch_indices,
146
- sample_indices,
147
- frame_indices,
148
- ].reshape(-1, 3, 3) # (b * m * t, 3, 3)
149
- valid_frames = compute_collinear_mask(
150
- frame_coordinates[:, 1] - frame_coordinates[:, 0],
151
- frame_coordinates[:, 1] - frame_coordinates[:, 2],
152
- ).reshape(base_batch, multiplicity, -1) # (b, m, t)
153
- return (
154
- frame_indices,
155
- valid_frames * feats["token_pad_mask"][:, None, :],
156
- ) # (b, m, t, 3), (b, m, t)
157
-
158
-
159
- def compute_aggregated_metric(logits: torch.Tensor, end: float = 1.0) -> torch.Tensor:
160
- """Convert categorical confidence logits to bin-center expectations."""
161
-
162
- num_bins = logits.shape[-1]
163
- bin_width = end / num_bins
164
- centers = torch.arange(
165
- 0.5 * bin_width,
166
- end,
167
- bin_width,
168
- device=logits.device,
169
- ) # (n_bin,)
170
- # logits: (..., n_bin)
171
- probabilities = nn.functional.softmax(logits, dim=-1) # (..., n_bin)
172
- broadcast_shape = (1,) * (probabilities.ndim - 1) + centers.shape
173
- return torch.sum(
174
- probabilities * centers.view(broadcast_shape), dim=-1
175
- ) # (...)
176
-
177
-
178
- def tm_function(d: torch.Tensor, n_res: torch.Tensor) -> torch.Tensor:
179
- """Evaluate the TM-score distance kernel."""
180
-
181
- # d and n_res broadcast to a shared shape (...).
182
- distance_scale = 1.24 * (torch.clip(n_res, min=19) - 15) ** (1 / 3) - 1.8 # (...)
183
- return 1 / (1 + (d / distance_scale) ** 2) # (...)
184
-
185
-
186
- def _maximum_masked_tm(
187
- expected_tm: torch.Tensor,
188
- pair_mask: torch.Tensor,
189
- ) -> torch.Tensor:
190
- # expected_tm/pair_mask: (b * m, t, t).
191
- per_anchor = torch.sum(expected_tm * pair_mask, dim=-1) / (
192
- torch.sum(pair_mask, dim=-1) + 1e-5
193
- ) # (b * m, t)
194
- return torch.max(per_anchor, dim=1).values # (b * m,)
195
-
196
-
197
- def compute_ptms(
198
- logits: torch.Tensor,
199
- x_preds: torch.Tensor,
200
- feats: dict[str, torch.Tensor],
201
- multiplicity: int,
202
- ) -> tuple[
203
- torch.Tensor,
204
- torch.Tensor,
205
- torch.Tensor,
206
- torch.Tensor,
207
- dict[int, dict[int, torch.Tensor]],
208
- ]:
209
- """Compute pTM, ipTM, interface-type, and chain-pair confidence scores."""
210
-
211
- # logits: (b * m, t, t, n_bin); x_preds: (b * m, a, 3).
212
- _, frame_mask = compute_frame_pred(
213
- x_preds,
214
- feats["frames_idx"],
215
- feats,
216
- multiplicity,
217
- inference=True,
218
- ) # frame_mask: (b, m, t)
219
- token_mask = feats["token_pad_mask"].repeat_interleave(
220
- multiplicity, 0
221
- ) # (b * m, t)
222
- valid_anchor = frame_mask.reshape(-1, frame_mask.shape[-1]) # (b * m, t)
223
- base_pair_mask = (
224
- valid_anchor[:, :, None] * token_mask[:, None, :] * token_mask[:, :, None]
225
- ) # (b * m, t, t)
226
- asym_id = feats["asym_id"].repeat_interleave(multiplicity, 0) # (b * m, t)
227
- interface_mask = base_pair_mask * (
228
- asym_id[:, None, :] != asym_id[:, :, None]
229
- ) # (b * m, t, t)
230
-
231
- num_bins = logits.shape[-1]
232
- pae_centers = torch.arange(
233
- 0.5 * (32.0 / num_bins),
234
- 32.0,
235
- 32.0 / num_bins,
236
- device=logits.device,
237
- ).unsqueeze(0) # (1, n_bin)
238
- n_res = token_mask.sum(dim=-1, keepdim=True) # (b * m, 1)
239
- tm_values = tm_function(pae_centers, n_res).unsqueeze(1).unsqueeze(
240
- 2
241
- ) # (b * m, 1, 1, n_bin)
242
- expected_tm = torch.sum(
243
- nn.functional.softmax(logits, dim=-1) * tm_values, dim=-1
244
- ) # (b * m, t, t)
245
-
246
- ptm = _maximum_masked_tm(expected_tm, base_pair_mask) # (b * m,)
247
- iptm = _maximum_masked_tm(expected_tm, interface_mask) # (b * m,)
248
-
249
- token_type = feats["mol_type"].repeat_interleave(multiplicity, 0) # (b * m, t)
250
- ligand = (token_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, t)
251
- protein = (token_type == const.chain_type_ids["PROTEIN"]).float() # (b * m, t)
252
- ligand_protein = (
253
- ligand[:, :, None] * protein[:, None, :] + protein[:, :, None] * ligand[:, None, :]
254
- ) # (b * m, t, t)
255
- protein_protein = protein[:, :, None] * protein[:, None, :] # (b * m, t, t)
256
- ligand_iptm = _maximum_masked_tm(
257
- expected_tm, interface_mask * ligand_protein
258
- ) # (b * m,)
259
- protein_iptm = _maximum_masked_tm(
260
- expected_tm, interface_mask * protein_protein
261
- ) # (b * m,)
262
-
263
- chain_pair_iptm: dict[int, dict[int, torch.Tensor]] = {}
264
- for first_chain in torch.unique(asym_id).tolist():
265
- scores: dict[int, torch.Tensor] = {}
266
- for second_chain in torch.unique(asym_id).tolist():
267
- chain_mask = base_pair_mask
268
- chain_mask = chain_mask * (asym_id[:, None, :] == first_chain)
269
- chain_mask = chain_mask * (asym_id[:, :, None] == second_chain)
270
- scores[second_chain] = _maximum_masked_tm(expected_tm, chain_mask)
271
- chain_pair_iptm[first_chain] = scores
272
-
273
- return ptm, iptm, ligand_iptm, protein_iptm, chain_pair_iptm
 
1
+ """Confidence geometry and aggregate metrics for Boltz2 outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from torch import nn
8
+
9
+ from . import vb_const as const
10
+
11
+
12
+ def compute_collinear_mask(v1: torch.Tensor, v2: torch.Tensor) -> torch.Tensor:
13
+ """Mark nondegenerate vector pairs whose angle defines a stable frame."""
14
+
15
+ # v1/v2: (n, 3)
16
+ norm1 = torch.norm(v1, dim=1, keepdim=True) # (n, 1)
17
+ norm2 = torch.norm(v2, dim=1, keepdim=True) # (n, 1)
18
+ unit1 = v1 / (norm1 + 1e-6) # (n, 3)
19
+ unit2 = v2 / (norm2 + 1e-6) # (n, 3)
20
+ separated = torch.abs(torch.sum(unit1 * unit2, dim=1)) < 0.9063 # (n,)
21
+ return (
22
+ separated & (norm1.reshape(-1) > 1e-2) & (norm2.reshape(-1) > 1e-2)
23
+ ) # (n,)
24
+
25
+
26
+ def _atom_chain_ids(feats: dict[str, torch.Tensor]) -> torch.Tensor:
27
+ with torch.amp.autocast("cuda", enabled=False):
28
+ return torch.bmm(
29
+ feats["atom_to_token"].float(),
30
+ feats["asym_id"].unsqueeze(-1).float(),
31
+ ).squeeze(-1) # (b, a)
32
+
33
+
34
+ def _replace_nonpolymer_frames(
35
+ coordinates: torch.Tensor,
36
+ frame_indices: torch.Tensor,
37
+ feats: dict[str, torch.Tensor],
38
+ atom_chain_ids: torch.Tensor,
39
+ resolved_mask: torch.Tensor | None,
40
+ *,
41
+ inference: bool,
42
+ ) -> None:
43
+ # coordinates: (b, m, a, 3); frame_indices: (b, m, t_or_a, 3).
44
+ token_chain_ids = feats["asym_id"] # (b, t)
45
+ for batch_index, sample_coordinates in enumerate(coordinates):
46
+ # sample_coordinates: (m, a, 3)
47
+ token_offset = 0
48
+ atom_offset = 0
49
+ for chain_id in torch.unique(token_chain_ids[batch_index]):
50
+ token_mask = (token_chain_ids[batch_index] == chain_id) * feats[
51
+ "token_pad_mask"
52
+ ][batch_index] # (t,)
53
+ atom_mask = (atom_chain_ids[batch_index] == chain_id) * feats[
54
+ "atom_pad_mask"
55
+ ][batch_index] # (a,)
56
+ token_count = int(token_mask.sum().item())
57
+ atom_count = int(atom_mask.sum().item())
58
+ is_nonpolymer = (
59
+ feats["mol_type"][batch_index, token_offset] == const.chain_type_ids["NONPOLYMER"]
60
+ )
61
+ if is_nonpolymer and atom_count >= 3:
62
+ chain_resolved = (
63
+ feats["atom_pad_mask"][batch_index]
64
+ if inference
65
+ else (
66
+ feats["atom_resolved_mask"][batch_index]
67
+ if resolved_mask is None
68
+ else resolved_mask[batch_index]
69
+ )
70
+ )
71
+ chain_atom_mask = atom_mask.bool() # (a,)
72
+ chain_coordinates = sample_coordinates[:, chain_atom_mask] # (m, a_c, 3)
73
+ differences = (
74
+ chain_coordinates[:, None, :, :] - chain_coordinates[:, :, None, :]
75
+ ) # (m, a_c, a_c, 3)
76
+ distances = differences.square().sum(dim=-1) ** 0.5 # (m, a_c, a_c)
77
+ valid = chain_resolved[chain_atom_mask] # (a_c,)
78
+ invalid_pairs = 1 - (valid[None, :] * valid[:, None]).to(
79
+ torch.float32
80
+ ) # (a_c, a_c)
81
+ invalid_pairs[invalid_pairs == 1] = torch.inf # (a_c, a_c)
82
+ nearest = torch.sort(
83
+ distances + invalid_pairs, dim=2
84
+ ).indices # (m, a_c, a_c)
85
+ frames = (
86
+ torch.cat(
87
+ (nearest[:, :, 1:2], nearest[:, :, 0:1], nearest[:, :, 2:3]),
88
+ dim=2,
89
+ )
90
+ + atom_offset
91
+ ) # (m, a_c, 3)
92
+ frame_indices[
93
+ batch_index,
94
+ :,
95
+ token_offset : token_offset + atom_count,
96
+ :,
97
+ ] = frames
98
+ token_offset += token_count
99
+ atom_offset += atom_count
100
+
101
+
102
+ def compute_frame_pred(
103
+ pred_atom_coords: torch.Tensor,
104
+ frames_idx_true: torch.Tensor,
105
+ feats: dict[str, torch.Tensor],
106
+ multiplicity: int,
107
+ resolved_mask: torch.Tensor | None = None,
108
+ inference: bool = False,
109
+ ) -> tuple[torch.Tensor, torch.Tensor]:
110
+ """Construct predicted local frames and their validity mask.
111
+
112
+ ``pred_atom_coords`` has shape ``(b * multiplicity, a, 3)``. Polymer
113
+ frames retain the supplied atom indices; nonpolymer frames use each
114
+ atom's three nearest resolved neighbors.
115
+ """
116
+
117
+ atom_chain_ids = _atom_chain_ids(feats) # (b, a)
118
+ expanded_batch, _, _ = pred_atom_coords.shape
119
+ base_batch = expanded_batch // multiplicity
120
+ coordinates = pred_atom_coords.reshape(
121
+ base_batch, multiplicity, -1, 3
122
+ ) # (b, m, a, 3)
123
+ frame_indices = frames_idx_true.clone().repeat_interleave(
124
+ multiplicity, 0
125
+ ) # (b * m, 1, t, 3) or (b * m, t, 3)
126
+ frame_indices = frame_indices.reshape(
127
+ base_batch, multiplicity, -1, 3
128
+ ) # (b, m, t, 3)
129
+ _replace_nonpolymer_frames(
130
+ coordinates,
131
+ frame_indices,
132
+ feats,
133
+ atom_chain_ids,
134
+ resolved_mask,
135
+ inference=inference,
136
+ )
137
+
138
+ batch_indices = torch.arange(
139
+ base_batch, device=frame_indices.device
140
+ )[:, None, None, None] # (b, 1, 1, 1)
141
+ sample_indices = torch.arange(
142
+ multiplicity, device=frame_indices.device
143
+ )[None, :, None, None] # (1, m, 1, 1)
144
+ frame_coordinates = coordinates[
145
+ batch_indices,
146
+ sample_indices,
147
+ frame_indices,
148
+ ].reshape(-1, 3, 3) # (b * m * t, 3, 3)
149
+ valid_frames = compute_collinear_mask(
150
+ frame_coordinates[:, 1] - frame_coordinates[:, 0],
151
+ frame_coordinates[:, 1] - frame_coordinates[:, 2],
152
+ ).reshape(base_batch, multiplicity, -1) # (b, m, t)
153
+ return (
154
+ frame_indices,
155
+ valid_frames * feats["token_pad_mask"][:, None, :],
156
+ ) # (b, m, t, 3), (b, m, t)
157
+
158
+
159
+ def compute_aggregated_metric(logits: torch.Tensor, end: float = 1.0) -> torch.Tensor:
160
+ """Convert categorical confidence logits to bin-center expectations."""
161
+
162
+ num_bins = logits.shape[-1]
163
+ bin_width = end / num_bins
164
+ centers = torch.arange(
165
+ 0.5 * bin_width,
166
+ end,
167
+ bin_width,
168
+ device=logits.device,
169
+ ) # (n_bin,)
170
+ # logits: (..., n_bin)
171
+ probabilities = nn.functional.softmax(logits, dim=-1) # (..., n_bin)
172
+ broadcast_shape = (1,) * (probabilities.ndim - 1) + centers.shape
173
+ return torch.sum(
174
+ probabilities * centers.view(broadcast_shape), dim=-1
175
+ ) # (...)
176
+
177
+
178
+ def tm_function(d: torch.Tensor, n_res: torch.Tensor) -> torch.Tensor:
179
+ """Evaluate the TM-score distance kernel."""
180
+
181
+ # d and n_res broadcast to a shared shape (...).
182
+ distance_scale = 1.24 * (torch.clip(n_res, min=19) - 15) ** (1 / 3) - 1.8 # (...)
183
+ return 1 / (1 + (d / distance_scale) ** 2) # (...)
184
+
185
+
186
+ def _maximum_masked_tm(
187
+ expected_tm: torch.Tensor,
188
+ pair_mask: torch.Tensor,
189
+ ) -> torch.Tensor:
190
+ # expected_tm/pair_mask: (b * m, t, t).
191
+ per_anchor = torch.sum(expected_tm * pair_mask, dim=-1) / (
192
+ torch.sum(pair_mask, dim=-1) + 1e-5
193
+ ) # (b * m, t)
194
+ return torch.max(per_anchor, dim=1).values # (b * m,)
195
+
196
+
197
+ def compute_ptms(
198
+ logits: torch.Tensor,
199
+ x_preds: torch.Tensor,
200
+ feats: dict[str, torch.Tensor],
201
+ multiplicity: int,
202
+ ) -> tuple[
203
+ torch.Tensor,
204
+ torch.Tensor,
205
+ torch.Tensor,
206
+ torch.Tensor,
207
+ dict[int, dict[int, torch.Tensor]],
208
+ ]:
209
+ """Compute pTM, ipTM, interface-type, and chain-pair confidence scores."""
210
+
211
+ # logits: (b * m, t, t, n_bin); x_preds: (b * m, a, 3).
212
+ _, frame_mask = compute_frame_pred(
213
+ x_preds,
214
+ feats["frames_idx"],
215
+ feats,
216
+ multiplicity,
217
+ inference=True,
218
+ ) # frame_mask: (b, m, t)
219
+ token_mask = feats["token_pad_mask"].repeat_interleave(
220
+ multiplicity, 0
221
+ ) # (b * m, t)
222
+ valid_anchor = frame_mask.reshape(-1, frame_mask.shape[-1]) # (b * m, t)
223
+ base_pair_mask = (
224
+ valid_anchor[:, :, None] * token_mask[:, None, :] * token_mask[:, :, None]
225
+ ) # (b * m, t, t)
226
+ asym_id = feats["asym_id"].repeat_interleave(multiplicity, 0) # (b * m, t)
227
+ interface_mask = base_pair_mask * (
228
+ asym_id[:, None, :] != asym_id[:, :, None]
229
+ ) # (b * m, t, t)
230
+
231
+ num_bins = logits.shape[-1]
232
+ pae_centers = torch.arange(
233
+ 0.5 * (32.0 / num_bins),
234
+ 32.0,
235
+ 32.0 / num_bins,
236
+ device=logits.device,
237
+ ).unsqueeze(0) # (1, n_bin)
238
+ n_res = token_mask.sum(dim=-1, keepdim=True) # (b * m, 1)
239
+ tm_values = tm_function(pae_centers, n_res).unsqueeze(1).unsqueeze(
240
+ 2
241
+ ) # (b * m, 1, 1, n_bin)
242
+ expected_tm = torch.sum(
243
+ nn.functional.softmax(logits, dim=-1) * tm_values, dim=-1
244
+ ) # (b * m, t, t)
245
+
246
+ ptm = _maximum_masked_tm(expected_tm, base_pair_mask) # (b * m,)
247
+ iptm = _maximum_masked_tm(expected_tm, interface_mask) # (b * m,)
248
+
249
+ token_type = feats["mol_type"].repeat_interleave(multiplicity, 0) # (b * m, t)
250
+ ligand = (token_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, t)
251
+ protein = (token_type == const.chain_type_ids["PROTEIN"]).float() # (b * m, t)
252
+ ligand_protein = (
253
+ ligand[:, :, None] * protein[:, None, :] + protein[:, :, None] * ligand[:, None, :]
254
+ ) # (b * m, t, t)
255
+ protein_protein = protein[:, :, None] * protein[:, None, :] # (b * m, t, t)
256
+ ligand_iptm = _maximum_masked_tm(
257
+ expected_tm, interface_mask * ligand_protein
258
+ ) # (b * m,)
259
+ protein_iptm = _maximum_masked_tm(
260
+ expected_tm, interface_mask * protein_protein
261
+ ) # (b * m,)
262
+
263
+ chain_pair_iptm: dict[int, dict[int, torch.Tensor]] = {}
264
+ for first_chain in torch.unique(asym_id).tolist():
265
+ scores: dict[int, torch.Tensor] = {}
266
+ for second_chain in torch.unique(asym_id).tolist():
267
+ chain_mask = base_pair_mask
268
+ chain_mask = chain_mask * (asym_id[:, None, :] == first_chain)
269
+ chain_mask = chain_mask * (asym_id[:, :, None] == second_chain)
270
+ scores[second_chain] = _maximum_masked_tm(expected_tm, chain_mask)
271
+ chain_pair_iptm[first_chain] = scores
272
+
273
+ return ptm, iptm, ligand_iptm, protein_iptm, chain_pair_iptm
fastplms/models/boltz/vb_layers_dropout.py CHANGED
@@ -1,43 +1,43 @@
1
- """Broadcast dropout masks used by the Boltz pair stack.
2
-
3
- The mask is sampled in ``float32`` even when the pair tensor uses a lower
4
- precision dtype. This matches the checkpoint implementation while keeping
5
- the broadcast axis explicit.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import torch
11
-
12
- from torch import Tensor
13
-
14
-
15
- def _broadcast_mask_shape(Z: Tensor, *, columnwise: bool) -> tuple[int, ...]:
16
- """Return the row-wise or column-wise pair-mask shape for ``Z``."""
17
-
18
- if Z.ndim != 4:
19
- raise ValueError(f"pair tensor must have four dimensions, got {Z.ndim}")
20
- b, rows, columns, _ = Z.shape
21
- return (b, 1, columns, 1) if columnwise else (b, rows, 1, 1)
22
-
23
-
24
- def get_dropout_mask(
25
- dropout: float,
26
- z: Tensor,
27
- training: bool,
28
- columnwise: bool = False,
29
- ) -> Tensor:
30
- """Sample an inverted-dropout mask that broadcasts over pair channels.
31
-
32
- ``Z`` is the pair tensor with shape ``(b, n, n, d)``. Row-wise masks
33
- vary along the first residue axis; column-wise masks vary along the
34
- second. Evaluation returns an all-one mask while retaining the same RNG
35
- call pattern as training.
36
- """
37
-
38
- probability = float(dropout) if training else 0.0
39
- sample_shape = _broadcast_mask_shape(z, columnwise=columnwise)
40
- keep = (
41
- torch.rand(sample_shape, dtype=torch.float32, device=z.device) >= probability
42
- ) # (b, 1, n, 1) or (b, n, 1, 1)
43
- return keep * (1.0 / (1.0 - probability)) # same broadcast-mask shape
 
1
+ """Broadcast dropout masks used by the Boltz pair stack.
2
+
3
+ The mask is sampled in ``float32`` even when the pair tensor uses a lower
4
+ precision dtype. This matches the checkpoint implementation while keeping
5
+ the broadcast axis explicit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from torch import Tensor
13
+
14
+
15
+ def _broadcast_mask_shape(Z: Tensor, *, columnwise: bool) -> tuple[int, ...]:
16
+ """Return the row-wise or column-wise pair-mask shape for ``Z``."""
17
+
18
+ if Z.ndim != 4:
19
+ raise ValueError(f"pair tensor must have four dimensions, got {Z.ndim}")
20
+ b, rows, columns, _ = Z.shape
21
+ return (b, 1, columns, 1) if columnwise else (b, rows, 1, 1)
22
+
23
+
24
+ def get_dropout_mask(
25
+ dropout: float,
26
+ z: Tensor,
27
+ training: bool,
28
+ columnwise: bool = False,
29
+ ) -> Tensor:
30
+ """Sample an inverted-dropout mask that broadcasts over pair channels.
31
+
32
+ ``Z`` is the pair tensor with shape ``(b, n, n, d)``. Row-wise masks
33
+ vary along the first residue axis; column-wise masks vary along the
34
+ second. Evaluation returns an all-one mask while retaining the same RNG
35
+ call pattern as training.
36
+ """
37
+
38
+ probability = float(dropout) if training else 0.0
39
+ sample_shape = _broadcast_mask_shape(z, columnwise=columnwise)
40
+ keep = (
41
+ torch.rand(sample_shape, dtype=torch.float32, device=z.device) >= probability
42
+ ) # (b, 1, n, 1) or (b, n, 1, 1)
43
+ return keep * (1.0 / (1.0 - probability)) # same broadcast-mask shape
fastplms/models/boltz/vb_layers_initialize.py CHANGED
@@ -1,110 +1,110 @@
1
- """Torch-only parameter initialization for the Boltz2 runtime."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
- import torch
7
-
8
- from typing import Literal
9
- from torch import Tensor
10
-
11
-
12
- FanMode = Literal["fan_in", "fan_out", "fan_avg"]
13
-
14
-
15
- def _calculate_fan(shape: torch.Size | tuple[int, ...], fan: FanMode = "fan_in") -> float:
16
- """Resolve the selected fan for a two-dimensional linear weight tensor."""
17
-
18
- if len(shape) != 2:
19
- raise ValueError(f"linear weights must be two-dimensional, received {tuple(shape)}")
20
- fan_out, fan_in = shape
21
- if fan == "fan_in":
22
- return float(fan_in)
23
- if fan == "fan_out":
24
- return float(fan_out)
25
- if fan == "fan_avg":
26
- return (fan_in + fan_out) / 2
27
- raise ValueError(f"invalid fan mode: {fan!r}")
28
-
29
-
30
- def trunc_normal_init_(
31
- weights: Tensor,
32
- scale: float = 1.0,
33
- fan: FanMode = "fan_in",
34
- ) -> None:
35
- """Fill W from a normal distribution truncated at two standard deviations."""
36
-
37
- # weights: (d_out, d_in); initialization preserves this shape in place.
38
- variance = scale / max(1.0, _calculate_fan(weights.shape, fan))
39
- std = math.sqrt(variance)
40
- with torch.no_grad():
41
- torch.nn.init.trunc_normal_(weights, mean=0.0, std=std, a=-2 * std, b=2 * std)
42
-
43
-
44
- def lecun_normal_init_(weights: Tensor) -> None:
45
- """Initialize W using fan-in-scaled truncated normal values."""
46
-
47
- # weights: (d_out, d_in), mutated in place.
48
- trunc_normal_init_(weights)
49
-
50
-
51
- def he_normal_init_(weights: Tensor) -> None:
52
- """Initialize W using twice the fan-in variance."""
53
-
54
- # weights: (d_out, d_in), mutated in place.
55
- trunc_normal_init_(weights, scale=2.0)
56
-
57
-
58
- def glorot_uniform_init_(weights: Tensor) -> None:
59
- """Initialize W with Xavier uniform values."""
60
-
61
- # weights: (...), rank at least two; mutated in place.
62
- torch.nn.init.xavier_uniform_(weights, gain=1.0)
63
-
64
-
65
- def _fill_(tensor: Tensor, value: float) -> None:
66
- # tensor: (...), mutated in place without changing shape.
67
- with torch.no_grad():
68
- tensor.fill_(value)
69
-
70
-
71
- def final_init_(weights: Tensor) -> None:
72
- """Zero the final projection W."""
73
-
74
- # weights: (...), mutated in place.
75
- _fill_(weights, 0.0)
76
-
77
-
78
- def gating_init_(weights: Tensor) -> None:
79
- """Zero the gating projection W."""
80
-
81
- # weights: (...), mutated in place.
82
- _fill_(weights, 0.0)
83
-
84
-
85
- def bias_init_zero_(bias: Tensor) -> None:
86
- """Set the bias tensor to zero."""
87
-
88
- # bias: (...), mutated in place.
89
- _fill_(bias, 0.0)
90
-
91
-
92
- def bias_init_one_(bias: Tensor) -> None:
93
- """Set the bias tensor to one."""
94
-
95
- # bias: (...), mutated in place.
96
- _fill_(bias, 1.0)
97
-
98
-
99
- def normal_init_(weights: Tensor) -> None:
100
- """Initialize W with linear Kaiming-normal values."""
101
-
102
- # weights: (...), rank at least two; mutated in place.
103
- torch.nn.init.kaiming_normal_(weights, nonlinearity="linear")
104
-
105
-
106
- def ipa_point_weights_init_(weights: Tensor) -> None:
107
- """Initialize W so applying softplus yields one."""
108
-
109
- # weights: (...), mutated in place.
110
- _fill_(weights, 0.541324854612918)
 
1
+ """Torch-only parameter initialization for the Boltz2 runtime."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import torch
7
+
8
+ from typing import Literal
9
+ from torch import Tensor
10
+
11
+
12
+ FanMode = Literal["fan_in", "fan_out", "fan_avg"]
13
+
14
+
15
+ def _calculate_fan(shape: torch.Size | tuple[int, ...], fan: FanMode = "fan_in") -> float:
16
+ """Resolve the selected fan for a two-dimensional linear weight tensor."""
17
+
18
+ if len(shape) != 2:
19
+ raise ValueError(f"linear weights must be two-dimensional, received {tuple(shape)}")
20
+ fan_out, fan_in = shape
21
+ if fan == "fan_in":
22
+ return float(fan_in)
23
+ if fan == "fan_out":
24
+ return float(fan_out)
25
+ if fan == "fan_avg":
26
+ return (fan_in + fan_out) / 2
27
+ raise ValueError(f"invalid fan mode: {fan!r}")
28
+
29
+
30
+ def trunc_normal_init_(
31
+ weights: Tensor,
32
+ scale: float = 1.0,
33
+ fan: FanMode = "fan_in",
34
+ ) -> None:
35
+ """Fill W from a normal distribution truncated at two standard deviations."""
36
+
37
+ # weights: (d_out, d_in); initialization preserves this shape in place.
38
+ variance = scale / max(1.0, _calculate_fan(weights.shape, fan))
39
+ std = math.sqrt(variance)
40
+ with torch.no_grad():
41
+ torch.nn.init.trunc_normal_(weights, mean=0.0, std=std, a=-2 * std, b=2 * std)
42
+
43
+
44
+ def lecun_normal_init_(weights: Tensor) -> None:
45
+ """Initialize W using fan-in-scaled truncated normal values."""
46
+
47
+ # weights: (d_out, d_in), mutated in place.
48
+ trunc_normal_init_(weights)
49
+
50
+
51
+ def he_normal_init_(weights: Tensor) -> None:
52
+ """Initialize W using twice the fan-in variance."""
53
+
54
+ # weights: (d_out, d_in), mutated in place.
55
+ trunc_normal_init_(weights, scale=2.0)
56
+
57
+
58
+ def glorot_uniform_init_(weights: Tensor) -> None:
59
+ """Initialize W with Xavier uniform values."""
60
+
61
+ # weights: (...), rank at least two; mutated in place.
62
+ torch.nn.init.xavier_uniform_(weights, gain=1.0)
63
+
64
+
65
+ def _fill_(tensor: Tensor, value: float) -> None:
66
+ # tensor: (...), mutated in place without changing shape.
67
+ with torch.no_grad():
68
+ tensor.fill_(value)
69
+
70
+
71
+ def final_init_(weights: Tensor) -> None:
72
+ """Zero the final projection W."""
73
+
74
+ # weights: (...), mutated in place.
75
+ _fill_(weights, 0.0)
76
+
77
+
78
+ def gating_init_(weights: Tensor) -> None:
79
+ """Zero the gating projection W."""
80
+
81
+ # weights: (...), mutated in place.
82
+ _fill_(weights, 0.0)
83
+
84
+
85
+ def bias_init_zero_(bias: Tensor) -> None:
86
+ """Set the bias tensor to zero."""
87
+
88
+ # bias: (...), mutated in place.
89
+ _fill_(bias, 0.0)
90
+
91
+
92
+ def bias_init_one_(bias: Tensor) -> None:
93
+ """Set the bias tensor to one."""
94
+
95
+ # bias: (...), mutated in place.
96
+ _fill_(bias, 1.0)
97
+
98
+
99
+ def normal_init_(weights: Tensor) -> None:
100
+ """Initialize W with linear Kaiming-normal values."""
101
+
102
+ # weights: (...), rank at least two; mutated in place.
103
+ torch.nn.init.kaiming_normal_(weights, nonlinearity="linear")
104
+
105
+
106
+ def ipa_point_weights_init_(weights: Tensor) -> None:
107
+ """Initialize W so applying softplus yields one."""
108
+
109
+ # weights: (...), mutated in place.
110
+ _fill_(weights, 0.541324854612918)
fastplms/models/boltz/vb_layers_outer_product_mean.py CHANGED
@@ -1,105 +1,105 @@
1
- """Outer-product aggregation from MSA states to pair states."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from torch import Tensor, nn
8
-
9
- from . import vb_layers_initialize as init
10
-
11
-
12
- class OuterProductMean(nn.Module):
13
- """Aggregate M with shape ``(b, s, l, d_m)`` into pair tensor Z."""
14
-
15
- def __init__(self, c_in: int, c_hidden: int, c_out: int) -> None:
16
- super().__init__()
17
- self.c_hidden = c_hidden
18
- self.norm = nn.LayerNorm(c_in)
19
- self.proj_a = nn.Linear(c_in, c_hidden, bias=False)
20
- self.proj_b = nn.Linear(c_in, c_hidden, bias=False)
21
- self.proj_o = nn.Linear(c_hidden * c_hidden, c_out)
22
- init.final_init_(self.proj_o.weight)
23
- init.final_init_(self.proj_o.bias)
24
-
25
- @staticmethod
26
- def _pair_counts(mask: Tensor) -> Tensor:
27
- """Count valid MSA observations for each residue pair."""
28
-
29
- # mask: (b, s, l, 1)
30
- counts: Tensor | None = None
31
- for start in range(0, mask.shape[1], 64):
32
- mask_slice = mask[:, start : start + 64] # (b, s_chunk, l, 1)
33
- contribution = (
34
- mask_slice[:, :, None, :] * mask_slice[:, :, :, None]
35
- ).sum(dim=1) # (b, l, l, 1)
36
- counts = (
37
- contribution if counts is None else counts + contribution
38
- ) # (b, l, l, 1)
39
- if counts is None:
40
- raise ValueError("MSA depth must be positive")
41
- return counts.clamp(min=1) # (b, l, l, 1)
42
-
43
- @staticmethod
44
- def _outer_product(left: Tensor, right: Tensor, counts: Tensor) -> Tensor:
45
- # left: (b, s, l, d_left); right: (b, s, l, d_right).
46
- # counts: (b, l, l, 1); chunked callers may use d_left < d_right.
47
- outer = torch.einsum("bsic,bsjd->bijcd", left, right) # (b, l, l, d_left, d_right)
48
- return outer.reshape(*outer.shape[:3], -1) / counts # (b, l, l, d_left * d_right)
49
-
50
- def _chunked_projection(
51
- self,
52
- left: Tensor,
53
- right: Tensor,
54
- counts: Tensor,
55
- chunk_size: int,
56
- target: Tensor,
57
- ) -> Tensor:
58
- # left/right: (b, s, l, d_h); counts: (b, l, l, 1).
59
- output: Tensor | None = None
60
- for start in range(0, self.c_hidden, chunk_size):
61
- stop = min(start + chunk_size, self.c_hidden)
62
- outer = self._outer_product(
63
- left[..., start:stop], right, counts
64
- ) # (b, l, l, d_chunk * d_h)
65
- weight = self.proj_o.weight[
66
- :,
67
- start * self.c_hidden : stop * self.c_hidden,
68
- ] # (d_z, d_chunk * d_h)
69
- contribution = outer.to(target) @ weight.T # (b, l, l, d_z)
70
- output = (
71
- contribution if output is None else output + contribution
72
- ) # (b, l, l, d_z)
73
- if output is None:
74
- raise ValueError("c_hidden must be positive")
75
- return output + self.proj_o.bias # (b, l, l, d_z)
76
-
77
- def forward(self, m: Tensor, mask: Tensor, chunk_size: int | None = None) -> Tensor:
78
- """Return pair tensor Z with shape ``(b, l, l, d_z)``."""
79
-
80
- # m: (b, s, l, d_m); mask: (b, s, l).
81
- expanded_mask = mask.unsqueeze(-1).to(m) # (b, s, l, 1)
82
- normalized = self.norm(m) # (b, s, l, d_m)
83
- left = self.proj_a(normalized) * expanded_mask # (b, s, l, d_h)
84
- right = self.proj_b(normalized) * expanded_mask # (b, s, l, d_h)
85
-
86
- if chunk_size is not None and not self.training:
87
- if chunk_size <= 0:
88
- raise ValueError("chunk_size must be positive")
89
- counts = self._pair_counts(expanded_mask) # (b, l, l, 1)
90
- return self._chunked_projection(
91
- left,
92
- right,
93
- counts,
94
- chunk_size,
95
- normalized,
96
- )
97
-
98
- pair_mask = (
99
- expanded_mask[:, :, None, :] * expanded_mask[:, :, :, None]
100
- ) # (b, s, l, l, 1)
101
- counts = pair_mask.sum(dim=1).clamp(min=1) # (b, l, l, 1)
102
- outer = self._outer_product(
103
- left.float(), right.float(), counts
104
- ) # (b, l, l, d_h**2)
105
- return self.proj_o(outer.to(normalized)) # (b, l, l, d_z)
 
1
+ """Outer-product aggregation from MSA states to pair states."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from torch import Tensor, nn
8
+
9
+ from . import vb_layers_initialize as init
10
+
11
+
12
+ class OuterProductMean(nn.Module):
13
+ """Aggregate M with shape ``(b, s, l, d_m)`` into pair tensor Z."""
14
+
15
+ def __init__(self, c_in: int, c_hidden: int, c_out: int) -> None:
16
+ super().__init__()
17
+ self.c_hidden = c_hidden
18
+ self.norm = nn.LayerNorm(c_in)
19
+ self.proj_a = nn.Linear(c_in, c_hidden, bias=False)
20
+ self.proj_b = nn.Linear(c_in, c_hidden, bias=False)
21
+ self.proj_o = nn.Linear(c_hidden * c_hidden, c_out)
22
+ init.final_init_(self.proj_o.weight)
23
+ init.final_init_(self.proj_o.bias)
24
+
25
+ @staticmethod
26
+ def _pair_counts(mask: Tensor) -> Tensor:
27
+ """Count valid MSA observations for each residue pair."""
28
+
29
+ # mask: (b, s, l, 1)
30
+ counts: Tensor | None = None
31
+ for start in range(0, mask.shape[1], 64):
32
+ mask_slice = mask[:, start : start + 64] # (b, s_chunk, l, 1)
33
+ contribution = (
34
+ mask_slice[:, :, None, :] * mask_slice[:, :, :, None]
35
+ ).sum(dim=1) # (b, l, l, 1)
36
+ counts = (
37
+ contribution if counts is None else counts + contribution
38
+ ) # (b, l, l, 1)
39
+ if counts is None:
40
+ raise ValueError("MSA depth must be positive")
41
+ return counts.clamp(min=1) # (b, l, l, 1)
42
+
43
+ @staticmethod
44
+ def _outer_product(left: Tensor, right: Tensor, counts: Tensor) -> Tensor:
45
+ # left: (b, s, l, d_left); right: (b, s, l, d_right).
46
+ # counts: (b, l, l, 1); chunked callers may use d_left < d_right.
47
+ outer = torch.einsum("bsic,bsjd->bijcd", left, right) # (b, l, l, d_left, d_right)
48
+ return outer.reshape(*outer.shape[:3], -1) / counts # (b, l, l, d_left * d_right)
49
+
50
+ def _chunked_projection(
51
+ self,
52
+ left: Tensor,
53
+ right: Tensor,
54
+ counts: Tensor,
55
+ chunk_size: int,
56
+ target: Tensor,
57
+ ) -> Tensor:
58
+ # left/right: (b, s, l, d_h); counts: (b, l, l, 1).
59
+ output: Tensor | None = None
60
+ for start in range(0, self.c_hidden, chunk_size):
61
+ stop = min(start + chunk_size, self.c_hidden)
62
+ outer = self._outer_product(
63
+ left[..., start:stop], right, counts
64
+ ) # (b, l, l, d_chunk * d_h)
65
+ weight = self.proj_o.weight[
66
+ :,
67
+ start * self.c_hidden : stop * self.c_hidden,
68
+ ] # (d_z, d_chunk * d_h)
69
+ contribution = outer.to(target) @ weight.T # (b, l, l, d_z)
70
+ output = (
71
+ contribution if output is None else output + contribution
72
+ ) # (b, l, l, d_z)
73
+ if output is None:
74
+ raise ValueError("c_hidden must be positive")
75
+ return output + self.proj_o.bias # (b, l, l, d_z)
76
+
77
+ def forward(self, m: Tensor, mask: Tensor, chunk_size: int | None = None) -> Tensor:
78
+ """Return pair tensor Z with shape ``(b, l, l, d_z)``."""
79
+
80
+ # m: (b, s, l, d_m); mask: (b, s, l).
81
+ expanded_mask = mask.unsqueeze(-1).to(m) # (b, s, l, 1)
82
+ normalized = self.norm(m) # (b, s, l, d_m)
83
+ left = self.proj_a(normalized) * expanded_mask # (b, s, l, d_h)
84
+ right = self.proj_b(normalized) * expanded_mask # (b, s, l, d_h)
85
+
86
+ if chunk_size is not None and not self.training:
87
+ if chunk_size <= 0:
88
+ raise ValueError("chunk_size must be positive")
89
+ counts = self._pair_counts(expanded_mask) # (b, l, l, 1)
90
+ return self._chunked_projection(
91
+ left,
92
+ right,
93
+ counts,
94
+ chunk_size,
95
+ normalized,
96
+ )
97
+
98
+ pair_mask = (
99
+ expanded_mask[:, :, None, :] * expanded_mask[:, :, :, None]
100
+ ) # (b, s, l, l, 1)
101
+ counts = pair_mask.sum(dim=1).clamp(min=1) # (b, l, l, 1)
102
+ outer = self._outer_product(
103
+ left.float(), right.float(), counts
104
+ ) # (b, l, l, d_h**2)
105
+ return self.proj_o(outer.to(normalized)) # (b, l, l, d_z)
fastplms/models/boltz/vb_layers_pair_averaging.py CHANGED
@@ -1,112 +1,112 @@
1
- """Pair-weighted aggregation of Boltz2 MSA states."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from torch import Tensor, nn
8
-
9
- from . import vb_layers_initialize as init
10
-
11
-
12
- class PairWeightedAveraging(nn.Module):
13
- """Update M with pair-derived attention weights while preserving its shape."""
14
-
15
- def __init__(
16
- self,
17
- c_m: int,
18
- c_z: int,
19
- c_h: int,
20
- num_heads: int,
21
- inf: float = 1e6,
22
- ) -> None:
23
- super().__init__()
24
- self.c_m = c_m
25
- self.c_z = c_z
26
- self.c_h = c_h
27
- self.num_heads = num_heads
28
- self.inf = inf
29
- self.norm_m = nn.LayerNorm(c_m)
30
- self.norm_z = nn.LayerNorm(c_z)
31
- self.proj_m = nn.Linear(c_m, c_h * num_heads, bias=False)
32
- self.proj_g = nn.Linear(c_m, c_h * num_heads, bias=False)
33
- self.proj_z = nn.Linear(c_z, num_heads, bias=False)
34
- self.proj_o = nn.Linear(c_h * num_heads, c_m, bias=False)
35
- init.final_init_(self.proj_o.weight)
36
-
37
- def _attention_weights(self, pair_states: Tensor, mask: Tensor) -> Tensor:
38
- # pair_states: (b, l, l, d_z); mask: (b, l, l).
39
- logits = self.proj_z(pair_states).permute(0, 3, 1, 2) # (b, h, l, l)
40
- logits = logits + (1 - mask[:, None]) * -self.inf # (b, h, l, l)
41
- return torch.softmax(logits, dim=-1) # (b, h, l, l)
42
-
43
- def _all_heads(self, msa_states: Tensor, pair_states: Tensor, mask: Tensor) -> Tensor:
44
- # msa_states: (b, s, l, d_m); pair_states: (b, l, l, d_z).
45
- values = self.proj_m(msa_states).reshape(
46
- *msa_states.shape[:3],
47
- self.num_heads,
48
- self.c_h,
49
- ) # (b, s, l, h, d_h)
50
- values = values.permute(0, 3, 1, 2, 4) # (b, h, s, l, d_h)
51
- weights = self._attention_weights(pair_states, mask) # (b, h, l, l)
52
- gate = self.proj_g(msa_states).sigmoid() # (b, s, l, h * d_h)
53
- attended = torch.einsum(
54
- "bhij,bhsjd->bhsid", weights, values
55
- ) # (b, h, s, l, d_h)
56
- attended = attended.permute(0, 2, 3, 1, 4) # (b, s, l, h, d_h)
57
- attended = attended.reshape(
58
- *attended.shape[:3], self.num_heads * self.c_h
59
- ) # (b, s, l, h * d_h)
60
- return self.proj_o(gate * attended) # (b, s, l, d_m)
61
-
62
- def _one_head(self, msa_states: Tensor, pair_states: Tensor, mask: Tensor, head: int) -> Tensor:
63
- # msa_states: (b, s, l, d_m); pair_states: (b, l, l, d_z).
64
- start = head * self.c_h
65
- stop = start + self.c_h
66
- value = msa_states @ self.proj_m.weight[start:stop].T # (b, s, l, d_h)
67
- value = value.reshape(*value.shape[:3], 1, self.c_h).permute(
68
- 0, 3, 1, 2, 4
69
- ) # (b, 1, s, l, d_h)
70
- logits = pair_states @ self.proj_z.weight[head : head + 1].T # (b, l, l, 1)
71
- logits = logits.permute(0, 3, 1, 2) # (b, 1, l, l)
72
- weights = torch.softmax(
73
- logits + (1 - mask[:, None]) * -self.inf, dim=-1
74
- ) # (b, 1, l, l)
75
- gate = (
76
- msa_states @ self.proj_g.weight[start:stop].T
77
- ).sigmoid() # (b, s, l, d_h)
78
- attended = torch.einsum(
79
- "bhij,bhsjd->bhsid", weights, value
80
- ) # (b, 1, s, l, d_h)
81
- attended = attended.permute(0, 2, 3, 1, 4).reshape(
82
- *msa_states.shape[:3],
83
- self.c_h,
84
- ) # (b, s, l, d_h)
85
- return (gate * attended) @ self.proj_o.weight[:, start:stop].T # (b, s, l, d_m)
86
-
87
- def forward(
88
- self,
89
- m: Tensor,
90
- z: Tensor,
91
- mask: Tensor,
92
- chunk_heads: bool = True,
93
- ) -> Tensor:
94
- """Return updated M with shape ``(b, s, l, d_m)``."""
95
-
96
- # m: (b, s, l, d_m); z: (b, l, l, d_z); mask: (b, l, l).
97
- msa_states = self.norm_m(m) # (b, s, l, d_m)
98
- pair_states = self.norm_z(z) # (b, l, l, d_z)
99
- if not chunk_heads or self.training:
100
- return self._all_heads(msa_states, pair_states, mask)
101
-
102
- output: Tensor | None = None
103
- for head in range(self.num_heads):
104
- contribution = self._one_head(
105
- msa_states, pair_states, mask, head
106
- ) # (b, s, l, d_m)
107
- output = (
108
- contribution if output is None else output + contribution
109
- ) # (b, s, l, d_m)
110
- if output is None:
111
- raise ValueError("num_heads must be positive")
112
- return output # (b, s, l, d_m)
 
1
+ """Pair-weighted aggregation of Boltz2 MSA states."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from torch import Tensor, nn
8
+
9
+ from . import vb_layers_initialize as init
10
+
11
+
12
+ class PairWeightedAveraging(nn.Module):
13
+ """Update M with pair-derived attention weights while preserving its shape."""
14
+
15
+ def __init__(
16
+ self,
17
+ c_m: int,
18
+ c_z: int,
19
+ c_h: int,
20
+ num_heads: int,
21
+ inf: float = 1e6,
22
+ ) -> None:
23
+ super().__init__()
24
+ self.c_m = c_m
25
+ self.c_z = c_z
26
+ self.c_h = c_h
27
+ self.num_heads = num_heads
28
+ self.inf = inf
29
+ self.norm_m = nn.LayerNorm(c_m)
30
+ self.norm_z = nn.LayerNorm(c_z)
31
+ self.proj_m = nn.Linear(c_m, c_h * num_heads, bias=False)
32
+ self.proj_g = nn.Linear(c_m, c_h * num_heads, bias=False)
33
+ self.proj_z = nn.Linear(c_z, num_heads, bias=False)
34
+ self.proj_o = nn.Linear(c_h * num_heads, c_m, bias=False)
35
+ init.final_init_(self.proj_o.weight)
36
+
37
+ def _attention_weights(self, pair_states: Tensor, mask: Tensor) -> Tensor:
38
+ # pair_states: (b, l, l, d_z); mask: (b, l, l).
39
+ logits = self.proj_z(pair_states).permute(0, 3, 1, 2) # (b, h, l, l)
40
+ logits = logits + (1 - mask[:, None]) * -self.inf # (b, h, l, l)
41
+ return torch.softmax(logits, dim=-1) # (b, h, l, l)
42
+
43
+ def _all_heads(self, msa_states: Tensor, pair_states: Tensor, mask: Tensor) -> Tensor:
44
+ # msa_states: (b, s, l, d_m); pair_states: (b, l, l, d_z).
45
+ values = self.proj_m(msa_states).reshape(
46
+ *msa_states.shape[:3],
47
+ self.num_heads,
48
+ self.c_h,
49
+ ) # (b, s, l, h, d_h)
50
+ values = values.permute(0, 3, 1, 2, 4) # (b, h, s, l, d_h)
51
+ weights = self._attention_weights(pair_states, mask) # (b, h, l, l)
52
+ gate = self.proj_g(msa_states).sigmoid() # (b, s, l, h * d_h)
53
+ attended = torch.einsum(
54
+ "bhij,bhsjd->bhsid", weights, values
55
+ ) # (b, h, s, l, d_h)
56
+ attended = attended.permute(0, 2, 3, 1, 4) # (b, s, l, h, d_h)
57
+ attended = attended.reshape(
58
+ *attended.shape[:3], self.num_heads * self.c_h
59
+ ) # (b, s, l, h * d_h)
60
+ return self.proj_o(gate * attended) # (b, s, l, d_m)
61
+
62
+ def _one_head(self, msa_states: Tensor, pair_states: Tensor, mask: Tensor, head: int) -> Tensor:
63
+ # msa_states: (b, s, l, d_m); pair_states: (b, l, l, d_z).
64
+ start = head * self.c_h
65
+ stop = start + self.c_h
66
+ value = msa_states @ self.proj_m.weight[start:stop].T # (b, s, l, d_h)
67
+ value = value.reshape(*value.shape[:3], 1, self.c_h).permute(
68
+ 0, 3, 1, 2, 4
69
+ ) # (b, 1, s, l, d_h)
70
+ logits = pair_states @ self.proj_z.weight[head : head + 1].T # (b, l, l, 1)
71
+ logits = logits.permute(0, 3, 1, 2) # (b, 1, l, l)
72
+ weights = torch.softmax(
73
+ logits + (1 - mask[:, None]) * -self.inf, dim=-1
74
+ ) # (b, 1, l, l)
75
+ gate = (
76
+ msa_states @ self.proj_g.weight[start:stop].T
77
+ ).sigmoid() # (b, s, l, d_h)
78
+ attended = torch.einsum(
79
+ "bhij,bhsjd->bhsid", weights, value
80
+ ) # (b, 1, s, l, d_h)
81
+ attended = attended.permute(0, 2, 3, 1, 4).reshape(
82
+ *msa_states.shape[:3],
83
+ self.c_h,
84
+ ) # (b, s, l, d_h)
85
+ return (gate * attended) @ self.proj_o.weight[:, start:stop].T # (b, s, l, d_m)
86
+
87
+ def forward(
88
+ self,
89
+ m: Tensor,
90
+ z: Tensor,
91
+ mask: Tensor,
92
+ chunk_heads: bool = True,
93
+ ) -> Tensor:
94
+ """Return updated M with shape ``(b, s, l, d_m)``."""
95
+
96
+ # m: (b, s, l, d_m); z: (b, l, l, d_z); mask: (b, l, l).
97
+ msa_states = self.norm_m(m) # (b, s, l, d_m)
98
+ pair_states = self.norm_z(z) # (b, l, l, d_z)
99
+ if not chunk_heads or self.training:
100
+ return self._all_heads(msa_states, pair_states, mask)
101
+
102
+ output: Tensor | None = None
103
+ for head in range(self.num_heads):
104
+ contribution = self._one_head(
105
+ msa_states, pair_states, mask, head
106
+ ) # (b, s, l, d_m)
107
+ output = (
108
+ contribution if output is None else output + contribution
109
+ ) # (b, s, l, d_m)
110
+ if output is None:
111
+ raise ValueError("num_heads must be positive")
112
+ return output # (b, s, l, d_m)
fastplms/models/boltz/vb_layers_pairformer.py CHANGED
@@ -1,350 +1,350 @@
1
- """Pairformer layers for joint or pair-only Boltz2 representations."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from typing import Any, cast
8
- from torch import Tensor, nn
9
- from torch.utils.checkpoint import checkpoint
10
-
11
- from . import vb_const as const
12
- from .vb_layers_attention import AttentionPairBias
13
- from .vb_layers_attentionv2 import AttentionPairBias as AttentionPairBiasV2
14
- from .vb_layers_dropout import get_dropout_mask
15
- from .vb_layers_transition import Transition
16
- from .vb_layers_triangular_mult import (
17
- TriangleMultiplicationIncoming,
18
- TriangleMultiplicationOutgoing,
19
- )
20
- from .vb_tri_attn_attention import (
21
- TriangleAttentionEndingNode,
22
- TriangleAttentionStartingNode,
23
- )
24
-
25
-
26
- def _pair_update(
27
- modules: Any,
28
- pair_states: Tensor,
29
- pair_mask: Tensor,
30
- chunk_size: int | None,
31
- use_kernels: bool,
32
- use_cuequiv_mul: bool,
33
- use_cuequiv_attn: bool,
34
- ) -> Tensor:
35
- """Apply multiplicative, attention, and transition updates to pair tensor Z."""
36
-
37
- # pair_states: (b, l, l, d_z); pair_mask: (b, l, l).
38
- multiplication_kernel = use_kernels or use_cuequiv_mul
39
- attention_kernel = use_kernels or use_cuequiv_attn
40
- output = pair_states # (b, l, l, d_z)
41
- for update in (modules.tri_mul_out, modules.tri_mul_in):
42
- dropout = get_dropout_mask(
43
- modules.dropout, output, modules.training
44
- ) # broadcastable to (b, l, l, d_z)
45
- output = output + dropout * update(
46
- output,
47
- mask=pair_mask,
48
- use_kernels=multiplication_kernel,
49
- ) # (b, l, l, d_z)
50
-
51
- dropout = get_dropout_mask(
52
- modules.dropout, output, modules.training
53
- ) # broadcastable to (b, l, l, d_z)
54
- output = output + dropout * modules.tri_att_start(
55
- output,
56
- mask=pair_mask,
57
- chunk_size=chunk_size,
58
- use_kernels=attention_kernel,
59
- ) # (b, l, l, d_z)
60
- dropout = get_dropout_mask(
61
- modules.dropout,
62
- output,
63
- modules.training,
64
- columnwise=True,
65
- ) # broadcastable to (b, l, l, d_z)
66
- output = output + dropout * modules.tri_att_end(
67
- output,
68
- mask=pair_mask,
69
- chunk_size=chunk_size,
70
- use_kernels=attention_kernel,
71
- ) # (b, l, l, d_z)
72
- return cast(Tensor, output + modules.transition_z(output)) # (b, l, l, d_z)
73
-
74
-
75
- def _triangle_chunk_size(pair_states: Tensor, training: bool) -> int | None:
76
- if training:
77
- return None
78
- return 128 if pair_states.shape[1] > const.chunk_size_threshold else 512
79
-
80
-
81
- class PairformerLayer(nn.Module):
82
- """Update sequence tensor S and pair tensor Z once."""
83
-
84
- def __init__(
85
- self,
86
- token_s: int,
87
- token_z: int,
88
- num_heads: int = 16,
89
- dropout: float = 0.25,
90
- pairwise_head_width: int = 32,
91
- pairwise_num_heads: int = 4,
92
- post_layer_norm: bool = False,
93
- v2: bool = False,
94
- ) -> None:
95
- super().__init__()
96
- self.token_z = token_z
97
- self.dropout = dropout
98
- self.num_heads = num_heads
99
- self.post_layer_norm = post_layer_norm
100
- self.pre_norm_s = nn.LayerNorm(token_s)
101
- attention_class = AttentionPairBiasV2 if v2 else AttentionPairBias
102
- self.attention = attention_class(token_s, token_z, num_heads)
103
- self.tri_mul_out = TriangleMultiplicationOutgoing(token_z)
104
- self.tri_mul_in = TriangleMultiplicationIncoming(token_z)
105
- self.tri_att_start = TriangleAttentionStartingNode(
106
- token_z,
107
- pairwise_head_width,
108
- pairwise_num_heads,
109
- inf=1e9,
110
- )
111
- self.tri_att_end = TriangleAttentionEndingNode(
112
- token_z,
113
- pairwise_head_width,
114
- pairwise_num_heads,
115
- inf=1e9,
116
- )
117
- self.transition_s = Transition(token_s, token_s * 4)
118
- self.transition_z = Transition(token_z, token_z * 4)
119
- self.s_post_norm = nn.LayerNorm(token_s) if post_layer_norm else nn.Identity()
120
-
121
- def forward(
122
- self,
123
- s: Tensor,
124
- z: Tensor,
125
- mask: Tensor,
126
- pair_mask: Tensor,
127
- chunk_size_tri_attn: int | None = None,
128
- use_kernels: bool = False,
129
- use_cuequiv_mul: bool = False,
130
- use_cuequiv_attn: bool = False,
131
- ) -> tuple[Tensor, Tensor]:
132
- """Return updated ``S: (b, l, d_s)`` and ``Z: (b, l, l, d_z)``."""
133
-
134
- pair_output = _pair_update(
135
- self,
136
- z,
137
- pair_mask,
138
- chunk_size_tri_attn,
139
- use_kernels,
140
- use_cuequiv_mul,
141
- use_cuequiv_attn,
142
- ) # (b, l, l, d_z)
143
- with torch.autocast("cuda", enabled=False):
144
- normalized = self.pre_norm_s(s.float()) # (b, l, d_s)
145
- sequence_output = s.float() + self.attention(
146
- s=normalized,
147
- z=pair_output.float(),
148
- mask=mask.float(),
149
- k_in=normalized,
150
- ) # (b, l, d_s)
151
- sequence_output = (
152
- sequence_output + self.transition_s(sequence_output)
153
- ) # (b, l, d_s)
154
- sequence_output = cast(
155
- Tensor, self.s_post_norm(sequence_output)
156
- ) # (b, l, d_s)
157
- return sequence_output, pair_output # (b, l, d_s), (b, l, l, d_z)
158
-
159
-
160
- class PairformerModule(nn.Module):
161
- """Run a stack of joint sequence and pair updates."""
162
-
163
- def __init__(
164
- self,
165
- token_s: int,
166
- token_z: int,
167
- num_blocks: int,
168
- num_heads: int = 16,
169
- dropout: float = 0.25,
170
- pairwise_head_width: int = 32,
171
- pairwise_num_heads: int = 4,
172
- post_layer_norm: bool = False,
173
- activation_checkpointing: bool = False,
174
- v2: bool = False,
175
- **kwargs: Any,
176
- ) -> None:
177
- del kwargs
178
- super().__init__()
179
- self.token_z = token_z
180
- self.num_blocks = num_blocks
181
- self.dropout = dropout
182
- self.num_heads = num_heads
183
- self.post_layer_norm = post_layer_norm
184
- self.activation_checkpointing = activation_checkpointing
185
- self.layers = nn.ModuleList(
186
- [
187
- PairformerLayer(
188
- token_s,
189
- token_z,
190
- num_heads,
191
- dropout,
192
- pairwise_head_width,
193
- pairwise_num_heads,
194
- post_layer_norm,
195
- v2,
196
- )
197
- for _ in range(num_blocks)
198
- ]
199
- )
200
-
201
- def forward(
202
- self,
203
- s: Tensor,
204
- z: Tensor,
205
- mask: Tensor,
206
- pair_mask: Tensor,
207
- use_kernels: bool = False,
208
- ) -> tuple[Tensor, Tensor]:
209
- """Return S and Z after every configured pairformer block."""
210
-
211
- # s: (b, l, d_s); z: (b, l, l, d_z).
212
- chunk_size = _triangle_chunk_size(z, self.training)
213
- sequence_output, pair_output = s, z # (b, l, d_s), (b, l, l, d_z)
214
- for layer in self.layers:
215
- if self.activation_checkpointing:
216
- sequence_output, pair_output = checkpoint(
217
- layer,
218
- sequence_output,
219
- pair_output,
220
- mask,
221
- pair_mask,
222
- chunk_size,
223
- use_kernels,
224
- use_reentrant=False,
225
- ) # (b, l, d_s), (b, l, l, d_z)
226
- else:
227
- sequence_output, pair_output = layer(
228
- sequence_output,
229
- pair_output,
230
- mask,
231
- pair_mask,
232
- chunk_size,
233
- use_kernels,
234
- ) # (b, l, d_s), (b, l, l, d_z)
235
- return sequence_output, pair_output # (b, l, d_s), (b, l, l, d_z)
236
-
237
-
238
- class PairformerNoSeqLayer(nn.Module):
239
- """Update pair tensor Z without a sequence track."""
240
-
241
- def __init__(
242
- self,
243
- token_z: int,
244
- dropout: float = 0.25,
245
- pairwise_head_width: int = 32,
246
- pairwise_num_heads: int = 4,
247
- post_layer_norm: bool = False,
248
- ) -> None:
249
- super().__init__()
250
- self.token_z = token_z
251
- self.dropout = dropout
252
- self.post_layer_norm = post_layer_norm
253
- self.tri_mul_out = TriangleMultiplicationOutgoing(token_z)
254
- self.tri_mul_in = TriangleMultiplicationIncoming(token_z)
255
- self.tri_att_start = TriangleAttentionStartingNode(
256
- token_z,
257
- pairwise_head_width,
258
- pairwise_num_heads,
259
- inf=1e9,
260
- )
261
- self.tri_att_end = TriangleAttentionEndingNode(
262
- token_z,
263
- pairwise_head_width,
264
- pairwise_num_heads,
265
- inf=1e9,
266
- )
267
- self.transition_z = Transition(token_z, token_z * 4)
268
-
269
- def forward(
270
- self,
271
- z: Tensor,
272
- pair_mask: Tensor,
273
- chunk_size_tri_attn: int | None = None,
274
- use_kernels: bool = False,
275
- use_cuequiv_mul: bool = False,
276
- use_cuequiv_attn: bool = False,
277
- ) -> Tensor:
278
- """Return updated pair tensor Z with shape ``(b, l, l, d_z)``."""
279
-
280
- return _pair_update(
281
- self,
282
- z,
283
- pair_mask,
284
- chunk_size_tri_attn,
285
- use_kernels,
286
- use_cuequiv_mul,
287
- use_cuequiv_attn,
288
- )
289
-
290
-
291
- class PairformerNoSeqModule(nn.Module):
292
- """Run a stack of pair-only updates."""
293
-
294
- def __init__(
295
- self,
296
- token_z: int,
297
- num_blocks: int,
298
- dropout: float = 0.25,
299
- pairwise_head_width: int = 32,
300
- pairwise_num_heads: int = 4,
301
- post_layer_norm: bool = False,
302
- activation_checkpointing: bool = False,
303
- **kwargs: Any,
304
- ) -> None:
305
- del kwargs
306
- super().__init__()
307
- self.token_z = token_z
308
- self.num_blocks = num_blocks
309
- self.dropout = dropout
310
- self.post_layer_norm = post_layer_norm
311
- self.activation_checkpointing = activation_checkpointing
312
- self.layers = nn.ModuleList(
313
- [
314
- PairformerNoSeqLayer(
315
- token_z,
316
- dropout,
317
- pairwise_head_width,
318
- pairwise_num_heads,
319
- post_layer_norm,
320
- )
321
- for _ in range(num_blocks)
322
- ]
323
- )
324
-
325
- def forward(
326
- self,
327
- z: Tensor,
328
- pair_mask: Tensor,
329
- use_kernels: bool = False,
330
- ) -> Tensor:
331
- """Return Z after every configured pair-only block."""
332
-
333
- # z: (b, l, l, d_z); pair_mask: (b, l, l).
334
- chunk_size = _triangle_chunk_size(z, self.training)
335
- output = z # (b, l, l, d_z)
336
- for layer in self.layers:
337
- if self.activation_checkpointing:
338
- output = checkpoint(
339
- layer,
340
- output,
341
- pair_mask,
342
- chunk_size,
343
- use_kernels,
344
- use_reentrant=False,
345
- ) # (b, l, l, d_z)
346
- else:
347
- output = layer(
348
- output, pair_mask, chunk_size, use_kernels
349
- ) # (b, l, l, d_z)
350
- return output # (b, l, l, d_z)
 
1
+ """Pairformer layers for joint or pair-only Boltz2 representations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from typing import Any, cast
8
+ from torch import Tensor, nn
9
+ from torch.utils.checkpoint import checkpoint
10
+
11
+ from . import vb_const as const
12
+ from .vb_layers_attention import AttentionPairBias
13
+ from .vb_layers_attentionv2 import AttentionPairBias as AttentionPairBiasV2
14
+ from .vb_layers_dropout import get_dropout_mask
15
+ from .vb_layers_transition import Transition
16
+ from .vb_layers_triangular_mult import (
17
+ TriangleMultiplicationIncoming,
18
+ TriangleMultiplicationOutgoing,
19
+ )
20
+ from .vb_tri_attn_attention import (
21
+ TriangleAttentionEndingNode,
22
+ TriangleAttentionStartingNode,
23
+ )
24
+
25
+
26
+ def _pair_update(
27
+ modules: Any,
28
+ pair_states: Tensor,
29
+ pair_mask: Tensor,
30
+ chunk_size: int | None,
31
+ use_kernels: bool,
32
+ use_cuequiv_mul: bool,
33
+ use_cuequiv_attn: bool,
34
+ ) -> Tensor:
35
+ """Apply multiplicative, attention, and transition updates to pair tensor Z."""
36
+
37
+ # pair_states: (b, l, l, d_z); pair_mask: (b, l, l).
38
+ multiplication_kernel = use_kernels or use_cuequiv_mul
39
+ attention_kernel = use_kernels or use_cuequiv_attn
40
+ output = pair_states # (b, l, l, d_z)
41
+ for update in (modules.tri_mul_out, modules.tri_mul_in):
42
+ dropout = get_dropout_mask(
43
+ modules.dropout, output, modules.training
44
+ ) # broadcastable to (b, l, l, d_z)
45
+ output = output + dropout * update(
46
+ output,
47
+ mask=pair_mask,
48
+ use_kernels=multiplication_kernel,
49
+ ) # (b, l, l, d_z)
50
+
51
+ dropout = get_dropout_mask(
52
+ modules.dropout, output, modules.training
53
+ ) # broadcastable to (b, l, l, d_z)
54
+ output = output + dropout * modules.tri_att_start(
55
+ output,
56
+ mask=pair_mask,
57
+ chunk_size=chunk_size,
58
+ use_kernels=attention_kernel,
59
+ ) # (b, l, l, d_z)
60
+ dropout = get_dropout_mask(
61
+ modules.dropout,
62
+ output,
63
+ modules.training,
64
+ columnwise=True,
65
+ ) # broadcastable to (b, l, l, d_z)
66
+ output = output + dropout * modules.tri_att_end(
67
+ output,
68
+ mask=pair_mask,
69
+ chunk_size=chunk_size,
70
+ use_kernels=attention_kernel,
71
+ ) # (b, l, l, d_z)
72
+ return cast(Tensor, output + modules.transition_z(output)) # (b, l, l, d_z)
73
+
74
+
75
+ def _triangle_chunk_size(pair_states: Tensor, training: bool) -> int | None:
76
+ if training:
77
+ return None
78
+ return 128 if pair_states.shape[1] > const.chunk_size_threshold else 512
79
+
80
+
81
+ class PairformerLayer(nn.Module):
82
+ """Update sequence tensor S and pair tensor Z once."""
83
+
84
+ def __init__(
85
+ self,
86
+ token_s: int,
87
+ token_z: int,
88
+ num_heads: int = 16,
89
+ dropout: float = 0.25,
90
+ pairwise_head_width: int = 32,
91
+ pairwise_num_heads: int = 4,
92
+ post_layer_norm: bool = False,
93
+ v2: bool = False,
94
+ ) -> None:
95
+ super().__init__()
96
+ self.token_z = token_z
97
+ self.dropout = dropout
98
+ self.num_heads = num_heads
99
+ self.post_layer_norm = post_layer_norm
100
+ self.pre_norm_s = nn.LayerNorm(token_s)
101
+ attention_class = AttentionPairBiasV2 if v2 else AttentionPairBias
102
+ self.attention = attention_class(token_s, token_z, num_heads)
103
+ self.tri_mul_out = TriangleMultiplicationOutgoing(token_z)
104
+ self.tri_mul_in = TriangleMultiplicationIncoming(token_z)
105
+ self.tri_att_start = TriangleAttentionStartingNode(
106
+ token_z,
107
+ pairwise_head_width,
108
+ pairwise_num_heads,
109
+ inf=1e9,
110
+ )
111
+ self.tri_att_end = TriangleAttentionEndingNode(
112
+ token_z,
113
+ pairwise_head_width,
114
+ pairwise_num_heads,
115
+ inf=1e9,
116
+ )
117
+ self.transition_s = Transition(token_s, token_s * 4)
118
+ self.transition_z = Transition(token_z, token_z * 4)
119
+ self.s_post_norm = nn.LayerNorm(token_s) if post_layer_norm else nn.Identity()
120
+
121
+ def forward(
122
+ self,
123
+ s: Tensor,
124
+ z: Tensor,
125
+ mask: Tensor,
126
+ pair_mask: Tensor,
127
+ chunk_size_tri_attn: int | None = None,
128
+ use_kernels: bool = False,
129
+ use_cuequiv_mul: bool = False,
130
+ use_cuequiv_attn: bool = False,
131
+ ) -> tuple[Tensor, Tensor]:
132
+ """Return updated ``S: (b, l, d_s)`` and ``Z: (b, l, l, d_z)``."""
133
+
134
+ pair_output = _pair_update(
135
+ self,
136
+ z,
137
+ pair_mask,
138
+ chunk_size_tri_attn,
139
+ use_kernels,
140
+ use_cuequiv_mul,
141
+ use_cuequiv_attn,
142
+ ) # (b, l, l, d_z)
143
+ with torch.autocast("cuda", enabled=False):
144
+ normalized = self.pre_norm_s(s.float()) # (b, l, d_s)
145
+ sequence_output = s.float() + self.attention(
146
+ s=normalized,
147
+ z=pair_output.float(),
148
+ mask=mask.float(),
149
+ k_in=normalized,
150
+ ) # (b, l, d_s)
151
+ sequence_output = (
152
+ sequence_output + self.transition_s(sequence_output)
153
+ ) # (b, l, d_s)
154
+ sequence_output = cast(
155
+ Tensor, self.s_post_norm(sequence_output)
156
+ ) # (b, l, d_s)
157
+ return sequence_output, pair_output # (b, l, d_s), (b, l, l, d_z)
158
+
159
+
160
+ class PairformerModule(nn.Module):
161
+ """Run a stack of joint sequence and pair updates."""
162
+
163
+ def __init__(
164
+ self,
165
+ token_s: int,
166
+ token_z: int,
167
+ num_blocks: int,
168
+ num_heads: int = 16,
169
+ dropout: float = 0.25,
170
+ pairwise_head_width: int = 32,
171
+ pairwise_num_heads: int = 4,
172
+ post_layer_norm: bool = False,
173
+ activation_checkpointing: bool = False,
174
+ v2: bool = False,
175
+ **kwargs: Any,
176
+ ) -> None:
177
+ del kwargs
178
+ super().__init__()
179
+ self.token_z = token_z
180
+ self.num_blocks = num_blocks
181
+ self.dropout = dropout
182
+ self.num_heads = num_heads
183
+ self.post_layer_norm = post_layer_norm
184
+ self.activation_checkpointing = activation_checkpointing
185
+ self.layers = nn.ModuleList(
186
+ [
187
+ PairformerLayer(
188
+ token_s,
189
+ token_z,
190
+ num_heads,
191
+ dropout,
192
+ pairwise_head_width,
193
+ pairwise_num_heads,
194
+ post_layer_norm,
195
+ v2,
196
+ )
197
+ for _ in range(num_blocks)
198
+ ]
199
+ )
200
+
201
+ def forward(
202
+ self,
203
+ s: Tensor,
204
+ z: Tensor,
205
+ mask: Tensor,
206
+ pair_mask: Tensor,
207
+ use_kernels: bool = False,
208
+ ) -> tuple[Tensor, Tensor]:
209
+ """Return S and Z after every configured pairformer block."""
210
+
211
+ # s: (b, l, d_s); z: (b, l, l, d_z).
212
+ chunk_size = _triangle_chunk_size(z, self.training)
213
+ sequence_output, pair_output = s, z # (b, l, d_s), (b, l, l, d_z)
214
+ for layer in self.layers:
215
+ if self.activation_checkpointing:
216
+ sequence_output, pair_output = checkpoint(
217
+ layer,
218
+ sequence_output,
219
+ pair_output,
220
+ mask,
221
+ pair_mask,
222
+ chunk_size,
223
+ use_kernels,
224
+ use_reentrant=False,
225
+ ) # (b, l, d_s), (b, l, l, d_z)
226
+ else:
227
+ sequence_output, pair_output = layer(
228
+ sequence_output,
229
+ pair_output,
230
+ mask,
231
+ pair_mask,
232
+ chunk_size,
233
+ use_kernels,
234
+ ) # (b, l, d_s), (b, l, l, d_z)
235
+ return sequence_output, pair_output # (b, l, d_s), (b, l, l, d_z)
236
+
237
+
238
+ class PairformerNoSeqLayer(nn.Module):
239
+ """Update pair tensor Z without a sequence track."""
240
+
241
+ def __init__(
242
+ self,
243
+ token_z: int,
244
+ dropout: float = 0.25,
245
+ pairwise_head_width: int = 32,
246
+ pairwise_num_heads: int = 4,
247
+ post_layer_norm: bool = False,
248
+ ) -> None:
249
+ super().__init__()
250
+ self.token_z = token_z
251
+ self.dropout = dropout
252
+ self.post_layer_norm = post_layer_norm
253
+ self.tri_mul_out = TriangleMultiplicationOutgoing(token_z)
254
+ self.tri_mul_in = TriangleMultiplicationIncoming(token_z)
255
+ self.tri_att_start = TriangleAttentionStartingNode(
256
+ token_z,
257
+ pairwise_head_width,
258
+ pairwise_num_heads,
259
+ inf=1e9,
260
+ )
261
+ self.tri_att_end = TriangleAttentionEndingNode(
262
+ token_z,
263
+ pairwise_head_width,
264
+ pairwise_num_heads,
265
+ inf=1e9,
266
+ )
267
+ self.transition_z = Transition(token_z, token_z * 4)
268
+
269
+ def forward(
270
+ self,
271
+ z: Tensor,
272
+ pair_mask: Tensor,
273
+ chunk_size_tri_attn: int | None = None,
274
+ use_kernels: bool = False,
275
+ use_cuequiv_mul: bool = False,
276
+ use_cuequiv_attn: bool = False,
277
+ ) -> Tensor:
278
+ """Return updated pair tensor Z with shape ``(b, l, l, d_z)``."""
279
+
280
+ return _pair_update(
281
+ self,
282
+ z,
283
+ pair_mask,
284
+ chunk_size_tri_attn,
285
+ use_kernels,
286
+ use_cuequiv_mul,
287
+ use_cuequiv_attn,
288
+ )
289
+
290
+
291
+ class PairformerNoSeqModule(nn.Module):
292
+ """Run a stack of pair-only updates."""
293
+
294
+ def __init__(
295
+ self,
296
+ token_z: int,
297
+ num_blocks: int,
298
+ dropout: float = 0.25,
299
+ pairwise_head_width: int = 32,
300
+ pairwise_num_heads: int = 4,
301
+ post_layer_norm: bool = False,
302
+ activation_checkpointing: bool = False,
303
+ **kwargs: Any,
304
+ ) -> None:
305
+ del kwargs
306
+ super().__init__()
307
+ self.token_z = token_z
308
+ self.num_blocks = num_blocks
309
+ self.dropout = dropout
310
+ self.post_layer_norm = post_layer_norm
311
+ self.activation_checkpointing = activation_checkpointing
312
+ self.layers = nn.ModuleList(
313
+ [
314
+ PairformerNoSeqLayer(
315
+ token_z,
316
+ dropout,
317
+ pairwise_head_width,
318
+ pairwise_num_heads,
319
+ post_layer_norm,
320
+ )
321
+ for _ in range(num_blocks)
322
+ ]
323
+ )
324
+
325
+ def forward(
326
+ self,
327
+ z: Tensor,
328
+ pair_mask: Tensor,
329
+ use_kernels: bool = False,
330
+ ) -> Tensor:
331
+ """Return Z after every configured pair-only block."""
332
+
333
+ # z: (b, l, l, d_z); pair_mask: (b, l, l).
334
+ chunk_size = _triangle_chunk_size(z, self.training)
335
+ output = z # (b, l, l, d_z)
336
+ for layer in self.layers:
337
+ if self.activation_checkpointing:
338
+ output = checkpoint(
339
+ layer,
340
+ output,
341
+ pair_mask,
342
+ chunk_size,
343
+ use_kernels,
344
+ use_reentrant=False,
345
+ ) # (b, l, l, d_z)
346
+ else:
347
+ output = layer(
348
+ output, pair_mask, chunk_size, use_kernels
349
+ ) # (b, l, l, d_z)
350
+ return output # (b, l, l, d_z)
fastplms/models/boltz/vb_layers_transition.py CHANGED
@@ -1,79 +1,79 @@
1
- """Gated feed-forward transition used by Boltz2 pair representations."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from torch import Tensor, nn
8
-
9
- from . import vb_layers_initialize as init
10
-
11
-
12
- class Transition(nn.Module):
13
- """Apply a normalized SwiGLU-style transition.
14
-
15
- The checkpoint-facing module names remain ``norm`` and ``fc1`` through
16
- ``fc3``. For an input tensor X with shape ``(..., d)``, the module returns
17
- a tensor with shape ``(..., d_out)``.
18
- """
19
-
20
- def __init__(
21
- self,
22
- dim: int = 128,
23
- hidden: int = 512,
24
- out_dim: int | None = None,
25
- ) -> None:
26
- super().__init__()
27
- output_dim = dim if out_dim is None else out_dim
28
- self.norm = nn.LayerNorm(dim, eps=1e-5)
29
- self.fc1 = nn.Linear(dim, hidden, bias=False)
30
- self.fc2 = nn.Linear(dim, hidden, bias=False)
31
- self.fc3 = nn.Linear(hidden, output_dim, bias=False)
32
- self.silu = nn.SiLU()
33
- self.hidden = hidden
34
-
35
- init.bias_init_one_(self.norm.weight)
36
- init.bias_init_zero_(self.norm.bias)
37
- init.lecun_normal_init_(self.fc1.weight)
38
- init.lecun_normal_init_(self.fc2.weight)
39
- init.final_init_(self.fc3.weight)
40
-
41
- def _project_hidden_slice(self, normalized: Tensor, start: int, stop: int) -> Tensor:
42
- """Return one hidden-width contribution to the output projection."""
43
-
44
- # normalized: (..., d); the selected hidden width is d_chunk.
45
- gate = self.silu(
46
- torch.matmul(normalized, self.fc1.weight[start:stop].T)
47
- ) # (..., d_chunk)
48
- value = torch.matmul(
49
- normalized, self.fc2.weight[start:stop].T
50
- ) # (..., d_chunk)
51
- return torch.matmul(
52
- gate * value, self.fc3.weight[:, start:stop].T
53
- ) # (..., d_out)
54
-
55
- def forward(self, x: Tensor, chunk_size: int | None = None) -> Tensor:
56
- """Transform X, optionally accumulating the hidden dimension in chunks."""
57
-
58
- # X is the normalized input tensor with shape (..., d).
59
- normalized = self.norm(x) # (..., d)
60
- if chunk_size is None or self.training:
61
- # H is the gated hidden tensor with shape (..., d_hidden).
62
- hidden_states = self.silu(self.fc1(normalized)) * self.fc2(normalized)
63
- return self.fc3(hidden_states) # (..., d_out)
64
- if chunk_size <= 0:
65
- raise ValueError("chunk_size must be positive")
66
-
67
- output: Tensor | None = None
68
- for start in range(0, self.hidden, chunk_size):
69
- contribution = self._project_hidden_slice(
70
- normalized,
71
- start,
72
- min(start + chunk_size, self.hidden),
73
- ) # (..., d_out)
74
- output = (
75
- contribution if output is None else output + contribution
76
- ) # (..., d_out)
77
- if output is None: # hidden is normally positive; keep malformed configs explicit.
78
- raise ValueError("hidden must be positive")
79
- return output # (..., d_out)
 
1
+ """Gated feed-forward transition used by Boltz2 pair representations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from torch import Tensor, nn
8
+
9
+ from . import vb_layers_initialize as init
10
+
11
+
12
+ class Transition(nn.Module):
13
+ """Apply a normalized SwiGLU-style transition.
14
+
15
+ The checkpoint-facing module names remain ``norm`` and ``fc1`` through
16
+ ``fc3``. For an input tensor X with shape ``(..., d)``, the module returns
17
+ a tensor with shape ``(..., d_out)``.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ dim: int = 128,
23
+ hidden: int = 512,
24
+ out_dim: int | None = None,
25
+ ) -> None:
26
+ super().__init__()
27
+ output_dim = dim if out_dim is None else out_dim
28
+ self.norm = nn.LayerNorm(dim, eps=1e-5)
29
+ self.fc1 = nn.Linear(dim, hidden, bias=False)
30
+ self.fc2 = nn.Linear(dim, hidden, bias=False)
31
+ self.fc3 = nn.Linear(hidden, output_dim, bias=False)
32
+ self.silu = nn.SiLU()
33
+ self.hidden = hidden
34
+
35
+ init.bias_init_one_(self.norm.weight)
36
+ init.bias_init_zero_(self.norm.bias)
37
+ init.lecun_normal_init_(self.fc1.weight)
38
+ init.lecun_normal_init_(self.fc2.weight)
39
+ init.final_init_(self.fc3.weight)
40
+
41
+ def _project_hidden_slice(self, normalized: Tensor, start: int, stop: int) -> Tensor:
42
+ """Return one hidden-width contribution to the output projection."""
43
+
44
+ # normalized: (..., d); the selected hidden width is d_chunk.
45
+ gate = self.silu(
46
+ torch.matmul(normalized, self.fc1.weight[start:stop].T)
47
+ ) # (..., d_chunk)
48
+ value = torch.matmul(
49
+ normalized, self.fc2.weight[start:stop].T
50
+ ) # (..., d_chunk)
51
+ return torch.matmul(
52
+ gate * value, self.fc3.weight[:, start:stop].T
53
+ ) # (..., d_out)
54
+
55
+ def forward(self, x: Tensor, chunk_size: int | None = None) -> Tensor:
56
+ """Transform X, optionally accumulating the hidden dimension in chunks."""
57
+
58
+ # X is the normalized input tensor with shape (..., d).
59
+ normalized = self.norm(x) # (..., d)
60
+ if chunk_size is None or self.training:
61
+ # H is the gated hidden tensor with shape (..., d_hidden).
62
+ hidden_states = self.silu(self.fc1(normalized)) * self.fc2(normalized)
63
+ return self.fc3(hidden_states) # (..., d_out)
64
+ if chunk_size <= 0:
65
+ raise ValueError("chunk_size must be positive")
66
+
67
+ output: Tensor | None = None
68
+ for start in range(0, self.hidden, chunk_size):
69
+ contribution = self._project_hidden_slice(
70
+ normalized,
71
+ start,
72
+ min(start + chunk_size, self.hidden),
73
+ ) # (..., d_out)
74
+ output = (
75
+ contribution if output is None else output + contribution
76
+ ) # (..., d_out)
77
+ if output is None: # hidden is normally positive; keep malformed configs explicit.
78
+ raise ValueError("hidden must be positive")
79
+ return output # (..., d_out)
fastplms/models/boltz/vb_layers_triangular_mult.py CHANGED
@@ -1,132 +1,132 @@
1
- """Incoming and outgoing triangular multiplicative pair updates."""
2
-
3
- from __future__ import annotations
4
-
5
- import importlib
6
- import torch
7
-
8
- from importlib.util import find_spec
9
- from typing import Literal
10
- from torch import Tensor, nn
11
-
12
- from . import vb_layers_initialize as init
13
-
14
-
15
- TriangleDirection = Literal["incoming", "outgoing"]
16
-
17
-
18
- @torch.compiler.disable
19
- def kernel_triangular_mult(
20
- x: Tensor,
21
- direction: TriangleDirection,
22
- mask: Tensor,
23
- norm_in_weight: Tensor,
24
- norm_in_bias: Tensor,
25
- p_in_weight: Tensor,
26
- g_in_weight: Tensor,
27
- norm_out_weight: Tensor,
28
- norm_out_bias: Tensor,
29
- p_out_weight: Tensor,
30
- g_out_weight: Tensor,
31
- eps: float,
32
- ) -> Tensor:
33
- """Dispatch the optional cuEquivariance triangle primitive lazily."""
34
-
35
- # x: (b, l, l, d); mask: (b, l, l); returned tensor: (b, l, l, d).
36
- if (
37
- find_spec("cuequivariance_torch") is None
38
- or find_spec("cuequivariance_ops_torch") is None
39
- ):
40
- raise RuntimeError(
41
- "Boltz2 use_kernels=True requires cuequivariance_torch and the CUDA 13 "
42
- "cuequivariance_ops_torch runtime from the 'structure,cueq' extras."
43
- )
44
- cueq = importlib.import_module("cuequivariance_torch")
45
- return cueq.triangle_multiplicative_update(
46
- x,
47
- direction=direction,
48
- mask=mask,
49
- norm_in_weight=norm_in_weight,
50
- norm_in_bias=norm_in_bias,
51
- p_in_weight=p_in_weight,
52
- g_in_weight=g_in_weight,
53
- norm_out_weight=norm_out_weight,
54
- norm_out_bias=norm_out_bias,
55
- p_out_weight=p_out_weight,
56
- g_out_weight=g_out_weight,
57
- eps=eps,
58
- )
59
-
60
-
61
- class _TriangleMultiplication(nn.Module):
62
- direction: TriangleDirection
63
- equation: str
64
-
65
- def __init__(self, dim: int, direction: TriangleDirection, equation: str) -> None:
66
- super().__init__()
67
- self.direction = direction
68
- self.equation = equation
69
- self.norm_in = nn.LayerNorm(dim, eps=1e-5)
70
- self.p_in = nn.Linear(dim, 2 * dim, bias=False)
71
- self.g_in = nn.Linear(dim, 2 * dim, bias=False)
72
- self.norm_out = nn.LayerNorm(dim)
73
- self.p_out = nn.Linear(dim, dim, bias=False)
74
- self.g_out = nn.Linear(dim, dim, bias=False)
75
-
76
- init.bias_init_one_(self.norm_in.weight)
77
- init.bias_init_zero_(self.norm_in.bias)
78
- init.lecun_normal_init_(self.p_in.weight)
79
- init.gating_init_(self.g_in.weight)
80
- init.bias_init_one_(self.norm_out.weight)
81
- init.bias_init_zero_(self.norm_out.bias)
82
- init.final_init_(self.p_out.weight)
83
- init.gating_init_(self.g_out.weight)
84
-
85
- def _kernel_forward(self, pair_states: Tensor, mask: Tensor) -> Tensor:
86
- # pair_states: (b, l, l, d); mask: (b, l, l).
87
- return kernel_triangular_mult(
88
- pair_states,
89
- direction=self.direction,
90
- mask=mask,
91
- norm_in_weight=self.norm_in.weight,
92
- norm_in_bias=self.norm_in.bias,
93
- p_in_weight=self.p_in.weight,
94
- g_in_weight=self.g_in.weight,
95
- norm_out_weight=self.norm_out.weight,
96
- norm_out_bias=self.norm_out.bias,
97
- p_out_weight=self.p_out.weight,
98
- g_out_weight=self.g_out.weight,
99
- eps=1e-5,
100
- ) # (b, l, l, d)
101
-
102
- def forward(self, x: Tensor, mask: Tensor, use_kernels: bool = False) -> Tensor:
103
- """Transform pair tensor X with shape ``(b, l, l, d)``."""
104
-
105
- if use_kernels:
106
- return self._kernel_forward(x, mask)
107
-
108
- # X_norm is the normalized pair tensor used by the output gate.
109
- normalized = self.norm_in(x) # (b, l, l, d)
110
- projected = (
111
- self.p_in(normalized) * self.g_in(normalized).sigmoid()
112
- ) # (b, l, l, 2 * d)
113
- projected = projected * mask.unsqueeze(-1) # (b, l, l, 2 * d)
114
- left, right = torch.chunk(projected.float(), 2, dim=-1) # each: (b, l, l, d)
115
- combined = torch.einsum(self.equation, left, right) # (b, l, l, d)
116
- return (
117
- self.p_out(self.norm_out(combined)) * self.g_out(normalized).sigmoid()
118
- ) # (b, l, l, d)
119
-
120
-
121
- class TriangleMultiplicationOutgoing(_TriangleMultiplication):
122
- """Aggregate pair paths that share their destination index."""
123
-
124
- def __init__(self, dim: int = 128) -> None:
125
- super().__init__(dim, direction="outgoing", equation="bikd,bjkd->bijd")
126
-
127
-
128
- class TriangleMultiplicationIncoming(_TriangleMultiplication):
129
- """Aggregate pair paths that share their source index."""
130
-
131
- def __init__(self, dim: int = 128) -> None:
132
- super().__init__(dim, direction="incoming", equation="bkid,bkjd->bijd")
 
1
+ """Incoming and outgoing triangular multiplicative pair updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import torch
7
+
8
+ from importlib.util import find_spec
9
+ from typing import Literal
10
+ from torch import Tensor, nn
11
+
12
+ from . import vb_layers_initialize as init
13
+
14
+
15
+ TriangleDirection = Literal["incoming", "outgoing"]
16
+
17
+
18
+ @torch.compiler.disable
19
+ def kernel_triangular_mult(
20
+ x: Tensor,
21
+ direction: TriangleDirection,
22
+ mask: Tensor,
23
+ norm_in_weight: Tensor,
24
+ norm_in_bias: Tensor,
25
+ p_in_weight: Tensor,
26
+ g_in_weight: Tensor,
27
+ norm_out_weight: Tensor,
28
+ norm_out_bias: Tensor,
29
+ p_out_weight: Tensor,
30
+ g_out_weight: Tensor,
31
+ eps: float,
32
+ ) -> Tensor:
33
+ """Dispatch the optional cuEquivariance triangle primitive lazily."""
34
+
35
+ # x: (b, l, l, d); mask: (b, l, l); returned tensor: (b, l, l, d).
36
+ if (
37
+ find_spec("cuequivariance_torch") is None
38
+ or find_spec("cuequivariance_ops_torch") is None
39
+ ):
40
+ raise RuntimeError(
41
+ "Boltz2 use_kernels=True requires cuequivariance_torch and the CUDA 13 "
42
+ "cuequivariance_ops_torch runtime from the 'structure,cueq' extras."
43
+ )
44
+ cueq = importlib.import_module("cuequivariance_torch")
45
+ return cueq.triangle_multiplicative_update(
46
+ x,
47
+ direction=direction,
48
+ mask=mask,
49
+ norm_in_weight=norm_in_weight,
50
+ norm_in_bias=norm_in_bias,
51
+ p_in_weight=p_in_weight,
52
+ g_in_weight=g_in_weight,
53
+ norm_out_weight=norm_out_weight,
54
+ norm_out_bias=norm_out_bias,
55
+ p_out_weight=p_out_weight,
56
+ g_out_weight=g_out_weight,
57
+ eps=eps,
58
+ )
59
+
60
+
61
+ class _TriangleMultiplication(nn.Module):
62
+ direction: TriangleDirection
63
+ equation: str
64
+
65
+ def __init__(self, dim: int, direction: TriangleDirection, equation: str) -> None:
66
+ super().__init__()
67
+ self.direction = direction
68
+ self.equation = equation
69
+ self.norm_in = nn.LayerNorm(dim, eps=1e-5)
70
+ self.p_in = nn.Linear(dim, 2 * dim, bias=False)
71
+ self.g_in = nn.Linear(dim, 2 * dim, bias=False)
72
+ self.norm_out = nn.LayerNorm(dim)
73
+ self.p_out = nn.Linear(dim, dim, bias=False)
74
+ self.g_out = nn.Linear(dim, dim, bias=False)
75
+
76
+ init.bias_init_one_(self.norm_in.weight)
77
+ init.bias_init_zero_(self.norm_in.bias)
78
+ init.lecun_normal_init_(self.p_in.weight)
79
+ init.gating_init_(self.g_in.weight)
80
+ init.bias_init_one_(self.norm_out.weight)
81
+ init.bias_init_zero_(self.norm_out.bias)
82
+ init.final_init_(self.p_out.weight)
83
+ init.gating_init_(self.g_out.weight)
84
+
85
+ def _kernel_forward(self, pair_states: Tensor, mask: Tensor) -> Tensor:
86
+ # pair_states: (b, l, l, d); mask: (b, l, l).
87
+ return kernel_triangular_mult(
88
+ pair_states,
89
+ direction=self.direction,
90
+ mask=mask,
91
+ norm_in_weight=self.norm_in.weight,
92
+ norm_in_bias=self.norm_in.bias,
93
+ p_in_weight=self.p_in.weight,
94
+ g_in_weight=self.g_in.weight,
95
+ norm_out_weight=self.norm_out.weight,
96
+ norm_out_bias=self.norm_out.bias,
97
+ p_out_weight=self.p_out.weight,
98
+ g_out_weight=self.g_out.weight,
99
+ eps=1e-5,
100
+ ) # (b, l, l, d)
101
+
102
+ def forward(self, x: Tensor, mask: Tensor, use_kernels: bool = False) -> Tensor:
103
+ """Transform pair tensor X with shape ``(b, l, l, d)``."""
104
+
105
+ if use_kernels:
106
+ return self._kernel_forward(x, mask)
107
+
108
+ # X_norm is the normalized pair tensor used by the output gate.
109
+ normalized = self.norm_in(x) # (b, l, l, d)
110
+ projected = (
111
+ self.p_in(normalized) * self.g_in(normalized).sigmoid()
112
+ ) # (b, l, l, 2 * d)
113
+ projected = projected * mask.unsqueeze(-1) # (b, l, l, 2 * d)
114
+ left, right = torch.chunk(projected.float(), 2, dim=-1) # each: (b, l, l, d)
115
+ combined = torch.einsum(self.equation, left, right) # (b, l, l, d)
116
+ return (
117
+ self.p_out(self.norm_out(combined)) * self.g_out(normalized).sigmoid()
118
+ ) # (b, l, l, d)
119
+
120
+
121
+ class TriangleMultiplicationOutgoing(_TriangleMultiplication):
122
+ """Aggregate pair paths that share their destination index."""
123
+
124
+ def __init__(self, dim: int = 128) -> None:
125
+ super().__init__(dim, direction="outgoing", equation="bikd,bjkd->bijd")
126
+
127
+
128
+ class TriangleMultiplicationIncoming(_TriangleMultiplication):
129
+ """Aggregate pair paths that share their source index."""
130
+
131
+ def __init__(self, dim: int = 128) -> None:
132
+ super().__init__(dim, direction="incoming", equation="bkid,bkjd->bijd")
fastplms/models/boltz/vb_loss_diffusionv2.py CHANGED
@@ -1,186 +1,186 @@
1
- """Geometry objectives shared by Boltz diffusion and steering code.
2
-
3
- The rigid-alignment mechanism is based on the Kabsch formulation used by
4
- AlphaFold 3 implementations. The implementation is maintained locally and
5
- does not import an upstream runtime package.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import warnings
11
- import torch
12
- import torch.nn.functional as functional
13
-
14
- from einops import einsum
15
-
16
-
17
- def _weighted_centroid(
18
- coordinates: torch.Tensor,
19
- weights: torch.Tensor,
20
- ) -> torch.Tensor:
21
- # coordinates: (..., n, 3); weights: (..., n, 1).
22
- return (coordinates * weights).sum(dim=-2, keepdim=True) / weights.sum(
23
- dim=-2,
24
- keepdim=True,
25
- ) # (..., 1, 3)
26
-
27
-
28
- def _warn_if_alignment_is_ambiguous(
29
- mask: torch.Tensor,
30
- singular_values: torch.Tensor,
31
- *,
32
- num_points: int,
33
- coordinate_dim: int,
34
- ) -> None:
35
- if torch.any(mask.sum(dim=-1) < coordinate_dim + 1):
36
- warnings.warn(
37
- "The size of one of the point clouds is <= dim+1. "
38
- "`WeightedRigidAlign` cannot return a unique rotation.",
39
- RuntimeWarning,
40
- stacklevel=3,
41
- )
42
- if (singular_values.abs() <= 1e-15).any() and num_points >= coordinate_dim + 1:
43
- warnings.warn(
44
- "Excessively low rank of cross-correlation between aligned "
45
- "point clouds. `WeightedRigidAlign` cannot return a unique rotation.",
46
- RuntimeWarning,
47
- stacklevel=3,
48
- )
49
-
50
-
51
- def weighted_rigid_align(
52
- true_coords: torch.Tensor,
53
- pred_coords: torch.Tensor,
54
- weights: torch.Tensor,
55
- mask: torch.Tensor,
56
- ) -> torch.Tensor:
57
- """Align true coordinates to predicted coordinates with weighted Kabsch.
58
-
59
- ``true_coords`` and ``pred_coords`` have shape ``(..., n, 3)``. The
60
- returned tensor is detached because alignment defines a fixed target for
61
- the diffusion loss.
62
- """
63
-
64
- output_shape = torch.broadcast_shapes(true_coords.shape, pred_coords.shape)
65
- *batch_shape, num_points, coordinate_dim = output_shape
66
- point_weights = (mask * weights).unsqueeze(-1) # (..., n, 1)
67
-
68
- true_centroid = _weighted_centroid(true_coords, point_weights) # (..., 1, 3)
69
- pred_centroid = _weighted_centroid(pred_coords, point_weights) # (..., 1, 3)
70
- true_centered = true_coords - true_centroid # (..., n, 3)
71
- pred_centered = pred_coords - pred_centroid # (..., n, 3)
72
-
73
- covariance = einsum(
74
- point_weights * pred_centered,
75
- true_centered,
76
- "... n i, ... n j -> ... i j",
77
- ) # (..., 3, 3)
78
- original_dtype = covariance.dtype
79
- covariance_fp32 = covariance.to(torch.float32) # (..., 3, 3)
80
- left_vectors, singular_values, right_vectors_h = torch.linalg.svd(
81
- covariance_fp32,
82
- driver="gesvd" if covariance_fp32.is_cuda else None,
83
- ) # left/right: (..., 3, 3); singular_values: (..., 3)
84
- right_vectors = right_vectors_h.mH # (..., 3, 3)
85
- _warn_if_alignment_is_ambiguous(
86
- mask,
87
- singular_values,
88
- num_points=num_points,
89
- coordinate_dim=coordinate_dim,
90
- )
91
-
92
- preliminary_rotation = torch.einsum(
93
- "... i j, ... k j -> ... i k",
94
- left_vectors,
95
- right_vectors,
96
- ).to(torch.float32) # (..., 3, 3)
97
- orientation = torch.eye(
98
- coordinate_dim,
99
- dtype=covariance_fp32.dtype,
100
- device=covariance.device,
101
- )[None].repeat(*batch_shape, 1, 1) # (..., 3, 3)
102
- orientation[..., -1, -1] = torch.det(preliminary_rotation) # (...)
103
- rotation = einsum(
104
- left_vectors,
105
- orientation,
106
- right_vectors,
107
- "... i j, ... j k, ... l k -> ... i l",
108
- ).to(original_dtype) # (..., 3, 3)
109
-
110
- aligned = (
111
- einsum(true_centered, rotation, "... n i, ... j i -> ... n j") + pred_centroid
112
- ) # (..., n, 3)
113
- aligned.detach_()
114
- return aligned # (..., n, 3)
115
-
116
-
117
- def _smooth_lddt_for_example(
118
- pred_coords: torch.Tensor,
119
- true_coords: torch.Tensor,
120
- is_nucleotide: torch.Tensor,
121
- coords_mask: torch.Tensor,
122
- *,
123
- nucleic_acid_cutoff: float,
124
- other_cutoff: float,
125
- ) -> torch.Tensor:
126
- # pred_coords/true_coords: (n, 3); is_nucleotide/coords_mask: (n,).
127
- true_distances = torch.cdist(true_coords, true_coords) # (n, n)
128
- nucleotide_rows = is_nucleotide.bool().unsqueeze(-1).expand_as(
129
- true_distances
130
- ) # (n, n)
131
- pair_mask = torch.where(
132
- nucleotide_rows,
133
- true_distances < nucleic_acid_cutoff,
134
- true_distances < other_cutoff,
135
- ) # (n, n)
136
- pair_mask &= ~torch.eye(
137
- pred_coords.shape[0],
138
- dtype=torch.bool,
139
- device=pred_coords.device,
140
- ) # (n, n)
141
- coordinate_rows = coords_mask.bool() # (n,)
142
- pair_mask &= coordinate_rows.unsqueeze(-1) # (n, n)
143
- pair_mask &= coordinate_rows.unsqueeze(-2) # (n, n)
144
-
145
- pair_indices = pair_mask.nonzero() # (n_pair, 2)
146
- true_pair_distances = true_distances[
147
- pair_indices[:, 0], pair_indices[:, 1]
148
- ] # (n_pair,)
149
- pred_pair_distances = functional.pairwise_distance(
150
- pred_coords[pair_indices[:, 0]],
151
- pred_coords[pair_indices[:, 1]],
152
- ) # (n_pair,)
153
- distance_error = torch.abs(true_pair_distances - pred_pair_distances) # (n_pair,)
154
- smooth_agreement = (
155
- sum(torch.sigmoid(threshold - distance_error) for threshold in (0.5, 1.0, 2.0, 4.0)) / 4.0
156
- ) # (n_pair,)
157
- return smooth_agreement.sum() / (pair_indices.shape[0] + 1e-5) # ()
158
-
159
-
160
- def smooth_lddt_loss(
161
- pred_coords: torch.Tensor,
162
- true_coords: torch.Tensor,
163
- is_nucleotide: torch.Tensor,
164
- coords_mask: torch.Tensor,
165
- nucleic_acid_cutoff: float = 30.0,
166
- other_cutoff: float = 15.0,
167
- multiplicity: int = 1,
168
- ) -> torch.Tensor:
169
- """Return one minus the smooth local-distance agreement.
170
-
171
- Coordinate tensors have shape ``(b, n, 3)``. Sequence-level masks may
172
- be shared across repeated diffusion samples through ``multiplicity``.
173
- """
174
-
175
- agreements = [
176
- _smooth_lddt_for_example(
177
- pred_coords[index],
178
- true_coords[index],
179
- is_nucleotide[index // multiplicity],
180
- coords_mask[index // multiplicity],
181
- nucleic_acid_cutoff=nucleic_acid_cutoff,
182
- other_cutoff=other_cutoff,
183
- )
184
- for index in range(true_coords.shape[0])
185
- ] # each: ()
186
- return 1.0 - torch.stack(agreements).mean(dim=0) # ()
 
1
+ """Geometry objectives shared by Boltz diffusion and steering code.
2
+
3
+ The rigid-alignment mechanism is based on the Kabsch formulation used by
4
+ AlphaFold 3 implementations. The implementation is maintained locally and
5
+ does not import an upstream runtime package.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import warnings
11
+ import torch
12
+ import torch.nn.functional as functional
13
+
14
+ from einops import einsum
15
+
16
+
17
+ def _weighted_centroid(
18
+ coordinates: torch.Tensor,
19
+ weights: torch.Tensor,
20
+ ) -> torch.Tensor:
21
+ # coordinates: (..., n, 3); weights: (..., n, 1).
22
+ return (coordinates * weights).sum(dim=-2, keepdim=True) / weights.sum(
23
+ dim=-2,
24
+ keepdim=True,
25
+ ) # (..., 1, 3)
26
+
27
+
28
+ def _warn_if_alignment_is_ambiguous(
29
+ mask: torch.Tensor,
30
+ singular_values: torch.Tensor,
31
+ *,
32
+ num_points: int,
33
+ coordinate_dim: int,
34
+ ) -> None:
35
+ if torch.any(mask.sum(dim=-1) < coordinate_dim + 1):
36
+ warnings.warn(
37
+ "The size of one of the point clouds is <= dim+1. "
38
+ "`WeightedRigidAlign` cannot return a unique rotation.",
39
+ RuntimeWarning,
40
+ stacklevel=3,
41
+ )
42
+ if (singular_values.abs() <= 1e-15).any() and num_points >= coordinate_dim + 1:
43
+ warnings.warn(
44
+ "Excessively low rank of cross-correlation between aligned "
45
+ "point clouds. `WeightedRigidAlign` cannot return a unique rotation.",
46
+ RuntimeWarning,
47
+ stacklevel=3,
48
+ )
49
+
50
+
51
+ def weighted_rigid_align(
52
+ true_coords: torch.Tensor,
53
+ pred_coords: torch.Tensor,
54
+ weights: torch.Tensor,
55
+ mask: torch.Tensor,
56
+ ) -> torch.Tensor:
57
+ """Align true coordinates to predicted coordinates with weighted Kabsch.
58
+
59
+ ``true_coords`` and ``pred_coords`` have shape ``(..., n, 3)``. The
60
+ returned tensor is detached because alignment defines a fixed target for
61
+ the diffusion loss.
62
+ """
63
+
64
+ output_shape = torch.broadcast_shapes(true_coords.shape, pred_coords.shape)
65
+ *batch_shape, num_points, coordinate_dim = output_shape
66
+ point_weights = (mask * weights).unsqueeze(-1) # (..., n, 1)
67
+
68
+ true_centroid = _weighted_centroid(true_coords, point_weights) # (..., 1, 3)
69
+ pred_centroid = _weighted_centroid(pred_coords, point_weights) # (..., 1, 3)
70
+ true_centered = true_coords - true_centroid # (..., n, 3)
71
+ pred_centered = pred_coords - pred_centroid # (..., n, 3)
72
+
73
+ covariance = einsum(
74
+ point_weights * pred_centered,
75
+ true_centered,
76
+ "... n i, ... n j -> ... i j",
77
+ ) # (..., 3, 3)
78
+ original_dtype = covariance.dtype
79
+ covariance_fp32 = covariance.to(torch.float32) # (..., 3, 3)
80
+ left_vectors, singular_values, right_vectors_h = torch.linalg.svd(
81
+ covariance_fp32,
82
+ driver="gesvd" if covariance_fp32.is_cuda else None,
83
+ ) # left/right: (..., 3, 3); singular_values: (..., 3)
84
+ right_vectors = right_vectors_h.mH # (..., 3, 3)
85
+ _warn_if_alignment_is_ambiguous(
86
+ mask,
87
+ singular_values,
88
+ num_points=num_points,
89
+ coordinate_dim=coordinate_dim,
90
+ )
91
+
92
+ preliminary_rotation = torch.einsum(
93
+ "... i j, ... k j -> ... i k",
94
+ left_vectors,
95
+ right_vectors,
96
+ ).to(torch.float32) # (..., 3, 3)
97
+ orientation = torch.eye(
98
+ coordinate_dim,
99
+ dtype=covariance_fp32.dtype,
100
+ device=covariance.device,
101
+ )[None].repeat(*batch_shape, 1, 1) # (..., 3, 3)
102
+ orientation[..., -1, -1] = torch.det(preliminary_rotation) # (...)
103
+ rotation = einsum(
104
+ left_vectors,
105
+ orientation,
106
+ right_vectors,
107
+ "... i j, ... j k, ... l k -> ... i l",
108
+ ).to(original_dtype) # (..., 3, 3)
109
+
110
+ aligned = (
111
+ einsum(true_centered, rotation, "... n i, ... j i -> ... n j") + pred_centroid
112
+ ) # (..., n, 3)
113
+ aligned.detach_()
114
+ return aligned # (..., n, 3)
115
+
116
+
117
+ def _smooth_lddt_for_example(
118
+ pred_coords: torch.Tensor,
119
+ true_coords: torch.Tensor,
120
+ is_nucleotide: torch.Tensor,
121
+ coords_mask: torch.Tensor,
122
+ *,
123
+ nucleic_acid_cutoff: float,
124
+ other_cutoff: float,
125
+ ) -> torch.Tensor:
126
+ # pred_coords/true_coords: (n, 3); is_nucleotide/coords_mask: (n,).
127
+ true_distances = torch.cdist(true_coords, true_coords) # (n, n)
128
+ nucleotide_rows = is_nucleotide.bool().unsqueeze(-1).expand_as(
129
+ true_distances
130
+ ) # (n, n)
131
+ pair_mask = torch.where(
132
+ nucleotide_rows,
133
+ true_distances < nucleic_acid_cutoff,
134
+ true_distances < other_cutoff,
135
+ ) # (n, n)
136
+ pair_mask &= ~torch.eye(
137
+ pred_coords.shape[0],
138
+ dtype=torch.bool,
139
+ device=pred_coords.device,
140
+ ) # (n, n)
141
+ coordinate_rows = coords_mask.bool() # (n,)
142
+ pair_mask &= coordinate_rows.unsqueeze(-1) # (n, n)
143
+ pair_mask &= coordinate_rows.unsqueeze(-2) # (n, n)
144
+
145
+ pair_indices = pair_mask.nonzero() # (n_pair, 2)
146
+ true_pair_distances = true_distances[
147
+ pair_indices[:, 0], pair_indices[:, 1]
148
+ ] # (n_pair,)
149
+ pred_pair_distances = functional.pairwise_distance(
150
+ pred_coords[pair_indices[:, 0]],
151
+ pred_coords[pair_indices[:, 1]],
152
+ ) # (n_pair,)
153
+ distance_error = torch.abs(true_pair_distances - pred_pair_distances) # (n_pair,)
154
+ smooth_agreement = (
155
+ sum(torch.sigmoid(threshold - distance_error) for threshold in (0.5, 1.0, 2.0, 4.0)) / 4.0
156
+ ) # (n_pair,)
157
+ return smooth_agreement.sum() / (pair_indices.shape[0] + 1e-5) # ()
158
+
159
+
160
+ def smooth_lddt_loss(
161
+ pred_coords: torch.Tensor,
162
+ true_coords: torch.Tensor,
163
+ is_nucleotide: torch.Tensor,
164
+ coords_mask: torch.Tensor,
165
+ nucleic_acid_cutoff: float = 30.0,
166
+ other_cutoff: float = 15.0,
167
+ multiplicity: int = 1,
168
+ ) -> torch.Tensor:
169
+ """Return one minus the smooth local-distance agreement.
170
+
171
+ Coordinate tensors have shape ``(b, n, 3)``. Sequence-level masks may
172
+ be shared across repeated diffusion samples through ``multiplicity``.
173
+ """
174
+
175
+ agreements = [
176
+ _smooth_lddt_for_example(
177
+ pred_coords[index],
178
+ true_coords[index],
179
+ is_nucleotide[index // multiplicity],
180
+ coords_mask[index // multiplicity],
181
+ nucleic_acid_cutoff=nucleic_acid_cutoff,
182
+ other_cutoff=other_cutoff,
183
+ )
184
+ for index in range(true_coords.shape[0])
185
+ ] # each: ()
186
+ return 1.0 - torch.stack(agreements).mean(dim=0) # ()
fastplms/models/boltz/vb_modules_confidencev2.py CHANGED
@@ -1,548 +1,548 @@
1
- """Predict atom and token confidence from Boltz2 sequence and pair states."""
2
-
3
- import torch
4
-
5
- from typing import Any
6
- from torch import nn
7
-
8
- from . import vb_const as const
9
- from . import vb_layers_initialize as init
10
- from .vb_layers_confidence_utils import (
11
- compute_aggregated_metric,
12
- compute_ptms,
13
- )
14
- from .vb_layers_pairformer import PairformerModule
15
- from .vb_modules_encodersv2 import RelativePositionEncoder
16
- from .vb_modules_trunkv2 import (
17
- ContactConditioning,
18
- )
19
- from .vb_modules_utils import LinearNoBias
20
-
21
-
22
- def _token_slot_logits_to_atom_logits(
23
- token_logits: torch.Tensor,
24
- atom_to_token: torch.Tensor,
25
- atom_pad_mask: torch.Tensor,
26
- *,
27
- multiplicity: int,
28
- ) -> torch.Tensor:
29
- """Gather per-token atom-slot logits onto each example's atom table.
30
-
31
- ``token_logits`` is ordered as ``(batch * multiplicity, token, slot,
32
- channel)``. ``atom_to_token`` determines both the owning token and the
33
- within-token slot of every atom, so heterogeneous atom counts do not leak
34
- across batch rows.
35
- """
36
-
37
- # b: batch; m: multiplicity; a: atoms; t: tokens; c: channels.
38
- if token_logits.ndim != 4:
39
- raise ValueError(
40
- "token_logits must have shape (batch * multiplicity, token, slot, channel), "
41
- f"got {tuple(token_logits.shape)}."
42
- )
43
- if atom_to_token.ndim != 3:
44
- raise ValueError(
45
- "atom_to_token must have shape (batch, atom, token), "
46
- f"got {tuple(atom_to_token.shape)}."
47
- )
48
- if atom_pad_mask.shape != atom_to_token.shape[:2]:
49
- raise ValueError(
50
- "atom_pad_mask must match the batch and atom axes of atom_to_token; "
51
- f"got {tuple(atom_pad_mask.shape)} and {tuple(atom_to_token.shape)}."
52
- )
53
- if multiplicity < 1:
54
- raise ValueError(f"multiplicity must be positive, got {multiplicity}.")
55
-
56
- batch_size, atom_count, token_count = atom_to_token.shape
57
- if token_logits.shape[0] != batch_size * multiplicity:
58
- raise ValueError(
59
- "token_logits batch axis must equal batch * multiplicity; "
60
- f"got {token_logits.shape[0]} and {batch_size} * {multiplicity}."
61
- )
62
- if token_logits.shape[1] != token_count:
63
- raise ValueError(
64
- "token_logits and atom_to_token disagree on token count; "
65
- f"got {token_logits.shape[1]} and {token_count}."
66
- )
67
-
68
- valid_atoms = atom_pad_mask.bool() # (b, a)
69
- assignments = atom_to_token.bool() & valid_atoms.unsqueeze(-1) # (b, a, t)
70
- token_index = assignments.to(dtype=torch.int64).argmax(dim=-1) # (b, a)
71
- # Cumulative one-hot counts give each atom its ordinal within its owning
72
- # token without assuming that atoms from different tokens are contiguous.
73
- cumulative_slots = assignments.to(dtype=torch.int64).cumsum(dim=1) - 1 # (b, a, t)
74
- slot_index = (cumulative_slots * assignments).sum(dim=-1) # (b, a)
75
-
76
- slots_per_token = token_logits.shape[2]
77
- flattened_index = token_index * slots_per_token + slot_index # (b, a)
78
- flattened_index = flattened_index.masked_fill(~valid_atoms, 0) # (b, a)
79
- flattened_index = flattened_index.repeat_interleave(multiplicity, dim=0) # (b * m, a)
80
- expanded_atom_mask = valid_atoms.repeat_interleave(multiplicity, dim=0) # (b * m, a)
81
-
82
- flattened_logits = token_logits.flatten(1, 2) # (b * m, t * n_slot, c)
83
- gather_index = flattened_index.unsqueeze(-1).expand(
84
- -1,
85
- atom_count,
86
- flattened_logits.shape[-1],
87
- ) # (b * m, a, c)
88
- atom_logits = torch.gather(flattened_logits, dim=1, index=gather_index) # (b * m, a, c)
89
- return atom_logits * expanded_atom_mask.unsqueeze(-1).to(dtype=atom_logits.dtype) # (b * m, a, c)
90
-
91
-
92
- class ConfidenceModule(nn.Module):
93
- """Update confidence features with predicted geometry and a pairformer stack."""
94
-
95
- def __init__(
96
- self,
97
- token_s: int,
98
- token_z: int,
99
- pairformer_args: dict[str, Any],
100
- num_dist_bins: int = 64,
101
- token_level_confidence: bool = True,
102
- max_dist: float = 22,
103
- add_s_to_z_prod: bool = False,
104
- add_s_input_to_s: bool = False,
105
- add_z_input_to_z: bool = False,
106
- maximum_bond_distance: int = 0,
107
- bond_type_feature: bool = False,
108
- confidence_args: dict[str, Any] | None = None,
109
- compile_pairformer: bool = False,
110
- fix_sym_check: bool = False,
111
- cyclic_pos_enc: bool = False,
112
- return_latent_feats: bool = False,
113
- conditioning_cutoff_min: float | None = None,
114
- conditioning_cutoff_max: float | None = None,
115
- **kwargs: Any,
116
- ) -> None:
117
- super().__init__()
118
- self.max_num_atoms_per_token = 23
119
- self.no_update_s = pairformer_args.get("no_update_s", False)
120
- boundaries = torch.linspace(2, max_dist, num_dist_bins - 1) # (n_dist_bin - 1,)
121
- self.register_buffer("boundaries", boundaries)
122
- self.dist_bin_pairwise_embed = nn.Embedding(num_dist_bins, token_z)
123
- init.gating_init_(self.dist_bin_pairwise_embed.weight)
124
- self.token_level_confidence = token_level_confidence
125
-
126
- self.s_to_z = LinearNoBias(token_s, token_z)
127
- self.s_to_z_transpose = LinearNoBias(token_s, token_z)
128
- init.gating_init_(self.s_to_z.weight)
129
- init.gating_init_(self.s_to_z_transpose.weight)
130
-
131
- self.add_s_to_z_prod = add_s_to_z_prod
132
- if add_s_to_z_prod:
133
- self.s_to_z_prod_in1 = LinearNoBias(token_s, token_z)
134
- self.s_to_z_prod_in2 = LinearNoBias(token_s, token_z)
135
- self.s_to_z_prod_out = LinearNoBias(token_z, token_z)
136
- init.gating_init_(self.s_to_z_prod_out.weight)
137
-
138
- self.s_inputs_norm = nn.LayerNorm(token_s)
139
- if not self.no_update_s:
140
- self.s_norm = nn.LayerNorm(token_s)
141
- self.z_norm = nn.LayerNorm(token_z)
142
-
143
- self.add_s_input_to_s = add_s_input_to_s
144
- if add_s_input_to_s:
145
- self.s_input_to_s = LinearNoBias(token_s, token_s)
146
- init.gating_init_(self.s_input_to_s.weight)
147
-
148
- self.add_z_input_to_z = add_z_input_to_z
149
- if add_z_input_to_z:
150
- self.rel_pos = RelativePositionEncoder(
151
- token_z, fix_sym_check=fix_sym_check, cyclic_pos_enc=cyclic_pos_enc
152
- )
153
- self.token_bonds = nn.Linear(
154
- 1 if maximum_bond_distance == 0 else maximum_bond_distance + 2,
155
- token_z,
156
- bias=False,
157
- )
158
- self.bond_type_feature = bond_type_feature
159
- if bond_type_feature:
160
- self.token_bonds_type = nn.Embedding(len(const.bond_types) + 1, token_z)
161
-
162
- self.contact_conditioning = ContactConditioning(
163
- token_z=token_z,
164
- cutoff_min=conditioning_cutoff_min,
165
- cutoff_max=conditioning_cutoff_max,
166
- )
167
- pairformer_args["v2"] = True
168
- self.pairformer_stack = PairformerModule(
169
- token_s,
170
- token_z,
171
- **pairformer_args,
172
- )
173
- self.return_latent_feats = return_latent_feats
174
-
175
- self.confidence_heads = ConfidenceHeads(
176
- token_s,
177
- token_z,
178
- token_level_confidence=token_level_confidence,
179
- **confidence_args,
180
- )
181
-
182
- def forward(
183
- self,
184
- s_inputs: torch.Tensor, # (b, t, d_s)
185
  s: torch.Tensor, # (b, t, d_s)
186
  z: torch.Tensor, # (b, t, t, d_z)
187
- x_pred: torch.Tensor, # (b * m, a, 3) or (b, m, a, 3)
188
- feats: dict[str, torch.Tensor],
189
  pred_distogram_logits: torch.Tensor, # (b, t, t, n_bin)
190
- multiplicity: int = 1,
191
- run_sequentially: bool = False,
192
- use_kernels: bool = False,
193
- ) -> dict[str, Any]:
194
- # b: base batch; m: diffusion multiplicity; t: tokens; a: atoms.
195
- if run_sequentially and multiplicity > 1:
196
- batch_size = z.shape[0]
197
- expected_shape = (batch_size, multiplicity)
198
- if x_pred.ndim >= 4 and x_pred.shape[:2] == expected_shape:
199
- sample_coordinates = x_pred # (b, m, a, 3)
200
- elif x_pred.shape[0] == batch_size * multiplicity:
201
- sample_coordinates = x_pred.reshape(
202
- batch_size,
203
- multiplicity,
204
- *x_pred.shape[1:],
205
- ) # (b, m, a, 3)
206
- else:
207
- raise ValueError(
208
- "Sequential confidence expected coordinates with leading shape "
209
- f"{expected_shape} or {batch_size * multiplicity}, got "
210
- f"{tuple(x_pred.shape)}."
211
- )
212
- out_dicts = []
213
- for sample_idx in range(multiplicity):
214
- out_dicts.append(
215
- self.forward(
216
- s_inputs,
217
- s,
218
- z,
219
- sample_coordinates[:, sample_idx],
220
- feats,
221
- pred_distogram_logits,
222
- multiplicity=1,
223
- run_sequentially=False,
224
- use_kernels=use_kernels,
225
- )
226
- )
227
-
228
- out_dict = {}
229
- for key in out_dicts[0]:
230
- if key != "pair_chains_iptm":
231
- values = [out[key] for out in out_dicts] # list of sample tensors, each (b, *feature_shape)
232
- out_dict[key] = torch.stack(values, dim=1).flatten(0, 1) # (b * m, *feature_shape)
233
- else:
234
- pair_chains_iptm = {}
235
- for chain_idx1 in out_dicts[0][key]:
236
- chains_iptm = {}
237
- for chain_idx2 in out_dicts[0][key][chain_idx1]:
238
- values = [
239
- out[key][chain_idx1][chain_idx2]
240
- for out in out_dicts
241
  ] # list of sample chain-pair scores, each (b,)
242
- chains_iptm[chain_idx2] = torch.stack(
243
- values,
244
- dim=1,
245
- ).flatten(0, 1) # (b * m,)
246
- pair_chains_iptm[chain_idx1] = chains_iptm
247
- out_dict[key] = pair_chains_iptm # mapping of chain-pair scores, each (b * m,)
248
- return out_dict
249
-
250
- s_inputs = self.s_inputs_norm(s_inputs) # (b, t, d_s)
251
- if not self.no_update_s:
252
- s = self.s_norm(s) # (b, t, d_s)
253
-
254
- if self.add_s_input_to_s:
255
- s = s + self.s_input_to_s(s_inputs) # (b, t, d_s)
256
-
257
- z = self.z_norm(z) # (b, t, t, d_z)
258
-
259
- if self.add_z_input_to_z:
260
- relative_position_encoding = self.rel_pos(feats) # (b, t, t, d_z)
261
- z = z + relative_position_encoding # (b, t, t, d_z)
262
- z = z + self.token_bonds(feats["token_bonds"].float()) # (b, t, t, d_z)
263
- if self.bond_type_feature:
264
- z = z + self.token_bonds_type(feats["type_bonds"].long()) # (b, t, t, d_z)
265
- z = z + self.contact_conditioning(feats) # (b, t, t, d_z)
266
-
267
- s = s.repeat_interleave(multiplicity, 0) # (b * m, t, d_s)
268
-
269
- z = (
270
- z
271
- + self.s_to_z(s_inputs)[:, :, None, :]
272
- + self.s_to_z_transpose(s_inputs)[:, None, :, :]
273
- ) # (b, t, t, d_z)
274
- if self.add_s_to_z_prod:
275
- z = z + self.s_to_z_prod_out(
276
- self.s_to_z_prod_in1(s_inputs)[:, :, None, :]
277
- * self.s_to_z_prod_in2(s_inputs)[:, None, :, :]
278
- ) # (b, t, t, d_z)
279
-
280
- z = z.repeat_interleave(multiplicity, 0) # (b * m, t, t, d_z)
281
- s_inputs = s_inputs.repeat_interleave(multiplicity, 0) # (b * m, t, d_s)
282
-
283
  token_to_rep_atom = feats["token_to_rep_atom"] # (b, t, a)
284
  token_to_rep_atom = token_to_rep_atom.repeat_interleave(multiplicity, 0) # (b * m, t, a)
285
- if len(x_pred.shape) == 4:
286
- b, multiplicity, n, _ = x_pred.shape
287
- x_pred = x_pred.reshape(b * multiplicity, n, -1) # (b * m, a, 3)
288
- else:
289
- _, n, _ = x_pred.shape
290
- x_pred_repr = torch.bmm(token_to_rep_atom.float(), x_pred) # (b * m, t, 3)
291
- d = torch.cdist(x_pred_repr, x_pred_repr) # (b * m, t, t)
292
- distogram = (d.unsqueeze(-1) > self.boundaries).sum(dim=-1).long() # (b * m, t, t)
293
- distogram = self.dist_bin_pairwise_embed(distogram) # (b * m, t, t, d_z)
294
  z = z + distogram # (b * m, t, t, d_z)
295
-
296
- mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
297
- pair_mask = mask[:, :, None] * mask[:, None, :] # (b * m, t, t)
298
-
299
- s_t, z_t = self.pairformer_stack(
300
- s, z, mask=mask, pair_mask=pair_mask, use_kernels=use_kernels
301
- ) # (b * m, t, d_s), (b * m, t, t, d_z)
302
-
303
- # Confidence heads consume the pairformer states without an additional residual.
304
- s = s_t # (b * m, t, d_s)
305
- z = z_t # (b * m, t, t, d_z)
306
-
307
- out_dict = {}
308
-
309
- if self.return_latent_feats:
310
- out_dict["s_conf"] = s # (b * m, t, d_s)
311
- out_dict["z_conf"] = z # (b * m, t, t, d_z)
312
-
313
- out_dict.update(
314
- self.confidence_heads(
315
- s=s,
316
- z=z,
317
- x_pred=x_pred,
318
- d=d,
319
- feats=feats,
320
- multiplicity=multiplicity,
321
- pred_distogram_logits=pred_distogram_logits,
322
- )
323
- )
324
- return out_dict
325
-
326
-
327
- class ConfidenceHeads(nn.Module):
328
- def __init__(
329
- self,
330
- token_s: int,
331
- token_z: int,
332
- num_plddt_bins: int = 50,
333
- num_pde_bins: int = 64,
334
- num_pae_bins: int = 64,
335
- token_level_confidence: bool = True,
336
- use_separate_heads: bool = False,
337
- **kwargs: Any,
338
- ) -> None:
339
- super().__init__()
340
- self.max_num_atoms_per_token = 23
341
- self.token_level_confidence = token_level_confidence
342
- self.use_separate_heads = use_separate_heads
343
-
344
- if self.use_separate_heads:
345
- self.to_pae_intra_logits = LinearNoBias(token_z, num_pae_bins)
346
- self.to_pae_inter_logits = LinearNoBias(token_z, num_pae_bins)
347
- else:
348
- self.to_pae_logits = LinearNoBias(token_z, num_pae_bins)
349
-
350
- if self.use_separate_heads:
351
- self.to_pde_intra_logits = LinearNoBias(token_z, num_pde_bins)
352
- self.to_pde_inter_logits = LinearNoBias(token_z, num_pde_bins)
353
- else:
354
- self.to_pde_logits = LinearNoBias(token_z, num_pde_bins)
355
-
356
- if self.token_level_confidence:
357
- self.to_plddt_logits = LinearNoBias(token_s, num_plddt_bins)
358
- self.to_resolved_logits = LinearNoBias(token_s, 2)
359
- else:
360
- self.to_plddt_logits = LinearNoBias(
361
- token_s, num_plddt_bins * self.max_num_atoms_per_token
362
- )
363
- self.to_resolved_logits = LinearNoBias(token_s, 2 * self.max_num_atoms_per_token)
364
-
365
- def forward(
366
- self,
367
  s: torch.Tensor, # (b * m, t, d_s)
368
  z: torch.Tensor, # (b * m, t, t, d_z)
369
  x_pred: torch.Tensor, # (b * m, a, 3)
370
  d: torch.Tensor, # (b * m, t, t)
371
- feats: dict[str, torch.Tensor],
372
  pred_distogram_logits: torch.Tensor, # (b, t, t, n_bin)
373
- multiplicity: int = 1,
374
- ) -> dict[str, Any]:
375
- # b: base batch; m: diffusion multiplicity; t: tokens; a: atoms.
376
- if self.use_separate_heads:
377
- asym_id_token = feats["asym_id"] # (b, t)
378
- is_same_chain = asym_id_token.unsqueeze(-1) == asym_id_token.unsqueeze(-2) # (b, t, t)
379
- is_different_chain = ~is_same_chain # (b, t, t)
380
-
381
- if self.use_separate_heads:
382
- pae_intra_logits = self.to_pae_intra_logits(z) # (b * m, t, t, n_pae)
383
- pae_intra_logits = pae_intra_logits * is_same_chain.float().unsqueeze(-1) # (b * m, t, t, n_pae)
384
-
385
- pae_inter_logits = self.to_pae_inter_logits(z) # (b * m, t, t, n_pae)
386
- pae_inter_logits = pae_inter_logits * is_different_chain.float().unsqueeze(-1) # (b * m, t, t, n_pae)
387
-
388
- pae_logits = pae_inter_logits + pae_intra_logits # (b * m, t, t, n_pae)
389
- else:
390
- pae_logits = self.to_pae_logits(z) # (b * m, t, t, n_pae)
391
-
392
- if self.use_separate_heads:
393
- pde_intra_logits = self.to_pde_intra_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
394
- pde_intra_logits = pde_intra_logits * is_same_chain.float().unsqueeze(-1) # (b * m, t, t, n_pde)
395
-
396
- pde_inter_logits = self.to_pde_inter_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
397
- pde_inter_logits = pde_inter_logits * is_different_chain.float().unsqueeze(-1) # (b * m, t, t, n_pde)
398
-
399
- pde_logits = pde_inter_logits + pde_intra_logits # (b * m, t, t, n_pde)
400
- else:
401
- pde_logits = self.to_pde_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
402
- resolved_logits = self.to_resolved_logits(s) # (b * m, t, c_resolved); c_resolved is 2 or 2 * n_slot
403
- plddt_logits = self.to_plddt_logits(s) # (b * m, t, c_plddt); c_plddt is n_plddt or n_slot * n_plddt
404
-
405
- ligand_weight = 20
406
- non_interface_weight = 1
407
- interface_weight = 10
408
-
409
  token_type = feats["mol_type"] # (b, t)
410
  token_type = token_type.repeat_interleave(multiplicity, 0) # (b * m, t)
411
- is_ligand_token = (token_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, t)
412
-
413
- if self.token_level_confidence:
414
- plddt = compute_aggregated_metric(plddt_logits) # (b * m, n_item); n_item is t for token confidence, a for atom confidence
415
- token_pad_mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
416
- complex_plddt = (plddt * token_pad_mask).sum(dim=-1) / token_pad_mask.sum(dim=-1) # (b * m,)
417
-
418
- is_contact = (d < 8).float() # (b * m, t, t)
419
- is_different_chain = (
420
- feats["asym_id"].unsqueeze(-1) != feats["asym_id"].unsqueeze(-2)
421
- ).float() # (b, t, t)
422
- is_different_chain = is_different_chain.repeat_interleave(multiplicity, 0) # (b * m, t, t)
423
- token_interface_mask = torch.max(
424
- is_contact * is_different_chain * (1 - is_ligand_token).unsqueeze(-1),
425
- dim=-1,
426
- ).values # (b * m, t)
427
- token_non_interface_mask = (1 - token_interface_mask) * (1 - is_ligand_token) # (b * m, t)
428
- iplddt_weight = (
429
- is_ligand_token * ligand_weight
430
- + token_interface_mask * interface_weight
431
- + token_non_interface_mask * non_interface_weight
432
- ) # (b * m, t)
433
- complex_iplddt = (plddt * token_pad_mask * iplddt_weight).sum(dim=-1) / torch.sum(
434
- token_pad_mask * iplddt_weight, dim=-1
435
- ) # (b * m,)
436
-
437
- else:
438
- # token to atom conversion for resolved logits
439
- b, n, _ = resolved_logits.shape
440
- resolved_logits = resolved_logits.reshape(b, n, self.max_num_atoms_per_token, 2) # (b * m, t, n_slot, 2)
441
- resolved_logits = _token_slot_logits_to_atom_logits(
442
- resolved_logits,
443
- feats["atom_to_token"],
444
- feats["atom_pad_mask"],
445
- multiplicity=multiplicity,
446
- ) # (b * m, a, 2)
447
- plddt_logits = plddt_logits.reshape(b, n, self.max_num_atoms_per_token, -1) # (b * m, t, n_slot, n_plddt)
448
- plddt_logits = _token_slot_logits_to_atom_logits(
449
- plddt_logits,
450
- feats["atom_to_token"],
451
- feats["atom_pad_mask"],
452
- multiplicity=multiplicity,
453
- ) # (b * m, a, n_plddt)
454
- atom_pad_mask = feats["atom_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, a)
455
- plddt = compute_aggregated_metric(plddt_logits) # (b * m, n_item); n_item is t for token confidence, a for atom confidence
456
-
457
- complex_plddt = (plddt * atom_pad_mask).sum(dim=-1) / atom_pad_mask.sum(dim=-1) # (b * m,)
458
- atom_to_token = feats["atom_to_token"].float().repeat_interleave(
459
- multiplicity,
460
- 0,
461
- ) # (b * m, a, t)
462
- chain_id_token = feats["asym_id"].float().repeat_interleave(
463
- multiplicity,
464
- 0,
465
- ) # (b * m, t)
466
- atom_type = torch.bmm(
467
- atom_to_token,
468
- token_type.float().unsqueeze(-1),
469
- ).squeeze(-1) # (b * m, a)
470
- is_ligand_atom = (atom_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, a)
471
- d_atom = torch.cdist(x_pred, x_pred) # (b * m, a, a)
472
- is_contact = (d_atom < 8).float() # (b * m, a, a)
473
- chain_id_atom = torch.bmm(atom_to_token, chain_id_token.unsqueeze(-1)).squeeze(-1) # (b * m, a)
474
- is_different_chain = (
475
- chain_id_atom.unsqueeze(-1) != chain_id_atom.unsqueeze(-2)
476
- ).float() # (b * m, a, a)
477
-
478
- atom_interface_mask = torch.max(
479
- is_contact * is_different_chain * (1 - is_ligand_atom).unsqueeze(-1),
480
- dim=-1,
481
- ).values # (b * m, a)
482
- atom_non_interface_mask = (1 - atom_interface_mask) * (1 - is_ligand_atom) # (b * m, a)
483
- iplddt_weight = (
484
- is_ligand_atom * ligand_weight
485
- + atom_interface_mask * interface_weight
486
- + atom_non_interface_mask * non_interface_weight
487
- ) # (b * m, a)
488
-
489
- complex_iplddt = (plddt * atom_pad_mask * iplddt_weight).sum(dim=-1) / torch.sum(
490
- atom_pad_mask * iplddt_weight,
491
- dim=-1,
492
- ) # (b * m,)
493
-
494
- # Compute the gPDE and giPDE
495
- pde = compute_aggregated_metric(pde_logits, end=32) # (b * m, t, t)
496
- pred_distogram_prob = nn.functional.softmax(
497
- pred_distogram_logits, dim=-1
498
- ).repeat_interleave(multiplicity, 0) # (b * m, t, t, n_bin)
499
- contacts = torch.zeros((1, 1, 1, 64), dtype=pred_distogram_prob.dtype).to(
500
- pred_distogram_prob.device
501
- ) # (1, 1, 1, 64)
502
- contacts[:, :, :, :20] = 1.0 # (1, 1, 1, 20) selected bins; contacts retains (1, 1, 1, 64)
503
- prob_contact = (pred_distogram_prob * contacts).sum(-1) # (b * m, t, t)
504
- token_pad_mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
505
- token_pad_pair_mask = (
506
- token_pad_mask.unsqueeze(-1)
507
- * token_pad_mask.unsqueeze(-2)
508
- * (1 - torch.eye(token_pad_mask.shape[1], device=token_pad_mask.device).unsqueeze(0))
509
- ) # (b * m, t, t)
510
- token_pair_mask = token_pad_pair_mask * prob_contact # (b * m, t, t)
511
- complex_pde_numerator = (pde * token_pair_mask).sum(dim=(1, 2)) # (b * m,)
512
- complex_pde_denominator = token_pair_mask.sum(dim=(1, 2)) # (b * m,)
513
- complex_pde = complex_pde_numerator / torch.where(
514
- complex_pde_denominator > 0,
515
- complex_pde_denominator,
516
- torch.ones_like(complex_pde_denominator),
517
- ) # (b * m,)
518
- asym_id = feats["asym_id"].repeat_interleave(multiplicity, 0) # (b * m, t)
519
- token_interface_pair_mask = token_pair_mask * (
520
- asym_id.unsqueeze(-1) != asym_id.unsqueeze(-2)
521
- ) # (b * m, t, t)
522
- complex_ipde = (pde * token_interface_pair_mask).sum(dim=(1, 2)) / (
523
- token_interface_pair_mask.sum(dim=(1, 2)) + 1e-5
524
- ) # (b * m,)
525
- out_dict = dict(
526
- pde_logits=pde_logits,
527
- plddt_logits=plddt_logits,
528
- resolved_logits=resolved_logits,
529
- pde=pde,
530
- plddt=plddt,
531
- complex_plddt=complex_plddt,
532
- complex_iplddt=complex_iplddt,
533
- complex_pde=complex_pde,
534
- complex_ipde=complex_ipde,
535
- )
536
- out_dict["pae_logits"] = pae_logits # (b * m, t, t, n_pae)
537
- out_dict["pae"] = compute_aggregated_metric(pae_logits, end=32) # (b * m, t, t)
538
-
539
- ptm, iptm, ligand_iptm, protein_iptm, pair_chains_iptm = compute_ptms(
540
- pae_logits, x_pred, feats, multiplicity
541
- ) # four (b * m,) scores and a mapping of chain-pair scores (b * m,)
542
- out_dict["ptm"] = ptm # (b * m,)
543
- out_dict["iptm"] = iptm # (b * m,)
544
- out_dict["ligand_iptm"] = ligand_iptm # (b * m,)
545
- out_dict["protein_iptm"] = protein_iptm # (b * m,)
546
- out_dict["pair_chains_iptm"] = pair_chains_iptm # each chain-pair tensor: (b * m,)
547
-
548
- return out_dict
 
1
+ """Predict atom and token confidence from Boltz2 sequence and pair states."""
2
+
3
+ import torch
4
+
5
+ from typing import Any
6
+ from torch import nn
7
+
8
+ from . import vb_const as const
9
+ from . import vb_layers_initialize as init
10
+ from .vb_layers_confidence_utils import (
11
+ compute_aggregated_metric,
12
+ compute_ptms,
13
+ )
14
+ from .vb_layers_pairformer import PairformerModule
15
+ from .vb_modules_encodersv2 import RelativePositionEncoder
16
+ from .vb_modules_trunkv2 import (
17
+ ContactConditioning,
18
+ )
19
+ from .vb_modules_utils import LinearNoBias
20
+
21
+
22
+ def _token_slot_logits_to_atom_logits(
23
+ token_logits: torch.Tensor,
24
+ atom_to_token: torch.Tensor,
25
+ atom_pad_mask: torch.Tensor,
26
+ *,
27
+ multiplicity: int,
28
+ ) -> torch.Tensor:
29
+ """Gather per-token atom-slot logits onto each example's atom table.
30
+
31
+ ``token_logits`` is ordered as ``(batch * multiplicity, token, slot,
32
+ channel)``. ``atom_to_token`` determines both the owning token and the
33
+ within-token slot of every atom, so heterogeneous atom counts do not leak
34
+ across batch rows.
35
+ """
36
+
37
+ # b: batch; m: multiplicity; a: atoms; t: tokens; c: channels.
38
+ if token_logits.ndim != 4:
39
+ raise ValueError(
40
+ "token_logits must have shape (batch * multiplicity, token, slot, channel), "
41
+ f"got {tuple(token_logits.shape)}."
42
+ )
43
+ if atom_to_token.ndim != 3:
44
+ raise ValueError(
45
+ "atom_to_token must have shape (batch, atom, token), "
46
+ f"got {tuple(atom_to_token.shape)}."
47
+ )
48
+ if atom_pad_mask.shape != atom_to_token.shape[:2]:
49
+ raise ValueError(
50
+ "atom_pad_mask must match the batch and atom axes of atom_to_token; "
51
+ f"got {tuple(atom_pad_mask.shape)} and {tuple(atom_to_token.shape)}."
52
+ )
53
+ if multiplicity < 1:
54
+ raise ValueError(f"multiplicity must be positive, got {multiplicity}.")
55
+
56
+ batch_size, atom_count, token_count = atom_to_token.shape
57
+ if token_logits.shape[0] != batch_size * multiplicity:
58
+ raise ValueError(
59
+ "token_logits batch axis must equal batch * multiplicity; "
60
+ f"got {token_logits.shape[0]} and {batch_size} * {multiplicity}."
61
+ )
62
+ if token_logits.shape[1] != token_count:
63
+ raise ValueError(
64
+ "token_logits and atom_to_token disagree on token count; "
65
+ f"got {token_logits.shape[1]} and {token_count}."
66
+ )
67
+
68
+ valid_atoms = atom_pad_mask.bool() # (b, a)
69
+ assignments = atom_to_token.bool() & valid_atoms.unsqueeze(-1) # (b, a, t)
70
+ token_index = assignments.to(dtype=torch.int64).argmax(dim=-1) # (b, a)
71
+ # Cumulative one-hot counts give each atom its ordinal within its owning
72
+ # token without assuming that atoms from different tokens are contiguous.
73
+ cumulative_slots = assignments.to(dtype=torch.int64).cumsum(dim=1) - 1 # (b, a, t)
74
+ slot_index = (cumulative_slots * assignments).sum(dim=-1) # (b, a)
75
+
76
+ slots_per_token = token_logits.shape[2]
77
+ flattened_index = token_index * slots_per_token + slot_index # (b, a)
78
+ flattened_index = flattened_index.masked_fill(~valid_atoms, 0) # (b, a)
79
+ flattened_index = flattened_index.repeat_interleave(multiplicity, dim=0) # (b * m, a)
80
+ expanded_atom_mask = valid_atoms.repeat_interleave(multiplicity, dim=0) # (b * m, a)
81
+
82
+ flattened_logits = token_logits.flatten(1, 2) # (b * m, t * n_slot, c)
83
+ gather_index = flattened_index.unsqueeze(-1).expand(
84
+ -1,
85
+ atom_count,
86
+ flattened_logits.shape[-1],
87
+ ) # (b * m, a, c)
88
+ atom_logits = torch.gather(flattened_logits, dim=1, index=gather_index) # (b * m, a, c)
89
+ return atom_logits * expanded_atom_mask.unsqueeze(-1).to(dtype=atom_logits.dtype) # (b * m, a, c)
90
+
91
+
92
+ class ConfidenceModule(nn.Module):
93
+ """Update confidence features with predicted geometry and a pairformer stack."""
94
+
95
+ def __init__(
96
+ self,
97
+ token_s: int,
98
+ token_z: int,
99
+ pairformer_args: dict[str, Any],
100
+ num_dist_bins: int = 64,
101
+ token_level_confidence: bool = True,
102
+ max_dist: float = 22,
103
+ add_s_to_z_prod: bool = False,
104
+ add_s_input_to_s: bool = False,
105
+ add_z_input_to_z: bool = False,
106
+ maximum_bond_distance: int = 0,
107
+ bond_type_feature: bool = False,
108
+ confidence_args: dict[str, Any] | None = None,
109
+ compile_pairformer: bool = False,
110
+ fix_sym_check: bool = False,
111
+ cyclic_pos_enc: bool = False,
112
+ return_latent_feats: bool = False,
113
+ conditioning_cutoff_min: float | None = None,
114
+ conditioning_cutoff_max: float | None = None,
115
+ **kwargs: Any,
116
+ ) -> None:
117
+ super().__init__()
118
+ self.max_num_atoms_per_token = 23
119
+ self.no_update_s = pairformer_args.get("no_update_s", False)
120
+ boundaries = torch.linspace(2, max_dist, num_dist_bins - 1) # (n_dist_bin - 1,)
121
+ self.register_buffer("boundaries", boundaries)
122
+ self.dist_bin_pairwise_embed = nn.Embedding(num_dist_bins, token_z)
123
+ init.gating_init_(self.dist_bin_pairwise_embed.weight)
124
+ self.token_level_confidence = token_level_confidence
125
+
126
+ self.s_to_z = LinearNoBias(token_s, token_z)
127
+ self.s_to_z_transpose = LinearNoBias(token_s, token_z)
128
+ init.gating_init_(self.s_to_z.weight)
129
+ init.gating_init_(self.s_to_z_transpose.weight)
130
+
131
+ self.add_s_to_z_prod = add_s_to_z_prod
132
+ if add_s_to_z_prod:
133
+ self.s_to_z_prod_in1 = LinearNoBias(token_s, token_z)
134
+ self.s_to_z_prod_in2 = LinearNoBias(token_s, token_z)
135
+ self.s_to_z_prod_out = LinearNoBias(token_z, token_z)
136
+ init.gating_init_(self.s_to_z_prod_out.weight)
137
+
138
+ self.s_inputs_norm = nn.LayerNorm(token_s)
139
+ if not self.no_update_s:
140
+ self.s_norm = nn.LayerNorm(token_s)
141
+ self.z_norm = nn.LayerNorm(token_z)
142
+
143
+ self.add_s_input_to_s = add_s_input_to_s
144
+ if add_s_input_to_s:
145
+ self.s_input_to_s = LinearNoBias(token_s, token_s)
146
+ init.gating_init_(self.s_input_to_s.weight)
147
+
148
+ self.add_z_input_to_z = add_z_input_to_z
149
+ if add_z_input_to_z:
150
+ self.rel_pos = RelativePositionEncoder(
151
+ token_z, fix_sym_check=fix_sym_check, cyclic_pos_enc=cyclic_pos_enc
152
+ )
153
+ self.token_bonds = nn.Linear(
154
+ 1 if maximum_bond_distance == 0 else maximum_bond_distance + 2,
155
+ token_z,
156
+ bias=False,
157
+ )
158
+ self.bond_type_feature = bond_type_feature
159
+ if bond_type_feature:
160
+ self.token_bonds_type = nn.Embedding(len(const.bond_types) + 1, token_z)
161
+
162
+ self.contact_conditioning = ContactConditioning(
163
+ token_z=token_z,
164
+ cutoff_min=conditioning_cutoff_min,
165
+ cutoff_max=conditioning_cutoff_max,
166
+ )
167
+ pairformer_args["v2"] = True
168
+ self.pairformer_stack = PairformerModule(
169
+ token_s,
170
+ token_z,
171
+ **pairformer_args,
172
+ )
173
+ self.return_latent_feats = return_latent_feats
174
+
175
+ self.confidence_heads = ConfidenceHeads(
176
+ token_s,
177
+ token_z,
178
+ token_level_confidence=token_level_confidence,
179
+ **confidence_args,
180
+ )
181
+
182
+ def forward(
183
+ self,
184
+ s_inputs: torch.Tensor, # (b, t, d_s)
185
  s: torch.Tensor, # (b, t, d_s)
186
  z: torch.Tensor, # (b, t, t, d_z)
187
+ x_pred: torch.Tensor, # (b * m, a, 3) or (b, m, a, 3)
188
+ feats: dict[str, torch.Tensor],
189
  pred_distogram_logits: torch.Tensor, # (b, t, t, n_bin)
190
+ multiplicity: int = 1,
191
+ run_sequentially: bool = False,
192
+ use_kernels: bool = False,
193
+ ) -> dict[str, Any]:
194
+ # b: base batch; m: diffusion multiplicity; t: tokens; a: atoms.
195
+ if run_sequentially and multiplicity > 1:
196
+ batch_size = z.shape[0]
197
+ expected_shape = (batch_size, multiplicity)
198
+ if x_pred.ndim >= 4 and x_pred.shape[:2] == expected_shape:
199
+ sample_coordinates = x_pred # (b, m, a, 3)
200
+ elif x_pred.shape[0] == batch_size * multiplicity:
201
+ sample_coordinates = x_pred.reshape(
202
+ batch_size,
203
+ multiplicity,
204
+ *x_pred.shape[1:],
205
+ ) # (b, m, a, 3)
206
+ else:
207
+ raise ValueError(
208
+ "Sequential confidence expected coordinates with leading shape "
209
+ f"{expected_shape} or {batch_size * multiplicity}, got "
210
+ f"{tuple(x_pred.shape)}."
211
+ )
212
+ out_dicts = []
213
+ for sample_idx in range(multiplicity):
214
+ out_dicts.append(
215
+ self.forward(
216
+ s_inputs,
217
+ s,
218
+ z,
219
+ sample_coordinates[:, sample_idx],
220
+ feats,
221
+ pred_distogram_logits,
222
+ multiplicity=1,
223
+ run_sequentially=False,
224
+ use_kernels=use_kernels,
225
+ )
226
+ )
227
+
228
+ out_dict = {}
229
+ for key in out_dicts[0]:
230
+ if key != "pair_chains_iptm":
231
+ values = [out[key] for out in out_dicts] # list of sample tensors, each (b, *feature_shape)
232
+ out_dict[key] = torch.stack(values, dim=1).flatten(0, 1) # (b * m, *feature_shape)
233
+ else:
234
+ pair_chains_iptm = {}
235
+ for chain_idx1 in out_dicts[0][key]:
236
+ chains_iptm = {}
237
+ for chain_idx2 in out_dicts[0][key][chain_idx1]:
238
+ values = [
239
+ out[key][chain_idx1][chain_idx2]
240
+ for out in out_dicts
241
  ] # list of sample chain-pair scores, each (b,)
242
+ chains_iptm[chain_idx2] = torch.stack(
243
+ values,
244
+ dim=1,
245
+ ).flatten(0, 1) # (b * m,)
246
+ pair_chains_iptm[chain_idx1] = chains_iptm
247
+ out_dict[key] = pair_chains_iptm # mapping of chain-pair scores, each (b * m,)
248
+ return out_dict
249
+
250
+ s_inputs = self.s_inputs_norm(s_inputs) # (b, t, d_s)
251
+ if not self.no_update_s:
252
+ s = self.s_norm(s) # (b, t, d_s)
253
+
254
+ if self.add_s_input_to_s:
255
+ s = s + self.s_input_to_s(s_inputs) # (b, t, d_s)
256
+
257
+ z = self.z_norm(z) # (b, t, t, d_z)
258
+
259
+ if self.add_z_input_to_z:
260
+ relative_position_encoding = self.rel_pos(feats) # (b, t, t, d_z)
261
+ z = z + relative_position_encoding # (b, t, t, d_z)
262
+ z = z + self.token_bonds(feats["token_bonds"].float()) # (b, t, t, d_z)
263
+ if self.bond_type_feature:
264
+ z = z + self.token_bonds_type(feats["type_bonds"].long()) # (b, t, t, d_z)
265
+ z = z + self.contact_conditioning(feats) # (b, t, t, d_z)
266
+
267
+ s = s.repeat_interleave(multiplicity, 0) # (b * m, t, d_s)
268
+
269
+ z = (
270
+ z
271
+ + self.s_to_z(s_inputs)[:, :, None, :]
272
+ + self.s_to_z_transpose(s_inputs)[:, None, :, :]
273
+ ) # (b, t, t, d_z)
274
+ if self.add_s_to_z_prod:
275
+ z = z + self.s_to_z_prod_out(
276
+ self.s_to_z_prod_in1(s_inputs)[:, :, None, :]
277
+ * self.s_to_z_prod_in2(s_inputs)[:, None, :, :]
278
+ ) # (b, t, t, d_z)
279
+
280
+ z = z.repeat_interleave(multiplicity, 0) # (b * m, t, t, d_z)
281
+ s_inputs = s_inputs.repeat_interleave(multiplicity, 0) # (b * m, t, d_s)
282
+
283
  token_to_rep_atom = feats["token_to_rep_atom"] # (b, t, a)
284
  token_to_rep_atom = token_to_rep_atom.repeat_interleave(multiplicity, 0) # (b * m, t, a)
285
+ if len(x_pred.shape) == 4:
286
+ b, multiplicity, n, _ = x_pred.shape
287
+ x_pred = x_pred.reshape(b * multiplicity, n, -1) # (b * m, a, 3)
288
+ else:
289
+ _, n, _ = x_pred.shape
290
+ x_pred_repr = torch.bmm(token_to_rep_atom.float(), x_pred) # (b * m, t, 3)
291
+ d = torch.cdist(x_pred_repr, x_pred_repr) # (b * m, t, t)
292
+ distogram = (d.unsqueeze(-1) > self.boundaries).sum(dim=-1).long() # (b * m, t, t)
293
+ distogram = self.dist_bin_pairwise_embed(distogram) # (b * m, t, t, d_z)
294
  z = z + distogram # (b * m, t, t, d_z)
295
+
296
+ mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
297
+ pair_mask = mask[:, :, None] * mask[:, None, :] # (b * m, t, t)
298
+
299
+ s_t, z_t = self.pairformer_stack(
300
+ s, z, mask=mask, pair_mask=pair_mask, use_kernels=use_kernels
301
+ ) # (b * m, t, d_s), (b * m, t, t, d_z)
302
+
303
+ # Confidence heads consume the pairformer states without an additional residual.
304
+ s = s_t # (b * m, t, d_s)
305
+ z = z_t # (b * m, t, t, d_z)
306
+
307
+ out_dict = {}
308
+
309
+ if self.return_latent_feats:
310
+ out_dict["s_conf"] = s # (b * m, t, d_s)
311
+ out_dict["z_conf"] = z # (b * m, t, t, d_z)
312
+
313
+ out_dict.update(
314
+ self.confidence_heads(
315
+ s=s,
316
+ z=z,
317
+ x_pred=x_pred,
318
+ d=d,
319
+ feats=feats,
320
+ multiplicity=multiplicity,
321
+ pred_distogram_logits=pred_distogram_logits,
322
+ )
323
+ )
324
+ return out_dict
325
+
326
+
327
+ class ConfidenceHeads(nn.Module):
328
+ def __init__(
329
+ self,
330
+ token_s: int,
331
+ token_z: int,
332
+ num_plddt_bins: int = 50,
333
+ num_pde_bins: int = 64,
334
+ num_pae_bins: int = 64,
335
+ token_level_confidence: bool = True,
336
+ use_separate_heads: bool = False,
337
+ **kwargs: Any,
338
+ ) -> None:
339
+ super().__init__()
340
+ self.max_num_atoms_per_token = 23
341
+ self.token_level_confidence = token_level_confidence
342
+ self.use_separate_heads = use_separate_heads
343
+
344
+ if self.use_separate_heads:
345
+ self.to_pae_intra_logits = LinearNoBias(token_z, num_pae_bins)
346
+ self.to_pae_inter_logits = LinearNoBias(token_z, num_pae_bins)
347
+ else:
348
+ self.to_pae_logits = LinearNoBias(token_z, num_pae_bins)
349
+
350
+ if self.use_separate_heads:
351
+ self.to_pde_intra_logits = LinearNoBias(token_z, num_pde_bins)
352
+ self.to_pde_inter_logits = LinearNoBias(token_z, num_pde_bins)
353
+ else:
354
+ self.to_pde_logits = LinearNoBias(token_z, num_pde_bins)
355
+
356
+ if self.token_level_confidence:
357
+ self.to_plddt_logits = LinearNoBias(token_s, num_plddt_bins)
358
+ self.to_resolved_logits = LinearNoBias(token_s, 2)
359
+ else:
360
+ self.to_plddt_logits = LinearNoBias(
361
+ token_s, num_plddt_bins * self.max_num_atoms_per_token
362
+ )
363
+ self.to_resolved_logits = LinearNoBias(token_s, 2 * self.max_num_atoms_per_token)
364
+
365
+ def forward(
366
+ self,
367
  s: torch.Tensor, # (b * m, t, d_s)
368
  z: torch.Tensor, # (b * m, t, t, d_z)
369
  x_pred: torch.Tensor, # (b * m, a, 3)
370
  d: torch.Tensor, # (b * m, t, t)
371
+ feats: dict[str, torch.Tensor],
372
  pred_distogram_logits: torch.Tensor, # (b, t, t, n_bin)
373
+ multiplicity: int = 1,
374
+ ) -> dict[str, Any]:
375
+ # b: base batch; m: diffusion multiplicity; t: tokens; a: atoms.
376
+ if self.use_separate_heads:
377
+ asym_id_token = feats["asym_id"] # (b, t)
378
+ is_same_chain = asym_id_token.unsqueeze(-1) == asym_id_token.unsqueeze(-2) # (b, t, t)
379
+ is_different_chain = ~is_same_chain # (b, t, t)
380
+
381
+ if self.use_separate_heads:
382
+ pae_intra_logits = self.to_pae_intra_logits(z) # (b * m, t, t, n_pae)
383
+ pae_intra_logits = pae_intra_logits * is_same_chain.float().unsqueeze(-1) # (b * m, t, t, n_pae)
384
+
385
+ pae_inter_logits = self.to_pae_inter_logits(z) # (b * m, t, t, n_pae)
386
+ pae_inter_logits = pae_inter_logits * is_different_chain.float().unsqueeze(-1) # (b * m, t, t, n_pae)
387
+
388
+ pae_logits = pae_inter_logits + pae_intra_logits # (b * m, t, t, n_pae)
389
+ else:
390
+ pae_logits = self.to_pae_logits(z) # (b * m, t, t, n_pae)
391
+
392
+ if self.use_separate_heads:
393
+ pde_intra_logits = self.to_pde_intra_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
394
+ pde_intra_logits = pde_intra_logits * is_same_chain.float().unsqueeze(-1) # (b * m, t, t, n_pde)
395
+
396
+ pde_inter_logits = self.to_pde_inter_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
397
+ pde_inter_logits = pde_inter_logits * is_different_chain.float().unsqueeze(-1) # (b * m, t, t, n_pde)
398
+
399
+ pde_logits = pde_inter_logits + pde_intra_logits # (b * m, t, t, n_pde)
400
+ else:
401
+ pde_logits = self.to_pde_logits(z + z.transpose(1, 2)) # (b * m, t, t, n_pde)
402
+ resolved_logits = self.to_resolved_logits(s) # (b * m, t, c_resolved); c_resolved is 2 or 2 * n_slot
403
+ plddt_logits = self.to_plddt_logits(s) # (b * m, t, c_plddt); c_plddt is n_plddt or n_slot * n_plddt
404
+
405
+ ligand_weight = 20
406
+ non_interface_weight = 1
407
+ interface_weight = 10
408
+
409
  token_type = feats["mol_type"] # (b, t)
410
  token_type = token_type.repeat_interleave(multiplicity, 0) # (b * m, t)
411
+ is_ligand_token = (token_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, t)
412
+
413
+ if self.token_level_confidence:
414
+ plddt = compute_aggregated_metric(plddt_logits) # (b * m, n_item); n_item is t for token confidence, a for atom confidence
415
+ token_pad_mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
416
+ complex_plddt = (plddt * token_pad_mask).sum(dim=-1) / token_pad_mask.sum(dim=-1) # (b * m,)
417
+
418
+ is_contact = (d < 8).float() # (b * m, t, t)
419
+ is_different_chain = (
420
+ feats["asym_id"].unsqueeze(-1) != feats["asym_id"].unsqueeze(-2)
421
+ ).float() # (b, t, t)
422
+ is_different_chain = is_different_chain.repeat_interleave(multiplicity, 0) # (b * m, t, t)
423
+ token_interface_mask = torch.max(
424
+ is_contact * is_different_chain * (1 - is_ligand_token).unsqueeze(-1),
425
+ dim=-1,
426
+ ).values # (b * m, t)
427
+ token_non_interface_mask = (1 - token_interface_mask) * (1 - is_ligand_token) # (b * m, t)
428
+ iplddt_weight = (
429
+ is_ligand_token * ligand_weight
430
+ + token_interface_mask * interface_weight
431
+ + token_non_interface_mask * non_interface_weight
432
+ ) # (b * m, t)
433
+ complex_iplddt = (plddt * token_pad_mask * iplddt_weight).sum(dim=-1) / torch.sum(
434
+ token_pad_mask * iplddt_weight, dim=-1
435
+ ) # (b * m,)
436
+
437
+ else:
438
+ # token to atom conversion for resolved logits
439
+ b, n, _ = resolved_logits.shape
440
+ resolved_logits = resolved_logits.reshape(b, n, self.max_num_atoms_per_token, 2) # (b * m, t, n_slot, 2)
441
+ resolved_logits = _token_slot_logits_to_atom_logits(
442
+ resolved_logits,
443
+ feats["atom_to_token"],
444
+ feats["atom_pad_mask"],
445
+ multiplicity=multiplicity,
446
+ ) # (b * m, a, 2)
447
+ plddt_logits = plddt_logits.reshape(b, n, self.max_num_atoms_per_token, -1) # (b * m, t, n_slot, n_plddt)
448
+ plddt_logits = _token_slot_logits_to_atom_logits(
449
+ plddt_logits,
450
+ feats["atom_to_token"],
451
+ feats["atom_pad_mask"],
452
+ multiplicity=multiplicity,
453
+ ) # (b * m, a, n_plddt)
454
+ atom_pad_mask = feats["atom_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, a)
455
+ plddt = compute_aggregated_metric(plddt_logits) # (b * m, n_item); n_item is t for token confidence, a for atom confidence
456
+
457
+ complex_plddt = (plddt * atom_pad_mask).sum(dim=-1) / atom_pad_mask.sum(dim=-1) # (b * m,)
458
+ atom_to_token = feats["atom_to_token"].float().repeat_interleave(
459
+ multiplicity,
460
+ 0,
461
+ ) # (b * m, a, t)
462
+ chain_id_token = feats["asym_id"].float().repeat_interleave(
463
+ multiplicity,
464
+ 0,
465
+ ) # (b * m, t)
466
+ atom_type = torch.bmm(
467
+ atom_to_token,
468
+ token_type.float().unsqueeze(-1),
469
+ ).squeeze(-1) # (b * m, a)
470
+ is_ligand_atom = (atom_type == const.chain_type_ids["NONPOLYMER"]).float() # (b * m, a)
471
+ d_atom = torch.cdist(x_pred, x_pred) # (b * m, a, a)
472
+ is_contact = (d_atom < 8).float() # (b * m, a, a)
473
+ chain_id_atom = torch.bmm(atom_to_token, chain_id_token.unsqueeze(-1)).squeeze(-1) # (b * m, a)
474
+ is_different_chain = (
475
+ chain_id_atom.unsqueeze(-1) != chain_id_atom.unsqueeze(-2)
476
+ ).float() # (b * m, a, a)
477
+
478
+ atom_interface_mask = torch.max(
479
+ is_contact * is_different_chain * (1 - is_ligand_atom).unsqueeze(-1),
480
+ dim=-1,
481
+ ).values # (b * m, a)
482
+ atom_non_interface_mask = (1 - atom_interface_mask) * (1 - is_ligand_atom) # (b * m, a)
483
+ iplddt_weight = (
484
+ is_ligand_atom * ligand_weight
485
+ + atom_interface_mask * interface_weight
486
+ + atom_non_interface_mask * non_interface_weight
487
+ ) # (b * m, a)
488
+
489
+ complex_iplddt = (plddt * atom_pad_mask * iplddt_weight).sum(dim=-1) / torch.sum(
490
+ atom_pad_mask * iplddt_weight,
491
+ dim=-1,
492
+ ) # (b * m,)
493
+
494
+ # Compute the gPDE and giPDE
495
+ pde = compute_aggregated_metric(pde_logits, end=32) # (b * m, t, t)
496
+ pred_distogram_prob = nn.functional.softmax(
497
+ pred_distogram_logits, dim=-1
498
+ ).repeat_interleave(multiplicity, 0) # (b * m, t, t, n_bin)
499
+ contacts = torch.zeros((1, 1, 1, 64), dtype=pred_distogram_prob.dtype).to(
500
+ pred_distogram_prob.device
501
+ ) # (1, 1, 1, 64)
502
+ contacts[:, :, :, :20] = 1.0 # (1, 1, 1, 20) selected bins; contacts retains (1, 1, 1, 64)
503
+ prob_contact = (pred_distogram_prob * contacts).sum(-1) # (b * m, t, t)
504
+ token_pad_mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
505
+ token_pad_pair_mask = (
506
+ token_pad_mask.unsqueeze(-1)
507
+ * token_pad_mask.unsqueeze(-2)
508
+ * (1 - torch.eye(token_pad_mask.shape[1], device=token_pad_mask.device).unsqueeze(0))
509
+ ) # (b * m, t, t)
510
+ token_pair_mask = token_pad_pair_mask * prob_contact # (b * m, t, t)
511
+ complex_pde_numerator = (pde * token_pair_mask).sum(dim=(1, 2)) # (b * m,)
512
+ complex_pde_denominator = token_pair_mask.sum(dim=(1, 2)) # (b * m,)
513
+ complex_pde = complex_pde_numerator / torch.where(
514
+ complex_pde_denominator > 0,
515
+ complex_pde_denominator,
516
+ torch.ones_like(complex_pde_denominator),
517
+ ) # (b * m,)
518
+ asym_id = feats["asym_id"].repeat_interleave(multiplicity, 0) # (b * m, t)
519
+ token_interface_pair_mask = token_pair_mask * (
520
+ asym_id.unsqueeze(-1) != asym_id.unsqueeze(-2)
521
+ ) # (b * m, t, t)
522
+ complex_ipde = (pde * token_interface_pair_mask).sum(dim=(1, 2)) / (
523
+ token_interface_pair_mask.sum(dim=(1, 2)) + 1e-5
524
+ ) # (b * m,)
525
+ out_dict = dict(
526
+ pde_logits=pde_logits,
527
+ plddt_logits=plddt_logits,
528
+ resolved_logits=resolved_logits,
529
+ pde=pde,
530
+ plddt=plddt,
531
+ complex_plddt=complex_plddt,
532
+ complex_iplddt=complex_iplddt,
533
+ complex_pde=complex_pde,
534
+ complex_ipde=complex_ipde,
535
+ )
536
+ out_dict["pae_logits"] = pae_logits # (b * m, t, t, n_pae)
537
+ out_dict["pae"] = compute_aggregated_metric(pae_logits, end=32) # (b * m, t, t)
538
+
539
+ ptm, iptm, ligand_iptm, protein_iptm, pair_chains_iptm = compute_ptms(
540
+ pae_logits, x_pred, feats, multiplicity
541
+ ) # four (b * m,) scores and a mapping of chain-pair scores (b * m,)
542
+ out_dict["ptm"] = ptm # (b * m,)
543
+ out_dict["iptm"] = iptm # (b * m,)
544
+ out_dict["ligand_iptm"] = ligand_iptm # (b * m,)
545
+ out_dict["protein_iptm"] = protein_iptm # (b * m,)
546
+ out_dict["pair_chains_iptm"] = pair_chains_iptm # each chain-pair tensor: (b * m,)
547
+
548
+ return out_dict
fastplms/models/boltz/vb_modules_diffusion_conditioning.py CHANGED
@@ -1,147 +1,147 @@
1
- """Precompute pair and atom biases used by the Boltz2 diffusion stack."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from collections.abc import Callable
8
- from torch import nn
9
-
10
- from .vb_modules_encodersv2 import AtomEncoder, PairwiseConditioning
11
-
12
-
13
- def _bias_projections(
14
- depth: int,
15
- input_dim: int,
16
- num_heads: int,
17
- ) -> nn.ModuleList:
18
- """Build one normalized, bias-free projection per transformer block."""
19
-
20
- return nn.ModuleList(
21
- [
22
- nn.Sequential(
23
- nn.LayerNorm(input_dim),
24
- nn.Linear(input_dim, num_heads, bias=False),
25
- )
26
- for _ in range(depth)
27
- ]
28
- )
29
-
30
-
31
- def _concatenate_biases(
32
- projections: nn.ModuleList,
33
- pair_features: torch.Tensor,
34
- ) -> torch.Tensor:
35
- # pair_features: (..., d_pair); each projection: (..., h).
36
- return torch.cat(
37
- [projection(pair_features) for projection in projections], dim=-1
38
- ) # (..., depth * h)
39
-
40
-
41
- class DiffusionConditioning(nn.Module):
42
- """Prepare conditioned atom features and per-layer attention biases."""
43
-
44
- def __init__(
45
- self,
46
- token_s: int,
47
- token_z: int,
48
- atom_s: int,
49
- atom_z: int,
50
- atoms_per_window_queries: int = 32,
51
- atoms_per_window_keys: int = 128,
52
- atom_encoder_depth: int = 3,
53
- atom_encoder_heads: int = 4,
54
- token_transformer_depth: int = 24,
55
- token_transformer_heads: int = 8,
56
- atom_decoder_depth: int = 3,
57
- atom_decoder_heads: int = 4,
58
- atom_feature_dim: int = 128,
59
- conditioning_transition_layers: int = 2,
60
- use_no_atom_char: bool = False,
61
- use_atom_backbone_feat: bool = False,
62
- use_residue_feats_atoms: bool = False,
63
- ) -> None:
64
- super().__init__()
65
- self.pairwise_conditioner = PairwiseConditioning(
66
- token_z=token_z,
67
- dim_token_rel_pos_feats=token_z,
68
- num_transitions=conditioning_transition_layers,
69
- )
70
- self.atom_encoder = AtomEncoder(
71
- atom_s=atom_s,
72
- atom_z=atom_z,
73
- token_s=token_s,
74
- token_z=token_z,
75
- atoms_per_window_queries=atoms_per_window_queries,
76
- atoms_per_window_keys=atoms_per_window_keys,
77
- atom_feature_dim=atom_feature_dim,
78
- structure_prediction=True,
79
- use_no_atom_char=use_no_atom_char,
80
- use_atom_backbone_feat=use_atom_backbone_feat,
81
- use_residue_feats_atoms=use_residue_feats_atoms,
82
- )
83
- self.atom_enc_proj_z = _bias_projections(
84
- atom_encoder_depth,
85
- atom_z,
86
- atom_encoder_heads,
87
- )
88
- self.atom_dec_proj_z = _bias_projections(
89
- atom_decoder_depth,
90
- atom_z,
91
- atom_decoder_heads,
92
- )
93
- self.token_trans_proj_z = _bias_projections(
94
- token_transformer_depth,
95
- token_z,
96
- token_transformer_heads,
97
- )
98
-
99
- def forward(
100
- self,
101
- s_trunk: torch.Tensor,
102
- z_trunk: torch.Tensor,
103
- relative_position_encoding: torch.Tensor,
104
- feats: dict[str, torch.Tensor],
105
- ) -> tuple[
106
- torch.Tensor,
107
- torch.Tensor,
108
- Callable[[torch.Tensor], torch.Tensor],
109
- torch.Tensor,
110
- torch.Tensor,
111
- torch.Tensor,
112
- ]:
113
- """Return conditioned atom tensors and concatenated layer biases.
114
-
115
- ``S`` has shape ``(b, n, d_s)`` and each ``Z`` tensor has shape
116
- ``(b, n, n, d_z)``. Biases are concatenated in transformer-block
117
- order so downstream code can select them by layer.
118
- """
119
-
120
- # b is batch size, t token count, a atom count, and k the atom-window count.
121
- z_conditioned = self.pairwise_conditioner(
122
- z_trunk,
123
- relative_position_encoding,
124
- ) # (b, t, t, d_z)
125
- q, c, p, to_keys = self.atom_encoder(
126
- feats=feats,
127
- s_trunk=s_trunk,
128
- z=z_conditioned,
129
- ) # q/c: (b, a, d_a); p: (b, k, w, h_k, d_p); to_keys: callable
130
- atom_encoder_bias = _concatenate_biases(
131
- self.atom_enc_proj_z, p
132
- ) # (b, k, w, h_k, depth_enc * heads_enc)
133
- atom_decoder_bias = _concatenate_biases(
134
- self.atom_dec_proj_z, p
135
- ) # (b, k, w, h_k, depth_dec * heads_dec)
136
- token_transformer_bias = _concatenate_biases(
137
- self.token_trans_proj_z,
138
- z_conditioned,
139
- ) # (b, t, t, depth_token * heads_token)
140
- return (
141
- q,
142
- c,
143
- to_keys,
144
- atom_encoder_bias,
145
- atom_decoder_bias,
146
- token_transformer_bias,
147
- ) # tensor shapes are traced above; to_keys is the atom-key gatherer
 
1
+ """Precompute pair and atom biases used by the Boltz2 diffusion stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from collections.abc import Callable
8
+ from torch import nn
9
+
10
+ from .vb_modules_encodersv2 import AtomEncoder, PairwiseConditioning
11
+
12
+
13
+ def _bias_projections(
14
+ depth: int,
15
+ input_dim: int,
16
+ num_heads: int,
17
+ ) -> nn.ModuleList:
18
+ """Build one normalized, bias-free projection per transformer block."""
19
+
20
+ return nn.ModuleList(
21
+ [
22
+ nn.Sequential(
23
+ nn.LayerNorm(input_dim),
24
+ nn.Linear(input_dim, num_heads, bias=False),
25
+ )
26
+ for _ in range(depth)
27
+ ]
28
+ )
29
+
30
+
31
+ def _concatenate_biases(
32
+ projections: nn.ModuleList,
33
+ pair_features: torch.Tensor,
34
+ ) -> torch.Tensor:
35
+ # pair_features: (..., d_pair); each projection: (..., h).
36
+ return torch.cat(
37
+ [projection(pair_features) for projection in projections], dim=-1
38
+ ) # (..., depth * h)
39
+
40
+
41
+ class DiffusionConditioning(nn.Module):
42
+ """Prepare conditioned atom features and per-layer attention biases."""
43
+
44
+ def __init__(
45
+ self,
46
+ token_s: int,
47
+ token_z: int,
48
+ atom_s: int,
49
+ atom_z: int,
50
+ atoms_per_window_queries: int = 32,
51
+ atoms_per_window_keys: int = 128,
52
+ atom_encoder_depth: int = 3,
53
+ atom_encoder_heads: int = 4,
54
+ token_transformer_depth: int = 24,
55
+ token_transformer_heads: int = 8,
56
+ atom_decoder_depth: int = 3,
57
+ atom_decoder_heads: int = 4,
58
+ atom_feature_dim: int = 128,
59
+ conditioning_transition_layers: int = 2,
60
+ use_no_atom_char: bool = False,
61
+ use_atom_backbone_feat: bool = False,
62
+ use_residue_feats_atoms: bool = False,
63
+ ) -> None:
64
+ super().__init__()
65
+ self.pairwise_conditioner = PairwiseConditioning(
66
+ token_z=token_z,
67
+ dim_token_rel_pos_feats=token_z,
68
+ num_transitions=conditioning_transition_layers,
69
+ )
70
+ self.atom_encoder = AtomEncoder(
71
+ atom_s=atom_s,
72
+ atom_z=atom_z,
73
+ token_s=token_s,
74
+ token_z=token_z,
75
+ atoms_per_window_queries=atoms_per_window_queries,
76
+ atoms_per_window_keys=atoms_per_window_keys,
77
+ atom_feature_dim=atom_feature_dim,
78
+ structure_prediction=True,
79
+ use_no_atom_char=use_no_atom_char,
80
+ use_atom_backbone_feat=use_atom_backbone_feat,
81
+ use_residue_feats_atoms=use_residue_feats_atoms,
82
+ )
83
+ self.atom_enc_proj_z = _bias_projections(
84
+ atom_encoder_depth,
85
+ atom_z,
86
+ atom_encoder_heads,
87
+ )
88
+ self.atom_dec_proj_z = _bias_projections(
89
+ atom_decoder_depth,
90
+ atom_z,
91
+ atom_decoder_heads,
92
+ )
93
+ self.token_trans_proj_z = _bias_projections(
94
+ token_transformer_depth,
95
+ token_z,
96
+ token_transformer_heads,
97
+ )
98
+
99
+ def forward(
100
+ self,
101
+ s_trunk: torch.Tensor,
102
+ z_trunk: torch.Tensor,
103
+ relative_position_encoding: torch.Tensor,
104
+ feats: dict[str, torch.Tensor],
105
+ ) -> tuple[
106
+ torch.Tensor,
107
+ torch.Tensor,
108
+ Callable[[torch.Tensor], torch.Tensor],
109
+ torch.Tensor,
110
+ torch.Tensor,
111
+ torch.Tensor,
112
+ ]:
113
+ """Return conditioned atom tensors and concatenated layer biases.
114
+
115
+ ``S`` has shape ``(b, n, d_s)`` and each ``Z`` tensor has shape
116
+ ``(b, n, n, d_z)``. Biases are concatenated in transformer-block
117
+ order so downstream code can select them by layer.
118
+ """
119
+
120
+ # b is batch size, t token count, a atom count, and k the atom-window count.
121
+ z_conditioned = self.pairwise_conditioner(
122
+ z_trunk,
123
+ relative_position_encoding,
124
+ ) # (b, t, t, d_z)
125
+ q, c, p, to_keys = self.atom_encoder(
126
+ feats=feats,
127
+ s_trunk=s_trunk,
128
+ z=z_conditioned,
129
+ ) # q/c: (b, a, d_a); p: (b, k, w, h_k, d_p); to_keys: callable
130
+ atom_encoder_bias = _concatenate_biases(
131
+ self.atom_enc_proj_z, p
132
+ ) # (b, k, w, h_k, depth_enc * heads_enc)
133
+ atom_decoder_bias = _concatenate_biases(
134
+ self.atom_dec_proj_z, p
135
+ ) # (b, k, w, h_k, depth_dec * heads_dec)
136
+ token_transformer_bias = _concatenate_biases(
137
+ self.token_trans_proj_z,
138
+ z_conditioned,
139
+ ) # (b, t, t, depth_token * heads_token)
140
+ return (
141
+ q,
142
+ c,
143
+ to_keys,
144
+ atom_encoder_bias,
145
+ atom_decoder_bias,
146
+ token_transformer_bias,
147
+ ) # tensor shapes are traced above; to_keys is the atom-key gatherer
fastplms/models/boltz/vb_modules_diffusionv2.py CHANGED
@@ -1,675 +1,675 @@
1
- """Denoise Boltz2 atom coordinates with optional structure-steering potentials."""
2
-
3
- # Based on https://github.com/lucidrains/alphafold3-pytorch.
4
- # MIT License, Copyright (c) 2024 Phil Wang.
5
-
6
- from __future__ import annotations
7
-
8
- import numpy as np
9
- import torch
10
- import torch.nn.functional as F
11
-
12
- from math import sqrt
13
- from typing import Any
14
- from einops import rearrange
15
- from torch import nn
16
- from torch.nn import Module
17
- from tqdm.auto import tqdm
18
-
19
- from . import vb_const as const
20
- from . import vb_layers_initialize as init
21
- from .vb_loss_diffusionv2 import (
22
- smooth_lddt_loss,
23
- weighted_rigid_align,
24
- )
25
- from .vb_modules_encodersv2 import (
26
- AtomAttentionDecoder,
27
- AtomAttentionEncoder,
28
- SingleConditioning,
29
- )
30
- from .vb_modules_transformersv2 import (
31
- DiffusionTransformer,
32
- )
33
- from .vb_modules_utils import (
34
- LinearNoBias,
35
- center_random_augmentation,
36
- compute_random_augmentation,
37
- default,
38
- log,
39
- )
40
- from .vb_potentials_potentials import get_potentials
41
-
42
-
43
- class DiffusionModule(Module):
44
- """Predict coordinate updates from token and local atom attention."""
45
-
46
- def __init__(
47
- self,
48
- token_s: int,
49
- atom_s: int,
50
- atoms_per_window_queries: int = 32,
51
- atoms_per_window_keys: int = 128,
52
- sigma_data: int = 16,
53
- dim_fourier: int = 256,
54
- atom_encoder_depth: int = 3,
55
- atom_encoder_heads: int = 4,
56
- token_transformer_depth: int = 24,
57
- token_transformer_heads: int = 8,
58
- atom_decoder_depth: int = 3,
59
- atom_decoder_heads: int = 4,
60
- conditioning_transition_layers: int = 2,
61
- activation_checkpointing: bool = False,
62
- transformer_post_ln: bool = False,
63
- ) -> None:
64
- super().__init__()
65
-
66
- self.atoms_per_window_queries = atoms_per_window_queries
67
- self.atoms_per_window_keys = atoms_per_window_keys
68
- self.sigma_data = sigma_data
69
- self.activation_checkpointing = activation_checkpointing
70
-
71
- # Token conditioning shares trunk features across repeated diffusion samples.
72
- self.single_conditioner = SingleConditioning(
73
- sigma_data=sigma_data,
74
- token_s=token_s,
75
- dim_fourier=dim_fourier,
76
- num_transitions=conditioning_transition_layers,
77
- )
78
-
79
- self.atom_attention_encoder = AtomAttentionEncoder(
80
- atom_s=atom_s,
81
- token_s=token_s,
82
- atoms_per_window_queries=atoms_per_window_queries,
83
- atoms_per_window_keys=atoms_per_window_keys,
84
- atom_encoder_depth=atom_encoder_depth,
85
- atom_encoder_heads=atom_encoder_heads,
86
- structure_prediction=True,
87
- activation_checkpointing=activation_checkpointing,
88
- transformer_post_layer_norm=transformer_post_ln,
89
- )
90
-
91
- self.s_to_a_linear = nn.Sequential(
92
- nn.LayerNorm(2 * token_s), LinearNoBias(2 * token_s, 2 * token_s)
93
- )
94
- init.final_init_(self.s_to_a_linear[1].weight)
95
-
96
- self.token_transformer = DiffusionTransformer(
97
- dim=2 * token_s,
98
- dim_single_cond=2 * token_s,
99
- depth=token_transformer_depth,
100
- heads=token_transformer_heads,
101
- activation_checkpointing=activation_checkpointing,
102
- )
103
-
104
- self.a_norm = nn.LayerNorm(2 * token_s)
105
-
106
- self.atom_attention_decoder = AtomAttentionDecoder(
107
- atom_s=atom_s,
108
- token_s=token_s,
109
- attn_window_queries=atoms_per_window_queries,
110
- attn_window_keys=atoms_per_window_keys,
111
- atom_decoder_depth=atom_decoder_depth,
112
- atom_decoder_heads=atom_decoder_heads,
113
- activation_checkpointing=activation_checkpointing,
114
- )
115
-
116
- def forward(
117
- self,
118
- s_inputs: torch.Tensor, # (b, t, d_s)
119
- s_trunk: torch.Tensor, # (b, t, d_s)
120
- r_noisy: torch.Tensor, # (b * m, a, 3)
121
- times: torch.Tensor, # (b * m,)
122
- feats: dict[str, torch.Tensor],
123
- diffusion_conditioning: dict[str, Any],
124
- multiplicity: int = 1,
125
- ) -> torch.Tensor:
126
- # b: base batch; m: multiplicity; t: tokens; a: atoms; d_s: token width.
127
- if self.activation_checkpointing:
128
- s, _normed_fourier = torch.utils.checkpoint.checkpoint(
129
- self.single_conditioner,
130
- times,
131
- s_trunk.repeat_interleave(multiplicity, 0),
132
- s_inputs.repeat_interleave(multiplicity, 0),
133
- use_reentrant=False,
134
- ) # s: (b * m, t, 2 * d_s); Fourier features: (b * m, d_fourier)
135
- else:
136
- s, _normed_fourier = self.single_conditioner(
137
- times,
138
- s_trunk.repeat_interleave(multiplicity, 0),
139
- s_inputs.repeat_interleave(multiplicity, 0),
140
- ) # s: (b * m, t, 2 * d_s); Fourier features: (b * m, d_fourier)
141
-
142
- # Sequence-local Atom Attention and aggregation to coarse-grained tokens
143
- a, q_skip, c_skip, to_keys = self.atom_attention_encoder(
144
- feats=feats,
145
- q=diffusion_conditioning["q"].float(),
146
- c=diffusion_conditioning["c"].float(),
147
- atom_enc_bias=diffusion_conditioning["atom_enc_bias"].float(),
148
- to_keys=diffusion_conditioning["to_keys"],
149
- r=r_noisy,
150
- multiplicity=multiplicity,
151
- ) # a: (b * m, t, 2 * d_s); q_skip/c_skip: (b * m, n_atom, d_a)
152
-
153
- # Full self-attention on token level
154
- a = a + self.s_to_a_linear(s) # (b * m, t, 2 * d_s)
155
-
156
- mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
157
- a = self.token_transformer(
158
- a,
159
- mask=mask.float(),
160
- s=s,
161
- bias=diffusion_conditioning[
162
- "token_trans_bias"
163
- ].float(), # note z is not expanded with multiplicity until after bias is computed
164
- multiplicity=multiplicity,
165
- ) # (b * m, t, 2 * d_s)
166
- a = self.a_norm(a) # (b * m, t, 2 * d_s)
167
-
168
- # Broadcast token activations to atoms and run Sequence-local Atom Attention
169
- r_update = self.atom_attention_decoder(
170
- a=a,
171
- q=q_skip,
172
- c=c_skip,
173
- atom_dec_bias=diffusion_conditioning["atom_dec_bias"].float(),
174
- feats=feats,
175
- multiplicity=multiplicity,
176
- to_keys=to_keys,
177
- ) # (b * m, n_atom, 3)
178
-
179
- return r_update # (b * m, n_atom, 3)
180
-
181
-
182
- class AtomDiffusion(Module):
183
- def __init__(
184
- self,
185
- score_model_args: dict[str, Any],
186
- num_sampling_steps: int = 5, # number of sampling steps
187
- sigma_min: float = 0.0004, # min noise level
188
- sigma_max: float = 160.0, # max noise level
189
- sigma_data: float = 16.0, # standard deviation of data distribution
190
- rho: float = 7, # controls the sampling schedule
191
- # Mean of the log-normal training-noise distribution.
192
- P_mean: float = -1.2,
193
- # Standard deviation of the log-normal training-noise distribution.
194
- P_std: float = 1.5,
195
- gamma_0: float = 0.8,
196
- gamma_min: float = 1.0,
197
- noise_scale: float = 1.003,
198
- step_scale: float = 1.5,
199
- step_scale_random: list[float] | None = None,
200
- coordinate_augmentation: bool = True,
201
- coordinate_augmentation_inference: bool | None = None,
202
- compile_score: bool = False,
203
- alignment_reverse_diff: bool = False,
204
- synchronize_sigmas: bool = False,
205
- ) -> None:
206
- super().__init__()
207
- self.score_model = DiffusionModule(
208
- **score_model_args,
209
- )
210
- if compile_score:
211
- self.score_model = torch.compile(self.score_model, dynamic=False, fullgraph=False)
212
-
213
- self.sigma_min = sigma_min
214
- self.sigma_max = sigma_max
215
- self.sigma_data = sigma_data
216
- self.rho = rho
217
- self.P_mean = P_mean
218
- self.P_std = P_std
219
- self.num_sampling_steps = num_sampling_steps
220
- self.gamma_0 = gamma_0
221
- self.gamma_min = gamma_min
222
- self.noise_scale = noise_scale
223
- self.step_scale = step_scale
224
- self.step_scale_random = step_scale_random
225
- self.coordinate_augmentation = coordinate_augmentation
226
- self.coordinate_augmentation_inference = (
227
- coordinate_augmentation_inference
228
- if coordinate_augmentation_inference is not None
229
- else coordinate_augmentation
230
- )
231
- self.alignment_reverse_diff = alignment_reverse_diff
232
- self.synchronize_sigmas = synchronize_sigmas
233
-
234
- self.token_s = score_model_args["token_s"]
235
- self.register_buffer("zero", torch.tensor(0.0), persistent=False)
236
-
237
- @property
238
- def device(self) -> torch.device:
239
- return next(self.score_model.parameters()).device
240
-
241
- def c_skip(self, sigma: torch.Tensor) -> torch.Tensor:
242
- # sigma: (...); coefficient preserves shape (...).
243
- return (self.sigma_data**2) / (sigma**2 + self.sigma_data**2)
244
-
245
- def c_out(self, sigma: torch.Tensor) -> torch.Tensor:
246
- # sigma: (...); coefficient preserves shape (...).
247
- return sigma * self.sigma_data / torch.sqrt(self.sigma_data**2 + sigma**2)
248
-
249
- def c_in(self, sigma: torch.Tensor) -> torch.Tensor:
250
- # sigma: (...); coefficient preserves shape (...).
251
- return 1 / torch.sqrt(sigma**2 + self.sigma_data**2)
252
-
253
- def c_noise(self, sigma: torch.Tensor) -> torch.Tensor:
254
- # sigma: (...); log-noise condition preserves shape (...).
255
- return log(sigma / self.sigma_data) * 0.25
256
-
257
- def preconditioned_network_forward(
258
- self,
259
- noised_atom_coords: torch.Tensor, #: Float['b m 3'],
260
- sigma: torch.Tensor | float, #: Float['b'] | Float[' '] | float,
261
- network_condition_kwargs: dict[str, Any],
262
- ) -> torch.Tensor:
263
- # noised_atom_coords: (s, a, 3); sigma: float or (s,).
264
- batch, device = noised_atom_coords.shape[0], noised_atom_coords.device
265
-
266
- if isinstance(sigma, float):
267
- sigma = torch.full((batch,), sigma, device=device) # (s,) after a scalar input is expanded
268
-
269
- padded_sigma = rearrange(sigma, "b -> b 1 1") # (s, 1, 1)
270
-
271
- r_update = self.score_model(
272
- r_noisy=self.c_in(padded_sigma) * noised_atom_coords,
273
- times=self.c_noise(sigma),
274
- **network_condition_kwargs,
275
- ) # (s, a, 3)
276
-
277
- denoised_coords = (
278
- self.c_skip(padded_sigma) * noised_atom_coords + self.c_out(padded_sigma) * r_update
279
- ) # (s, a, 3)
280
- return denoised_coords
281
-
282
- def sample_schedule(self, num_sampling_steps: int | None = None) -> torch.Tensor:
283
- # n_step is the requested sampling-step count; the returned schedule has n_step + 1 values.
284
- num_sampling_steps = default(num_sampling_steps, self.num_sampling_steps)
285
- inv_rho = 1 / self.rho
286
-
287
- steps = torch.arange(num_sampling_steps, device=self.device, dtype=torch.float32) # (n_step,)
288
- sigmas = (
289
- self.sigma_max**inv_rho
290
- + steps / (num_sampling_steps - 1) * (self.sigma_min**inv_rho - self.sigma_max**inv_rho)
291
- ) ** self.rho # (n_sigma,); schedule or batch axis is determined by the caller
292
-
293
- sigmas = sigmas * self.sigma_data # (n_sigma,); schedule or batch axis is determined by the caller
294
-
295
- sigmas = F.pad(sigmas, (0, 1), value=0.0) # last step is sigma value of 0.
296
- return sigmas
297
-
298
- def sample(
299
- self,
300
- atom_mask: torch.Tensor,
301
- num_sampling_steps: int | None = None,
302
- multiplicity: int = 1,
303
- max_parallel_samples: int | None = None,
304
- steering_args: dict[str, Any] | None = None,
305
- verbose: bool = False,
306
- **network_condition_kwargs: Any,
307
- ) -> dict[str, Any]:
308
- # a is atom count; m is multiplicity, including steering particles.
309
- # s is the current sample axis after batch repetition or particle resampling.
310
- if steering_args is not None and (
311
- steering_args["fk_steering"]
312
- or steering_args["physical_guidance_update"]
313
- or steering_args["contact_guidance_update"]
314
- ):
315
- potentials = get_potentials(steering_args, boltz2=True)
316
-
317
- if steering_args["fk_steering"]:
318
- multiplicity = multiplicity * steering_args["num_particles"]
319
- energy_traj = torch.empty((multiplicity, 0), device=self.device) # (m, n_evaluation)
320
- resample_weights = torch.ones(multiplicity, device=self.device).reshape(
321
- -1, steering_args["num_particles"]
322
- ) # (n_group, n_particle)
323
- if steering_args["physical_guidance_update"] or steering_args["contact_guidance_update"]:
324
- scaled_guidance_update = torch.zeros(
325
- (multiplicity, *atom_mask.shape[1:], 3),
326
- dtype=torch.float32,
327
- device=self.device,
328
- ) # (s, a, 3)
329
- if max_parallel_samples is None:
330
- max_parallel_samples = multiplicity
331
-
332
- num_sampling_steps = default(num_sampling_steps, self.num_sampling_steps)
333
- atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (s, a); s expands on repetition and can shrink on resampling
334
-
335
- shape = (*atom_mask.shape, 3)
336
-
337
- # Pair each schedule value with the next sigma and gamma.
338
- sigmas = self.sample_schedule(num_sampling_steps) # (n_sigma,); schedule or batch axis is determined by the caller
339
- gammas = torch.where(sigmas > self.gamma_min, self.gamma_0, 0.0) # (n_step + 1,)
340
- sigmas_and_gammas = list(zip(sigmas[:-1], sigmas[1:], gammas[1:], strict=True))
341
- if self.training and self.step_scale_random is not None:
342
- step_scale = np.random.choice(self.step_scale_random)
343
- else:
344
- step_scale = self.step_scale
345
-
346
- # atom position is noise at the beginning
347
- init_sigma = sigmas[0] # ()
348
- atom_coords = init_sigma * torch.randn(shape, device=self.device) # (s, a, 3)
349
- token_repr = None # None; the current sampler does not produce token features
350
- atom_coords_denoised = None # (s, a, 3) or None before the first denoising step
351
-
352
- for step_idx, (sigma_tm, sigma_t, gamma) in enumerate(
353
- tqdm(
354
- sigmas_and_gammas,
355
- desc="Boltz2: Diffusion sampling",
356
- unit="step",
357
- disable=not verbose,
358
- dynamic_ncols=True,
359
- leave=False,
360
- )
361
- ):
362
- random_R, random_tr = compute_random_augmentation(
363
- multiplicity, device=atom_coords.device, dtype=atom_coords.dtype
364
- ) # (m, 3, 3), (m, 1, 3)
365
- atom_coords = atom_coords - atom_coords.mean(dim=-2, keepdims=True) # (s, a, 3)
366
- atom_coords = torch.einsum("bmd,bds->bms", atom_coords, random_R) + random_tr # (s, a, 3)
367
- if atom_coords_denoised is not None:
368
- atom_coords_denoised -= atom_coords_denoised.mean(dim=-2, keepdims=True) # (s, a, 3) or None before the first denoising step
369
- atom_coords_denoised = (
370
- torch.einsum("bmd,bds->bms", atom_coords_denoised, random_R) + random_tr
371
- ) # (s, a, 3) or None before the first denoising step
372
- if (
373
- steering_args["physical_guidance_update"]
374
- or steering_args["contact_guidance_update"]
375
- ) and scaled_guidance_update is not None:
376
- scaled_guidance_update = torch.einsum(
377
- "bmd,bds->bms", scaled_guidance_update, random_R
378
- ) # (s, a, 3)
379
-
380
- sigma_tm, sigma_t, gamma = sigma_tm.item(), sigma_t.item(), gamma.item() # three scalar tensors () become Python floats
381
-
382
- t_hat = sigma_tm * (1 + gamma)
383
- steering_t = 1.0 - (step_idx / num_sampling_steps)
384
- noise_var = self.noise_scale**2 * (t_hat**2 - sigma_tm**2)
385
- eps = sqrt(noise_var) * torch.randn(shape, device=self.device) # (s, a, 3)
386
- atom_coords_noisy = atom_coords + eps # (s, a, 3)
387
-
388
- with torch.no_grad():
389
- atom_coords_denoised = torch.zeros_like(atom_coords_noisy) # (s, a, 3) or None before the first denoising step
390
- sample_ids = torch.arange(multiplicity).to(atom_coords_noisy.device) # (m,)
391
- sample_ids_chunks = sample_ids.chunk(multiplicity % max_parallel_samples + 1) # tuple of index tensors, each (n_chunk,)
392
-
393
- for sample_ids_chunk in sample_ids_chunks:
394
- atom_coords_denoised_chunk = self.preconditioned_network_forward(
395
- atom_coords_noisy[sample_ids_chunk],
396
- t_hat,
397
- network_condition_kwargs=dict(
398
- multiplicity=sample_ids_chunk.numel(),
399
- **network_condition_kwargs,
400
- ),
401
- ) # (n_chunk, a, 3)
402
- atom_coords_denoised[sample_ids_chunk] = atom_coords_denoised_chunk # (n_chunk, a, 3)
403
-
404
- if steering_args["fk_steering"] and (
405
- (step_idx % steering_args["fk_resampling_interval"] == 0 and noise_var > 0)
406
- or step_idx == num_sampling_steps - 1
407
- ):
408
- # Compute energy of x_0 prediction
409
- energy = torch.zeros(multiplicity, device=self.device) # (m,)
410
- for potential in potentials:
411
- parameters = potential.compute_parameters(steering_t)
412
- if parameters["resampling_weight"] > 0:
413
- component_energy = potential.compute(
414
- atom_coords_denoised,
415
- network_condition_kwargs["feats"],
416
- parameters,
417
- ) # (m,)
418
- energy += parameters["resampling_weight"] * component_energy # (m,)
419
- energy_traj = torch.cat((energy_traj, energy.unsqueeze(1)), dim=1) # (m, n_evaluation)
420
-
421
- # Compute log G values
422
- if step_idx == 0:
423
- log_G = -1 * energy # (m,)
424
- else:
425
- log_G = energy_traj[:, -2] - energy_traj[:, -1] # (m,)
426
-
427
- # Compute ll difference between guided and unguided transition distribution
428
- if (
429
- steering_args["physical_guidance_update"]
430
- or steering_args["contact_guidance_update"]
431
- ) and noise_var > 0:
432
- ll_difference = (eps**2 - (eps + scaled_guidance_update) ** 2).sum(
433
- dim=(-1, -2)
434
- ) / (2 * noise_var) # (m,)
435
- else:
436
- ll_difference = torch.zeros_like(energy) # (m,)
437
-
438
- # Compute resampling weights
439
- resample_weights = F.softmax(
440
- (ll_difference + steering_args["fk_lambda"] * log_G).reshape(
441
- -1, steering_args["num_particles"]
442
- ),
443
- dim=1,
444
- ) # (n_group, n_particle)
445
-
446
- # Compute guidance update to x_0 prediction
447
- if (
448
- steering_args["physical_guidance_update"]
449
- or steering_args["contact_guidance_update"]
450
- ) and step_idx < num_sampling_steps - 1:
451
- guidance_update = torch.zeros_like(atom_coords_denoised) # (s, a, 3)
452
- for guidance_step in range(steering_args["num_gd_steps"]):
453
- energy_gradient = torch.zeros_like(atom_coords_denoised) # (s, a, 3)
454
- for potential in potentials:
455
- parameters = potential.compute_parameters(steering_t)
456
- if (
457
- parameters["guidance_weight"] > 0
458
- and (guidance_step) % parameters["guidance_interval"] == 0
459
- ):
460
- energy_gradient += parameters[
461
- "guidance_weight"
462
- ] * potential.compute_gradient(
463
- atom_coords_denoised + guidance_update,
464
- network_condition_kwargs["feats"],
465
- parameters,
466
- ) # (s, a, 3)
467
- guidance_update -= energy_gradient # (s, a, 3)
468
- atom_coords_denoised += guidance_update # (s, a, 3) or None before the first denoising step
469
- scaled_guidance_update = (
470
- guidance_update * -1 * self.step_scale * (sigma_t - t_hat) / t_hat
471
- ) # (s, a, 3)
472
-
473
- if steering_args["fk_steering"] and (
474
- (step_idx % steering_args["fk_resampling_interval"] == 0 and noise_var > 0)
475
- or step_idx == num_sampling_steps - 1
476
- ):
477
- resample_indices = (
478
- torch.multinomial(
479
- resample_weights,
480
- resample_weights.shape[1] if step_idx < num_sampling_steps - 1 else 1,
481
- replacement=True,
482
- )
483
- + resample_weights.shape[1]
484
- * torch.arange(
485
- resample_weights.shape[0], device=resample_weights.device
486
- ).unsqueeze(-1)
487
- ).flatten() # (s_next,); final resampling keeps one particle per group
488
-
489
- atom_coords = atom_coords[resample_indices] # (s, a, 3)
490
- atom_coords_noisy = atom_coords_noisy[resample_indices] # (s, a, 3)
491
- atom_mask = atom_mask[resample_indices] # (s, a); s expands on repetition and can shrink on resampling
492
- if atom_coords_denoised is not None:
493
- atom_coords_denoised = atom_coords_denoised[resample_indices] # (s, a, 3) or None before the first denoising step
494
- energy_traj = energy_traj[resample_indices] # (s_next, n_evaluation)
495
- if (
496
- steering_args["physical_guidance_update"]
497
- or steering_args["contact_guidance_update"]
498
- ):
499
- scaled_guidance_update = scaled_guidance_update[resample_indices] # (s, a, 3)
500
- if token_repr is not None:
501
- token_repr = token_repr[resample_indices] # (s_next, *feature_shape)
502
-
503
- if self.alignment_reverse_diff:
504
- with torch.autocast("cuda", enabled=False):
505
- atom_coords_noisy = weighted_rigid_align(
506
- atom_coords_noisy.float(),
507
- atom_coords_denoised.float(),
508
- atom_mask.float(),
509
- atom_mask.float(),
510
- ) # (s, a, 3)
511
-
512
- atom_coords_noisy = atom_coords_noisy.to(atom_coords_denoised) # (s, a, 3)
513
-
514
- denoised_over_sigma = (atom_coords_noisy - atom_coords_denoised) / t_hat # (s, a, 3)
515
- atom_coords_next = (
516
- atom_coords_noisy + step_scale * (sigma_t - t_hat) * denoised_over_sigma
517
- ) # (s, a, 3)
518
-
519
- atom_coords = atom_coords_next # (s, a, 3)
520
-
521
- return dict(sample_atom_coords=atom_coords, diff_token_repr=token_repr)
522
-
523
- def loss_weight(self, sigma: torch.Tensor) -> torch.Tensor:
524
- # sigma: (...); weight preserves shape (...).
525
- return (sigma**2 + self.sigma_data**2) / ((sigma * self.sigma_data) ** 2)
526
-
527
- def noise_distribution(self, batch_size: int) -> torch.Tensor:
528
- # Return one noise scale per example, shape (batch_size,).
529
- return (
530
- self.sigma_data
531
- * (self.P_mean + self.P_std * torch.randn((batch_size,), device=self.device)).exp()
532
- )
533
-
534
- def forward(
535
- self,
536
- s_inputs: torch.Tensor,
537
- s_trunk: torch.Tensor,
538
- feats: dict[str, torch.Tensor],
539
- diffusion_conditioning: dict[str, Any],
540
- multiplicity: int = 1,
541
- ) -> dict[str, Any]:
542
- # training diffusion step
543
- batch_size = feats["coords"].shape[0] // multiplicity
544
-
545
- if self.synchronize_sigmas:
546
- sigmas = self.noise_distribution(batch_size).repeat_interleave(multiplicity, 0) # (n_sigma,); schedule or batch axis is determined by the caller
547
- else:
548
- sigmas = self.noise_distribution(batch_size * multiplicity) # (n_sigma,); schedule or batch axis is determined by the caller
549
- padded_sigmas = rearrange(sigmas, "b -> b 1 1") # (b * m, 1, 1)
550
-
551
- atom_coords = feats["coords"] # (s, a, 3)
552
-
553
- atom_mask = feats["atom_pad_mask"] # (s, a); s expands on repetition and can shrink on resampling
554
- atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (s, a); s expands on repetition and can shrink on resampling
555
-
556
- atom_coords = center_random_augmentation(
557
- atom_coords, atom_mask, augmentation=self.coordinate_augmentation
558
- ) # (s, a, 3)
559
-
560
- noise = torch.randn_like(atom_coords) # (b * m, a, 3)
561
- noised_atom_coords = atom_coords + padded_sigmas * noise # (b * m, a, 3)
562
-
563
- denoised_atom_coords = self.preconditioned_network_forward(
564
- noised_atom_coords,
565
- sigmas,
566
- network_condition_kwargs={
567
- "s_inputs": s_inputs,
568
- "s_trunk": s_trunk,
569
- "feats": feats,
570
- "multiplicity": multiplicity,
571
- "diffusion_conditioning": diffusion_conditioning,
572
- },
573
- ) # (b * m, a, 3)
574
-
575
- return {
576
- "denoised_atom_coords": denoised_atom_coords,
577
- "sigmas": sigmas,
578
- "aligned_true_atom_coords": atom_coords,
579
- }
580
-
581
- def compute_loss(
582
- self,
583
- feats: dict[str, torch.Tensor],
584
- out_dict: dict[str, torch.Tensor],
585
- add_smooth_lddt_loss: bool = True,
586
- nucleotide_loss_weight: float = 5.0,
587
- ligand_loss_weight: float = 10.0,
588
- multiplicity: int = 1,
589
- filter_by_plddt: float = 0.0,
590
- ) -> dict[str, Any]:
591
- # Coordinates: (b * m, a, 3); base feature masks: (b, a).
592
- with torch.autocast("cuda", enabled=False):
593
- denoised_atom_coords = out_dict["denoised_atom_coords"].float() # (b * m, a, 3)
594
- sigmas = out_dict["sigmas"].float() # (n_sigma,); schedule or batch axis is determined by the caller
595
-
596
- resolved_atom_mask_uni = feats["atom_resolved_mask"].float() # (b, a)
597
-
598
- if filter_by_plddt > 0:
599
- plddt_mask = feats["plddt"] > filter_by_plddt # (b, a)
600
- resolved_atom_mask_uni = resolved_atom_mask_uni * plddt_mask.float() # (b, a)
601
-
602
- resolved_atom_mask = resolved_atom_mask_uni.repeat_interleave(multiplicity, 0) # (b * m, a)
603
-
604
- align_weights = denoised_atom_coords.new_ones(denoised_atom_coords.shape[:2]) # (b * m, a)
605
- atom_type = (
606
- torch.bmm(
607
- feats["atom_to_token"].float(),
608
- feats["mol_type"].unsqueeze(-1).float(),
609
- )
610
- .squeeze(-1)
611
- .long()
612
- ) # (b, a)
613
- atom_type_mult = atom_type.repeat_interleave(multiplicity, 0) # (b * m, a)
614
-
615
- align_weights = (
616
- align_weights
617
- * (
618
- 1
619
- + nucleotide_loss_weight
620
- * (
621
- torch.eq(atom_type_mult, const.chain_type_ids["DNA"]).float()
622
- + torch.eq(atom_type_mult, const.chain_type_ids["RNA"]).float()
623
- )
624
- + ligand_loss_weight
625
- * torch.eq(atom_type_mult, const.chain_type_ids["NONPOLYMER"]).float()
626
- ).float()
627
- ) # (b * m, a)
628
-
629
- atom_coords = out_dict["aligned_true_atom_coords"].float() # (s, a, 3)
630
- atom_coords_aligned_ground_truth = weighted_rigid_align(
631
- atom_coords.detach(),
632
- denoised_atom_coords.detach(),
633
- align_weights.detach(),
634
- mask=feats["atom_resolved_mask"]
635
- .float()
636
- .repeat_interleave(multiplicity, 0)
637
- .detach(),
638
- ) # (b * m, a, 3)
639
-
640
- atom_coords_aligned_ground_truth = atom_coords_aligned_ground_truth.to(
641
- denoised_atom_coords
642
- ) # (b * m, a, 3)
643
-
644
- # weighted MSE loss of denoised atom positions
645
- mse_loss = ((denoised_atom_coords - atom_coords_aligned_ground_truth) ** 2).sum(dim=-1) # (b * m, a)
646
- mse_loss = torch.sum(mse_loss * align_weights * resolved_atom_mask, dim=-1) / (
647
- torch.sum(3 * align_weights * resolved_atom_mask, dim=-1) + 1e-5
648
- ) # (b * m,)
649
-
650
- # weight by sigma factor
651
- loss_weights = self.loss_weight(sigmas) # (b * m,)
652
- mse_loss = (mse_loss * loss_weights).mean() # ()
653
-
654
- total_loss = mse_loss # ()
655
-
656
- # proposed auxiliary smooth lddt loss
657
- lddt_loss = self.zero # ()
658
- if add_smooth_lddt_loss:
659
- lddt_loss = smooth_lddt_loss(
660
- denoised_atom_coords,
661
- feats["coords"],
662
- torch.eq(atom_type, const.chain_type_ids["DNA"]).float()
663
- + torch.eq(atom_type, const.chain_type_ids["RNA"]).float(),
664
- coords_mask=resolved_atom_mask_uni,
665
- multiplicity=multiplicity,
666
- ) # ()
667
-
668
- total_loss = total_loss + lddt_loss # ()
669
-
670
- loss_breakdown = {
671
- "mse_loss": mse_loss,
672
- "smooth_lddt_loss": lddt_loss,
673
- }
674
-
675
- return {"loss": total_loss, "loss_breakdown": loss_breakdown}
 
1
+ """Denoise Boltz2 atom coordinates with optional structure-steering potentials."""
2
+
3
+ # Based on https://github.com/lucidrains/alphafold3-pytorch.
4
+ # MIT License, Copyright (c) 2024 Phil Wang.
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn.functional as F
11
+
12
+ from math import sqrt
13
+ from typing import Any
14
+ from einops import rearrange
15
+ from torch import nn
16
+ from torch.nn import Module
17
+ from tqdm.auto import tqdm
18
+
19
+ from . import vb_const as const
20
+ from . import vb_layers_initialize as init
21
+ from .vb_loss_diffusionv2 import (
22
+ smooth_lddt_loss,
23
+ weighted_rigid_align,
24
+ )
25
+ from .vb_modules_encodersv2 import (
26
+ AtomAttentionDecoder,
27
+ AtomAttentionEncoder,
28
+ SingleConditioning,
29
+ )
30
+ from .vb_modules_transformersv2 import (
31
+ DiffusionTransformer,
32
+ )
33
+ from .vb_modules_utils import (
34
+ LinearNoBias,
35
+ center_random_augmentation,
36
+ compute_random_augmentation,
37
+ default,
38
+ log,
39
+ )
40
+ from .vb_potentials_potentials import get_potentials
41
+
42
+
43
+ class DiffusionModule(Module):
44
+ """Predict coordinate updates from token and local atom attention."""
45
+
46
+ def __init__(
47
+ self,
48
+ token_s: int,
49
+ atom_s: int,
50
+ atoms_per_window_queries: int = 32,
51
+ atoms_per_window_keys: int = 128,
52
+ sigma_data: int = 16,
53
+ dim_fourier: int = 256,
54
+ atom_encoder_depth: int = 3,
55
+ atom_encoder_heads: int = 4,
56
+ token_transformer_depth: int = 24,
57
+ token_transformer_heads: int = 8,
58
+ atom_decoder_depth: int = 3,
59
+ atom_decoder_heads: int = 4,
60
+ conditioning_transition_layers: int = 2,
61
+ activation_checkpointing: bool = False,
62
+ transformer_post_ln: bool = False,
63
+ ) -> None:
64
+ super().__init__()
65
+
66
+ self.atoms_per_window_queries = atoms_per_window_queries
67
+ self.atoms_per_window_keys = atoms_per_window_keys
68
+ self.sigma_data = sigma_data
69
+ self.activation_checkpointing = activation_checkpointing
70
+
71
+ # Token conditioning shares trunk features across repeated diffusion samples.
72
+ self.single_conditioner = SingleConditioning(
73
+ sigma_data=sigma_data,
74
+ token_s=token_s,
75
+ dim_fourier=dim_fourier,
76
+ num_transitions=conditioning_transition_layers,
77
+ )
78
+
79
+ self.atom_attention_encoder = AtomAttentionEncoder(
80
+ atom_s=atom_s,
81
+ token_s=token_s,
82
+ atoms_per_window_queries=atoms_per_window_queries,
83
+ atoms_per_window_keys=atoms_per_window_keys,
84
+ atom_encoder_depth=atom_encoder_depth,
85
+ atom_encoder_heads=atom_encoder_heads,
86
+ structure_prediction=True,
87
+ activation_checkpointing=activation_checkpointing,
88
+ transformer_post_layer_norm=transformer_post_ln,
89
+ )
90
+
91
+ self.s_to_a_linear = nn.Sequential(
92
+ nn.LayerNorm(2 * token_s), LinearNoBias(2 * token_s, 2 * token_s)
93
+ )
94
+ init.final_init_(self.s_to_a_linear[1].weight)
95
+
96
+ self.token_transformer = DiffusionTransformer(
97
+ dim=2 * token_s,
98
+ dim_single_cond=2 * token_s,
99
+ depth=token_transformer_depth,
100
+ heads=token_transformer_heads,
101
+ activation_checkpointing=activation_checkpointing,
102
+ )
103
+
104
+ self.a_norm = nn.LayerNorm(2 * token_s)
105
+
106
+ self.atom_attention_decoder = AtomAttentionDecoder(
107
+ atom_s=atom_s,
108
+ token_s=token_s,
109
+ attn_window_queries=atoms_per_window_queries,
110
+ attn_window_keys=atoms_per_window_keys,
111
+ atom_decoder_depth=atom_decoder_depth,
112
+ atom_decoder_heads=atom_decoder_heads,
113
+ activation_checkpointing=activation_checkpointing,
114
+ )
115
+
116
+ def forward(
117
+ self,
118
+ s_inputs: torch.Tensor, # (b, t, d_s)
119
+ s_trunk: torch.Tensor, # (b, t, d_s)
120
+ r_noisy: torch.Tensor, # (b * m, a, 3)
121
+ times: torch.Tensor, # (b * m,)
122
+ feats: dict[str, torch.Tensor],
123
+ diffusion_conditioning: dict[str, Any],
124
+ multiplicity: int = 1,
125
+ ) -> torch.Tensor:
126
+ # b: base batch; m: multiplicity; t: tokens; a: atoms; d_s: token width.
127
+ if self.activation_checkpointing:
128
+ s, _normed_fourier = torch.utils.checkpoint.checkpoint(
129
+ self.single_conditioner,
130
+ times,
131
+ s_trunk.repeat_interleave(multiplicity, 0),
132
+ s_inputs.repeat_interleave(multiplicity, 0),
133
+ use_reentrant=False,
134
+ ) # s: (b * m, t, 2 * d_s); Fourier features: (b * m, d_fourier)
135
+ else:
136
+ s, _normed_fourier = self.single_conditioner(
137
+ times,
138
+ s_trunk.repeat_interleave(multiplicity, 0),
139
+ s_inputs.repeat_interleave(multiplicity, 0),
140
+ ) # s: (b * m, t, 2 * d_s); Fourier features: (b * m, d_fourier)
141
+
142
+ # Sequence-local Atom Attention and aggregation to coarse-grained tokens
143
+ a, q_skip, c_skip, to_keys = self.atom_attention_encoder(
144
+ feats=feats,
145
+ q=diffusion_conditioning["q"].float(),
146
+ c=diffusion_conditioning["c"].float(),
147
+ atom_enc_bias=diffusion_conditioning["atom_enc_bias"].float(),
148
+ to_keys=diffusion_conditioning["to_keys"],
149
+ r=r_noisy,
150
+ multiplicity=multiplicity,
151
+ ) # a: (b * m, t, 2 * d_s); q_skip/c_skip: (b * m, n_atom, d_a)
152
+
153
+ # Full self-attention on token level
154
+ a = a + self.s_to_a_linear(s) # (b * m, t, 2 * d_s)
155
+
156
+ mask = feats["token_pad_mask"].repeat_interleave(multiplicity, 0) # (b * m, t)
157
+ a = self.token_transformer(
158
+ a,
159
+ mask=mask.float(),
160
+ s=s,
161
+ bias=diffusion_conditioning[
162
+ "token_trans_bias"
163
+ ].float(), # note z is not expanded with multiplicity until after bias is computed
164
+ multiplicity=multiplicity,
165
+ ) # (b * m, t, 2 * d_s)
166
+ a = self.a_norm(a) # (b * m, t, 2 * d_s)
167
+
168
+ # Broadcast token activations to atoms and run Sequence-local Atom Attention
169
+ r_update = self.atom_attention_decoder(
170
+ a=a,
171
+ q=q_skip,
172
+ c=c_skip,
173
+ atom_dec_bias=diffusion_conditioning["atom_dec_bias"].float(),
174
+ feats=feats,
175
+ multiplicity=multiplicity,
176
+ to_keys=to_keys,
177
+ ) # (b * m, n_atom, 3)
178
+
179
+ return r_update # (b * m, n_atom, 3)
180
+
181
+
182
+ class AtomDiffusion(Module):
183
+ def __init__(
184
+ self,
185
+ score_model_args: dict[str, Any],
186
+ num_sampling_steps: int = 5, # number of sampling steps
187
+ sigma_min: float = 0.0004, # min noise level
188
+ sigma_max: float = 160.0, # max noise level
189
+ sigma_data: float = 16.0, # standard deviation of data distribution
190
+ rho: float = 7, # controls the sampling schedule
191
+ # Mean of the log-normal training-noise distribution.
192
+ P_mean: float = -1.2,
193
+ # Standard deviation of the log-normal training-noise distribution.
194
+ P_std: float = 1.5,
195
+ gamma_0: float = 0.8,
196
+ gamma_min: float = 1.0,
197
+ noise_scale: float = 1.003,
198
+ step_scale: float = 1.5,
199
+ step_scale_random: list[float] | None = None,
200
+ coordinate_augmentation: bool = True,
201
+ coordinate_augmentation_inference: bool | None = None,
202
+ compile_score: bool = False,
203
+ alignment_reverse_diff: bool = False,
204
+ synchronize_sigmas: bool = False,
205
+ ) -> None:
206
+ super().__init__()
207
+ self.score_model = DiffusionModule(
208
+ **score_model_args,
209
+ )
210
+ if compile_score:
211
+ self.score_model = torch.compile(self.score_model, dynamic=False, fullgraph=False)
212
+
213
+ self.sigma_min = sigma_min
214
+ self.sigma_max = sigma_max
215
+ self.sigma_data = sigma_data
216
+ self.rho = rho
217
+ self.P_mean = P_mean
218
+ self.P_std = P_std
219
+ self.num_sampling_steps = num_sampling_steps
220
+ self.gamma_0 = gamma_0
221
+ self.gamma_min = gamma_min
222
+ self.noise_scale = noise_scale
223
+ self.step_scale = step_scale
224
+ self.step_scale_random = step_scale_random
225
+ self.coordinate_augmentation = coordinate_augmentation
226
+ self.coordinate_augmentation_inference = (
227
+ coordinate_augmentation_inference
228
+ if coordinate_augmentation_inference is not None
229
+ else coordinate_augmentation
230
+ )
231
+ self.alignment_reverse_diff = alignment_reverse_diff
232
+ self.synchronize_sigmas = synchronize_sigmas
233
+
234
+ self.token_s = score_model_args["token_s"]
235
+ self.register_buffer("zero", torch.tensor(0.0), persistent=False)
236
+
237
+ @property
238
+ def device(self) -> torch.device:
239
+ return next(self.score_model.parameters()).device
240
+
241
+ def c_skip(self, sigma: torch.Tensor) -> torch.Tensor:
242
+ # sigma: (...); coefficient preserves shape (...).
243
+ return (self.sigma_data**2) / (sigma**2 + self.sigma_data**2)
244
+
245
+ def c_out(self, sigma: torch.Tensor) -> torch.Tensor:
246
+ # sigma: (...); coefficient preserves shape (...).
247
+ return sigma * self.sigma_data / torch.sqrt(self.sigma_data**2 + sigma**2)
248
+
249
+ def c_in(self, sigma: torch.Tensor) -> torch.Tensor:
250
+ # sigma: (...); coefficient preserves shape (...).
251
+ return 1 / torch.sqrt(sigma**2 + self.sigma_data**2)
252
+
253
+ def c_noise(self, sigma: torch.Tensor) -> torch.Tensor:
254
+ # sigma: (...); log-noise condition preserves shape (...).
255
+ return log(sigma / self.sigma_data) * 0.25
256
+
257
+ def preconditioned_network_forward(
258
+ self,
259
+ noised_atom_coords: torch.Tensor, #: Float['b m 3'],
260
+ sigma: torch.Tensor | float, #: Float['b'] | Float[' '] | float,
261
+ network_condition_kwargs: dict[str, Any],
262
+ ) -> torch.Tensor:
263
+ # noised_atom_coords: (s, a, 3); sigma: float or (s,).
264
+ batch, device = noised_atom_coords.shape[0], noised_atom_coords.device
265
+
266
+ if isinstance(sigma, float):
267
+ sigma = torch.full((batch,), sigma, device=device) # (s,) after a scalar input is expanded
268
+
269
+ padded_sigma = rearrange(sigma, "b -> b 1 1") # (s, 1, 1)
270
+
271
+ r_update = self.score_model(
272
+ r_noisy=self.c_in(padded_sigma) * noised_atom_coords,
273
+ times=self.c_noise(sigma),
274
+ **network_condition_kwargs,
275
+ ) # (s, a, 3)
276
+
277
+ denoised_coords = (
278
+ self.c_skip(padded_sigma) * noised_atom_coords + self.c_out(padded_sigma) * r_update
279
+ ) # (s, a, 3)
280
+ return denoised_coords
281
+
282
+ def sample_schedule(self, num_sampling_steps: int | None = None) -> torch.Tensor:
283
+ # n_step is the requested sampling-step count; the returned schedule has n_step + 1 values.
284
+ num_sampling_steps = default(num_sampling_steps, self.num_sampling_steps)
285
+ inv_rho = 1 / self.rho
286
+
287
+ steps = torch.arange(num_sampling_steps, device=self.device, dtype=torch.float32) # (n_step,)
288
+ sigmas = (
289
+ self.sigma_max**inv_rho
290
+ + steps / (num_sampling_steps - 1) * (self.sigma_min**inv_rho - self.sigma_max**inv_rho)
291
+ ) ** self.rho # (n_sigma,); schedule or batch axis is determined by the caller
292
+
293
+ sigmas = sigmas * self.sigma_data # (n_sigma,); schedule or batch axis is determined by the caller
294
+
295
+ sigmas = F.pad(sigmas, (0, 1), value=0.0) # last step is sigma value of 0.
296
+ return sigmas
297
+
298
+ def sample(
299
+ self,
300
+ atom_mask: torch.Tensor,
301
+ num_sampling_steps: int | None = None,
302
+ multiplicity: int = 1,
303
+ max_parallel_samples: int | None = None,
304
+ steering_args: dict[str, Any] | None = None,
305
+ verbose: bool = False,
306
+ **network_condition_kwargs: Any,
307
+ ) -> dict[str, Any]:
308
+ # a is atom count; m is multiplicity, including steering particles.
309
+ # s is the current sample axis after batch repetition or particle resampling.
310
+ if steering_args is not None and (
311
+ steering_args["fk_steering"]
312
+ or steering_args["physical_guidance_update"]
313
+ or steering_args["contact_guidance_update"]
314
+ ):
315
+ potentials = get_potentials(steering_args, boltz2=True)
316
+
317
+ if steering_args["fk_steering"]:
318
+ multiplicity = multiplicity * steering_args["num_particles"]
319
+ energy_traj = torch.empty((multiplicity, 0), device=self.device) # (m, n_evaluation)
320
+ resample_weights = torch.ones(multiplicity, device=self.device).reshape(
321
+ -1, steering_args["num_particles"]
322
+ ) # (n_group, n_particle)
323
+ if steering_args["physical_guidance_update"] or steering_args["contact_guidance_update"]:
324
+ scaled_guidance_update = torch.zeros(
325
+ (multiplicity, *atom_mask.shape[1:], 3),
326
+ dtype=torch.float32,
327
+ device=self.device,
328
+ ) # (s, a, 3)
329
+ if max_parallel_samples is None:
330
+ max_parallel_samples = multiplicity
331
+
332
+ num_sampling_steps = default(num_sampling_steps, self.num_sampling_steps)
333
+ atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (s, a); s expands on repetition and can shrink on resampling
334
+
335
+ shape = (*atom_mask.shape, 3)
336
+
337
+ # Pair each schedule value with the next sigma and gamma.
338
+ sigmas = self.sample_schedule(num_sampling_steps) # (n_sigma,); schedule or batch axis is determined by the caller
339
+ gammas = torch.where(sigmas > self.gamma_min, self.gamma_0, 0.0) # (n_step + 1,)
340
+ sigmas_and_gammas = list(zip(sigmas[:-1], sigmas[1:], gammas[1:], strict=True))
341
+ if self.training and self.step_scale_random is not None:
342
+ step_scale = np.random.choice(self.step_scale_random)
343
+ else:
344
+ step_scale = self.step_scale
345
+
346
+ # atom position is noise at the beginning
347
+ init_sigma = sigmas[0] # ()
348
+ atom_coords = init_sigma * torch.randn(shape, device=self.device) # (s, a, 3)
349
+ token_repr = None # None; the current sampler does not produce token features
350
+ atom_coords_denoised = None # (s, a, 3) or None before the first denoising step
351
+
352
+ for step_idx, (sigma_tm, sigma_t, gamma) in enumerate(
353
+ tqdm(
354
+ sigmas_and_gammas,
355
+ desc="Boltz2: Diffusion sampling",
356
+ unit="step",
357
+ disable=not verbose,
358
+ dynamic_ncols=True,
359
+ leave=False,
360
+ )
361
+ ):
362
+ random_R, random_tr = compute_random_augmentation(
363
+ multiplicity, device=atom_coords.device, dtype=atom_coords.dtype
364
+ ) # (m, 3, 3), (m, 1, 3)
365
+ atom_coords = atom_coords - atom_coords.mean(dim=-2, keepdims=True) # (s, a, 3)
366
+ atom_coords = torch.einsum("bmd,bds->bms", atom_coords, random_R) + random_tr # (s, a, 3)
367
+ if atom_coords_denoised is not None:
368
+ atom_coords_denoised -= atom_coords_denoised.mean(dim=-2, keepdims=True) # (s, a, 3) or None before the first denoising step
369
+ atom_coords_denoised = (
370
+ torch.einsum("bmd,bds->bms", atom_coords_denoised, random_R) + random_tr
371
+ ) # (s, a, 3) or None before the first denoising step
372
+ if (
373
+ steering_args["physical_guidance_update"]
374
+ or steering_args["contact_guidance_update"]
375
+ ) and scaled_guidance_update is not None:
376
+ scaled_guidance_update = torch.einsum(
377
+ "bmd,bds->bms", scaled_guidance_update, random_R
378
+ ) # (s, a, 3)
379
+
380
+ sigma_tm, sigma_t, gamma = sigma_tm.item(), sigma_t.item(), gamma.item() # three scalar tensors () become Python floats
381
+
382
+ t_hat = sigma_tm * (1 + gamma)
383
+ steering_t = 1.0 - (step_idx / num_sampling_steps)
384
+ noise_var = self.noise_scale**2 * (t_hat**2 - sigma_tm**2)
385
+ eps = sqrt(noise_var) * torch.randn(shape, device=self.device) # (s, a, 3)
386
+ atom_coords_noisy = atom_coords + eps # (s, a, 3)
387
+
388
+ with torch.no_grad():
389
+ atom_coords_denoised = torch.zeros_like(atom_coords_noisy) # (s, a, 3) or None before the first denoising step
390
+ sample_ids = torch.arange(multiplicity).to(atom_coords_noisy.device) # (m,)
391
+ sample_ids_chunks = sample_ids.chunk(multiplicity % max_parallel_samples + 1) # tuple of index tensors, each (n_chunk,)
392
+
393
+ for sample_ids_chunk in sample_ids_chunks:
394
+ atom_coords_denoised_chunk = self.preconditioned_network_forward(
395
+ atom_coords_noisy[sample_ids_chunk],
396
+ t_hat,
397
+ network_condition_kwargs=dict(
398
+ multiplicity=sample_ids_chunk.numel(),
399
+ **network_condition_kwargs,
400
+ ),
401
+ ) # (n_chunk, a, 3)
402
+ atom_coords_denoised[sample_ids_chunk] = atom_coords_denoised_chunk # (n_chunk, a, 3)
403
+
404
+ if steering_args["fk_steering"] and (
405
+ (step_idx % steering_args["fk_resampling_interval"] == 0 and noise_var > 0)
406
+ or step_idx == num_sampling_steps - 1
407
+ ):
408
+ # Compute energy of x_0 prediction
409
+ energy = torch.zeros(multiplicity, device=self.device) # (m,)
410
+ for potential in potentials:
411
+ parameters = potential.compute_parameters(steering_t)
412
+ if parameters["resampling_weight"] > 0:
413
+ component_energy = potential.compute(
414
+ atom_coords_denoised,
415
+ network_condition_kwargs["feats"],
416
+ parameters,
417
+ ) # (m,)
418
+ energy += parameters["resampling_weight"] * component_energy # (m,)
419
+ energy_traj = torch.cat((energy_traj, energy.unsqueeze(1)), dim=1) # (m, n_evaluation)
420
+
421
+ # Compute log G values
422
+ if step_idx == 0:
423
+ log_G = -1 * energy # (m,)
424
+ else:
425
+ log_G = energy_traj[:, -2] - energy_traj[:, -1] # (m,)
426
+
427
+ # Compute ll difference between guided and unguided transition distribution
428
+ if (
429
+ steering_args["physical_guidance_update"]
430
+ or steering_args["contact_guidance_update"]
431
+ ) and noise_var > 0:
432
+ ll_difference = (eps**2 - (eps + scaled_guidance_update) ** 2).sum(
433
+ dim=(-1, -2)
434
+ ) / (2 * noise_var) # (m,)
435
+ else:
436
+ ll_difference = torch.zeros_like(energy) # (m,)
437
+
438
+ # Compute resampling weights
439
+ resample_weights = F.softmax(
440
+ (ll_difference + steering_args["fk_lambda"] * log_G).reshape(
441
+ -1, steering_args["num_particles"]
442
+ ),
443
+ dim=1,
444
+ ) # (n_group, n_particle)
445
+
446
+ # Compute guidance update to x_0 prediction
447
+ if (
448
+ steering_args["physical_guidance_update"]
449
+ or steering_args["contact_guidance_update"]
450
+ ) and step_idx < num_sampling_steps - 1:
451
+ guidance_update = torch.zeros_like(atom_coords_denoised) # (s, a, 3)
452
+ for guidance_step in range(steering_args["num_gd_steps"]):
453
+ energy_gradient = torch.zeros_like(atom_coords_denoised) # (s, a, 3)
454
+ for potential in potentials:
455
+ parameters = potential.compute_parameters(steering_t)
456
+ if (
457
+ parameters["guidance_weight"] > 0
458
+ and (guidance_step) % parameters["guidance_interval"] == 0
459
+ ):
460
+ energy_gradient += parameters[
461
+ "guidance_weight"
462
+ ] * potential.compute_gradient(
463
+ atom_coords_denoised + guidance_update,
464
+ network_condition_kwargs["feats"],
465
+ parameters,
466
+ ) # (s, a, 3)
467
+ guidance_update -= energy_gradient # (s, a, 3)
468
+ atom_coords_denoised += guidance_update # (s, a, 3) or None before the first denoising step
469
+ scaled_guidance_update = (
470
+ guidance_update * -1 * self.step_scale * (sigma_t - t_hat) / t_hat
471
+ ) # (s, a, 3)
472
+
473
+ if steering_args["fk_steering"] and (
474
+ (step_idx % steering_args["fk_resampling_interval"] == 0 and noise_var > 0)
475
+ or step_idx == num_sampling_steps - 1
476
+ ):
477
+ resample_indices = (
478
+ torch.multinomial(
479
+ resample_weights,
480
+ resample_weights.shape[1] if step_idx < num_sampling_steps - 1 else 1,
481
+ replacement=True,
482
+ )
483
+ + resample_weights.shape[1]
484
+ * torch.arange(
485
+ resample_weights.shape[0], device=resample_weights.device
486
+ ).unsqueeze(-1)
487
+ ).flatten() # (s_next,); final resampling keeps one particle per group
488
+
489
+ atom_coords = atom_coords[resample_indices] # (s, a, 3)
490
+ atom_coords_noisy = atom_coords_noisy[resample_indices] # (s, a, 3)
491
+ atom_mask = atom_mask[resample_indices] # (s, a); s expands on repetition and can shrink on resampling
492
+ if atom_coords_denoised is not None:
493
+ atom_coords_denoised = atom_coords_denoised[resample_indices] # (s, a, 3) or None before the first denoising step
494
+ energy_traj = energy_traj[resample_indices] # (s_next, n_evaluation)
495
+ if (
496
+ steering_args["physical_guidance_update"]
497
+ or steering_args["contact_guidance_update"]
498
+ ):
499
+ scaled_guidance_update = scaled_guidance_update[resample_indices] # (s, a, 3)
500
+ if token_repr is not None:
501
+ token_repr = token_repr[resample_indices] # (s_next, *feature_shape)
502
+
503
+ if self.alignment_reverse_diff:
504
+ with torch.autocast("cuda", enabled=False):
505
+ atom_coords_noisy = weighted_rigid_align(
506
+ atom_coords_noisy.float(),
507
+ atom_coords_denoised.float(),
508
+ atom_mask.float(),
509
+ atom_mask.float(),
510
+ ) # (s, a, 3)
511
+
512
+ atom_coords_noisy = atom_coords_noisy.to(atom_coords_denoised) # (s, a, 3)
513
+
514
+ denoised_over_sigma = (atom_coords_noisy - atom_coords_denoised) / t_hat # (s, a, 3)
515
+ atom_coords_next = (
516
+ atom_coords_noisy + step_scale * (sigma_t - t_hat) * denoised_over_sigma
517
+ ) # (s, a, 3)
518
+
519
+ atom_coords = atom_coords_next # (s, a, 3)
520
+
521
+ return dict(sample_atom_coords=atom_coords, diff_token_repr=token_repr)
522
+
523
+ def loss_weight(self, sigma: torch.Tensor) -> torch.Tensor:
524
+ # sigma: (...); weight preserves shape (...).
525
+ return (sigma**2 + self.sigma_data**2) / ((sigma * self.sigma_data) ** 2)
526
+
527
+ def noise_distribution(self, batch_size: int) -> torch.Tensor:
528
+ # Return one noise scale per example, shape (batch_size,).
529
+ return (
530
+ self.sigma_data
531
+ * (self.P_mean + self.P_std * torch.randn((batch_size,), device=self.device)).exp()
532
+ )
533
+
534
+ def forward(
535
+ self,
536
+ s_inputs: torch.Tensor,
537
+ s_trunk: torch.Tensor,
538
+ feats: dict[str, torch.Tensor],
539
+ diffusion_conditioning: dict[str, Any],
540
+ multiplicity: int = 1,
541
+ ) -> dict[str, Any]:
542
+ # training diffusion step
543
+ batch_size = feats["coords"].shape[0] // multiplicity
544
+
545
+ if self.synchronize_sigmas:
546
+ sigmas = self.noise_distribution(batch_size).repeat_interleave(multiplicity, 0) # (n_sigma,); schedule or batch axis is determined by the caller
547
+ else:
548
+ sigmas = self.noise_distribution(batch_size * multiplicity) # (n_sigma,); schedule or batch axis is determined by the caller
549
+ padded_sigmas = rearrange(sigmas, "b -> b 1 1") # (b * m, 1, 1)
550
+
551
+ atom_coords = feats["coords"] # (s, a, 3)
552
+
553
+ atom_mask = feats["atom_pad_mask"] # (s, a); s expands on repetition and can shrink on resampling
554
+ atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (s, a); s expands on repetition and can shrink on resampling
555
+
556
+ atom_coords = center_random_augmentation(
557
+ atom_coords, atom_mask, augmentation=self.coordinate_augmentation
558
+ ) # (s, a, 3)
559
+
560
+ noise = torch.randn_like(atom_coords) # (b * m, a, 3)
561
+ noised_atom_coords = atom_coords + padded_sigmas * noise # (b * m, a, 3)
562
+
563
+ denoised_atom_coords = self.preconditioned_network_forward(
564
+ noised_atom_coords,
565
+ sigmas,
566
+ network_condition_kwargs={
567
+ "s_inputs": s_inputs,
568
+ "s_trunk": s_trunk,
569
+ "feats": feats,
570
+ "multiplicity": multiplicity,
571
+ "diffusion_conditioning": diffusion_conditioning,
572
+ },
573
+ ) # (b * m, a, 3)
574
+
575
+ return {
576
+ "denoised_atom_coords": denoised_atom_coords,
577
+ "sigmas": sigmas,
578
+ "aligned_true_atom_coords": atom_coords,
579
+ }
580
+
581
+ def compute_loss(
582
+ self,
583
+ feats: dict[str, torch.Tensor],
584
+ out_dict: dict[str, torch.Tensor],
585
+ add_smooth_lddt_loss: bool = True,
586
+ nucleotide_loss_weight: float = 5.0,
587
+ ligand_loss_weight: float = 10.0,
588
+ multiplicity: int = 1,
589
+ filter_by_plddt: float = 0.0,
590
+ ) -> dict[str, Any]:
591
+ # Coordinates: (b * m, a, 3); base feature masks: (b, a).
592
+ with torch.autocast("cuda", enabled=False):
593
+ denoised_atom_coords = out_dict["denoised_atom_coords"].float() # (b * m, a, 3)
594
+ sigmas = out_dict["sigmas"].float() # (n_sigma,); schedule or batch axis is determined by the caller
595
+
596
+ resolved_atom_mask_uni = feats["atom_resolved_mask"].float() # (b, a)
597
+
598
+ if filter_by_plddt > 0:
599
+ plddt_mask = feats["plddt"] > filter_by_plddt # (b, a)
600
+ resolved_atom_mask_uni = resolved_atom_mask_uni * plddt_mask.float() # (b, a)
601
+
602
+ resolved_atom_mask = resolved_atom_mask_uni.repeat_interleave(multiplicity, 0) # (b * m, a)
603
+
604
+ align_weights = denoised_atom_coords.new_ones(denoised_atom_coords.shape[:2]) # (b * m, a)
605
+ atom_type = (
606
+ torch.bmm(
607
+ feats["atom_to_token"].float(),
608
+ feats["mol_type"].unsqueeze(-1).float(),
609
+ )
610
+ .squeeze(-1)
611
+ .long()
612
+ ) # (b, a)
613
+ atom_type_mult = atom_type.repeat_interleave(multiplicity, 0) # (b * m, a)
614
+
615
+ align_weights = (
616
+ align_weights
617
+ * (
618
+ 1
619
+ + nucleotide_loss_weight
620
+ * (
621
+ torch.eq(atom_type_mult, const.chain_type_ids["DNA"]).float()
622
+ + torch.eq(atom_type_mult, const.chain_type_ids["RNA"]).float()
623
+ )
624
+ + ligand_loss_weight
625
+ * torch.eq(atom_type_mult, const.chain_type_ids["NONPOLYMER"]).float()
626
+ ).float()
627
+ ) # (b * m, a)
628
+
629
+ atom_coords = out_dict["aligned_true_atom_coords"].float() # (s, a, 3)
630
+ atom_coords_aligned_ground_truth = weighted_rigid_align(
631
+ atom_coords.detach(),
632
+ denoised_atom_coords.detach(),
633
+ align_weights.detach(),
634
+ mask=feats["atom_resolved_mask"]
635
+ .float()
636
+ .repeat_interleave(multiplicity, 0)
637
+ .detach(),
638
+ ) # (b * m, a, 3)
639
+
640
+ atom_coords_aligned_ground_truth = atom_coords_aligned_ground_truth.to(
641
+ denoised_atom_coords
642
+ ) # (b * m, a, 3)
643
+
644
+ # weighted MSE loss of denoised atom positions
645
+ mse_loss = ((denoised_atom_coords - atom_coords_aligned_ground_truth) ** 2).sum(dim=-1) # (b * m, a)
646
+ mse_loss = torch.sum(mse_loss * align_weights * resolved_atom_mask, dim=-1) / (
647
+ torch.sum(3 * align_weights * resolved_atom_mask, dim=-1) + 1e-5
648
+ ) # (b * m,)
649
+
650
+ # weight by sigma factor
651
+ loss_weights = self.loss_weight(sigmas) # (b * m,)
652
+ mse_loss = (mse_loss * loss_weights).mean() # ()
653
+
654
+ total_loss = mse_loss # ()
655
+
656
+ # proposed auxiliary smooth lddt loss
657
+ lddt_loss = self.zero # ()
658
+ if add_smooth_lddt_loss:
659
+ lddt_loss = smooth_lddt_loss(
660
+ denoised_atom_coords,
661
+ feats["coords"],
662
+ torch.eq(atom_type, const.chain_type_ids["DNA"]).float()
663
+ + torch.eq(atom_type, const.chain_type_ids["RNA"]).float(),
664
+ coords_mask=resolved_atom_mask_uni,
665
+ multiplicity=multiplicity,
666
+ ) # ()
667
+
668
+ total_loss = total_loss + lddt_loss # ()
669
+
670
+ loss_breakdown = {
671
+ "mse_loss": mse_loss,
672
+ "smooth_lddt_loss": lddt_loss,
673
+ }
674
+
675
+ return {"loss": total_loss, "loss_breakdown": loss_breakdown}
fastplms/models/boltz/vb_modules_encodersv2.py CHANGED
@@ -1,653 +1,653 @@
1
- """Token, pair, and atom encoders used by the local Boltz2 runtime.
2
-
3
- The classes retain checkpoint-facing submodule names while separating feature
4
- assembly, window indexing, conditioning, and atom-token aggregation into small
5
- mechanism-specific units. No production import depends on the upstream Boltz
6
- package.
7
-
8
- The Fourier and atom-attention mechanisms derive from AlphaFold 3 community
9
- implementations under MIT terms. See ``THIRD_PARTY_NOTICES.md``.
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import torch
15
-
16
- from collections.abc import Callable
17
- from functools import partial
18
- from math import pi
19
- from torch import nn
20
- from torch.nn.functional import one_hot
21
-
22
- from . import vb_layers_initialize as init
23
- from .vb_layers_transition import Transition
24
- from .vb_modules_transformersv2 import AtomTransformer
25
- from .vb_modules_utils import LinearNoBias
26
-
27
-
28
- def _transition_stack(
29
- count: int,
30
- dim: int,
31
- hidden_dim: int,
32
- ) -> nn.ModuleList:
33
- return nn.ModuleList([Transition(dim=dim, hidden=hidden_dim) for _ in range(count)])
34
-
35
-
36
- class FourierEmbedding(nn.Module):
37
- """Embed diffusion time with fixed random Fourier frequencies."""
38
-
39
- def __init__(self, dim: int) -> None:
40
- super().__init__()
41
- self.proj = nn.Linear(1, dim)
42
- nn.init.normal_(self.proj.weight, mean=0, std=1)
43
- nn.init.normal_(self.proj.bias, mean=0, std=1)
44
- self.proj.requires_grad_(False)
45
-
46
- def forward(self, times: torch.Tensor) -> torch.Tensor:
47
- """Map times with shape ``(b,)`` to an embedding ``H: (b, d)``."""
48
-
49
- random_phase = self.proj(times.reshape(-1, 1)) # (b, d)
50
- return torch.cos(2 * pi * random_phase) # (b, d)
51
-
52
-
53
- def _pairwise_difference(values: torch.Tensor) -> torch.Tensor:
54
- # values: (b, n)
55
- return values[:, :, None] - values[:, None, :] # (b, n, n)
56
-
57
-
58
- class RelativePositionEncoder(nn.Module):
59
- """Encode residue, token, entity, and symmetry relationships."""
60
-
61
- def __init__(
62
- self,
63
- token_z: int,
64
- r_max: int = 32,
65
- s_max: int = 2,
66
- fix_sym_check: bool = False,
67
- cyclic_pos_enc: bool = False,
68
- ) -> None:
69
- super().__init__()
70
- self.r_max = r_max
71
- self.s_max = s_max
72
- input_dim = 4 * (r_max + 1) + 2 * (s_max + 1) + 1
73
- self.linear_layer = LinearNoBias(input_dim, token_z)
74
- self.fix_sym_check = fix_sym_check
75
- self.cyclic_pos_enc = cyclic_pos_enc
76
-
77
- def forward(self, feats: dict[str, torch.Tensor]) -> torch.Tensor:
78
- """Return relative pair features ``Z: (b, n, n, token_z)``."""
79
-
80
- same_chain = (
81
- feats["asym_id"][:, :, None] == feats["asym_id"][:, None, :]
82
- ) # (b, n, n)
83
- same_residue = (
84
- feats["residue_index"][:, :, None] == feats["residue_index"][:, None, :]
85
- ) # (b, n, n)
86
- same_entity = (
87
- feats["entity_id"][:, :, None] == feats["entity_id"][:, None, :]
88
- ) # (b, n, n)
89
-
90
- residue_offset = _pairwise_difference(feats["residue_index"]) # (b, n, n)
91
- if self.cyclic_pos_enc and torch.any(feats["cyclic_period"] > 0):
92
- period = torch.where(
93
- feats["cyclic_period"] > 0,
94
- feats["cyclic_period"],
95
- torch.zeros_like(feats["cyclic_period"]) + 10000,
96
- ) # (b, n)
97
- residue_offset = (
98
- residue_offset - period * torch.round(residue_offset / period)
99
- ).long() # (b, n, n)
100
- residue_offset = torch.clip(
101
- residue_offset + self.r_max,
102
- 0,
103
- 2 * self.r_max,
104
- ) # (b, n, n)
105
- residue_offset = torch.where(
106
- same_chain,
107
- residue_offset,
108
- torch.zeros_like(residue_offset) + 2 * self.r_max + 1,
109
- ) # (b, n, n)
110
- residue_features = one_hot(
111
- residue_offset, 2 * self.r_max + 2
112
- ) # (b, n, n, 2 * r_max + 2)
113
-
114
- token_offset = torch.clip(
115
- _pairwise_difference(feats["token_index"]) + self.r_max,
116
- 0,
117
- 2 * self.r_max,
118
- ) # (b, n, n)
119
- token_offset = torch.where(
120
- same_chain & same_residue,
121
- token_offset,
122
- torch.zeros_like(token_offset) + 2 * self.r_max + 1,
123
- ) # (b, n, n)
124
- token_features = one_hot(
125
- token_offset, 2 * self.r_max + 2
126
- ) # (b, n, n, 2 * r_max + 2)
127
-
128
- symmetry_offset = torch.clip(
129
- _pairwise_difference(feats["sym_id"]) + self.s_max,
130
- 0,
131
- 2 * self.s_max,
132
- ) # (b, n, n)
133
- invalid_symmetry = ~same_entity if self.fix_sym_check else same_chain # (b, n, n)
134
- symmetry_offset = torch.where(
135
- invalid_symmetry,
136
- torch.zeros_like(symmetry_offset) + 2 * self.s_max + 1,
137
- symmetry_offset,
138
- ) # (b, n, n)
139
- symmetry_features = one_hot(
140
- symmetry_offset, 2 * self.s_max + 2
141
- ) # (b, n, n, 2 * s_max + 2)
142
-
143
- pair_features = torch.cat(
144
- (
145
- residue_features.float(),
146
- token_features.float(),
147
- same_entity.unsqueeze(-1).float(),
148
- symmetry_features.float(),
149
- ),
150
- dim=-1,
151
- ) # (b, n, n, d_rel)
152
- return self.linear_layer(pair_features) # (b, n, n, token_z)
153
-
154
-
155
- class SingleConditioning(nn.Module):
156
- """Condition token features on trunk inputs and diffusion time."""
157
-
158
- def __init__(
159
- self,
160
- sigma_data: float,
161
- token_s: int = 384,
162
- dim_fourier: int = 256,
163
- num_transitions: int = 2,
164
- transition_expansion_factor: int = 2,
165
- eps: float = 1e-20,
166
- disable_times: bool = False,
167
- ) -> None:
168
- super().__init__()
169
- self.eps = eps
170
- self.sigma_data = sigma_data
171
- self.disable_times = disable_times
172
- conditioning_dim = 2 * token_s
173
- self.norm_single = nn.LayerNorm(conditioning_dim)
174
- self.single_embed = nn.Linear(conditioning_dim, conditioning_dim)
175
- if not disable_times:
176
- self.fourier_embed = FourierEmbedding(dim_fourier)
177
- self.norm_fourier = nn.LayerNorm(dim_fourier)
178
- self.fourier_to_single = LinearNoBias(dim_fourier, conditioning_dim)
179
- self.transitions = _transition_stack(
180
- num_transitions,
181
- conditioning_dim,
182
- transition_expansion_factor * conditioning_dim,
183
- )
184
-
185
- def forward(
186
- self,
187
- times: torch.Tensor,
188
- s_trunk: torch.Tensor,
189
- s_inputs: torch.Tensor,
190
- ) -> tuple[torch.Tensor, torch.Tensor | None]:
191
- """Return conditioned token features ``S: (b, n, 2 * token_s)``."""
192
-
193
- # times: (b,); s_trunk/s_inputs: (b, n, token_s).
194
- single = torch.cat((s_trunk, s_inputs), dim=-1) # (b, n, 2 * token_s)
195
- single = self.single_embed(self.norm_single(single)) # (b, n, 2 * token_s)
196
- normalized_fourier = None # (b, d_fourier) or None
197
- if not self.disable_times:
198
- fourier = self.fourier_embed(times) # (b, d_fourier)
199
- normalized_fourier = self.norm_fourier(fourier) # (b, d_fourier)
200
- time_condition = self.fourier_to_single(
201
- normalized_fourier
202
- ) # (b, 2 * token_s)
203
- single = time_condition[:, None, :] + single # (b, n, 2 * token_s)
204
- for transition in self.transitions:
205
- single = transition(single) + single # (b, n, 2 * token_s)
206
- return single, normalized_fourier # (b, n, 2 * token_s), (b, d_fourier) or None
207
-
208
-
209
- class PairwiseConditioning(nn.Module):
210
- """Fuse trunk pair features with relative-position features."""
211
-
212
- def __init__(
213
- self,
214
- token_z: int,
215
- dim_token_rel_pos_feats: int,
216
- num_transitions: int = 2,
217
- transition_expansion_factor: int = 2,
218
- ) -> None:
219
- super().__init__()
220
- combined_dim = token_z + dim_token_rel_pos_feats
221
- self.dim_pairwise_init_proj = nn.Sequential(
222
- nn.LayerNorm(combined_dim),
223
- LinearNoBias(combined_dim, token_z),
224
- )
225
- self.transitions = _transition_stack(
226
- num_transitions,
227
- token_z,
228
- transition_expansion_factor * token_z,
229
- )
230
-
231
- def forward(
232
- self,
233
- z_trunk: torch.Tensor,
234
- token_rel_pos_feats: torch.Tensor,
235
- ) -> torch.Tensor:
236
- """Return conditioned pair features ``Z: (b, n, n, token_z)``."""
237
-
238
- pair = self.dim_pairwise_init_proj(
239
- torch.cat((z_trunk, token_rel_pos_feats), dim=-1)
240
- ) # (b, n, n, token_z)
241
- for transition in self.transitions:
242
- pair = transition(pair) + pair # (b, n, n, token_z)
243
- return pair # (b, n, n, token_z)
244
-
245
-
246
- def get_indexing_matrix(
247
- k: int,
248
- w: int,
249
- h: int,
250
- device: torch.device,
251
- ) -> torch.Tensor:
252
- """Build the atom-window gather matrix used for local attention keys."""
253
-
254
- for name, value in (("k", k), ("w", w), ("h", h)):
255
- if isinstance(value, bool) or not isinstance(value, int):
256
- raise TypeError(f"{name} must be an int, got {type(value).__name__}.")
257
- if value <= 0:
258
- raise ValueError(f"{name} must be positive, got {value}.")
259
- if w % 2 != 0:
260
- raise ValueError(f"w must be even, got {w}.")
261
- half_window = w // 2
262
- if h % half_window != 0:
263
- raise ValueError(f"h must be divisible by w // 2 ({half_window}), got {h}.")
264
- key_blocks = h // half_window
265
- if key_blocks % 2 != 0:
266
- raise ValueError(
267
- f"h must contain an even number of half-window key blocks; received {key_blocks}."
268
- )
269
-
270
- positions = torch.arange(2 * k, device=device) # (2 * k,)
271
- relative_blocks = (
272
- (positions.unsqueeze(0) - positions.unsqueeze(1)) + key_blocks // 2
273
- ).clamp(min=0, max=key_blocks + 1) # (2 * k, 2 * k)
274
- relative_blocks = relative_blocks.view(k, 2, 2 * k)[:, 0, :] # (k, 2 * k)
275
- selectors = one_hot(relative_blocks, num_classes=key_blocks + 2)[
276
- ..., 1:-1
277
- ].transpose(1, 0) # (2 * k, k, key_blocks)
278
- return selectors.reshape(
279
- 2 * k, key_blocks * k
280
- ).float() # (2 * k, key_blocks * k)
281
-
282
-
283
- def single_to_keys(
284
- single: torch.Tensor,
285
- indexing_matrix: torch.Tensor,
286
- w: int,
287
- h: int,
288
- ) -> torch.Tensor:
289
- """Gather a sequence tensor into overlapping local key windows."""
290
-
291
- b, n, d = single.shape
292
- k = n // w
293
- half_windows = single.view(b, 2 * k, w // 2, d) # (b, 2 * k, w / 2, d)
294
- gathered = torch.einsum(
295
- "b j i d, j k -> b k i d", half_windows, indexing_matrix
296
- ) # (b, key_blocks * k, w / 2, d)
297
- return gathered.reshape(b, k, h, d) # (b, k, h, d)
298
-
299
-
300
- class AtomEncoder(nn.Module):
301
- """Encode reference atoms and local atom-pair geometry."""
302
-
303
- def __init__(
304
- self,
305
- atom_s: int,
306
- atom_z: int,
307
- token_s: int,
308
- token_z: int,
309
- atoms_per_window_queries: int,
310
- atoms_per_window_keys: int,
311
- atom_feature_dim: int,
312
- structure_prediction: bool = True,
313
- use_no_atom_char: bool = False,
314
- use_atom_backbone_feat: bool = False,
315
- use_residue_feats_atoms: bool = False,
316
- ) -> None:
317
- super().__init__()
318
- self.embed_atom_features = nn.Linear(atom_feature_dim, atom_s)
319
- self.embed_atompair_ref_pos = LinearNoBias(3, atom_z)
320
- self.embed_atompair_ref_dist = LinearNoBias(1, atom_z)
321
- self.embed_atompair_mask = LinearNoBias(1, atom_z)
322
- self.atoms_per_window_queries = atoms_per_window_queries
323
- self.atoms_per_window_keys = atoms_per_window_keys
324
- self.use_no_atom_char = use_no_atom_char
325
- self.use_atom_backbone_feat = use_atom_backbone_feat
326
- self.use_residue_feats_atoms = use_residue_feats_atoms
327
- self.structure_prediction = structure_prediction
328
-
329
- if structure_prediction:
330
- self.s_to_c_trans = nn.Sequential(
331
- nn.LayerNorm(token_s),
332
- LinearNoBias(token_s, atom_s),
333
- )
334
- init.final_init_(self.s_to_c_trans[1].weight)
335
- self.z_to_p_trans = nn.Sequential(
336
- nn.LayerNorm(token_z),
337
- LinearNoBias(token_z, atom_z),
338
- )
339
- init.final_init_(self.z_to_p_trans[1].weight)
340
-
341
- self.c_to_p_trans_k = nn.Sequential(nn.ReLU(), LinearNoBias(atom_s, atom_z))
342
- init.final_init_(self.c_to_p_trans_k[1].weight)
343
- self.c_to_p_trans_q = nn.Sequential(nn.ReLU(), LinearNoBias(atom_s, atom_z))
344
- init.final_init_(self.c_to_p_trans_q[1].weight)
345
- self.p_mlp = nn.Sequential(
346
- nn.ReLU(),
347
- LinearNoBias(atom_z, atom_z),
348
- nn.ReLU(),
349
- LinearNoBias(atom_z, atom_z),
350
- nn.ReLU(),
351
- LinearNoBias(atom_z, atom_z),
352
- )
353
- init.final_init_(self.p_mlp[5].weight)
354
-
355
- def _assemble_atom_features(
356
- self,
357
- feats: dict[str, torch.Tensor],
358
- b: int,
359
- n: int,
360
- ) -> torch.Tensor:
361
- # Feature tensors share leading shape (b, n).
362
- feature_parts = [
363
- feats["ref_pos"],
364
- feats["ref_charge"].unsqueeze(-1),
365
- feats["ref_element"],
366
- ] # each: (b, n, d_feature)
367
- if not self.use_no_atom_char:
368
- feature_parts.append(feats["ref_atom_name_chars"].reshape(b, n, 4 * 64))
369
- if self.use_atom_backbone_feat:
370
- feature_parts.append(feats["atom_backbone_feat"])
371
- if self.use_residue_feats_atoms:
372
- residue_features = torch.cat(
373
- (
374
- feats["res_type"],
375
- feats["modified"].unsqueeze(-1),
376
- one_hot(feats["mol_type"], num_classes=4).float(),
377
- ),
378
- dim=-1,
379
- ) # (b, t, d_residue)
380
- feature_parts.append(
381
- torch.bmm(feats["atom_to_token"].float(), residue_features)
382
- ) # (b, n, d_residue)
383
- return torch.cat(feature_parts, dim=-1) # (b, n, atom_feature_dim)
384
-
385
- def forward(
386
- self,
387
- feats: dict[str, torch.Tensor],
388
- s_trunk: torch.Tensor | None = None,
389
- z: torch.Tensor | None = None,
390
- ) -> tuple[
391
- torch.Tensor,
392
- torch.Tensor,
393
- torch.Tensor,
394
- Callable[[torch.Tensor], torch.Tensor],
395
- ]:
396
- """Return atom queries, conditioned singles, pairs, and key gatherer."""
397
-
398
- with torch.autocast("cuda", enabled=False):
399
- b, n, _ = feats["ref_pos"].shape
400
- atom_mask = feats["atom_pad_mask"].bool() # (b, n)
401
- atom_positions = feats["ref_pos"] # (b, n, 3)
402
- atom_space = feats["ref_space_uid"] # (b, n)
403
- atom_features = self._assemble_atom_features(
404
- feats, b, n
405
- ) # (b, n, atom_feature_dim)
406
- conditioned_single = self.embed_atom_features(
407
- atom_features
408
- ) # (b, n, atom_s)
409
-
410
- w = self.atoms_per_window_queries
411
- h = self.atoms_per_window_keys
412
- b, n = conditioned_single.shape[:2]
413
- k = n // w
414
- indexing = get_indexing_matrix(
415
- k, w, h, conditioned_single.device
416
- ) # (2 * k, key_blocks * k)
417
- to_keys = partial(single_to_keys, indexing_matrix=indexing, w=w, h=h)
418
-
419
- position_queries = atom_positions.view(b, k, w, 1, 3) # (b, k, w, 1, 3)
420
- position_keys = to_keys(atom_positions).view(
421
- b, k, 1, h, 3
422
- ) # (b, k, 1, h, 3)
423
- displacement = position_keys - position_queries # (b, k, w, h, 3)
424
- inverse_squared_distance = 1 / (
425
- 1 + torch.sum(displacement * displacement, dim=-1, keepdim=True)
426
- ) # (b, k, w, h, 1)
427
-
428
- mask_queries = atom_mask.view(b, k, w, 1) # (b, k, w, 1)
429
- mask_keys = (
430
- to_keys(atom_mask.unsqueeze(-1).float()).view(b, k, 1, h).bool()
431
- ) # (b, k, 1, h)
432
- space_queries = atom_space.view(b, k, w, 1) # (b, k, w, 1)
433
- space_keys = (
434
- to_keys(atom_space.unsqueeze(-1).float()).view(b, k, 1, h).long()
435
- ) # (b, k, 1, h)
436
- valid_pair = (
437
- (mask_queries & mask_keys & (space_queries == space_keys)).float().unsqueeze(-1)
438
- ) # (b, k, w, h, 1)
439
-
440
- pair = self.embed_atompair_ref_pos(displacement) * valid_pair # (b, k, w, h, atom_z)
441
- pair = (
442
- pair + self.embed_atompair_ref_dist(inverse_squared_distance) * valid_pair
443
- ) # (b, k, w, h, atom_z)
444
- pair = pair + self.embed_atompair_mask(valid_pair) * valid_pair # (b, k, w, h, atom_z)
445
- query = conditioned_single # (b, n, atom_s)
446
-
447
- if self.structure_prediction:
448
- if s_trunk is None or z is None:
449
- raise ValueError("structure prediction requires S and Z trunk tensors")
450
- atom_to_token = feats["atom_to_token"].float() # (b, n, t)
451
- single_update = self.s_to_c_trans(s_trunk.float()) # (b, t, atom_s)
452
- single_update = torch.bmm(
453
- atom_to_token, single_update
454
- ) # (b, n, atom_s)
455
- conditioned_single = conditioned_single + single_update.to(
456
- conditioned_single
457
- ) # (b, n, atom_s)
458
-
459
- token_queries = atom_to_token.view(
460
- b, k, w, atom_to_token.shape[-1]
461
- ) # (b, k, w, t)
462
- token_keys = to_keys(atom_to_token) # (b, k, h, t)
463
- pair_update = self.z_to_p_trans(z.float()) # (b, t, t, atom_z)
464
- pair_update = torch.einsum(
465
- "bijd,bwki,bwlj->bwkld",
466
- pair_update,
467
- token_queries,
468
- token_keys,
469
- ) # (b, k, w, h, atom_z)
470
- pair = pair + pair_update.to(pair) # (b, k, w, h, atom_z)
471
-
472
- pair = pair + self.c_to_p_trans_q(
473
- conditioned_single.view(b, k, w, 1, conditioned_single.shape[-1])
474
- ) # (b, k, w, h, atom_z)
475
- pair = pair + self.c_to_p_trans_k(
476
- to_keys(conditioned_single).view(
477
- b,
478
- k,
479
- 1,
480
- h,
481
- conditioned_single.shape[-1],
482
- )
483
- ) # (b, k, w, h, atom_z)
484
- pair = pair + self.p_mlp(pair) # (b, k, w, h, atom_z)
485
- return (
486
- query,
487
- conditioned_single,
488
- pair,
489
- to_keys,
490
- ) # (b, n, atom_s), (b, n, atom_s), (b, k, w, h, atom_z), callable
491
-
492
-
493
- class AtomAttentionEncoder(nn.Module):
494
- """Run local atom attention and aggregate atoms to tokens."""
495
-
496
- def __init__(
497
- self,
498
- atom_s: int,
499
- token_s: int,
500
- atoms_per_window_queries: int,
501
- atoms_per_window_keys: int,
502
- atom_encoder_depth: int = 3,
503
- atom_encoder_heads: int = 4,
504
- structure_prediction: bool = True,
505
- activation_checkpointing: bool = False,
506
- transformer_post_layer_norm: bool = False,
507
- ) -> None:
508
- super().__init__()
509
- self.structure_prediction = structure_prediction
510
- if structure_prediction:
511
- self.r_to_q_trans = LinearNoBias(3, atom_s)
512
- init.final_init_(self.r_to_q_trans.weight)
513
- self.atom_encoder = AtomTransformer(
514
- dim=atom_s,
515
- dim_single_cond=atom_s,
516
- attn_window_queries=atoms_per_window_queries,
517
- attn_window_keys=atoms_per_window_keys,
518
- depth=atom_encoder_depth,
519
- heads=atom_encoder_heads,
520
- activation_checkpointing=activation_checkpointing,
521
- post_layer_norm=transformer_post_layer_norm,
522
- )
523
- output_dim = 2 * token_s if structure_prediction else token_s
524
- self.atom_to_token_trans = nn.Sequential(
525
- LinearNoBias(atom_s, output_dim),
526
- nn.ReLU(),
527
- )
528
-
529
- def forward(
530
- self,
531
- feats: dict[str, torch.Tensor],
532
- q: torch.Tensor,
533
- c: torch.Tensor,
534
- atom_enc_bias: torch.Tensor,
535
- to_keys: Callable[[torch.Tensor], torch.Tensor],
536
- r: torch.Tensor | None = None,
537
- multiplicity: int = 1,
538
- ) -> tuple[
539
- torch.Tensor,
540
- torch.Tensor,
541
- torch.Tensor,
542
- Callable[[torch.Tensor], torch.Tensor],
543
- ]:
544
- b, n, _ = feats["ref_pos"].shape
545
- del b, n
546
- # q/c: (b, a, atom_s); atom_enc_bias uses local-window attention axes.
547
- atom_mask = feats["atom_pad_mask"].bool() # (b, a)
548
- if self.structure_prediction:
549
- if r is None:
550
- raise ValueError("structure prediction requires atom coordinates R")
551
- q = q.repeat_interleave(multiplicity, 0) # (b * m, a, atom_s)
552
- q = q + self.r_to_q_trans(r) # (b * m, a, atom_s)
553
- c = c.repeat_interleave(multiplicity, 0) # (b * m, a, atom_s)
554
- atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (b * m, a)
555
- q = self.atom_encoder(
556
- q=q,
557
- mask=atom_mask,
558
- c=c,
559
- bias=atom_enc_bias,
560
- multiplicity=multiplicity,
561
- to_keys=to_keys,
562
- ) # (b * m, a, atom_s)
563
-
564
- with torch.autocast("cuda", enabled=False):
565
- atom_update = self.atom_to_token_trans(q).float() # (b * m, a, d_token)
566
- atom_to_token = feats["atom_to_token"].float() # (b, a, t)
567
- atom_to_token = atom_to_token.repeat_interleave(
568
- multiplicity, 0
569
- ) # (b * m, a, t)
570
- atom_to_token_mean = atom_to_token / (
571
- atom_to_token.sum(dim=1, keepdim=True) + 1e-6
572
- ) # (b * m, a, t)
573
- token_update = torch.bmm(
574
- atom_to_token_mean.transpose(1, 2), atom_update
575
- ) # (b * m, t, d_token)
576
- return (
577
- token_update.to(q),
578
- q,
579
- c,
580
- to_keys,
581
- ) # (b * m, t, d_token), two (b * m, a, atom_s), callable
582
-
583
-
584
- class AtomAttentionDecoder(nn.Module):
585
- """Map token updates back to atoms and predict coordinate displacements."""
586
-
587
- def __init__(
588
- self,
589
- atom_s: int,
590
- token_s: int,
591
- attn_window_queries: int,
592
- attn_window_keys: int,
593
- atom_decoder_depth: int = 3,
594
- atom_decoder_heads: int = 4,
595
- activation_checkpointing: bool = False,
596
- transformer_post_layer_norm: bool = False,
597
- ) -> None:
598
- super().__init__()
599
- self.a_to_q_trans = LinearNoBias(2 * token_s, atom_s)
600
- init.final_init_(self.a_to_q_trans.weight)
601
- self.atom_decoder = AtomTransformer(
602
- dim=atom_s,
603
- dim_single_cond=atom_s,
604
- attn_window_queries=attn_window_queries,
605
- attn_window_keys=attn_window_keys,
606
- depth=atom_decoder_depth,
607
- heads=atom_decoder_heads,
608
- activation_checkpointing=activation_checkpointing,
609
- post_layer_norm=transformer_post_layer_norm,
610
- )
611
- if transformer_post_layer_norm:
612
- self.atom_feat_to_atom_pos_update = LinearNoBias(atom_s, 3)
613
- init.final_init_(self.atom_feat_to_atom_pos_update.weight)
614
- else:
615
- self.atom_feat_to_atom_pos_update = nn.Sequential(
616
- nn.LayerNorm(atom_s),
617
- LinearNoBias(atom_s, 3),
618
- )
619
- init.final_init_(self.atom_feat_to_atom_pos_update[1].weight)
620
-
621
- def forward(
622
- self,
623
- a: torch.Tensor,
624
- q: torch.Tensor,
625
- c: torch.Tensor,
626
- atom_dec_bias: torch.Tensor,
627
- feats: dict[str, torch.Tensor],
628
- to_keys: Callable[[torch.Tensor], torch.Tensor],
629
- multiplicity: int = 1,
630
- ) -> torch.Tensor:
631
- """Return atom-coordinate updates ``R_update: (b, a, 3)``."""
632
-
633
- with torch.autocast("cuda", enabled=False):
634
- # a: (b * m, t, 2 * token_s); q/c: (b * m, a, atom_s).
635
- atom_to_token = feats["atom_to_token"].float() # (b, a, t)
636
- atom_to_token = atom_to_token.repeat_interleave(
637
- multiplicity, 0
638
- ) # (b * m, a, t)
639
- token_update = self.a_to_q_trans(a.float()) # (b * m, t, atom_s)
640
- atom_update = torch.bmm(atom_to_token, token_update) # (b * m, a, atom_s)
641
- q = q + atom_update.to(q) # (b * m, a, atom_s)
642
- atom_mask = feats["atom_pad_mask"].repeat_interleave(
643
- multiplicity, 0
644
- ) # (b * m, a)
645
- q = self.atom_decoder(
646
- q=q,
647
- mask=atom_mask,
648
- c=c,
649
- bias=atom_dec_bias,
650
- multiplicity=multiplicity,
651
- to_keys=to_keys,
652
- ) # (b * m, a, atom_s)
653
- return self.atom_feat_to_atom_pos_update(q) # (b * m, a, 3)
 
1
+ """Token, pair, and atom encoders used by the local Boltz2 runtime.
2
+
3
+ The classes retain checkpoint-facing submodule names while separating feature
4
+ assembly, window indexing, conditioning, and atom-token aggregation into small
5
+ mechanism-specific units. No production import depends on the upstream Boltz
6
+ package.
7
+
8
+ The Fourier and atom-attention mechanisms derive from AlphaFold 3 community
9
+ implementations under MIT terms. See ``THIRD_PARTY_NOTICES.md``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import torch
15
+
16
+ from collections.abc import Callable
17
+ from functools import partial
18
+ from math import pi
19
+ from torch import nn
20
+ from torch.nn.functional import one_hot
21
+
22
+ from . import vb_layers_initialize as init
23
+ from .vb_layers_transition import Transition
24
+ from .vb_modules_transformersv2 import AtomTransformer
25
+ from .vb_modules_utils import LinearNoBias
26
+
27
+
28
+ def _transition_stack(
29
+ count: int,
30
+ dim: int,
31
+ hidden_dim: int,
32
+ ) -> nn.ModuleList:
33
+ return nn.ModuleList([Transition(dim=dim, hidden=hidden_dim) for _ in range(count)])
34
+
35
+
36
+ class FourierEmbedding(nn.Module):
37
+ """Embed diffusion time with fixed random Fourier frequencies."""
38
+
39
+ def __init__(self, dim: int) -> None:
40
+ super().__init__()
41
+ self.proj = nn.Linear(1, dim)
42
+ nn.init.normal_(self.proj.weight, mean=0, std=1)
43
+ nn.init.normal_(self.proj.bias, mean=0, std=1)
44
+ self.proj.requires_grad_(False)
45
+
46
+ def forward(self, times: torch.Tensor) -> torch.Tensor:
47
+ """Map times with shape ``(b,)`` to an embedding ``H: (b, d)``."""
48
+
49
+ random_phase = self.proj(times.reshape(-1, 1)) # (b, d)
50
+ return torch.cos(2 * pi * random_phase) # (b, d)
51
+
52
+
53
+ def _pairwise_difference(values: torch.Tensor) -> torch.Tensor:
54
+ # values: (b, n)
55
+ return values[:, :, None] - values[:, None, :] # (b, n, n)
56
+
57
+
58
+ class RelativePositionEncoder(nn.Module):
59
+ """Encode residue, token, entity, and symmetry relationships."""
60
+
61
+ def __init__(
62
+ self,
63
+ token_z: int,
64
+ r_max: int = 32,
65
+ s_max: int = 2,
66
+ fix_sym_check: bool = False,
67
+ cyclic_pos_enc: bool = False,
68
+ ) -> None:
69
+ super().__init__()
70
+ self.r_max = r_max
71
+ self.s_max = s_max
72
+ input_dim = 4 * (r_max + 1) + 2 * (s_max + 1) + 1
73
+ self.linear_layer = LinearNoBias(input_dim, token_z)
74
+ self.fix_sym_check = fix_sym_check
75
+ self.cyclic_pos_enc = cyclic_pos_enc
76
+
77
+ def forward(self, feats: dict[str, torch.Tensor]) -> torch.Tensor:
78
+ """Return relative pair features ``Z: (b, n, n, token_z)``."""
79
+
80
+ same_chain = (
81
+ feats["asym_id"][:, :, None] == feats["asym_id"][:, None, :]
82
+ ) # (b, n, n)
83
+ same_residue = (
84
+ feats["residue_index"][:, :, None] == feats["residue_index"][:, None, :]
85
+ ) # (b, n, n)
86
+ same_entity = (
87
+ feats["entity_id"][:, :, None] == feats["entity_id"][:, None, :]
88
+ ) # (b, n, n)
89
+
90
+ residue_offset = _pairwise_difference(feats["residue_index"]) # (b, n, n)
91
+ if self.cyclic_pos_enc and torch.any(feats["cyclic_period"] > 0):
92
+ period = torch.where(
93
+ feats["cyclic_period"] > 0,
94
+ feats["cyclic_period"],
95
+ torch.zeros_like(feats["cyclic_period"]) + 10000,
96
+ ) # (b, n)
97
+ residue_offset = (
98
+ residue_offset - period * torch.round(residue_offset / period)
99
+ ).long() # (b, n, n)
100
+ residue_offset = torch.clip(
101
+ residue_offset + self.r_max,
102
+ 0,
103
+ 2 * self.r_max,
104
+ ) # (b, n, n)
105
+ residue_offset = torch.where(
106
+ same_chain,
107
+ residue_offset,
108
+ torch.zeros_like(residue_offset) + 2 * self.r_max + 1,
109
+ ) # (b, n, n)
110
+ residue_features = one_hot(
111
+ residue_offset, 2 * self.r_max + 2
112
+ ) # (b, n, n, 2 * r_max + 2)
113
+
114
+ token_offset = torch.clip(
115
+ _pairwise_difference(feats["token_index"]) + self.r_max,
116
+ 0,
117
+ 2 * self.r_max,
118
+ ) # (b, n, n)
119
+ token_offset = torch.where(
120
+ same_chain & same_residue,
121
+ token_offset,
122
+ torch.zeros_like(token_offset) + 2 * self.r_max + 1,
123
+ ) # (b, n, n)
124
+ token_features = one_hot(
125
+ token_offset, 2 * self.r_max + 2
126
+ ) # (b, n, n, 2 * r_max + 2)
127
+
128
+ symmetry_offset = torch.clip(
129
+ _pairwise_difference(feats["sym_id"]) + self.s_max,
130
+ 0,
131
+ 2 * self.s_max,
132
+ ) # (b, n, n)
133
+ invalid_symmetry = ~same_entity if self.fix_sym_check else same_chain # (b, n, n)
134
+ symmetry_offset = torch.where(
135
+ invalid_symmetry,
136
+ torch.zeros_like(symmetry_offset) + 2 * self.s_max + 1,
137
+ symmetry_offset,
138
+ ) # (b, n, n)
139
+ symmetry_features = one_hot(
140
+ symmetry_offset, 2 * self.s_max + 2
141
+ ) # (b, n, n, 2 * s_max + 2)
142
+
143
+ pair_features = torch.cat(
144
+ (
145
+ residue_features.float(),
146
+ token_features.float(),
147
+ same_entity.unsqueeze(-1).float(),
148
+ symmetry_features.float(),
149
+ ),
150
+ dim=-1,
151
+ ) # (b, n, n, d_rel)
152
+ return self.linear_layer(pair_features) # (b, n, n, token_z)
153
+
154
+
155
+ class SingleConditioning(nn.Module):
156
+ """Condition token features on trunk inputs and diffusion time."""
157
+
158
+ def __init__(
159
+ self,
160
+ sigma_data: float,
161
+ token_s: int = 384,
162
+ dim_fourier: int = 256,
163
+ num_transitions: int = 2,
164
+ transition_expansion_factor: int = 2,
165
+ eps: float = 1e-20,
166
+ disable_times: bool = False,
167
+ ) -> None:
168
+ super().__init__()
169
+ self.eps = eps
170
+ self.sigma_data = sigma_data
171
+ self.disable_times = disable_times
172
+ conditioning_dim = 2 * token_s
173
+ self.norm_single = nn.LayerNorm(conditioning_dim)
174
+ self.single_embed = nn.Linear(conditioning_dim, conditioning_dim)
175
+ if not disable_times:
176
+ self.fourier_embed = FourierEmbedding(dim_fourier)
177
+ self.norm_fourier = nn.LayerNorm(dim_fourier)
178
+ self.fourier_to_single = LinearNoBias(dim_fourier, conditioning_dim)
179
+ self.transitions = _transition_stack(
180
+ num_transitions,
181
+ conditioning_dim,
182
+ transition_expansion_factor * conditioning_dim,
183
+ )
184
+
185
+ def forward(
186
+ self,
187
+ times: torch.Tensor,
188
+ s_trunk: torch.Tensor,
189
+ s_inputs: torch.Tensor,
190
+ ) -> tuple[torch.Tensor, torch.Tensor | None]:
191
+ """Return conditioned token features ``S: (b, n, 2 * token_s)``."""
192
+
193
+ # times: (b,); s_trunk/s_inputs: (b, n, token_s).
194
+ single = torch.cat((s_trunk, s_inputs), dim=-1) # (b, n, 2 * token_s)
195
+ single = self.single_embed(self.norm_single(single)) # (b, n, 2 * token_s)
196
+ normalized_fourier = None # (b, d_fourier) or None
197
+ if not self.disable_times:
198
+ fourier = self.fourier_embed(times) # (b, d_fourier)
199
+ normalized_fourier = self.norm_fourier(fourier) # (b, d_fourier)
200
+ time_condition = self.fourier_to_single(
201
+ normalized_fourier
202
+ ) # (b, 2 * token_s)
203
+ single = time_condition[:, None, :] + single # (b, n, 2 * token_s)
204
+ for transition in self.transitions:
205
+ single = transition(single) + single # (b, n, 2 * token_s)
206
+ return single, normalized_fourier # (b, n, 2 * token_s), (b, d_fourier) or None
207
+
208
+
209
+ class PairwiseConditioning(nn.Module):
210
+ """Fuse trunk pair features with relative-position features."""
211
+
212
+ def __init__(
213
+ self,
214
+ token_z: int,
215
+ dim_token_rel_pos_feats: int,
216
+ num_transitions: int = 2,
217
+ transition_expansion_factor: int = 2,
218
+ ) -> None:
219
+ super().__init__()
220
+ combined_dim = token_z + dim_token_rel_pos_feats
221
+ self.dim_pairwise_init_proj = nn.Sequential(
222
+ nn.LayerNorm(combined_dim),
223
+ LinearNoBias(combined_dim, token_z),
224
+ )
225
+ self.transitions = _transition_stack(
226
+ num_transitions,
227
+ token_z,
228
+ transition_expansion_factor * token_z,
229
+ )
230
+
231
+ def forward(
232
+ self,
233
+ z_trunk: torch.Tensor,
234
+ token_rel_pos_feats: torch.Tensor,
235
+ ) -> torch.Tensor:
236
+ """Return conditioned pair features ``Z: (b, n, n, token_z)``."""
237
+
238
+ pair = self.dim_pairwise_init_proj(
239
+ torch.cat((z_trunk, token_rel_pos_feats), dim=-1)
240
+ ) # (b, n, n, token_z)
241
+ for transition in self.transitions:
242
+ pair = transition(pair) + pair # (b, n, n, token_z)
243
+ return pair # (b, n, n, token_z)
244
+
245
+
246
+ def get_indexing_matrix(
247
+ k: int,
248
+ w: int,
249
+ h: int,
250
+ device: torch.device,
251
+ ) -> torch.Tensor:
252
+ """Build the atom-window gather matrix used for local attention keys."""
253
+
254
+ for name, value in (("k", k), ("w", w), ("h", h)):
255
+ if isinstance(value, bool) or not isinstance(value, int):
256
+ raise TypeError(f"{name} must be an int, got {type(value).__name__}.")
257
+ if value <= 0:
258
+ raise ValueError(f"{name} must be positive, got {value}.")
259
+ if w % 2 != 0:
260
+ raise ValueError(f"w must be even, got {w}.")
261
+ half_window = w // 2
262
+ if h % half_window != 0:
263
+ raise ValueError(f"h must be divisible by w // 2 ({half_window}), got {h}.")
264
+ key_blocks = h // half_window
265
+ if key_blocks % 2 != 0:
266
+ raise ValueError(
267
+ f"h must contain an even number of half-window key blocks; received {key_blocks}."
268
+ )
269
+
270
+ positions = torch.arange(2 * k, device=device) # (2 * k,)
271
+ relative_blocks = (
272
+ (positions.unsqueeze(0) - positions.unsqueeze(1)) + key_blocks // 2
273
+ ).clamp(min=0, max=key_blocks + 1) # (2 * k, 2 * k)
274
+ relative_blocks = relative_blocks.view(k, 2, 2 * k)[:, 0, :] # (k, 2 * k)
275
+ selectors = one_hot(relative_blocks, num_classes=key_blocks + 2)[
276
+ ..., 1:-1
277
+ ].transpose(1, 0) # (2 * k, k, key_blocks)
278
+ return selectors.reshape(
279
+ 2 * k, key_blocks * k
280
+ ).float() # (2 * k, key_blocks * k)
281
+
282
+
283
+ def single_to_keys(
284
+ single: torch.Tensor,
285
+ indexing_matrix: torch.Tensor,
286
+ w: int,
287
+ h: int,
288
+ ) -> torch.Tensor:
289
+ """Gather a sequence tensor into overlapping local key windows."""
290
+
291
+ b, n, d = single.shape
292
+ k = n // w
293
+ half_windows = single.view(b, 2 * k, w // 2, d) # (b, 2 * k, w / 2, d)
294
+ gathered = torch.einsum(
295
+ "b j i d, j k -> b k i d", half_windows, indexing_matrix
296
+ ) # (b, key_blocks * k, w / 2, d)
297
+ return gathered.reshape(b, k, h, d) # (b, k, h, d)
298
+
299
+
300
+ class AtomEncoder(nn.Module):
301
+ """Encode reference atoms and local atom-pair geometry."""
302
+
303
+ def __init__(
304
+ self,
305
+ atom_s: int,
306
+ atom_z: int,
307
+ token_s: int,
308
+ token_z: int,
309
+ atoms_per_window_queries: int,
310
+ atoms_per_window_keys: int,
311
+ atom_feature_dim: int,
312
+ structure_prediction: bool = True,
313
+ use_no_atom_char: bool = False,
314
+ use_atom_backbone_feat: bool = False,
315
+ use_residue_feats_atoms: bool = False,
316
+ ) -> None:
317
+ super().__init__()
318
+ self.embed_atom_features = nn.Linear(atom_feature_dim, atom_s)
319
+ self.embed_atompair_ref_pos = LinearNoBias(3, atom_z)
320
+ self.embed_atompair_ref_dist = LinearNoBias(1, atom_z)
321
+ self.embed_atompair_mask = LinearNoBias(1, atom_z)
322
+ self.atoms_per_window_queries = atoms_per_window_queries
323
+ self.atoms_per_window_keys = atoms_per_window_keys
324
+ self.use_no_atom_char = use_no_atom_char
325
+ self.use_atom_backbone_feat = use_atom_backbone_feat
326
+ self.use_residue_feats_atoms = use_residue_feats_atoms
327
+ self.structure_prediction = structure_prediction
328
+
329
+ if structure_prediction:
330
+ self.s_to_c_trans = nn.Sequential(
331
+ nn.LayerNorm(token_s),
332
+ LinearNoBias(token_s, atom_s),
333
+ )
334
+ init.final_init_(self.s_to_c_trans[1].weight)
335
+ self.z_to_p_trans = nn.Sequential(
336
+ nn.LayerNorm(token_z),
337
+ LinearNoBias(token_z, atom_z),
338
+ )
339
+ init.final_init_(self.z_to_p_trans[1].weight)
340
+
341
+ self.c_to_p_trans_k = nn.Sequential(nn.ReLU(), LinearNoBias(atom_s, atom_z))
342
+ init.final_init_(self.c_to_p_trans_k[1].weight)
343
+ self.c_to_p_trans_q = nn.Sequential(nn.ReLU(), LinearNoBias(atom_s, atom_z))
344
+ init.final_init_(self.c_to_p_trans_q[1].weight)
345
+ self.p_mlp = nn.Sequential(
346
+ nn.ReLU(),
347
+ LinearNoBias(atom_z, atom_z),
348
+ nn.ReLU(),
349
+ LinearNoBias(atom_z, atom_z),
350
+ nn.ReLU(),
351
+ LinearNoBias(atom_z, atom_z),
352
+ )
353
+ init.final_init_(self.p_mlp[5].weight)
354
+
355
+ def _assemble_atom_features(
356
+ self,
357
+ feats: dict[str, torch.Tensor],
358
+ b: int,
359
+ n: int,
360
+ ) -> torch.Tensor:
361
+ # Feature tensors share leading shape (b, n).
362
+ feature_parts = [
363
+ feats["ref_pos"],
364
+ feats["ref_charge"].unsqueeze(-1),
365
+ feats["ref_element"],
366
+ ] # each: (b, n, d_feature)
367
+ if not self.use_no_atom_char:
368
+ feature_parts.append(feats["ref_atom_name_chars"].reshape(b, n, 4 * 64))
369
+ if self.use_atom_backbone_feat:
370
+ feature_parts.append(feats["atom_backbone_feat"])
371
+ if self.use_residue_feats_atoms:
372
+ residue_features = torch.cat(
373
+ (
374
+ feats["res_type"],
375
+ feats["modified"].unsqueeze(-1),
376
+ one_hot(feats["mol_type"], num_classes=4).float(),
377
+ ),
378
+ dim=-1,
379
+ ) # (b, t, d_residue)
380
+ feature_parts.append(
381
+ torch.bmm(feats["atom_to_token"].float(), residue_features)
382
+ ) # (b, n, d_residue)
383
+ return torch.cat(feature_parts, dim=-1) # (b, n, atom_feature_dim)
384
+
385
+ def forward(
386
+ self,
387
+ feats: dict[str, torch.Tensor],
388
+ s_trunk: torch.Tensor | None = None,
389
+ z: torch.Tensor | None = None,
390
+ ) -> tuple[
391
+ torch.Tensor,
392
+ torch.Tensor,
393
+ torch.Tensor,
394
+ Callable[[torch.Tensor], torch.Tensor],
395
+ ]:
396
+ """Return atom queries, conditioned singles, pairs, and key gatherer."""
397
+
398
+ with torch.autocast("cuda", enabled=False):
399
+ b, n, _ = feats["ref_pos"].shape
400
+ atom_mask = feats["atom_pad_mask"].bool() # (b, n)
401
+ atom_positions = feats["ref_pos"] # (b, n, 3)
402
+ atom_space = feats["ref_space_uid"] # (b, n)
403
+ atom_features = self._assemble_atom_features(
404
+ feats, b, n
405
+ ) # (b, n, atom_feature_dim)
406
+ conditioned_single = self.embed_atom_features(
407
+ atom_features
408
+ ) # (b, n, atom_s)
409
+
410
+ w = self.atoms_per_window_queries
411
+ h = self.atoms_per_window_keys
412
+ b, n = conditioned_single.shape[:2]
413
+ k = n // w
414
+ indexing = get_indexing_matrix(
415
+ k, w, h, conditioned_single.device
416
+ ) # (2 * k, key_blocks * k)
417
+ to_keys = partial(single_to_keys, indexing_matrix=indexing, w=w, h=h)
418
+
419
+ position_queries = atom_positions.view(b, k, w, 1, 3) # (b, k, w, 1, 3)
420
+ position_keys = to_keys(atom_positions).view(
421
+ b, k, 1, h, 3
422
+ ) # (b, k, 1, h, 3)
423
+ displacement = position_keys - position_queries # (b, k, w, h, 3)
424
+ inverse_squared_distance = 1 / (
425
+ 1 + torch.sum(displacement * displacement, dim=-1, keepdim=True)
426
+ ) # (b, k, w, h, 1)
427
+
428
+ mask_queries = atom_mask.view(b, k, w, 1) # (b, k, w, 1)
429
+ mask_keys = (
430
+ to_keys(atom_mask.unsqueeze(-1).float()).view(b, k, 1, h).bool()
431
+ ) # (b, k, 1, h)
432
+ space_queries = atom_space.view(b, k, w, 1) # (b, k, w, 1)
433
+ space_keys = (
434
+ to_keys(atom_space.unsqueeze(-1).float()).view(b, k, 1, h).long()
435
+ ) # (b, k, 1, h)
436
+ valid_pair = (
437
+ (mask_queries & mask_keys & (space_queries == space_keys)).float().unsqueeze(-1)
438
+ ) # (b, k, w, h, 1)
439
+
440
+ pair = self.embed_atompair_ref_pos(displacement) * valid_pair # (b, k, w, h, atom_z)
441
+ pair = (
442
+ pair + self.embed_atompair_ref_dist(inverse_squared_distance) * valid_pair
443
+ ) # (b, k, w, h, atom_z)
444
+ pair = pair + self.embed_atompair_mask(valid_pair) * valid_pair # (b, k, w, h, atom_z)
445
+ query = conditioned_single # (b, n, atom_s)
446
+
447
+ if self.structure_prediction:
448
+ if s_trunk is None or z is None:
449
+ raise ValueError("structure prediction requires S and Z trunk tensors")
450
+ atom_to_token = feats["atom_to_token"].float() # (b, n, t)
451
+ single_update = self.s_to_c_trans(s_trunk.float()) # (b, t, atom_s)
452
+ single_update = torch.bmm(
453
+ atom_to_token, single_update
454
+ ) # (b, n, atom_s)
455
+ conditioned_single = conditioned_single + single_update.to(
456
+ conditioned_single
457
+ ) # (b, n, atom_s)
458
+
459
+ token_queries = atom_to_token.view(
460
+ b, k, w, atom_to_token.shape[-1]
461
+ ) # (b, k, w, t)
462
+ token_keys = to_keys(atom_to_token) # (b, k, h, t)
463
+ pair_update = self.z_to_p_trans(z.float()) # (b, t, t, atom_z)
464
+ pair_update = torch.einsum(
465
+ "bijd,bwki,bwlj->bwkld",
466
+ pair_update,
467
+ token_queries,
468
+ token_keys,
469
+ ) # (b, k, w, h, atom_z)
470
+ pair = pair + pair_update.to(pair) # (b, k, w, h, atom_z)
471
+
472
+ pair = pair + self.c_to_p_trans_q(
473
+ conditioned_single.view(b, k, w, 1, conditioned_single.shape[-1])
474
+ ) # (b, k, w, h, atom_z)
475
+ pair = pair + self.c_to_p_trans_k(
476
+ to_keys(conditioned_single).view(
477
+ b,
478
+ k,
479
+ 1,
480
+ h,
481
+ conditioned_single.shape[-1],
482
+ )
483
+ ) # (b, k, w, h, atom_z)
484
+ pair = pair + self.p_mlp(pair) # (b, k, w, h, atom_z)
485
+ return (
486
+ query,
487
+ conditioned_single,
488
+ pair,
489
+ to_keys,
490
+ ) # (b, n, atom_s), (b, n, atom_s), (b, k, w, h, atom_z), callable
491
+
492
+
493
+ class AtomAttentionEncoder(nn.Module):
494
+ """Run local atom attention and aggregate atoms to tokens."""
495
+
496
+ def __init__(
497
+ self,
498
+ atom_s: int,
499
+ token_s: int,
500
+ atoms_per_window_queries: int,
501
+ atoms_per_window_keys: int,
502
+ atom_encoder_depth: int = 3,
503
+ atom_encoder_heads: int = 4,
504
+ structure_prediction: bool = True,
505
+ activation_checkpointing: bool = False,
506
+ transformer_post_layer_norm: bool = False,
507
+ ) -> None:
508
+ super().__init__()
509
+ self.structure_prediction = structure_prediction
510
+ if structure_prediction:
511
+ self.r_to_q_trans = LinearNoBias(3, atom_s)
512
+ init.final_init_(self.r_to_q_trans.weight)
513
+ self.atom_encoder = AtomTransformer(
514
+ dim=atom_s,
515
+ dim_single_cond=atom_s,
516
+ attn_window_queries=atoms_per_window_queries,
517
+ attn_window_keys=atoms_per_window_keys,
518
+ depth=atom_encoder_depth,
519
+ heads=atom_encoder_heads,
520
+ activation_checkpointing=activation_checkpointing,
521
+ post_layer_norm=transformer_post_layer_norm,
522
+ )
523
+ output_dim = 2 * token_s if structure_prediction else token_s
524
+ self.atom_to_token_trans = nn.Sequential(
525
+ LinearNoBias(atom_s, output_dim),
526
+ nn.ReLU(),
527
+ )
528
+
529
+ def forward(
530
+ self,
531
+ feats: dict[str, torch.Tensor],
532
+ q: torch.Tensor,
533
+ c: torch.Tensor,
534
+ atom_enc_bias: torch.Tensor,
535
+ to_keys: Callable[[torch.Tensor], torch.Tensor],
536
+ r: torch.Tensor | None = None,
537
+ multiplicity: int = 1,
538
+ ) -> tuple[
539
+ torch.Tensor,
540
+ torch.Tensor,
541
+ torch.Tensor,
542
+ Callable[[torch.Tensor], torch.Tensor],
543
+ ]:
544
+ b, n, _ = feats["ref_pos"].shape
545
+ del b, n
546
+ # q/c: (b, a, atom_s); atom_enc_bias uses local-window attention axes.
547
+ atom_mask = feats["atom_pad_mask"].bool() # (b, a)
548
+ if self.structure_prediction:
549
+ if r is None:
550
+ raise ValueError("structure prediction requires atom coordinates R")
551
+ q = q.repeat_interleave(multiplicity, 0) # (b * m, a, atom_s)
552
+ q = q + self.r_to_q_trans(r) # (b * m, a, atom_s)
553
+ c = c.repeat_interleave(multiplicity, 0) # (b * m, a, atom_s)
554
+ atom_mask = atom_mask.repeat_interleave(multiplicity, 0) # (b * m, a)
555
+ q = self.atom_encoder(
556
+ q=q,
557
+ mask=atom_mask,
558
+ c=c,
559
+ bias=atom_enc_bias,
560
+ multiplicity=multiplicity,
561
+ to_keys=to_keys,
562
+ ) # (b * m, a, atom_s)
563
+
564
+ with torch.autocast("cuda", enabled=False):
565
+ atom_update = self.atom_to_token_trans(q).float() # (b * m, a, d_token)
566
+ atom_to_token = feats["atom_to_token"].float() # (b, a, t)
567
+ atom_to_token = atom_to_token.repeat_interleave(
568
+ multiplicity, 0
569
+ ) # (b * m, a, t)
570
+ atom_to_token_mean = atom_to_token / (
571
+ atom_to_token.sum(dim=1, keepdim=True) + 1e-6
572
+ ) # (b * m, a, t)
573
+ token_update = torch.bmm(
574
+ atom_to_token_mean.transpose(1, 2), atom_update
575
+ ) # (b * m, t, d_token)
576
+ return (
577
+ token_update.to(q),
578
+ q,
579
+ c,
580
+ to_keys,
581
+ ) # (b * m, t, d_token), two (b * m, a, atom_s), callable
582
+
583
+
584
+ class AtomAttentionDecoder(nn.Module):
585
+ """Map token updates back to atoms and predict coordinate displacements."""
586
+
587
+ def __init__(
588
+ self,
589
+ atom_s: int,
590
+ token_s: int,
591
+ attn_window_queries: int,
592
+ attn_window_keys: int,
593
+ atom_decoder_depth: int = 3,
594
+ atom_decoder_heads: int = 4,
595
+ activation_checkpointing: bool = False,
596
+ transformer_post_layer_norm: bool = False,
597
+ ) -> None:
598
+ super().__init__()
599
+ self.a_to_q_trans = LinearNoBias(2 * token_s, atom_s)
600
+ init.final_init_(self.a_to_q_trans.weight)
601
+ self.atom_decoder = AtomTransformer(
602
+ dim=atom_s,
603
+ dim_single_cond=atom_s,
604
+ attn_window_queries=attn_window_queries,
605
+ attn_window_keys=attn_window_keys,
606
+ depth=atom_decoder_depth,
607
+ heads=atom_decoder_heads,
608
+ activation_checkpointing=activation_checkpointing,
609
+ post_layer_norm=transformer_post_layer_norm,
610
+ )
611
+ if transformer_post_layer_norm:
612
+ self.atom_feat_to_atom_pos_update = LinearNoBias(atom_s, 3)
613
+ init.final_init_(self.atom_feat_to_atom_pos_update.weight)
614
+ else:
615
+ self.atom_feat_to_atom_pos_update = nn.Sequential(
616
+ nn.LayerNorm(atom_s),
617
+ LinearNoBias(atom_s, 3),
618
+ )
619
+ init.final_init_(self.atom_feat_to_atom_pos_update[1].weight)
620
+
621
+ def forward(
622
+ self,
623
+ a: torch.Tensor,
624
+ q: torch.Tensor,
625
+ c: torch.Tensor,
626
+ atom_dec_bias: torch.Tensor,
627
+ feats: dict[str, torch.Tensor],
628
+ to_keys: Callable[[torch.Tensor], torch.Tensor],
629
+ multiplicity: int = 1,
630
+ ) -> torch.Tensor:
631
+ """Return atom-coordinate updates ``R_update: (b, a, 3)``."""
632
+
633
+ with torch.autocast("cuda", enabled=False):
634
+ # a: (b * m, t, 2 * token_s); q/c: (b * m, a, atom_s).
635
+ atom_to_token = feats["atom_to_token"].float() # (b, a, t)
636
+ atom_to_token = atom_to_token.repeat_interleave(
637
+ multiplicity, 0
638
+ ) # (b * m, a, t)
639
+ token_update = self.a_to_q_trans(a.float()) # (b * m, t, atom_s)
640
+ atom_update = torch.bmm(atom_to_token, token_update) # (b * m, a, atom_s)
641
+ q = q + atom_update.to(q) # (b * m, a, atom_s)
642
+ atom_mask = feats["atom_pad_mask"].repeat_interleave(
643
+ multiplicity, 0
644
+ ) # (b * m, a)
645
+ q = self.atom_decoder(
646
+ q=q,
647
+ mask=atom_mask,
648
+ c=c,
649
+ bias=atom_dec_bias,
650
+ multiplicity=multiplicity,
651
+ to_keys=to_keys,
652
+ ) # (b * m, a, atom_s)
653
+ return self.atom_feat_to_atom_pos_update(q) # (b * m, a, 3)
fastplms/models/boltz/vb_modules_transformersv2.py CHANGED
@@ -1,276 +1,276 @@
1
- """Conditioned transformer blocks used by Boltz2 diffusion."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Callable
6
- from typing import Any, cast
7
- from torch import Tensor, nn
8
- from torch.utils.checkpoint import checkpoint
9
-
10
- from .vb_layers_attentionv2 import AttentionPairBias
11
- from .vb_modules_utils import LinearNoBias, SwiGLU
12
-
13
-
14
- class AdaLN(nn.Module):
15
- """Normalize activations and apply scale and shift from conditioning S."""
16
-
17
- def __init__(self, dim: int, dim_single_cond: int) -> None:
18
- super().__init__()
19
- self.a_norm = nn.LayerNorm(dim, elementwise_affine=False, bias=False)
20
- self.s_norm = nn.LayerNorm(dim_single_cond, bias=False)
21
- self.s_scale = nn.Linear(dim_single_cond, dim)
22
- self.s_bias = LinearNoBias(dim_single_cond, dim)
23
-
24
- def forward(self, a: Tensor, s: Tensor) -> Tensor:
25
- # a: (..., d); s: (..., d_c).
26
- normalized = self.a_norm(a) # (..., d)
27
- conditioning = self.s_norm(s) # (..., d_c)
28
- # (..., d)
29
- output = self.s_scale(conditioning).sigmoid() * normalized + self.s_bias(conditioning)
30
- return cast(Tensor, output) # (..., d)
31
-
32
-
33
- class ConditionedTransitionBlock(nn.Module):
34
- """Apply a conditioned gated transition without changing tensor shape."""
35
-
36
- def __init__(
37
- self,
38
- dim_single: int,
39
- dim_single_cond: int,
40
- expansion_factor: float = 2,
41
- ) -> None:
42
- super().__init__()
43
- self.adaln = AdaLN(dim_single, dim_single_cond)
44
- inner_dim = int(dim_single * expansion_factor)
45
- self.swish_gate = nn.Sequential(
46
- LinearNoBias(dim_single, inner_dim * 2),
47
- SwiGLU(),
48
- )
49
- self.a_to_b = LinearNoBias(dim_single, inner_dim)
50
- self.b_to_a = LinearNoBias(inner_dim, dim_single)
51
-
52
- projection = nn.Linear(dim_single_cond, dim_single)
53
- nn.init.zeros_(projection.weight) # (d, d_c)
54
- nn.init.constant_(projection.bias, -2.0) # (d,)
55
- self.output_projection = nn.Sequential(projection, nn.Sigmoid())
56
-
57
- def forward(self, a: Tensor, s: Tensor) -> Tensor:
58
- # a: (..., d); s: (..., d_c).
59
- normalized = self.adaln(a, s) # (..., d)
60
- hidden = self.swish_gate(normalized) * self.a_to_b(normalized) # (..., d_inner)
61
- # (..., d)
62
- return cast(Tensor, self.output_projection(s) * self.b_to_a(hidden))
63
-
64
-
65
- class DiffusionTransformer(nn.Module):
66
- """Run conditioned transformer layers over token or atom states."""
67
-
68
- def __init__(
69
- self,
70
- depth: int,
71
- heads: int,
72
- dim: int = 384,
73
- dim_single_cond: int | None = None,
74
- pair_bias_attn: bool = True,
75
- activation_checkpointing: bool = False,
76
- post_layer_norm: bool = False,
77
- ) -> None:
78
- super().__init__()
79
- conditioning_dim = dim if dim_single_cond is None else dim_single_cond
80
- self.activation_checkpointing = activation_checkpointing
81
- self.pair_bias_attn = pair_bias_attn
82
- self.layers = nn.ModuleList(
83
- [
84
- DiffusionTransformerLayer(
85
- heads,
86
- dim,
87
- conditioning_dim,
88
- post_layer_norm,
89
- )
90
- for _ in range(depth)
91
- ]
92
- )
93
-
94
- def _split_pair_bias(self, bias: Tensor | None) -> Tensor | None:
95
- # bias: (b_z, l_q, l_k, n_layer * h) or None.
96
- if not self.pair_bias_attn:
97
- return None
98
- if bias is None:
99
- raise ValueError("pair bias is required when pair_bias_attn=True")
100
- batch_size, query_length, key_length, width = bias.shape # b_z, l_q, l_k, n_layer * h
101
- depth = len(self.layers) # n_layer
102
- if depth == 0 or width % depth:
103
- raise ValueError("pair-bias width must be divisible by transformer depth")
104
- # (b_z, l_q, l_k, n_layer, h)
105
- return bias.view(batch_size, query_length, key_length, depth, width // depth)
106
-
107
- def forward(
108
- self,
109
- a: Tensor,
110
- s: Tensor,
111
- bias: Tensor | None = None,
112
- mask: Tensor | None = None,
113
- to_keys: Callable[[Tensor], Tensor] | None = None,
114
- multiplicity: int = 1,
115
- ) -> Tensor:
116
- """Transform A and preserve shape ``(b * m, l, d)``."""
117
-
118
- # a: (b * m, l_q, d); s: (b * m, l_q, d_c).
119
- # bias: (b, l_q, l_k, n_layer * h) or None.
120
- # mask: (b * m, l_q) before an optional to_keys mapping; to_keys preserves d.
121
- layer_biases = self._split_pair_bias(bias) # (b, l_q, l_k, n_layer, h) or None
122
- output = a # (b * m, l_q, d)
123
- for index, layer in enumerate(self.layers):
124
- # (b, l_q, l_k, h) or None
125
- layer_bias = None if layer_biases is None else layer_biases[..., index, :]
126
- if self.activation_checkpointing:
127
- output = checkpoint( # (b * m, l_q, d)
128
- layer,
129
- output,
130
- s,
131
- layer_bias,
132
- mask,
133
- to_keys,
134
- multiplicity,
135
- use_reentrant=False,
136
- )
137
- else:
138
- output = layer( # (b * m, l_q, d)
139
- output,
140
- s,
141
- layer_bias,
142
- mask,
143
- to_keys,
144
- multiplicity,
145
- )
146
- return output # (b * m, l_q, d)
147
-
148
-
149
- class DiffusionTransformerLayer(nn.Module):
150
- """One adaptive-normalization attention and transition block."""
151
-
152
- def __init__(
153
- self,
154
- heads: int,
155
- dim: int = 384,
156
- dim_single_cond: int | None = None,
157
- post_layer_norm: bool = False,
158
- ) -> None:
159
- super().__init__()
160
- conditioning_dim = dim if dim_single_cond is None else dim_single_cond
161
- self.adaln = AdaLN(dim, conditioning_dim)
162
- self.pair_bias_attn = AttentionPairBias(
163
- c_s=dim,
164
- num_heads=heads,
165
- compute_pair_bias=False,
166
- )
167
- self.output_projection_linear = nn.Linear(conditioning_dim, dim)
168
- nn.init.zeros_(self.output_projection_linear.weight) # (d, d_c)
169
- nn.init.constant_(self.output_projection_linear.bias, -2.0) # (d,)
170
- self.output_projection = nn.Sequential(
171
- self.output_projection_linear,
172
- nn.Sigmoid(),
173
- )
174
- self.transition = ConditionedTransitionBlock(dim, conditioning_dim)
175
- self.post_lnorm = nn.LayerNorm(dim) if post_layer_norm else nn.Identity()
176
-
177
- def forward(
178
- self,
179
- a: Tensor,
180
- s: Tensor,
181
- bias: Tensor | None = None,
182
- mask: Tensor | None = None,
183
- to_keys: Callable[[Tensor], Tensor] | None = None,
184
- multiplicity: int = 1,
185
- ) -> Tensor:
186
- """Update activation tensor A with conditioning S and pair bias."""
187
-
188
- # a: (b * m, l_q, d); s: (b * m, l_q, d_c).
189
- # bias: (b, l_q, l_k, h) or None.
190
- # mask: (b * m, l_q) or None; to_keys maps l_q to l_k.
191
- if bias is None or mask is None:
192
- raise ValueError("diffusion attention requires pair bias and a key mask")
193
- normalized = self.adaln(a, s) # (b * m, l_q, d)
194
- key_states = normalized # (b * m, l_q, d)
195
- key_mask = mask # (b * m, l_q)
196
- if to_keys is not None:
197
- key_states = to_keys(normalized) # (b * m, l_k, d)
198
- key_mask = to_keys(mask.unsqueeze(-1)).squeeze(-1) # (b * m, l_k)
199
-
200
- attended = self.pair_bias_attn( # (b * m, l_q, d)
201
- s=normalized,
202
- z=bias,
203
- mask=key_mask,
204
- multiplicity=multiplicity,
205
- k_in=key_states,
206
- )
207
- output = a + self.output_projection(s) * attended # (b * m, l_q, d)
208
- output = output + self.transition(output, s) # (b * m, l_q, d)
209
- return cast(Tensor, self.post_lnorm(output)) # (b * m, l_q, d)
210
-
211
-
212
- class AtomTransformer(nn.Module):
213
- """Apply the diffusion transformer over fixed atom-query windows."""
214
-
215
- def __init__(
216
- self,
217
- attn_window_queries: int,
218
- attn_window_keys: int,
219
- **diffusion_transformer_kwargs: Any,
220
- ) -> None:
221
- super().__init__()
222
- self.attn_window_queries = attn_window_queries
223
- self.attn_window_keys = attn_window_keys
224
- self.diffusion_transformer = DiffusionTransformer(
225
- **diffusion_transformer_kwargs,
226
- )
227
-
228
- def forward(
229
- self,
230
- q: Tensor,
231
- c: Tensor,
232
- bias: Tensor,
233
- to_keys: Callable[[Tensor], Tensor],
234
- mask: Tensor,
235
- multiplicity: int = 1,
236
- ) -> Tensor:
237
- """Transform atom tensor Q with shape ``(b, n_atoms, d)``."""
238
-
239
- query_window = self.attn_window_queries # w_q
240
- key_window = self.attn_window_keys # w_k
241
- batch_size, atom_count, width = q.shape # b * m, n_atom, d
242
- window_count = atom_count // query_window # k
243
- # q, c: (b * m, n_atom, d); bias: (b, k, w_q, w_k, n_layer * h).
244
- # mask: (b * m, n_atom); k = n_atom // w_q.
245
- # (b * m * k, w_q, d)
246
- query_states = q.view(batch_size * window_count, query_window, -1)
247
- # (b * m * k, w_q, d_c)
248
- conditioning = c.view(batch_size * window_count, query_window, -1)
249
- # (b * m * k, w_q)
250
- query_mask = mask.view(batch_size * window_count, query_window)
251
- # (b * m, k, w_q, w_k, n_layer * h)
252
- pair_bias = bias.repeat_interleave(multiplicity, dim=0)
253
- pair_bias = pair_bias.view( # (b * m * k, w_q, w_k, n_layer * h)
254
- pair_bias.shape[0] * window_count,
255
- query_window,
256
- key_window,
257
- -1,
258
- )
259
-
260
- def windowed_keys(states: Tensor) -> Tensor:
261
- # states: (b * m * k, w_q, d_x).
262
- # (b * m, n_atom, d_x)
263
- merged = states.view(batch_size, window_count * query_window, -1)
264
- # (b * m * k, w_k, d_x)
265
- return to_keys(merged).view(batch_size * window_count, key_window, -1)
266
-
267
- output = self.diffusion_transformer( # (b * m * k, w_q, d)
268
- a=query_states,
269
- s=conditioning,
270
- bias=pair_bias,
271
- mask=query_mask.float(),
272
- multiplicity=1,
273
- to_keys=windowed_keys,
274
- )
275
- # (b * m, n_atom, d)
276
- return cast(Tensor, output.view(batch_size, window_count * query_window, width))
 
1
+ """Conditioned transformer blocks used by Boltz2 diffusion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any, cast
7
+ from torch import Tensor, nn
8
+ from torch.utils.checkpoint import checkpoint
9
+
10
+ from .vb_layers_attentionv2 import AttentionPairBias
11
+ from .vb_modules_utils import LinearNoBias, SwiGLU
12
+
13
+
14
+ class AdaLN(nn.Module):
15
+ """Normalize activations and apply scale and shift from conditioning S."""
16
+
17
+ def __init__(self, dim: int, dim_single_cond: int) -> None:
18
+ super().__init__()
19
+ self.a_norm = nn.LayerNorm(dim, elementwise_affine=False, bias=False)
20
+ self.s_norm = nn.LayerNorm(dim_single_cond, bias=False)
21
+ self.s_scale = nn.Linear(dim_single_cond, dim)
22
+ self.s_bias = LinearNoBias(dim_single_cond, dim)
23
+
24
+ def forward(self, a: Tensor, s: Tensor) -> Tensor:
25
+ # a: (..., d); s: (..., d_c).
26
+ normalized = self.a_norm(a) # (..., d)
27
+ conditioning = self.s_norm(s) # (..., d_c)
28
+ # (..., d)
29
+ output = self.s_scale(conditioning).sigmoid() * normalized + self.s_bias(conditioning)
30
+ return cast(Tensor, output) # (..., d)
31
+
32
+
33
+ class ConditionedTransitionBlock(nn.Module):
34
+ """Apply a conditioned gated transition without changing tensor shape."""
35
+
36
+ def __init__(
37
+ self,
38
+ dim_single: int,
39
+ dim_single_cond: int,
40
+ expansion_factor: float = 2,
41
+ ) -> None:
42
+ super().__init__()
43
+ self.adaln = AdaLN(dim_single, dim_single_cond)
44
+ inner_dim = int(dim_single * expansion_factor)
45
+ self.swish_gate = nn.Sequential(
46
+ LinearNoBias(dim_single, inner_dim * 2),
47
+ SwiGLU(),
48
+ )
49
+ self.a_to_b = LinearNoBias(dim_single, inner_dim)
50
+ self.b_to_a = LinearNoBias(inner_dim, dim_single)
51
+
52
+ projection = nn.Linear(dim_single_cond, dim_single)
53
+ nn.init.zeros_(projection.weight) # (d, d_c)
54
+ nn.init.constant_(projection.bias, -2.0) # (d,)
55
+ self.output_projection = nn.Sequential(projection, nn.Sigmoid())
56
+
57
+ def forward(self, a: Tensor, s: Tensor) -> Tensor:
58
+ # a: (..., d); s: (..., d_c).
59
+ normalized = self.adaln(a, s) # (..., d)
60
+ hidden = self.swish_gate(normalized) * self.a_to_b(normalized) # (..., d_inner)
61
+ # (..., d)
62
+ return cast(Tensor, self.output_projection(s) * self.b_to_a(hidden))
63
+
64
+
65
+ class DiffusionTransformer(nn.Module):
66
+ """Run conditioned transformer layers over token or atom states."""
67
+
68
+ def __init__(
69
+ self,
70
+ depth: int,
71
+ heads: int,
72
+ dim: int = 384,
73
+ dim_single_cond: int | None = None,
74
+ pair_bias_attn: bool = True,
75
+ activation_checkpointing: bool = False,
76
+ post_layer_norm: bool = False,
77
+ ) -> None:
78
+ super().__init__()
79
+ conditioning_dim = dim if dim_single_cond is None else dim_single_cond
80
+ self.activation_checkpointing = activation_checkpointing
81
+ self.pair_bias_attn = pair_bias_attn
82
+ self.layers = nn.ModuleList(
83
+ [
84
+ DiffusionTransformerLayer(
85
+ heads,
86
+ dim,
87
+ conditioning_dim,
88
+ post_layer_norm,
89
+ )
90
+ for _ in range(depth)
91
+ ]
92
+ )
93
+
94
+ def _split_pair_bias(self, bias: Tensor | None) -> Tensor | None:
95
+ # bias: (b_z, l_q, l_k, n_layer * h) or None.
96
+ if not self.pair_bias_attn:
97
+ return None
98
+ if bias is None:
99
+ raise ValueError("pair bias is required when pair_bias_attn=True")
100
+ batch_size, query_length, key_length, width = bias.shape # b_z, l_q, l_k, n_layer * h
101
+ depth = len(self.layers) # n_layer
102
+ if depth == 0 or width % depth:
103
+ raise ValueError("pair-bias width must be divisible by transformer depth")
104
+ # (b_z, l_q, l_k, n_layer, h)
105
+ return bias.view(batch_size, query_length, key_length, depth, width // depth)
106
+
107
+ def forward(
108
+ self,
109
+ a: Tensor,
110
+ s: Tensor,
111
+ bias: Tensor | None = None,
112
+ mask: Tensor | None = None,
113
+ to_keys: Callable[[Tensor], Tensor] | None = None,
114
+ multiplicity: int = 1,
115
+ ) -> Tensor:
116
+ """Transform A and preserve shape ``(b * m, l, d)``."""
117
+
118
+ # a: (b * m, l_q, d); s: (b * m, l_q, d_c).
119
+ # bias: (b, l_q, l_k, n_layer * h) or None.
120
+ # mask: (b * m, l_q) before an optional to_keys mapping; to_keys preserves d.
121
+ layer_biases = self._split_pair_bias(bias) # (b, l_q, l_k, n_layer, h) or None
122
+ output = a # (b * m, l_q, d)
123
+ for index, layer in enumerate(self.layers):
124
+ # (b, l_q, l_k, h) or None
125
+ layer_bias = None if layer_biases is None else layer_biases[..., index, :]
126
+ if self.activation_checkpointing:
127
+ output = checkpoint( # (b * m, l_q, d)
128
+ layer,
129
+ output,
130
+ s,
131
+ layer_bias,
132
+ mask,
133
+ to_keys,
134
+ multiplicity,
135
+ use_reentrant=False,
136
+ )
137
+ else:
138
+ output = layer( # (b * m, l_q, d)
139
+ output,
140
+ s,
141
+ layer_bias,
142
+ mask,
143
+ to_keys,
144
+ multiplicity,
145
+ )
146
+ return output # (b * m, l_q, d)
147
+
148
+
149
+ class DiffusionTransformerLayer(nn.Module):
150
+ """One adaptive-normalization attention and transition block."""
151
+
152
+ def __init__(
153
+ self,
154
+ heads: int,
155
+ dim: int = 384,
156
+ dim_single_cond: int | None = None,
157
+ post_layer_norm: bool = False,
158
+ ) -> None:
159
+ super().__init__()
160
+ conditioning_dim = dim if dim_single_cond is None else dim_single_cond
161
+ self.adaln = AdaLN(dim, conditioning_dim)
162
+ self.pair_bias_attn = AttentionPairBias(
163
+ c_s=dim,
164
+ num_heads=heads,
165
+ compute_pair_bias=False,
166
+ )
167
+ self.output_projection_linear = nn.Linear(conditioning_dim, dim)
168
+ nn.init.zeros_(self.output_projection_linear.weight) # (d, d_c)
169
+ nn.init.constant_(self.output_projection_linear.bias, -2.0) # (d,)
170
+ self.output_projection = nn.Sequential(
171
+ self.output_projection_linear,
172
+ nn.Sigmoid(),
173
+ )
174
+ self.transition = ConditionedTransitionBlock(dim, conditioning_dim)
175
+ self.post_lnorm = nn.LayerNorm(dim) if post_layer_norm else nn.Identity()
176
+
177
+ def forward(
178
+ self,
179
+ a: Tensor,
180
+ s: Tensor,
181
+ bias: Tensor | None = None,
182
+ mask: Tensor | None = None,
183
+ to_keys: Callable[[Tensor], Tensor] | None = None,
184
+ multiplicity: int = 1,
185
+ ) -> Tensor:
186
+ """Update activation tensor A with conditioning S and pair bias."""
187
+
188
+ # a: (b * m, l_q, d); s: (b * m, l_q, d_c).
189
+ # bias: (b, l_q, l_k, h) or None.
190
+ # mask: (b * m, l_q) or None; to_keys maps l_q to l_k.
191
+ if bias is None or mask is None:
192
+ raise ValueError("diffusion attention requires pair bias and a key mask")
193
+ normalized = self.adaln(a, s) # (b * m, l_q, d)
194
+ key_states = normalized # (b * m, l_q, d)
195
+ key_mask = mask # (b * m, l_q)
196
+ if to_keys is not None:
197
+ key_states = to_keys(normalized) # (b * m, l_k, d)
198
+ key_mask = to_keys(mask.unsqueeze(-1)).squeeze(-1) # (b * m, l_k)
199
+
200
+ attended = self.pair_bias_attn( # (b * m, l_q, d)
201
+ s=normalized,
202
+ z=bias,
203
+ mask=key_mask,
204
+ multiplicity=multiplicity,
205
+ k_in=key_states,
206
+ )
207
+ output = a + self.output_projection(s) * attended # (b * m, l_q, d)
208
+ output = output + self.transition(output, s) # (b * m, l_q, d)
209
+ return cast(Tensor, self.post_lnorm(output)) # (b * m, l_q, d)
210
+
211
+
212
+ class AtomTransformer(nn.Module):
213
+ """Apply the diffusion transformer over fixed atom-query windows."""
214
+
215
+ def __init__(
216
+ self,
217
+ attn_window_queries: int,
218
+ attn_window_keys: int,
219
+ **diffusion_transformer_kwargs: Any,
220
+ ) -> None:
221
+ super().__init__()
222
+ self.attn_window_queries = attn_window_queries
223
+ self.attn_window_keys = attn_window_keys
224
+ self.diffusion_transformer = DiffusionTransformer(
225
+ **diffusion_transformer_kwargs,
226
+ )
227
+
228
+ def forward(
229
+ self,
230
+ q: Tensor,
231
+ c: Tensor,
232
+ bias: Tensor,
233
+ to_keys: Callable[[Tensor], Tensor],
234
+ mask: Tensor,
235
+ multiplicity: int = 1,
236
+ ) -> Tensor:
237
+ """Transform atom tensor Q with shape ``(b, n_atoms, d)``."""
238
+
239
+ query_window = self.attn_window_queries # w_q
240
+ key_window = self.attn_window_keys # w_k
241
+ batch_size, atom_count, width = q.shape # b * m, n_atom, d
242
+ window_count = atom_count // query_window # k
243
+ # q, c: (b * m, n_atom, d); bias: (b, k, w_q, w_k, n_layer * h).
244
+ # mask: (b * m, n_atom); k = n_atom // w_q.
245
+ # (b * m * k, w_q, d)
246
+ query_states = q.view(batch_size * window_count, query_window, -1)
247
+ # (b * m * k, w_q, d_c)
248
+ conditioning = c.view(batch_size * window_count, query_window, -1)
249
+ # (b * m * k, w_q)
250
+ query_mask = mask.view(batch_size * window_count, query_window)
251
+ # (b * m, k, w_q, w_k, n_layer * h)
252
+ pair_bias = bias.repeat_interleave(multiplicity, dim=0)
253
+ pair_bias = pair_bias.view( # (b * m * k, w_q, w_k, n_layer * h)
254
+ pair_bias.shape[0] * window_count,
255
+ query_window,
256
+ key_window,
257
+ -1,
258
+ )
259
+
260
+ def windowed_keys(states: Tensor) -> Tensor:
261
+ # states: (b * m * k, w_q, d_x).
262
+ # (b * m, n_atom, d_x)
263
+ merged = states.view(batch_size, window_count * query_window, -1)
264
+ # (b * m * k, w_k, d_x)
265
+ return to_keys(merged).view(batch_size * window_count, key_window, -1)
266
+
267
+ output = self.diffusion_transformer( # (b * m * k, w_q, d)
268
+ a=query_states,
269
+ s=conditioning,
270
+ bias=pair_bias,
271
+ mask=query_mask.float(),
272
+ multiplicity=1,
273
+ to_keys=windowed_keys,
274
+ )
275
+ # (b * m, n_atom, d)
276
+ return cast(Tensor, output.view(batch_size, window_count * query_window, width))
fastplms/models/boltz/vb_modules_trunkv2.py CHANGED
@@ -1,624 +1,624 @@
1
- """Input, template, MSA, and prediction-head modules for the Boltz2 trunk."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from typing import Any, cast
8
- from torch import Tensor, nn
9
- from torch.nn import functional as F
10
- from torch.utils.checkpoint import checkpoint
11
-
12
- from . import vb_const as const
13
- from .vb_layers_dropout import get_dropout_mask
14
- from .vb_layers_outer_product_mean import OuterProductMean
15
- from .vb_layers_pair_averaging import PairWeightedAveraging
16
- from .vb_layers_pairformer import PairformerNoSeqLayer, PairformerNoSeqModule
17
- from .vb_layers_transition import Transition
18
- from .vb_modules_encodersv2 import (
19
- AtomAttentionEncoder,
20
- AtomEncoder,
21
- FourierEmbedding,
22
- )
23
-
24
-
25
- class ContactConditioning(nn.Module):
26
- """Encode selected, unselected, and distance-threshold contact constraints."""
27
-
28
- def __init__(self, token_z: int, cutoff_min: float, cutoff_max: float) -> None:
29
- super().__init__()
30
- self.fourier_embedding = FourierEmbedding(token_z)
31
- input_width = token_z + len(const.contact_conditioning_info) - 1
32
- self.encoder = nn.Linear(input_width, token_z)
33
- self.encoding_unspecified = nn.Parameter(torch.zeros(token_z)) # (d_z,)
34
- self.encoding_unselected = nn.Parameter(torch.zeros(token_z)) # (d_z,)
35
- self.cutoff_min = cutoff_min
36
- self.cutoff_max = cutoff_max
37
-
38
- def forward(self, feats: dict[str, Tensor]) -> Tensor:
39
- """Return contact tensor C with shape ``(b, l, l, d_z)``."""
40
-
41
- if const.contact_conditioning_info["UNSPECIFIED"] != 0:
42
- raise ValueError("UNSPECIFIED contact conditioning must use channel zero")
43
- if const.contact_conditioning_info["UNSELECTED"] != 1:
44
- raise ValueError("UNSELECTED contact conditioning must use channel one")
45
-
46
- # c_contact is the number of contact-conditioning categories.
47
- categories = feats["contact_conditioning"] # (b, l, l, c_contact)
48
- threshold = feats["contact_threshold"] # (b, l, l)
49
- # (b, l, l)
50
- normalized = (threshold - self.cutoff_min) / (self.cutoff_max - self.cutoff_min)
51
- # (b, l, l, d_z)
52
- fourier = self.fourier_embedding(normalized.flatten()).reshape((*normalized.shape, -1))
53
- selected_features = torch.cat( # (b, l, l, c_contact - 1 + d_z)
54
- [categories[..., 2:], normalized.unsqueeze(-1), fourier],
55
- dim=-1,
56
- )
57
- selected = self.encoder(selected_features) # (b, l, l, d_z)
58
- special = categories[..., :2] # (b, l, l, 2)
59
- return cast(
60
- Tensor,
61
- selected * (1 - special.sum(dim=-1, keepdim=True))
62
- + self.encoding_unspecified * special[..., 0:1]
63
- + self.encoding_unselected * special[..., 1:2],
64
- ) # (b, l, l, d_z)
65
-
66
-
67
- class InputEmbedder(nn.Module):
68
- """Combine atom, residue, profile, and optional experimental features."""
69
-
70
- def __init__(
71
- self,
72
- atom_s: int,
73
- atom_z: int,
74
- token_s: int,
75
- token_z: int,
76
- atoms_per_window_queries: int,
77
- atoms_per_window_keys: int,
78
- atom_feature_dim: int,
79
- atom_encoder_depth: int,
80
- atom_encoder_heads: int,
81
- activation_checkpointing: bool = False,
82
- add_method_conditioning: bool = False,
83
- add_modified_flag: bool = False,
84
- add_cyclic_flag: bool = False,
85
- add_mol_type_feat: bool = False,
86
- use_no_atom_char: bool = False,
87
- use_atom_backbone_feat: bool = False,
88
- use_residue_feats_atoms: bool = False,
89
- ) -> None:
90
- super().__init__()
91
- self.token_s = token_s
92
- self.add_method_conditioning = add_method_conditioning
93
- self.add_modified_flag = add_modified_flag
94
- self.add_cyclic_flag = add_cyclic_flag
95
- self.add_mol_type_feat = add_mol_type_feat
96
- self.atom_encoder = AtomEncoder(
97
- atom_s=atom_s,
98
- atom_z=atom_z,
99
- token_s=token_s,
100
- token_z=token_z,
101
- atoms_per_window_queries=atoms_per_window_queries,
102
- atoms_per_window_keys=atoms_per_window_keys,
103
- atom_feature_dim=atom_feature_dim,
104
- structure_prediction=False,
105
- use_no_atom_char=use_no_atom_char,
106
- use_atom_backbone_feat=use_atom_backbone_feat,
107
- use_residue_feats_atoms=use_residue_feats_atoms,
108
- )
109
- self.atom_enc_proj_z = nn.Sequential(
110
- nn.LayerNorm(atom_z),
111
- nn.Linear(atom_z, atom_encoder_depth * atom_encoder_heads, bias=False),
112
- )
113
- self.atom_attention_encoder = AtomAttentionEncoder(
114
- atom_s=atom_s,
115
- token_s=token_s,
116
- atoms_per_window_queries=atoms_per_window_queries,
117
- atoms_per_window_keys=atoms_per_window_keys,
118
- atom_encoder_depth=atom_encoder_depth,
119
- atom_encoder_heads=atom_encoder_heads,
120
- structure_prediction=False,
121
- activation_checkpointing=activation_checkpointing,
122
- )
123
- self.res_type_encoding = nn.Linear(const.num_tokens, token_s, bias=False)
124
- self.msa_profile_encoding = nn.Linear(const.num_tokens + 1, token_s, bias=False)
125
-
126
- if add_method_conditioning:
127
- self.method_conditioning_init = nn.Embedding(const.num_method_types, token_s)
128
- self.method_conditioning_init.weight.data.fill_(0) # (n_method, d_s)
129
- if add_modified_flag:
130
- self.modified_conditioning_init = nn.Embedding(2, token_s)
131
- self.modified_conditioning_init.weight.data.fill_(0) # (2, d_s)
132
- if add_cyclic_flag:
133
- self.cyclic_conditioning_init = nn.Linear(1, token_s, bias=False)
134
- self.cyclic_conditioning_init.weight.data.fill_(0) # (d_s, 1)
135
- if add_mol_type_feat:
136
- self.mol_type_conditioning_init = nn.Embedding(
137
- len(const.chain_type_ids),
138
- token_s,
139
- )
140
- self.mol_type_conditioning_init.weight.data.fill_(0) # (n_mol_type, d_s)
141
-
142
- def forward(self, feats: dict[str, Tensor], affinity: bool = False) -> Tensor:
143
- """Return embedded sequence tensor S with shape ``(b, l, d_s)``."""
144
-
145
- # n_atom is the padded atom count; k is the number of atom windows.
146
- residue_type = feats["res_type"].float() # (b, l, n_token_type)
147
- suffix = "_affinity" if affinity else ""
148
- profile = feats[f"profile{suffix}"] # (b, l, n_token_type)
149
- deletion_mean = feats[f"deletion_mean{suffix}"].unsqueeze(-1) # (b, l, 1)
150
- # (b, n_atom, d_a), (b, n_atom, d_a), (b, k, w_q, w_k, d_az), callable
151
- atom_queries, atom_conditioning, atom_pairs, to_keys = self.atom_encoder(feats)
152
- atom_bias = self.atom_enc_proj_z(atom_pairs) # (b, k, w_q, w_k, n_layer * h)
153
- atom_output, _, _, _ = self.atom_attention_encoder(
154
- feats=feats,
155
- q=atom_queries,
156
- c=atom_conditioning,
157
- atom_enc_bias=atom_bias,
158
- to_keys=to_keys,
159
- ) # (b, l, d_s), (b, n_atom, d_a), (b, n_atom, d_a), callable
160
- output = ( # (b, l, d_s)
161
- atom_output
162
- + self.res_type_encoding(residue_type)
163
- + self.msa_profile_encoding(torch.cat([profile, deletion_mean], dim=-1))
164
- )
165
- if self.add_method_conditioning:
166
- # method_feature: (b, l); output: (b, l, d_s).
167
- output = output + self.method_conditioning_init(feats["method_feature"])
168
- if self.add_modified_flag:
169
- # modified: (b, l); output: (b, l, d_s).
170
- output = output + self.modified_conditioning_init(feats["modified"])
171
- if self.add_cyclic_flag:
172
- cyclic = feats["cyclic_period"].clamp(max=1.0).unsqueeze(-1) # (b, l, 1)
173
- output = output + self.cyclic_conditioning_init(cyclic) # (b, l, d_s)
174
- if self.add_mol_type_feat:
175
- # mol_type: (b, l); output: (b, l, d_s).
176
- output = output + self.mol_type_conditioning_init(feats["mol_type"])
177
- return cast(Tensor, output) # (b, l, d_s)
178
-
179
-
180
- class _TemplateBase(nn.Module):
181
- def __init__(
182
- self,
183
- token_z: int,
184
- template_dim: int,
185
- template_blocks: int,
186
- dropout: float,
187
- pairwise_head_width: int,
188
- pairwise_num_heads: int,
189
- post_layer_norm: bool,
190
- activation_checkpointing: bool,
191
- min_dist: float,
192
- max_dist: float,
193
- num_bins: int,
194
- ) -> None:
195
- super().__init__()
196
- self.min_dist = min_dist
197
- self.max_dist = max_dist
198
- self.num_bins = num_bins
199
- self.relu = nn.ReLU()
200
- self.z_norm = nn.LayerNorm(token_z)
201
- self.v_norm = nn.LayerNorm(template_dim)
202
- self.z_proj = nn.Linear(token_z, template_dim, bias=False)
203
- feature_width = const.num_tokens * 2 + num_bins + 5
204
- self.a_proj = nn.Linear(feature_width, template_dim, bias=False)
205
- self.u_proj = nn.Linear(template_dim, token_z, bias=False)
206
- self.pairformer = PairformerNoSeqModule(
207
- template_dim,
208
- num_blocks=template_blocks,
209
- dropout=dropout,
210
- pairwise_head_width=pairwise_head_width,
211
- pairwise_num_heads=pairwise_num_heads,
212
- post_layer_norm=post_layer_norm,
213
- activation_checkpointing=activation_checkpointing,
214
- )
215
-
216
- def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
217
- raise NotImplementedError
218
-
219
- def _template_features(
220
- self,
221
- feats: dict[str, Tensor],
222
- template_pair_mask: Tensor,
223
- ) -> Tensor:
224
- # residue_type: (b, t, l, n_token_type); template_pair_mask: (b, t, l, l).
225
- residue_type = feats["template_restype"] # (b, t, l, n_token_type)
226
- cb_mask = feats["template_mask_cb"] # (b, t, l)
227
- frame_mask = feats["template_mask_frame"] # (b, t, l)
228
- # (b, t, l, l, 1)
229
- cb_pair_mask = (cb_mask[..., :, None] * cb_mask[..., None, :]).unsqueeze(-1)
230
- # (b, t, l, l, 1)
231
- frame_pair_mask = (frame_mask[..., :, None] * frame_mask[..., None, :]).unsqueeze(-1)
232
- with torch.autocast(device_type="cuda", enabled=False):
233
- # template_cb: (b, t, l, 3).
234
- # (b, t, l, l)
235
- cb_distances = torch.cdist(feats["template_cb"], feats["template_cb"])
236
- boundaries = torch.linspace( # (n_bin - 1,)
237
- self.min_dist,
238
- self.max_dist,
239
- self.num_bins - 1,
240
- ).to(cb_distances.device)
241
- bins = (cb_distances[..., None] > boundaries).sum(dim=-1).long() # (b, t, l, l)
242
- distogram = F.one_hot(bins, num_classes=self.num_bins) # (b, t, l, l, n_bin)
243
-
244
- # (b, t, 1, l, 3, 3)
245
- rotations = feats["template_frame_rot"].unsqueeze(2).transpose(-1, -2)
246
- # (b, t, 1, l, 3, 1)
247
- translations = feats["template_frame_t"].unsqueeze(2).unsqueeze(-1)
248
- # (b, t, l, 1, 3, 1)
249
- ca_coordinates = feats["template_ca"].unsqueeze(3).unsqueeze(-1)
250
- # (b, t, l, l, 3, 1)
251
- vectors = torch.matmul(rotations, ca_coordinates - translations)
252
- norms = torch.norm(vectors, dim=-1, keepdim=True) # (b, t, l, l, 3, 1)
253
- unit_vectors = torch.where( # (b, t, l, l, 3)
254
- norms > 0,
255
- vectors / norms,
256
- torch.zeros_like(vectors),
257
- ).squeeze(-1)
258
- pair_features = torch.cat( # (b, t, l, l, n_bin + 5)
259
- [distogram, cb_pair_mask, unit_vectors, frame_pair_mask],
260
- dim=-1,
261
- )
262
- # (b, t, l, l, n_bin + 5)
263
- pair_features = pair_features * template_pair_mask.unsqueeze(-1)
264
- residue_i = residue_type[:, :, :, None].expand( # (b, t, l, l, n_token_type)
265
- -1,
266
- -1,
267
- -1,
268
- residue_type.size(2),
269
- -1,
270
- )
271
- residue_j = residue_type[:, :, None, :].expand( # (b, t, l, l, n_token_type)
272
- -1,
273
- -1,
274
- residue_type.size(2),
275
- -1,
276
- -1,
277
- )
278
- return cast(
279
- Tensor,
280
- self.a_proj(torch.cat([pair_features, residue_i, residue_j], dim=-1)),
281
- ) # (b, t, l, l, d_t)
282
-
283
- def forward(
284
- self,
285
- z: Tensor,
286
- feats: dict[str, Tensor],
287
- pair_mask: Tensor,
288
- use_kernels: bool = False,
289
- ) -> Tensor:
290
- """Aggregate template pair tensor V into trunk update U."""
291
-
292
- # z: (b, l, l, d_z); pair_mask: (b, l, l).
293
- residue_type = feats["template_restype"] # (b, t, l, n_token_type)
294
- batch_size, template_count = residue_type.shape[:2]
295
- template_present = feats["template_mask"].any(dim=2).float() # (b, t)
296
- present_count = template_present.sum(dim=1).clamp(min=1) # (b,)
297
- features = self._template_features( # (b, t, l, l, d_t)
298
- feats,
299
- self._template_pair_mask(feats, template_count),
300
- )
301
- expanded_mask = pair_mask[:, None].expand(-1, template_count, -1, -1) # (b, t, l, l)
302
- expanded_mask = expanded_mask.reshape( # (b * t, l, l)
303
- batch_size * template_count,
304
- *expanded_mask.shape[2:],
305
- )
306
- template_states = self.z_proj(self.z_norm(z[:, None])) + features # (b, t, l, l, d_t)
307
- template_states = template_states.view( # (b * t, l, l, d_t)
308
- batch_size * template_count,
309
- *template_states.shape[2:],
310
- )
311
- template_states = template_states + self.pairformer( # (b * t, l, l, d_t)
312
- template_states,
313
- expanded_mask,
314
- use_kernels=use_kernels,
315
- )
316
- template_states = self.v_norm(template_states).view( # (b, t, l, l, d_t)
317
- batch_size,
318
- template_count,
319
- *template_states.shape[1:],
320
- )
321
- weights = template_present[:, :, None, None, None] # (b, t, 1, 1, 1)
322
- aggregate = (template_states * weights).sum(dim=1) # (b, l, l, d_t)
323
- # (b, l, l, d_t)
324
- aggregate = aggregate / present_count[:, None, None, None].to(template_states)
325
- return cast(Tensor, self.u_proj(self.relu(aggregate))) # (b, l, l, d_z)
326
-
327
-
328
- class TemplateModule(_TemplateBase):
329
- """Aggregate templates while restricting features to the same chain."""
330
-
331
- def __init__(
332
- self,
333
- token_z: int,
334
- template_dim: int,
335
- template_blocks: int,
336
- dropout: float = 0.25,
337
- pairwise_head_width: int = 32,
338
- pairwise_num_heads: int = 4,
339
- post_layer_norm: bool = False,
340
- activation_checkpointing: bool = False,
341
- min_dist: float = 3.25,
342
- max_dist: float = 50.75,
343
- num_bins: int = 38,
344
- **kwargs: Any,
345
- ) -> None:
346
- del kwargs
347
- super().__init__(
348
- token_z,
349
- template_dim,
350
- template_blocks,
351
- dropout,
352
- pairwise_head_width,
353
- pairwise_num_heads,
354
- post_layer_norm,
355
- activation_checkpointing,
356
- min_dist,
357
- max_dist,
358
- num_bins,
359
- )
360
-
361
- def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
362
- asym_id = feats["asym_id"] # (b, l)
363
- same_chain = (asym_id[:, :, None] == asym_id[:, None, :]).float() # (b, l, l)
364
- return same_chain[:, None].expand(-1, count, -1, -1) # (b, t, l, l)
365
-
366
-
367
- class TemplateV2Module(_TemplateBase):
368
- """Aggregate templates under per-template visibility groups."""
369
-
370
- def __init__(
371
- self,
372
- token_z: int,
373
- template_dim: int,
374
- template_blocks: int,
375
- dropout: float = 0.25,
376
- pairwise_head_width: int = 32,
377
- pairwise_num_heads: int = 4,
378
- post_layer_norm: bool = False,
379
- activation_checkpointing: bool = False,
380
- min_dist: float = 3.25,
381
- max_dist: float = 50.75,
382
- num_bins: int = 38,
383
- **kwargs: Any,
384
- ) -> None:
385
- del kwargs
386
- super().__init__(
387
- token_z,
388
- template_dim,
389
- template_blocks,
390
- dropout,
391
- pairwise_head_width,
392
- pairwise_num_heads,
393
- post_layer_norm,
394
- activation_checkpointing,
395
- min_dist,
396
- max_dist,
397
- num_bins,
398
- )
399
-
400
- def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
401
- del count
402
- visibility = feats["visibility_ids"] # (b, t, l)
403
- return (visibility[..., :, None] == visibility[..., None, :]).float() # (b, t, l, l)
404
-
405
-
406
- class MSAModule(nn.Module):
407
- """Embed and update an MSA before returning its accumulated pair update."""
408
-
409
- def __init__(
410
- self,
411
- msa_s: int,
412
- token_z: int,
413
- token_s: int,
414
- msa_blocks: int,
415
- msa_dropout: float,
416
- z_dropout: float,
417
- pairwise_head_width: int = 32,
418
- pairwise_num_heads: int = 4,
419
- activation_checkpointing: bool = False,
420
- use_paired_feature: bool = True,
421
- subsample_msa: bool = False,
422
- num_subsampled_msa: int = 1024,
423
- **kwargs: Any,
424
- ) -> None:
425
- del kwargs
426
- super().__init__()
427
- self.msa_blocks = msa_blocks
428
- self.msa_dropout = msa_dropout
429
- self.z_dropout = z_dropout
430
- self.use_paired_feature = use_paired_feature
431
- self.activation_checkpointing = activation_checkpointing
432
- self.subsample_msa = subsample_msa
433
- self.num_subsampled_msa = num_subsampled_msa
434
- self.s_proj = nn.Linear(token_s, msa_s, bias=False)
435
- input_width = const.num_tokens + 2 + int(use_paired_feature)
436
- self.msa_proj = nn.Linear(input_width, msa_s, bias=False)
437
- self.layers = nn.ModuleList(
438
- [
439
- MSALayer(
440
- msa_s,
441
- token_z,
442
- msa_dropout,
443
- z_dropout,
444
- pairwise_head_width,
445
- pairwise_num_heads,
446
- )
447
- for _ in range(msa_blocks)
448
- ]
449
- )
450
-
451
- @staticmethod
452
- def _chunk_configuration(
453
- pair_states: Tensor,
454
- training: bool,
455
- ) -> tuple[bool, int | None, int | None, int | None, int | None]:
456
- # pair_states: (b, l, l, d_z).
457
- if training:
458
- return False, None, None, None, None
459
- if pair_states.shape[1] > const.chunk_size_threshold:
460
- return True, 64, 32, 4, 128
461
- return False, None, None, None, 512
462
-
463
- def forward(
464
- self,
465
- z: Tensor,
466
- emb: Tensor,
467
- feats: dict[str, Tensor],
468
- use_kernels: bool = False,
469
- ) -> Tensor:
470
- """Return updated pair tensor Z after every MSA block."""
471
-
472
- chunking = self._chunk_configuration(z, self.training)
473
- # z: (b, l, l, d_z); emb: (b, l, d_s).
474
- # s is MSA depth; n_token_type is the residue vocabulary size.
475
- msa = feats["msa"] # (b, s, l) or (b, s, l, n_token_type)
476
- if msa.dtype in (torch.long, torch.int32, torch.int64):
477
- msa = F.one_hot(msa, num_classes=const.num_tokens).float() # (b, s, l, n_token_type)
478
- msa_mask = feats["msa_mask"] # (b, s, l)
479
- components = [ # (b, s, l, n_token_type), then two (b, s, l, 1) tensors
480
- msa,
481
- feats["has_deletion"].unsqueeze(-1), # (b, s, l, 1)
482
- feats["deletion_value"].unsqueeze(-1), # (b, s, l, 1)
483
- ]
484
- if self.use_paired_feature:
485
- components.append(feats["msa_paired"].unsqueeze(-1)) # (b, s, l, 1)
486
- msa_input = torch.cat(components, dim=-1) # (b, s, l, n_token_type + 2 or 3)
487
- if self.subsample_msa:
488
- indices = torch.randperm(msa.shape[1])[: self.num_subsampled_msa] # (s_sub,)
489
- msa_input = msa_input[:, indices] # (b, s_sub, l, n_token_type + 2 or 3)
490
- msa_mask = msa_mask[:, indices] # (b, s_sub, l)
491
-
492
- msa_states = self.msa_proj(msa_input) + self.s_proj(emb).unsqueeze(1) # (b, s, l, d_m)
493
- token_mask = feats["token_pad_mask"].float() # (b, l)
494
- pair_mask = token_mask[:, :, None] * token_mask[:, None, :] # (b, l, l)
495
- pair_states = z # (b, l, l, d_z)
496
- for layer in self.layers:
497
- # Tensor arguments: pair_states (b, l, l, d_z), msa_states (b, s, l, d_m),
498
- # pair_mask (b, l, l), msa_mask (b, s, l).
499
- arguments = (
500
- pair_states,
501
- msa_states,
502
- pair_mask,
503
- msa_mask,
504
- *chunking,
505
- use_kernels,
506
- )
507
- if self.activation_checkpointing and self.training:
508
- pair_states, msa_states = checkpoint( # (b, l, l, d_z), (b, s, l, d_m)
509
- layer,
510
- *arguments,
511
- )
512
- else:
513
- pair_states, msa_states = layer(*arguments) # (b, l, l, d_z), (b, s, l, d_m)
514
- return pair_states # (b, l, l, d_z)
515
-
516
-
517
- class MSALayer(nn.Module):
518
- """Exchange information between MSA tensor M and pair tensor Z."""
519
-
520
- def __init__(
521
- self,
522
- msa_s: int,
523
- token_z: int,
524
- msa_dropout: float,
525
- z_dropout: float,
526
- pairwise_head_width: int = 32,
527
- pairwise_num_heads: int = 4,
528
- ) -> None:
529
- super().__init__()
530
- self.msa_dropout = msa_dropout
531
- self.msa_transition = Transition(msa_s, msa_s * 4)
532
- self.pair_weighted_averaging = PairWeightedAveraging(
533
- c_m=msa_s,
534
- c_z=token_z,
535
- c_h=32,
536
- num_heads=8,
537
- )
538
- self.pairformer_layer = PairformerNoSeqLayer(
539
- token_z=token_z,
540
- dropout=z_dropout,
541
- pairwise_head_width=pairwise_head_width,
542
- pairwise_num_heads=pairwise_num_heads,
543
- )
544
- self.outer_product_mean = OuterProductMean(msa_s, 32, token_z)
545
-
546
- def forward(
547
- self,
548
- z: Tensor,
549
- m: Tensor,
550
- token_mask: Tensor,
551
- msa_mask: Tensor,
552
- chunk_heads_pwa: bool = False,
553
- chunk_size_transition_z: int | None = None,
554
- chunk_size_transition_msa: int | None = None,
555
- chunk_size_outer_product: int | None = None,
556
- chunk_size_tri_attn: int | None = None,
557
- use_kernels: bool = False,
558
- ) -> tuple[Tensor, Tensor]:
559
- """Return updated Z and M tensors."""
560
-
561
- del chunk_size_transition_z
562
- # z: (b, l, l, d_z); m: (b, s, l, d_m).
563
- # token_mask: (b, l, l); msa_mask: (b, s, l).
564
- dropout = get_dropout_mask(self.msa_dropout, m, self.training) # (b, s, 1, 1)
565
- msa_states = m + dropout * self.pair_weighted_averaging( # (b, s, l, d_m)
566
- m,
567
- z,
568
- token_mask,
569
- chunk_heads_pwa,
570
- )
571
- msa_states = msa_states + self.msa_transition( # (b, s, l, d_m)
572
- msa_states,
573
- chunk_size_transition_msa,
574
- )
575
- pair_states = z + self.outer_product_mean( # (b, l, l, d_z)
576
- msa_states,
577
- msa_mask,
578
- chunk_size_outer_product,
579
- )
580
- pair_states = self.pairformer_layer( # (b, l, l, d_z)
581
- pair_states,
582
- token_mask,
583
- chunk_size_tri_attn,
584
- use_kernels=use_kernels,
585
- )
586
- return pair_states, msa_states # (b, l, l, d_z), (b, s, l, d_m)
587
-
588
-
589
- class BFactorModule(nn.Module):
590
- """Predict a per-token B-factor histogram."""
591
-
592
- def __init__(self, token_s: int, num_bins: int) -> None:
593
- super().__init__()
594
- self.bfactor = nn.Linear(token_s, num_bins)
595
- self.num_bins = num_bins
596
-
597
- def forward(self, s: Tensor) -> Tensor:
598
- # s: (..., d_s).
599
- return cast(Tensor, self.bfactor(s)) # (..., n_bin)
600
-
601
-
602
- class DistogramModule(nn.Module):
603
- """Predict symmetric residue-pair distance histograms."""
604
-
605
- def __init__(self, token_z: int, num_bins: int, num_distograms: int = 1) -> None:
606
- super().__init__()
607
- self.distogram = nn.Linear(token_z, num_distograms * num_bins)
608
- self.num_distograms = num_distograms
609
- self.num_bins = num_bins
610
-
611
- def forward(self, z: Tensor) -> Tensor:
612
- # z: (b, l, l, d_z).
613
- symmetric = z + z.transpose(1, 2) # (b, l, l, d_z)
614
- logits = self.distogram(symmetric) # (b, l, l, n_distogram * n_bin)
615
- return cast(
616
- Tensor,
617
- logits.reshape(
618
- symmetric.shape[0],
619
- symmetric.shape[1],
620
- symmetric.shape[2],
621
- self.num_distograms,
622
- self.num_bins,
623
- ),
624
- ) # (b, l, l, n_distogram, n_bin)
 
1
+ """Input, template, MSA, and prediction-head modules for the Boltz2 trunk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from typing import Any, cast
8
+ from torch import Tensor, nn
9
+ from torch.nn import functional as F
10
+ from torch.utils.checkpoint import checkpoint
11
+
12
+ from . import vb_const as const
13
+ from .vb_layers_dropout import get_dropout_mask
14
+ from .vb_layers_outer_product_mean import OuterProductMean
15
+ from .vb_layers_pair_averaging import PairWeightedAveraging
16
+ from .vb_layers_pairformer import PairformerNoSeqLayer, PairformerNoSeqModule
17
+ from .vb_layers_transition import Transition
18
+ from .vb_modules_encodersv2 import (
19
+ AtomAttentionEncoder,
20
+ AtomEncoder,
21
+ FourierEmbedding,
22
+ )
23
+
24
+
25
+ class ContactConditioning(nn.Module):
26
+ """Encode selected, unselected, and distance-threshold contact constraints."""
27
+
28
+ def __init__(self, token_z: int, cutoff_min: float, cutoff_max: float) -> None:
29
+ super().__init__()
30
+ self.fourier_embedding = FourierEmbedding(token_z)
31
+ input_width = token_z + len(const.contact_conditioning_info) - 1
32
+ self.encoder = nn.Linear(input_width, token_z)
33
+ self.encoding_unspecified = nn.Parameter(torch.zeros(token_z)) # (d_z,)
34
+ self.encoding_unselected = nn.Parameter(torch.zeros(token_z)) # (d_z,)
35
+ self.cutoff_min = cutoff_min
36
+ self.cutoff_max = cutoff_max
37
+
38
+ def forward(self, feats: dict[str, Tensor]) -> Tensor:
39
+ """Return contact tensor C with shape ``(b, l, l, d_z)``."""
40
+
41
+ if const.contact_conditioning_info["UNSPECIFIED"] != 0:
42
+ raise ValueError("UNSPECIFIED contact conditioning must use channel zero")
43
+ if const.contact_conditioning_info["UNSELECTED"] != 1:
44
+ raise ValueError("UNSELECTED contact conditioning must use channel one")
45
+
46
+ # c_contact is the number of contact-conditioning categories.
47
+ categories = feats["contact_conditioning"] # (b, l, l, c_contact)
48
+ threshold = feats["contact_threshold"] # (b, l, l)
49
+ # (b, l, l)
50
+ normalized = (threshold - self.cutoff_min) / (self.cutoff_max - self.cutoff_min)
51
+ # (b, l, l, d_z)
52
+ fourier = self.fourier_embedding(normalized.flatten()).reshape((*normalized.shape, -1))
53
+ selected_features = torch.cat( # (b, l, l, c_contact - 1 + d_z)
54
+ [categories[..., 2:], normalized.unsqueeze(-1), fourier],
55
+ dim=-1,
56
+ )
57
+ selected = self.encoder(selected_features) # (b, l, l, d_z)
58
+ special = categories[..., :2] # (b, l, l, 2)
59
+ return cast(
60
+ Tensor,
61
+ selected * (1 - special.sum(dim=-1, keepdim=True))
62
+ + self.encoding_unspecified * special[..., 0:1]
63
+ + self.encoding_unselected * special[..., 1:2],
64
+ ) # (b, l, l, d_z)
65
+
66
+
67
+ class InputEmbedder(nn.Module):
68
+ """Combine atom, residue, profile, and optional experimental features."""
69
+
70
+ def __init__(
71
+ self,
72
+ atom_s: int,
73
+ atom_z: int,
74
+ token_s: int,
75
+ token_z: int,
76
+ atoms_per_window_queries: int,
77
+ atoms_per_window_keys: int,
78
+ atom_feature_dim: int,
79
+ atom_encoder_depth: int,
80
+ atom_encoder_heads: int,
81
+ activation_checkpointing: bool = False,
82
+ add_method_conditioning: bool = False,
83
+ add_modified_flag: bool = False,
84
+ add_cyclic_flag: bool = False,
85
+ add_mol_type_feat: bool = False,
86
+ use_no_atom_char: bool = False,
87
+ use_atom_backbone_feat: bool = False,
88
+ use_residue_feats_atoms: bool = False,
89
+ ) -> None:
90
+ super().__init__()
91
+ self.token_s = token_s
92
+ self.add_method_conditioning = add_method_conditioning
93
+ self.add_modified_flag = add_modified_flag
94
+ self.add_cyclic_flag = add_cyclic_flag
95
+ self.add_mol_type_feat = add_mol_type_feat
96
+ self.atom_encoder = AtomEncoder(
97
+ atom_s=atom_s,
98
+ atom_z=atom_z,
99
+ token_s=token_s,
100
+ token_z=token_z,
101
+ atoms_per_window_queries=atoms_per_window_queries,
102
+ atoms_per_window_keys=atoms_per_window_keys,
103
+ atom_feature_dim=atom_feature_dim,
104
+ structure_prediction=False,
105
+ use_no_atom_char=use_no_atom_char,
106
+ use_atom_backbone_feat=use_atom_backbone_feat,
107
+ use_residue_feats_atoms=use_residue_feats_atoms,
108
+ )
109
+ self.atom_enc_proj_z = nn.Sequential(
110
+ nn.LayerNorm(atom_z),
111
+ nn.Linear(atom_z, atom_encoder_depth * atom_encoder_heads, bias=False),
112
+ )
113
+ self.atom_attention_encoder = AtomAttentionEncoder(
114
+ atom_s=atom_s,
115
+ token_s=token_s,
116
+ atoms_per_window_queries=atoms_per_window_queries,
117
+ atoms_per_window_keys=atoms_per_window_keys,
118
+ atom_encoder_depth=atom_encoder_depth,
119
+ atom_encoder_heads=atom_encoder_heads,
120
+ structure_prediction=False,
121
+ activation_checkpointing=activation_checkpointing,
122
+ )
123
+ self.res_type_encoding = nn.Linear(const.num_tokens, token_s, bias=False)
124
+ self.msa_profile_encoding = nn.Linear(const.num_tokens + 1, token_s, bias=False)
125
+
126
+ if add_method_conditioning:
127
+ self.method_conditioning_init = nn.Embedding(const.num_method_types, token_s)
128
+ self.method_conditioning_init.weight.data.fill_(0) # (n_method, d_s)
129
+ if add_modified_flag:
130
+ self.modified_conditioning_init = nn.Embedding(2, token_s)
131
+ self.modified_conditioning_init.weight.data.fill_(0) # (2, d_s)
132
+ if add_cyclic_flag:
133
+ self.cyclic_conditioning_init = nn.Linear(1, token_s, bias=False)
134
+ self.cyclic_conditioning_init.weight.data.fill_(0) # (d_s, 1)
135
+ if add_mol_type_feat:
136
+ self.mol_type_conditioning_init = nn.Embedding(
137
+ len(const.chain_type_ids),
138
+ token_s,
139
+ )
140
+ self.mol_type_conditioning_init.weight.data.fill_(0) # (n_mol_type, d_s)
141
+
142
+ def forward(self, feats: dict[str, Tensor], affinity: bool = False) -> Tensor:
143
+ """Return embedded sequence tensor S with shape ``(b, l, d_s)``."""
144
+
145
+ # n_atom is the padded atom count; k is the number of atom windows.
146
+ residue_type = feats["res_type"].float() # (b, l, n_token_type)
147
+ suffix = "_affinity" if affinity else ""
148
+ profile = feats[f"profile{suffix}"] # (b, l, n_token_type)
149
+ deletion_mean = feats[f"deletion_mean{suffix}"].unsqueeze(-1) # (b, l, 1)
150
+ # (b, n_atom, d_a), (b, n_atom, d_a), (b, k, w_q, w_k, d_az), callable
151
+ atom_queries, atom_conditioning, atom_pairs, to_keys = self.atom_encoder(feats)
152
+ atom_bias = self.atom_enc_proj_z(atom_pairs) # (b, k, w_q, w_k, n_layer * h)
153
+ atom_output, _, _, _ = self.atom_attention_encoder(
154
+ feats=feats,
155
+ q=atom_queries,
156
+ c=atom_conditioning,
157
+ atom_enc_bias=atom_bias,
158
+ to_keys=to_keys,
159
+ ) # (b, l, d_s), (b, n_atom, d_a), (b, n_atom, d_a), callable
160
+ output = ( # (b, l, d_s)
161
+ atom_output
162
+ + self.res_type_encoding(residue_type)
163
+ + self.msa_profile_encoding(torch.cat([profile, deletion_mean], dim=-1))
164
+ )
165
+ if self.add_method_conditioning:
166
+ # method_feature: (b, l); output: (b, l, d_s).
167
+ output = output + self.method_conditioning_init(feats["method_feature"])
168
+ if self.add_modified_flag:
169
+ # modified: (b, l); output: (b, l, d_s).
170
+ output = output + self.modified_conditioning_init(feats["modified"])
171
+ if self.add_cyclic_flag:
172
+ cyclic = feats["cyclic_period"].clamp(max=1.0).unsqueeze(-1) # (b, l, 1)
173
+ output = output + self.cyclic_conditioning_init(cyclic) # (b, l, d_s)
174
+ if self.add_mol_type_feat:
175
+ # mol_type: (b, l); output: (b, l, d_s).
176
+ output = output + self.mol_type_conditioning_init(feats["mol_type"])
177
+ return cast(Tensor, output) # (b, l, d_s)
178
+
179
+
180
+ class _TemplateBase(nn.Module):
181
+ def __init__(
182
+ self,
183
+ token_z: int,
184
+ template_dim: int,
185
+ template_blocks: int,
186
+ dropout: float,
187
+ pairwise_head_width: int,
188
+ pairwise_num_heads: int,
189
+ post_layer_norm: bool,
190
+ activation_checkpointing: bool,
191
+ min_dist: float,
192
+ max_dist: float,
193
+ num_bins: int,
194
+ ) -> None:
195
+ super().__init__()
196
+ self.min_dist = min_dist
197
+ self.max_dist = max_dist
198
+ self.num_bins = num_bins
199
+ self.relu = nn.ReLU()
200
+ self.z_norm = nn.LayerNorm(token_z)
201
+ self.v_norm = nn.LayerNorm(template_dim)
202
+ self.z_proj = nn.Linear(token_z, template_dim, bias=False)
203
+ feature_width = const.num_tokens * 2 + num_bins + 5
204
+ self.a_proj = nn.Linear(feature_width, template_dim, bias=False)
205
+ self.u_proj = nn.Linear(template_dim, token_z, bias=False)
206
+ self.pairformer = PairformerNoSeqModule(
207
+ template_dim,
208
+ num_blocks=template_blocks,
209
+ dropout=dropout,
210
+ pairwise_head_width=pairwise_head_width,
211
+ pairwise_num_heads=pairwise_num_heads,
212
+ post_layer_norm=post_layer_norm,
213
+ activation_checkpointing=activation_checkpointing,
214
+ )
215
+
216
+ def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
217
+ raise NotImplementedError
218
+
219
+ def _template_features(
220
+ self,
221
+ feats: dict[str, Tensor],
222
+ template_pair_mask: Tensor,
223
+ ) -> Tensor:
224
+ # residue_type: (b, t, l, n_token_type); template_pair_mask: (b, t, l, l).
225
+ residue_type = feats["template_restype"] # (b, t, l, n_token_type)
226
+ cb_mask = feats["template_mask_cb"] # (b, t, l)
227
+ frame_mask = feats["template_mask_frame"] # (b, t, l)
228
+ # (b, t, l, l, 1)
229
+ cb_pair_mask = (cb_mask[..., :, None] * cb_mask[..., None, :]).unsqueeze(-1)
230
+ # (b, t, l, l, 1)
231
+ frame_pair_mask = (frame_mask[..., :, None] * frame_mask[..., None, :]).unsqueeze(-1)
232
+ with torch.autocast(device_type="cuda", enabled=False):
233
+ # template_cb: (b, t, l, 3).
234
+ # (b, t, l, l)
235
+ cb_distances = torch.cdist(feats["template_cb"], feats["template_cb"])
236
+ boundaries = torch.linspace( # (n_bin - 1,)
237
+ self.min_dist,
238
+ self.max_dist,
239
+ self.num_bins - 1,
240
+ ).to(cb_distances.device)
241
+ bins = (cb_distances[..., None] > boundaries).sum(dim=-1).long() # (b, t, l, l)
242
+ distogram = F.one_hot(bins, num_classes=self.num_bins) # (b, t, l, l, n_bin)
243
+
244
+ # (b, t, 1, l, 3, 3)
245
+ rotations = feats["template_frame_rot"].unsqueeze(2).transpose(-1, -2)
246
+ # (b, t, 1, l, 3, 1)
247
+ translations = feats["template_frame_t"].unsqueeze(2).unsqueeze(-1)
248
+ # (b, t, l, 1, 3, 1)
249
+ ca_coordinates = feats["template_ca"].unsqueeze(3).unsqueeze(-1)
250
+ # (b, t, l, l, 3, 1)
251
+ vectors = torch.matmul(rotations, ca_coordinates - translations)
252
+ norms = torch.norm(vectors, dim=-1, keepdim=True) # (b, t, l, l, 3, 1)
253
+ unit_vectors = torch.where( # (b, t, l, l, 3)
254
+ norms > 0,
255
+ vectors / norms,
256
+ torch.zeros_like(vectors),
257
+ ).squeeze(-1)
258
+ pair_features = torch.cat( # (b, t, l, l, n_bin + 5)
259
+ [distogram, cb_pair_mask, unit_vectors, frame_pair_mask],
260
+ dim=-1,
261
+ )
262
+ # (b, t, l, l, n_bin + 5)
263
+ pair_features = pair_features * template_pair_mask.unsqueeze(-1)
264
+ residue_i = residue_type[:, :, :, None].expand( # (b, t, l, l, n_token_type)
265
+ -1,
266
+ -1,
267
+ -1,
268
+ residue_type.size(2),
269
+ -1,
270
+ )
271
+ residue_j = residue_type[:, :, None, :].expand( # (b, t, l, l, n_token_type)
272
+ -1,
273
+ -1,
274
+ residue_type.size(2),
275
+ -1,
276
+ -1,
277
+ )
278
+ return cast(
279
+ Tensor,
280
+ self.a_proj(torch.cat([pair_features, residue_i, residue_j], dim=-1)),
281
+ ) # (b, t, l, l, d_t)
282
+
283
+ def forward(
284
+ self,
285
+ z: Tensor,
286
+ feats: dict[str, Tensor],
287
+ pair_mask: Tensor,
288
+ use_kernels: bool = False,
289
+ ) -> Tensor:
290
+ """Aggregate template pair tensor V into trunk update U."""
291
+
292
+ # z: (b, l, l, d_z); pair_mask: (b, l, l).
293
+ residue_type = feats["template_restype"] # (b, t, l, n_token_type)
294
+ batch_size, template_count = residue_type.shape[:2]
295
+ template_present = feats["template_mask"].any(dim=2).float() # (b, t)
296
+ present_count = template_present.sum(dim=1).clamp(min=1) # (b,)
297
+ features = self._template_features( # (b, t, l, l, d_t)
298
+ feats,
299
+ self._template_pair_mask(feats, template_count),
300
+ )
301
+ expanded_mask = pair_mask[:, None].expand(-1, template_count, -1, -1) # (b, t, l, l)
302
+ expanded_mask = expanded_mask.reshape( # (b * t, l, l)
303
+ batch_size * template_count,
304
+ *expanded_mask.shape[2:],
305
+ )
306
+ template_states = self.z_proj(self.z_norm(z[:, None])) + features # (b, t, l, l, d_t)
307
+ template_states = template_states.view( # (b * t, l, l, d_t)
308
+ batch_size * template_count,
309
+ *template_states.shape[2:],
310
+ )
311
+ template_states = template_states + self.pairformer( # (b * t, l, l, d_t)
312
+ template_states,
313
+ expanded_mask,
314
+ use_kernels=use_kernels,
315
+ )
316
+ template_states = self.v_norm(template_states).view( # (b, t, l, l, d_t)
317
+ batch_size,
318
+ template_count,
319
+ *template_states.shape[1:],
320
+ )
321
+ weights = template_present[:, :, None, None, None] # (b, t, 1, 1, 1)
322
+ aggregate = (template_states * weights).sum(dim=1) # (b, l, l, d_t)
323
+ # (b, l, l, d_t)
324
+ aggregate = aggregate / present_count[:, None, None, None].to(template_states)
325
+ return cast(Tensor, self.u_proj(self.relu(aggregate))) # (b, l, l, d_z)
326
+
327
+
328
+ class TemplateModule(_TemplateBase):
329
+ """Aggregate templates while restricting features to the same chain."""
330
+
331
+ def __init__(
332
+ self,
333
+ token_z: int,
334
+ template_dim: int,
335
+ template_blocks: int,
336
+ dropout: float = 0.25,
337
+ pairwise_head_width: int = 32,
338
+ pairwise_num_heads: int = 4,
339
+ post_layer_norm: bool = False,
340
+ activation_checkpointing: bool = False,
341
+ min_dist: float = 3.25,
342
+ max_dist: float = 50.75,
343
+ num_bins: int = 38,
344
+ **kwargs: Any,
345
+ ) -> None:
346
+ del kwargs
347
+ super().__init__(
348
+ token_z,
349
+ template_dim,
350
+ template_blocks,
351
+ dropout,
352
+ pairwise_head_width,
353
+ pairwise_num_heads,
354
+ post_layer_norm,
355
+ activation_checkpointing,
356
+ min_dist,
357
+ max_dist,
358
+ num_bins,
359
+ )
360
+
361
+ def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
362
+ asym_id = feats["asym_id"] # (b, l)
363
+ same_chain = (asym_id[:, :, None] == asym_id[:, None, :]).float() # (b, l, l)
364
+ return same_chain[:, None].expand(-1, count, -1, -1) # (b, t, l, l)
365
+
366
+
367
+ class TemplateV2Module(_TemplateBase):
368
+ """Aggregate templates under per-template visibility groups."""
369
+
370
+ def __init__(
371
+ self,
372
+ token_z: int,
373
+ template_dim: int,
374
+ template_blocks: int,
375
+ dropout: float = 0.25,
376
+ pairwise_head_width: int = 32,
377
+ pairwise_num_heads: int = 4,
378
+ post_layer_norm: bool = False,
379
+ activation_checkpointing: bool = False,
380
+ min_dist: float = 3.25,
381
+ max_dist: float = 50.75,
382
+ num_bins: int = 38,
383
+ **kwargs: Any,
384
+ ) -> None:
385
+ del kwargs
386
+ super().__init__(
387
+ token_z,
388
+ template_dim,
389
+ template_blocks,
390
+ dropout,
391
+ pairwise_head_width,
392
+ pairwise_num_heads,
393
+ post_layer_norm,
394
+ activation_checkpointing,
395
+ min_dist,
396
+ max_dist,
397
+ num_bins,
398
+ )
399
+
400
+ def _template_pair_mask(self, feats: dict[str, Tensor], count: int) -> Tensor:
401
+ del count
402
+ visibility = feats["visibility_ids"] # (b, t, l)
403
+ return (visibility[..., :, None] == visibility[..., None, :]).float() # (b, t, l, l)
404
+
405
+
406
+ class MSAModule(nn.Module):
407
+ """Embed and update an MSA before returning its accumulated pair update."""
408
+
409
+ def __init__(
410
+ self,
411
+ msa_s: int,
412
+ token_z: int,
413
+ token_s: int,
414
+ msa_blocks: int,
415
+ msa_dropout: float,
416
+ z_dropout: float,
417
+ pairwise_head_width: int = 32,
418
+ pairwise_num_heads: int = 4,
419
+ activation_checkpointing: bool = False,
420
+ use_paired_feature: bool = True,
421
+ subsample_msa: bool = False,
422
+ num_subsampled_msa: int = 1024,
423
+ **kwargs: Any,
424
+ ) -> None:
425
+ del kwargs
426
+ super().__init__()
427
+ self.msa_blocks = msa_blocks
428
+ self.msa_dropout = msa_dropout
429
+ self.z_dropout = z_dropout
430
+ self.use_paired_feature = use_paired_feature
431
+ self.activation_checkpointing = activation_checkpointing
432
+ self.subsample_msa = subsample_msa
433
+ self.num_subsampled_msa = num_subsampled_msa
434
+ self.s_proj = nn.Linear(token_s, msa_s, bias=False)
435
+ input_width = const.num_tokens + 2 + int(use_paired_feature)
436
+ self.msa_proj = nn.Linear(input_width, msa_s, bias=False)
437
+ self.layers = nn.ModuleList(
438
+ [
439
+ MSALayer(
440
+ msa_s,
441
+ token_z,
442
+ msa_dropout,
443
+ z_dropout,
444
+ pairwise_head_width,
445
+ pairwise_num_heads,
446
+ )
447
+ for _ in range(msa_blocks)
448
+ ]
449
+ )
450
+
451
+ @staticmethod
452
+ def _chunk_configuration(
453
+ pair_states: Tensor,
454
+ training: bool,
455
+ ) -> tuple[bool, int | None, int | None, int | None, int | None]:
456
+ # pair_states: (b, l, l, d_z).
457
+ if training:
458
+ return False, None, None, None, None
459
+ if pair_states.shape[1] > const.chunk_size_threshold:
460
+ return True, 64, 32, 4, 128
461
+ return False, None, None, None, 512
462
+
463
+ def forward(
464
+ self,
465
+ z: Tensor,
466
+ emb: Tensor,
467
+ feats: dict[str, Tensor],
468
+ use_kernels: bool = False,
469
+ ) -> Tensor:
470
+ """Return updated pair tensor Z after every MSA block."""
471
+
472
+ chunking = self._chunk_configuration(z, self.training)
473
+ # z: (b, l, l, d_z); emb: (b, l, d_s).
474
+ # s is MSA depth; n_token_type is the residue vocabulary size.
475
+ msa = feats["msa"] # (b, s, l) or (b, s, l, n_token_type)
476
+ if msa.dtype in (torch.long, torch.int32, torch.int64):
477
+ msa = F.one_hot(msa, num_classes=const.num_tokens).float() # (b, s, l, n_token_type)
478
+ msa_mask = feats["msa_mask"] # (b, s, l)
479
+ components = [ # (b, s, l, n_token_type), then two (b, s, l, 1) tensors
480
+ msa,
481
+ feats["has_deletion"].unsqueeze(-1), # (b, s, l, 1)
482
+ feats["deletion_value"].unsqueeze(-1), # (b, s, l, 1)
483
+ ]
484
+ if self.use_paired_feature:
485
+ components.append(feats["msa_paired"].unsqueeze(-1)) # (b, s, l, 1)
486
+ msa_input = torch.cat(components, dim=-1) # (b, s, l, n_token_type + 2 or 3)
487
+ if self.subsample_msa:
488
+ indices = torch.randperm(msa.shape[1])[: self.num_subsampled_msa] # (s_sub,)
489
+ msa_input = msa_input[:, indices] # (b, s_sub, l, n_token_type + 2 or 3)
490
+ msa_mask = msa_mask[:, indices] # (b, s_sub, l)
491
+
492
+ msa_states = self.msa_proj(msa_input) + self.s_proj(emb).unsqueeze(1) # (b, s, l, d_m)
493
+ token_mask = feats["token_pad_mask"].float() # (b, l)
494
+ pair_mask = token_mask[:, :, None] * token_mask[:, None, :] # (b, l, l)
495
+ pair_states = z # (b, l, l, d_z)
496
+ for layer in self.layers:
497
+ # Tensor arguments: pair_states (b, l, l, d_z), msa_states (b, s, l, d_m),
498
+ # pair_mask (b, l, l), msa_mask (b, s, l).
499
+ arguments = (
500
+ pair_states,
501
+ msa_states,
502
+ pair_mask,
503
+ msa_mask,
504
+ *chunking,
505
+ use_kernels,
506
+ )
507
+ if self.activation_checkpointing and self.training:
508
+ pair_states, msa_states = checkpoint( # (b, l, l, d_z), (b, s, l, d_m)
509
+ layer,
510
+ *arguments,
511
+ )
512
+ else:
513
+ pair_states, msa_states = layer(*arguments) # (b, l, l, d_z), (b, s, l, d_m)
514
+ return pair_states # (b, l, l, d_z)
515
+
516
+
517
+ class MSALayer(nn.Module):
518
+ """Exchange information between MSA tensor M and pair tensor Z."""
519
+
520
+ def __init__(
521
+ self,
522
+ msa_s: int,
523
+ token_z: int,
524
+ msa_dropout: float,
525
+ z_dropout: float,
526
+ pairwise_head_width: int = 32,
527
+ pairwise_num_heads: int = 4,
528
+ ) -> None:
529
+ super().__init__()
530
+ self.msa_dropout = msa_dropout
531
+ self.msa_transition = Transition(msa_s, msa_s * 4)
532
+ self.pair_weighted_averaging = PairWeightedAveraging(
533
+ c_m=msa_s,
534
+ c_z=token_z,
535
+ c_h=32,
536
+ num_heads=8,
537
+ )
538
+ self.pairformer_layer = PairformerNoSeqLayer(
539
+ token_z=token_z,
540
+ dropout=z_dropout,
541
+ pairwise_head_width=pairwise_head_width,
542
+ pairwise_num_heads=pairwise_num_heads,
543
+ )
544
+ self.outer_product_mean = OuterProductMean(msa_s, 32, token_z)
545
+
546
+ def forward(
547
+ self,
548
+ z: Tensor,
549
+ m: Tensor,
550
+ token_mask: Tensor,
551
+ msa_mask: Tensor,
552
+ chunk_heads_pwa: bool = False,
553
+ chunk_size_transition_z: int | None = None,
554
+ chunk_size_transition_msa: int | None = None,
555
+ chunk_size_outer_product: int | None = None,
556
+ chunk_size_tri_attn: int | None = None,
557
+ use_kernels: bool = False,
558
+ ) -> tuple[Tensor, Tensor]:
559
+ """Return updated Z and M tensors."""
560
+
561
+ del chunk_size_transition_z
562
+ # z: (b, l, l, d_z); m: (b, s, l, d_m).
563
+ # token_mask: (b, l, l); msa_mask: (b, s, l).
564
+ dropout = get_dropout_mask(self.msa_dropout, m, self.training) # (b, s, 1, 1)
565
+ msa_states = m + dropout * self.pair_weighted_averaging( # (b, s, l, d_m)
566
+ m,
567
+ z,
568
+ token_mask,
569
+ chunk_heads_pwa,
570
+ )
571
+ msa_states = msa_states + self.msa_transition( # (b, s, l, d_m)
572
+ msa_states,
573
+ chunk_size_transition_msa,
574
+ )
575
+ pair_states = z + self.outer_product_mean( # (b, l, l, d_z)
576
+ msa_states,
577
+ msa_mask,
578
+ chunk_size_outer_product,
579
+ )
580
+ pair_states = self.pairformer_layer( # (b, l, l, d_z)
581
+ pair_states,
582
+ token_mask,
583
+ chunk_size_tri_attn,
584
+ use_kernels=use_kernels,
585
+ )
586
+ return pair_states, msa_states # (b, l, l, d_z), (b, s, l, d_m)
587
+
588
+
589
+ class BFactorModule(nn.Module):
590
+ """Predict a per-token B-factor histogram."""
591
+
592
+ def __init__(self, token_s: int, num_bins: int) -> None:
593
+ super().__init__()
594
+ self.bfactor = nn.Linear(token_s, num_bins)
595
+ self.num_bins = num_bins
596
+
597
+ def forward(self, s: Tensor) -> Tensor:
598
+ # s: (..., d_s).
599
+ return cast(Tensor, self.bfactor(s)) # (..., n_bin)
600
+
601
+
602
+ class DistogramModule(nn.Module):
603
+ """Predict symmetric residue-pair distance histograms."""
604
+
605
+ def __init__(self, token_z: int, num_bins: int, num_distograms: int = 1) -> None:
606
+ super().__init__()
607
+ self.distogram = nn.Linear(token_z, num_distograms * num_bins)
608
+ self.num_distograms = num_distograms
609
+ self.num_bins = num_bins
610
+
611
+ def forward(self, z: Tensor) -> Tensor:
612
+ # z: (b, l, l, d_z).
613
+ symmetric = z + z.transpose(1, 2) # (b, l, l, d_z)
614
+ logits = self.distogram(symmetric) # (b, l, l, n_distogram * n_bin)
615
+ return cast(
616
+ Tensor,
617
+ logits.reshape(
618
+ symmetric.shape[0],
619
+ symmetric.shape[1],
620
+ symmetric.shape[2],
621
+ self.num_distograms,
622
+ self.num_bins,
623
+ ),
624
+ ) # (b, l, l, n_distogram, n_bin)
fastplms/models/boltz/vb_modules_utils.py CHANGED
@@ -1,283 +1,283 @@
1
- """Small geometry, augmentation, activation, and EMA utilities for Boltz2.
2
-
3
- The quaternion behavior follows PyTorch3D's public rotation convention. License
4
- and attribution records are retained in ``THIRD_PARTY_NOTICES.md``.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import torch
10
-
11
- from collections.abc import Iterable, Mapping, Sequence
12
- from functools import partial
13
- from typing import Any
14
- from torch import Tensor, nn
15
- from torch.nn import functional as F
16
-
17
-
18
- LinearNoBias = partial(nn.Linear, bias=False)
19
-
20
-
21
- def exists(value: object) -> bool:
22
- """Return whether a value is not ``None``."""
23
-
24
- return value is not None
25
-
26
-
27
- def default(value: Any, fallback: Any) -> Any:
28
- """Return ``value`` unless it is ``None``."""
29
-
30
- return fallback if value is None else value
31
-
32
-
33
- def log(tensor: Tensor, eps: float = 1e-20) -> Tensor:
34
- """Compute a finite logarithm by applying a scalar lower bound."""
35
-
36
- # tensor: (...).
37
- return torch.log(tensor.clamp(min=eps)) # (...)
38
-
39
-
40
- class SwiGLU(nn.Module):
41
- """Split X in half and apply a SiLU-gated linear unit."""
42
-
43
- def forward(self, x: Tensor) -> Tensor:
44
- # x: (..., 2 * d).
45
- values, gates = x.chunk(2, dim=-1) # each: (..., d)
46
- return F.silu(gates) * values # (..., d)
47
-
48
-
49
- def _masked_center(coordinates: Tensor, mask: Tensor) -> Tensor:
50
- # coordinates: (b, n_atom, 3); mask: (b, n_atom).
51
- weights = mask[:, :, None] # (b, n_atom, 1)
52
- # (b, 1, 3)
53
- return (coordinates * weights).sum(dim=1, keepdim=True) / weights.sum(
54
- dim=1,
55
- keepdim=True,
56
- )
57
-
58
-
59
- def center(atom_coords: Tensor, atom_mask: Tensor) -> Tensor:
60
- """Center coordinate tensor X with shape ``(b, n_atoms, 3)``."""
61
-
62
- # atom_coords: (b, n_atom, 3); atom_mask: (b, n_atom).
63
- return atom_coords - _masked_center(atom_coords, atom_mask) # (b, n_atom, 3)
64
-
65
-
66
- def _copysign(magnitudes: Tensor, signs: Tensor) -> Tensor:
67
- """Apply the elementwise sign of S to magnitude tensor M."""
68
-
69
- # magnitudes, signs: broadcast-compatible (...).
70
- return torch.where((magnitudes < 0) != (signs < 0), -magnitudes, magnitudes) # (...)
71
-
72
-
73
- def quaternion_to_matrix(quaternions: Tensor) -> Tensor:
74
- """Convert real-first quaternion tensor Q from ``(..., 4)`` to ``(..., 3, 3)``."""
75
-
76
- # quaternions: (..., 4).
77
- real, i_axis, j_axis, k_axis = torch.unbind(quaternions, dim=-1) # each: (...)
78
- scale = 2.0 / (quaternions * quaternions).sum(dim=-1) # (...)
79
- entries = ( # nine tensors, each (...)
80
- 1 - scale * (j_axis * j_axis + k_axis * k_axis),
81
- scale * (i_axis * j_axis - k_axis * real),
82
- scale * (i_axis * k_axis + j_axis * real),
83
- scale * (i_axis * j_axis + k_axis * real),
84
- 1 - scale * (i_axis * i_axis + k_axis * k_axis),
85
- scale * (j_axis * k_axis - i_axis * real),
86
- scale * (i_axis * k_axis - j_axis * real),
87
- scale * (j_axis * k_axis + i_axis * real),
88
- 1 - scale * (i_axis * i_axis + j_axis * j_axis),
89
- )
90
- # (..., 3, 3)
91
- return torch.stack(entries, dim=-1).reshape((*quaternions.shape[:-1], 3, 3))
92
-
93
-
94
- def random_quaternions(
95
- n: int,
96
- dtype: torch.dtype | None = None,
97
- device: torch.device | str | None = None,
98
- ) -> Tensor:
99
- """Draw ``n`` uniformly distributed unit quaternions with nonnegative real part."""
100
-
101
- resolved_device = torch.device(device) if isinstance(device, str) else device
102
- samples = torch.randn((n, 4), dtype=dtype, device=resolved_device) # (n, 4)
103
- squared_norm = (samples * samples).sum(dim=1) # (n,)
104
- signed_norm = _copysign(torch.sqrt(squared_norm), samples[:, 0]) # (n,)
105
- return samples / signed_norm[:, None] # (n, 4)
106
-
107
-
108
- def random_rotations(
109
- n: int,
110
- dtype: torch.dtype | None = None,
111
- device: torch.device | str | None = None,
112
- ) -> Tensor:
113
- """Draw rotation tensor R with shape ``(n, 3, 3)``."""
114
-
115
- # (n, 3, 3)
116
- return quaternion_to_matrix(random_quaternions(n, dtype=dtype, device=device))
117
-
118
-
119
- def compute_random_augmentation(
120
- multiplicity: int,
121
- s_trans: float = 1.0,
122
- device: torch.device | str | None = None,
123
- dtype: torch.dtype = torch.float32,
124
- ) -> tuple[Tensor, Tensor]:
125
- """Draw independent rotations R and translations T for a replicated batch."""
126
-
127
- rotations = random_rotations(multiplicity, dtype=dtype, device=device) # (m, 3, 3)
128
- translations = ( # (m, 1, 3)
129
- torch.randn(
130
- (multiplicity, 1, 3),
131
- dtype=dtype,
132
- device=device,
133
- )
134
- * s_trans
135
- )
136
- return rotations, translations # (m, 3, 3), (m, 1, 3)
137
-
138
-
139
- def randomly_rotate(
140
- coords: Tensor,
141
- return_second_coords: bool = False,
142
- second_coords: Tensor | None = None,
143
- ) -> Tensor | tuple[Tensor, Tensor | None]:
144
- """Rotate X and optionally Y using the same sampled rotation tensor R."""
145
-
146
- # coords: (b, n, 3); second_coords: (b, n_second, 3) or None.
147
- rotations = random_rotations(len(coords), coords.dtype, coords.device) # (b, 3, 3)
148
- rotated = torch.einsum("bmd,bds->bms", coords, rotations) # (b, n, 3)
149
- if not return_second_coords:
150
- return rotated # (b, n, 3)
151
- rotated_second = ( # (b, n_second, 3) or None
152
- None if second_coords is None else torch.einsum("bmd,bds->bms", second_coords, rotations)
153
- )
154
- return rotated, rotated_second # (b, n, 3), (b, n_second, 3) or None
155
-
156
-
157
- def center_random_augmentation(
158
- atom_coords: Tensor,
159
- atom_mask: Tensor,
160
- s_trans: float = 1.0,
161
- augmentation: bool = True,
162
- centering: bool = True,
163
- return_second_coords: bool = False,
164
- second_coords: Tensor | None = None,
165
- ) -> Tensor | tuple[Tensor, Tensor | None]:
166
- """Center and rigidly augment coordinate tensors X and optional Y."""
167
-
168
- # atom_coords: (b, n_atom, 3); atom_mask: (b, n_atom).
169
- # second_coords: (b, n_second, 3) or None.
170
- primary = atom_coords # (b, n_atom, 3)
171
- secondary = second_coords # (b, n_second, 3) or None
172
- if centering:
173
- centroid = _masked_center(primary, atom_mask) # (b, 1, 3)
174
- primary = primary - centroid # (b, n_atom, 3)
175
- if secondary is not None:
176
- secondary = secondary - centroid # (b, n_second, 3)
177
-
178
- if augmentation:
179
- primary, secondary = randomly_rotate( # (b, n_atom, 3), (b, n_second, 3) or None
180
- primary,
181
- return_second_coords=True,
182
- second_coords=secondary,
183
- )
184
- translation = torch.randn_like(primary[:, 0:1, :]) * s_trans # (b, 1, 3)
185
- primary = primary + translation # (b, n_atom, 3)
186
- if secondary is not None:
187
- secondary = secondary + translation # (b, n_second, 3)
188
-
189
- # (b, n_atom, 3), optionally paired with (b, n_second, 3) or None.
190
- return (primary, secondary) if return_second_coords else primary
191
-
192
-
193
- class ExponentialMovingAverage:
194
- """Maintain detached exponential moving averages of trainable parameters."""
195
-
196
- def __init__(
197
- self,
198
- parameters: Iterable[nn.Parameter],
199
- decay: float,
200
- use_num_updates: bool = True,
201
- ) -> None:
202
- # parameters: one independently shaped tensor (...) per trainable parameter.
203
- if not 0.0 <= decay <= 1.0:
204
- raise ValueError("decay must lie in [0, 1]")
205
- self.decay = decay
206
- self.num_updates: int | None = 0 if use_num_updates else None
207
- self.shadow_params = [ # each: same shape as one trainable parameter, (...)
208
- parameter.clone().detach() for parameter in parameters if parameter.requires_grad
209
- ]
210
- self.collected_params: list[Tensor] = []
211
-
212
- @staticmethod
213
- def _trainable(parameters: Iterable[nn.Parameter]) -> list[nn.Parameter]:
214
- # Each item is one trainable parameter with an independently varying shape (...).
215
- return [parameter for parameter in parameters if parameter.requires_grad]
216
-
217
- def update(self, parameters: Iterable[nn.Parameter]) -> None:
218
- # parameters: one independently shaped tensor (...) per trainable parameter.
219
- trainable = self._trainable(parameters) # each: one trainable parameter, (...)
220
- if len(trainable) != len(self.shadow_params):
221
- raise ValueError("EMA parameter count changed")
222
- decay = self.decay
223
- if self.num_updates is not None:
224
- self.num_updates += 1
225
- decay = min(decay, (1 + self.num_updates) / (10 + self.num_updates))
226
- update_weight = 1.0 - decay
227
- with torch.no_grad():
228
- for shadow, parameter in zip(self.shadow_params, trainable, strict=True):
229
- shadow.sub_(update_weight * (shadow - parameter)) # (...) unchanged in place
230
-
231
- def compatible(self, parameters: Sequence[Tensor]) -> bool:
232
- """Return whether parameter count and tensor shapes match the EMA state."""
233
-
234
- # Each shadow and corresponding parameter has an independently varying shape (...).
235
- return len(self.shadow_params) == len(parameters) and all(
236
- shadow.shape == parameter.shape
237
- for shadow, parameter in zip(self.shadow_params, parameters, strict=True)
238
- )
239
-
240
- def copy_to(self, parameters: Iterable[nn.Parameter]) -> None:
241
- # parameters: one independently shaped tensor (...) per trainable parameter.
242
- trainable = self._trainable(parameters) # each: one trainable parameter, (...)
243
- if len(trainable) != len(self.shadow_params):
244
- raise ValueError("EMA parameter count changed")
245
- for shadow, parameter in zip(self.shadow_params, trainable, strict=True):
246
- parameter.data.copy_(shadow.data) # (...) unchanged in place
247
-
248
- def store(self, parameters: Iterable[nn.Parameter]) -> None:
249
- # parameters: one independently shaped tensor (...) per parameter.
250
- # Each clone has the same shape (...) as its corresponding parameter.
251
- self.collected_params = [parameter.clone() for parameter in parameters]
252
-
253
- def restore(self, parameters: Iterable[nn.Parameter]) -> None:
254
- # parameters: one independently shaped tensor (...) per parameter.
255
- current = list(parameters) # each: one parameter, (...)
256
- if len(current) != len(self.collected_params):
257
- raise ValueError("stored EMA parameter count changed")
258
- for collected, parameter in zip(self.collected_params, current, strict=True):
259
- parameter.data.copy_(collected.data) # (...) unchanged in place
260
-
261
- def state_dict(self) -> dict[str, Any]:
262
- # shadow_params contains independently shaped tensors (...).
263
- return {
264
- "decay": self.decay,
265
- "num_updates": self.num_updates,
266
- "shadow_params": self.shadow_params,
267
- }
268
-
269
- def load_state_dict(
270
- self,
271
- state_dict: Mapping[str, Any],
272
- device: torch.device | str,
273
- ) -> None:
274
- # state_dict["shadow_params"] contains independently shaped tensors (...).
275
- self.decay = float(state_dict["decay"])
276
- updates = state_dict["num_updates"]
277
- self.num_updates = None if updates is None else int(updates)
278
- # Each tensor retains its shape (...).
279
- self.shadow_params = [tensor.to(device) for tensor in state_dict["shadow_params"]]
280
-
281
- def to(self, device: torch.device | str) -> None:
282
- # Each shadow parameter retains its independently varying shape (...).
283
- self.shadow_params = [tensor.to(device) for tensor in self.shadow_params]
 
1
+ """Small geometry, augmentation, activation, and EMA utilities for Boltz2.
2
+
3
+ The quaternion behavior follows PyTorch3D's public rotation convention. License
4
+ and attribution records are retained in ``THIRD_PARTY_NOTICES.md``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import torch
10
+
11
+ from collections.abc import Iterable, Mapping, Sequence
12
+ from functools import partial
13
+ from typing import Any
14
+ from torch import Tensor, nn
15
+ from torch.nn import functional as F
16
+
17
+
18
+ LinearNoBias = partial(nn.Linear, bias=False)
19
+
20
+
21
+ def exists(value: object) -> bool:
22
+ """Return whether a value is not ``None``."""
23
+
24
+ return value is not None
25
+
26
+
27
+ def default(value: Any, fallback: Any) -> Any:
28
+ """Return ``value`` unless it is ``None``."""
29
+
30
+ return fallback if value is None else value
31
+
32
+
33
+ def log(tensor: Tensor, eps: float = 1e-20) -> Tensor:
34
+ """Compute a finite logarithm by applying a scalar lower bound."""
35
+
36
+ # tensor: (...).
37
+ return torch.log(tensor.clamp(min=eps)) # (...)
38
+
39
+
40
+ class SwiGLU(nn.Module):
41
+ """Split X in half and apply a SiLU-gated linear unit."""
42
+
43
+ def forward(self, x: Tensor) -> Tensor:
44
+ # x: (..., 2 * d).
45
+ values, gates = x.chunk(2, dim=-1) # each: (..., d)
46
+ return F.silu(gates) * values # (..., d)
47
+
48
+
49
+ def _masked_center(coordinates: Tensor, mask: Tensor) -> Tensor:
50
+ # coordinates: (b, n_atom, 3); mask: (b, n_atom).
51
+ weights = mask[:, :, None] # (b, n_atom, 1)
52
+ # (b, 1, 3)
53
+ return (coordinates * weights).sum(dim=1, keepdim=True) / weights.sum(
54
+ dim=1,
55
+ keepdim=True,
56
+ )
57
+
58
+
59
+ def center(atom_coords: Tensor, atom_mask: Tensor) -> Tensor:
60
+ """Center coordinate tensor X with shape ``(b, n_atoms, 3)``."""
61
+
62
+ # atom_coords: (b, n_atom, 3); atom_mask: (b, n_atom).
63
+ return atom_coords - _masked_center(atom_coords, atom_mask) # (b, n_atom, 3)
64
+
65
+
66
+ def _copysign(magnitudes: Tensor, signs: Tensor) -> Tensor:
67
+ """Apply the elementwise sign of S to magnitude tensor M."""
68
+
69
+ # magnitudes, signs: broadcast-compatible (...).
70
+ return torch.where((magnitudes < 0) != (signs < 0), -magnitudes, magnitudes) # (...)
71
+
72
+
73
+ def quaternion_to_matrix(quaternions: Tensor) -> Tensor:
74
+ """Convert real-first quaternion tensor Q from ``(..., 4)`` to ``(..., 3, 3)``."""
75
+
76
+ # quaternions: (..., 4).
77
+ real, i_axis, j_axis, k_axis = torch.unbind(quaternions, dim=-1) # each: (...)
78
+ scale = 2.0 / (quaternions * quaternions).sum(dim=-1) # (...)
79
+ entries = ( # nine tensors, each (...)
80
+ 1 - scale * (j_axis * j_axis + k_axis * k_axis),
81
+ scale * (i_axis * j_axis - k_axis * real),
82
+ scale * (i_axis * k_axis + j_axis * real),
83
+ scale * (i_axis * j_axis + k_axis * real),
84
+ 1 - scale * (i_axis * i_axis + k_axis * k_axis),
85
+ scale * (j_axis * k_axis - i_axis * real),
86
+ scale * (i_axis * k_axis - j_axis * real),
87
+ scale * (j_axis * k_axis + i_axis * real),
88
+ 1 - scale * (i_axis * i_axis + j_axis * j_axis),
89
+ )
90
+ # (..., 3, 3)
91
+ return torch.stack(entries, dim=-1).reshape((*quaternions.shape[:-1], 3, 3))
92
+
93
+
94
+ def random_quaternions(
95
+ n: int,
96
+ dtype: torch.dtype | None = None,
97
+ device: torch.device | str | None = None,
98
+ ) -> Tensor:
99
+ """Draw ``n`` uniformly distributed unit quaternions with nonnegative real part."""
100
+
101
+ resolved_device = torch.device(device) if isinstance(device, str) else device
102
+ samples = torch.randn((n, 4), dtype=dtype, device=resolved_device) # (n, 4)
103
+ squared_norm = (samples * samples).sum(dim=1) # (n,)
104
+ signed_norm = _copysign(torch.sqrt(squared_norm), samples[:, 0]) # (n,)
105
+ return samples / signed_norm[:, None] # (n, 4)
106
+
107
+
108
+ def random_rotations(
109
+ n: int,
110
+ dtype: torch.dtype | None = None,
111
+ device: torch.device | str | None = None,
112
+ ) -> Tensor:
113
+ """Draw rotation tensor R with shape ``(n, 3, 3)``."""
114
+
115
+ # (n, 3, 3)
116
+ return quaternion_to_matrix(random_quaternions(n, dtype=dtype, device=device))
117
+
118
+
119
+ def compute_random_augmentation(
120
+ multiplicity: int,
121
+ s_trans: float = 1.0,
122
+ device: torch.device | str | None = None,
123
+ dtype: torch.dtype = torch.float32,
124
+ ) -> tuple[Tensor, Tensor]:
125
+ """Draw independent rotations R and translations T for a replicated batch."""
126
+
127
+ rotations = random_rotations(multiplicity, dtype=dtype, device=device) # (m, 3, 3)
128
+ translations = ( # (m, 1, 3)
129
+ torch.randn(
130
+ (multiplicity, 1, 3),
131
+ dtype=dtype,
132
+ device=device,
133
+ )
134
+ * s_trans
135
+ )
136
+ return rotations, translations # (m, 3, 3), (m, 1, 3)
137
+
138
+
139
+ def randomly_rotate(
140
+ coords: Tensor,
141
+ return_second_coords: bool = False,
142
+ second_coords: Tensor | None = None,
143
+ ) -> Tensor | tuple[Tensor, Tensor | None]:
144
+ """Rotate X and optionally Y using the same sampled rotation tensor R."""
145
+
146
+ # coords: (b, n, 3); second_coords: (b, n_second, 3) or None.
147
+ rotations = random_rotations(len(coords), coords.dtype, coords.device) # (b, 3, 3)
148
+ rotated = torch.einsum("bmd,bds->bms", coords, rotations) # (b, n, 3)
149
+ if not return_second_coords:
150
+ return rotated # (b, n, 3)
151
+ rotated_second = ( # (b, n_second, 3) or None
152
+ None if second_coords is None else torch.einsum("bmd,bds->bms", second_coords, rotations)
153
+ )
154
+ return rotated, rotated_second # (b, n, 3), (b, n_second, 3) or None
155
+
156
+
157
+ def center_random_augmentation(
158
+ atom_coords: Tensor,
159
+ atom_mask: Tensor,
160
+ s_trans: float = 1.0,
161
+ augmentation: bool = True,
162
+ centering: bool = True,
163
+ return_second_coords: bool = False,
164
+ second_coords: Tensor | None = None,
165
+ ) -> Tensor | tuple[Tensor, Tensor | None]:
166
+ """Center and rigidly augment coordinate tensors X and optional Y."""
167
+
168
+ # atom_coords: (b, n_atom, 3); atom_mask: (b, n_atom).
169
+ # second_coords: (b, n_second, 3) or None.
170
+ primary = atom_coords # (b, n_atom, 3)
171
+ secondary = second_coords # (b, n_second, 3) or None
172
+ if centering:
173
+ centroid = _masked_center(primary, atom_mask) # (b, 1, 3)
174
+ primary = primary - centroid # (b, n_atom, 3)
175
+ if secondary is not None:
176
+ secondary = secondary - centroid # (b, n_second, 3)
177
+
178
+ if augmentation:
179
+ primary, secondary = randomly_rotate( # (b, n_atom, 3), (b, n_second, 3) or None
180
+ primary,
181
+ return_second_coords=True,
182
+ second_coords=secondary,
183
+ )
184
+ translation = torch.randn_like(primary[:, 0:1, :]) * s_trans # (b, 1, 3)
185
+ primary = primary + translation # (b, n_atom, 3)
186
+ if secondary is not None:
187
+ secondary = secondary + translation # (b, n_second, 3)
188
+
189
+ # (b, n_atom, 3), optionally paired with (b, n_second, 3) or None.
190
+ return (primary, secondary) if return_second_coords else primary
191
+
192
+
193
+ class ExponentialMovingAverage:
194
+ """Maintain detached exponential moving averages of trainable parameters."""
195
+
196
+ def __init__(
197
+ self,
198
+ parameters: Iterable[nn.Parameter],
199
+ decay: float,
200
+ use_num_updates: bool = True,
201
+ ) -> None:
202
+ # parameters: one independently shaped tensor (...) per trainable parameter.
203
+ if not 0.0 <= decay <= 1.0:
204
+ raise ValueError("decay must lie in [0, 1]")
205
+ self.decay = decay
206
+ self.num_updates: int | None = 0 if use_num_updates else None
207
+ self.shadow_params = [ # each: same shape as one trainable parameter, (...)
208
+ parameter.clone().detach() for parameter in parameters if parameter.requires_grad
209
+ ]
210
+ self.collected_params: list[Tensor] = []
211
+
212
+ @staticmethod
213
+ def _trainable(parameters: Iterable[nn.Parameter]) -> list[nn.Parameter]:
214
+ # Each item is one trainable parameter with an independently varying shape (...).
215
+ return [parameter for parameter in parameters if parameter.requires_grad]
216
+
217
+ def update(self, parameters: Iterable[nn.Parameter]) -> None:
218
+ # parameters: one independently shaped tensor (...) per trainable parameter.
219
+ trainable = self._trainable(parameters) # each: one trainable parameter, (...)
220
+ if len(trainable) != len(self.shadow_params):
221
+ raise ValueError("EMA parameter count changed")
222
+ decay = self.decay
223
+ if self.num_updates is not None:
224
+ self.num_updates += 1
225
+ decay = min(decay, (1 + self.num_updates) / (10 + self.num_updates))
226
+ update_weight = 1.0 - decay
227
+ with torch.no_grad():
228
+ for shadow, parameter in zip(self.shadow_params, trainable, strict=True):
229
+ shadow.sub_(update_weight * (shadow - parameter)) # (...) unchanged in place
230
+
231
+ def compatible(self, parameters: Sequence[Tensor]) -> bool:
232
+ """Return whether parameter count and tensor shapes match the EMA state."""
233
+
234
+ # Each shadow and corresponding parameter has an independently varying shape (...).
235
+ return len(self.shadow_params) == len(parameters) and all(
236
+ shadow.shape == parameter.shape
237
+ for shadow, parameter in zip(self.shadow_params, parameters, strict=True)
238
+ )
239
+
240
+ def copy_to(self, parameters: Iterable[nn.Parameter]) -> None:
241
+ # parameters: one independently shaped tensor (...) per trainable parameter.
242
+ trainable = self._trainable(parameters) # each: one trainable parameter, (...)
243
+ if len(trainable) != len(self.shadow_params):
244
+ raise ValueError("EMA parameter count changed")
245
+ for shadow, parameter in zip(self.shadow_params, trainable, strict=True):
246
+ parameter.data.copy_(shadow.data) # (...) unchanged in place
247
+
248
+ def store(self, parameters: Iterable[nn.Parameter]) -> None:
249
+ # parameters: one independently shaped tensor (...) per parameter.
250
+ # Each clone has the same shape (...) as its corresponding parameter.
251
+ self.collected_params = [parameter.clone() for parameter in parameters]
252
+
253
+ def restore(self, parameters: Iterable[nn.Parameter]) -> None:
254
+ # parameters: one independently shaped tensor (...) per parameter.
255
+ current = list(parameters) # each: one parameter, (...)
256
+ if len(current) != len(self.collected_params):
257
+ raise ValueError("stored EMA parameter count changed")
258
+ for collected, parameter in zip(self.collected_params, current, strict=True):
259
+ parameter.data.copy_(collected.data) # (...) unchanged in place
260
+
261
+ def state_dict(self) -> dict[str, Any]:
262
+ # shadow_params contains independently shaped tensors (...).
263
+ return {
264
+ "decay": self.decay,
265
+ "num_updates": self.num_updates,
266
+ "shadow_params": self.shadow_params,
267
+ }
268
+
269
+ def load_state_dict(
270
+ self,
271
+ state_dict: Mapping[str, Any],
272
+ device: torch.device | str,
273
+ ) -> None:
274
+ # state_dict["shadow_params"] contains independently shaped tensors (...).
275
+ self.decay = float(state_dict["decay"])
276
+ updates = state_dict["num_updates"]
277
+ self.num_updates = None if updates is None else int(updates)
278
+ # Each tensor retains its shape (...).
279
+ self.shadow_params = [tensor.to(device) for tensor in state_dict["shadow_params"]]
280
+
281
+ def to(self, device: torch.device | str) -> None:
282
+ # Each shadow parameter retains its independently varying shape (...).
283
+ self.shadow_params = [tensor.to(device) for tensor in self.shadow_params]
fastplms/models/boltz/vb_potentials_potentials.py CHANGED
@@ -1,896 +1,896 @@
1
- """Differentiable structure-steering potentials for Boltz2 diffusion.
2
-
3
- Each potential follows the same mechanism: derive constraint indices and
4
- bounds from features, evaluate a geometric variable, map that variable to an
5
- energy, and optionally scatter its analytic derivative back to atom
6
- coordinates. This module is maintained independently from the pinned parity
7
- oracle while preserving the public class names used by converted checkpoints.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import torch
13
-
14
- from abc import ABC, abstractmethod
15
- from dataclasses import dataclass
16
- from typing import Any
17
-
18
- from . import vb_const as const
19
- from .vb_loss_diffusionv2 import weighted_rigid_align
20
- from .vb_potentials_schedules import (
21
- ExponentialInterpolation,
22
- ParameterSchedule,
23
- PiecewiseStepFunction,
24
- )
25
-
26
-
27
- Parameter = ParameterSchedule | float | int | bool
28
- ParameterMap = dict[str, Parameter]
29
-
30
-
31
- @dataclass(frozen=True, slots=True)
32
- class _PreparedPotential:
33
- coordinates: torch.Tensor # (..., a, 3)
34
- index: torch.Tensor # (q, n)
35
- function_args: tuple[Any, ...]
36
- com_index: torch.Tensor | None # (a_input,)
37
- atom_pad_mask: torch.Tensor | None # (a_input,)
38
- ref_coords: torch.Tensor | None # (n_ref, n, 3)
39
- ref_mask: torch.Tensor | None # (n_ref, n)
40
- ref_token_index: torch.Tensor | None # (a_input,)
41
- negation_mask: torch.Tensor | None # (n,)
42
- union_index: torch.Tensor | None # (n,)
43
-
44
-
45
- def _reduce_atoms_to_centers(
46
- coordinates: torch.Tensor,
47
- com_index: torch.Tensor,
48
- atom_pad_mask: torch.Tensor,
49
- ) -> torch.Tensor:
50
- # coordinates: (..., a, 3); com_index/atom_pad_mask: (a,).
51
- unpadded_index = com_index[atom_pad_mask] # (a_valid,)
52
- unpadded_coordinates = coordinates[..., atom_pad_mask, :] # (..., a_valid, 3)
53
- center_shape = (*unpadded_coordinates.shape[:-2], unpadded_index.max() + 1, 3)
54
- return torch.zeros(center_shape, device=coordinates.device).scatter_reduce(
55
- -2,
56
- unpadded_index.unsqueeze(-1).expand_as(unpadded_coordinates),
57
- unpadded_coordinates,
58
- "mean",
59
- ) # (..., n_center, 3)
60
-
61
-
62
- def _union_weights(
63
- energy: torch.Tensor,
64
- union_index: torch.Tensor,
65
- union_lambda: float,
66
- ) -> torch.Tensor:
67
- # energy: (..., n); union_index: (n,).
68
- unnormalized = torch.exp(-union_lambda * energy) # (..., n)
69
- partition = torch.zeros(
70
- (*energy.shape[:-1], union_index.max() + 1),
71
- device=union_index.device,
72
- ).scatter_reduce(
73
- -1,
74
- union_index.expand_as(unnormalized),
75
- unnormalized,
76
- "sum",
77
- ) # (..., n_union)
78
- weights = unnormalized / partition[..., union_index] # (..., n)
79
- weights[partition[..., union_index] == 0] = 0 # (..., n)
80
- return weights # (..., n)
81
-
82
-
83
- def _scatter_constraint_gradients(
84
- coefficients: torch.Tensor,
85
- variable_gradient: torch.Tensor,
86
- index: torch.Tensor,
87
- coordinates: torch.Tensor,
88
- ) -> torch.Tensor:
89
- # coefficients: (..., n); variable_gradient: (..., q, n, 3).
90
- # index: (q, n); coordinates: (b, a, 3).
91
- product = coefficients.tile(variable_gradient.shape[-3]).unsqueeze(
92
- -1
93
- ) * variable_gradient.flatten(start_dim=-3, end_dim=-2) # (..., q * n, 3)
94
- if product.dim() > 3:
95
- product = product.sum(dim=list(range(1, product.dim() - 2))) # (b, q * n, 3)
96
- scatter_index = (
97
- index.flatten(start_dim=0, end_dim=1).unsqueeze(-1).expand((*coordinates.shape[:-2], -1, 3))
98
- ) # (b, q * n, 3)
99
- return torch.zeros_like(coordinates).scatter_reduce(
100
- -2,
101
- scatter_index,
102
- product,
103
- "sum",
104
- ) # (b, a, 3)
105
-
106
-
107
- class Potential(ABC):
108
- """Base contract for energy and analytic-gradient steering potentials."""
109
-
110
- def __init__(self, parameters: ParameterMap | None = None) -> None:
111
- self.parameters = parameters
112
-
113
- def _prepare(
114
- self,
115
- coordinates: torch.Tensor,
116
- feats: dict[str, torch.Tensor],
117
- parameters: dict[str, Any],
118
- computed_args: tuple[Any, ...] | None = None,
119
- ) -> _PreparedPotential:
120
- # coordinates: (..., a_input, 3).
121
- if computed_args is None:
122
- computed_args = self.compute_args(feats, parameters)
123
- index, args, com_args, ref_args, operator_args = computed_args # index: (q, n)
124
- com_index = atom_pad_mask = None
125
- if com_args is not None:
126
- com_index, atom_pad_mask = com_args # each (a_input,)
127
- coordinates = _reduce_atoms_to_centers(
128
- coordinates,
129
- com_index,
130
- atom_pad_mask,
131
- ) # (..., n_center, 3)
132
-
133
- ref_coords = ref_mask = ref_token_index = None
134
- if ref_args is not None:
135
- # ref_coords: (n_ref, n, 3); ref_mask: (n_ref, n).
136
- # ref_atom_index: (n,); ref_token_index: (a_input,).
137
- ref_coords, ref_mask, ref_atom_index, ref_token_index = ref_args
138
- coordinates = coordinates[..., ref_atom_index, :] # (..., n, 3)
139
-
140
- negation_mask = union_index = None
141
- if operator_args is not None:
142
- negation_mask, union_index = operator_args # each (n,)
143
- return _PreparedPotential(
144
- coordinates=coordinates,
145
- index=index,
146
- function_args=args,
147
- com_index=com_index,
148
- atom_pad_mask=atom_pad_mask,
149
- ref_coords=ref_coords,
150
- ref_mask=ref_mask,
151
- ref_token_index=ref_token_index,
152
- negation_mask=negation_mask,
153
- union_index=union_index,
154
- )
155
-
156
- def compute(
157
- self,
158
- coords: torch.Tensor,
159
- feats: dict[str, torch.Tensor],
160
- parameters: dict[str, Any],
161
- ) -> torch.Tensor:
162
- """Evaluate one energy per coordinate sample."""
163
-
164
- # coords: (..., a, 3).
165
- computed_args = self.compute_args(feats, parameters)
166
- if computed_args[0].shape[1] == 0:
167
- return torch.zeros(coords.shape[:-2], device=coords.device) # (...)
168
- prepared = self._prepare(coords, feats, parameters, computed_args)
169
- value = self.compute_variable(
170
- prepared.coordinates,
171
- prepared.index,
172
- ref_coords=prepared.ref_coords,
173
- ref_mask=prepared.ref_mask,
174
- compute_gradient=False,
175
- ) # (..., n)
176
- energy = self.compute_function(
177
- value,
178
- *prepared.function_args,
179
- negation_mask=prepared.negation_mask,
180
- compute_derivative=False,
181
- ) # (..., n)
182
- if prepared.union_index is not None:
183
- weights = _union_weights(
184
- energy,
185
- prepared.union_index,
186
- parameters["union_lambda"],
187
- ) # (..., n)
188
- return (energy * weights).sum(dim=-1) # (...)
189
- return energy.sum(dim=tuple(range(1, energy.dim()))) # (b,)
190
-
191
- def compute_gradient(
192
- self,
193
- coords: torch.Tensor,
194
- feats: dict[str, torch.Tensor],
195
- parameters: dict[str, Any],
196
- ) -> torch.Tensor:
197
- """Return the analytic coordinate gradient of the potential energy."""
198
-
199
- # coords: (b, a, 3).
200
- computed_args = self.compute_args(feats, parameters)
201
- if computed_args[0].shape[1] == 0:
202
- return torch.zeros_like(coords) # (b, a, 3)
203
- prepared = self._prepare(coords, feats, parameters, computed_args)
204
- value, variable_gradient = self.compute_variable(
205
- prepared.coordinates,
206
- prepared.index,
207
- ref_coords=prepared.ref_coords,
208
- ref_mask=prepared.ref_mask,
209
- compute_gradient=True,
210
- ) # value: (..., n); variable_gradient: (..., q, n, 3)
211
- energy, energy_derivative = self.compute_function(
212
- value,
213
- *prepared.function_args,
214
- negation_mask=prepared.negation_mask,
215
- compute_derivative=True,
216
- ) # each (..., n)
217
- if prepared.union_index is not None:
218
- weights = _union_weights(
219
- energy,
220
- prepared.union_index,
221
- parameters["union_lambda"],
222
- ) # (..., n)
223
- union_energy = torch.zeros(
224
- (*energy.shape[:-1], prepared.union_index.max() + 1),
225
- device=prepared.union_index.device,
226
- ).scatter_reduce(
227
- -1,
228
- prepared.union_index.expand_as(energy),
229
- energy * weights,
230
- "sum",
231
- ) # (..., n_union)
232
- coefficients = (
233
- energy_derivative
234
- * weights
235
- * (
236
- 1
237
- + parameters["union_lambda"]
238
- * (energy - union_energy[..., prepared.union_index])
239
- )
240
- ) # (..., n)
241
- else:
242
- coefficients = energy_derivative # (..., n)
243
-
244
- atom_gradient = _scatter_constraint_gradients(
245
- coefficients,
246
- variable_gradient,
247
- prepared.index,
248
- prepared.coordinates,
249
- ) # (b, a_prepared, 3)
250
- if prepared.com_index is not None:
251
- atom_gradient = atom_gradient[..., prepared.com_index, :] # (b, a, 3)
252
- elif prepared.ref_token_index is not None:
253
- atom_gradient = atom_gradient[..., prepared.ref_token_index, :] # (b, a, 3)
254
- return atom_gradient # (b, a, 3)
255
-
256
- def compute_parameters(self, t: float) -> dict[str, Any] | None:
257
- """Resolve scheduled parameters at diffusion time ``t``."""
258
-
259
- if self.parameters is None:
260
- return None
261
- return {
262
- name: parameter.compute(t) if isinstance(parameter, ParameterSchedule) else parameter
263
- for name, parameter in self.parameters.items()
264
- }
265
-
266
- @abstractmethod
267
- def compute_function(
268
- self,
269
- value: torch.Tensor,
270
- *args: Any,
271
- negation_mask: torch.Tensor | None = None,
272
- compute_derivative: bool = False,
273
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
274
- # value: (..., n); negation_mask: (n,).
275
- raise NotImplementedError
276
-
277
- @abstractmethod
278
- def compute_variable(
279
- self,
280
- coords: torch.Tensor,
281
- index: torch.Tensor,
282
- ref_coords: torch.Tensor | None = None,
283
- ref_mask: torch.Tensor | None = None,
284
- compute_gradient: bool = False,
285
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
286
- # coords: (..., a, 3); index: (q, n).
287
- raise NotImplementedError
288
-
289
- @abstractmethod
290
- def compute_args(
291
- self,
292
- feats: dict[str, torch.Tensor],
293
- parameters: dict[str, Any],
294
- ) -> tuple[Any, ...]:
295
- raise NotImplementedError
296
-
297
- def get_reference_coords(
298
- self,
299
- feats: dict[str, torch.Tensor],
300
- parameters: dict[str, Any],
301
- ) -> tuple[None, None]:
302
- del feats, parameters
303
- return None, None
304
-
305
-
306
- class FlatBottomPotential(Potential):
307
- """Linear penalty outside an allowed lower-to-upper interval."""
308
-
309
- def compute_function(
310
- self,
311
- value: torch.Tensor,
312
- k: torch.Tensor,
313
- lower_bounds: torch.Tensor | None,
314
- upper_bounds: torch.Tensor | None,
315
- negation_mask: torch.Tensor | None = None,
316
- compute_derivative: bool = False,
317
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
318
- # value: (..., n); k/lower_bounds/upper_bounds: (n,) or (..., n).
319
- # negation_mask: (n,) or a shape broadcastable to (..., n).
320
- lower = (
321
- (torch.full_like(value, float("-inf")) if lower_bounds is None else lower_bounds)
322
- .expand_as(value)
323
- .clone()
324
- ) # (..., n)
325
- upper = (
326
- (torch.full_like(value, float("inf")) if upper_bounds is None else upper_bounds)
327
- .expand_as(value)
328
- .clone()
329
- ) # (..., n)
330
-
331
- if negation_mask is not None:
332
- if not torch.is_tensor(negation_mask):
333
- raise TypeError(
334
- f"negation_mask must be a boolean tensor, got {type(negation_mask).__name__}."
335
- )
336
- if negation_mask.dtype != torch.bool:
337
- raise TypeError(
338
- f"negation_mask must be a boolean tensor, got {negation_mask.dtype}."
339
- )
340
- if negation_mask.device != value.device:
341
- raise ValueError(
342
- "negation_mask must be on the same device as value; "
343
- f"got {negation_mask.device} and {value.device}."
344
- )
345
- try:
346
- expanded_negation_mask = negation_mask.expand_as(value) # (..., n)
347
- except RuntimeError as error:
348
- raise ValueError(
349
- "negation_mask must be broadcastable to value shape "
350
- f"{tuple(value.shape)}, got {tuple(negation_mask.shape)}."
351
- ) from error
352
- unbounded_below = torch.isneginf(lower) # (..., n)
353
- unbounded_above = torch.isposinf(upper) # (..., n)
354
- # (..., n)
355
- valid_negation = unbounded_below | unbounded_above | expanded_negation_mask
356
- if not bool(torch.all(valid_negation).item()):
357
- raise ValueError(
358
- "negation_mask may be false only where at least one bound is infinite."
359
- )
360
- select_upper = ~unbounded_above & ~expanded_negation_mask # (..., n)
361
- lower[select_upper] = upper[select_upper] # (..., n)
362
- upper[select_upper] = float("inf") # (..., n)
363
- select_lower = ~unbounded_below & ~expanded_negation_mask # (..., n)
364
- upper[select_lower] = lower[select_lower] # (..., n)
365
- lower[select_lower] = float("-inf") # (..., n)
366
-
367
- below = value < lower # (..., n)
368
- above = value > upper # (..., n)
369
- energy = torch.zeros_like(value) # (..., n)
370
- energy[below] = (k * (lower - value))[below] # (..., n)
371
- energy[above] = (k * (value - upper))[above] # (..., n)
372
- if not compute_derivative:
373
- return energy # (..., n)
374
- derivative = torch.zeros_like(value) # (..., n)
375
- derivative[below] = -k.expand_as(below)[below] # (..., n)
376
- derivative[above] = k.expand_as(above)[above] # (..., n)
377
- return energy, derivative # each (..., n)
378
-
379
-
380
- class ReferencePotential(Potential):
381
- """Measure atom displacement after weighted rigid alignment."""
382
-
383
- def compute_variable(
384
- self,
385
- coords: torch.Tensor,
386
- index: torch.Tensor,
387
- ref_coords: torch.Tensor,
388
- ref_mask: torch.Tensor,
389
- compute_gradient: bool = False,
390
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
391
- # coords: (b, a, 3); index: (1, n).
392
- # ref_coords: (n_ref, n, 3); ref_mask: (n_ref, n).
393
- aligned_reference = weighted_rigid_align(
394
- ref_coords.float(),
395
- coords[:, index].float(),
396
- ref_mask,
397
- ref_mask,
398
- ) # (b, n_ref, n, 3)
399
- displacement = coords[:, index] - aligned_reference # (b, n_ref, n, 3)
400
- distance = torch.linalg.norm(displacement, dim=-1) # (b, n_ref, n)
401
- if not compute_gradient:
402
- return distance # (b, n_ref, n)
403
- unit_displacement = displacement / distance.unsqueeze(-1) # (b, n_ref, n, 3)
404
- # (b, 1, n_ref, n, 3)
405
- gradient = (unit_displacement * ref_mask.unsqueeze(-1)).unsqueeze(1)
406
- return distance, gradient # (b, n_ref, n); (b, 1, n_ref, n, 3)
407
-
408
-
409
- class DistancePotential(Potential):
410
- """Measure Euclidean distances for indexed atom pairs."""
411
-
412
- def compute_variable(
413
- self,
414
- coords: torch.Tensor,
415
- index: torch.Tensor,
416
- ref_coords: torch.Tensor | None = None,
417
- ref_mask: torch.Tensor | None = None,
418
- compute_gradient: bool = False,
419
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
420
- # coords: (..., a, 3); index: (2, n).
421
- del ref_coords, ref_mask
422
- displacement = coords.index_select(-2, index[0]) - coords.index_select(
423
- -2,
424
- index[1],
425
- ) # (..., n, 3)
426
- distance = torch.linalg.norm(displacement, dim=-1) # (..., n)
427
- if not compute_gradient:
428
- return distance # (..., n)
429
- unit_displacement = displacement / distance.unsqueeze(-1) # (..., n, 3)
430
- # Returns (..., n); (..., 2, n, 3).
431
- return distance, torch.stack((unit_displacement, -unit_displacement), dim=1)
432
-
433
-
434
- class DihedralPotential(Potential):
435
- """Measure signed torsion angles for indexed atom quartets."""
436
-
437
- def compute_variable(
438
- self,
439
- coords: torch.Tensor,
440
- index: torch.Tensor,
441
- ref_coords: torch.Tensor | None = None,
442
- ref_mask: torch.Tensor | None = None,
443
- compute_gradient: bool = False,
444
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
445
- # coords: (..., a, 3); index: (4, n).
446
- del ref_coords, ref_mask
447
- # Each displacement has shape (..., n, 3).
448
- r_ij = coords.index_select(-2, index[0]) - coords.index_select(-2, index[1])
449
- r_kj = coords.index_select(-2, index[2]) - coords.index_select(-2, index[1])
450
- r_kl = coords.index_select(-2, index[2]) - coords.index_select(-2, index[3])
451
- n_ijk = torch.cross(r_ij, r_kj, dim=-1) # (..., n, 3)
452
- n_jkl = torch.cross(r_kj, r_kl, dim=-1) # (..., n, 3)
453
- r_kj_norm = torch.linalg.norm(r_kj, dim=-1) # (..., n)
454
- n_ijk_norm = torch.linalg.norm(n_ijk, dim=-1) # (..., n)
455
- n_jkl_norm = torch.linalg.norm(n_jkl, dim=-1) # (..., n)
456
-
457
- orientation = torch.sign(
458
- r_kj.unsqueeze(-2) @ torch.cross(n_ijk, n_jkl, dim=-1).unsqueeze(-1)
459
- ).squeeze(-1, -2) # (..., n)
460
- cosine = (n_ijk.unsqueeze(-2) @ n_jkl.unsqueeze(-1)).squeeze(-1, -2) / (
461
- n_ijk_norm * n_jkl_norm
462
- ) # (..., n)
463
- # (..., n)
464
- angle = orientation * torch.arccos(torch.clamp(cosine, -1 + 1e-8, 1 - 1e-8))
465
- if not compute_gradient:
466
- return angle # (..., n)
467
-
468
- projection_i = (
469
- (r_ij.unsqueeze(-2) @ r_kj.unsqueeze(-1)).squeeze(-1, -2) / (r_kj_norm**2)
470
- ).unsqueeze(-1) # (..., n, 1)
471
- projection_l = (
472
- (r_kl.unsqueeze(-2) @ r_kj.unsqueeze(-1)).squeeze(-1, -2) / (r_kj_norm**2)
473
- ).unsqueeze(-1) # (..., n, 1)
474
- grad_i = n_ijk * (r_kj_norm / n_ijk_norm**2).unsqueeze(-1) # (..., n, 3)
475
- grad_l = -n_jkl * (r_kj_norm / n_jkl_norm**2).unsqueeze(-1) # (..., n, 3)
476
- grad_j = (projection_i - 1) * grad_i - projection_l * grad_l # (..., n, 3)
477
- grad_k = (projection_l - 1) * grad_l - projection_i * grad_i # (..., n, 3)
478
- # Returns (..., n); (..., 4, n, 3).
479
- return angle, torch.stack((grad_i, grad_j, grad_k, grad_l), dim=1)
480
-
481
-
482
- class AbsDihedralPotential(DihedralPotential):
483
- """Measure the unsigned magnitude of indexed torsion angles."""
484
-
485
- def compute_variable(
486
- self,
487
- coords: torch.Tensor,
488
- index: torch.Tensor,
489
- ref_coords: torch.Tensor | None = None,
490
- ref_mask: torch.Tensor | None = None,
491
- compute_gradient: bool = False,
492
- ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
493
- # coords: (..., a, 3); index: (4, n).
494
- del ref_coords, ref_mask
495
- if not compute_gradient:
496
- return torch.abs(super().compute_variable(coords, index)) # (..., n)
497
- angle, gradient = super().compute_variable(
498
- coords,
499
- index,
500
- compute_gradient=True,
501
- ) # (..., n); (..., 4, n, 3)
502
- gradient[(angle < 0)[..., None, :, None].expand_as(gradient)] *= -1 # same
503
- return torch.abs(angle), gradient # (..., n); (..., 4, n, 3)
504
-
505
-
506
- def _element_radii(feats: dict[str, torch.Tensor]) -> torch.Tensor:
507
- # feats["ref_element"]: (1, a, n_element).
508
- element_radii = torch.zeros(
509
- const.num_elements,
510
- dtype=torch.float32,
511
- device=feats["ref_element"].device,
512
- ) # (n_element,)
513
- element_radii[1:119] = torch.tensor(
514
- const.vdw_radii,
515
- dtype=torch.float32,
516
- device=element_radii.device,
517
- ) # (n_element,)
518
- # (a,)
519
- return (feats["ref_element"].float() @ element_radii.unsqueeze(-1)).squeeze(-1)[0]
520
-
521
-
522
- def _atom_chain_ids(feats: dict[str, torch.Tensor]) -> torch.Tensor:
523
- # atom_to_token: (1, a, l); asym_id: (1, l).
524
- return (
525
- torch.bmm(
526
- feats["atom_to_token"].float(),
527
- feats["asym_id"].unsqueeze(-1).float(),
528
- )
529
- .squeeze(-1)
530
- .long()
531
- )[0] # (a,)
532
-
533
-
534
- class PoseBustersPotential(FlatBottomPotential, DistancePotential):
535
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
536
- # rdkit_bounds_index: (1, 2, n); bound and mask features: (1, n).
537
- pair_index = feats["rdkit_bounds_index"][0] # (2, n)
538
- lower = feats["rdkit_lower_bounds"][0].clone() # (n,)
539
- upper = feats["rdkit_upper_bounds"][0].clone() # (n,)
540
- bond = feats["rdkit_bounds_bond_mask"][0] # (n,)
541
- angle = feats["rdkit_bounds_angle_mask"][0] # (n,)
542
- lower[bond * ~angle] *= 1.0 - parameters["bond_buffer"] # (n,)
543
- upper[bond * ~angle] *= 1.0 + parameters["bond_buffer"] # (n,)
544
- lower[~bond * angle] *= 1.0 - parameters["angle_buffer"] # (n,)
545
- upper[~bond * angle] *= 1.0 + parameters["angle_buffer"] # (n,)
546
- shared_buffer = min(parameters["bond_buffer"], parameters["angle_buffer"])
547
- lower[bond * angle] *= 1.0 - shared_buffer # (n,)
548
- upper[bond * angle] *= 1.0 + shared_buffer # (n,)
549
- lower[~bond * ~angle] *= 1.0 - parameters["clash_buffer"] # (n,)
550
- upper[~bond * ~angle] = float("inf") # (n,)
551
-
552
- atom_radii = _element_radii(feats) # (a,)
553
- bond_cutoff = 0.35 + atom_radii[pair_index].mean(dim=0) # (n,)
554
- lower[~bond] = torch.max(lower[~bond], bond_cutoff[~bond]) # (n,)
555
- upper[bond] = torch.min(upper[bond], bond_cutoff[bond]) # (n,)
556
- # Returns index (2, n) and three function arguments of shape (n,).
557
- return pair_index, (torch.ones_like(lower), lower, upper), None, None, None
558
-
559
-
560
- class ConnectionsPotential(FlatBottomPotential, DistancePotential):
561
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
562
- # connected_atom_index: (1, 2, n).
563
- pair_index = feats["connected_atom_index"][0] # (2, n)
564
- upper = torch.full(
565
- (pair_index.shape[1],),
566
- parameters["buffer"],
567
- device=pair_index.device,
568
- ) # (n,)
569
- # Returns index (2, n), k (n,), and upper bound (n,).
570
- return pair_index, (torch.ones_like(upper), None, upper), None, None, None
571
-
572
-
573
- class VDWOverlapPotential(FlatBottomPotential, DistancePotential):
574
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
575
- atom_chain_id = _atom_chain_ids(feats) # (a,)
576
- atom_pad_mask = feats["atom_pad_mask"][0].bool() # (a,)
577
- chain_sizes = torch.bincount(atom_chain_id[atom_pad_mask]) # (n_chain,)
578
- nonion_atom = (chain_sizes > 1)[atom_chain_id] # (a,)
579
- atom_radii = _element_radii(feats) # (a,)
580
- pair_index = torch.triu_indices(
581
- atom_chain_id.shape[0],
582
- atom_chain_id.shape[0],
583
- 1,
584
- device=atom_chain_id.device,
585
- ) # (2, n_pair)
586
- pair_pad_mask = atom_pad_mask[pair_index].all(dim=0) # (n_pair,)
587
- # (n_pair,)
588
- pair_ion_mask = nonion_atom[pair_index[0]] * nonion_atom[pair_index[1]]
589
-
590
- num_chains = atom_chain_id.max() + 1 # ()
591
- connected = feats["connected_chain_index"][0] # (2, n_connection)
592
- connected_matrix = torch.eye(
593
- num_chains,
594
- device=atom_chain_id.device,
595
- dtype=torch.bool,
596
- ) # (n_chain, n_chain)
597
- connected_matrix[connected[0], connected[1]] = True # (n_chain, n_chain)
598
- connected_matrix[connected[1], connected[0]] = True # (n_chain, n_chain)
599
- connected_pair = connected_matrix[
600
- atom_chain_id[pair_index[0]],
601
- atom_chain_id[pair_index[1]],
602
- ] # (n_pair,)
603
- # (2, n)
604
- pair_index = pair_index[:, pair_pad_mask * pair_ion_mask * ~connected_pair]
605
- # (n,)
606
- lower = atom_radii[pair_index].sum(dim=0) * (1.0 - parameters["buffer"])
607
- # Returns index (2, n), k (n,), and lower bound (n,).
608
- return pair_index, (torch.ones_like(lower), lower, None), None, None, None
609
-
610
-
611
- class SymmetricChainCOMPotential(FlatBottomPotential, DistancePotential):
612
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
613
- atom_chain_id = _atom_chain_ids(feats) # (a,)
614
- atom_pad_mask = feats["atom_pad_mask"][0].bool() # (a,)
615
- nonion_chain = torch.bincount(atom_chain_id[atom_pad_mask]) > 1 # (n_chain,)
616
- pair_index = feats["symmetric_chain_index"][0] # (2, n_candidate)
617
- pair_index = pair_index[
618
- :,
619
- nonion_chain[pair_index[0]] * nonion_chain[pair_index[1]],
620
- ] # (2, n)
621
- lower = torch.full(
622
- (pair_index.shape[1],),
623
- parameters["buffer"],
624
- dtype=torch.float32,
625
- device=pair_index.device,
626
- ) # (n,)
627
- # Returns center index (2, n), k/lower (n,), and atom-to-center maps (a,).
628
- return (
629
- pair_index,
630
- (torch.ones_like(lower), lower, None),
631
- (atom_chain_id, atom_pad_mask),
632
- None,
633
- None,
634
- )
635
-
636
-
637
- def _oriented_bounds(
638
- orientations: torch.Tensor,
639
- positive_lower: float,
640
- negative_upper: float,
641
- ) -> tuple[torch.Tensor, torch.Tensor]:
642
- # orientations: (n,).
643
- lower = torch.zeros(orientations.shape, device=orientations.device) # (n,)
644
- upper = torch.zeros(orientations.shape, device=orientations.device) # (n,)
645
- lower[orientations] = positive_lower # (n,)
646
- upper[orientations] = float("inf") # (n,)
647
- lower[~orientations] = float("-inf") # (n,)
648
- upper[~orientations] = negative_upper # (n,)
649
- return lower, upper # each (n,)
650
-
651
-
652
- class StereoBondPotential(FlatBottomPotential, AbsDihedralPotential):
653
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
654
- # stereo_bond_index: (1, 4, n); orientations: (1, n).
655
- index = feats["stereo_bond_index"][0] # (4, n)
656
- orientation = feats["stereo_bond_orientations"][0].bool() # (n,)
657
- lower, upper = _oriented_bounds(
658
- orientation,
659
- torch.pi - parameters["buffer"],
660
- parameters["buffer"],
661
- ) # each (n,)
662
- # Returns index (4, n) and three function arguments of shape (n,).
663
- return index, (torch.ones_like(lower), lower, upper), None, None, None
664
-
665
-
666
- class ChiralAtomPotential(FlatBottomPotential, DihedralPotential):
667
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
668
- # chiral_atom_index: (1, 4, n); orientations: (1, n).
669
- index = feats["chiral_atom_index"][0] # (4, n)
670
- orientation = feats["chiral_atom_orientations"][0].bool() # (n,)
671
- lower, upper = _oriented_bounds(
672
- orientation,
673
- parameters["buffer"],
674
- -parameters["buffer"],
675
- ) # each (n,)
676
- # Returns index (4, n) and three function arguments of shape (n,).
677
- return index, (torch.ones_like(lower), lower, upper), None, None, None
678
-
679
-
680
- class PlanarBondPotential(FlatBottomPotential, AbsDihedralPotential):
681
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
682
- # The feature stores two atom-index rows for each six-entry bond pattern.
683
- bond_index = feats["planar_bond_index"][0].T # (2, n_bond_entry)
684
- improper_pattern = torch.tensor(
685
- [[1, 2, 3, 0], [4, 5, 0, 3]],
686
- device=bond_index.device,
687
- ).T # (4, 2)
688
- # (4, n_improper)
689
- improper_index = bond_index[:, improper_pattern].swapaxes(0, 1).flatten(start_dim=1)
690
- upper = torch.full(
691
- (improper_index.shape[1],),
692
- parameters["buffer"],
693
- device=improper_index.device,
694
- ) # (n_improper,)
695
- # Returns index (4, n_improper), k (n_improper,), and upper (n_improper,).
696
- return (
697
- improper_index,
698
- (torch.ones_like(upper), None, upper),
699
- None,
700
- None,
701
- None,
702
- )
703
-
704
-
705
- class TemplateReferencePotential(FlatBottomPotential, ReferencePotential):
706
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
707
- del parameters
708
- if "template_mask_cb" not in feats or "template_force" not in feats:
709
- # Empty sentinel index: (1, 0).
710
- return torch.empty((1, 0)), None, None, None, None
711
- # template_mask_cb/template_force: (b, n_template, n)/(b, n_template).
712
- template_mask = feats["template_mask_cb"][feats["template_force"]] # (n_ref, n)
713
- if template_mask.shape[0] == 0:
714
- # Empty sentinel index: (1, 0).
715
- return torch.empty((1, 0)), None, None, None, None
716
-
717
- ref_coords = feats["template_cb"][feats["template_force"]].clone() # (n_ref, n, 3)
718
- ref_mask = feats["template_mask_cb"][feats["template_force"]].clone() # (n_ref, n)
719
- atom_indices = torch.arange(
720
- feats["atom_pad_mask"].shape[1],
721
- device=feats["atom_pad_mask"].device,
722
- dtype=torch.float32,
723
- )[None, :, None] # (1, a, 1)
724
- ref_atom_index = (
725
- torch.bmm(
726
- feats["token_to_rep_atom"].float(),
727
- atom_indices,
728
- )
729
- .squeeze(-1)
730
- .long()[0] # (n,)
731
- )
732
- ref_token_index = (
733
- torch.bmm(
734
- feats["atom_to_token"].float(),
735
- feats["token_index"].unsqueeze(-1).float(),
736
- )
737
- .squeeze(-1)
738
- .long()[0] # (a,)
739
- )
740
-
741
- index = torch.arange(
742
- template_mask.shape[-1],
743
- dtype=torch.long,
744
- device=template_mask.device,
745
- )[None] # (1, n)
746
- upper = torch.full(
747
- template_mask.shape,
748
- float("inf"),
749
- device=index.device,
750
- dtype=torch.float32,
751
- ) # (n_ref, n)
752
- reference_indices = torch.argwhere(template_mask).T # (2, n_active)
753
- upper[reference_indices.unbind()] = feats["template_force_threshold"][
754
- feats["template_force"]
755
- ][reference_indices[0]] # (n_ref, n)
756
- # Returns index (1, n), function args (n_ref, n), and reference mappings.
757
- return (
758
- index,
759
- (torch.ones_like(upper), None, upper),
760
- None,
761
- (ref_coords, ref_mask, ref_atom_index, ref_token_index),
762
- None,
763
- )
764
-
765
-
766
- class ContactPotentital(FlatBottomPotential, DistancePotential):
767
- """Contact-union potential retaining the historical checkpoint name."""
768
-
769
- def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
770
- del parameters
771
- # pair index: (1, 2, n); threshold/operator features: (1, n).
772
- index = feats["contact_pair_index"][0] # (2, n)
773
- upper = feats["contact_thresholds"][0].clone() # (n,)
774
- # Returns index (2, n), k/upper (n,), and two operator tensors (n,).
775
- return (
776
- index,
777
- (torch.ones_like(upper), None, upper),
778
- None,
779
- None,
780
- (
781
- feats["contact_negation_mask"][0],
782
- feats["contact_union_index"][0],
783
- ),
784
- )
785
-
786
-
787
- ContactPotential = ContactPotentital
788
-
789
-
790
- def get_potentials(
791
- steering_args: dict[str, bool],
792
- boltz2: bool = False,
793
- ) -> list[Potential]:
794
- """Build the ordered potential stack for requested steering modes."""
795
-
796
- use_fk = steering_args["fk_steering"]
797
- use_physical = steering_args["physical_guidance_update"]
798
- use_contacts = steering_args.get("contact_guidance_update", False)
799
- potentials: list[Potential] = []
800
- if use_fk or use_physical:
801
- potentials.extend(
802
- (
803
- SymmetricChainCOMPotential(
804
- {
805
- "guidance_interval": 4,
806
- "guidance_weight": 0.5 if use_physical else 0.0,
807
- "resampling_weight": 0.5,
808
- "buffer": ExponentialInterpolation(1.0, 5.0, -2.0),
809
- }
810
- ),
811
- VDWOverlapPotential(
812
- {
813
- "guidance_interval": 5,
814
- "guidance_weight": PiecewiseStepFunction(
815
- [0.4],
816
- [0.125, 0.0],
817
- )
818
- if use_physical
819
- else 0.0,
820
- "resampling_weight": PiecewiseStepFunction(
821
- [0.6],
822
- [0.01, 0.0],
823
- ),
824
- "buffer": 0.225,
825
- }
826
- ),
827
- ConnectionsPotential(
828
- {
829
- "guidance_interval": 1,
830
- "guidance_weight": 0.15 if use_physical else 0.0,
831
- "resampling_weight": 1.0,
832
- "buffer": 2.0,
833
- }
834
- ),
835
- PoseBustersPotential(
836
- {
837
- "guidance_interval": 1,
838
- "guidance_weight": 0.01 if use_physical else 0.0,
839
- "resampling_weight": 0.1,
840
- "bond_buffer": 0.125,
841
- "angle_buffer": 0.125,
842
- "clash_buffer": 0.10,
843
- }
844
- ),
845
- ChiralAtomPotential(
846
- {
847
- "guidance_interval": 1,
848
- "guidance_weight": 0.1 if use_physical else 0.0,
849
- "resampling_weight": 1.0,
850
- "buffer": 0.52360,
851
- }
852
- ),
853
- StereoBondPotential(
854
- {
855
- "guidance_interval": 1,
856
- "guidance_weight": 0.05 if use_physical else 0.0,
857
- "resampling_weight": 1.0,
858
- "buffer": 0.52360,
859
- }
860
- ),
861
- PlanarBondPotential(
862
- {
863
- "guidance_interval": 1,
864
- "guidance_weight": 0.05 if use_physical else 0.0,
865
- "resampling_weight": 1.0,
866
- "buffer": 0.26180,
867
- }
868
- ),
869
- )
870
- )
871
- if boltz2 and (use_fk or use_contacts):
872
- potentials.extend(
873
- (
874
- ContactPotentital(
875
- {
876
- "guidance_interval": 4,
877
- "guidance_weight": PiecewiseStepFunction(
878
- [0.25, 0.75],
879
- [0.0, 0.5, 1.0],
880
- )
881
- if use_contacts
882
- else 0.0,
883
- "resampling_weight": 1.0,
884
- "union_lambda": ExponentialInterpolation(8.0, 0.0, -2.0),
885
- }
886
- ),
887
- TemplateReferencePotential(
888
- {
889
- "guidance_interval": 2,
890
- "guidance_weight": 0.1 if use_contacts else 0.0,
891
- "resampling_weight": 1.0,
892
- }
893
- ),
894
- )
895
- )
896
- return potentials
 
1
+ """Differentiable structure-steering potentials for Boltz2 diffusion.
2
+
3
+ Each potential follows the same mechanism: derive constraint indices and
4
+ bounds from features, evaluate a geometric variable, map that variable to an
5
+ energy, and optionally scatter its analytic derivative back to atom
6
+ coordinates. This module is maintained independently from the pinned parity
7
+ oracle while preserving the public class names used by converted checkpoints.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import torch
13
+
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from . import vb_const as const
19
+ from .vb_loss_diffusionv2 import weighted_rigid_align
20
+ from .vb_potentials_schedules import (
21
+ ExponentialInterpolation,
22
+ ParameterSchedule,
23
+ PiecewiseStepFunction,
24
+ )
25
+
26
+
27
+ Parameter = ParameterSchedule | float | int | bool
28
+ ParameterMap = dict[str, Parameter]
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _PreparedPotential:
33
+ coordinates: torch.Tensor # (..., a, 3)
34
+ index: torch.Tensor # (q, n)
35
+ function_args: tuple[Any, ...]
36
+ com_index: torch.Tensor | None # (a_input,)
37
+ atom_pad_mask: torch.Tensor | None # (a_input,)
38
+ ref_coords: torch.Tensor | None # (n_ref, n, 3)
39
+ ref_mask: torch.Tensor | None # (n_ref, n)
40
+ ref_token_index: torch.Tensor | None # (a_input,)
41
+ negation_mask: torch.Tensor | None # (n,)
42
+ union_index: torch.Tensor | None # (n,)
43
+
44
+
45
+ def _reduce_atoms_to_centers(
46
+ coordinates: torch.Tensor,
47
+ com_index: torch.Tensor,
48
+ atom_pad_mask: torch.Tensor,
49
+ ) -> torch.Tensor:
50
+ # coordinates: (..., a, 3); com_index/atom_pad_mask: (a,).
51
+ unpadded_index = com_index[atom_pad_mask] # (a_valid,)
52
+ unpadded_coordinates = coordinates[..., atom_pad_mask, :] # (..., a_valid, 3)
53
+ center_shape = (*unpadded_coordinates.shape[:-2], unpadded_index.max() + 1, 3)
54
+ return torch.zeros(center_shape, device=coordinates.device).scatter_reduce(
55
+ -2,
56
+ unpadded_index.unsqueeze(-1).expand_as(unpadded_coordinates),
57
+ unpadded_coordinates,
58
+ "mean",
59
+ ) # (..., n_center, 3)
60
+
61
+
62
+ def _union_weights(
63
+ energy: torch.Tensor,
64
+ union_index: torch.Tensor,
65
+ union_lambda: float,
66
+ ) -> torch.Tensor:
67
+ # energy: (..., n); union_index: (n,).
68
+ unnormalized = torch.exp(-union_lambda * energy) # (..., n)
69
+ partition = torch.zeros(
70
+ (*energy.shape[:-1], union_index.max() + 1),
71
+ device=union_index.device,
72
+ ).scatter_reduce(
73
+ -1,
74
+ union_index.expand_as(unnormalized),
75
+ unnormalized,
76
+ "sum",
77
+ ) # (..., n_union)
78
+ weights = unnormalized / partition[..., union_index] # (..., n)
79
+ weights[partition[..., union_index] == 0] = 0 # (..., n)
80
+ return weights # (..., n)
81
+
82
+
83
+ def _scatter_constraint_gradients(
84
+ coefficients: torch.Tensor,
85
+ variable_gradient: torch.Tensor,
86
+ index: torch.Tensor,
87
+ coordinates: torch.Tensor,
88
+ ) -> torch.Tensor:
89
+ # coefficients: (..., n); variable_gradient: (..., q, n, 3).
90
+ # index: (q, n); coordinates: (b, a, 3).
91
+ product = coefficients.tile(variable_gradient.shape[-3]).unsqueeze(
92
+ -1
93
+ ) * variable_gradient.flatten(start_dim=-3, end_dim=-2) # (..., q * n, 3)
94
+ if product.dim() > 3:
95
+ product = product.sum(dim=list(range(1, product.dim() - 2))) # (b, q * n, 3)
96
+ scatter_index = (
97
+ index.flatten(start_dim=0, end_dim=1).unsqueeze(-1).expand((*coordinates.shape[:-2], -1, 3))
98
+ ) # (b, q * n, 3)
99
+ return torch.zeros_like(coordinates).scatter_reduce(
100
+ -2,
101
+ scatter_index,
102
+ product,
103
+ "sum",
104
+ ) # (b, a, 3)
105
+
106
+
107
+ class Potential(ABC):
108
+ """Base contract for energy and analytic-gradient steering potentials."""
109
+
110
+ def __init__(self, parameters: ParameterMap | None = None) -> None:
111
+ self.parameters = parameters
112
+
113
+ def _prepare(
114
+ self,
115
+ coordinates: torch.Tensor,
116
+ feats: dict[str, torch.Tensor],
117
+ parameters: dict[str, Any],
118
+ computed_args: tuple[Any, ...] | None = None,
119
+ ) -> _PreparedPotential:
120
+ # coordinates: (..., a_input, 3).
121
+ if computed_args is None:
122
+ computed_args = self.compute_args(feats, parameters)
123
+ index, args, com_args, ref_args, operator_args = computed_args # index: (q, n)
124
+ com_index = atom_pad_mask = None
125
+ if com_args is not None:
126
+ com_index, atom_pad_mask = com_args # each (a_input,)
127
+ coordinates = _reduce_atoms_to_centers(
128
+ coordinates,
129
+ com_index,
130
+ atom_pad_mask,
131
+ ) # (..., n_center, 3)
132
+
133
+ ref_coords = ref_mask = ref_token_index = None
134
+ if ref_args is not None:
135
+ # ref_coords: (n_ref, n, 3); ref_mask: (n_ref, n).
136
+ # ref_atom_index: (n,); ref_token_index: (a_input,).
137
+ ref_coords, ref_mask, ref_atom_index, ref_token_index = ref_args
138
+ coordinates = coordinates[..., ref_atom_index, :] # (..., n, 3)
139
+
140
+ negation_mask = union_index = None
141
+ if operator_args is not None:
142
+ negation_mask, union_index = operator_args # each (n,)
143
+ return _PreparedPotential(
144
+ coordinates=coordinates,
145
+ index=index,
146
+ function_args=args,
147
+ com_index=com_index,
148
+ atom_pad_mask=atom_pad_mask,
149
+ ref_coords=ref_coords,
150
+ ref_mask=ref_mask,
151
+ ref_token_index=ref_token_index,
152
+ negation_mask=negation_mask,
153
+ union_index=union_index,
154
+ )
155
+
156
+ def compute(
157
+ self,
158
+ coords: torch.Tensor,
159
+ feats: dict[str, torch.Tensor],
160
+ parameters: dict[str, Any],
161
+ ) -> torch.Tensor:
162
+ """Evaluate one energy per coordinate sample."""
163
+
164
+ # coords: (..., a, 3).
165
+ computed_args = self.compute_args(feats, parameters)
166
+ if computed_args[0].shape[1] == 0:
167
+ return torch.zeros(coords.shape[:-2], device=coords.device) # (...)
168
+ prepared = self._prepare(coords, feats, parameters, computed_args)
169
+ value = self.compute_variable(
170
+ prepared.coordinates,
171
+ prepared.index,
172
+ ref_coords=prepared.ref_coords,
173
+ ref_mask=prepared.ref_mask,
174
+ compute_gradient=False,
175
+ ) # (..., n)
176
+ energy = self.compute_function(
177
+ value,
178
+ *prepared.function_args,
179
+ negation_mask=prepared.negation_mask,
180
+ compute_derivative=False,
181
+ ) # (..., n)
182
+ if prepared.union_index is not None:
183
+ weights = _union_weights(
184
+ energy,
185
+ prepared.union_index,
186
+ parameters["union_lambda"],
187
+ ) # (..., n)
188
+ return (energy * weights).sum(dim=-1) # (...)
189
+ return energy.sum(dim=tuple(range(1, energy.dim()))) # (b,)
190
+
191
+ def compute_gradient(
192
+ self,
193
+ coords: torch.Tensor,
194
+ feats: dict[str, torch.Tensor],
195
+ parameters: dict[str, Any],
196
+ ) -> torch.Tensor:
197
+ """Return the analytic coordinate gradient of the potential energy."""
198
+
199
+ # coords: (b, a, 3).
200
+ computed_args = self.compute_args(feats, parameters)
201
+ if computed_args[0].shape[1] == 0:
202
+ return torch.zeros_like(coords) # (b, a, 3)
203
+ prepared = self._prepare(coords, feats, parameters, computed_args)
204
+ value, variable_gradient = self.compute_variable(
205
+ prepared.coordinates,
206
+ prepared.index,
207
+ ref_coords=prepared.ref_coords,
208
+ ref_mask=prepared.ref_mask,
209
+ compute_gradient=True,
210
+ ) # value: (..., n); variable_gradient: (..., q, n, 3)
211
+ energy, energy_derivative = self.compute_function(
212
+ value,
213
+ *prepared.function_args,
214
+ negation_mask=prepared.negation_mask,
215
+ compute_derivative=True,
216
+ ) # each (..., n)
217
+ if prepared.union_index is not None:
218
+ weights = _union_weights(
219
+ energy,
220
+ prepared.union_index,
221
+ parameters["union_lambda"],
222
+ ) # (..., n)
223
+ union_energy = torch.zeros(
224
+ (*energy.shape[:-1], prepared.union_index.max() + 1),
225
+ device=prepared.union_index.device,
226
+ ).scatter_reduce(
227
+ -1,
228
+ prepared.union_index.expand_as(energy),
229
+ energy * weights,
230
+ "sum",
231
+ ) # (..., n_union)
232
+ coefficients = (
233
+ energy_derivative
234
+ * weights
235
+ * (
236
+ 1
237
+ + parameters["union_lambda"]
238
+ * (energy - union_energy[..., prepared.union_index])
239
+ )
240
+ ) # (..., n)
241
+ else:
242
+ coefficients = energy_derivative # (..., n)
243
+
244
+ atom_gradient = _scatter_constraint_gradients(
245
+ coefficients,
246
+ variable_gradient,
247
+ prepared.index,
248
+ prepared.coordinates,
249
+ ) # (b, a_prepared, 3)
250
+ if prepared.com_index is not None:
251
+ atom_gradient = atom_gradient[..., prepared.com_index, :] # (b, a, 3)
252
+ elif prepared.ref_token_index is not None:
253
+ atom_gradient = atom_gradient[..., prepared.ref_token_index, :] # (b, a, 3)
254
+ return atom_gradient # (b, a, 3)
255
+
256
+ def compute_parameters(self, t: float) -> dict[str, Any] | None:
257
+ """Resolve scheduled parameters at diffusion time ``t``."""
258
+
259
+ if self.parameters is None:
260
+ return None
261
+ return {
262
+ name: parameter.compute(t) if isinstance(parameter, ParameterSchedule) else parameter
263
+ for name, parameter in self.parameters.items()
264
+ }
265
+
266
+ @abstractmethod
267
+ def compute_function(
268
+ self,
269
+ value: torch.Tensor,
270
+ *args: Any,
271
+ negation_mask: torch.Tensor | None = None,
272
+ compute_derivative: bool = False,
273
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
274
+ # value: (..., n); negation_mask: (n,).
275
+ raise NotImplementedError
276
+
277
+ @abstractmethod
278
+ def compute_variable(
279
+ self,
280
+ coords: torch.Tensor,
281
+ index: torch.Tensor,
282
+ ref_coords: torch.Tensor | None = None,
283
+ ref_mask: torch.Tensor | None = None,
284
+ compute_gradient: bool = False,
285
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
286
+ # coords: (..., a, 3); index: (q, n).
287
+ raise NotImplementedError
288
+
289
+ @abstractmethod
290
+ def compute_args(
291
+ self,
292
+ feats: dict[str, torch.Tensor],
293
+ parameters: dict[str, Any],
294
+ ) -> tuple[Any, ...]:
295
+ raise NotImplementedError
296
+
297
+ def get_reference_coords(
298
+ self,
299
+ feats: dict[str, torch.Tensor],
300
+ parameters: dict[str, Any],
301
+ ) -> tuple[None, None]:
302
+ del feats, parameters
303
+ return None, None
304
+
305
+
306
+ class FlatBottomPotential(Potential):
307
+ """Linear penalty outside an allowed lower-to-upper interval."""
308
+
309
+ def compute_function(
310
+ self,
311
+ value: torch.Tensor,
312
+ k: torch.Tensor,
313
+ lower_bounds: torch.Tensor | None,
314
+ upper_bounds: torch.Tensor | None,
315
+ negation_mask: torch.Tensor | None = None,
316
+ compute_derivative: bool = False,
317
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
318
+ # value: (..., n); k/lower_bounds/upper_bounds: (n,) or (..., n).
319
+ # negation_mask: (n,) or a shape broadcastable to (..., n).
320
+ lower = (
321
+ (torch.full_like(value, float("-inf")) if lower_bounds is None else lower_bounds)
322
+ .expand_as(value)
323
+ .clone()
324
+ ) # (..., n)
325
+ upper = (
326
+ (torch.full_like(value, float("inf")) if upper_bounds is None else upper_bounds)
327
+ .expand_as(value)
328
+ .clone()
329
+ ) # (..., n)
330
+
331
+ if negation_mask is not None:
332
+ if not torch.is_tensor(negation_mask):
333
+ raise TypeError(
334
+ f"negation_mask must be a boolean tensor, got {type(negation_mask).__name__}."
335
+ )
336
+ if negation_mask.dtype != torch.bool:
337
+ raise TypeError(
338
+ f"negation_mask must be a boolean tensor, got {negation_mask.dtype}."
339
+ )
340
+ if negation_mask.device != value.device:
341
+ raise ValueError(
342
+ "negation_mask must be on the same device as value; "
343
+ f"got {negation_mask.device} and {value.device}."
344
+ )
345
+ try:
346
+ expanded_negation_mask = negation_mask.expand_as(value) # (..., n)
347
+ except RuntimeError as error:
348
+ raise ValueError(
349
+ "negation_mask must be broadcastable to value shape "
350
+ f"{tuple(value.shape)}, got {tuple(negation_mask.shape)}."
351
+ ) from error
352
+ unbounded_below = torch.isneginf(lower) # (..., n)
353
+ unbounded_above = torch.isposinf(upper) # (..., n)
354
+ # (..., n)
355
+ valid_negation = unbounded_below | unbounded_above | expanded_negation_mask
356
+ if not bool(torch.all(valid_negation).item()):
357
+ raise ValueError(
358
+ "negation_mask may be false only where at least one bound is infinite."
359
+ )
360
+ select_upper = ~unbounded_above & ~expanded_negation_mask # (..., n)
361
+ lower[select_upper] = upper[select_upper] # (..., n)
362
+ upper[select_upper] = float("inf") # (..., n)
363
+ select_lower = ~unbounded_below & ~expanded_negation_mask # (..., n)
364
+ upper[select_lower] = lower[select_lower] # (..., n)
365
+ lower[select_lower] = float("-inf") # (..., n)
366
+
367
+ below = value < lower # (..., n)
368
+ above = value > upper # (..., n)
369
+ energy = torch.zeros_like(value) # (..., n)
370
+ energy[below] = (k * (lower - value))[below] # (..., n)
371
+ energy[above] = (k * (value - upper))[above] # (..., n)
372
+ if not compute_derivative:
373
+ return energy # (..., n)
374
+ derivative = torch.zeros_like(value) # (..., n)
375
+ derivative[below] = -k.expand_as(below)[below] # (..., n)
376
+ derivative[above] = k.expand_as(above)[above] # (..., n)
377
+ return energy, derivative # each (..., n)
378
+
379
+
380
+ class ReferencePotential(Potential):
381
+ """Measure atom displacement after weighted rigid alignment."""
382
+
383
+ def compute_variable(
384
+ self,
385
+ coords: torch.Tensor,
386
+ index: torch.Tensor,
387
+ ref_coords: torch.Tensor,
388
+ ref_mask: torch.Tensor,
389
+ compute_gradient: bool = False,
390
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
391
+ # coords: (b, a, 3); index: (1, n).
392
+ # ref_coords: (n_ref, n, 3); ref_mask: (n_ref, n).
393
+ aligned_reference = weighted_rigid_align(
394
+ ref_coords.float(),
395
+ coords[:, index].float(),
396
+ ref_mask,
397
+ ref_mask,
398
+ ) # (b, n_ref, n, 3)
399
+ displacement = coords[:, index] - aligned_reference # (b, n_ref, n, 3)
400
+ distance = torch.linalg.norm(displacement, dim=-1) # (b, n_ref, n)
401
+ if not compute_gradient:
402
+ return distance # (b, n_ref, n)
403
+ unit_displacement = displacement / distance.unsqueeze(-1) # (b, n_ref, n, 3)
404
+ # (b, 1, n_ref, n, 3)
405
+ gradient = (unit_displacement * ref_mask.unsqueeze(-1)).unsqueeze(1)
406
+ return distance, gradient # (b, n_ref, n); (b, 1, n_ref, n, 3)
407
+
408
+
409
+ class DistancePotential(Potential):
410
+ """Measure Euclidean distances for indexed atom pairs."""
411
+
412
+ def compute_variable(
413
+ self,
414
+ coords: torch.Tensor,
415
+ index: torch.Tensor,
416
+ ref_coords: torch.Tensor | None = None,
417
+ ref_mask: torch.Tensor | None = None,
418
+ compute_gradient: bool = False,
419
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
420
+ # coords: (..., a, 3); index: (2, n).
421
+ del ref_coords, ref_mask
422
+ displacement = coords.index_select(-2, index[0]) - coords.index_select(
423
+ -2,
424
+ index[1],
425
+ ) # (..., n, 3)
426
+ distance = torch.linalg.norm(displacement, dim=-1) # (..., n)
427
+ if not compute_gradient:
428
+ return distance # (..., n)
429
+ unit_displacement = displacement / distance.unsqueeze(-1) # (..., n, 3)
430
+ # Returns (..., n); (..., 2, n, 3).
431
+ return distance, torch.stack((unit_displacement, -unit_displacement), dim=1)
432
+
433
+
434
+ class DihedralPotential(Potential):
435
+ """Measure signed torsion angles for indexed atom quartets."""
436
+
437
+ def compute_variable(
438
+ self,
439
+ coords: torch.Tensor,
440
+ index: torch.Tensor,
441
+ ref_coords: torch.Tensor | None = None,
442
+ ref_mask: torch.Tensor | None = None,
443
+ compute_gradient: bool = False,
444
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
445
+ # coords: (..., a, 3); index: (4, n).
446
+ del ref_coords, ref_mask
447
+ # Each displacement has shape (..., n, 3).
448
+ r_ij = coords.index_select(-2, index[0]) - coords.index_select(-2, index[1])
449
+ r_kj = coords.index_select(-2, index[2]) - coords.index_select(-2, index[1])
450
+ r_kl = coords.index_select(-2, index[2]) - coords.index_select(-2, index[3])
451
+ n_ijk = torch.cross(r_ij, r_kj, dim=-1) # (..., n, 3)
452
+ n_jkl = torch.cross(r_kj, r_kl, dim=-1) # (..., n, 3)
453
+ r_kj_norm = torch.linalg.norm(r_kj, dim=-1) # (..., n)
454
+ n_ijk_norm = torch.linalg.norm(n_ijk, dim=-1) # (..., n)
455
+ n_jkl_norm = torch.linalg.norm(n_jkl, dim=-1) # (..., n)
456
+
457
+ orientation = torch.sign(
458
+ r_kj.unsqueeze(-2) @ torch.cross(n_ijk, n_jkl, dim=-1).unsqueeze(-1)
459
+ ).squeeze(-1, -2) # (..., n)
460
+ cosine = (n_ijk.unsqueeze(-2) @ n_jkl.unsqueeze(-1)).squeeze(-1, -2) / (
461
+ n_ijk_norm * n_jkl_norm
462
+ ) # (..., n)
463
+ # (..., n)
464
+ angle = orientation * torch.arccos(torch.clamp(cosine, -1 + 1e-8, 1 - 1e-8))
465
+ if not compute_gradient:
466
+ return angle # (..., n)
467
+
468
+ projection_i = (
469
+ (r_ij.unsqueeze(-2) @ r_kj.unsqueeze(-1)).squeeze(-1, -2) / (r_kj_norm**2)
470
+ ).unsqueeze(-1) # (..., n, 1)
471
+ projection_l = (
472
+ (r_kl.unsqueeze(-2) @ r_kj.unsqueeze(-1)).squeeze(-1, -2) / (r_kj_norm**2)
473
+ ).unsqueeze(-1) # (..., n, 1)
474
+ grad_i = n_ijk * (r_kj_norm / n_ijk_norm**2).unsqueeze(-1) # (..., n, 3)
475
+ grad_l = -n_jkl * (r_kj_norm / n_jkl_norm**2).unsqueeze(-1) # (..., n, 3)
476
+ grad_j = (projection_i - 1) * grad_i - projection_l * grad_l # (..., n, 3)
477
+ grad_k = (projection_l - 1) * grad_l - projection_i * grad_i # (..., n, 3)
478
+ # Returns (..., n); (..., 4, n, 3).
479
+ return angle, torch.stack((grad_i, grad_j, grad_k, grad_l), dim=1)
480
+
481
+
482
+ class AbsDihedralPotential(DihedralPotential):
483
+ """Measure the unsigned magnitude of indexed torsion angles."""
484
+
485
+ def compute_variable(
486
+ self,
487
+ coords: torch.Tensor,
488
+ index: torch.Tensor,
489
+ ref_coords: torch.Tensor | None = None,
490
+ ref_mask: torch.Tensor | None = None,
491
+ compute_gradient: bool = False,
492
+ ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
493
+ # coords: (..., a, 3); index: (4, n).
494
+ del ref_coords, ref_mask
495
+ if not compute_gradient:
496
+ return torch.abs(super().compute_variable(coords, index)) # (..., n)
497
+ angle, gradient = super().compute_variable(
498
+ coords,
499
+ index,
500
+ compute_gradient=True,
501
+ ) # (..., n); (..., 4, n, 3)
502
+ gradient[(angle < 0)[..., None, :, None].expand_as(gradient)] *= -1 # same
503
+ return torch.abs(angle), gradient # (..., n); (..., 4, n, 3)
504
+
505
+
506
+ def _element_radii(feats: dict[str, torch.Tensor]) -> torch.Tensor:
507
+ # feats["ref_element"]: (1, a, n_element).
508
+ element_radii = torch.zeros(
509
+ const.num_elements,
510
+ dtype=torch.float32,
511
+ device=feats["ref_element"].device,
512
+ ) # (n_element,)
513
+ element_radii[1:119] = torch.tensor(
514
+ const.vdw_radii,
515
+ dtype=torch.float32,
516
+ device=element_radii.device,
517
+ ) # (n_element,)
518
+ # (a,)
519
+ return (feats["ref_element"].float() @ element_radii.unsqueeze(-1)).squeeze(-1)[0]
520
+
521
+
522
+ def _atom_chain_ids(feats: dict[str, torch.Tensor]) -> torch.Tensor:
523
+ # atom_to_token: (1, a, l); asym_id: (1, l).
524
+ return (
525
+ torch.bmm(
526
+ feats["atom_to_token"].float(),
527
+ feats["asym_id"].unsqueeze(-1).float(),
528
+ )
529
+ .squeeze(-1)
530
+ .long()
531
+ )[0] # (a,)
532
+
533
+
534
+ class PoseBustersPotential(FlatBottomPotential, DistancePotential):
535
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
536
+ # rdkit_bounds_index: (1, 2, n); bound and mask features: (1, n).
537
+ pair_index = feats["rdkit_bounds_index"][0] # (2, n)
538
+ lower = feats["rdkit_lower_bounds"][0].clone() # (n,)
539
+ upper = feats["rdkit_upper_bounds"][0].clone() # (n,)
540
+ bond = feats["rdkit_bounds_bond_mask"][0] # (n,)
541
+ angle = feats["rdkit_bounds_angle_mask"][0] # (n,)
542
+ lower[bond * ~angle] *= 1.0 - parameters["bond_buffer"] # (n,)
543
+ upper[bond * ~angle] *= 1.0 + parameters["bond_buffer"] # (n,)
544
+ lower[~bond * angle] *= 1.0 - parameters["angle_buffer"] # (n,)
545
+ upper[~bond * angle] *= 1.0 + parameters["angle_buffer"] # (n,)
546
+ shared_buffer = min(parameters["bond_buffer"], parameters["angle_buffer"])
547
+ lower[bond * angle] *= 1.0 - shared_buffer # (n,)
548
+ upper[bond * angle] *= 1.0 + shared_buffer # (n,)
549
+ lower[~bond * ~angle] *= 1.0 - parameters["clash_buffer"] # (n,)
550
+ upper[~bond * ~angle] = float("inf") # (n,)
551
+
552
+ atom_radii = _element_radii(feats) # (a,)
553
+ bond_cutoff = 0.35 + atom_radii[pair_index].mean(dim=0) # (n,)
554
+ lower[~bond] = torch.max(lower[~bond], bond_cutoff[~bond]) # (n,)
555
+ upper[bond] = torch.min(upper[bond], bond_cutoff[bond]) # (n,)
556
+ # Returns index (2, n) and three function arguments of shape (n,).
557
+ return pair_index, (torch.ones_like(lower), lower, upper), None, None, None
558
+
559
+
560
+ class ConnectionsPotential(FlatBottomPotential, DistancePotential):
561
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
562
+ # connected_atom_index: (1, 2, n).
563
+ pair_index = feats["connected_atom_index"][0] # (2, n)
564
+ upper = torch.full(
565
+ (pair_index.shape[1],),
566
+ parameters["buffer"],
567
+ device=pair_index.device,
568
+ ) # (n,)
569
+ # Returns index (2, n), k (n,), and upper bound (n,).
570
+ return pair_index, (torch.ones_like(upper), None, upper), None, None, None
571
+
572
+
573
+ class VDWOverlapPotential(FlatBottomPotential, DistancePotential):
574
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
575
+ atom_chain_id = _atom_chain_ids(feats) # (a,)
576
+ atom_pad_mask = feats["atom_pad_mask"][0].bool() # (a,)
577
+ chain_sizes = torch.bincount(atom_chain_id[atom_pad_mask]) # (n_chain,)
578
+ nonion_atom = (chain_sizes > 1)[atom_chain_id] # (a,)
579
+ atom_radii = _element_radii(feats) # (a,)
580
+ pair_index = torch.triu_indices(
581
+ atom_chain_id.shape[0],
582
+ atom_chain_id.shape[0],
583
+ 1,
584
+ device=atom_chain_id.device,
585
+ ) # (2, n_pair)
586
+ pair_pad_mask = atom_pad_mask[pair_index].all(dim=0) # (n_pair,)
587
+ # (n_pair,)
588
+ pair_ion_mask = nonion_atom[pair_index[0]] * nonion_atom[pair_index[1]]
589
+
590
+ num_chains = atom_chain_id.max() + 1 # ()
591
+ connected = feats["connected_chain_index"][0] # (2, n_connection)
592
+ connected_matrix = torch.eye(
593
+ num_chains,
594
+ device=atom_chain_id.device,
595
+ dtype=torch.bool,
596
+ ) # (n_chain, n_chain)
597
+ connected_matrix[connected[0], connected[1]] = True # (n_chain, n_chain)
598
+ connected_matrix[connected[1], connected[0]] = True # (n_chain, n_chain)
599
+ connected_pair = connected_matrix[
600
+ atom_chain_id[pair_index[0]],
601
+ atom_chain_id[pair_index[1]],
602
+ ] # (n_pair,)
603
+ # (2, n)
604
+ pair_index = pair_index[:, pair_pad_mask * pair_ion_mask * ~connected_pair]
605
+ # (n,)
606
+ lower = atom_radii[pair_index].sum(dim=0) * (1.0 - parameters["buffer"])
607
+ # Returns index (2, n), k (n,), and lower bound (n,).
608
+ return pair_index, (torch.ones_like(lower), lower, None), None, None, None
609
+
610
+
611
+ class SymmetricChainCOMPotential(FlatBottomPotential, DistancePotential):
612
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
613
+ atom_chain_id = _atom_chain_ids(feats) # (a,)
614
+ atom_pad_mask = feats["atom_pad_mask"][0].bool() # (a,)
615
+ nonion_chain = torch.bincount(atom_chain_id[atom_pad_mask]) > 1 # (n_chain,)
616
+ pair_index = feats["symmetric_chain_index"][0] # (2, n_candidate)
617
+ pair_index = pair_index[
618
+ :,
619
+ nonion_chain[pair_index[0]] * nonion_chain[pair_index[1]],
620
+ ] # (2, n)
621
+ lower = torch.full(
622
+ (pair_index.shape[1],),
623
+ parameters["buffer"],
624
+ dtype=torch.float32,
625
+ device=pair_index.device,
626
+ ) # (n,)
627
+ # Returns center index (2, n), k/lower (n,), and atom-to-center maps (a,).
628
+ return (
629
+ pair_index,
630
+ (torch.ones_like(lower), lower, None),
631
+ (atom_chain_id, atom_pad_mask),
632
+ None,
633
+ None,
634
+ )
635
+
636
+
637
+ def _oriented_bounds(
638
+ orientations: torch.Tensor,
639
+ positive_lower: float,
640
+ negative_upper: float,
641
+ ) -> tuple[torch.Tensor, torch.Tensor]:
642
+ # orientations: (n,).
643
+ lower = torch.zeros(orientations.shape, device=orientations.device) # (n,)
644
+ upper = torch.zeros(orientations.shape, device=orientations.device) # (n,)
645
+ lower[orientations] = positive_lower # (n,)
646
+ upper[orientations] = float("inf") # (n,)
647
+ lower[~orientations] = float("-inf") # (n,)
648
+ upper[~orientations] = negative_upper # (n,)
649
+ return lower, upper # each (n,)
650
+
651
+
652
+ class StereoBondPotential(FlatBottomPotential, AbsDihedralPotential):
653
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
654
+ # stereo_bond_index: (1, 4, n); orientations: (1, n).
655
+ index = feats["stereo_bond_index"][0] # (4, n)
656
+ orientation = feats["stereo_bond_orientations"][0].bool() # (n,)
657
+ lower, upper = _oriented_bounds(
658
+ orientation,
659
+ torch.pi - parameters["buffer"],
660
+ parameters["buffer"],
661
+ ) # each (n,)
662
+ # Returns index (4, n) and three function arguments of shape (n,).
663
+ return index, (torch.ones_like(lower), lower, upper), None, None, None
664
+
665
+
666
+ class ChiralAtomPotential(FlatBottomPotential, DihedralPotential):
667
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
668
+ # chiral_atom_index: (1, 4, n); orientations: (1, n).
669
+ index = feats["chiral_atom_index"][0] # (4, n)
670
+ orientation = feats["chiral_atom_orientations"][0].bool() # (n,)
671
+ lower, upper = _oriented_bounds(
672
+ orientation,
673
+ parameters["buffer"],
674
+ -parameters["buffer"],
675
+ ) # each (n,)
676
+ # Returns index (4, n) and three function arguments of shape (n,).
677
+ return index, (torch.ones_like(lower), lower, upper), None, None, None
678
+
679
+
680
+ class PlanarBondPotential(FlatBottomPotential, AbsDihedralPotential):
681
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
682
+ # The feature stores two atom-index rows for each six-entry bond pattern.
683
+ bond_index = feats["planar_bond_index"][0].T # (2, n_bond_entry)
684
+ improper_pattern = torch.tensor(
685
+ [[1, 2, 3, 0], [4, 5, 0, 3]],
686
+ device=bond_index.device,
687
+ ).T # (4, 2)
688
+ # (4, n_improper)
689
+ improper_index = bond_index[:, improper_pattern].swapaxes(0, 1).flatten(start_dim=1)
690
+ upper = torch.full(
691
+ (improper_index.shape[1],),
692
+ parameters["buffer"],
693
+ device=improper_index.device,
694
+ ) # (n_improper,)
695
+ # Returns index (4, n_improper), k (n_improper,), and upper (n_improper,).
696
+ return (
697
+ improper_index,
698
+ (torch.ones_like(upper), None, upper),
699
+ None,
700
+ None,
701
+ None,
702
+ )
703
+
704
+
705
+ class TemplateReferencePotential(FlatBottomPotential, ReferencePotential):
706
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
707
+ del parameters
708
+ if "template_mask_cb" not in feats or "template_force" not in feats:
709
+ # Empty sentinel index: (1, 0).
710
+ return torch.empty((1, 0)), None, None, None, None
711
+ # template_mask_cb/template_force: (b, n_template, n)/(b, n_template).
712
+ template_mask = feats["template_mask_cb"][feats["template_force"]] # (n_ref, n)
713
+ if template_mask.shape[0] == 0:
714
+ # Empty sentinel index: (1, 0).
715
+ return torch.empty((1, 0)), None, None, None, None
716
+
717
+ ref_coords = feats["template_cb"][feats["template_force"]].clone() # (n_ref, n, 3)
718
+ ref_mask = feats["template_mask_cb"][feats["template_force"]].clone() # (n_ref, n)
719
+ atom_indices = torch.arange(
720
+ feats["atom_pad_mask"].shape[1],
721
+ device=feats["atom_pad_mask"].device,
722
+ dtype=torch.float32,
723
+ )[None, :, None] # (1, a, 1)
724
+ ref_atom_index = (
725
+ torch.bmm(
726
+ feats["token_to_rep_atom"].float(),
727
+ atom_indices,
728
+ )
729
+ .squeeze(-1)
730
+ .long()[0] # (n,)
731
+ )
732
+ ref_token_index = (
733
+ torch.bmm(
734
+ feats["atom_to_token"].float(),
735
+ feats["token_index"].unsqueeze(-1).float(),
736
+ )
737
+ .squeeze(-1)
738
+ .long()[0] # (a,)
739
+ )
740
+
741
+ index = torch.arange(
742
+ template_mask.shape[-1],
743
+ dtype=torch.long,
744
+ device=template_mask.device,
745
+ )[None] # (1, n)
746
+ upper = torch.full(
747
+ template_mask.shape,
748
+ float("inf"),
749
+ device=index.device,
750
+ dtype=torch.float32,
751
+ ) # (n_ref, n)
752
+ reference_indices = torch.argwhere(template_mask).T # (2, n_active)
753
+ upper[reference_indices.unbind()] = feats["template_force_threshold"][
754
+ feats["template_force"]
755
+ ][reference_indices[0]] # (n_ref, n)
756
+ # Returns index (1, n), function args (n_ref, n), and reference mappings.
757
+ return (
758
+ index,
759
+ (torch.ones_like(upper), None, upper),
760
+ None,
761
+ (ref_coords, ref_mask, ref_atom_index, ref_token_index),
762
+ None,
763
+ )
764
+
765
+
766
+ class ContactPotentital(FlatBottomPotential, DistancePotential):
767
+ """Contact-union potential retaining the historical checkpoint name."""
768
+
769
+ def compute_args(self, feats: dict[str, torch.Tensor], parameters: dict[str, Any]) -> tuple[Any, ...]:
770
+ del parameters
771
+ # pair index: (1, 2, n); threshold/operator features: (1, n).
772
+ index = feats["contact_pair_index"][0] # (2, n)
773
+ upper = feats["contact_thresholds"][0].clone() # (n,)
774
+ # Returns index (2, n), k/upper (n,), and two operator tensors (n,).
775
+ return (
776
+ index,
777
+ (torch.ones_like(upper), None, upper),
778
+ None,
779
+ None,
780
+ (
781
+ feats["contact_negation_mask"][0],
782
+ feats["contact_union_index"][0],
783
+ ),
784
+ )
785
+
786
+
787
+ ContactPotential = ContactPotentital
788
+
789
+
790
+ def get_potentials(
791
+ steering_args: dict[str, bool],
792
+ boltz2: bool = False,
793
+ ) -> list[Potential]:
794
+ """Build the ordered potential stack for requested steering modes."""
795
+
796
+ use_fk = steering_args["fk_steering"]
797
+ use_physical = steering_args["physical_guidance_update"]
798
+ use_contacts = steering_args.get("contact_guidance_update", False)
799
+ potentials: list[Potential] = []
800
+ if use_fk or use_physical:
801
+ potentials.extend(
802
+ (
803
+ SymmetricChainCOMPotential(
804
+ {
805
+ "guidance_interval": 4,
806
+ "guidance_weight": 0.5 if use_physical else 0.0,
807
+ "resampling_weight": 0.5,
808
+ "buffer": ExponentialInterpolation(1.0, 5.0, -2.0),
809
+ }
810
+ ),
811
+ VDWOverlapPotential(
812
+ {
813
+ "guidance_interval": 5,
814
+ "guidance_weight": PiecewiseStepFunction(
815
+ [0.4],
816
+ [0.125, 0.0],
817
+ )
818
+ if use_physical
819
+ else 0.0,
820
+ "resampling_weight": PiecewiseStepFunction(
821
+ [0.6],
822
+ [0.01, 0.0],
823
+ ),
824
+ "buffer": 0.225,
825
+ }
826
+ ),
827
+ ConnectionsPotential(
828
+ {
829
+ "guidance_interval": 1,
830
+ "guidance_weight": 0.15 if use_physical else 0.0,
831
+ "resampling_weight": 1.0,
832
+ "buffer": 2.0,
833
+ }
834
+ ),
835
+ PoseBustersPotential(
836
+ {
837
+ "guidance_interval": 1,
838
+ "guidance_weight": 0.01 if use_physical else 0.0,
839
+ "resampling_weight": 0.1,
840
+ "bond_buffer": 0.125,
841
+ "angle_buffer": 0.125,
842
+ "clash_buffer": 0.10,
843
+ }
844
+ ),
845
+ ChiralAtomPotential(
846
+ {
847
+ "guidance_interval": 1,
848
+ "guidance_weight": 0.1 if use_physical else 0.0,
849
+ "resampling_weight": 1.0,
850
+ "buffer": 0.52360,
851
+ }
852
+ ),
853
+ StereoBondPotential(
854
+ {
855
+ "guidance_interval": 1,
856
+ "guidance_weight": 0.05 if use_physical else 0.0,
857
+ "resampling_weight": 1.0,
858
+ "buffer": 0.52360,
859
+ }
860
+ ),
861
+ PlanarBondPotential(
862
+ {
863
+ "guidance_interval": 1,
864
+ "guidance_weight": 0.05 if use_physical else 0.0,
865
+ "resampling_weight": 1.0,
866
+ "buffer": 0.26180,
867
+ }
868
+ ),
869
+ )
870
+ )
871
+ if boltz2 and (use_fk or use_contacts):
872
+ potentials.extend(
873
+ (
874
+ ContactPotentital(
875
+ {
876
+ "guidance_interval": 4,
877
+ "guidance_weight": PiecewiseStepFunction(
878
+ [0.25, 0.75],
879
+ [0.0, 0.5, 1.0],
880
+ )
881
+ if use_contacts
882
+ else 0.0,
883
+ "resampling_weight": 1.0,
884
+ "union_lambda": ExponentialInterpolation(8.0, 0.0, -2.0),
885
+ }
886
+ ),
887
+ TemplateReferencePotential(
888
+ {
889
+ "guidance_interval": 2,
890
+ "guidance_weight": 0.1 if use_contacts else 0.0,
891
+ "resampling_weight": 1.0,
892
+ }
893
+ ),
894
+ )
895
+ )
896
+ return potentials
fastplms/models/boltz/vb_potentials_schedules.py CHANGED
@@ -1,68 +1,68 @@
1
- """Scalar schedules for structure-steering potentials."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
-
7
- from abc import ABC, abstractmethod
8
- from collections.abc import Sequence
9
-
10
-
11
- class ParameterSchedule(ABC):
12
- """Map normalized diffusion time ``t`` to a potential parameter."""
13
-
14
- @abstractmethod
15
- def compute(self, t: float) -> float:
16
- """Evaluate the schedule at ``t``."""
17
-
18
-
19
- class ExponentialInterpolation(ParameterSchedule):
20
- """Interpolate from ``start`` to ``end`` with exponential curvature."""
21
-
22
- def __init__(self, start: float, end: float, alpha: float) -> None:
23
- self.start = start
24
- self.end = end
25
- self.alpha = alpha
26
-
27
- def compute(self, t: float) -> float:
28
- span = self.end - self.start
29
- if self.alpha == 0:
30
- return self.start + span * t
31
- numerator = math.exp(self.alpha * t) - 1
32
- denominator = math.exp(self.alpha) - 1
33
- return self.start + span * numerator / denominator
34
-
35
-
36
- class PiecewiseStepFunction(ParameterSchedule):
37
- """Select values separated by strict upper thresholds.
38
-
39
- A time exactly equal to a threshold remains in the lower interval. This
40
- boundary convention is part of the steering-input contract.
41
- """
42
-
43
- def __init__(
44
- self,
45
- thresholds: Sequence[float],
46
- values: Sequence[float],
47
- ) -> None:
48
- self.thresholds = tuple(thresholds)
49
- self.values = tuple(values)
50
- if not self.thresholds:
51
- raise ValueError("PiecewiseStepFunction requires at least one threshold.")
52
- if len(self.values) != len(self.thresholds) + 1:
53
- raise ValueError(
54
- "PiecewiseStepFunction requires exactly one more value than threshold; "
55
- f"received {len(self.values)} values and {len(self.thresholds)} thresholds."
56
- )
57
- if any(
58
- current >= following
59
- for current, following in zip(self.thresholds, self.thresholds[1:], strict=False)
60
- ):
61
- raise ValueError("PiecewiseStepFunction thresholds must be strictly increasing.")
62
-
63
- def compute(self, t: float) -> float:
64
- interval = next(
65
- (index for index, threshold in enumerate(self.thresholds) if t <= threshold),
66
- len(self.thresholds),
67
- )
68
- return self.values[interval]
 
1
+ """Scalar schedules for structure-steering potentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Sequence
9
+
10
+
11
+ class ParameterSchedule(ABC):
12
+ """Map normalized diffusion time ``t`` to a potential parameter."""
13
+
14
+ @abstractmethod
15
+ def compute(self, t: float) -> float:
16
+ """Evaluate the schedule at ``t``."""
17
+
18
+
19
+ class ExponentialInterpolation(ParameterSchedule):
20
+ """Interpolate from ``start`` to ``end`` with exponential curvature."""
21
+
22
+ def __init__(self, start: float, end: float, alpha: float) -> None:
23
+ self.start = start
24
+ self.end = end
25
+ self.alpha = alpha
26
+
27
+ def compute(self, t: float) -> float:
28
+ span = self.end - self.start
29
+ if self.alpha == 0:
30
+ return self.start + span * t
31
+ numerator = math.exp(self.alpha * t) - 1
32
+ denominator = math.exp(self.alpha) - 1
33
+ return self.start + span * numerator / denominator
34
+
35
+
36
+ class PiecewiseStepFunction(ParameterSchedule):
37
+ """Select values separated by strict upper thresholds.
38
+
39
+ A time exactly equal to a threshold remains in the lower interval. This
40
+ boundary convention is part of the steering-input contract.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ thresholds: Sequence[float],
46
+ values: Sequence[float],
47
+ ) -> None:
48
+ self.thresholds = tuple(thresholds)
49
+ self.values = tuple(values)
50
+ if not self.thresholds:
51
+ raise ValueError("PiecewiseStepFunction requires at least one threshold.")
52
+ if len(self.values) != len(self.thresholds) + 1:
53
+ raise ValueError(
54
+ "PiecewiseStepFunction requires exactly one more value than threshold; "
55
+ f"received {len(self.values)} values and {len(self.thresholds)} thresholds."
56
+ )
57
+ if any(
58
+ current >= following
59
+ for current, following in zip(self.thresholds, self.thresholds[1:], strict=False)
60
+ ):
61
+ raise ValueError("PiecewiseStepFunction thresholds must be strictly increasing.")
62
+
63
+ def compute(self, t: float) -> float:
64
+ interval = next(
65
+ (index for index, threshold in enumerate(self.thresholds) if t <= threshold),
66
+ len(self.thresholds),
67
+ )
68
+ return self.values[interval]
fastplms/models/boltz/vb_tri_attn_attention.py CHANGED
@@ -1,151 +1,151 @@
1
- """Triangular row and column attention for Boltz2 pair states."""
2
-
3
- from __future__ import annotations
4
-
5
- import torch
6
-
7
- from typing import cast
8
- from torch import Tensor, nn
9
-
10
- from .vb_tri_attn_primitives import Attention, LayerNorm, Linear
11
- from .vb_tri_attn_utils import chunk_layer, permute_final_dims
12
-
13
-
14
- class TriangleAttention(nn.Module):
15
- """Apply pair-biased attention along one axis of pair tensor X."""
16
-
17
- def __init__(
18
- self,
19
- c_in: int,
20
- c_hidden: int,
21
- no_heads: int,
22
- starting: bool = True,
23
- inf: float = 1e9,
24
- ) -> None:
25
- super().__init__()
26
- self.c_in = c_in
27
- self.c_hidden = c_hidden
28
- self.no_heads = no_heads
29
- self.starting = starting
30
- self.inf = inf
31
- self.layer_norm = LayerNorm(c_in)
32
- self.linear = Linear(c_in, no_heads, bias=False, init="normal")
33
- self.mha = Attention(c_in, c_in, c_in, c_hidden, no_heads)
34
-
35
- @torch.jit.ignore # type: ignore[untyped-decorator]
36
- def _chunk(
37
- self,
38
- x: Tensor,
39
- tri_bias: Tensor,
40
- mask_bias: Tensor,
41
- mask: Tensor,
42
- chunk_size: int,
43
- use_kernels: bool = False,
44
- ) -> Tensor:
45
- """Evaluate attention in slices of the batch-like leading axes."""
46
-
47
- # x: (..., l, l, d); biases/mask retain broadcastable triangular axes.
48
- def attention_call(**inputs: Tensor) -> Tensor:
49
- return cast(Tensor, self.mha(**inputs, use_kernels=use_kernels))
50
-
51
- inputs = {
52
- "q_x": x,
53
- "kv_x": x,
54
- "tri_bias": tri_bias,
55
- "mask_bias": mask_bias,
56
- "mask": mask,
57
- } # tensor values retain the input shapes above
58
- return cast(
59
- Tensor,
60
- chunk_layer(
61
- attention_call,
62
- inputs,
63
- chunk_size=chunk_size,
64
- no_batch_dims=len(x.shape[:-2]),
65
- _out=None,
66
- ),
67
- )
68
-
69
- def _run_attention(
70
- self,
71
- states: Tensor,
72
- triangle_bias: Tensor,
73
- mask_bias: Tensor,
74
- expanded_mask: Tensor,
75
- chunk_size: int | None,
76
- use_kernels: bool,
77
- ) -> Tensor:
78
- # states: (..., l, l, d); returned tensor has the same shape.
79
- if chunk_size is not None and not use_kernels:
80
- return cast(
81
- Tensor,
82
- self._chunk(
83
- states,
84
- triangle_bias,
85
- mask_bias,
86
- expanded_mask,
87
- chunk_size,
88
- use_kernels=False,
89
- ),
90
- )
91
- return cast(
92
- Tensor,
93
- self.mha(
94
- states,
95
- states,
96
- triangle_bias,
97
- mask_bias,
98
- expanded_mask,
99
- use_kernels=use_kernels,
100
- ),
101
- )
102
-
103
- def forward(
104
- self,
105
- x: Tensor,
106
- mask: Tensor | None = None,
107
- chunk_size: int | None = None,
108
- use_kernels: bool = False,
109
- ) -> Tensor:
110
- """Transform X with shape ``(..., l, l, d)`` under mask M."""
111
-
112
- pair_mask = x.new_ones(x.shape[:-1]) if mask is None else mask # (..., l, l)
113
- pair_states = x # (..., l, l, d)
114
- if not self.starting:
115
- pair_states = pair_states.transpose(-2, -3) # (..., l, l, d)
116
- pair_mask = pair_mask.transpose(-1, -2) # (..., l, l)
117
-
118
- normalized = self.layer_norm(pair_states) # (..., l, l, d)
119
- expanded_mask = pair_mask[..., :, None, None, :] # (..., l, 1, 1, l)
120
- mask_bias = self.inf * (expanded_mask - 1) # (..., l, 1, 1, l)
121
- triangle_bias = permute_final_dims(
122
- self.linear(normalized), (2, 0, 1)
123
- ) # (..., h, l, l)
124
- triangle_bias = triangle_bias.unsqueeze(-4) # (..., 1, h, l, l)
125
- output = self._run_attention(
126
- normalized,
127
- triangle_bias,
128
- mask_bias,
129
- expanded_mask,
130
- chunk_size,
131
- use_kernels,
132
- ) # (..., l, l, d)
133
- return (
134
- output if self.starting else output.transpose(-2, -3)
135
- ) # (..., l, l, d)
136
-
137
-
138
- TriangleAttentionStartingNode = TriangleAttention
139
-
140
-
141
- class TriangleAttentionEndingNode(TriangleAttention):
142
- """Apply triangular attention around each pair's ending node."""
143
-
144
- def __init__(
145
- self,
146
- c_in: int,
147
- c_hidden: int,
148
- no_heads: int,
149
- inf: float = 1e9,
150
- ) -> None:
151
- super().__init__(c_in, c_hidden, no_heads, starting=False, inf=inf)
 
1
+ """Triangular row and column attention for Boltz2 pair states."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ from typing import cast
8
+ from torch import Tensor, nn
9
+
10
+ from .vb_tri_attn_primitives import Attention, LayerNorm, Linear
11
+ from .vb_tri_attn_utils import chunk_layer, permute_final_dims
12
+
13
+
14
+ class TriangleAttention(nn.Module):
15
+ """Apply pair-biased attention along one axis of pair tensor X."""
16
+
17
+ def __init__(
18
+ self,
19
+ c_in: int,
20
+ c_hidden: int,
21
+ no_heads: int,
22
+ starting: bool = True,
23
+ inf: float = 1e9,
24
+ ) -> None:
25
+ super().__init__()
26
+ self.c_in = c_in
27
+ self.c_hidden = c_hidden
28
+ self.no_heads = no_heads
29
+ self.starting = starting
30
+ self.inf = inf
31
+ self.layer_norm = LayerNorm(c_in)
32
+ self.linear = Linear(c_in, no_heads, bias=False, init="normal")
33
+ self.mha = Attention(c_in, c_in, c_in, c_hidden, no_heads)
34
+
35
+ @torch.jit.ignore # type: ignore[untyped-decorator]
36
+ def _chunk(
37
+ self,
38
+ x: Tensor,
39
+ tri_bias: Tensor,
40
+ mask_bias: Tensor,
41
+ mask: Tensor,
42
+ chunk_size: int,
43
+ use_kernels: bool = False,
44
+ ) -> Tensor:
45
+ """Evaluate attention in slices of the batch-like leading axes."""
46
+
47
+ # x: (..., l, l, d); biases/mask retain broadcastable triangular axes.
48
+ def attention_call(**inputs: Tensor) -> Tensor:
49
+ return cast(Tensor, self.mha(**inputs, use_kernels=use_kernels))
50
+
51
+ inputs = {
52
+ "q_x": x,
53
+ "kv_x": x,
54
+ "tri_bias": tri_bias,
55
+ "mask_bias": mask_bias,
56
+ "mask": mask,
57
+ } # tensor values retain the input shapes above
58
+ return cast(
59
+ Tensor,
60
+ chunk_layer(
61
+ attention_call,
62
+ inputs,
63
+ chunk_size=chunk_size,
64
+ no_batch_dims=len(x.shape[:-2]),
65
+ _out=None,
66
+ ),
67
+ )
68
+
69
+ def _run_attention(
70
+ self,
71
+ states: Tensor,
72
+ triangle_bias: Tensor,
73
+ mask_bias: Tensor,
74
+ expanded_mask: Tensor,
75
+ chunk_size: int | None,
76
+ use_kernels: bool,
77
+ ) -> Tensor:
78
+ # states: (..., l, l, d); returned tensor has the same shape.
79
+ if chunk_size is not None and not use_kernels:
80
+ return cast(
81
+ Tensor,
82
+ self._chunk(
83
+ states,
84
+ triangle_bias,
85
+ mask_bias,
86
+ expanded_mask,
87
+ chunk_size,
88
+ use_kernels=False,
89
+ ),
90
+ )
91
+ return cast(
92
+ Tensor,
93
+ self.mha(
94
+ states,
95
+ states,
96
+ triangle_bias,
97
+ mask_bias,
98
+ expanded_mask,
99
+ use_kernels=use_kernels,
100
+ ),
101
+ )
102
+
103
+ def forward(
104
+ self,
105
+ x: Tensor,
106
+ mask: Tensor | None = None,
107
+ chunk_size: int | None = None,
108
+ use_kernels: bool = False,
109
+ ) -> Tensor:
110
+ """Transform X with shape ``(..., l, l, d)`` under mask M."""
111
+
112
+ pair_mask = x.new_ones(x.shape[:-1]) if mask is None else mask # (..., l, l)
113
+ pair_states = x # (..., l, l, d)
114
+ if not self.starting:
115
+ pair_states = pair_states.transpose(-2, -3) # (..., l, l, d)
116
+ pair_mask = pair_mask.transpose(-1, -2) # (..., l, l)
117
+
118
+ normalized = self.layer_norm(pair_states) # (..., l, l, d)
119
+ expanded_mask = pair_mask[..., :, None, None, :] # (..., l, 1, 1, l)
120
+ mask_bias = self.inf * (expanded_mask - 1) # (..., l, 1, 1, l)
121
+ triangle_bias = permute_final_dims(
122
+ self.linear(normalized), (2, 0, 1)
123
+ ) # (..., h, l, l)
124
+ triangle_bias = triangle_bias.unsqueeze(-4) # (..., 1, h, l, l)
125
+ output = self._run_attention(
126
+ normalized,
127
+ triangle_bias,
128
+ mask_bias,
129
+ expanded_mask,
130
+ chunk_size,
131
+ use_kernels,
132
+ ) # (..., l, l, d)
133
+ return (
134
+ output if self.starting else output.transpose(-2, -3)
135
+ ) # (..., l, l, d)
136
+
137
+
138
+ TriangleAttentionStartingNode = TriangleAttention
139
+
140
+
141
+ class TriangleAttentionEndingNode(TriangleAttention):
142
+ """Apply triangular attention around each pair's ending node."""
143
+
144
+ def __init__(
145
+ self,
146
+ c_in: int,
147
+ c_hidden: int,
148
+ no_heads: int,
149
+ inf: float = 1e9,
150
+ ) -> None:
151
+ super().__init__(c_in, c_hidden, no_heads, starting=False, inf=inf)
fastplms/models/boltz/vb_tri_attn_primitives.py CHANGED
@@ -1,266 +1,266 @@
1
- """Attention primitives for the Boltz pair stack.
2
-
3
- The module retains the converted checkpoint names while providing a compact
4
- implementation around PyTorch operations. Optional triangle kernels are
5
- loaded only at the call site, so importing FastPLMs does not initialize CUDA.
6
-
7
- Portions of the numerical contract follow OpenFold and DeepMind AlphaFold
8
- under Apache-2.0. See ``THIRD_PARTY_NOTICES.md`` for provenance.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- import importlib
14
- import math
15
- import torch
16
-
17
- from collections.abc import Callable, Sequence
18
- from importlib.util import find_spec
19
- from torch import nn
20
-
21
- from . import vb_layers_initialize as initialize
22
- from .vb_tri_attn_utils import flatten_final_dims, permute_final_dims
23
-
24
-
25
- def _initialize_weight(
26
- weight: torch.Tensor,
27
- bias: torch.Tensor | None,
28
- method: str,
29
- ) -> None:
30
- # weight: (d_out, d_in); bias: (d_out,) or None; both retain shape in place.
31
- initializers = {
32
- "default": initialize.lecun_normal_init_,
33
- "relu": initialize.he_normal_init_,
34
- "glorot": initialize.glorot_uniform_init_,
35
- "normal": initialize.normal_init_,
36
- "final": initialize.final_init_,
37
- "gating": initialize.gating_init_,
38
- }
39
- try:
40
- initializers[method](weight)
41
- except KeyError as error:
42
- raise ValueError(f"unknown linear initialization {method!r}") from error
43
- if method == "gating" and bias is not None:
44
- bias.fill_(1.0)
45
-
46
-
47
- class Linear(nn.Linear):
48
- """Linear projection with the initializers used by converted checkpoints."""
49
-
50
- def __init__(
51
- self,
52
- in_dim: int,
53
- out_dim: int,
54
- bias: bool = True,
55
- init: str = "default",
56
- init_fn: Callable[[torch.Tensor, torch.Tensor | None], None] | None = None,
57
- precision: torch.dtype | None = None,
58
- ) -> None:
59
- super().__init__(in_dim, out_dim, bias=bias)
60
- with torch.no_grad():
61
- if self.bias is not None:
62
- self.bias.zero_()
63
- if init_fn is None:
64
- _initialize_weight(self.weight, self.bias, init)
65
- else:
66
- init_fn(self.weight, self.bias)
67
- self.precision = precision
68
-
69
- def forward(self, input: torch.Tensor) -> torch.Tensor:
70
- """Project ``input`` while preserving explicit precision policies."""
71
-
72
- # input: (..., d_in); every branch returns (..., d_out).
73
- input_dtype = input.dtype
74
- if self.precision is not None:
75
- with torch.autocast("cuda", enabled=False):
76
- bias = (
77
- None if self.bias is None else self.bias.to(self.precision)
78
- ) # (d_out,) or None
79
- projected = nn.functional.linear(
80
- input.to(self.precision),
81
- self.weight.to(self.precision),
82
- bias,
83
- ) # (..., d_out)
84
- return projected.to(input_dtype) # (..., d_out)
85
- if input_dtype is torch.bfloat16:
86
- with torch.autocast("cuda", enabled=False):
87
- bias = (
88
- None if self.bias is None else self.bias.to(input_dtype)
89
- ) # (d_out,) or None
90
- return nn.functional.linear(
91
- input, self.weight.to(input_dtype), bias
92
- ) # (..., d_out)
93
- return nn.functional.linear(input, self.weight, self.bias) # (..., d_out)
94
-
95
-
96
- class LayerNorm(nn.Module):
97
- """Layer normalization that avoids an autocast upcast for BF16 inputs."""
98
-
99
- def __init__(self, c_in: int, eps: float = 1e-5) -> None:
100
- super().__init__()
101
- self.c_in = (c_in,)
102
- self.eps = eps
103
- self.weight = nn.Parameter(torch.ones(c_in)) # (c_in,)
104
- self.bias = nn.Parameter(torch.zeros(c_in)) # (c_in,)
105
-
106
- def forward(self, x: torch.Tensor) -> torch.Tensor:
107
- # x: (..., c_in); normalization preserves shape.
108
- if x.dtype is torch.bfloat16:
109
- with torch.autocast("cuda", enabled=False):
110
- return nn.functional.layer_norm(
111
- x,
112
- self.c_in,
113
- self.weight.to(x.dtype),
114
- self.bias.to(x.dtype),
115
- self.eps,
116
- )
117
- return nn.functional.layer_norm(x, self.c_in, self.weight, self.bias, self.eps)
118
-
119
-
120
- @torch.jit.ignore
121
- def softmax_no_cast(tensor: torch.Tensor, dim: int = -1) -> torch.Tensor:
122
- """Apply softmax without promoting BF16 inputs under CUDA autocast."""
123
-
124
- # tensor: (...); softmax preserves shape.
125
- if tensor.dtype is torch.bfloat16:
126
- with torch.autocast("cuda", enabled=False):
127
- return nn.functional.softmax(tensor, dim=dim)
128
- return nn.functional.softmax(tensor, dim=dim)
129
-
130
-
131
- def _attention(
132
- query: torch.Tensor,
133
- key: torch.Tensor,
134
- value: torch.Tensor,
135
- biases: Sequence[torch.Tensor],
136
- ) -> torch.Tensor:
137
- """Compute biased scaled attention after query scaling."""
138
-
139
- # query/key/value: (..., h, n_q_or_k, d_h).
140
- scores = torch.matmul(
141
- query, permute_final_dims(key, (1, 0))
142
- ) # (..., h, n_q, n_k)
143
- for bias in biases:
144
- scores += bias # (..., h, n_q, n_k)
145
- probabilities = softmax_no_cast(scores, dim=-1) # (..., h, n_q, n_k)
146
- return torch.matmul(probabilities, value) # (..., h, n_q, d_h)
147
-
148
-
149
- @torch.compiler.disable
150
- def kernel_triangular_attn(
151
- q: torch.Tensor,
152
- k: torch.Tensor,
153
- v: torch.Tensor,
154
- tri_bias: torch.Tensor,
155
- mask: torch.Tensor,
156
- scale: float,
157
- ) -> torch.Tensor:
158
- """Call the optional cuEquivariance triangle-attention primitive."""
159
-
160
- # q/k/v: (..., h, n, d_h); returned tensor uses the kernel's matching layout.
161
- if (
162
- find_spec("cuequivariance_torch") is None
163
- or find_spec("cuequivariance_ops_torch") is None
164
- ):
165
- raise RuntimeError(
166
- "Boltz2 use_kernels=True requires cuequivariance_torch and the CUDA 13 "
167
- "cuequivariance_ops_torch runtime from the 'structure,cueq' extras."
168
- )
169
- cueq = importlib.import_module("cuequivariance_torch")
170
- return cueq.triangle_attention(q, k, v, tri_bias, mask=mask, scale=scale)
171
-
172
-
173
- class Attention(nn.Module):
174
- """Multi-head pair attention with optional query-dependent output gates."""
175
-
176
- def __init__(
177
- self,
178
- c_q: int,
179
- c_k: int,
180
- c_v: int,
181
- c_hidden: int,
182
- no_heads: int,
183
- gating: bool = True,
184
- ) -> None:
185
- super().__init__()
186
- self.c_q = c_q
187
- self.c_k = c_k
188
- self.c_v = c_v
189
- self.c_hidden = c_hidden
190
- self.no_heads = no_heads
191
- self.gating = gating
192
-
193
- projected_dim = c_hidden * no_heads
194
- self.linear_q = Linear(c_q, projected_dim, bias=False, init="glorot")
195
- self.linear_k = Linear(c_k, projected_dim, bias=False, init="glorot")
196
- self.linear_v = Linear(c_v, projected_dim, bias=False, init="glorot")
197
- self.linear_o = Linear(projected_dim, c_q, bias=False, init="final")
198
- self.linear_g = Linear(c_q, projected_dim, bias=False, init="gating") if gating else None
199
- self.sigmoid = nn.Sigmoid()
200
-
201
- def _prep_qkv(
202
- self,
203
- q_x: torch.Tensor,
204
- kv_x: torch.Tensor,
205
- apply_scale: bool = True,
206
- ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
207
- """Project Q, K, and V to ``(..., h, n, d)`` tensors."""
208
-
209
- def split_heads(X: torch.Tensor) -> torch.Tensor:
210
- # X: (..., n, h * d_h)
211
- return X.view(*X.shape[:-1], self.no_heads, -1).transpose(
212
- -2, -3
213
- ) # (..., h, n, d_h)
214
-
215
- # q_x: (..., n_q, c_q); kv_x: (..., n_k, c_k).
216
- q = split_heads(self.linear_q(q_x)) # (..., h, n_q, d_h)
217
- k = split_heads(self.linear_k(kv_x)) # (..., h, n_k, d_h)
218
- v = split_heads(self.linear_v(kv_x)) # (..., h, n_k, d_h)
219
- if apply_scale:
220
- q /= math.sqrt(self.c_hidden) # (..., h, n_q, d_h)
221
- return q, k, v # (..., h, n_q, d_h), two (..., h, n_k, d_h)
222
-
223
- def _wrap_up(self, output: torch.Tensor, q_x: torch.Tensor) -> torch.Tensor:
224
- """Apply the optional gate, merge heads, and project the update."""
225
-
226
- # output: (..., n_q, h, d_h); q_x: (..., n_q, c_q).
227
- if self.linear_g is not None:
228
- gate = self.sigmoid(self.linear_g(q_x)) # (..., n_q, h * d_h)
229
- gate = gate.view(
230
- *gate.shape[:-1], self.no_heads, -1
231
- ) # (..., n_q, h, d_h)
232
- output = output * gate # (..., n_q, h, d_h)
233
- return self.linear_o(
234
- flatten_final_dims(output, 2)
235
- ) # (..., n_q, c_q)
236
-
237
- def forward(
238
- self,
239
- q_x: torch.Tensor,
240
- kv_x: torch.Tensor,
241
- tri_bias: torch.Tensor,
242
- mask_bias: torch.Tensor,
243
- mask: torch.Tensor,
244
- use_kernels: bool = False,
245
- ) -> torch.Tensor:
246
- """Return a gated pair update with shape ``(..., n, c_q)``."""
247
-
248
- q, k, v = self._prep_qkv(
249
- q_x, kv_x, apply_scale=not use_kernels
250
- ) # q: (..., h, n_q, d_h); k/v: (..., h, n_k, d_h)
251
- if use_kernels:
252
- output = kernel_triangular_attn(
253
- q,
254
- k,
255
- v,
256
- tri_bias=tri_bias,
257
- mask=mask.bool(),
258
- scale=1.0 / math.sqrt(self.c_hidden),
259
- ) # (..., h, n_q, d_h)
260
- else:
261
- output = _attention(
262
- q, k, v, (mask_bias, tri_bias)
263
- ) # (..., h, n_q, d_h)
264
- return self._wrap_up(
265
- output.transpose(-2, -3), q_x
266
- ) # (..., n_q, c_q)
 
1
+ """Attention primitives for the Boltz pair stack.
2
+
3
+ The module retains the converted checkpoint names while providing a compact
4
+ implementation around PyTorch operations. Optional triangle kernels are
5
+ loaded only at the call site, so importing FastPLMs does not initialize CUDA.
6
+
7
+ Portions of the numerical contract follow OpenFold and DeepMind AlphaFold
8
+ under Apache-2.0. See ``THIRD_PARTY_NOTICES.md`` for provenance.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import math
15
+ import torch
16
+
17
+ from collections.abc import Callable, Sequence
18
+ from importlib.util import find_spec
19
+ from torch import nn
20
+
21
+ from . import vb_layers_initialize as initialize
22
+ from .vb_tri_attn_utils import flatten_final_dims, permute_final_dims
23
+
24
+
25
+ def _initialize_weight(
26
+ weight: torch.Tensor,
27
+ bias: torch.Tensor | None,
28
+ method: str,
29
+ ) -> None:
30
+ # weight: (d_out, d_in); bias: (d_out,) or None; both retain shape in place.
31
+ initializers = {
32
+ "default": initialize.lecun_normal_init_,
33
+ "relu": initialize.he_normal_init_,
34
+ "glorot": initialize.glorot_uniform_init_,
35
+ "normal": initialize.normal_init_,
36
+ "final": initialize.final_init_,
37
+ "gating": initialize.gating_init_,
38
+ }
39
+ try:
40
+ initializers[method](weight)
41
+ except KeyError as error:
42
+ raise ValueError(f"unknown linear initialization {method!r}") from error
43
+ if method == "gating" and bias is not None:
44
+ bias.fill_(1.0)
45
+
46
+
47
+ class Linear(nn.Linear):
48
+ """Linear projection with the initializers used by converted checkpoints."""
49
+
50
+ def __init__(
51
+ self,
52
+ in_dim: int,
53
+ out_dim: int,
54
+ bias: bool = True,
55
+ init: str = "default",
56
+ init_fn: Callable[[torch.Tensor, torch.Tensor | None], None] | None = None,
57
+ precision: torch.dtype | None = None,
58
+ ) -> None:
59
+ super().__init__(in_dim, out_dim, bias=bias)
60
+ with torch.no_grad():
61
+ if self.bias is not None:
62
+ self.bias.zero_()
63
+ if init_fn is None:
64
+ _initialize_weight(self.weight, self.bias, init)
65
+ else:
66
+ init_fn(self.weight, self.bias)
67
+ self.precision = precision
68
+
69
+ def forward(self, input: torch.Tensor) -> torch.Tensor:
70
+ """Project ``input`` while preserving explicit precision policies."""
71
+
72
+ # input: (..., d_in); every branch returns (..., d_out).
73
+ input_dtype = input.dtype
74
+ if self.precision is not None:
75
+ with torch.autocast("cuda", enabled=False):
76
+ bias = (
77
+ None if self.bias is None else self.bias.to(self.precision)
78
+ ) # (d_out,) or None
79
+ projected = nn.functional.linear(
80
+ input.to(self.precision),
81
+ self.weight.to(self.precision),
82
+ bias,
83
+ ) # (..., d_out)
84
+ return projected.to(input_dtype) # (..., d_out)
85
+ if input_dtype is torch.bfloat16:
86
+ with torch.autocast("cuda", enabled=False):
87
+ bias = (
88
+ None if self.bias is None else self.bias.to(input_dtype)
89
+ ) # (d_out,) or None
90
+ return nn.functional.linear(
91
+ input, self.weight.to(input_dtype), bias
92
+ ) # (..., d_out)
93
+ return nn.functional.linear(input, self.weight, self.bias) # (..., d_out)
94
+
95
+
96
+ class LayerNorm(nn.Module):
97
+ """Layer normalization that avoids an autocast upcast for BF16 inputs."""
98
+
99
+ def __init__(self, c_in: int, eps: float = 1e-5) -> None:
100
+ super().__init__()
101
+ self.c_in = (c_in,)
102
+ self.eps = eps
103
+ self.weight = nn.Parameter(torch.ones(c_in)) # (c_in,)
104
+ self.bias = nn.Parameter(torch.zeros(c_in)) # (c_in,)
105
+
106
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
107
+ # x: (..., c_in); normalization preserves shape.
108
+ if x.dtype is torch.bfloat16:
109
+ with torch.autocast("cuda", enabled=False):
110
+ return nn.functional.layer_norm(
111
+ x,
112
+ self.c_in,
113
+ self.weight.to(x.dtype),
114
+ self.bias.to(x.dtype),
115
+ self.eps,
116
+ )
117
+ return nn.functional.layer_norm(x, self.c_in, self.weight, self.bias, self.eps)
118
+
119
+
120
+ @torch.jit.ignore
121
+ def softmax_no_cast(tensor: torch.Tensor, dim: int = -1) -> torch.Tensor:
122
+ """Apply softmax without promoting BF16 inputs under CUDA autocast."""
123
+
124
+ # tensor: (...); softmax preserves shape.
125
+ if tensor.dtype is torch.bfloat16:
126
+ with torch.autocast("cuda", enabled=False):
127
+ return nn.functional.softmax(tensor, dim=dim)
128
+ return nn.functional.softmax(tensor, dim=dim)
129
+
130
+
131
+ def _attention(
132
+ query: torch.Tensor,
133
+ key: torch.Tensor,
134
+ value: torch.Tensor,
135
+ biases: Sequence[torch.Tensor],
136
+ ) -> torch.Tensor:
137
+ """Compute biased scaled attention after query scaling."""
138
+
139
+ # query/key/value: (..., h, n_q_or_k, d_h).
140
+ scores = torch.matmul(
141
+ query, permute_final_dims(key, (1, 0))
142
+ ) # (..., h, n_q, n_k)
143
+ for bias in biases:
144
+ scores += bias # (..., h, n_q, n_k)
145
+ probabilities = softmax_no_cast(scores, dim=-1) # (..., h, n_q, n_k)
146
+ return torch.matmul(probabilities, value) # (..., h, n_q, d_h)
147
+
148
+
149
+ @torch.compiler.disable
150
+ def kernel_triangular_attn(
151
+ q: torch.Tensor,
152
+ k: torch.Tensor,
153
+ v: torch.Tensor,
154
+ tri_bias: torch.Tensor,
155
+ mask: torch.Tensor,
156
+ scale: float,
157
+ ) -> torch.Tensor:
158
+ """Call the optional cuEquivariance triangle-attention primitive."""
159
+
160
+ # q/k/v: (..., h, n, d_h); returned tensor uses the kernel's matching layout.
161
+ if (
162
+ find_spec("cuequivariance_torch") is None
163
+ or find_spec("cuequivariance_ops_torch") is None
164
+ ):
165
+ raise RuntimeError(
166
+ "Boltz2 use_kernels=True requires cuequivariance_torch and the CUDA 13 "
167
+ "cuequivariance_ops_torch runtime from the 'structure,cueq' extras."
168
+ )
169
+ cueq = importlib.import_module("cuequivariance_torch")
170
+ return cueq.triangle_attention(q, k, v, tri_bias, mask=mask, scale=scale)
171
+
172
+
173
+ class Attention(nn.Module):
174
+ """Multi-head pair attention with optional query-dependent output gates."""
175
+
176
+ def __init__(
177
+ self,
178
+ c_q: int,
179
+ c_k: int,
180
+ c_v: int,
181
+ c_hidden: int,
182
+ no_heads: int,
183
+ gating: bool = True,
184
+ ) -> None:
185
+ super().__init__()
186
+ self.c_q = c_q
187
+ self.c_k = c_k
188
+ self.c_v = c_v
189
+ self.c_hidden = c_hidden
190
+ self.no_heads = no_heads
191
+ self.gating = gating
192
+
193
+ projected_dim = c_hidden * no_heads
194
+ self.linear_q = Linear(c_q, projected_dim, bias=False, init="glorot")
195
+ self.linear_k = Linear(c_k, projected_dim, bias=False, init="glorot")
196
+ self.linear_v = Linear(c_v, projected_dim, bias=False, init="glorot")
197
+ self.linear_o = Linear(projected_dim, c_q, bias=False, init="final")
198
+ self.linear_g = Linear(c_q, projected_dim, bias=False, init="gating") if gating else None
199
+ self.sigmoid = nn.Sigmoid()
200
+
201
+ def _prep_qkv(
202
+ self,
203
+ q_x: torch.Tensor,
204
+ kv_x: torch.Tensor,
205
+ apply_scale: bool = True,
206
+ ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
207
+ """Project Q, K, and V to ``(..., h, n, d)`` tensors."""
208
+
209
+ def split_heads(X: torch.Tensor) -> torch.Tensor:
210
+ # X: (..., n, h * d_h)
211
+ return X.view(*X.shape[:-1], self.no_heads, -1).transpose(
212
+ -2, -3
213
+ ) # (..., h, n, d_h)
214
+
215
+ # q_x: (..., n_q, c_q); kv_x: (..., n_k, c_k).
216
+ q = split_heads(self.linear_q(q_x)) # (..., h, n_q, d_h)
217
+ k = split_heads(self.linear_k(kv_x)) # (..., h, n_k, d_h)
218
+ v = split_heads(self.linear_v(kv_x)) # (..., h, n_k, d_h)
219
+ if apply_scale:
220
+ q /= math.sqrt(self.c_hidden) # (..., h, n_q, d_h)
221
+ return q, k, v # (..., h, n_q, d_h), two (..., h, n_k, d_h)
222
+
223
+ def _wrap_up(self, output: torch.Tensor, q_x: torch.Tensor) -> torch.Tensor:
224
+ """Apply the optional gate, merge heads, and project the update."""
225
+
226
+ # output: (..., n_q, h, d_h); q_x: (..., n_q, c_q).
227
+ if self.linear_g is not None:
228
+ gate = self.sigmoid(self.linear_g(q_x)) # (..., n_q, h * d_h)
229
+ gate = gate.view(
230
+ *gate.shape[:-1], self.no_heads, -1
231
+ ) # (..., n_q, h, d_h)
232
+ output = output * gate # (..., n_q, h, d_h)
233
+ return self.linear_o(
234
+ flatten_final_dims(output, 2)
235
+ ) # (..., n_q, c_q)
236
+
237
+ def forward(
238
+ self,
239
+ q_x: torch.Tensor,
240
+ kv_x: torch.Tensor,
241
+ tri_bias: torch.Tensor,
242
+ mask_bias: torch.Tensor,
243
+ mask: torch.Tensor,
244
+ use_kernels: bool = False,
245
+ ) -> torch.Tensor:
246
+ """Return a gated pair update with shape ``(..., n, c_q)``."""
247
+
248
+ q, k, v = self._prep_qkv(
249
+ q_x, kv_x, apply_scale=not use_kernels
250
+ ) # q: (..., h, n_q, d_h); k/v: (..., h, n_k, d_h)
251
+ if use_kernels:
252
+ output = kernel_triangular_attn(
253
+ q,
254
+ k,
255
+ v,
256
+ tri_bias=tri_bias,
257
+ mask=mask.bool(),
258
+ scale=1.0 / math.sqrt(self.c_hidden),
259
+ ) # (..., h, n_q, d_h)
260
+ else:
261
+ output = _attention(
262
+ q, k, v, (mask_bias, tri_bias)
263
+ ) # (..., h, n_q, d_h)
264
+ return self._wrap_up(
265
+ output.transpose(-2, -3), q_x
266
+ ) # (..., n_q, c_q)
fastplms/models/boltz/vb_tri_attn_utils.py CHANGED
@@ -1,341 +1,341 @@
1
- """Tensor-tree and memory-bounded execution helpers for pair attention.
2
-
3
- These utilities implement the small subset of OpenFold-style chunking needed
4
- by the local Boltz pair stack. The implementation is independent, but keeps
5
- the public function names expected by converted checkpoints.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import torch
11
-
12
- from collections.abc import Callable, Sequence
13
- from functools import partial
14
- from math import prod
15
- from typing import Any
16
-
17
-
18
- def add(left: torch.Tensor, right: torch.Tensor, inplace: bool) -> torch.Tensor:
19
- """Add ``right`` to ``left``, optionally reusing ``left`` storage."""
20
-
21
- # left/right: broadcast-compatible shapes; result has their broadcast shape.
22
- if inplace:
23
- left += right # same shape as left
24
- return left # same shape as left
25
- return left + right # broadcast shape
26
-
27
-
28
- def permute_final_dims(tensor: torch.Tensor, inds: Sequence[int]) -> torch.Tensor:
29
- """Permute only the final ``len(inds)`` axes of ``tensor``."""
30
-
31
- # tensor: (..., d_0, ..., d_n); inds reorders the named final axes.
32
- final_count = len(inds)
33
- leading = list(range(tensor.ndim - final_count))
34
- final = [tensor.ndim - final_count + index for index in inds]
35
- return tensor.permute(leading + final) # (..., d_0, ..., d_n)
36
-
37
-
38
- def is_fp16_enabled() -> bool:
39
- """Return whether CUDA autocast currently targets ``float16``."""
40
-
41
- return torch.is_autocast_enabled() and torch.get_autocast_dtype("cuda") == torch.float16
42
-
43
-
44
- def dict_map(
45
- fn: Callable[[Any], Any],
46
- dic: dict[Any, Any],
47
- leaf_type: type | tuple[type, ...],
48
- ) -> dict[Any, Any]:
49
- """Apply ``fn`` to leaves in a nested dictionary tree."""
50
-
51
- return {key: tree_map(fn, value, leaf_type) for key, value in dic.items()}
52
-
53
-
54
- def tree_map(
55
- fn: Callable[[Any], Any],
56
- tree: Any,
57
- leaf_type: type | tuple[type, ...],
58
- ) -> Any:
59
- """Map a function over dict, list, and tuple containers."""
60
-
61
- if isinstance(tree, leaf_type):
62
- return fn(tree)
63
- if isinstance(tree, dict):
64
- return dict_map(fn, tree, leaf_type)
65
- if isinstance(tree, list):
66
- return [tree_map(fn, item, leaf_type) for item in tree]
67
- if isinstance(tree, tuple):
68
- return tuple(tree_map(fn, item, leaf_type) for item in tree)
69
- raise ValueError(f"tree type {type(tree)!r} is not supported")
70
-
71
-
72
- tensor_tree_map = partial(tree_map, leaf_type=torch.Tensor)
73
-
74
-
75
- def flatten_final_dims(tensor: torch.Tensor, no_dims: int) -> torch.Tensor:
76
- """Collapse the final ``no_dims`` axes into one axis."""
77
-
78
- # tensor: (..., d_0, ..., d_n)
79
- return tensor.reshape(*tensor.shape[:-no_dims], -1) # (..., prod(final dims))
80
-
81
-
82
- def _fetch_dims(tree: Any) -> list[torch.Size]:
83
- """Collect tensor shapes from a supported tree."""
84
-
85
- if isinstance(tree, torch.Tensor):
86
- return [tree.shape]
87
- if isinstance(tree, dict):
88
- children = tree.values()
89
- elif isinstance(tree, (list, tuple)):
90
- children = tree
91
- else:
92
- raise ValueError(f"tree type {type(tree)!r} is not supported")
93
-
94
- shapes: list[torch.Size] = []
95
- for child in children:
96
- shapes.extend(_fetch_dims(child))
97
- return shapes
98
-
99
-
100
- @torch.jit.ignore
101
- def _flat_idx_to_idx(flat_idx: int, dims: tuple[int, ...]) -> tuple[int, ...]:
102
- """Convert a row-major flat index into an index tuple."""
103
-
104
- coordinates = [0] * len(dims)
105
- remainder = flat_idx
106
- for axis in range(len(dims) - 1, -1, -1):
107
- remainder, coordinates[axis] = divmod(remainder, dims[axis])
108
- return tuple(coordinates)
109
-
110
-
111
- def _ravel_index(index: Sequence[int], dims: Sequence[int]) -> int:
112
- """Convert a row-major index tuple to a flat index."""
113
-
114
- flat = 0
115
- for coordinate, size in zip(index, dims, strict=True):
116
- flat = flat * size + coordinate
117
- return flat
118
-
119
-
120
- def _cover_flat_interval(
121
- start: int,
122
- stop: int,
123
- dims: tuple[int, ...],
124
- ) -> list[tuple[slice, ...]]:
125
- """Cover ``[start, stop)`` with ordered contiguous tensor slices."""
126
-
127
- if not dims:
128
- return [tuple()]
129
- if len(dims) == 1:
130
- return [(slice(start, stop),)]
131
-
132
- child_size = prod(dims[1:])
133
- first_child = start // child_size
134
- last_child = (stop - 1) // child_size
135
- if first_child == last_child:
136
- tail = _cover_flat_interval(
137
- start % child_size,
138
- (stop - 1) % child_size + 1,
139
- dims[1:],
140
- )
141
- prefix = slice(first_child, first_child + 1)
142
- return [(prefix, *item) for item in tail]
143
-
144
- slices: list[tuple[slice, ...]] = []
145
- start_offset = start % child_size
146
- first_full_child = first_child
147
- if start_offset:
148
- prefix = slice(first_child, first_child + 1)
149
- slices.extend(
150
- (prefix, *item) for item in _cover_flat_interval(start_offset, child_size, dims[1:])
151
- )
152
- first_full_child += 1
153
-
154
- stop_offset = stop % child_size
155
- full_stop = last_child if stop_offset else last_child + 1
156
- if first_full_child < full_stop:
157
- slices.append((slice(first_full_child, full_stop),))
158
-
159
- if stop_offset:
160
- prefix = slice(last_child, last_child + 1)
161
- slices.extend((prefix, *item) for item in _cover_flat_interval(0, stop_offset, dims[1:]))
162
- return slices
163
-
164
-
165
- @torch.jit.ignore
166
- def _get_minimal_slice_set(
167
- start: Sequence[int],
168
- end: Sequence[int],
169
- dims: Sequence[int],
170
- start_edges: Sequence[bool] | None = None,
171
- end_edges: Sequence[bool] | None = None,
172
- ) -> list[tuple[slice, ...]]:
173
- """Return ordered slices covering the inclusive row-major interval.
174
-
175
- ``start_edges`` and ``end_edges`` remain accepted for compatibility. The
176
- interval decomposition derives the same information directly.
177
- """
178
-
179
- del start_edges, end_edges
180
- shape = tuple(dims)
181
- if not shape:
182
- return [tuple()]
183
- flat_start = _ravel_index(start, shape)
184
- flat_stop = _ravel_index(end, shape) + 1
185
- return _cover_flat_interval(flat_start, flat_stop, shape)
186
-
187
-
188
- @torch.jit.ignore
189
- def _chunk_slice(
190
- tensor: torch.Tensor,
191
- flat_start: int,
192
- flat_end: int,
193
- no_batch_dims: int,
194
- ) -> torch.Tensor:
195
- """Slice a flattened batch interval without flattening the full tensor."""
196
-
197
- # tensor: (*batch_shape, *feature_shape).
198
- batch_shape = tuple(tensor.shape[:no_batch_dims])
199
- start = _flat_idx_to_idx(flat_start, batch_shape)
200
- end = _flat_idx_to_idx(flat_end - 1, batch_shape)
201
- pieces = [
202
- tensor[item] for item in _get_minimal_slice_set(start, end, batch_shape)
203
- ] # each: (*covered_batch_shape, *feature_shape)
204
- feature_shape = tuple(tensor.shape[no_batch_dims:])
205
- return torch.cat(
206
- [piece.reshape(-1, *feature_shape) for piece in pieces]
207
- ) # (flat_end - flat_start, *feature_shape)
208
-
209
-
210
- def _prepare_input(
211
- tensor: torch.Tensor,
212
- batch_shape: tuple[int, ...],
213
- no_batch_dims: int,
214
- *,
215
- low_mem: bool,
216
- ) -> torch.Tensor:
217
- # tensor: (*input_batch_shape, *feature_shape)
218
- feature_shape = tuple(tensor.shape[no_batch_dims:])
219
- if low_mem:
220
- return tensor.expand(batch_shape + feature_shape) # (*batch_shape, *feature_shape)
221
- if any(size != 1 for size in tensor.shape[:no_batch_dims]):
222
- tensor = tensor.expand(
223
- batch_shape + feature_shape
224
- ) # (*batch_shape, *feature_shape)
225
- return tensor.reshape(-1, *feature_shape) # (flat_batch, *feature_shape)
226
-
227
-
228
- def _write_chunk(
229
- destination: Any,
230
- source: Any,
231
- start: int,
232
- stop: int,
233
- *,
234
- add_into_out: bool,
235
- ) -> None:
236
- if isinstance(source, dict):
237
- for key, value in source.items():
238
- _write_chunk(
239
- destination[key],
240
- value,
241
- start,
242
- stop,
243
- add_into_out=add_into_out,
244
- )
245
- return
246
- if isinstance(source, (tuple, list)):
247
- for destination_item, source_item in zip(destination, source, strict=True):
248
- _write_chunk(
249
- destination_item,
250
- source_item,
251
- start,
252
- stop,
253
- add_into_out=add_into_out,
254
- )
255
- return
256
- if not isinstance(source, torch.Tensor):
257
- raise ValueError(f"output type {type(source)!r} is not supported")
258
- if add_into_out:
259
- destination[start:stop] += source # (stop - start, *feature_shape)
260
- else:
261
- destination[start:stop] = source # (stop - start, *feature_shape)
262
-
263
-
264
- def chunk_layer(
265
- layer: Callable[..., Any],
266
- inputs: dict[str, Any],
267
- chunk_size: int,
268
- no_batch_dims: int,
269
- low_mem: bool = False,
270
- _out: Any = None,
271
- _add_into_out: bool = False,
272
- ) -> Any:
273
- """Apply ``layer`` to flattened batch chunks and reassemble its output."""
274
-
275
- if not inputs:
276
- raise ValueError("at least one input is required")
277
- if chunk_size <= 0:
278
- raise ValueError("chunk_size must be positive")
279
-
280
- leading_shapes = [shape[:no_batch_dims] for shape in _fetch_dims(inputs)]
281
- batch_shape = tuple(max(sizes) for sizes in zip(*leading_shapes, strict=True))
282
- prepare = partial(
283
- _prepare_input,
284
- batch_shape=batch_shape,
285
- no_batch_dims=no_batch_dims,
286
- low_mem=low_mem,
287
- )
288
- prepared_inputs = tensor_tree_map(
289
- prepare, inputs
290
- ) # each tensor: (flat_batch, *feature_shape), or broadcast batch in low-memory mode
291
-
292
- output = None
293
- if _out is not None:
294
- output = tensor_tree_map(
295
- lambda tensor: tensor.reshape(-1, *tensor.shape[no_batch_dims:]),
296
- _out,
297
- ) # each tensor: (flat_batch, *feature_shape)
298
-
299
- flat_batch_size = prod(batch_shape)
300
- for start in range(0, flat_batch_size, chunk_size):
301
- stop = min(flat_batch_size, start + chunk_size)
302
- if low_mem:
303
- select = partial(
304
- _chunk_slice,
305
- flat_start=start,
306
- flat_end=stop,
307
- no_batch_dims=len(batch_shape),
308
- )
309
- else:
310
-
311
- def select(
312
- tensor: torch.Tensor,
313
- start: int = start,
314
- stop: int = stop,
315
- ) -> torch.Tensor:
316
- return tensor if tensor.shape[0] == 1 else tensor[start:stop]
317
-
318
- input_chunk = tensor_tree_map(
319
- select, prepared_inputs
320
- ) # each tensor: (chunk, *feature_shape)
321
- output_chunk = layer(
322
- **input_chunk
323
- ) # each output tensor: (chunk, *output_feature_shape)
324
-
325
- if output is None:
326
- output = tensor_tree_map(
327
- lambda tensor: tensor.new_zeros((flat_batch_size, *tensor.shape[1:])),
328
- output_chunk,
329
- ) # each tensor: (flat_batch, *output_feature_shape)
330
- _write_chunk(
331
- output,
332
- output_chunk,
333
- start,
334
- stop,
335
- add_into_out=_add_into_out,
336
- )
337
-
338
- return tensor_tree_map(
339
- lambda tensor: tensor.reshape(batch_shape + tuple(tensor.shape[1:])),
340
- output,
341
- ) # each tensor: (*batch_shape, *output_feature_shape)
 
1
+ """Tensor-tree and memory-bounded execution helpers for pair attention.
2
+
3
+ These utilities implement the small subset of OpenFold-style chunking needed
4
+ by the local Boltz pair stack. The implementation is independent, but keeps
5
+ the public function names expected by converted checkpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import torch
11
+
12
+ from collections.abc import Callable, Sequence
13
+ from functools import partial
14
+ from math import prod
15
+ from typing import Any
16
+
17
+
18
+ def add(left: torch.Tensor, right: torch.Tensor, inplace: bool) -> torch.Tensor:
19
+ """Add ``right`` to ``left``, optionally reusing ``left`` storage."""
20
+
21
+ # left/right: broadcast-compatible shapes; result has their broadcast shape.
22
+ if inplace:
23
+ left += right # same shape as left
24
+ return left # same shape as left
25
+ return left + right # broadcast shape
26
+
27
+
28
+ def permute_final_dims(tensor: torch.Tensor, inds: Sequence[int]) -> torch.Tensor:
29
+ """Permute only the final ``len(inds)`` axes of ``tensor``."""
30
+
31
+ # tensor: (..., d_0, ..., d_n); inds reorders the named final axes.
32
+ final_count = len(inds)
33
+ leading = list(range(tensor.ndim - final_count))
34
+ final = [tensor.ndim - final_count + index for index in inds]
35
+ return tensor.permute(leading + final) # (..., d_0, ..., d_n)
36
+
37
+
38
+ def is_fp16_enabled() -> bool:
39
+ """Return whether CUDA autocast currently targets ``float16``."""
40
+
41
+ return torch.is_autocast_enabled() and torch.get_autocast_dtype("cuda") == torch.float16
42
+
43
+
44
+ def dict_map(
45
+ fn: Callable[[Any], Any],
46
+ dic: dict[Any, Any],
47
+ leaf_type: type | tuple[type, ...],
48
+ ) -> dict[Any, Any]:
49
+ """Apply ``fn`` to leaves in a nested dictionary tree."""
50
+
51
+ return {key: tree_map(fn, value, leaf_type) for key, value in dic.items()}
52
+
53
+
54
+ def tree_map(
55
+ fn: Callable[[Any], Any],
56
+ tree: Any,
57
+ leaf_type: type | tuple[type, ...],
58
+ ) -> Any:
59
+ """Map a function over dict, list, and tuple containers."""
60
+
61
+ if isinstance(tree, leaf_type):
62
+ return fn(tree)
63
+ if isinstance(tree, dict):
64
+ return dict_map(fn, tree, leaf_type)
65
+ if isinstance(tree, list):
66
+ return [tree_map(fn, item, leaf_type) for item in tree]
67
+ if isinstance(tree, tuple):
68
+ return tuple(tree_map(fn, item, leaf_type) for item in tree)
69
+ raise ValueError(f"tree type {type(tree)!r} is not supported")
70
+
71
+
72
+ tensor_tree_map = partial(tree_map, leaf_type=torch.Tensor)
73
+
74
+
75
+ def flatten_final_dims(tensor: torch.Tensor, no_dims: int) -> torch.Tensor:
76
+ """Collapse the final ``no_dims`` axes into one axis."""
77
+
78
+ # tensor: (..., d_0, ..., d_n)
79
+ return tensor.reshape(*tensor.shape[:-no_dims], -1) # (..., prod(final dims))
80
+
81
+
82
+ def _fetch_dims(tree: Any) -> list[torch.Size]:
83
+ """Collect tensor shapes from a supported tree."""
84
+
85
+ if isinstance(tree, torch.Tensor):
86
+ return [tree.shape]
87
+ if isinstance(tree, dict):
88
+ children = tree.values()
89
+ elif isinstance(tree, (list, tuple)):
90
+ children = tree
91
+ else:
92
+ raise ValueError(f"tree type {type(tree)!r} is not supported")
93
+
94
+ shapes: list[torch.Size] = []
95
+ for child in children:
96
+ shapes.extend(_fetch_dims(child))
97
+ return shapes
98
+
99
+
100
+ @torch.jit.ignore
101
+ def _flat_idx_to_idx(flat_idx: int, dims: tuple[int, ...]) -> tuple[int, ...]:
102
+ """Convert a row-major flat index into an index tuple."""
103
+
104
+ coordinates = [0] * len(dims)
105
+ remainder = flat_idx
106
+ for axis in range(len(dims) - 1, -1, -1):
107
+ remainder, coordinates[axis] = divmod(remainder, dims[axis])
108
+ return tuple(coordinates)
109
+
110
+
111
+ def _ravel_index(index: Sequence[int], dims: Sequence[int]) -> int:
112
+ """Convert a row-major index tuple to a flat index."""
113
+
114
+ flat = 0
115
+ for coordinate, size in zip(index, dims, strict=True):
116
+ flat = flat * size + coordinate
117
+ return flat
118
+
119
+
120
+ def _cover_flat_interval(
121
+ start: int,
122
+ stop: int,
123
+ dims: tuple[int, ...],
124
+ ) -> list[tuple[slice, ...]]:
125
+ """Cover ``[start, stop)`` with ordered contiguous tensor slices."""
126
+
127
+ if not dims:
128
+ return [tuple()]
129
+ if len(dims) == 1:
130
+ return [(slice(start, stop),)]
131
+
132
+ child_size = prod(dims[1:])
133
+ first_child = start // child_size
134
+ last_child = (stop - 1) // child_size
135
+ if first_child == last_child:
136
+ tail = _cover_flat_interval(
137
+ start % child_size,
138
+ (stop - 1) % child_size + 1,
139
+ dims[1:],
140
+ )
141
+ prefix = slice(first_child, first_child + 1)
142
+ return [(prefix, *item) for item in tail]
143
+
144
+ slices: list[tuple[slice, ...]] = []
145
+ start_offset = start % child_size
146
+ first_full_child = first_child
147
+ if start_offset:
148
+ prefix = slice(first_child, first_child + 1)
149
+ slices.extend(
150
+ (prefix, *item) for item in _cover_flat_interval(start_offset, child_size, dims[1:])
151
+ )
152
+ first_full_child += 1
153
+
154
+ stop_offset = stop % child_size
155
+ full_stop = last_child if stop_offset else last_child + 1
156
+ if first_full_child < full_stop:
157
+ slices.append((slice(first_full_child, full_stop),))
158
+
159
+ if stop_offset:
160
+ prefix = slice(last_child, last_child + 1)
161
+ slices.extend((prefix, *item) for item in _cover_flat_interval(0, stop_offset, dims[1:]))
162
+ return slices
163
+
164
+
165
+ @torch.jit.ignore
166
+ def _get_minimal_slice_set(
167
+ start: Sequence[int],
168
+ end: Sequence[int],
169
+ dims: Sequence[int],
170
+ start_edges: Sequence[bool] | None = None,
171
+ end_edges: Sequence[bool] | None = None,
172
+ ) -> list[tuple[slice, ...]]:
173
+ """Return ordered slices covering the inclusive row-major interval.
174
+
175
+ ``start_edges`` and ``end_edges`` remain accepted for compatibility. The
176
+ interval decomposition derives the same information directly.
177
+ """
178
+
179
+ del start_edges, end_edges
180
+ shape = tuple(dims)
181
+ if not shape:
182
+ return [tuple()]
183
+ flat_start = _ravel_index(start, shape)
184
+ flat_stop = _ravel_index(end, shape) + 1
185
+ return _cover_flat_interval(flat_start, flat_stop, shape)
186
+
187
+
188
+ @torch.jit.ignore
189
+ def _chunk_slice(
190
+ tensor: torch.Tensor,
191
+ flat_start: int,
192
+ flat_end: int,
193
+ no_batch_dims: int,
194
+ ) -> torch.Tensor:
195
+ """Slice a flattened batch interval without flattening the full tensor."""
196
+
197
+ # tensor: (*batch_shape, *feature_shape).
198
+ batch_shape = tuple(tensor.shape[:no_batch_dims])
199
+ start = _flat_idx_to_idx(flat_start, batch_shape)
200
+ end = _flat_idx_to_idx(flat_end - 1, batch_shape)
201
+ pieces = [
202
+ tensor[item] for item in _get_minimal_slice_set(start, end, batch_shape)
203
+ ] # each: (*covered_batch_shape, *feature_shape)
204
+ feature_shape = tuple(tensor.shape[no_batch_dims:])
205
+ return torch.cat(
206
+ [piece.reshape(-1, *feature_shape) for piece in pieces]
207
+ ) # (flat_end - flat_start, *feature_shape)
208
+
209
+
210
+ def _prepare_input(
211
+ tensor: torch.Tensor,
212
+ batch_shape: tuple[int, ...],
213
+ no_batch_dims: int,
214
+ *,
215
+ low_mem: bool,
216
+ ) -> torch.Tensor:
217
+ # tensor: (*input_batch_shape, *feature_shape)
218
+ feature_shape = tuple(tensor.shape[no_batch_dims:])
219
+ if low_mem:
220
+ return tensor.expand(batch_shape + feature_shape) # (*batch_shape, *feature_shape)
221
+ if any(size != 1 for size in tensor.shape[:no_batch_dims]):
222
+ tensor = tensor.expand(
223
+ batch_shape + feature_shape
224
+ ) # (*batch_shape, *feature_shape)
225
+ return tensor.reshape(-1, *feature_shape) # (flat_batch, *feature_shape)
226
+
227
+
228
+ def _write_chunk(
229
+ destination: Any,
230
+ source: Any,
231
+ start: int,
232
+ stop: int,
233
+ *,
234
+ add_into_out: bool,
235
+ ) -> None:
236
+ if isinstance(source, dict):
237
+ for key, value in source.items():
238
+ _write_chunk(
239
+ destination[key],
240
+ value,
241
+ start,
242
+ stop,
243
+ add_into_out=add_into_out,
244
+ )
245
+ return
246
+ if isinstance(source, (tuple, list)):
247
+ for destination_item, source_item in zip(destination, source, strict=True):
248
+ _write_chunk(
249
+ destination_item,
250
+ source_item,
251
+ start,
252
+ stop,
253
+ add_into_out=add_into_out,
254
+ )
255
+ return
256
+ if not isinstance(source, torch.Tensor):
257
+ raise ValueError(f"output type {type(source)!r} is not supported")
258
+ if add_into_out:
259
+ destination[start:stop] += source # (stop - start, *feature_shape)
260
+ else:
261
+ destination[start:stop] = source # (stop - start, *feature_shape)
262
+
263
+
264
+ def chunk_layer(
265
+ layer: Callable[..., Any],
266
+ inputs: dict[str, Any],
267
+ chunk_size: int,
268
+ no_batch_dims: int,
269
+ low_mem: bool = False,
270
+ _out: Any = None,
271
+ _add_into_out: bool = False,
272
+ ) -> Any:
273
+ """Apply ``layer`` to flattened batch chunks and reassemble its output."""
274
+
275
+ if not inputs:
276
+ raise ValueError("at least one input is required")
277
+ if chunk_size <= 0:
278
+ raise ValueError("chunk_size must be positive")
279
+
280
+ leading_shapes = [shape[:no_batch_dims] for shape in _fetch_dims(inputs)]
281
+ batch_shape = tuple(max(sizes) for sizes in zip(*leading_shapes, strict=True))
282
+ prepare = partial(
283
+ _prepare_input,
284
+ batch_shape=batch_shape,
285
+ no_batch_dims=no_batch_dims,
286
+ low_mem=low_mem,
287
+ )
288
+ prepared_inputs = tensor_tree_map(
289
+ prepare, inputs
290
+ ) # each tensor: (flat_batch, *feature_shape), or broadcast batch in low-memory mode
291
+
292
+ output = None
293
+ if _out is not None:
294
+ output = tensor_tree_map(
295
+ lambda tensor: tensor.reshape(-1, *tensor.shape[no_batch_dims:]),
296
+ _out,
297
+ ) # each tensor: (flat_batch, *feature_shape)
298
+
299
+ flat_batch_size = prod(batch_shape)
300
+ for start in range(0, flat_batch_size, chunk_size):
301
+ stop = min(flat_batch_size, start + chunk_size)
302
+ if low_mem:
303
+ select = partial(
304
+ _chunk_slice,
305
+ flat_start=start,
306
+ flat_end=stop,
307
+ no_batch_dims=len(batch_shape),
308
+ )
309
+ else:
310
+
311
+ def select(
312
+ tensor: torch.Tensor,
313
+ start: int = start,
314
+ stop: int = stop,
315
+ ) -> torch.Tensor:
316
+ return tensor if tensor.shape[0] == 1 else tensor[start:stop]
317
+
318
+ input_chunk = tensor_tree_map(
319
+ select, prepared_inputs
320
+ ) # each tensor: (chunk, *feature_shape)
321
+ output_chunk = layer(
322
+ **input_chunk
323
+ ) # each output tensor: (chunk, *output_feature_shape)
324
+
325
+ if output is None:
326
+ output = tensor_tree_map(
327
+ lambda tensor: tensor.new_zeros((flat_batch_size, *tensor.shape[1:])),
328
+ output_chunk,
329
+ ) # each tensor: (flat_batch, *output_feature_shape)
330
+ _write_chunk(
331
+ output,
332
+ output_chunk,
333
+ start,
334
+ stop,
335
+ add_into_out=_add_into_out,
336
+ )
337
+
338
+ return tensor_tree_map(
339
+ lambda tensor: tensor.reshape(batch_shape + tuple(tensor.shape[1:])),
340
+ output,
341
+ ) # each tensor: (*batch_shape, *output_feature_shape)
fastplms/registry.py CHANGED
@@ -87,6 +87,8 @@ _ROOT_FIELDS = frozenset(
87
  "families",
88
  "models",
89
  "runtime_assets",
 
 
90
  }
91
  )
92
  _UPSTREAM_FIELDS = frozenset(
@@ -94,6 +96,7 @@ _UPSTREAM_FIELDS = frozenset(
94
  "id",
95
  "path",
96
  "url",
 
97
  "revision",
98
  "license",
99
  "license_files",
@@ -177,6 +180,17 @@ _RUNTIME_ASSET_FIELDS = frozenset(
177
  "offline_behavior",
178
  }
179
  )
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  class RegistryError(ValueError):
@@ -262,6 +276,20 @@ class CheckpointSource:
262
  return MappingProxyType({item.path: item for item in self.files})
263
 
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  @dataclass(frozen=True, slots=True)
266
  class OracleAsset:
267
  """Hash-pinned external file required by a native parity oracle."""
@@ -297,6 +325,19 @@ class OfficialGolden:
297
  tensors: FileDigest
298
 
299
 
 
 
 
 
 
 
 
 
 
 
 
 
 
300
  @dataclass(frozen=True, slots=True)
301
  class UpstreamSource:
302
  """Pinned official implementation used as a parity oracle."""
@@ -309,6 +350,13 @@ class UpstreamSource:
309
  license_files: tuple[str, ...]
310
  license_digests: tuple[FileDigest, ...] = ()
311
  distribution_files: tuple[FileDigest, ...] = ()
 
 
 
 
 
 
 
312
 
313
 
314
  @dataclass(frozen=True, slots=True)
@@ -478,6 +526,8 @@ class ModelRegistry(Mapping[str, ModelSpec]):
478
  runtime_assets: Mapping[str, RuntimeAsset] = MappingProxyType({}),
479
  attention_kernels: Mapping[str, AttentionKernelSpec] = MappingProxyType({}),
480
  legal_files: tuple[FileDigest, ...] = (),
 
 
481
  ) -> None:
482
  self.schema_version = schema_version
483
  self.upstreams = MappingProxyType(dict(upstreams))
@@ -486,6 +536,8 @@ class ModelRegistry(Mapping[str, ModelSpec]):
486
  self._models = MappingProxyType(dict(models))
487
  self.runtime_assets = MappingProxyType(dict(runtime_assets))
488
  self.legal_files = legal_files
 
 
489
 
490
  def __getitem__(self, key: str) -> ModelSpec:
491
  return self._models[key]
@@ -501,6 +553,22 @@ class ModelRegistry(Mapping[str, ModelSpec]):
501
  raise KeyError(family_id)
502
  return tuple(model for model in self._models.values() if model.family.id == family_id)
503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
  def supported_attention_dtypes(
505
  self,
506
  family_id: str,
@@ -988,8 +1056,11 @@ def _parse_upstreams(raw: object) -> dict[str, UpstreamSource]:
988
  raise RegistryError(f"Duplicate upstream path: {path!r}")
989
  paths.add(path)
990
  url = _require_str(value, "url", context)
991
- if not url.startswith("https://github.com/") or not url.endswith(".git"):
992
- raise RegistryError(f"{context}.url must be an HTTPS GitHub clone URL.")
 
 
 
993
  license_files = _require_str_list(value, "license_files", context)
994
  license_digests = _require_digest_list(value, "license_digests", context)
995
  if tuple(item.path for item in license_digests) != license_files:
@@ -1026,6 +1097,7 @@ def _parse_upstreams(raw: object) -> dict[str, UpstreamSource]:
1026
  license_files=license_files,
1027
  license_digests=license_digests,
1028
  distribution_files=distribution_files,
 
1029
  )
1030
  return upstreams
1031
 
@@ -1288,6 +1360,67 @@ def _parse_runtime_assets(
1288
  return runtime_assets
1289
 
1290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1291
  def _parse_confidence_adaptation(
1292
  table: Mapping[str, Any], context: str, model_id: str | None = None
1293
  ) -> ConfidenceAdaptation | None:
@@ -1667,6 +1800,57 @@ def _validate_registry(
1667
  )
1668
 
1669
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1670
  def _load_manifest_bytes(raw_bytes: bytes) -> ModelRegistry:
1671
  try:
1672
  manifest = tomllib.loads(raw_bytes.decode("utf-8"))
@@ -1685,6 +1869,8 @@ def _load_manifest_bytes(raw_bytes: bytes) -> ModelRegistry:
1685
  runtime_assets = _parse_runtime_assets(manifest.get("runtime_assets"), families)
1686
  models = _parse_models(manifest.get("models"), families)
1687
  _validate_registry(upstreams, attention_kernels, families, models)
 
 
1688
  return ModelRegistry(
1689
  schema_version=1,
1690
  upstreams=upstreams,
@@ -1693,6 +1879,8 @@ def _load_manifest_bytes(raw_bytes: bytes) -> ModelRegistry:
1693
  models=models,
1694
  runtime_assets=runtime_assets,
1695
  legal_files=legal_files,
 
 
1696
  )
1697
 
1698
 
@@ -1729,6 +1917,7 @@ __all__ = [
1729
  "CheckpointSource",
1730
  "FileDigest",
1731
  "GenerationContract",
 
1732
  "ModelFamily",
1733
  "ModelRegistry",
1734
  "ModelSpec",
@@ -1737,6 +1926,7 @@ __all__ = [
1737
  "RuntimeAsset",
1738
  "RuntimeAssetTrustKind",
1739
  "RuntimeExtra",
 
1740
  "TestTier",
1741
  "UpstreamSource",
1742
  "VramTier",
 
87
  "families",
88
  "models",
89
  "runtime_assets",
90
+ "golden_artifacts",
91
+ "sparse_autoencoders",
92
  }
93
  )
94
  _UPSTREAM_FIELDS = frozenset(
 
96
  "id",
97
  "path",
98
  "url",
99
+ "fetch_url",
100
  "revision",
101
  "license",
102
  "license_files",
 
180
  "offline_behavior",
181
  }
182
  )
183
+ _GOLDEN_ARTIFACT_FIELDS = frozenset(
184
+ {
185
+ "id",
186
+ "repository",
187
+ "revision",
188
+ "path",
189
+ "sha256",
190
+ "size",
191
+ "offline_behavior",
192
+ }
193
+ )
194
 
195
 
196
  class RegistryError(ValueError):
 
276
  return MappingProxyType({item.path: item for item in self.files})
277
 
278
 
279
+ @dataclass(frozen=True, slots=True)
280
+ class SparseAutoencoderSpec:
281
+ """Pinned released SAE and its input contract, not its unknown training revision."""
282
+
283
+ id: str
284
+ base_model: str
285
+ checkpoint: CheckpointSource
286
+ layer: int
287
+ input_width: int
288
+ k: int
289
+ codebook_dim: int
290
+ input_kind: Literal["hidden_state"] = "hidden_state"
291
+
292
+
293
  @dataclass(frozen=True, slots=True)
294
  class OracleAsset:
295
  """Hash-pinned external file required by a native parity oracle."""
 
325
  tensors: FileDigest
326
 
327
 
328
+ @dataclass(frozen=True, slots=True)
329
+ class GoldenArtifact:
330
+ """Durable Hub copy of one model's official golden tensors at an immutable revision."""
331
+
332
+ model_id: str
333
+ repository: str
334
+ revision: str
335
+ path: str
336
+ sha256: str
337
+ size: int
338
+ offline_behavior: str
339
+
340
+
341
  @dataclass(frozen=True, slots=True)
342
  class UpstreamSource:
343
  """Pinned official implementation used as a parity oracle."""
 
350
  license_files: tuple[str, ...]
351
  license_digests: tuple[FileDigest, ...] = ()
352
  distribution_files: tuple[FileDigest, ...] = ()
353
+ # Where the pinned revision is fetched when `url`, the source of record, no longer serves it.
354
+ fetch_url: str = ""
355
+
356
+ @property
357
+ def clone_url(self) -> str:
358
+ """The URL that `.gitmodules` and source archives use."""
359
+ return self.fetch_url or self.url
360
 
361
 
362
  @dataclass(frozen=True, slots=True)
 
526
  runtime_assets: Mapping[str, RuntimeAsset] = MappingProxyType({}),
527
  attention_kernels: Mapping[str, AttentionKernelSpec] = MappingProxyType({}),
528
  legal_files: tuple[FileDigest, ...] = (),
529
+ golden_artifacts: Mapping[str, GoldenArtifact] = MappingProxyType({}),
530
+ sparse_autoencoders: Mapping[str, SparseAutoencoderSpec] = MappingProxyType({}),
531
  ) -> None:
532
  self.schema_version = schema_version
533
  self.upstreams = MappingProxyType(dict(upstreams))
 
536
  self._models = MappingProxyType(dict(models))
537
  self.runtime_assets = MappingProxyType(dict(runtime_assets))
538
  self.legal_files = legal_files
539
+ self.golden_artifacts = MappingProxyType(dict(golden_artifacts))
540
+ self.sparse_autoencoders = MappingProxyType(dict(sparse_autoencoders))
541
 
542
  def __getitem__(self, key: str) -> ModelSpec:
543
  return self._models[key]
 
553
  raise KeyError(family_id)
554
  return tuple(model for model in self._models.values() if model.family.id == family_id)
555
 
556
+ def sae_for_base(
557
+ self, base_model: str, *, layer: int, k: int, codebook_dim: int
558
+ ) -> SparseAutoencoderSpec:
559
+ """Resolve an exact registered SAE selection without a Hub lookup or fallback."""
560
+ matches = tuple(
561
+ spec for spec in self.sparse_autoencoders.values()
562
+ if (spec.base_model, spec.layer, spec.k, spec.codebook_dim)
563
+ == (base_model, layer, k, codebook_dim)
564
+ )
565
+ if len(matches) != 1:
566
+ raise RegistryError(
567
+ f"Expected one pinned SAE for {base_model}, layer={layer}, k={k}, "
568
+ f"codebook_dim={codebook_dim}; found {len(matches)}."
569
+ )
570
+ return matches[0]
571
+
572
  def supported_attention_dtypes(
573
  self,
574
  family_id: str,
 
1056
  raise RegistryError(f"Duplicate upstream path: {path!r}")
1057
  paths.add(path)
1058
  url = _require_str(value, "url", context)
1059
+ fetch_url = _optional_str(value, "fetch_url", context) or ""
1060
+ for field, candidate in (("url", url), ("fetch_url", fetch_url)):
1061
+ is_github = candidate.startswith("https://github.com/")
1062
+ if candidate and not (is_github and candidate.endswith(".git")):
1063
+ raise RegistryError(f"{context}.{field} must be an HTTPS GitHub clone URL.")
1064
  license_files = _require_str_list(value, "license_files", context)
1065
  license_digests = _require_digest_list(value, "license_digests", context)
1066
  if tuple(item.path for item in license_digests) != license_files:
 
1097
  license_files=license_files,
1098
  license_digests=license_digests,
1099
  distribution_files=distribution_files,
1100
+ fetch_url=fetch_url,
1101
  )
1102
  return upstreams
1103
 
 
1360
  return runtime_assets
1361
 
1362
 
1363
+ def _parse_golden_artifacts(
1364
+ raw: object,
1365
+ models: Mapping[str, ModelSpec],
1366
+ ) -> dict[str, GoldenArtifact]:
1367
+ """Parse the optional Hub copies of official golden tensors, keyed by model ID.
1368
+
1369
+ A copy must be byte-identical to the tensors its model's official_golden pins, so its
1370
+ SHA-256 must equal that digest and the existing integrity check covers both files.
1371
+ """
1372
+ if raw is None:
1373
+ return {}
1374
+ if not isinstance(raw, list):
1375
+ raise RegistryError("golden_artifacts must be an array of tables.")
1376
+ golden_artifacts: dict[str, GoldenArtifact] = {}
1377
+ for index, value in enumerate(raw):
1378
+ context = f"golden_artifacts[{index}]"
1379
+ if not isinstance(value, dict):
1380
+ raise RegistryError(f"{context} must be a table.")
1381
+ _reject_unknown_fields(value, _GOLDEN_ARTIFACT_FIELDS, context)
1382
+ model_id = _require_str(value, "id", context)
1383
+ model = models.get(model_id)
1384
+ if model is None:
1385
+ raise RegistryError(f"{context}.id references unknown model {model_id!r}.")
1386
+ if model.official_golden is None:
1387
+ raise RegistryError(f"{context}.id names {model_id!r}, which has no official_golden.")
1388
+ if model_id in golden_artifacts:
1389
+ raise RegistryError(f"Duplicate golden artifact for model {model_id!r}.")
1390
+ repository = _require_str(value, "repository", context)
1391
+ if _REPOSITORY_ID_RE.fullmatch(repository) is None:
1392
+ raise RegistryError(f"{context}.repository must be a Hugging Face repository ID.")
1393
+ revision = _require_str(value, "revision", context)
1394
+ _validate_revision(revision, f"{context}.revision")
1395
+ path = _require_str(value, "path", context)
1396
+ expected_path = f"goldens/{model_id}.safetensors"
1397
+ if path != expected_path:
1398
+ raise RegistryError(f"{context}.path must be {expected_path!r}.")
1399
+ sha256 = _require_str(value, "sha256", context)
1400
+ if sha256 != model.official_golden.tensors.digest:
1401
+ raise RegistryError(
1402
+ f"{context}.sha256 must equal the official_golden tensors digest of {model_id!r}."
1403
+ )
1404
+ size = value.get("size")
1405
+ if isinstance(size, bool) or not isinstance(size, int) or size <= 0:
1406
+ raise RegistryError(f"{context}.size must be a positive byte count.")
1407
+ offline_behavior = _require_str(value, "offline_behavior", context)
1408
+ if offline_behavior not in _ALLOWED_RUNTIME_ASSET_OFFLINE_BEHAVIORS:
1409
+ raise RegistryError(
1410
+ f"{context}.offline_behavior is unsupported: {offline_behavior!r}."
1411
+ )
1412
+ golden_artifacts[model_id] = GoldenArtifact(
1413
+ model_id=model_id,
1414
+ repository=repository,
1415
+ revision=revision,
1416
+ path=path,
1417
+ sha256=sha256,
1418
+ size=size,
1419
+ offline_behavior=offline_behavior,
1420
+ )
1421
+ return golden_artifacts
1422
+
1423
+
1424
  def _parse_confidence_adaptation(
1425
  table: Mapping[str, Any], context: str, model_id: str | None = None
1426
  ) -> ConfidenceAdaptation | None:
 
1800
  )
1801
 
1802
 
1803
+ def _parse_sparse_autoencoders(
1804
+ raw: object, models: Mapping[str, ModelSpec]
1805
+ ) -> dict[str, SparseAutoencoderSpec]:
1806
+ """Validate optional SAE records independently of the base-model mapping."""
1807
+ if raw is None:
1808
+ return {}
1809
+ if not isinstance(raw, list):
1810
+ raise RegistryError("sparse_autoencoders must be an array of tables.")
1811
+ allowed = frozenset({
1812
+ "id", "base_model", "layer", "input_width", "k", "codebook_dim", "input_kind",
1813
+ "checkpoint_repo", "checkpoint_revision", "checkpoint_files",
1814
+ })
1815
+ records: dict[str, SparseAutoencoderSpec] = {}
1816
+ selections: set[tuple[str, int, int, int]] = set()
1817
+ for index, table in enumerate(raw):
1818
+ context = f"sparse_autoencoders[{index}]"
1819
+ if not isinstance(table, dict):
1820
+ raise RegistryError(f"{context} must be a table.")
1821
+ _reject_unknown_fields(table, allowed, context)
1822
+ identifier = _require_str(table, "id", context)
1823
+ if _IDENTIFIER_RE.fullmatch(identifier) is None or identifier in records:
1824
+ raise RegistryError(f"{context} has an invalid or duplicate SAE id: {identifier!r}.")
1825
+ base = _require_str(table, "base_model", context)
1826
+ if base not in models or models[base].family.id != "esm_plusplus":
1827
+ raise RegistryError(f"{context}.base_model must name a registered ESMC base.")
1828
+ numbers: dict[str, int] = {}
1829
+ for field in ("layer", "input_width", "k", "codebook_dim"):
1830
+ value = table.get(field)
1831
+ if type(value) is not int or value < (0 if field == "layer" else 1):
1832
+ raise RegistryError(f"{context}.{field} must be a valid integer dimension.")
1833
+ numbers[field] = value
1834
+ if numbers["k"] > numbers["codebook_dim"]:
1835
+ raise RegistryError(f"{context}.k exceeds codebook_dim.")
1836
+ if _require_str(table, "input_kind", context) != "hidden_state":
1837
+ raise RegistryError(f"{context} requires hidden_state input, not residual updates.")
1838
+ checkpoint = _parse_checkpoint(table, "checkpoint", context)
1839
+ expected_files = {"config.json", f"layer_{numbers['layer']}.safetensors"}
1840
+ if set(checkpoint.file_map) != expected_files or any(
1841
+ item.algorithm != "sha256" for item in checkpoint.files
1842
+ ):
1843
+ raise RegistryError(f"{context} must SHA-256 pin exactly {sorted(expected_files)}.")
1844
+ selection = (base, numbers["layer"], numbers["k"], numbers["codebook_dim"])
1845
+ if selection in selections:
1846
+ raise RegistryError(f"{context} duplicates an SAE selection: {selection}.")
1847
+ selections.add(selection)
1848
+ records[identifier] = SparseAutoencoderSpec(
1849
+ id=identifier, base_model=base, checkpoint=checkpoint, **numbers
1850
+ )
1851
+ return records
1852
+
1853
+
1854
  def _load_manifest_bytes(raw_bytes: bytes) -> ModelRegistry:
1855
  try:
1856
  manifest = tomllib.loads(raw_bytes.decode("utf-8"))
 
1869
  runtime_assets = _parse_runtime_assets(manifest.get("runtime_assets"), families)
1870
  models = _parse_models(manifest.get("models"), families)
1871
  _validate_registry(upstreams, attention_kernels, families, models)
1872
+ golden_artifacts = _parse_golden_artifacts(manifest.get("golden_artifacts"), models)
1873
+ sparse_autoencoders = _parse_sparse_autoencoders(manifest.get("sparse_autoencoders"), models)
1874
  return ModelRegistry(
1875
  schema_version=1,
1876
  upstreams=upstreams,
 
1879
  models=models,
1880
  runtime_assets=runtime_assets,
1881
  legal_files=legal_files,
1882
+ golden_artifacts=golden_artifacts,
1883
+ sparse_autoencoders=sparse_autoencoders,
1884
  )
1885
 
1886
 
 
1917
  "CheckpointSource",
1918
  "FileDigest",
1919
  "GenerationContract",
1920
+ "GoldenArtifact",
1921
  "ModelFamily",
1922
  "ModelRegistry",
1923
  "ModelSpec",
 
1926
  "RuntimeAsset",
1927
  "RuntimeAssetTrustKind",
1928
  "RuntimeExtra",
1929
+ "SparseAutoencoderSpec",
1930
  "TestTier",
1931
  "UpstreamSource",
1932
  "VramTier",
fastplms_bundle.py CHANGED
The diff for this file is too large to render. See raw diff
 
modeling_fastplms.py CHANGED
@@ -13,7 +13,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
13
 
14
  from .fastplms_bundle import RUNTIME_DATA, RUNTIME_HASH
15
 
16
- if RUNTIME_HASH != "492b2c57aeef44795a403c858cf27184fde20e2b231a5e8278dfa5c2f8999aed":
17
  raise RuntimeError("FastPLMs runtime identity differs from the bridge.")
18
 
19
  _RUNTIME_TEMPORARIES = []
 
13
 
14
  from .fastplms_bundle import RUNTIME_DATA, RUNTIME_HASH
15
 
16
+ if RUNTIME_HASH != "bc88b7fe9bf0dd2e7b5b3337e94c4d316bf38106418cdccdd1e27882f91a1659":
17
  raise RuntimeError("FastPLMs runtime identity differs from the bridge.")
18
 
19
  _RUNTIME_TEMPORARIES = []
requirements.txt CHANGED
@@ -1,20 +1,20 @@
1
  # Direct runtime dependencies for Synthyra/Boltz2.
2
  # FastPLMs source is embedded in this model repository.
3
- torch>=2.13,<2.14
4
- transformers>=5.13,<5.14
5
- huggingface-hub>=0.34,<2
6
- tokenizers>=0.22,<0.23
7
- safetensors>=0.5,<1
8
- numpy>=1.26,<3
9
- einops>=0.8,<1
10
- tqdm>=4.67,<5
11
- accelerate>=1.10,<2
12
- biopython>=1.85,<2
13
- biotite>=1.4,<2
14
- brotli>=1.1,<2
15
- msgpack>=1.1,<2
16
- msgpack-numpy>=0.4.8,<1
17
- omegaconf>=2.3,<3
18
- rdkit>=2025.9,<2027
19
- scipy>=1.15,<2
20
- zstandard>=0.23,<1
 
1
  # Direct runtime dependencies for Synthyra/Boltz2.
2
  # FastPLMs source is embedded in this model repository.
3
+ torch>=2.14
4
+ transformers>=5.17
5
+ huggingface-hub>=1.32
6
+ tokenizers>=0.23
7
+ safetensors>=0.8
8
+ numpy>=2.5
9
+ einops>=0.8
10
+ tqdm>=4.70
11
+ accelerate>=1.15
12
+ biopython>=1.88
13
+ biotite>=1.7
14
+ brotli>=1.2
15
+ msgpack>=1.2
16
+ msgpack-numpy>=0.4.8
17
+ omegaconf>=2.3
18
+ rdkit>=2026.3
19
+ scipy>=1.18
20
+ zstandard>=0.25