NucEngram / modeling_nucengram.py
FreakingPotato's picture
Add NucEngram genomic language model (base, S123) + usage
cb634e7 verified
Raw
History Blame Contribute Delete
4.2 kB
"""HuggingFace wrapper for the NucEngram genomic language model.
A char-level DNA masked language model (A/C/G/T/N, 9-token vocab) built on a
ModernBERT encoder with an n-gram memory module injected into two early layers.
`AutoModel.from_pretrained(..., trust_remote_code=True)` returns the encoder;
`last_hidden_state` is a per-nucleotide embedding you can pool + probe/fine-tune.
"""
from __future__ import annotations
import numpy as np
import torch
import torch.nn as nn
from transformers import PreTrainedModel
from transformers.modeling_outputs import BaseModelOutput, MaskedLMOutput
from .configuration_nucengram import NucEngramConfig
from .engram import EngramConfig
from .model_modernbert import ModernBertGenomicConfig
from .model_modernbert_engram import ModernBertEngramWrapper
from .tokenizer import encode as _encode, PAD_ID
_VOCAB = {i: c for i, c in zip(range(9), "_XXXACGTN")} # display only
class NucEngramPreTrainedModel(PreTrainedModel):
config_class = NucEngramConfig
base_model_prefix = "net"
_no_split_modules = ["ModernBertEncoderLayer", "Engram"]
def _init_weights(self, module):
pass # weights come from the pretrained checkpoint
# --- tokenisation helper (the tokenizer is a fixed char->id map) ---
@staticmethod
def encode(sequences, max_length=8192, device="cpu"):
"""str | list[str] -> (input_ids, attention_mask) LongTensors."""
if isinstance(sequences, str):
sequences = [sequences]
B = len(sequences)
L = min(max(len(s) for s in sequences), max_length)
ids = np.full((B, L), PAD_ID, dtype=np.int64)
for i, s in enumerate(sequences):
e = _encode(s).astype(np.int64)[:L]
ids[i, :len(e)] = e
ids = torch.from_numpy(ids)
return ids.to(device), (ids != PAD_ID).long().to(device)
class NucEngramModel(NucEngramPreTrainedModel):
"""Encoder — returns per-token `last_hidden_state` (use for embeddings / FT)."""
def __init__(self, config: NucEngramConfig):
super().__init__(config)
mb = ModernBertGenomicConfig(**config.backbone)
eng = EngramConfig(**config.engram)
self.net = ModernBertEngramWrapper(mb, eng)
self.post_init()
def forward(self, input_ids, attention_mask=None, **kwargs):
if attention_mask is None:
attention_mask = (input_ids != PAD_ID).long()
# the engram hooks read the raw ids off the wrapper
self.net._current_input_ids = input_ids
out = self.net.model.model(input_ids=input_ids, attention_mask=attention_mask)
return BaseModelOutput(last_hidden_state=out.last_hidden_state)
@torch.no_grad()
def embed(self, sequences, pooling="mean", max_length=8192):
"""Convenience: sequence(s) -> pooled embedding [B, hidden]."""
self.eval()
dev = next(self.parameters()).device
ids, am = self.encode(sequences, max_length=max_length, device=dev)
h = self.forward(ids, am).last_hidden_state.float()
if pooling == "cls":
return h[:, 0]
m = am.float().unsqueeze(-1)
return (h * m).sum(1) / m.sum(1).clamp_min(1.0)
class NucEngramForMaskedLM(NucEngramPreTrainedModel):
"""Same weights, exposing the MLM head (returns `logits` over the 9-token vocab)."""
def __init__(self, config: NucEngramConfig):
super().__init__(config)
mb = ModernBertGenomicConfig(**config.backbone)
eng = EngramConfig(**config.engram)
self.net = ModernBertEngramWrapper(mb, eng)
self.post_init()
def forward(self, input_ids, attention_mask=None, labels=None, **kwargs):
if attention_mask is None:
attention_mask = (input_ids != PAD_ID).long()
self.net._current_input_ids = input_ids
out = self.net.model(input_ids=input_ids, attention_mask=attention_mask)
logits = out.logits if hasattr(out, "logits") else out
loss = None
if labels is not None:
loss = nn.functional.cross_entropy(
logits.view(-1, logits.size(-1)), labels.view(-1), ignore_index=-100)
return MaskedLMOutput(loss=loss, logits=logits)