FastESMFold / fastplms /models /classification_probe.py
lhallee's picture
Update FastPLMs files
4a217a9 verified
Raw
History Blame Contribute Delete
20 kB
"""Shared transformer probes for residue and sequence prediction tasks."""
from __future__ import annotations
import math
from typing import Any
import torch
from torch import nn
from torch.nn import functional as F
from transformers.modeling_outputs import (
BaseModelOutput,
SequenceClassifierOutput,
TokenClassifierOutput,
)
try:
from fastplms.attention import (
AttentionBackend,
_get_flex_attention_fn,
flex_attention,
get_attention_mask,
resolve_attention_backend,
)
from fastplms.embeddings.pooling import Pooler
from fastplms.models._esm_rotary import RotaryEmbedding
except ModuleNotFoundError as error:
_COMPOSITE_REQUIRED_NAMES = (
"AttentionBackend",
"Pooler",
"RotaryEmbedding",
"_get_flex_attention_fn",
"flex_attention",
"get_attention_mask",
"resolve_attention_backend",
)
if error.name != "fastplms" or any(
name not in globals() for name in _COMPOSITE_REQUIRED_NAMES
):
raise
# Flat Hub composites define every shared symbol above this source.
_SUPPORTED_BACKENDS = frozenset(
{
AttentionBackend.EAGER,
AttentionBackend.SDPA,
AttentionBackend.FLEX_ATTENTION,
}
)
_SUPPORTED_PROBLEM_TYPES = frozenset(
{
"regression",
"single_label_classification",
"multi_label_classification",
}
)
_UNSUPPORTED_POOLING = frozenset({"cls", "parti"})
def _config_value(config: Any, name: str, default: Any) -> Any:
value = getattr(config, name, None)
return default if value is None else value
def _attention_backend(config: Any) -> AttentionBackend:
requested = getattr(config, "_attn_implementation", None)
if requested is None:
requested = getattr(config, "attn_backend", "sdpa")
backend = resolve_attention_backend(requested)
if backend not in _SUPPORTED_BACKENDS:
expected = ", ".join(sorted(item.value for item in _SUPPORTED_BACKENDS))
raise ValueError(
f"Classification probes support only {expected}; received {backend.value!r}."
)
return backend
def resolve_problem_type(
config: Any,
labels: torch.Tensor,
*,
num_labels: int,
) -> str:
"""Resolve and persist the standard Transformers classification problem type."""
problem_type = getattr(config, "problem_type", None)
if problem_type is None:
if num_labels == 1:
problem_type = "regression"
elif labels.dtype in {torch.long, torch.int}:
problem_type = "single_label_classification"
else:
problem_type = "multi_label_classification"
config.problem_type = problem_type
if problem_type not in _SUPPORTED_PROBLEM_TYPES:
raise ValueError(
f"Unsupported problem_type {problem_type!r}; expected one of "
f"{sorted(_SUPPORTED_PROBLEM_TYPES)}."
)
return problem_type
def sequence_classification_loss(
logits: torch.Tensor,
labels: torch.Tensor,
*,
problem_type: str,
num_labels: int,
) -> torch.Tensor:
"""Compute a Hugging Face-compatible sequence task loss."""
labels = labels.to(logits.device)
if problem_type == "regression":
if num_labels == 1:
return F.mse_loss(logits.squeeze(-1), labels.squeeze(-1).to(logits.dtype))
return F.mse_loss(logits, labels.to(logits.dtype))
if problem_type == "single_label_classification":
return F.cross_entropy(logits.reshape(-1, num_labels), labels.reshape(-1).long())
if problem_type == "multi_label_classification":
return F.binary_cross_entropy_with_logits(logits, labels.to(logits.dtype))
raise ValueError(f"Unsupported problem_type {problem_type!r}.")
def _masked_elementwise_loss(
losses: torch.Tensor,
labels: torch.Tensor,
) -> torch.Tensor:
valid = labels.ne(-100)
if not bool(valid.any()):
return losses.sum() * 0
return losses.masked_select(valid).mean()
def token_classification_loss(
logits: torch.Tensor,
labels: torch.Tensor,
*,
problem_type: str,
num_labels: int,
) -> torch.Tensor:
"""Compute a token task loss, excluding every label element equal to ``-100``."""
labels = labels.to(logits.device)
if problem_type == "regression":
targets = labels.to(logits.dtype)
if num_labels == 1 and targets.ndim == logits.ndim - 1:
targets = targets.unsqueeze(-1)
if targets.shape != logits.shape:
raise ValueError(
"Token regression labels must match logits, except that the final "
"singleton dimension may be omitted when num_labels=1."
)
return _masked_elementwise_loss(F.mse_loss(logits, targets, reduction="none"), targets)
if problem_type == "single_label_classification":
if not bool(labels.ne(-100).any()):
return logits.sum() * 0
return F.cross_entropy(
logits.reshape(-1, num_labels),
labels.reshape(-1).long(),
ignore_index=-100,
)
if problem_type == "multi_label_classification":
if labels.shape != logits.shape:
raise ValueError("Multilabel token labels must have the same shape as logits.")
losses = F.binary_cross_entropy_with_logits(
logits,
labels.to(logits.dtype),
reduction="none",
)
return _masked_elementwise_loss(losses, labels)
raise ValueError(f"Unsupported problem_type {problem_type!r}.")
class SwiGLU(nn.Module):
"""SwiGLU activation used by the Protify-aligned feed-forward layer."""
def forward(self, inputs: torch.Tensor) -> torch.Tensor:
gate, values = inputs.chunk(2, dim=-1)
return F.silu(gate) * values
class ProbeSelfAttention(nn.Module):
"""Four-head RoPE self-attention with explicit, fail-closed dispatch."""
def __init__(
self,
hidden_size: int,
num_heads: int,
dropout: float,
backend: AttentionBackend,
use_bias: bool,
) -> None:
super().__init__()
if hidden_size % num_heads:
raise ValueError("classifier_probe_hidden_size must be divisible by its head count.")
self.hidden_size = hidden_size
self.num_heads = num_heads
self.head_size = hidden_size // num_heads
self.dropout = dropout
self.backend = backend
self.qkv = nn.Linear(hidden_size, 3 * hidden_size, bias=use_bias)
self.output = nn.Linear(hidden_size, hidden_size, bias=use_bias)
self.rotary = RotaryEmbedding(self.head_size)
def _reshape(self, tensor: torch.Tensor) -> torch.Tensor:
batch_size, sequence_length, _ = tensor.shape
return tensor.view(
batch_size,
sequence_length,
self.num_heads,
self.head_size,
).transpose(1, 2)
def forward(
self,
hidden_states: torch.Tensor,
*,
attention_mask: torch.Tensor | None,
output_attentions: bool,
) -> tuple[torch.Tensor, torch.Tensor | None]:
batch_size, sequence_length, _ = hidden_states.shape
query, key, value = self.qkv(hidden_states).chunk(3, dim=-1)
query = self._reshape(query)
key = self._reshape(key)
value = self._reshape(value)
query, key = self.rotary(query, key)
if output_attentions and self.backend != AttentionBackend.EAGER:
raise ValueError(
f"output_attentions=True is unavailable for {self.backend.value!r}; "
"select 'eager' explicitly."
)
_, attention_mask_4d, flex_block_mask = get_attention_mask(
self.backend,
batch_size,
sequence_length,
hidden_states.device,
attention_mask,
hidden_states.dtype,
)
dropout = self.dropout if self.training else 0.0
attention_weights = None
if self.backend == AttentionBackend.EAGER:
scores = query @ key.transpose(-2, -1) / math.sqrt(self.head_size)
if attention_mask_4d is not None:
scores = scores.masked_fill(~attention_mask_4d, float("-inf"))
attention_weights = scores.softmax(dim=-1)
context = F.dropout(attention_weights, p=dropout, training=self.training) @ value
elif self.backend == AttentionBackend.SDPA:
context = F.scaled_dot_product_attention(
query,
key,
value,
attn_mask=attention_mask_4d,
dropout_p=dropout,
)
elif self.backend == AttentionBackend.FLEX_ATTENTION:
if flex_attention is None:
raise RuntimeError("'flex_attention' was requested but is unavailable.")
flex_fn = _get_flex_attention_fn(
device=query.device,
dtype=query.dtype,
shape=tuple(query.shape),
mask_semantics="padding",
)
if flex_fn is None:
raise RuntimeError("'flex_attention' was requested but is unavailable.")
context = flex_fn(
query,
key,
value,
block_mask=flex_block_mask,
scale=1.0 / math.sqrt(self.head_size),
kernel_options={"PRESCALE_QK": True, "BLOCK_N": 32},
)
else:
raise AssertionError(f"Unhandled attention backend {self.backend.value!r}.")
context = context.transpose(1, 2).contiguous().view(
batch_size,
sequence_length,
self.hidden_size,
)
return self.output(context), attention_weights
class ProteinTransformerProbe(nn.Module):
"""Project residue embeddings and refine them with exactly one pre-LN block."""
def __init__(self, config: Any, input_size: int) -> None:
super().__init__()
hidden_size = int(_config_value(config, "classifier_probe_hidden_size", 512))
num_heads = int(_config_value(config, "classifier_probe_num_heads", 4))
dropout = float(_config_value(config, "classifier_probe_dropout", 0.1))
use_bias = bool(
_config_value(
config,
"classifier_use_bias",
_config_value(config, "use_bias", False),
)
)
if hidden_size != 512 or num_heads != 4 or hidden_size // num_heads != 128:
raise ValueError(
"The folding classification probe requires a 512-wide projection with "
"four 128-wide attention heads."
)
self.hidden_size = hidden_size
self.input_norm = nn.LayerNorm(input_size)
self.input_projection = nn.Linear(input_size, hidden_size, bias=use_bias)
self.attention_norm = nn.LayerNorm(hidden_size)
self.attention = ProbeSelfAttention(
hidden_size,
num_heads,
dropout,
_attention_backend(config),
use_bias,
)
intermediate_size = int(math.ceil((8 / 3) * hidden_size / 256) * 256)
self.feed_forward_norm = nn.LayerNorm(hidden_size)
self.feed_forward = nn.Sequential(
nn.Linear(hidden_size, 2 * intermediate_size, bias=use_bias),
SwiGLU(),
nn.Dropout(dropout),
nn.Linear(intermediate_size, hidden_size, bias=use_bias),
)
self.residual_dropout = nn.Dropout(dropout)
@property
def attn_backend(self) -> str:
return self.attention.backend.value
def forward(
self,
embeddings: torch.Tensor,
attention_mask: torch.Tensor | None = None,
*,
output_attentions: bool = False,
output_hidden_states: bool = False,
return_dict: bool = True,
) -> BaseModelOutput | tuple[torch.Tensor, ...]:
if embeddings.ndim != 3:
raise ValueError("embeddings must have shape (batch, residue, channel).")
embeddings = embeddings.to(dtype=self.input_projection.weight.dtype)
hidden_states = self.input_projection(self.input_norm(embeddings))
attention_output, attention_weights = self.attention(
self.attention_norm(hidden_states),
attention_mask=attention_mask,
output_attentions=output_attentions,
)
hidden_states = hidden_states + self.residual_dropout(attention_output)
hidden_states = hidden_states + self.residual_dropout(
self.feed_forward(self.feed_forward_norm(hidden_states))
)
output = BaseModelOutput(
last_hidden_state=hidden_states,
hidden_states=(hidden_states,) if output_hidden_states else None,
attentions=(attention_weights,) if output_attentions else None,
)
return output if return_dict else output.to_tuple()
class _ClassificationProbe(nn.Module):
def __init__(self, config: Any, input_size: int, *, sequence_task: bool) -> None:
super().__init__()
self.config = config
self.num_labels = int(_config_value(config, "num_labels", 2))
self.transformer = ProteinTransformerProbe(config, input_size)
self.sequence_task = sequence_task
pooling_types = _config_value(config, "classifier_pooling_types", ["mean"])
self.pooler = Pooler(pooling_types) if sequence_task else None
if self.pooler is not None:
unsupported = sorted(set(self.pooler.names) & _UNSUPPORTED_POOLING)
if unsupported:
raise ValueError(
"Classification probes consume residue-only representations and do not "
f"support pooling operation(s) {unsupported}."
)
hidden_size = self.transformer.hidden_size
classifier_input = hidden_size * (len(self.pooler.names) if self.pooler else 1)
classifier_hidden = int(_config_value(config, "classifier_hidden_size", 4096))
classifier_dropout = float(_config_value(config, "classifier_dropout", 0.2))
use_bias = bool(
_config_value(
config,
"classifier_use_bias",
_config_value(config, "use_bias", False),
)
)
projection_size = int(math.ceil((2 * self.num_labels) / 256) * 256)
classifier_layers: list[nn.Module] = [
nn.LayerNorm(classifier_input),
nn.Linear(classifier_input, classifier_hidden, bias=use_bias),
nn.ReLU(),
nn.Dropout(classifier_dropout),
nn.Linear(classifier_hidden, projection_size, bias=use_bias),
nn.ReLU(),
nn.Dropout(classifier_dropout),
]
if not sequence_task:
classifier_layers.extend(
[
nn.Linear(projection_size, projection_size, bias=use_bias),
nn.ReLU(),
]
)
classifier_layers.append(nn.Linear(projection_size, self.num_labels, bias=use_bias))
self.classifier = nn.Sequential(*classifier_layers)
def _forward_transformer(
self,
embeddings: torch.Tensor,
attention_mask: torch.Tensor | None,
output_attentions: bool | None,
output_hidden_states: bool | None,
) -> BaseModelOutput:
output_attentions = (
bool(output_attentions)
if output_attentions is not None
else bool(getattr(self.config, "output_attentions", False))
)
output_hidden_states = (
bool(output_hidden_states)
if output_hidden_states is not None
else bool(getattr(self.config, "output_hidden_states", False))
)
return self.transformer(
embeddings,
attention_mask,
output_attentions=output_attentions,
output_hidden_states=output_hidden_states,
return_dict=True,
)
class SequenceClassificationProbe(_ClassificationProbe):
"""Protify-style sequence classifier over externally supplied residue embeddings."""
def __init__(self, config: Any, input_size: int) -> None:
super().__init__(config, input_size, sequence_task=True)
def forward(
self,
embeddings: torch.Tensor,
attention_mask: torch.Tensor | None = None,
labels: torch.Tensor | None = None,
output_attentions: bool | None = None,
output_hidden_states: bool | None = None,
return_dict: bool | None = None,
) -> SequenceClassifierOutput | tuple[torch.Tensor, ...]:
if attention_mask is None:
attention_mask = torch.ones(
embeddings.shape[:2],
device=embeddings.device,
dtype=torch.bool,
)
outputs = self._forward_transformer(
embeddings,
attention_mask,
output_attentions,
output_hidden_states,
)
if self.pooler is None:
raise AssertionError("Sequence classification requires a configured pooler.")
pooled = self.pooler(outputs.last_hidden_state, attention_mask)
logits = self.classifier(pooled)
loss = None
if labels is not None:
problem_type = resolve_problem_type(self.config, labels, num_labels=self.num_labels)
loss = sequence_classification_loss(
logits,
labels,
problem_type=problem_type,
num_labels=self.num_labels,
)
result = SequenceClassifierOutput(
loss=loss,
logits=logits,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions,
)
use_return_dict = (
bool(return_dict)
if return_dict is not None
else bool(getattr(self.config, "use_return_dict", True))
)
return result if use_return_dict else result.to_tuple()
class TokenClassificationProbe(_ClassificationProbe):
"""Protify-style residue classifier or regressor over supplied embeddings."""
def __init__(self, config: Any, input_size: int) -> None:
super().__init__(config, input_size, sequence_task=False)
def forward(
self,
embeddings: torch.Tensor,
attention_mask: torch.Tensor | None = None,
labels: torch.Tensor | None = None,
output_attentions: bool | None = None,
output_hidden_states: bool | None = None,
return_dict: bool | None = None,
) -> TokenClassifierOutput | tuple[torch.Tensor, ...]:
outputs = self._forward_transformer(
embeddings,
attention_mask,
output_attentions,
output_hidden_states,
)
logits = self.classifier(outputs.last_hidden_state)
loss = None
if labels is not None:
problem_type = resolve_problem_type(self.config, labels, num_labels=self.num_labels)
loss = token_classification_loss(
logits,
labels,
problem_type=problem_type,
num_labels=self.num_labels,
)
result = TokenClassifierOutput(
loss=loss,
logits=logits,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions,
)
use_return_dict = (
bool(return_dict)
if return_dict is not None
else bool(getattr(self.config, "use_return_dict", True))
)
return result if use_return_dict else result.to_tuple()
__all__ = [
"ProbeSelfAttention",
"ProteinTransformerProbe",
"SequenceClassificationProbe",
"SwiGLU",
"TokenClassificationProbe",
"resolve_problem_type",
"sequence_classification_loss",
"token_classification_loss",
]