"""Explainability and ranking helpers for DST-GNN outputs.""" from __future__ import annotations import numpy as np import pandas as pd import torch from torch import nn from torch_geometric.data import Data from torch_geometric.explain import Explainer, GNNExplainer from .model import DSTGNN def rank_nodes_by_embedding_drift( node_names: list[str], start_embeddings: torch.Tensor, end_embeddings: torch.Tensor, ) -> pd.DataFrame: """Rank nodes by embedding change magnitude between two stages.""" drift = torch.norm(end_embeddings - start_embeddings, dim=1).detach().cpu().numpy() frame = pd.DataFrame({"node": node_names, "drift_score": drift}) return frame.sort_values("drift_score", ascending=False).reset_index(drop=True) def rank_edges_by_stage_change( node_names: list[str], start_matrix: np.ndarray, end_matrix: np.ndarray, ) -> pd.DataFrame: """Rank directed edges by absolute SSS change between two stages.""" records: list[dict[str, float | str]] = [] num_nodes = len(node_names) for source in range(num_nodes): for target in range(num_nodes): if source == target: continue delta = float(end_matrix[source, target] - start_matrix[source, target]) records.append( { "source": node_names[source], "target": node_names[target], "delta_sss": delta, "abs_delta_sss": abs(delta), } ) frame = pd.DataFrame.from_records(records) return frame.sort_values("abs_delta_sss", ascending=False).reset_index(drop=True) class _TransitionReadout(nn.Module): """Wrapper that exposes next-stage adjacency predictions for GNNExplainer.""" def __init__(self, model: DSTGNN, prev_hidden: torch.Tensor | None = None) -> None: super().__init__() self.model = model if prev_hidden is not None: self.register_buffer("prev_hidden", prev_hidden.detach().clone()) else: self.prev_hidden = None def forward( self, x: torch.Tensor, edge_index: torch.Tensor, edge_weight: torch.Tensor, ) -> torch.Tensor: hidden = self.model.encode_step(x, edge_index, edge_weight, self.prev_hidden) return self.model.decode_adjacency(hidden) def explain_node_transition( model: DSTGNN, graph: Data, target_node: int, prev_hidden: torch.Tensor | None = None, epochs: int = 200, ): """Explain a node's predicted next-stage connectivity profile.""" wrapper = _TransitionReadout(model, prev_hidden=prev_hidden) explainer = Explainer( model=wrapper, algorithm=GNNExplainer(epochs=epochs), explanation_type="model", node_mask_type="attributes", edge_mask_type="object", model_config={ "mode": "regression", "task_level": "node", "return_type": "raw", }, ) return explainer( graph.x, graph.edge_index, edge_weight=graph.edge_weight, index=target_node, ) def edge_mask_to_frame( graph: Data, node_names: list[str], edge_mask: torch.Tensor, ) -> pd.DataFrame: """Convert a learned edge mask to a sortable DataFrame.""" source_nodes = graph.edge_index[0].detach().cpu().numpy() target_nodes = graph.edge_index[1].detach().cpu().numpy() weights = graph.edge_weight.detach().cpu().numpy() scores = edge_mask.detach().cpu().numpy() frame = pd.DataFrame( { "source": [node_names[index] for index in source_nodes], "target": [node_names[index] for index in target_nodes], "message_passing_weight": weights, "explanation_score": scores, } ) return frame.sort_values("explanation_score", ascending=False).reset_index(drop=True)