| """ |
| Modern Protein Language Model |
| ============================= |
| A <200M parameter encoder combining ModernBERT architecture + ELECTRA-style |
| replaced token detection for protein sequence predictive tasks. |
| |
| Key innovations over ESM-2: |
| 1. ModernBERT architecture: Pre-LN, RMSNorm, GeGLU, RoPE, FlashAttention |
| 2. ELECTRA-style discriminative pre-training (not just MLM) |
| 3. Deep & narrow design (24 layers, 512 hidden ~120M params) |
| 4. 30% masking rate with curriculum decay |
| 5. Span masking for structural motifs |
| """ |
|
|
| import math |
| from dataclasses import dataclass |
| from typing import Optional, Tuple |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class ModernProteinConfig: |
| vocab_size: int = 33 |
| hidden_size: int = 512 |
| num_hidden_layers: int = 24 |
| num_attention_heads: int = 16 |
| intermediate_size: int = 1536 |
| max_position_embeddings: int = 1024 |
| layer_norm_eps: float = 1e-6 |
| hidden_dropout_prob: float = 0.0 |
| attention_probs_dropout_prob: float = 0.0 |
| initializer_range: float = 0.02 |
| rope_theta: float = 10000.0 |
| use_rms_norm: bool = True |
| use_geglu: bool = True |
| use_flash_attn: bool = True |
| tie_word_embeddings: bool = True |
| |
| generator_size_multiplier: float = 0.25 |
| discriminator_lambda: float = 50.0 |
| |
| mask_prob: float = 0.30 |
| mask_prob_end: float = 0.05 |
| span_masking: bool = True |
| mean_span_length: float = 3.0 |
|
|
|
|
| |
| |
| |
|
|
| class RMSNorm(nn.Module): |
| def __init__(self, dim: int, eps: float = 1e-6): |
| super().__init__() |
| self.eps = eps |
| self.weight = nn.Parameter(torch.ones(dim)) |
|
|
| def forward(self, x): |
| norm = x.norm(2, dim=-1, keepdim=True) * (x.size(-1) ** -0.5) |
| return self.weight * (x / (norm + self.eps)) |
|
|
|
|
| |
| |
| |
|
|
| def rotate_half(x): |
| x1, x2 = x.chunk(2, dim=-1) |
| return torch.cat([-x2, x1], dim=-1) |
|
|
|
|
| def apply_rotary_pos_emb(q, k, cos, sin): |
| q_embed = (q * cos) + (rotate_half(q) * sin) |
| k_embed = (k * cos) + (rotate_half(k) * sin) |
| return q_embed, k_embed |
|
|
|
|
| class RotaryEmbedding(nn.Module): |
| def __init__(self, dim: int, max_seq_len: int = 2048, base: float = 10000.0): |
| super().__init__() |
| inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) |
| self.register_buffer("inv_freq", inv_freq) |
| self.max_seq_len = max_seq_len |
| self.dim = dim |
| t = torch.arange(max_seq_len, dtype=self.inv_freq.dtype) |
| freqs = torch.einsum("i,j->ij", t, self.inv_freq) |
| emb = torch.cat([freqs, freqs], dim=-1) |
| self.register_buffer("cos_cached", emb.cos()[None, None, :, :]) |
| self.register_buffer("sin_cached", emb.sin()[None, None, :, :]) |
|
|
| def forward(self, seq_len: int): |
| return ( |
| self.cos_cached[:, :, :seq_len, :], |
| self.sin_cached[:, :, :seq_len, :], |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class ModernProteinAttention(nn.Module): |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| self.num_heads = config.num_attention_heads |
| self.head_dim = config.hidden_size // config.num_attention_heads |
| self.scale = self.head_dim ** -0.5 |
|
|
| self.qkv = nn.Linear(config.hidden_size, 3 * config.hidden_size, bias=False) |
| self.out_proj = nn.Linear(config.hidden_size, config.hidden_size, bias=False) |
| self.dropout = nn.Dropout(config.attention_probs_dropout_prob) |
| self.rotary = RotaryEmbedding(self.head_dim, config.max_position_embeddings, config.rope_theta) |
|
|
| def forward(self, x, attention_mask=None): |
| bsz, seq_len, _ = x.shape |
| qkv = self.qkv(x) |
| q, k, v = qkv.chunk(3, dim=-1) |
| q = q.view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2) |
| k = k.view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2) |
| v = v.view(bsz, seq_len, self.num_heads, self.head_dim).transpose(1, 2) |
|
|
| cos, sin = self.rotary(seq_len) |
| q, k = apply_rotary_pos_emb(q, k, cos, sin) |
|
|
| |
| attn_output = F.scaled_dot_product_attention( |
| q, k, v, |
| attn_mask=attention_mask, |
| dropout_p=self.dropout.p if self.training else 0.0, |
| is_causal=False, |
| ) |
| attn_output = attn_output.transpose(1, 2).contiguous().view(bsz, seq_len, -1) |
| return self.out_proj(attn_output) |
|
|
|
|
| |
| |
| |
|
|
| class GeGLU(nn.Module): |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| self.w1 = nn.Linear(config.hidden_size, config.intermediate_size, bias=False) |
| self.w2 = nn.Linear(config.hidden_size, config.intermediate_size, bias=False) |
| self.w3 = nn.Linear(config.intermediate_size, config.hidden_size, bias=False) |
|
|
| def forward(self, x): |
| return self.w3(F.gelu(self.w1(x)) * self.w2(x)) |
|
|
|
|
| class ModernProteinMLP(nn.Module): |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| if config.use_geglu: |
| self.mlp = GeGLU(config) |
| else: |
| self.mlp = nn.Sequential( |
| nn.Linear(config.hidden_size, config.intermediate_size, bias=False), |
| nn.GELU(), |
| nn.Linear(config.intermediate_size, config.hidden_size, bias=False), |
| ) |
|
|
| def forward(self, x): |
| return self.mlp(x) |
|
|
|
|
| |
| |
| |
|
|
| class ModernProteinLayer(nn.Module): |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| Norm = RMSNorm if config.use_rms_norm else nn.LayerNorm |
| self.ln1 = Norm(config.hidden_size, eps=config.layer_norm_eps) |
| self.attn = ModernProteinAttention(config) |
| self.ln2 = Norm(config.hidden_size, eps=config.layer_norm_eps) |
| self.mlp = ModernProteinMLP(config) |
|
|
| def forward(self, x, attention_mask=None): |
| x = x + self.attn(self.ln1(x), attention_mask) |
| x = x + self.mlp(self.ln2(x)) |
| return x |
|
|
|
|
| |
| |
| |
|
|
| class ModernProteinEncoder(nn.Module): |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| self.config = config |
| self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) |
| self.layers = nn.ModuleList([ModernProteinLayer(config) for _ in range(config.num_hidden_layers)]) |
| Norm = RMSNorm if config.use_rms_norm else nn.LayerNorm |
| self.ln_final = Norm(config.hidden_size, eps=config.layer_norm_eps) |
| self._init_weights() |
|
|
| def _init_weights(self): |
| for module in self.modules(): |
| if isinstance(module, nn.Linear): |
| nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range) |
| if module.bias is not None: |
| nn.init.zeros_(module.bias) |
| elif isinstance(module, nn.Embedding): |
| nn.init.normal_(module.weight, mean=0.0, std=self.config.initializer_range) |
|
|
| def forward(self, input_ids, attention_mask=None): |
| x = self.embed_tokens(input_ids) |
| for layer in self.layers: |
| x = layer(x, attention_mask) |
| return self.ln_final(x) |
|
|
|
|
| |
| |
| |
|
|
| class ModernProteinForELECTRA(nn.Module): |
| """ |
| ELECTRA-style pre-training for proteins. |
| Small generator predicts masked tokens. |
| Discriminator predicts whether each token is original or replaced. |
| """ |
| def __init__(self, config: ModernProteinConfig): |
| super().__init__() |
| self.config = config |
| self.discriminator = ModernProteinEncoder(config) |
| self.discriminator_head = nn.Linear(config.hidden_size, 1) |
|
|
| |
| gen_config = ModernProteinConfig( |
| vocab_size=config.vocab_size, |
| hidden_size=int(config.hidden_size * config.generator_size_multiplier), |
| num_hidden_layers=max(1, config.num_hidden_layers // 2), |
| num_attention_heads=max(2, config.num_attention_heads // 2), |
| intermediate_size=int(config.intermediate_size * config.generator_size_multiplier), |
| max_position_embeddings=config.max_position_embeddings, |
| layer_norm_eps=config.layer_norm_eps, |
| use_rms_norm=config.use_rms_norm, |
| use_geglu=config.use_geglu, |
| tie_word_embeddings=False, |
| ) |
| self.generator = ModernProteinEncoder(gen_config) |
| self.generator_head = nn.Linear(gen_config.hidden_size, config.vocab_size, bias=False) |
|
|
| def forward(self, input_ids, attention_mask=None, labels=None, is_replaced=None): |
| |
| gen_hidden = self.generator(input_ids, attention_mask) |
| gen_logits = self.generator_head(gen_hidden) |
|
|
| |
| with torch.no_grad(): |
| sampled_tokens = torch.argmax(gen_logits, dim=-1) |
|
|
| |
| corrupted_input = input_ids.clone() |
| mask = (input_ids == 32) |
| corrupted_input[mask] = sampled_tokens[mask] |
|
|
| |
| disc_hidden = self.discriminator(corrupted_input, attention_mask) |
| disc_logits = self.discriminator_head(disc_hidden).squeeze(-1) |
|
|
| loss = None |
| if labels is not None and is_replaced is not None: |
| gen_loss = F.cross_entropy( |
| gen_logits.view(-1, self.config.vocab_size), |
| labels.view(-1), |
| ignore_index=-100, |
| ) |
| disc_loss = F.binary_cross_entropy_with_logits( |
| disc_logits.view(-1), |
| is_replaced.view(-1).float(), |
| ) |
| loss = gen_loss + self.config.discriminator_lambda * disc_loss |
|
|
| return { |
| "loss": loss, |
| "gen_logits": gen_logits, |
| "disc_logits": disc_logits, |
| } |
|
|
|
|
| |
| |
| |
|
|
| class ModernProteinForSequenceClassification(nn.Module): |
| def __init__(self, config: ModernProteinConfig, num_labels: int): |
| super().__init__() |
| self.encoder = ModernProteinEncoder(config) |
| self.classifier = nn.Linear(config.hidden_size, num_labels) |
|
|
| def forward(self, input_ids, attention_mask=None, labels=None): |
| hidden = self.encoder(input_ids, attention_mask) |
| pooled = hidden[:, 0] |
| logits = self.classifier(pooled) |
| loss = None |
| if labels is not None: |
| if self.classifier.out_features == 1: |
| loss = F.mse_loss(logits.squeeze(), labels.float()) |
| else: |
| loss = F.cross_entropy(logits, labels) |
| return {"loss": loss, "logits": logits} |
|
|
|
|
| class ModernProteinForTokenClassification(nn.Module): |
| def __init__(self, config: ModernProteinConfig, num_labels: int): |
| super().__init__() |
| self.encoder = ModernProteinEncoder(config) |
| self.classifier = nn.Linear(config.hidden_size, num_labels) |
|
|
| def forward(self, input_ids, attention_mask=None, labels=None): |
| hidden = self.encoder(input_ids, attention_mask) |
| logits = self.classifier(hidden) |
| loss = None |
| if labels is not None: |
| loss = F.cross_entropy( |
| logits.view(-1, self.classifier.out_features), |
| labels.view(-1), |
| ignore_index=-100, |
| ) |
| return {"loss": loss, "logits": logits} |
|
|
|
|
| |
| |
| |
|
|
| def span_mask_tokens(input_ids, mask_token_id, vocab_size, mask_prob=0.30, |
| mean_span_length=3.0, pad_token_id=1): |
| """ |
| Span masking for protein sequences. |
| Masks contiguous spans (simulating structural motif masking). |
| """ |
| batch_size, seq_len = input_ids.shape |
| masked_input = input_ids.clone() |
| labels = input_ids.clone() |
| labels.fill_(-100) |
| is_replaced = torch.zeros_like(input_ids, dtype=torch.float) |
|
|
| for b in range(batch_size): |
| valid_len = (input_ids[b] != pad_token_id).sum().item() |
| num_to_mask = int(valid_len * mask_prob) |
| masked_count = 0 |
|
|
| while masked_count < num_to_mask: |
| span_len = max(1, int(torch.poisson(torch.tensor(mean_span_length)).item())) |
| start = torch.randint(1, valid_len, (1,)).item() |
| if start + span_len > valid_len: |
| span_len = valid_len - start |
| end = start + span_len |
|
|
| for pos in range(start, end): |
| if masked_count >= num_to_mask: |
| break |
| rand = torch.rand(1).item() |
| if rand < 0.8: |
| masked_input[b, pos] = mask_token_id |
| elif rand < 0.9: |
| masked_input[b, pos] = torch.randint(0, vocab_size, (1,)).item() |
| |
| labels[b, pos] = input_ids[b, pos] |
| is_replaced[b, pos] = 1.0 |
| masked_count += 1 |
|
|
| return masked_input, labels, is_replaced |
|
|
|
|
| |
| |
| |
|
|
| def count_parameters(model): |
| return sum(p.numel() for p in model.parameters() if p.requires_grad) |
|
|
|
|
| if __name__ == "__main__": |
| config = ModernProteinConfig() |
| model = ModernProteinForELECTRA(config) |
| print(f"Discriminator params: {count_parameters(model.discriminator) / 1e6:.1f}M") |
| print(f"Generator params: {count_parameters(model.generator) / 1e6:.1f}M") |
| print(f"Total params: {count_parameters(model) / 1e6:.1f}M") |
|
|
| |
| batch_size, seq_len = 2, 128 |
| input_ids = torch.randint(0, 33, (batch_size, seq_len)) |
| input_ids[:, 0] = 0 |
| masked, labels, is_replaced = span_mask_tokens(input_ids, 32, 33) |
| out = model(masked, labels=labels, is_replaced=is_replaced) |
| print(f"Loss: {out['loss'].item():.4f}") |
|
|