| """ |
| Cross-Attention Denoiser for Image-conditioned RNA-seq Diffusion. |
| |
| The denoiser takes: |
| - noisy RNA-seq embeddings (scGPT, 512-dim) |
| - imaging features (ViT-L, 5120-dim) as conditioning context |
| - diffusion timestep |
| |
| And predicts the noise Ξ΅ added to the RNA-seq embeddings. |
| |
| Architecture: |
| 1. Project imaging features β model_dim, apply self-attention to create a |
| rich context representation. |
| 2. Embed noisy RNA + sinusoidal time embedding β model_dim. |
| 3. Multiple cross-attention transformer blocks where RNA queries attend to |
| imaging context. |
| 4. Project back to RNA embedding space and predict noise. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| from models.model_utils import ( |
| SinusoidalTimeEmbedding, |
| Mish, |
| FeedForward, |
| AdaLayerNorm, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| class CrossAttentionBlock(nn.Module): |
| """ |
| Single cross-attention layer: RNA query attends to imaging context. |
| Uses adaptive layer norm for time-conditioning and pre-norm residual style. |
| """ |
|
|
| def __init__(self, dim: int, num_heads: int, time_dim: int, ff_mult: int = 4, dropout: float = 0.1): |
| super().__init__() |
|
|
| |
| self.norm_rna = AdaLayerNorm(dim, time_dim) |
| self.norm_ctx = nn.LayerNorm(dim) |
| self.norm_ff = AdaLayerNorm(dim, time_dim) |
|
|
| |
| self.cross_attn = nn.MultiheadAttention( |
| embed_dim=dim, |
| num_heads=num_heads, |
| dropout=dropout, |
| batch_first=True, |
| ) |
|
|
| |
| self.self_attn_norm = AdaLayerNorm(dim, time_dim) |
| self.self_attn = nn.MultiheadAttention( |
| embed_dim=dim, |
| num_heads=num_heads, |
| dropout=dropout, |
| batch_first=True, |
| ) |
|
|
| |
| self.ff = FeedForward(dim, mult=ff_mult, dropout=dropout) |
|
|
| def forward( |
| self, |
| rna: torch.Tensor, |
| context: torch.Tensor, |
| time_emb: torch.Tensor, |
| ) -> torch.Tensor: |
| |
| rna_normed = self.norm_rna(rna, time_emb) |
| ctx_normed = self.norm_ctx(context) |
| rna = rna + self.cross_attn(rna_normed, ctx_normed, ctx_normed, need_weights=False)[0] |
|
|
| |
| |
| rna_normed = self.self_attn_norm(rna, time_emb) |
| rna = rna + self.self_attn(rna_normed, rna_normed, rna_normed, need_weights=False)[0] |
|
|
| |
| rna = rna + self.ff(self.norm_ff(rna, time_emb)) |
|
|
| return rna |
|
|
|
|
| |
| |
| |
|
|
| class ImagingEncoder(nn.Module): |
| """ |
| Encodes a set of imaging cell features into context representations. |
| Uses a small self-attention stack to let imaging cells attend to each other |
| before being used as cross-attention context. |
| """ |
|
|
| def __init__(self, img_dim: int, model_dim: int, num_heads: int = 4, num_layers: int = 2, dropout: float = 0.1): |
| super().__init__() |
|
|
| self.proj = nn.Sequential( |
| nn.Linear(img_dim, model_dim), |
| Mish(), |
| nn.LayerNorm(model_dim), |
| nn.Dropout(dropout), |
| ) |
|
|
| encoder_layer = nn.TransformerEncoderLayer( |
| d_model=model_dim, |
| nhead=num_heads, |
| dim_feedforward=model_dim * 4, |
| dropout=dropout, |
| activation="gelu", |
| batch_first=True, |
| norm_first=True, |
| ) |
| self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) |
|
|
| def forward(self, img_features: torch.Tensor) -> torch.Tensor: |
| """ |
| Args: |
| img_features: (B, N, img_dim) |
| Returns: |
| (B, N, model_dim) context representations |
| """ |
| x = self.proj(img_features) |
| return self.encoder(x) |
|
|
|
|
| |
| |
| |
|
|
| class Img2RNADenoiser(nn.Module): |
| """ |
| Noise prediction network for image-conditioned RNA-seq diffusion. |
| |
| Args: |
| img_dim: input imaging feature dimension (5120) |
| rna_dim: RNA-seq embedding dimension (512) |
| model_dim: internal model dimension (1024) |
| num_heads: number of attention heads (8) |
| num_layers: number of cross-attention blocks (6) |
| time_dim: time embedding dimension (256) |
| ff_mult: feedforward multiplier (4) |
| dropout: dropout rate (0.1) |
| """ |
|
|
| def __init__( |
| self, |
| img_dim: int = 5120, |
| rna_dim: int = 512, |
| model_dim: int = 1024, |
| num_heads: int = 8, |
| num_layers: int = 6, |
| time_dim: int = 256, |
| ff_mult: int = 4, |
| dropout: float = 0.1, |
| ): |
| super().__init__() |
|
|
| self.model_dim = model_dim |
| self.rna_dim = rna_dim |
|
|
| |
| self.time_embed = nn.Sequential( |
| SinusoidalTimeEmbedding(time_dim), |
| nn.Linear(time_dim, time_dim * 4), |
| Mish(), |
| nn.Linear(time_dim * 4, time_dim), |
| ) |
|
|
| |
| self.img_encoder = ImagingEncoder( |
| img_dim=img_dim, |
| model_dim=model_dim, |
| num_heads=min(num_heads, 4), |
| num_layers=2, |
| dropout=dropout, |
| ) |
|
|
| |
| self.rna_proj = nn.Sequential( |
| nn.Linear(rna_dim, model_dim), |
| Mish(), |
| nn.LayerNorm(model_dim), |
| ) |
|
|
| |
| self.layers = nn.ModuleList([ |
| CrossAttentionBlock( |
| dim=model_dim, |
| num_heads=num_heads, |
| time_dim=time_dim, |
| ff_mult=ff_mult, |
| dropout=dropout, |
| ) |
| for _ in range(num_layers) |
| ]) |
|
|
| |
| self.out_norm = nn.LayerNorm(model_dim) |
| self.out_proj = nn.Sequential( |
| nn.Linear(model_dim, model_dim), |
| Mish(), |
| nn.Linear(model_dim, rna_dim), |
| ) |
|
|
| self._init_weights() |
|
|
| def _init_weights(self): |
| """ |
| Initialize weights for stable diffusion training. |
| |
| Uses PyTorch defaults (kaiming) for all layers, with a small-scale |
| init on the very last linear so the model starts by predicting |
| near-zero noise while still allowing gradient flow. |
| """ |
| |
| nn.init.normal_(self.out_proj[2].weight, std=1e-4) |
| nn.init.zeros_(self.out_proj[2].bias) |
|
|
| def forward( |
| self, |
| noisy_rna: torch.Tensor, |
| img_features: torch.Tensor, |
| timestep: torch.Tensor, |
| ) -> torch.Tensor: |
| """ |
| Predict the noise component in the noisy RNA embedding. |
| |
| Returns: |
| predicted_noise: (B, rna_dim) |
| """ |
| |
| t_emb = self.time_embed(timestep) |
|
|
| |
| context = self.img_encoder(img_features) |
|
|
| |
| rna = self.rna_proj(noisy_rna).unsqueeze(1) |
|
|
| |
| for layer in self.layers: |
| rna = layer(rna, context, t_emb) |
|
|
| |
| rna = self.out_norm(rna.squeeze(1)) |
| noise_pred = self.out_proj(rna) |
|
|
| return noise_pred |
|
|
| @torch.no_grad() |
| def count_parameters(self) -> dict: |
| """Count trainable and total parameters.""" |
| total = sum(p.numel() for p in self.parameters()) |
| trainable = sum(p.numel() for p in self.parameters() if p.requires_grad) |
| return {"total": total, "trainable": trainable} |
|
|