PhenoSeq / models /lit_module.py
naidooreed's picture
Upload folder using huggingface_hub
fdb5676 verified
Raw
History Blame Contribute Delete
10.1 kB
"""
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,
# Model config
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,
# Diffusion config
num_steps: int = 1000,
schedule: str = "cosine",
beta_start: float = 1e-4,
beta_end: float = 0.02,
loss_type: str = "l2",
# Normalization stats
rna_norm: Optional[dict] = None,
# Training config
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,
# Sampling config (for validation visualisation)
val_sample_every_n_epochs: int = 10,
ddim_steps: int = 50,
):
super().__init__()
self.save_hyperparameters()
# Build denoiser
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,
)
# Build diffusion wrapper
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,
)
# EMA (initialised in on_fit_start so it lives on correct device)
self._ema: Optional[EMA] = None
self._ema_decay = ema_decay
# Cache for logging
self._val_step_outputs: list[dict] = []
# ── Lifecycle hooks ───────────────────────────────────────────────────
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"]),
})
# ── Training ──────────────────────────────────────────────────────────
def training_step(self, batch: dict, batch_idx: int) -> torch.Tensor:
img_features = batch["img_features"] # (B, N, img_dim)
rna_embedding = batch["rna_embedding"] # (B, rna_dim)
result = self.diffusion.compute_loss(rna_embedding, img_features)
loss = result["loss"]
# Log metrics
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)
# ── Validation ────────────────────────────────────────────────────────
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):
# Optional: generate samples for qualitative monitoring
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."""
# Use EMA weights for sampling
if self._ema is not None:
self._ema.apply(self.diffusion)
try:
# Create dummy imaging conditioning (random from normal; in practice
# you'd use real validation imaging features)
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)
# ── Optimizer & scheduler ─────────────────────────────────────────────
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":
# Total steps will be set by trainer
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):
# Gradient clipping
if self.hparams.max_grad_norm > 0:
nn.utils.clip_grad_norm_(self.parameters(), self.hparams.max_grad_norm)
# ── Inference helpers ─────────────────────────────────────────────────
@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)