import warnings
from typing import Optional, Tuple
import numpy as np
import torch
import torch.nn.functional as F
from sklearn.cluster import SpectralClustering
from torch import Tensor
from torch_geometric.typing import Adj
from torch_geometric.utils import to_dense_adj
from tgp.select.base_select import Select, SelectOutput
from tgp.utils.ops import connectivity_to_edge_index, is_multi_graph_batch
from tgp.utils.typing import SinvType
def laplacian(adj: np.ndarray, normalized: bool = True) -> np.ndarray:
r"""Compute the graph Laplacian from a dense adjacency matrix.
Given an adjacency matrix :math:`\mathbf{A}`, the function returns either:
.. math::
\mathbf{L} = \mathbf{D} - \mathbf{A} \quad \text{(unnormalized)}
or the symmetric normalized Laplacian:
.. math::
\mathbf{L} = \mathbf{I} - \mathbf{D}^{-1/2}\mathbf{A}\mathbf{D}^{-1/2}.
Args:
adj (np.ndarray):
Dense adjacency matrix :math:`\mathbf{A} \in \mathbb{R}^{N \times N}`.
normalized (bool, optional):
If :obj:`True`, returns the normalized Laplacian.
(default: :obj:`True`)
Returns:
np.ndarray:
Laplacian matrix :math:`\mathbf{L} \in \mathbb{R}^{N \times N}`.
"""
d = adj.sum(axis=0).reshape(-1)
if not normalized:
return np.diag(d) - adj
d = d + np.spacing(np.array(0, dtype=adj.dtype))
d_inv_sqrt = 1.0 / np.sqrt(d)
d_inv_sqrt_mat = np.diag(d_inv_sqrt)
identity = np.eye(d.size, dtype=adj.dtype)
return identity - d_inv_sqrt_mat @ adj @ d_inv_sqrt_mat
def eigenvectors(L: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
r"""Compute eigenvalues and eigenvectors of a Laplacian matrix.
Args:
L (np.ndarray):
Laplacian matrix :math:`\mathbf{L} \in \mathbb{R}^{N \times N}`.
Returns:
Tuple[np.ndarray, np.ndarray]:
- Eigenvalues :math:`\boldsymbol{\lambda} \in \mathbb{R}^{N}`
- Eigenvectors :math:`\mathbf{U} \in \mathbb{R}^{N \times N}` (columns)
"""
lamb, U = np.linalg.eigh(L)
return lamb, U
def _group_nodes_by_cluster(cluster_labels: np.ndarray) -> dict:
clusters = {}
for node_idx, label in enumerate(cluster_labels):
label_int = int(label)
clusters.setdefault(label_int, []).append(node_idx)
return {
label: np.asarray(node_indices, dtype=np.int64)
for label, node_indices in clusters.items()
}
def build_pooling_matrix(
adj_np: np.ndarray,
cluster_labels: np.ndarray,
num_modes: int,
normalized: bool = True,
expected_num_clusters: Optional[int] = None,
) -> np.ndarray:
r"""Build the eigenvector-based pooling matrix :math:`\boldsymbol{\Theta}`.
For each cluster, we compute the Laplacian of the induced subgraph and use
the first :math:`H` eigenvectors as pooling modes. The resulting matrix is
assembled as:
.. math::
\boldsymbol{\Theta} = [\boldsymbol{\Theta}^{(1)} \; \cdots \; \boldsymbol{\Theta}^{(H)}]
\in \mathbb{R}^{N \times (K\cdot H)},
where :math:`\boldsymbol{\Theta}^{(h)}` places the :math:`h`-th eigenvector
of each cluster on the rows corresponding to its nodes and zeros elsewhere.
Args:
adj_np (np.ndarray):
Dense adjacency matrix :math:`\mathbf{A} \in \mathbb{R}^{N \times N}`.
cluster_labels (np.ndarray):
Cluster assignment vector of length :math:`N`.
num_modes (int):
Number of eigenvector modes :math:`H`.
normalized (bool, optional):
If :obj:`True`, uses the normalized Laplacian.
(default: :obj:`True`)
expected_num_clusters (int, optional):
Fixed number of clusters :math:`K` used to allocate columns in the
output, even when some clusters are empty.
Returns:
np.ndarray:
Pooling matrix
:math:`\boldsymbol{\Theta} \in \mathbb{R}^{N \times (K \cdot H)}`.
"""
num_nodes = adj_np.shape[0]
clusters = _group_nodes_by_cluster(cluster_labels)
if expected_num_clusters is None:
label_to_idx = {label: idx for idx, label in enumerate(sorted(clusters))}
num_clusters = len(label_to_idx)
else:
label_to_idx = {label: label for label in clusters}
num_clusters = expected_num_clusters
theta = np.zeros((num_nodes, num_clusters * num_modes), dtype=adj_np.dtype)
for label, node_indices in clusters.items():
cluster_idx = label_to_idx[label]
adj_cluster = adj_np[np.ix_(node_indices, node_indices)]
cluster_size = node_indices.size
if cluster_size == 1:
value = float(adj_cluster[0, 0])
theta[node_indices[0], cluster_idx::num_clusters] = value
continue
_, eigvecs = eigenvectors(laplacian(adj_cluster, normalized=normalized))
max_mode_idx = cluster_size - 1
for mode_idx in range(num_modes):
eigvec = eigvecs[:, min(mode_idx, max_mode_idx)]
# Fix eigenvector sign ambiguity to keep deterministic output.
if eigvec[0] < 0:
eigvec = -eigvec
theta[node_indices, mode_idx * num_clusters + cluster_idx] = eigvec
return theta
def _cluster_from_adj(adj_np: np.ndarray, k: int) -> Tuple[np.ndarray, int]:
num_nodes = adj_np.shape[0]
actual_k = max(1, min(k, num_nodes))
if actual_k == 1:
return np.zeros(num_nodes, dtype=np.int64), actual_k
# When k >= N, using one cluster per node is equivalent and avoids
# SpectralClustering warnings about eigensolver fallbacks.
if actual_k >= num_nodes:
return np.arange(num_nodes, dtype=np.int64), num_nodes
sc = SpectralClustering(n_clusters=actual_k, affinity="precomputed", n_init=10)
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=".*not fully connected.*",
category=UserWarning,
)
sc.fit(adj_np)
return sc.labels_.astype(np.int64), actual_k
def _select_from_dense_adjacency(
adj_dense: Tensor,
k: int,
num_modes: int,
normalized: bool,
num_classes: Optional[int] = None,
) -> Tuple[Tensor, Tensor]:
adj_np = adj_dense.cpu().numpy()
cluster_labels, actual_k = _cluster_from_adj(adj_np, k)
s_num_classes = actual_k if num_classes is None else num_classes
cluster_index = torch.as_tensor(
cluster_labels, dtype=torch.long, device=adj_dense.device
)
s = F.one_hot(cluster_index, num_classes=s_num_classes).to(dtype=adj_dense.dtype)
theta_np = build_pooling_matrix(
adj_np=adj_np,
cluster_labels=cluster_labels,
num_modes=num_modes,
normalized=normalized,
expected_num_clusters=num_classes,
)
theta = torch.as_tensor(theta_np, dtype=adj_dense.dtype, device=adj_dense.device)
return s, theta
[docs]
def eigenpool_select(
edge_index: Adj,
k: int,
edge_weight: Optional[Tensor] = None,
batch: Optional[Tensor] = None,
num_nodes: Optional[int] = None,
fixed_k: bool = False,
s_inv_op: SinvType = "transpose",
num_modes: int = 5,
normalized: bool = True,
) -> SelectOutput:
r"""Compute EigenPool assignments and eigenvector pooling matrices.
Given a graph with :math:`N` nodes, this function computes:
+ a hard assignment matrix
:math:`\mathbf{S} \in \{0,1\}^{N \times K}` via spectral clustering;
+ an eigenvector pooling matrix
:math:`\boldsymbol{\Theta} \in \mathbb{R}^{N \times (K\cdot H)}`.
For consistency with the connector notation, :math:`\boldsymbol{\Omega}` used in
:class:`~tgp.connect.EigenPoolConnect` is the same matrix as
:math:`\mathbf{S}`.
Args:
edge_index (~torch_geometric.typing.Adj):
Graph connectivity in edge index or dense adjacency format.
k (int):
Number of clusters (supernodes).
edge_weight (~torch.Tensor, optional):
Edge weights associated with ``edge_index``. (default: :obj:`None`)
batch (~torch.Tensor, optional):
Batch vector :math:`\mathbf{b} \in \{0,\dots,B-1\}^N` for multi-graph inputs.
(default: :obj:`None`)
num_nodes (int, optional):
Total number of nodes. Useful when ``edge_index`` is empty.
(default: :obj:`None`)
fixed_k (bool, optional):
If :obj:`True`, always use exactly ``k`` output clusters
(allowing empty clusters). If :obj:`False`, single-graph mode
may reduce the effective number of clusters for tiny graphs.
(default: :obj:`False`)
s_inv_op (~tgp.utils.typing.SinvType, optional):
Operation used to compute :math:`\mathbf{S}_\text{inv}` stored in
:class:`~tgp.select.SelectOutput`. (default: ``"transpose"``)
num_modes (int, optional):
Number of eigenvector modes :math:`H` used to build
:math:`\boldsymbol{\Theta}`. (default: ``5``)
normalized (bool, optional):
If :obj:`True`, uses the normalized Laplacian for eigenvectors.
(default: :obj:`True`)
Returns:
~tgp.select.SelectOutput:
Selection output with:
- ``s``: dense one-hot assignment matrix :math:`[N, K]`
- ``theta``: pooling matrix :math:`\boldsymbol{\Theta}` (or a list
of per-graph matrices for multi-graph batches)
"""
edge_index_conv, edge_weight_conv = connectivity_to_edge_index(
edge_index, edge_weight
)
device = edge_index_conv.device
# Infer node count from connectivity, then merge with explicit hints.
# This is important for edgeless graphs where edge_index carries no nodes.
inferred_num_nodes = (
int(edge_index_conv.max().item()) + 1 if edge_index_conv.numel() > 0 else 0
)
if batch is not None:
inferred_num_nodes = max(inferred_num_nodes, batch.size(0))
if num_nodes is None:
num_nodes = inferred_num_nodes
else:
num_nodes = max(int(num_nodes), inferred_num_nodes)
if num_nodes == 0:
raise ValueError("Cannot perform eigenpool selection on empty graph.")
is_multi_graph = is_multi_graph_batch(batch)
# Single graph case: compute one assignment and pooling matrix for the entire graph.
if not is_multi_graph:
adj_dense = to_dense_adj(
edge_index_conv, edge_attr=edge_weight_conv, max_num_nodes=num_nodes
).squeeze(0)
s, theta = _select_from_dense_adjacency(
adj_dense=adj_dense,
k=k,
num_modes=num_modes,
normalized=normalized,
# In pre-coarsening we may need a fixed width K across samples
# to make downstream collation of dense assignments deterministic.
num_classes=k if fixed_k else None,
)
return SelectOutput(
s=s,
s_inv_op=s_inv_op,
batch=batch,
theta=theta,
)
# Multi-graph batch: process each graph separately and return a list of theta matrices.
batch_size = int(batch.max().item()) + 1
num_nodes_per_graph = torch.bincount(batch, minlength=batch_size)
# Prefix sums let us convert global node ids to local graph ids when needed.
node_ptr = torch.cat(
[num_nodes_per_graph.new_zeros(1), num_nodes_per_graph.cumsum(0)], dim=0
)
if edge_index_conv.numel() == 0:
edge_batch = batch.new_empty((0,), dtype=torch.long)
else:
# In COO edge_index, source node graph id is enough because edges do not cross graphs.
edge_batch = batch[edge_index_conv[0]]
if edge_weight_conv is None:
out_dtype = torch.get_default_dtype()
else:
out_dtype = edge_weight_conv.dtype
s_list, theta_list = [], []
for i, n_nodes_tensor in enumerate(num_nodes_per_graph):
n_nodes = int(n_nodes_tensor.item())
if n_nodes == 0:
# Preserve graph slots for empty graphs to keep list/batch alignment.
s_list.append(torch.zeros((0, k), dtype=out_dtype, device=device))
theta_list.append(
torch.zeros((0, k * num_modes), dtype=out_dtype, device=device)
)
continue
edge_mask = edge_batch == i
edge_index_i = edge_index_conv[:, edge_mask]
if edge_weight_conv is None:
edge_weight_i = None
else:
edge_weight_i = edge_weight_conv[edge_mask]
if edge_index_i.numel() == 0:
# Graph has nodes but no edges: use all-zero adjacency.
adj_dense = torch.zeros((n_nodes, n_nodes), dtype=out_dtype, device=device)
else:
node_start = int(node_ptr[i].item())
# Convert global node indices to per-graph local indexing [0, n_nodes).
edge_index_i = edge_index_i - node_start
adj_dense = to_dense_adj(
edge_index_i,
edge_attr=edge_weight_i,
max_num_nodes=n_nodes,
).squeeze(0)
s, theta = _select_from_dense_adjacency(
adj_dense=adj_dense,
k=k,
num_modes=num_modes,
normalized=normalized,
# Batched mode always uses fixed K so all graphs can be concatenated.
num_classes=k,
)
s_list.append(s.to(dtype=out_dtype))
theta_list.append(theta.to(dtype=out_dtype))
s = (
torch.cat(s_list, dim=0)
if s_list
else torch.zeros((0, k), device=device, dtype=out_dtype)
)
return SelectOutput(
s=s,
s_inv_op=s_inv_op,
batch=batch,
theta=theta_list,
)
[docs]
class EigenPoolSelect(Select):
r"""The :math:`\texttt{select}` operator for EigenPooling.
This operator performs spectral clustering on the adjacency matrix to build
a dense assignment matrix
:math:`\mathbf{S} \in \{0,1\}^{N \times K}` and the eigenvector pooling
matrix :math:`\boldsymbol{\Theta} \in \mathbb{R}^{N \times (K\cdot H)}` used
by the EigenPooling reduce/lift steps.
The same assignment matrix may also be denoted as
:math:`\boldsymbol{\Omega}` in connectivity formulas; in this implementation
:math:`\boldsymbol{\Omega} = \mathbf{S}`.
Args:
k (int):
Number of clusters (supernodes).
s_inv_op (~tgp.utils.typing.SinvType, optional):
Operation used to compute :math:`\mathbf{S}_\text{inv}` from
:math:`\mathbf{S}`. (default: ``"transpose"``)
num_modes (int, optional):
Number of eigenvector modes :math:`H`. (default: ``5``)
normalized (bool, optional):
If :obj:`True`, use the normalized Laplacian. (default: :obj:`True`)
"""
is_dense: bool = True
def __init__(
self,
k: int,
s_inv_op: SinvType = "transpose",
num_modes: int = 5,
normalized: bool = True,
):
super().__init__()
self.k = k
self.s_inv_op = s_inv_op
self.num_modes = num_modes
self.normalized = normalized
[docs]
def forward(
self,
x: Optional[Tensor] = None,
edge_index: Optional[Adj] = None,
edge_weight: Optional[Tensor] = None,
*,
batch: Optional[Tensor] = None,
num_nodes: Optional[int] = None,
**kwargs,
) -> SelectOutput:
r"""Forward pass.
Args:
x (~torch.Tensor, optional):
Node features (unused by EigenPooling). (default: :obj:`None`)
edge_index (~torch_geometric.typing.Adj, optional):
Graph connectivity.
edge_weight (~torch.Tensor, optional):
Edge weights associated with ``edge_index``. (default: :obj:`None`)
batch (~torch.Tensor, optional):
Batch vector for multi-graph inputs. (default: :obj:`None`)
num_nodes (int, optional):
Number of nodes in the graph. (default: :obj:`None`)
Returns:
~tgp.select.SelectOutput:
Selection output with:
- ``s``: assignment matrix :math:`\mathbf{S}`
- ``theta``: pooling matrix :math:`\boldsymbol{\Theta}`
"""
return eigenpool_select(
edge_index=edge_index,
k=self.k,
edge_weight=edge_weight,
batch=batch,
num_nodes=num_nodes,
fixed_k=bool(kwargs.pop("fixed_k", False)),
s_inv_op=self.s_inv_op,
num_modes=self.num_modes,
normalized=self.normalized,
)
def __repr__(self) -> str:
return f"{self.__class__.__name__}(k={self.k})"