ESMFold2-Fast / fastplms /models /esm_plusplus /modeling_esm_plusplus_sae.py
lhallee's picture
Update FastPLMs files
a60bb65 verified
Raw
History Blame Contribute Delete
9.99 kB
"""Hidden-state sparse autoencoders for ESM++ (ESMC) models.
FastPLMs implements the published Biohub hidden-state SAE contract directly, so attaching an SAE
needs only PyTorch, Transformers, and the checkpoint itself. Biohub still owns the SAE weights:
this module reads their published repository layout, one shared ``config.json`` plus one
``layer_{index}.safetensors`` shard per backbone layer, and never redistributes those tensors.
A layer produced here satisfies the same attachment contract as an official Biohub
``ESMCSAEModel.layers`` entry, so ``PreTrainedESMplusplusModel.add_sae_models`` accepts either.
"""
from __future__ import annotations
import json
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from huggingface_hub import hf_hub_download
from safetensors.torch import load_file
from torch import Tensor
SAE_CONFIG_FILE = "config.json"
SAE_REQUIRED_CONFIG_FIELDS = ("d_model", "codebook_dim", "k")
# Trained per-feature statistics. Shards that never trained them omit them, and the ones default
# makes the (features / max) * idf normalization an identity.
SAE_OPTIONAL_STATE_NAMES = ("idf", "max")
STANDARDIZATION_EPSILON = 1e-5
@dataclass(frozen=True, slots=True)
class ESMplusplusSAEParams:
"""Shape contract of one hidden-state SAE.
``layer`` indexes the ESM++ hidden state the SAE reads, where ``0`` is the embedding output and
``num_hidden_layers`` is the final normalized state.
"""
d_model: int
codebook_dim: int
k: int
layer: int
@dataclass(slots=True)
class ESMplusplusSAEOutput:
"""Sparse features, and the optional reconstruction error, for one batch of residues."""
feature_magnitudes: Tensor
reconstruction_loss: Tensor | None = None
def _standardize_residue_states(residue_states: Tensor) -> Tensor:
"""Center and scale each residue vector the way the SAEs were trained."""
# residue_states: (n, d)
centered = residue_states - residue_states.mean(dim=-1, keepdim=True) # (n, d)
return centered / (centered.std(dim=-1, keepdim=True) + STANDARDIZATION_EPSILON) # (n, d)
class ESMplusplusSAELayer(nn.Module):
"""Top-k sparse autoencoder over one ESM++ hidden state.
Encoding standardizes each residue vector, projects it into a wide codebook, and keeps only the
``k`` largest activations. The decoder is used only to measure reconstruction error, which is
why it is opt-in: interpretation and gradient-based design read ``feature_magnitudes`` alone.
"""
idf: Tensor
max: Tensor
def __init__(self, params: ESMplusplusSAEParams) -> None:
super().__init__()
self.params = params
self.W_enc = nn.Parameter(torch.empty(params.d_model, params.codebook_dim)) # (d, c)
self.W_dec = nn.Parameter(torch.empty(params.codebook_dim, params.d_model)) # (c, d)
self.b_dec = nn.Parameter(torch.zeros(params.d_model)) # (d,)
self.register_buffer("idf", torch.ones(params.codebook_dim)) # (c,)
self.register_buffer("max", torch.ones(params.codebook_dim)) # (c,)
@property
def layer(self) -> int:
"""ESM++ hidden-state index this SAE was trained against."""
return self.params.layer
def forward(
self,
residue_states: Tensor,
*,
compute_reconstruction_loss: bool = False,
) -> ESMplusplusSAEOutput:
# residue_states: (n, d) for n residues; c is the codebook width, k the retained features
standardized = _standardize_residue_states(residue_states) # (n, d)
preactivations = F.relu((standardized - self.b_dec) @ self.W_enc) # (n, c)
retained = torch.topk(preactivations, self.params.k, dim=-1) # values, indices: (n, k)
feature_magnitudes = torch.zeros_like(preactivations).scatter(
-1, retained.indices, retained.values
) # (n, c)
if not compute_reconstruction_loss:
return ESMplusplusSAEOutput(feature_magnitudes=feature_magnitudes)
reconstructed = feature_magnitudes @ self.W_dec + self.b_dec # (n, d)
return ESMplusplusSAEOutput(
feature_magnitudes=feature_magnitudes,
reconstruction_loss=(reconstructed - standardized).pow(2).mean(dim=-1), # (n,)
)
def get_sae_output(self, layer_states: Tensor, token_mask: Tensor) -> ESMplusplusSAEOutput:
"""Encode the unpadded residues of one ESM++ hidden state.
This name and signature are the attachment contract that
``PreTrainedESMplusplusModel.add_sae_models`` validates and calls.
"""
# layer_states: (b, l, d); token_mask: (b, l)
residue_states = layer_states[token_mask] # (n, d) for n valid tokens
encoded: ESMplusplusSAEOutput = self(residue_states)
return encoded
def _repository_file(
repository: str | os.PathLike[str],
filename: str,
*,
revision: str | None,
cache_dir: str | os.PathLike[str] | None,
token: str | bool | None,
local_files_only: bool,
) -> Path:
"""Resolve one repository file, from a local directory or the Hub cache."""
# A directory counts as local only when it holds the shared config, so a stale directory named
# like a Hub identifier cannot shadow the download.
local_directory = Path(repository)
if (local_directory / SAE_CONFIG_FILE).is_file():
path = local_directory / filename
if not path.is_file():
raise FileNotFoundError(f"SAE repository {local_directory} has no {filename}.")
return path
return Path(
hf_hub_download(
repo_id=str(repository),
filename=filename,
revision=revision,
cache_dir=None if cache_dir is None else str(cache_dir),
token=token,
local_files_only=local_files_only,
)
)
def _load_sae_layer(
shard_path: Path,
*,
params: ESMplusplusSAEParams,
device: torch.device,
dtype: torch.dtype | None,
) -> ESMplusplusSAELayer:
state = load_file(str(shard_path), device=str(device))
encoder = state.get("W_enc")
if encoder is None:
raise ValueError(f"{shard_path} is not an ESMC SAE shard; it has no 'W_enc' entry.")
# Build on the meta device so the shard tensors are the only materialized copy of a codebook
# that reaches roughly one gigabyte for the widest published SAEs.
with torch.device("meta"):
sae_layer = ESMplusplusSAELayer(params)
sae_layer.to(dtype=encoder.dtype if dtype is None else dtype)
sae_layer.to_empty(device=device)
# to_empty leaves the statistics buffers uninitialized, so restore the identity defaults that
# shards without trained statistics rely on.
sae_layer.idf.fill_(1.0)
sae_layer.max.fill_(1.0)
incompatible = sae_layer.load_state_dict(state, strict=False)
missing = tuple(
name for name in incompatible.missing_keys if name not in SAE_OPTIONAL_STATE_NAMES
)
unexpected = tuple(incompatible.unexpected_keys)
if missing or unexpected:
raise ValueError(
f"{shard_path} does not match the ESMC SAE state contract; "
f"missing {list(missing)}, unexpected {list(unexpected)}."
)
return sae_layer
def load_esmc_sae_layers(
repository: str | os.PathLike[str],
layers: Sequence[int],
*,
revision: str | None = None,
cache_dir: str | os.PathLike[str] | None = None,
token: str | bool | None = None,
local_files_only: bool = False,
device: torch.device | str = "cpu",
dtype: torch.dtype | None = None,
) -> dict[int, ESMplusplusSAELayer]:
"""Load the requested hidden-state SAE layers from a Hub repository or a local directory.
Only the shared config and the requested shards are read, so a repository that publishes every
backbone layer costs one shard per requested layer. ``dtype`` defaults to the dtype stored in
the shard; pass the ESM++ model dtype when the SAE has to consume its hidden states directly.
"""
requested = tuple(dict.fromkeys(int(layer) for layer in layers))
if not requested:
raise ValueError("Loading SAE layers requires at least one backbone layer index.")
config_path = _repository_file(
repository,
SAE_CONFIG_FILE,
revision=revision,
cache_dir=cache_dir,
token=token,
local_files_only=local_files_only,
)
config = json.loads(config_path.read_text(encoding="utf-8"))
missing_fields = [name for name in SAE_REQUIRED_CONFIG_FIELDS if name not in config]
if missing_fields:
raise ValueError(f"{config_path} is not an ESMC SAE config; it omits {missing_fields}.")
available = tuple(int(index) for index in config.get("available_layers", ()))
target_device = torch.device(device)
sae_layers: dict[int, ESMplusplusSAELayer] = {}
for layer in requested:
if available and layer not in available:
raise ValueError(
f"SAE repository {repository} does not publish layer {layer}; "
f"available layers are {list(available)}."
)
shard_path = _repository_file(
repository,
f"layer_{layer}.safetensors",
revision=revision,
cache_dir=cache_dir,
token=token,
local_files_only=local_files_only,
)
sae_layers[layer] = _load_sae_layer(
shard_path,
params=ESMplusplusSAEParams(
d_model=int(config["d_model"]),
codebook_dim=int(config["codebook_dim"]),
k=int(config["k"]),
layer=layer,
),
device=target_device,
dtype=dtype,
)
return sae_layers
__all__ = [
"ESMplusplusSAELayer",
"ESMplusplusSAEOutput",
"ESMplusplusSAEParams",
"load_esmc_sae_layers",
]