Source code for spatialvi.train._callbacks

"""Training callbacks for spatial models."""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

import numpy as np
from lightning.pytorch.callbacks import Callback

if TYPE_CHECKING:
    from lightning import LightningModule, Trainer

logger = logging.getLogger(__name__)


[docs] class SpatialMetricsCallback(Callback): """Callback for computing spatial metrics during training. Parameters ---------- compute_every_n_epochs Frequency of metric computation. metrics List of metrics to compute. """ def __init__( self, compute_every_n_epochs: int = 10, metrics: list[str] | None = None, ): super().__init__() self.compute_every_n_epochs = compute_every_n_epochs self.metrics = metrics or ["spatial_loss"]
[docs] def on_train_epoch_end( self, trainer: Trainer, pl_module: LightningModule, ) -> None: """Compute metrics at epoch end.""" if (trainer.current_epoch + 1) % self.compute_every_n_epochs != 0: return if "spatial_loss" in self.metrics: # Log spatial coherence if hasattr(pl_module, "module") and hasattr(pl_module.module, "spatial_weight"): spatial_weight = pl_module.module.spatial_weight pl_module.log("spatial_weight_current", spatial_weight)
[docs] class NeighborSamplingCallback(Callback): """Callback for neighbor-aware batch sampling. This callback modifies the data loader to include neighbor information in each batch. Parameters ---------- neighbor_key Key in obsm for neighbor indices. include_neighbor_expr Whether to include neighbor expression. """ def __init__( self, neighbor_key: str = "nn_index", include_neighbor_expr: bool = True, ): super().__init__() self.neighbor_key = neighbor_key self.include_neighbor_expr = include_neighbor_expr
[docs] def on_train_batch_start( self, trainer: Trainer, pl_module: LightningModule, batch: dict, batch_idx: int, ) -> None: """Augment batch with neighbor information.""" # This is a placeholder - actual implementation depends on # how the dataloader is structured pass
[docs] class EarlyStoppingOnSpatialLoss(Callback): """Early stopping based on spatial loss convergence. Parameters ---------- patience Number of epochs to wait for improvement. min_delta Minimum change to qualify as improvement. monitor Metric to monitor. """ def __init__( self, patience: int = 20, min_delta: float = 1e-4, monitor: str = "spatial_loss", ): super().__init__() self.patience = patience self.min_delta = min_delta self.monitor = monitor self.best_value = float("inf") self.wait_count = 0
[docs] def on_validation_epoch_end( self, trainer: Trainer, pl_module: LightningModule, ) -> None: """Check for improvement.""" current = trainer.callback_metrics.get(self.monitor) if current is None: return current = current.item() if hasattr(current, "item") else current if current < self.best_value - self.min_delta: self.best_value = current self.wait_count = 0 else: self.wait_count += 1 if self.wait_count >= self.patience: logger.info(f"Early stopping triggered after {trainer.current_epoch} epochs") trainer.should_stop = True
[docs] class SpatialRegularizationScheduler(Callback): """Scheduler for spatial regularization weight. Parameters ---------- initial_weight Starting weight. final_weight Final weight. warmup_epochs Number of epochs to warmup. schedule Schedule type: "linear", "cosine", "step". """ def __init__( self, initial_weight: float = 0.0, final_weight: float = 1.0, warmup_epochs: int = 50, schedule: str = "linear", ): super().__init__() self.initial_weight = initial_weight self.final_weight = final_weight self.warmup_epochs = warmup_epochs self.schedule = schedule def _compute_weight(self, epoch: int) -> float: """Compute weight for given epoch.""" if epoch >= self.warmup_epochs: return self.final_weight progress = epoch / self.warmup_epochs if self.schedule == "linear": return self.initial_weight + progress * (self.final_weight - self.initial_weight) elif self.schedule == "cosine": return self.final_weight - (self.final_weight - self.initial_weight) * (1 + np.cos(np.pi * progress)) / 2 elif self.schedule == "step": # Step at halfway if progress < 0.5: return self.initial_weight else: return self.final_weight else: return self.final_weight
[docs] def on_train_epoch_start( self, trainer: Trainer, pl_module: LightningModule, ) -> None: """Update spatial weight.""" weight = self._compute_weight(trainer.current_epoch) if hasattr(pl_module, "module") and hasattr(pl_module.module, "spatial_weight"): pl_module.module.spatial_weight = weight pl_module.log("scheduled_spatial_weight", weight)