Spaces:
Sleeping
Sleeping
File size: 9,703 Bytes
914512c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """bioai.models.caduceus_adapter -- best-effort Caduceus adapter with CNN fallback.
Caduceus (Schiff et al. 2024, https://arxiv.org/abs/2403.09235) is a long-context
Mamba-2 model for DNA that respects reverse-complement symmetry. The
``kuleshov-group/caduceus-ph-1`` checkpoint on HuggingFace expects integer
**token** input (BPE tokenizer over A/C/G/T), not one-hot.
This adapter tries to load Caduceus from HuggingFace; if anything fails
(network error, OOM, missing transformers, missing trust_remote_code deps,
etc.) it transparently falls back to :class:`bioai.models.sirna_cnn.SiRNACNN`.
The demo video prints which backend is active so judges can see at a glance.
The forward signature matches ``SiRNACNN.forward`` -- ``(B, 4, 21)`` one-hot
in, ``(efficacy_pred, safety_pred)`` out -- so callers can swap the two
without touching their code.
"""
from __future__ import annotations
import logging
from typing import Tuple
import torch
import torch.nn as nn
from .sirna_cnn import SiRNACNN, resolve_device
logger = logging.getLogger(__name__)
# HuggingFace id for Caduceus-Ph-1 (the 1.3M-param pretraining checkpoint).
CADUCEUS_MODEL_ID = "kuleshov-group/caduceus-ph-1"
class CaduceusAdapter(nn.Module):
"""Caduceus-backed siRNA predictor with automatic CNN fallback.
Parameters
----------
seq_len:
Expected siRNA length (default 21).
num_safety_species:
Number of safety-panel species (default 6).
device:
``'auto' | 'cpu' | 'cuda'``.
force_backend:
``None`` (try Caduceus then fall back) or ``'cnn'`` (skip the
Caduceus attempt, useful in CI / low-RAM environments).
"""
def __init__(
self,
seq_len: int = 21,
num_safety_species: int = 6,
device: str = "auto",
force_backend: str | None = None,
):
super().__init__()
self.seq_len = seq_len
self.num_safety_species = num_safety_species
self.device = resolve_device(device)
self.backend: str = "cnn" # set during _try_load_caduceus
# Small heads that map the Caduceus pooled embedding -> predictions.
# Initialised here so they live on the right device even if Caduceus
# later fails (we still need them for the CNN fallback).
self.caduceus_embed_dim: int = 256
self.efficacy_head = nn.Linear(self.caduceus_embed_dim, 1).to(self.device)
self.safety_head = nn.Linear(self.caduceus_embed_dim, num_safety_species).to(self.device)
self._caduceus = None
self._cnn_fallback: SiRNACNN | None = None
if force_backend == "cnn":
self.backend = "cnn"
print("[CaduceusAdapter] force_backend='cnn' -> using SiRNACNN")
else:
self._try_load_caduceus()
if self.backend == "cnn":
self._init_cnn_fallback()
# ------------------------------------------------------------------ #
def _try_load_caduceus(self) -> None:
"""Attempt to download + load Caduceus from HuggingFace.
Any exception (ImportError, network, OOM, model-config issue) drops
us back to ``self.backend = 'cnn'``.
"""
try:
from transformers import AutoModel, AutoTokenizer # type: ignore
except Exception as exc: # pragma: no cover - depends on env
print(f"[CaduceusAdapter] transformers unavailable ({exc!r}); falling back to SiRNACNN")
self.backend = "cnn"
return
try:
print(f"[CaduceusAdapter] Attempting to load {CADUCEUS_MODEL_ID} from HuggingFace...")
tokenizer = AutoTokenizer.from_pretrained(CADUCEUS_MODEL_ID, trust_remote_code=True)
model = AutoModel.from_pretrained(
CADUCEUS_MODEL_ID, trust_remote_code=True, add_pooling_layer=False
)
model.eval()
for p in model.parameters():
p.requires_grad_(False)
# Discover the real embedding dim from the config so our heads
# match (Caduceus-Ph-1 is 256, but later checkpoints may differ).
cfg_dim = getattr(getattr(model, "config", None), "d_model", None)
if cfg_dim is not None and cfg_dim != self.caduceus_embed_dim:
self.caduceus_embed_dim = int(cfg_dim)
self.efficacy_head = nn.Linear(self.caduceus_embed_dim, 1).to(self.device)
self.safety_head = nn.Linear(self.caduceus_embed_dim, self.num_safety_species).to(self.device)
model.to(self.device)
self._caduceus = model
self._tokenizer = tokenizer
self.backend = "caduceus"
print(f"[CaduceusAdapter] Caduceus loaded (embed_dim={self.caduceus_embed_dim}). Backend=caduceus")
except Exception as exc:
print(f"[CaduceusAdapter] Caduceus load failed ({exc!r}); falling back to SiRNACNN")
self.backend = "cnn"
# ------------------------------------------------------------------ #
def _init_cnn_fallback(self) -> None:
self._cnn_fallback = SiRNACNN(
seq_len=self.seq_len,
num_safety_species=self.num_safety_species,
).to(self.device)
print("[CaduceusAdapter] SiRNACNN fallback initialised. Backend=cnn")
# ------------------------------------------------------------------ #
@staticmethod
def _onehot_to_tokens(x: torch.Tensor) -> torch.Tensor:
"""``(B, 4, L)`` one-hot -> ``(B, L)`` integer tokens (argmax)."""
return x.argmax(dim=1).long()
def _tokens_to_caduceus_ids(self, tokens: torch.Tensor) -> torch.Tensor:
"""Convert (B, L) int tokens to Caduceus input_ids.
Caduceus-Ph-1 uses a simple BPE where A/C/G/T map to specific ids.
Most checkpoints add ``[CLS]``/``[SEP]`` automatically via the
tokenizer; we let the tokenizer handle it. If the tokenizer is not
callable in the expected way, we fall back to A=5,C=6,G=7,T=8 (the
raw Caduceus BPE ids observed in the published config) and prepend
CLS=1 / append SEP=2.
"""
import torch as _t
# Convert tokens back to a DNA string, then re-tokenise.
idx_to_base = {0: "A", 1: "C", 2: "G", 3: "T"}
seqs = [
"".join(idx_to_base.get(int(t), "A") for t in row)
for row in tokens.cpu().numpy()
]
try:
enc = self._tokenizer(
seqs, return_tensors="pt", padding=True, truncation=True,
)
return enc["input_ids"].to(self.device)
except Exception as exc:
print(f"[CaduceusAdapter] tokenizer call failed ({exc!r}); using raw BPE ids")
# Raw Caduceus BPE ids: A=5, C=6, G=7, T=8 (per the published config).
raw_map = {0: 5, 1: 6, 2: 7, 3: 8}
ids = tokens.clone().cpu()
for k, v in raw_map.items():
ids[ids == k] = v
cls_col = _t.full((ids.size(0), 1), 1, dtype=ids.dtype)
sep_col = _t.full((ids.size(0), 1), 2, dtype=ids.dtype)
return _t.cat([cls_col, ids, sep_col], dim=1).to(self.device)
# ------------------------------------------------------------------ #
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
"""Same signature as :class:`SiRNACNN`: ``(B, 4, seq_len)`` one-hot in,
``(efficacy_pred, safety_pred)`` out.
"""
x = x.to(self.device)
if self.backend == "cnn" or self._cnn_fallback is not None and self._caduceus is None:
assert self._cnn_fallback is not None
return self._cnn_fallback.forward(x)
# Caduceus path
try:
tokens = self._onehot_to_tokens(x)
input_ids = self._tokens_to_caduceus_ids(tokens)
with torch.no_grad():
outputs = self._caduceus(input_ids)
# Caduceus returns last_hidden_state (B, L, D). Mean-pool over L.
hidden = outputs.last_hidden_state if hasattr(outputs, "last_hidden_state") else outputs[0]
mask = (input_ids != 0).float().unsqueeze(-1) # treat pad=0
pooled = (hidden * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1.0)
eff = torch.sigmoid(self.efficacy_head(pooled))
safe = torch.sigmoid(self.safety_head(pooled))
return eff, safe
except Exception as exc:
# Never crash the pipeline -- fall through to CNN.
print(f"[CaduceusAdapter] forward failed ({exc!r}); using CNN fallback for this batch")
if self._cnn_fallback is None:
self._init_cnn_fallback()
return self._cnn_fallback.forward(x)
# ------------------------------------------------------------------ #
# Passthroughs so callers can treat this like the underlying model
# ------------------------------------------------------------------ #
def parameters(self, recurse: bool = True):
if self.backend == "cnn" and self._cnn_fallback is not None:
return self._cnn_fallback.parameters(recurse=recurse)
# When using Caduceus (frozen), only the heads are trainable.
return list(self.efficacy_head.parameters()) + list(self.safety_head.parameters())
def train(self, mode: bool = True):
if self.backend == "cnn" and self._cnn_fallback is not None:
self._cnn_fallback.train(mode)
else:
super().train(mode)
# Caduceus itself stays in eval mode (frozen backbone).
if self._caduceus is not None:
self._caduceus.eval()
return self
def eval(self):
return self.train(False)
|