Instructions to use Synthyra/ESMplusplus_large with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Synthyra/ESMplusplus_large with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("fill-mask", model="Synthyra/ESMplusplus_large", trust_remote_code=True)# Load model directly from transformers import AutoModelForMaskedLM model = AutoModelForMaskedLM.from_pretrained("Synthyra/ESMplusplus_large", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Update FastPLMs files
Browse files
README.md
CHANGED
|
@@ -237,16 +237,13 @@ sequence-group IDs. `-1` marks padding. Omit `sequence_id` to use
|
|
| 237 |
|
| 238 |
ESM++ supports hidden-state SAEs from the official
|
| 239 |
[Biohub ESMC SAE collection](https://huggingface.co/collections/biohub/esmc-saes-for-hidden-states-all-layers).
|
| 240 |
-
|
| 241 |
-
|
| 242 |
|
| 243 |
```python
|
| 244 |
import torch
|
| 245 |
-
from transformers import AutoModel
|
| 246 |
|
| 247 |
-
|
| 248 |
-
sae.initialize_layers([27])
|
| 249 |
-
model.add_sae_models([sae.layers["27"]])
|
| 250 |
|
| 251 |
with torch.inference_mode():
|
| 252 |
output = model(**batch, normalize_sae=True)
|
|
@@ -255,6 +252,12 @@ features = output.sae_outputs["layer27"]
|
|
| 255 |
print(features.shape, features.layout) # (valid_token_count, codebook_dim), sparse COO
|
| 256 |
```
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
SAEs run after you attach them. Use `compute_sae=False` to skip SAE work.
|
| 259 |
Outputs are detached sparse tensors with keys such as `layer{N}`. They omit
|
| 260 |
padding. The model uses `sequence_id`, then `attention_mask`, for padding.
|
|
|
|
| 237 |
|
| 238 |
ESM++ supports hidden-state SAEs from the official
|
| 239 |
[Biohub ESMC SAE collection](https://huggingface.co/collections/biohub/esmc-saes-for-hidden-states-all-layers).
|
| 240 |
+
This artifact implements the SAE contract, so no Biohub runtime code is needed.
|
| 241 |
+
Select an SAE for this ESMC scale, then load only the layers you need:
|
| 242 |
|
| 243 |
```python
|
| 244 |
import torch
|
|
|
|
| 245 |
|
| 246 |
+
model.load_sae_models("biohub/ESMC-600M-sae-layer27-k64-codebook65536", [27])
|
|
|
|
|
|
|
| 247 |
|
| 248 |
with torch.inference_mode():
|
| 249 |
output = model(**batch, normalize_sae=True)
|
|
|
|
| 252 |
print(features.shape, features.layout) # (valid_token_count, codebook_dim), sparse COO
|
| 253 |
```
|
| 254 |
|
| 255 |
+
`load_sae_models` reads the shared `config.json` and one
|
| 256 |
+
`layer_{index}.safetensors` shard per requested layer, from a Hub repository
|
| 257 |
+
or a local directory, and attaches the layers on the model device in the model
|
| 258 |
+
dtype. `add_sae_models` still accepts official Biohub `ESMCSAEModel.layers`
|
| 259 |
+
entries.
|
| 260 |
+
|
| 261 |
SAEs run after you attach them. Use `compute_sae=False` to skip SAE work.
|
| 262 |
Outputs are detached sparse tensors with keys such as `layer{N}`. They omit
|
| 263 |
padding. The model uses `sequence_id`, then `attention_mask`, for padding.
|
fastplms/models/esm_plusplus/modeling_esm_plusplus.py
CHANGED
|
@@ -5,6 +5,8 @@ from __future__ import annotations
|
|
| 5 |
import importlib
|
| 6 |
import importlib.metadata
|
| 7 |
import math
|
|
|
|
|
|
|
| 8 |
from contextlib import contextmanager
|
| 9 |
from dataclasses import asdict, dataclass
|
| 10 |
from functools import partial
|
|
@@ -25,6 +27,8 @@ from transformers.modeling_outputs import (
|
|
| 25 |
TokenClassifierOutput,
|
| 26 |
)
|
| 27 |
|
|
|
|
|
|
|
| 28 |
|
| 29 |
try:
|
| 30 |
from fastplms.attention import (
|
|
@@ -1115,14 +1119,48 @@ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
|
|
| 1115 |
)
|
| 1116 |
return self._esmc_precision_status
|
| 1117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1118 |
def add_sae_models(self, sae_models: list[nn.Module]) -> None:
|
| 1119 |
-
"""Attach
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1120 |
|
| 1121 |
for sae_model in sae_models:
|
| 1122 |
if not isinstance(sae_model, nn.Module):
|
| 1123 |
raise TypeError(
|
| 1124 |
-
"Each SAE must be an nn.Module
|
| 1125 |
-
"ESMCSAEModel.layers entry."
|
| 1126 |
)
|
| 1127 |
layer = getattr(sae_model, "layer", None)
|
| 1128 |
if isinstance(layer, bool) or not isinstance(layer, int):
|
|
@@ -1162,11 +1200,14 @@ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
|
|
| 1162 |
) -> tuple[tuple[int, ...], torch.Tensor | None]:
|
| 1163 |
if not compute_sae or not self._sae_models:
|
| 1164 |
return (), None
|
| 1165 |
-
if input_ids is None:
|
|
|
|
|
|
|
| 1166 |
raise ValueError(
|
| 1167 |
-
"SAE computation
|
|
|
|
| 1168 |
)
|
| 1169 |
-
if torch.any(input_ids == self.config.mask_token_id):
|
| 1170 |
raise ValueError("SAE inputs must not contain mask tokens; SAEs were trained unmasked.")
|
| 1171 |
if sequence_id is not None:
|
| 1172 |
token_mask = sequence_id >= 0
|
|
@@ -1183,7 +1224,15 @@ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
|
|
| 1183 |
token_mask: torch.Tensor | None,
|
| 1184 |
*,
|
| 1185 |
normalize_sae: bool,
|
|
|
|
| 1186 |
) -> dict[str, torch.Tensor] | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1187 |
if not self._sae_models:
|
| 1188 |
return None
|
| 1189 |
if hidden_states is None or token_mask is None:
|
|
@@ -1193,14 +1242,18 @@ class PreTrainedESMplusplusModel(FastPLMsAttentionMixin, PreTrainedModel):
|
|
| 1193 |
layer = int(key.removeprefix("layer"))
|
| 1194 |
if layer not in hidden_states:
|
| 1195 |
raise RuntimeError(f"ESM++ did not collect the requested SAE layer {layer}.")
|
| 1196 |
-
|
|
|
|
|
|
|
|
|
|
| 1197 |
features = getattr(sae_output, "feature_magnitudes", None)
|
| 1198 |
if not isinstance(features, torch.Tensor):
|
| 1199 |
raise TypeError("SAE get_sae_output must return tensor feature_magnitudes.")
|
| 1200 |
-
|
|
|
|
| 1201 |
if normalize_sae:
|
| 1202 |
features = (features / sae_model.max) * sae_model.idf
|
| 1203 |
-
outputs[key] = features.to_sparse()
|
| 1204 |
return outputs
|
| 1205 |
|
| 1206 |
def _pad_fp8_inputs(
|
|
@@ -1422,6 +1475,7 @@ class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin):
|
|
| 1422 |
return_dict: bool | None = None,
|
| 1423 |
compute_sae: bool = True,
|
| 1424 |
normalize_sae: bool = False,
|
|
|
|
| 1425 |
) -> TransformerOutput | tuple[torch.Tensor, ...]:
|
| 1426 |
"""Run ESMC inference with the pinned Biohub mask precedence.
|
| 1427 |
|
|
@@ -1475,6 +1529,7 @@ class ESMplusplusModel(PreTrainedESMplusplusModel, EmbeddingMixin):
|
|
| 1475 |
transformer_output.sae_hidden_states,
|
| 1476 |
sae_token_mask,
|
| 1477 |
normalize_sae=normalize_sae,
|
|
|
|
| 1478 |
)
|
| 1479 |
if sae_layers
|
| 1480 |
else None
|
|
@@ -1571,6 +1626,7 @@ class ESMplusplusForMaskedLM(
|
|
| 1571 |
compute_logits: bool = True,
|
| 1572 |
compute_sae: bool = True,
|
| 1573 |
normalize_sae: bool = False,
|
|
|
|
| 1574 |
) -> ESMplusplusOutput | tuple[torch.Tensor, ...]:
|
| 1575 |
if input_ids is None and inputs_embeds is None:
|
| 1576 |
raise ValueError("You have to specify either input_ids or inputs_embeds")
|
|
@@ -1617,6 +1673,7 @@ class ESMplusplusForMaskedLM(
|
|
| 1617 |
output.sae_hidden_states,
|
| 1618 |
sae_token_mask,
|
| 1619 |
normalize_sae=normalize_sae,
|
|
|
|
| 1620 |
)
|
| 1621 |
if sae_layers
|
| 1622 |
else None
|
|
|
|
| 5 |
import importlib
|
| 6 |
import importlib.metadata
|
| 7 |
import math
|
| 8 |
+
import os
|
| 9 |
+
from collections.abc import Sequence
|
| 10 |
from contextlib import contextmanager
|
| 11 |
from dataclasses import asdict, dataclass
|
| 12 |
from functools import partial
|
|
|
|
| 27 |
TokenClassifierOutput,
|
| 28 |
)
|
| 29 |
|
| 30 |
+
from .modeling_esm_plusplus_sae import ESMplusplusSAELayer, load_esmc_sae_layers
|
| 31 |
+
|
| 32 |
|
| 33 |
try:
|
| 34 |
from fastplms.attention import (
|
|
|
|
| 1119 |
)
|
| 1120 |
return self._esmc_precision_status
|
| 1121 |
|
| 1122 |
+
def load_sae_models(
|
| 1123 |
+
self,
|
| 1124 |
+
repository: str | os.PathLike[str],
|
| 1125 |
+
layers: Sequence[int],
|
| 1126 |
+
*,
|
| 1127 |
+
revision: str | None = None,
|
| 1128 |
+
cache_dir: str | os.PathLike[str] | None = None,
|
| 1129 |
+
token: str | bool | None = None,
|
| 1130 |
+
local_files_only: bool = False,
|
| 1131 |
+
dtype: torch.dtype | None = None,
|
| 1132 |
+
) -> dict[int, ESMplusplusSAELayer]:
|
| 1133 |
+
"""Load hidden-state SAE layers from a Hub repository or local directory, then attach them.
|
| 1134 |
+
|
| 1135 |
+
The layers land on this model's device and, unless ``dtype`` says otherwise, in this
|
| 1136 |
+
model's parameter dtype, so they consume its hidden states without a dtype mismatch.
|
| 1137 |
+
"""
|
| 1138 |
+
|
| 1139 |
+
sae_layers = load_esmc_sae_layers(
|
| 1140 |
+
repository,
|
| 1141 |
+
layers,
|
| 1142 |
+
revision=revision,
|
| 1143 |
+
cache_dir=cache_dir,
|
| 1144 |
+
token=token,
|
| 1145 |
+
local_files_only=local_files_only,
|
| 1146 |
+
device=self.device,
|
| 1147 |
+
dtype=self.dtype if dtype is None else dtype,
|
| 1148 |
+
)
|
| 1149 |
+
self.add_sae_models(list(sae_layers.values()))
|
| 1150 |
+
return sae_layers
|
| 1151 |
+
|
| 1152 |
def add_sae_models(self, sae_models: list[nn.Module]) -> None:
|
| 1153 |
+
"""Attach hidden-state SAE layers to this ESM++ model.
|
| 1154 |
+
|
| 1155 |
+
Accepts layers from ``load_sae_models`` and official Biohub ``ESMCSAEModel.layers``
|
| 1156 |
+
entries, which share one attachment contract.
|
| 1157 |
+
"""
|
| 1158 |
|
| 1159 |
for sae_model in sae_models:
|
| 1160 |
if not isinstance(sae_model, nn.Module):
|
| 1161 |
raise TypeError(
|
| 1162 |
+
"Each SAE must be an nn.Module exposing the hidden-state SAE contract, such "
|
| 1163 |
+
"as a load_sae_models layer or an official Biohub ESMCSAEModel.layers entry."
|
| 1164 |
)
|
| 1165 |
layer = getattr(sae_model, "layer", None)
|
| 1166 |
if isinstance(layer, bool) or not isinstance(layer, int):
|
|
|
|
| 1200 |
) -> tuple[tuple[int, ...], torch.Tensor | None]:
|
| 1201 |
if not compute_sae or not self._sae_models:
|
| 1202 |
return (), None
|
| 1203 |
+
if input_ids is None and attention_mask is None and sequence_id is None:
|
| 1204 |
+
# Embedding inputs carry no token identities, so the caller must supply the mask.
|
| 1205 |
+
# Rejecting masked tokens is then the caller's precondition rather than ours.
|
| 1206 |
raise ValueError(
|
| 1207 |
+
"SAE computation from inputs_embeds requires an explicit attention_mask or "
|
| 1208 |
+
"sequence_id, because the token mask cannot be recovered from embeddings."
|
| 1209 |
)
|
| 1210 |
+
if input_ids is not None and torch.any(input_ids == self.config.mask_token_id):
|
| 1211 |
raise ValueError("SAE inputs must not contain mask tokens; SAEs were trained unmasked.")
|
| 1212 |
if sequence_id is not None:
|
| 1213 |
token_mask = sequence_id >= 0
|
|
|
|
| 1224 |
token_mask: torch.Tensor | None,
|
| 1225 |
*,
|
| 1226 |
normalize_sae: bool,
|
| 1227 |
+
differentiable_sae: bool = False,
|
| 1228 |
) -> dict[str, torch.Tensor] | None:
|
| 1229 |
+
"""Encode collected hidden states with every attached SAE.
|
| 1230 |
+
|
| 1231 |
+
The default detaches and sparsifies, which is right for interpretation and matches the
|
| 1232 |
+
official implementation. ``differentiable_sae`` instead keeps the result attached to the
|
| 1233 |
+
graph and dense, so a gradient-based sequence designer can optimize an objective built on
|
| 1234 |
+
SAE features. The arithmetic is identical either way; only the tape and the layout differ.
|
| 1235 |
+
"""
|
| 1236 |
if not self._sae_models:
|
| 1237 |
return None
|
| 1238 |
if hidden_states is None or token_mask is None:
|
|
|
|
| 1242 |
layer = int(key.removeprefix("layer"))
|
| 1243 |
if layer not in hidden_states:
|
| 1244 |
raise RuntimeError(f"ESM++ did not collect the requested SAE layer {layer}.")
|
| 1245 |
+
layer_states = hidden_states[layer]
|
| 1246 |
+
sae_output = sae_model.get_sae_output(
|
| 1247 |
+
layer_states if differentiable_sae else layer_states.clone(), token_mask
|
| 1248 |
+
)
|
| 1249 |
features = getattr(sae_output, "feature_magnitudes", None)
|
| 1250 |
if not isinstance(features, torch.Tensor):
|
| 1251 |
raise TypeError("SAE get_sae_output must return tensor feature_magnitudes.")
|
| 1252 |
+
if not differentiable_sae:
|
| 1253 |
+
features = features.detach()
|
| 1254 |
if normalize_sae:
|
| 1255 |
features = (features / sae_model.max) * sae_model.idf
|
| 1256 |
+
outputs[key] = features if differentiable_sae else features.to_sparse()
|
| 1257 |
return outputs
|
| 1258 |
|
| 1259 |
def _pad_fp8_inputs(
|
|
|
|
| 1475 |
return_dict: bool | None = None,
|
| 1476 |
compute_sae: bool = True,
|
| 1477 |
normalize_sae: bool = False,
|
| 1478 |
+
differentiable_sae: bool = False,
|
| 1479 |
) -> TransformerOutput | tuple[torch.Tensor, ...]:
|
| 1480 |
"""Run ESMC inference with the pinned Biohub mask precedence.
|
| 1481 |
|
|
|
|
| 1529 |
transformer_output.sae_hidden_states,
|
| 1530 |
sae_token_mask,
|
| 1531 |
normalize_sae=normalize_sae,
|
| 1532 |
+
differentiable_sae=differentiable_sae,
|
| 1533 |
)
|
| 1534 |
if sae_layers
|
| 1535 |
else None
|
|
|
|
| 1626 |
compute_logits: bool = True,
|
| 1627 |
compute_sae: bool = True,
|
| 1628 |
normalize_sae: bool = False,
|
| 1629 |
+
differentiable_sae: bool = False,
|
| 1630 |
) -> ESMplusplusOutput | tuple[torch.Tensor, ...]:
|
| 1631 |
if input_ids is None and inputs_embeds is None:
|
| 1632 |
raise ValueError("You have to specify either input_ids or inputs_embeds")
|
|
|
|
| 1673 |
output.sae_hidden_states,
|
| 1674 |
sae_token_mask,
|
| 1675 |
normalize_sae=normalize_sae,
|
| 1676 |
+
differentiable_sae=differentiable_sae,
|
| 1677 |
)
|
| 1678 |
if sae_layers
|
| 1679 |
else None
|
fastplms/models/esm_plusplus/modeling_esm_plusplus_sae.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hidden-state sparse autoencoders for ESM++ (ESMC) models.
|
| 2 |
+
|
| 3 |
+
FastPLMs implements the published Biohub hidden-state SAE contract directly, so attaching an SAE
|
| 4 |
+
needs only PyTorch, Transformers, and the checkpoint itself. Biohub still owns the SAE weights:
|
| 5 |
+
this module reads their published repository layout, one shared ``config.json`` plus one
|
| 6 |
+
``layer_{index}.safetensors`` shard per backbone layer, and never redistributes those tensors.
|
| 7 |
+
|
| 8 |
+
A layer produced here satisfies the same attachment contract as an official Biohub
|
| 9 |
+
``ESMCSAEModel.layers`` entry, so ``PreTrainedESMplusplusModel.add_sae_models`` accepts either.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import torch
|
| 17 |
+
import torch.nn as nn
|
| 18 |
+
import torch.nn.functional as F
|
| 19 |
+
|
| 20 |
+
from collections.abc import Sequence
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from huggingface_hub import hf_hub_download
|
| 24 |
+
from safetensors.torch import load_file
|
| 25 |
+
from torch import Tensor
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
SAE_CONFIG_FILE = "config.json"
|
| 29 |
+
SAE_REQUIRED_CONFIG_FIELDS = ("d_model", "codebook_dim", "k")
|
| 30 |
+
# Trained per-feature statistics. Shards that never trained them omit them, and the ones default
|
| 31 |
+
# makes the (features / max) * idf normalization an identity.
|
| 32 |
+
SAE_OPTIONAL_STATE_NAMES = ("idf", "max")
|
| 33 |
+
STANDARDIZATION_EPSILON = 1e-5
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True, slots=True)
|
| 37 |
+
class ESMplusplusSAEParams:
|
| 38 |
+
"""Shape contract of one hidden-state SAE.
|
| 39 |
+
|
| 40 |
+
``layer`` indexes the ESM++ hidden state the SAE reads, where ``0`` is the embedding output and
|
| 41 |
+
``num_hidden_layers`` is the final normalized state.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
d_model: int
|
| 45 |
+
codebook_dim: int
|
| 46 |
+
k: int
|
| 47 |
+
layer: int
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass(slots=True)
|
| 51 |
+
class ESMplusplusSAEOutput:
|
| 52 |
+
"""Sparse features, and the optional reconstruction error, for one batch of residues."""
|
| 53 |
+
|
| 54 |
+
feature_magnitudes: Tensor
|
| 55 |
+
reconstruction_loss: Tensor | None = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _standardize_residue_states(residue_states: Tensor) -> Tensor:
|
| 59 |
+
"""Center and scale each residue vector the way the SAEs were trained."""
|
| 60 |
+
|
| 61 |
+
# residue_states: (n, d)
|
| 62 |
+
centered = residue_states - residue_states.mean(dim=-1, keepdim=True) # (n, d)
|
| 63 |
+
return centered / (centered.std(dim=-1, keepdim=True) + STANDARDIZATION_EPSILON) # (n, d)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ESMplusplusSAELayer(nn.Module):
|
| 67 |
+
"""Top-k sparse autoencoder over one ESM++ hidden state.
|
| 68 |
+
|
| 69 |
+
Encoding standardizes each residue vector, projects it into a wide codebook, and keeps only the
|
| 70 |
+
``k`` largest activations. The decoder is used only to measure reconstruction error, which is
|
| 71 |
+
why it is opt-in: interpretation and gradient-based design read ``feature_magnitudes`` alone.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
idf: Tensor
|
| 75 |
+
max: Tensor
|
| 76 |
+
|
| 77 |
+
def __init__(self, params: ESMplusplusSAEParams) -> None:
|
| 78 |
+
super().__init__()
|
| 79 |
+
self.params = params
|
| 80 |
+
self.W_enc = nn.Parameter(torch.empty(params.d_model, params.codebook_dim)) # (d, c)
|
| 81 |
+
self.W_dec = nn.Parameter(torch.empty(params.codebook_dim, params.d_model)) # (c, d)
|
| 82 |
+
self.b_dec = nn.Parameter(torch.zeros(params.d_model)) # (d,)
|
| 83 |
+
self.register_buffer("idf", torch.ones(params.codebook_dim)) # (c,)
|
| 84 |
+
self.register_buffer("max", torch.ones(params.codebook_dim)) # (c,)
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def layer(self) -> int:
|
| 88 |
+
"""ESM++ hidden-state index this SAE was trained against."""
|
| 89 |
+
|
| 90 |
+
return self.params.layer
|
| 91 |
+
|
| 92 |
+
def forward(
|
| 93 |
+
self,
|
| 94 |
+
residue_states: Tensor,
|
| 95 |
+
*,
|
| 96 |
+
compute_reconstruction_loss: bool = False,
|
| 97 |
+
) -> ESMplusplusSAEOutput:
|
| 98 |
+
# residue_states: (n, d) for n residues; c is the codebook width, k the retained features
|
| 99 |
+
standardized = _standardize_residue_states(residue_states) # (n, d)
|
| 100 |
+
preactivations = F.relu((standardized - self.b_dec) @ self.W_enc) # (n, c)
|
| 101 |
+
retained = torch.topk(preactivations, self.params.k, dim=-1) # values, indices: (n, k)
|
| 102 |
+
feature_magnitudes = torch.zeros_like(preactivations).scatter(
|
| 103 |
+
-1, retained.indices, retained.values
|
| 104 |
+
) # (n, c)
|
| 105 |
+
if not compute_reconstruction_loss:
|
| 106 |
+
return ESMplusplusSAEOutput(feature_magnitudes=feature_magnitudes)
|
| 107 |
+
|
| 108 |
+
reconstructed = feature_magnitudes @ self.W_dec + self.b_dec # (n, d)
|
| 109 |
+
return ESMplusplusSAEOutput(
|
| 110 |
+
feature_magnitudes=feature_magnitudes,
|
| 111 |
+
reconstruction_loss=(reconstructed - standardized).pow(2).mean(dim=-1), # (n,)
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
def get_sae_output(self, layer_states: Tensor, token_mask: Tensor) -> ESMplusplusSAEOutput:
|
| 115 |
+
"""Encode the unpadded residues of one ESM++ hidden state.
|
| 116 |
+
|
| 117 |
+
This name and signature are the attachment contract that
|
| 118 |
+
``PreTrainedESMplusplusModel.add_sae_models`` validates and calls.
|
| 119 |
+
"""
|
| 120 |
+
|
| 121 |
+
# layer_states: (b, l, d); token_mask: (b, l)
|
| 122 |
+
residue_states = layer_states[token_mask] # (n, d) for n valid tokens
|
| 123 |
+
encoded: ESMplusplusSAEOutput = self(residue_states)
|
| 124 |
+
return encoded
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _repository_file(
|
| 128 |
+
repository: str | os.PathLike[str],
|
| 129 |
+
filename: str,
|
| 130 |
+
*,
|
| 131 |
+
revision: str | None,
|
| 132 |
+
cache_dir: str | os.PathLike[str] | None,
|
| 133 |
+
token: str | bool | None,
|
| 134 |
+
local_files_only: bool,
|
| 135 |
+
) -> Path:
|
| 136 |
+
"""Resolve one repository file, from a local directory or the Hub cache."""
|
| 137 |
+
|
| 138 |
+
# A directory counts as local only when it holds the shared config, so a stale directory named
|
| 139 |
+
# like a Hub identifier cannot shadow the download.
|
| 140 |
+
local_directory = Path(repository)
|
| 141 |
+
if (local_directory / SAE_CONFIG_FILE).is_file():
|
| 142 |
+
path = local_directory / filename
|
| 143 |
+
if not path.is_file():
|
| 144 |
+
raise FileNotFoundError(f"SAE repository {local_directory} has no {filename}.")
|
| 145 |
+
return path
|
| 146 |
+
|
| 147 |
+
return Path(
|
| 148 |
+
hf_hub_download(
|
| 149 |
+
repo_id=str(repository),
|
| 150 |
+
filename=filename,
|
| 151 |
+
revision=revision,
|
| 152 |
+
cache_dir=None if cache_dir is None else str(cache_dir),
|
| 153 |
+
token=token,
|
| 154 |
+
local_files_only=local_files_only,
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _load_sae_layer(
|
| 160 |
+
shard_path: Path,
|
| 161 |
+
*,
|
| 162 |
+
params: ESMplusplusSAEParams,
|
| 163 |
+
device: torch.device,
|
| 164 |
+
dtype: torch.dtype | None,
|
| 165 |
+
) -> ESMplusplusSAELayer:
|
| 166 |
+
state = load_file(str(shard_path), device=str(device))
|
| 167 |
+
encoder = state.get("W_enc")
|
| 168 |
+
if encoder is None:
|
| 169 |
+
raise ValueError(f"{shard_path} is not an ESMC SAE shard; it has no 'W_enc' entry.")
|
| 170 |
+
|
| 171 |
+
# Build on the meta device so the shard tensors are the only materialized copy of a codebook
|
| 172 |
+
# that reaches roughly one gigabyte for the widest published SAEs.
|
| 173 |
+
with torch.device("meta"):
|
| 174 |
+
sae_layer = ESMplusplusSAELayer(params)
|
| 175 |
+
sae_layer.to(dtype=encoder.dtype if dtype is None else dtype)
|
| 176 |
+
sae_layer.to_empty(device=device)
|
| 177 |
+
# to_empty leaves the statistics buffers uninitialized, so restore the identity defaults that
|
| 178 |
+
# shards without trained statistics rely on.
|
| 179 |
+
sae_layer.idf.fill_(1.0)
|
| 180 |
+
sae_layer.max.fill_(1.0)
|
| 181 |
+
|
| 182 |
+
incompatible = sae_layer.load_state_dict(state, strict=False)
|
| 183 |
+
missing = tuple(
|
| 184 |
+
name for name in incompatible.missing_keys if name not in SAE_OPTIONAL_STATE_NAMES
|
| 185 |
+
)
|
| 186 |
+
unexpected = tuple(incompatible.unexpected_keys)
|
| 187 |
+
if missing or unexpected:
|
| 188 |
+
raise ValueError(
|
| 189 |
+
f"{shard_path} does not match the ESMC SAE state contract; "
|
| 190 |
+
f"missing {list(missing)}, unexpected {list(unexpected)}."
|
| 191 |
+
)
|
| 192 |
+
return sae_layer
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def load_esmc_sae_layers(
|
| 196 |
+
repository: str | os.PathLike[str],
|
| 197 |
+
layers: Sequence[int],
|
| 198 |
+
*,
|
| 199 |
+
revision: str | None = None,
|
| 200 |
+
cache_dir: str | os.PathLike[str] | None = None,
|
| 201 |
+
token: str | bool | None = None,
|
| 202 |
+
local_files_only: bool = False,
|
| 203 |
+
device: torch.device | str = "cpu",
|
| 204 |
+
dtype: torch.dtype | None = None,
|
| 205 |
+
) -> dict[int, ESMplusplusSAELayer]:
|
| 206 |
+
"""Load the requested hidden-state SAE layers from a Hub repository or a local directory.
|
| 207 |
+
|
| 208 |
+
Only the shared config and the requested shards are read, so a repository that publishes every
|
| 209 |
+
backbone layer costs one shard per requested layer. ``dtype`` defaults to the dtype stored in
|
| 210 |
+
the shard; pass the ESM++ model dtype when the SAE has to consume its hidden states directly.
|
| 211 |
+
"""
|
| 212 |
+
|
| 213 |
+
requested = tuple(dict.fromkeys(int(layer) for layer in layers))
|
| 214 |
+
if not requested:
|
| 215 |
+
raise ValueError("Loading SAE layers requires at least one backbone layer index.")
|
| 216 |
+
|
| 217 |
+
config_path = _repository_file(
|
| 218 |
+
repository,
|
| 219 |
+
SAE_CONFIG_FILE,
|
| 220 |
+
revision=revision,
|
| 221 |
+
cache_dir=cache_dir,
|
| 222 |
+
token=token,
|
| 223 |
+
local_files_only=local_files_only,
|
| 224 |
+
)
|
| 225 |
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
| 226 |
+
missing_fields = [name for name in SAE_REQUIRED_CONFIG_FIELDS if name not in config]
|
| 227 |
+
if missing_fields:
|
| 228 |
+
raise ValueError(f"{config_path} is not an ESMC SAE config; it omits {missing_fields}.")
|
| 229 |
+
available = tuple(int(index) for index in config.get("available_layers", ()))
|
| 230 |
+
|
| 231 |
+
target_device = torch.device(device)
|
| 232 |
+
sae_layers: dict[int, ESMplusplusSAELayer] = {}
|
| 233 |
+
for layer in requested:
|
| 234 |
+
if available and layer not in available:
|
| 235 |
+
raise ValueError(
|
| 236 |
+
f"SAE repository {repository} does not publish layer {layer}; "
|
| 237 |
+
f"available layers are {list(available)}."
|
| 238 |
+
)
|
| 239 |
+
shard_path = _repository_file(
|
| 240 |
+
repository,
|
| 241 |
+
f"layer_{layer}.safetensors",
|
| 242 |
+
revision=revision,
|
| 243 |
+
cache_dir=cache_dir,
|
| 244 |
+
token=token,
|
| 245 |
+
local_files_only=local_files_only,
|
| 246 |
+
)
|
| 247 |
+
sae_layers[layer] = _load_sae_layer(
|
| 248 |
+
shard_path,
|
| 249 |
+
params=ESMplusplusSAEParams(
|
| 250 |
+
d_model=int(config["d_model"]),
|
| 251 |
+
codebook_dim=int(config["codebook_dim"]),
|
| 252 |
+
k=int(config["k"]),
|
| 253 |
+
layer=layer,
|
| 254 |
+
),
|
| 255 |
+
device=target_device,
|
| 256 |
+
dtype=dtype,
|
| 257 |
+
)
|
| 258 |
+
return sae_layers
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
__all__ = [
|
| 262 |
+
"ESMplusplusSAELayer",
|
| 263 |
+
"ESMplusplusSAEOutput",
|
| 264 |
+
"ESMplusplusSAEParams",
|
| 265 |
+
"load_esmc_sae_layers",
|
| 266 |
+
]
|
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 != "
|
| 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 != "80b9bd43aa70400483b3f35121ed5039a743154f5b44b872578b5a12bf95bdd0":
|
| 17 |
raise RuntimeError("FastPLMs runtime identity differs from the bridge.")
|
| 18 |
|
| 19 |
_RUNTIME_TEMPORARIES = []
|