from typing import Optional
import scipy.sparse.csgraph as csgraph
import torch
from torch import Tensor
from torch_geometric.typing import Adj
from torch_geometric.utils import (
get_laplacian,
scatter,
subgraph,
to_scipy_sparse_matrix,
unbatch,
)
from torch_scatter import scatter_mul
from tgp.select import Select, SelectOutput
from tgp.utils import (
check_and_filter_edge_weights,
connectivity_to_edge_index,
is_dense_adj,
is_multi_graph_batch,
)
from tgp.utils.typing import SinvType
def _dense_cosine_similarity(
x: Tensor, leader_mask: Tensor, batch: Optional[Tensor]
) -> Tensor:
r"""Computes a dense block-diagonal cosine similarity matrix.
This method calculates the cosine similarity between each node and the identified leaders.
Only similarities between nodes belonging to the same graph are considered valid.
Args:
x (Tensor): The node feature matrix of shape :math:`[N, F]`, where :math:`N` is the number of nodes
and :math:`F` is the number of features.
leader_mask (Tensor): A boolean mask of shape :math:`[N]` indicating which nodes are leaders.
batch (Tensor, optional): A vector of shape :math:`[N]` indicating the graph each node belongs to.
Returns:
Tensor: A dense cosine similarity matrix of shape :math:`[N, K]`, where :math:`K` is the number of leaders.
"""
device = x.device
eps = 1e-8
if batch is None:
batch = torch.zeros(x.size(0), dtype=torch.int64, device=device)
# Get all leader indices
global_leader_idx = torch.nonzero(leader_mask, as_tuple=True)[0]
# Get leader features
x_leaders = x[global_leader_idx] # [K, F]
# Compute cosine similarity between all nodes and all leaders
# Use broadcasting: x is [N, F], x_leaders is [K, F]
# We want to compute similarity for each (n, k) pair
dot_product = x @ x_leaders.t() # [N, K]
node_norms = x.norm(dim=-1, keepdim=True) # [N, 1]
leader_norms = x_leaders.norm(dim=-1, keepdim=True).t() # [1, K]
# Compute cosine similarity
cosine_sim = dot_product / (node_norms * leader_norms + eps) # [N, K]
# Zero out similarities between nodes and leaders from different graphs
# Create a mask where cosine_sim[i, j] is valid only if batch[i] == batch[global_leader_idx[j]]
batch_nodes = batch.unsqueeze(1) # [N, 1]
batch_leaders = batch[global_leader_idx].unsqueeze(0) # [1, K]
same_graph_mask = batch_nodes == batch_leaders # [N, K]
# Ensure nodes are only assigned to leaders within the same graph.
# Using -inf guarantees that softmax produces exactly zero probability mass
# for leaders belonging to different graphs.
cosine_sim = cosine_sim.masked_fill(~same_graph_mask, float("-inf"))
return cosine_sim
[docs]
class LaPoolSelect(Select):
r"""The select operator for the LaPool operator (:class:`~tgp.poolers.LaPooling`)
as proposed in the paper `Towards Interpretable Sparse Graph Representation Learning
with Laplacian Pooling <https://arxiv.org/abs/1905.11577>`_. (Emmanuel Noutahi et al., 2019).
This operator computes a soft assignment matrix :math:`\mathbf{S}` by first identifying a set of
leaders, and then assigning every remaining node to the cluster of the closest
leader:
.. math::
\begin{align*}
\mathbf{v} &= \| \mathbf{L} \mathbf{X} \|_d \\
\mathbf{i} &= \{ i \mid \mathbf{v}_i > \mathbf{v}_j, \forall j \in \mathcal{N}(i) \} \\
\mathbf{S}^\top &= \texttt{SparseSoftmax} \left( \beta \frac{\mathbf{X}\mathbf{X}_{\mathbf{i}}^\top}{\|\mathbf{X}\|\|\mathbf{X}_{\mathbf{i}}\|} \right)
\end{align*}
where:
+ :math:`\mathbf{L}` is the Laplacian matrix of the graph,
+ :math:`\mathbf{X}` is the input node feature matrix,
+ :math:`\beta` is a regularization vector that is applied element-wise to the selection matrix.
Args:
shortest_path_reg (bool, optional): If :obj:`True`, :math:`\beta` is equal to
the inverse of the shortest path between each node and its corresponding leader
(this can be expensive since it runs on CPU). Otherwise :math:`\beta=1`.
batched_representation (bool, optional):
If :obj:`True`, expects batched input :math:`\mathbf{X} \in \mathbb{R}^{B \times N \times F}`
and a dense adjacency tensor :math:`\mathbf{A} \in \mathbb{R}^{B \times N \times N}`.
If :obj:`False`, expects unbatched input :math:`\mathbf{X} \in \mathbb{R}^{N \times F}` and a
sparse adjacency in one of the formats supported by
:class:`~torch_geometric.typing.Adj` (or a dense :math:`[N, N]` tensor).
(default: :obj:`True`)
s_inv_op (~tgp.utils.typing.SinvType, optional):
The operation used to compute :math:`\mathbf{S}_\text{inv}` from the select matrix
:math:`\mathbf{S}`. :math:`\mathbf{S}_\text{inv}` is stored in the ``"s_inv"`` attribute of
the :class:`~tgp.select.SelectOutput`. It can be one of:
- ``"transpose"`` (default): Computes :math:`\mathbf{S}_\text{inv}` as :math:`\mathbf{S}^\top`,
the transpose of :math:`\mathbf{S}`.
- ``"inverse"``: Computes :math:`\mathbf{S}_\text{inv}` as :math:`\mathbf{S}^+`,
the Moore-Penrose pseudoinverse of :math:`\mathbf{S}`.
"""
is_dense: bool = True
def __init__(
self,
shortest_path_reg: bool = False,
batched_representation: bool = True,
s_inv_op: SinvType = "transpose",
):
super().__init__()
self.s_inv_op = s_inv_op
self.shortest_path_reg = shortest_path_reg
self.batched_representation = batched_representation
[docs]
def forward(
self,
x: Tensor,
edge_index: Adj,
edge_weight: Optional[Tensor] = None,
batch: Optional[Tensor] = None,
mask: Optional[Tensor] = None,
num_nodes: Optional[int] = None,
**kwargs,
) -> SelectOutput:
r"""Forward pass.
Args:
x (~torch.Tensor):
The node feature matrix of shape :math:`[N, F]`,
where :math:`N` is the number of nodes in the batch and
:math:`F` is the number of node features.
edge_index (~torch_geometric.typing.Adj, optional): The connectivity matrix.
It can either be a ``torch_sparse.SparseTensor`` of (sparse) shape :math:`[N, N]`,
where :math:`N` is the number of nodes in the batch or a :obj:`~torch.Tensor` of shape
:math:`[2, E]`, where :math:`E` is the number of edges in the batch.
For batched dense inputs, it also accepts dense adjacency tensors of shape
:math:`[B, N, N]` (or :math:`[N, N]` for unbatched dense inputs).
edge_weight (~torch.Tensor, optional):
A vector of shape :math:`[E]` or :math:`[E, 1]` containing the weights of the edges.
(default: :obj:`None`)
batch (~torch.Tensor, optional): The batch vector
:math:`\mathbf{b} \in {\{ 0, \ldots, B-1\}}^N`, which indicates
to which graph in the batch each node belongs.
(default: :obj:`None`)
mask (~torch.Tensor, optional):
Input-node validity mask :math:`\mathbf{M} \in {\{ 0, 1 \}}^{B \times N}`
with :obj:`True` on real (non-padded) nodes in each graph
(batched mode only). (default: :obj:`None`)
num_nodes (int, optional):
The total number of nodes of the graphs in the batch.
(default: :obj:`None`)
Returns:
:class:`~tgp.select.SelectOutput`: The output of :math:`\texttt{select}` operator.
"""
if self.batched_representation:
if x.dim() == 2:
x = x.unsqueeze(0)
elif x.dim() != 3:
raise ValueError("x must have shape [B, N, F].")
if not is_dense_adj(edge_index):
raise ValueError(
"Batched LaPoolSelect expects a dense adjacency tensor."
)
if edge_index.dim() == 2:
edge_index = edge_index.unsqueeze(0)
elif edge_index.dim() != 3:
raise ValueError(
"Batched LaPoolSelect expects a dense adjacency tensor of shape "
"[B, N, N]."
)
s = self._forward_batched(x, edge_index, mask)
return SelectOutput(s=s, s_inv_op=self.s_inv_op, in_mask=mask)
if x.dim() != 2:
raise ValueError("x must have shape [N, F].")
if mask is not None:
raise ValueError("mask is only supported for batched representations.")
if is_dense_adj(edge_index):
raise ValueError(
"Unbatched LaPoolSelect expects a sparse adjacency tensor."
)
s = self._forward_unbatched(
x=x,
edge_index=edge_index,
edge_weight=edge_weight,
batch=batch,
num_nodes=num_nodes or x.size(0),
)
return SelectOutput(s=s, s_inv_op=self.s_inv_op, batch=batch)
def _forward_batched(
self, x: Tensor, adj: Tensor, mask: Optional[Tensor]
) -> Tensor:
batch_size, num_nodes, _ = x.shape
if mask is None:
mask = x.new_ones(batch_size, num_nodes, dtype=torch.bool)
else:
if mask.dim() == 1:
mask = mask.unsqueeze(0)
mask = mask.to(torch.bool)
adj = adj * mask.unsqueeze(-1) * mask.unsqueeze(-2)
deg = adj.sum(dim=-1)
v = deg.unsqueeze(-1) * x - torch.bmm(adj, x)
v_norm = v.norm(dim=-1)
neighbor_mask = adj != 0
neighbor_vals = v_norm.unsqueeze(1).expand(-1, num_nodes, -1)
neighbor_vals = neighbor_vals.masked_fill(~neighbor_mask, float("-inf"))
neighbor_max = neighbor_vals.max(dim=-1).values
leader_mask = (v_norm >= neighbor_max) & mask
need_leader = (~leader_mask.any(dim=1)) & mask.any(dim=1)
leader_mask = leader_mask | (need_leader.unsqueeze(1) & mask)
x_flat = x.reshape(batch_size * num_nodes, -1)
leader_flat = leader_mask.reshape(-1)
batch_flat = torch.arange(batch_size, device=x.device).repeat_interleave(
num_nodes
)
cosine_similarity_dense = _dense_cosine_similarity(
x_flat, leader_flat, batch_flat
)
beta = 1.0
if self.shortest_path_reg:
edge_index = adj.nonzero(as_tuple=False)
if edge_index.numel() > 0:
row = edge_index[:, 0] * num_nodes + edge_index[:, 1]
col = edge_index[:, 0] * num_nodes + edge_index[:, 2]
edge_index = torch.stack([row, col], dim=0)
sp_matrix = to_scipy_sparse_matrix(
edge_index, num_nodes=batch_size * num_nodes
).tocsr()
shortest_path = torch.tensor(
csgraph.shortest_path(sp_matrix, directed=False),
dtype=torch.float32,
device=x.device,
)
beta = torch.zeros_like(shortest_path, dtype=torch.float32)
nonzero = shortest_path != 0
beta[nonzero] = 1 / shortest_path[nonzero]
beta = beta[:, leader_flat].to(dtype=cosine_similarity_dense.dtype)
s = torch.softmax(cosine_similarity_dense, dim=-1)
s = beta * s
s_non_leader = s.clone()
s_non_leader[leader_flat] = 0.0
leader_idx = torch.nonzero(leader_flat).squeeze()
if leader_idx.dim() == 0:
leader_idx = leader_idx.unsqueeze(0)
leader_cols = torch.arange(leader_idx.size(0), device=leader_idx.device)
kronecker_delta = torch.zeros_like(s)
kronecker_delta[leader_idx, leader_cols] = 1.0
s = s_non_leader + kronecker_delta
leaders_per_graph = scatter(
leader_flat.float(), batch_flat, dim=0, dim_size=batch_size, reduce="sum"
).long()
K_max = int(leaders_per_graph.max().item())
cum_leaders = torch.cat(
[
leaders_per_graph.new_zeros(1),
torch.cumsum(leaders_per_graph, dim=0),
]
).long()
s_new = torch.zeros(
batch_size * num_nodes, K_max, device=x.device, dtype=s.dtype
)
for b in range(batch_size):
start = cum_leaders[b].item()
end = cum_leaders[b + 1].item()
k_b = end - start
if k_b > 0:
s_new[b * num_nodes : (b + 1) * num_nodes, :k_b] = s[
b * num_nodes : (b + 1) * num_nodes, start:end
]
s = s_new * mask.reshape(-1, 1).to(s.dtype)
return s.reshape(batch_size, num_nodes, K_max)
def _forward_unbatched(
self,
x: Tensor,
edge_index: Adj,
edge_weight: Optional[Tensor],
batch: Optional[Tensor],
num_nodes: int,
) -> Tensor:
# Multi-graph: run single-graph path per graph, pad to K_max, concatenate
if is_multi_graph_batch(batch):
edge_index, edge_weight = connectivity_to_edge_index(
edge_index, edge_weight
)
batch_size = int(batch.max().item()) + 1
unbatched_x = unbatch(x, batch)
# Build per-graph edge views explicitly to preserve graphs that have
# nodes but no edges. `unbatch_edge_index` may skip such graphs,
# which would silently truncate zip(...) and drop rows in S.
unbatched_ei = []
unbatched_ew = []
for b in range(batch_size):
node_mask = batch == b
ei_b, ew_b = subgraph(
subset=node_mask,
edge_index=edge_index,
edge_attr=edge_weight,
relabel_nodes=True,
num_nodes=x.size(0),
)
unbatched_ei.append(ei_b)
unbatched_ew.append(ew_b)
s_list = [
self._forward_unbatched(
x=x_i,
edge_index=ei_i,
edge_weight=ew_i,
batch=None,
num_nodes=x_i.size(0),
)
for x_i, ei_i, ew_i in zip(unbatched_x, unbatched_ei, unbatched_ew)
]
K_max = max(s_i.size(-1) for s_i in s_list)
return torch.cat(
[
s_i
if s_i.size(-1) == K_max
else torch.cat(
[
s_i,
s_i.new_zeros(s_i.size(0), K_max - s_i.size(-1)),
],
dim=-1,
)
for s_i in s_list
],
dim=0,
)
edge_index, edge_weight = connectivity_to_edge_index(edge_index, edge_weight)
edge_weight = check_and_filter_edge_weights(edge_weight)
lap_edge_index, lap_edge_weights = get_laplacian(
edge_index, edge_weight=edge_weight, num_nodes=num_nodes
)
lap_sparse = torch.sparse_coo_tensor(
lap_edge_index,
lap_edge_weights,
size=(num_nodes, num_nodes),
device=x.device,
)
v = torch.sparse.mm(lap_sparse, x)
v = v.norm(dim=-1, keepdim=True)
row, col = lap_edge_index[0], lap_edge_index[1]
if row.size(0) == 0 or (lap_edge_weights == 0).all():
leader_mask = torch.ones(num_nodes, dtype=torch.bool, device=x.device)
else:
leader_check = (v[row] >= v[col]).int().squeeze()
leader_mask = scatter_mul(
leader_check, row, dim=0, dim_size=num_nodes
).bool()
if not leader_mask.any():
leader_mask = torch.ones(num_nodes, dtype=torch.bool, device=x.device)
cosine_similarity_dense = _dense_cosine_similarity(x, leader_mask, batch)
beta = 1.0
if self.shortest_path_reg:
sp_matrix = to_scipy_sparse_matrix(edge_index, num_nodes=num_nodes).tocsr()
shortest_path = torch.tensor(
csgraph.shortest_path(sp_matrix, directed=False),
dtype=torch.float32,
device=x.device,
)
beta = torch.zeros_like(shortest_path, dtype=torch.float32)
nonzero = shortest_path != 0
beta[nonzero] = 1 / shortest_path[nonzero]
beta = beta[:, leader_mask].to(dtype=cosine_similarity_dense.dtype)
s = torch.softmax(cosine_similarity_dense, dim=-1)
s = beta * s
s_non_leader = s.clone()
s_non_leader[leader_mask] = 0.0
leader_idx = torch.nonzero(leader_mask).squeeze()
if leader_idx.dim() == 0:
leader_idx = leader_idx.unsqueeze(0)
leader_cols = torch.arange(leader_idx.size(0), device=leader_idx.device)
kronecker_delta = torch.zeros_like(s)
kronecker_delta[leader_idx, leader_cols] = 1.0
s = s_non_leader + kronecker_delta
return s
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}(s_inv_op={self.s_inv_op}, "
f"shortest_path_reg={self.shortest_path_reg})"
)