Source code for tgp.select.nmf_select

from typing import Optional

import torch
from sklearn.decomposition import non_negative_factorization
from torch import Tensor
from torch_geometric.typing import Adj
from torch_geometric.utils import to_dense_adj

from tgp.select import Select, SelectOutput
from tgp.utils.ops import connectivity_to_edge_index, is_multi_graph_batch
from tgp.utils.typing import SinvType


[docs] class NMFSelect(Select): r"""Select operator that performs Non-negative Matrix Factorization pooling as proposed in the paper `"A Non-Negative Factorization approach to node pooling in Graph Convolutional Neural Networks" <https://arxiv.org/abs/1909.03287>`_ (Bacciu and Di Sotto, AIIA 2019). This select operator computes the non-negative matrix factorization .. math:: \mathbf{A} = \mathbf{W}\mathbf{H} and returns :math:`\mathbf{H}^\top` as the dense clustering matrix. Args: k (int): Number of clusters or supernodes in the pooler graph. 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, k: int, s_inv_op: SinvType = "transpose"): super().__init__() self.k = k self.s_inv_op = s_inv_op def _factorize_single_adjacency( self, adj: Tensor, ) -> Tensor: r"""Factorize a single dense adjacency and return a dense assignment. The returned assignment has shape :math:`[N, \tilde{K}]`, where :math:`\tilde{K} = \min(K, N)` (with :math:`K=\texttt{self.k}`). """ num_nodes = adj.size(0) if num_nodes == 0: return adj.new_zeros((0, 0)) actual_k = max(1, min(self.k, num_nodes)) # When k >= N on non-trivial graphs, use the trivial one-node-per-cluster assignment. if num_nodes > 1 and actual_k >= num_nodes: s = torch.eye(num_nodes, device=adj.device, dtype=adj.dtype) elif actual_k == 1: s = torch.ones((num_nodes, 1), device=adj.device, dtype=adj.dtype) else: adj_np = adj.clamp(min=0).cpu().numpy() # NMF requires non-negative input. _, h, _ = non_negative_factorization( adj_np, n_components=actual_k, init="random", max_iter=5000, ) s = torch.tensor(h.T, device=adj.device, dtype=adj.dtype) s = torch.softmax(s, dim=-1) return s @staticmethod def _pad_assignment(s: Tensor, k: int) -> Tensor: """Right-pad assignment columns with zeros to obtain shape :math:`[N, K]`. This is needed whenever `k` is fixed globally but some graphs have fewer nodes than `k`. """ if s.size(-1) >= k: return s pad = s.new_zeros((s.size(0), k - s.size(-1))) return torch.cat([s, pad], dim=-1) @staticmethod def _to_dense_single_sparse_graph( edge_index: Tensor, edge_weight: Optional[Tensor], batch: Optional[Tensor], num_nodes: Optional[int], ) -> Tensor: """Convert a sparse single-graph input to a dense adjacency.""" # Determine max_num_nodes for to_dense_adj. If num_nodes is provided, use it. Otherwise, infer from edge_index or batch. if batch is None or batch.numel() == 0: max_num_nodes = num_nodes if max_num_nodes is None: max_num_nodes = ( int(edge_index.max().item()) + 1 if edge_index.numel() > 0 else 0 ) else: max_num_nodes = batch.size(0) if num_nodes is not None: max_num_nodes = max(num_nodes, max_num_nodes) return to_dense_adj( edge_index, edge_attr=edge_weight, max_num_nodes=max_num_nodes, ).squeeze(0)
[docs] def forward( self, edge_index: Adj, edge_weight: Optional[Tensor] = None, *, batch: Optional[Tensor] = None, num_nodes: Optional[int] = None, fixed_k: bool = False, **kwargs, ) -> SelectOutput: r"""Forward pass of the select operator. Args: edge_index (~torch_geometric.typing.Adj): Graph connectivity. Sparse graph connectivity (``edge_index``, SparseTensor, or torch COO). edge_weight (~torch.Tensor, optional): Edge weights for sparse inputs. (default: :obj:`None`) batch (~torch.Tensor, optional): Batch vector for sparse inputs. (default: :obj:`None`) num_nodes (int, optional): Number of nodes for sparse inputs when it cannot be inferred from ``edge_index``. (default: :obj:`None`) fixed_k (bool, optional): If :obj:`True`, force assignment width to exactly ``k`` for single sparse graphs by right-padding zero columns. Useful for pre-coarsening where per-sample outputs are collated together. (default: :obj:`False`) Returns: :class:`~tgp.select.SelectOutput`: The output of :math:`\texttt{select}` operator. """ edge_index_conv, edge_weight_conv = connectivity_to_edge_index( edge_index, edge_weight ) device = edge_index_conv.device is_single_graph = not is_multi_graph_batch(batch) # Single sparse graph: return [N, actual_k]. if is_single_graph: adj_dense = self._to_dense_single_sparse_graph( edge_index=edge_index_conv, edge_weight=edge_weight_conv, batch=batch, num_nodes=num_nodes, ) s = self._factorize_single_adjacency(adj_dense) if fixed_k: s = self._pad_assignment(s, self.k) return SelectOutput(s=s, s_inv_op=self.s_inv_op, batch=batch) # Multi-graph sparse batch: factorize each graph independently and return [N, K]. batch_size = int(batch.max().item()) + 1 num_nodes_per_graph = torch.bincount(batch, minlength=batch_size) node_ptr = torch.cat( [num_nodes_per_graph.new_zeros(1), num_nodes_per_graph.cumsum(0)], dim=0 ) # Build an edge->graph mapping (vector of shape [num_edges]) to slice out edges for each graph. if edge_index_conv.numel() == 0: edge_batch = batch.new_empty((0,), dtype=torch.long) else: edge_batch = batch[edge_index_conv[0]] if edge_weight_conv is None: dtype = torch.get_default_dtype() else: dtype = edge_weight_conv.dtype s_list = [] for i in range(batch_size): n_nodes = int(num_nodes_per_graph[i].item()) if n_nodes == 0: s_list.append(torch.zeros((0, self.k), dtype=dtype, device=device)) continue # Slice out edges belonging to the current graph i. 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: adj_dense = torch.zeros((n_nodes, n_nodes), dtype=dtype, device=device) else: node_start = int(node_ptr[i].item()) 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_i = self._pad_assignment( self._factorize_single_adjacency(adj_dense), self.k, ) s_list.append(s_i) s = ( torch.cat(s_list, dim=0) if s_list else torch.zeros((0, self.k), dtype=dtype, device=device) ) return SelectOutput(s=s, s_inv_op=self.s_inv_op, batch=batch)
def __repr__(self) -> str: return f"{self.__class__.__name__}(k={self.k}, s_inv_op={self.s_inv_op})"