hutaobo's picture
Upload cleaned DST-GNN manuscript code
2936fa5 verified
Raw
History Blame Contribute Delete
5.01 kB
"""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