| """ |
| PyTorch Lightning LightningModule for Img2RNA diffusion training. |
| |
| Wraps the GaussianDiffusion model with: |
| - Automatic optimizer / scheduler setup |
| - Training / validation step definitions |
| - EMA weight averaging |
| - Periodic sample generation for qualitative monitoring |
| """ |
|
|
| from __future__ import annotations |
|
|
| import copy |
| from typing import Any, Optional |
|
|
| import torch |
| import torch.nn as nn |
| import pytorch_lightning as pl |
|
|
| from models.denoiser import Img2RNADenoiser |
| from models.diffusion import GaussianDiffusion |
|
|
|
|
| class EMA: |
| """Exponential Moving Average of model parameters (Lightning-compatible).""" |
|
|
| def __init__(self, model: nn.Module, decay: float = 0.9999): |
| self.decay = decay |
| self.shadow = { |
| name: p.clone().detach() |
| for name, p in model.named_parameters() |
| if p.requires_grad |
| } |
| self._backup: dict[str, torch.Tensor] = {} |
|
|
| @torch.no_grad() |
| def update(self, model: nn.Module): |
| for name, p in model.named_parameters(): |
| if p.requires_grad and name in self.shadow: |
| self.shadow[name].lerp_(p.data, 1 - self.decay) |
|
|
| def apply(self, model: nn.Module): |
| self._backup = { |
| name: p.data.clone() |
| for name, p in model.named_parameters() |
| if name in self.shadow |
| } |
| for name, p in model.named_parameters(): |
| if name in self.shadow: |
| p.data.copy_(self.shadow[name]) |
|
|
| def restore(self, model: nn.Module): |
| for name, p in model.named_parameters(): |
| if name in self._backup: |
| p.data.copy_(self._backup[name]) |
| self._backup.clear() |
|
|
| def state_dict(self): |
| return {k: v.cpu() for k, v in self.shadow.items()} |
|
|
| def load_state_dict(self, state_dict: dict): |
| self.shadow = {k: v.clone() for k, v in state_dict.items()} |
|
|
|
|
| class Img2RNALitModule(pl.LightningModule): |
| """ |
| Lightning module for image-conditioned RNA-seq diffusion. |
| |
| Handles training, validation, EMA, and optional sample generation. |
| Works with any Lightning logger (TensorBoard, wandb, CSV, etc.). |
| """ |
|
|
| 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, |
| |
| num_steps: int = 1000, |
| schedule: str = "cosine", |
| beta_start: float = 1e-4, |
| beta_end: float = 0.02, |
| loss_type: str = "l2", |
| |
| rna_norm: Optional[dict] = None, |
| |
| lr: float = 1e-4, |
| weight_decay: float = 1e-5, |
| warmup_epochs: int = 5, |
| scheduler_type: str = "cosine", |
| max_grad_norm: float = 1.0, |
| ema_decay: float = 0.9999, |
| |
| val_sample_every_n_epochs: int = 10, |
| ddim_steps: int = 50, |
| ): |
| super().__init__() |
| self.save_hyperparameters() |
|
|
| |
| self.denoiser = Img2RNADenoiser( |
| img_dim=img_dim, |
| rna_dim=rna_dim, |
| model_dim=model_dim, |
| num_heads=num_heads, |
| num_layers=num_layers, |
| time_dim=time_dim, |
| ff_mult=ff_mult, |
| dropout=dropout, |
| ) |
|
|
| |
| self.diffusion = GaussianDiffusion( |
| denoiser=self.denoiser, |
| num_steps=num_steps, |
| schedule=schedule, |
| beta_start=beta_start, |
| beta_end=beta_end, |
| loss_type=loss_type, |
| rna_norm=rna_norm, |
| ) |
|
|
| |
| self._ema: Optional[EMA] = None |
| self._ema_decay = ema_decay |
|
|
| |
| self._val_step_outputs: list[dict] = [] |
|
|
| |
|
|
| def on_fit_start(self): |
| self._ema = EMA(self.diffusion, decay=self._ema_decay) |
| param_info = self.denoiser.count_parameters() |
| self.log_dict({ |
| "model/total_params": float(param_info["total"]), |
| "model/trainable_params": float(param_info["trainable"]), |
| }) |
|
|
| |
|
|
| def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor: |
| img_features = batch["img_features"] |
| rna_embedding = batch["rna_embedding"] |
|
|
| result = self.diffusion.compute_loss(rna_embedding, img_features) |
| loss = result["loss"] |
|
|
| |
| self.log("train/loss", loss, on_step=True, on_epoch=True, prog_bar=True) |
| self.log("train/mse", result["mse"], on_step=False, on_epoch=True) |
| self.log("train/lr", self.optimizers().param_groups[0]["lr"], on_step=True, on_epoch=False) |
|
|
| return loss |
|
|
| def on_train_batch_end(self, outputs, batch, batch_idx): |
| if self._ema is not None: |
| self._ema.update(self.diffusion) |
|
|
| |
|
|
| def validation_step(self, batch: dict, batch_idx: int) -> dict: |
| img_features = batch["img_features"] |
| rna_embedding = batch["rna_embedding"] |
|
|
| result = self.diffusion.compute_loss(rna_embedding, img_features) |
|
|
| self.log("val/loss", result["loss"], on_step=False, on_epoch=True, prog_bar=True, sync_dist=True) |
| self.log("val/mse", result["mse"], on_step=False, on_epoch=True, sync_dist=True) |
|
|
| self._val_step_outputs.append({ |
| "loss": result["loss"].detach(), |
| "mse": result["mse"].detach(), |
| }) |
|
|
| return result |
|
|
| def on_validation_epoch_end(self): |
| |
| if ( |
| self.current_epoch > 0 |
| and self.current_epoch % self.hparams.val_sample_every_n_epochs == 0 |
| ): |
| self._log_generated_samples() |
|
|
| self._val_step_outputs.clear() |
|
|
| @torch.no_grad() |
| def _log_generated_samples(self, n_samples: int = 8): |
| """Generate a few samples and log statistics to the logger.""" |
| |
| if self._ema is not None: |
| self._ema.apply(self.diffusion) |
|
|
| try: |
| |
| |
| dummy_img = torch.randn( |
| n_samples, |
| self.hparams.get("n_imaging_cells", 16), |
| self.hparams.img_dim, |
| device=self.device, |
| ) |
| generated = self.diffusion.sample_ddim( |
| dummy_img, |
| num_inference_steps=self.hparams.ddim_steps, |
| ) |
|
|
| self.log("val/generated_mean", generated.mean()) |
| self.log("val/generated_std", generated.std()) |
| self.log("val/generated_min", generated.min()) |
| self.log("val/generated_max", generated.max()) |
|
|
| finally: |
| if self._ema is not None: |
| self._ema.restore(self.diffusion) |
|
|
| |
|
|
| def configure_optimizers(self): |
| optimizer = torch.optim.AdamW( |
| self.parameters(), |
| lr=self.hparams.lr, |
| weight_decay=self.hparams.weight_decay, |
| betas=(0.9, 0.999), |
| ) |
|
|
| if self.hparams.scheduler_type == "cosine": |
| |
| scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( |
| optimizer, |
| T_max=self.trainer.estimated_stepping_batches, |
| eta_min=self.hparams.lr * 0.01, |
| ) |
| return { |
| "optimizer": optimizer, |
| "lr_scheduler": { |
| "scheduler": scheduler, |
| "interval": "step", |
| "frequency": 1, |
| }, |
| } |
| else: |
| return optimizer |
|
|
| def on_before_optimizer_step(self, optimizer): |
| |
| if self.hparams.max_grad_norm > 0: |
| nn.utils.clip_grad_norm_(self.parameters(), self.hparams.max_grad_norm) |
|
|
| |
|
|
| @torch.no_grad() |
| def generate( |
| self, |
| img_features: torch.Tensor, |
| use_ema: bool = True, |
| ddim: bool = True, |
| ddim_steps: int = 50, |
| ) -> torch.Tensor: |
| """ |
| Generate RNA-seq embeddings from imaging features. |
| |
| Args: |
| img_features: (B, N, img_dim) |
| use_ema: whether to use EMA weights |
| ddim: use DDIM sampling (faster) |
| ddim_steps: number of DDIM steps |
| Returns: |
| (B, rna_dim) generated embeddings |
| """ |
| if use_ema and self._ema is not None: |
| self._ema.apply(self.diffusion) |
|
|
| try: |
| if ddim: |
| return self.diffusion.sample_ddim(img_features, num_inference_steps=ddim_steps) |
| else: |
| return self.diffusion.sample(img_features) |
| finally: |
| if use_ema and self._ema is not None: |
| self._ema.restore(self.diffusion) |
|
|