"""Model and training utilities for DST-GNN.""" from __future__ import annotations from typing import Iterable import torch import torch.nn.functional as F from torch import nn from torch_geometric.data import Data from torch_geometric.nn import GCNConv class DSTGNN(nn.Module): """A simple spatio-temporal GNN for sequential COSTE graphs. The model follows the manuscript description with: - one-hot node identity features - diffusion-style graph convolutions over stage graphs - explicit temporal state propagation across T1 -> T2 -> T3 - an MSE objective on later-stage SSS reconstruction """ def __init__( self, num_nodes: int, hidden_channels: int = 32, dropout: float = 0.0, ) -> None: super().__init__() self.num_nodes = num_nodes self.hidden_channels = hidden_channels self.dropout = dropout self.input_projection = nn.Linear(num_nodes, hidden_channels) self.conv1 = GCNConv(hidden_channels, hidden_channels) self.conv2 = GCNConv(hidden_channels, hidden_channels) self.temporal_update = nn.GRUCell(hidden_channels, hidden_channels) self.edge_decoder = nn.Sequential( nn.Linear(hidden_channels * 2, hidden_channels), nn.ReLU(), nn.Linear(hidden_channels, 1), nn.Sigmoid(), ) def encode_step( self, x: torch.Tensor, edge_index: torch.Tensor, edge_weight: torch.Tensor, prev_hidden: torch.Tensor | None = None, ) -> torch.Tensor: """Encode one stage graph while propagating temporal state.""" base_state = self.input_projection(x) if prev_hidden is None: prev_hidden = torch.zeros_like(base_state) hidden = base_state + prev_hidden hidden = self.conv1(hidden, edge_index, edge_weight=edge_weight) hidden = F.relu(hidden) hidden = F.dropout(hidden, p=self.dropout, training=self.training) hidden = self.conv2(hidden, edge_index, edge_weight=edge_weight) hidden = F.relu(hidden) hidden = self.temporal_update(hidden, prev_hidden) return hidden def decode_adjacency(self, node_embeddings: torch.Tensor) -> torch.Tensor: """Decode directed SSS predictions for all ordered cell-type pairs.""" num_nodes = node_embeddings.size(0) left = node_embeddings.unsqueeze(1).expand(num_nodes, num_nodes, -1) right = node_embeddings.unsqueeze(0).expand(num_nodes, num_nodes, -1) logits = self.edge_decoder(torch.cat([left, right], dim=-1)).squeeze(-1) logits = logits.clamp(0.0, 1.0) logits.fill_diagonal_(0.0) return logits def forecast_sequence(self, graphs: Iterable[Data]) -> list[torch.Tensor]: """Forecast later-stage SSS matrices from earlier observed graphs.""" predictions: list[torch.Tensor] = [] hidden: torch.Tensor | None = None graph_list = list(graphs) for graph in graph_list[:-1]: hidden = self.encode_step(graph.x, graph.edge_index, graph.edge_weight, hidden) predictions.append(self.decode_adjacency(hidden)) return predictions def encode_observed_sequence(self, graphs: Iterable[Data]) -> list[torch.Tensor]: """Return node embeddings after each observed stage graph.""" embeddings: list[torch.Tensor] = [] hidden: torch.Tensor | None = None for graph in graphs: hidden = self.encode_step(graph.x, graph.edge_index, graph.edge_weight, hidden) embeddings.append(hidden) return embeddings def compute_transition_loss(model: DSTGNN, graphs: list[Data]) -> torch.Tensor: """Average MSE between predicted and observed later-stage SSS matrices.""" predictions = model.forecast_sequence(graphs) losses = [ F.mse_loss(prediction, graphs[index + 1].y) for index, prediction in enumerate(predictions) ] if not losses: raise ValueError("At least two stage graphs are required for temporal training.") return torch.stack(losses).mean() def train_model( model: DSTGNN, graphs: list[Data], epochs: int = 400, lr: float = 0.01, weight_decay: float = 5e-4, verbose_every: int = 50, ) -> list[dict[str, float]]: """Train DST-GNN with Adam and an MSE objective.""" optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay) history: list[dict[str, float]] = [] for epoch in range(1, epochs + 1): model.train() optimizer.zero_grad() loss = compute_transition_loss(model, graphs) loss.backward() optimizer.step() record = {"epoch": float(epoch), "loss": float(loss.item())} history.append(record) if verbose_every and (epoch == 1 or epoch % verbose_every == 0 or epoch == epochs): print(f"Epoch {epoch:04d} | loss={loss.item():.6f}") return history