Source code for tgp.src

from collections.abc import Iterator
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple, Union

import torch
from torch import Tensor
from torch_geometric.data import Data
from torch_geometric.typing import Adj
from torch_geometric.utils import to_dense_adj, to_dense_batch

from tgp.connect import Connect
from tgp.lift import Lift
from tgp.reduce import Reduce
from tgp.select import Select, SelectOutput
from tgp.utils import Signature, connectivity_to_edge_index, foo_signature
from tgp.utils.ops import build_pooled_batch, dense_to_block_diag, is_dense_adj


[docs] @dataclass class PoolingOutput: r"""The pooling output of a model of class :class:`~tgp.src.SRCPooling`. Args: x (~torch.Tensor): The pooled node features. edge_index (~torch.Tensor): The edge indices of the pooled graph. edge_weight (~torch.Tensor, optional): The edge features of the coarsened graph. (default: :obj:`None`) batch (~torch.Tensor, optional): The batch vector of the pooled nodes. so (:class:`~tgp.select.SelectOutput`): The selection output. (default: :obj:`None`) mask: Derived from ``so.out_mask`` when ``so`` is set. This is a pooled-supernode validity mask of shape :math:`[B, K]` (or :math:`[1, K]` for single-graph dense assignments). loss (Optional[Dict], optional): The loss dictionary. (default: :obj:`None`) """ x: Optional[Tensor] = None edge_index: Optional[Tensor] = None edge_weight: Optional[Tensor] = None batch: Optional[Tensor] = None so: Optional[SelectOutput] = None loss: Optional[Dict] = None @property def mask(self) -> Optional[Tensor]: """Pooled-supernode validity mask, derived from ``so.out_mask``.""" return self.so.out_mask if self.so is not None else None def __repr__(self) -> str: return ( f"PoolingOutput(so={[self.so.num_nodes, self.so.num_supernodes] if self.so is not None else None}, " f"x={[*self.x.shape] if self.x is not None else None}, " f"edge_index={[*self.edge_index.shape] if self.edge_index is not None else None}, " f"edge_weight={[*self.edge_weight.shape] if self.edge_weight is not None else None}, " f"batch={[*self.batch.shape] if self.batch is not None else None}, " f"mask={[*self.mask.shape] if self.mask is not None else None}, " f"loss={list(self.loss.keys()) if self.loss is not None else None})" ) def __iter__(self) -> Iterator: return iter( ( self.x, self.edge_index, self.edge_weight, self.batch, self.mask, self.so, self.loss, ) ) @property def has_loss(self): r"""Returns :obj:`True` if the pooling output has a loss.""" return bool(isinstance(self.loss, dict) and len(self.loss) > 0)
[docs] def get_loss_value(self, name: str = None) -> Union[float, List[float]]: r"""Returns the value of the loss with name ``name`` or all losses. If the pooling output does not have a loss, it returns ``0``. Args: name (str, optional): The name of the loss to return. If :obj:`None`, returns all losses. (default: :obj:`None`) Returns: Union[float, List[float]]: The value of the loss ``name`` or all losses. """ if not self.has_loss: return 0 if name is None: return [v for v in self.loss.values()] return self.loss[name]
[docs] def as_data(self): r"""Converts the pooling output to a :class:`~torch_geometric.data.Data` object. Returns: ~torch_geometric.data.Data: The pooling output as a Data object. """ num_nodes = None if self.batch is not None: num_nodes = self.batch.numel() elif self.x is not None: num_nodes = self.x.size(-2) elif self.so is not None: num_nodes = self.so.num_supernodes return Data( x=self.x, edge_index=self.edge_index, edge_weight=self.edge_weight, batch=self.batch, mask=self.mask, so=self.so, num_nodes=num_nodes, )
[docs] class SRCPooling(torch.nn.Module): r"""A base class for pooling layers based on the paper `"Understanding Pooling in Graph Neural Networks" <https://arxiv.org/abs/1905.05178>`_ (Grattarola et al., TNNLS 2022). Each pooler should inherit from this class. :class:`~tgp.src.SRCPooling` decomposes a pooling layer into three components: + :class:`~tgp.select.Select` defines how input nodes map to supernodes. + :class:`~tgp.reduce.Reduce` defines how input node features are aggregated. + :class:`~tgp.lift.Lift` defines how pooled node features are un-pooled. + :class:`~tgp.connect.Connect` decides how the supernodes are connected to each other. This class should return an object of type :obj:`~tgp.src.PoolingOutput`. Args: selector (:class:`~tgp.select.Select`): The node selection operator. reducer (:class:`~tgp.reduce.Reduce`): The node feature aggregation operator. lifter (:class:`~tgp.lift.Lift`): The node feature un-pooling operator. connector (:class:`~tgp.connect.Connect`): The edge connection operator. cached (bool, optional): If set to :obj:`True`, will cache the :class:`~tgp.select.Select` output and the :class:`~tgp.connect.Connect` output. (default: :obj:`False`) """ def __init__( self, selector: Select = None, reducer: Reduce = None, lifter: Lift = None, connector: Connect = None, cached: bool = False, ): super().__init__() self.selector = selector self.reducer = reducer self.lifter = lifter self.connector = connector self.cached = cached self._so_cached = None self._pooled_edge_index = None self._pooled_edge_weight = None def reset_parameters(self): r"""Resets all learnable parameters of the module.""" self.selector.reset_parameters() self.reducer.reset_parameters() self.lifter.reset_parameters() self.connector.reset_parameters()
[docs] def select( self, **kwargs, ) -> SelectOutput: r"""Calls the :class:`~tgp.select.Select` operator. Returns: An object of type :class:`~tgp.select.SelectOutput` containing the mapping from nodes to supernodes :math:`\mathbf{S} \in \mathbb{R}^{N \times K}`. """ if self.selector is not None: if self._so_cached is not None: return self._so_cached so = self.selector( **kwargs, ) if self.cached: self._so_cached = so return so raise NotImplementedError
[docs] def reduce( self, **kwargs, ) -> Tensor: r"""Calls the :class:`~tgp.reduce.Reduce` operator. Returns: The pooled supernode features :math:`\mathbf{X}_{\text{pool}}`. """ if self.reducer is not None: return self.reducer(**kwargs) raise NotImplementedError
[docs] def lift(self, **kwargs) -> Adj: r"""Calls the :class:`~tgp.lift.Lift` operator. Returns: The un-pooled node features :math:`\mathbf{X}_{\text{lift}} \approx \mathbf{X}`. """ if self.lifter is not None: return self.lifter(**kwargs) raise NotImplementedError
[docs] def connect( self, **kwargs, ) -> Tuple[Adj, Optional[Tensor]]: r"""Calls the :class:`~tgp.connect.Connect` operator. Returns: The adjacency matrix of the coarse graph :math:`\mathbf{A}_{\text{pool}}`. """ if self.connector is not None: if self._pooled_edge_index is not None: return self._pooled_edge_index, self._pooled_edge_weight pooled_edge_index, pooled_edge_weight = self.connector(**kwargs) if self.cached: self._pooled_edge_index = pooled_edge_index self._pooled_edge_weight = pooled_edge_weight return pooled_edge_index, pooled_edge_weight raise NotImplementedError
[docs] def preprocessing( self, x: Tensor, edge_index: Adj, **kwargs ) -> Tuple[Tensor, Adj, Optional[Tensor]]: """Preprocess inputs, if needed.""" return x, edge_index, None
@property def is_dense(self) -> bool: """Returns :obj:`True` if the pooler uses dense assignments.""" if self.selector is not None: return self.selector.is_dense raise NotImplementedError @property def is_sparse(self) -> bool: """Returns :obj:`True` if the pooler uses sparse assignments.""" return not self.is_dense @property def has_loss(self) -> bool: r"""Returns :obj:`True` if the pooler overrides ``compute_loss``.""" return self.compute_loss.__qualname__.split(".")[0] != "SRCPooling" @property def is_trainable(self) -> bool: r"""Returns :obj:`True` if any parameter belonging to the pooler or any of its registered sub-modules is trainable. """ return any(p.requires_grad for p in self.parameters()) def compute_loss(self, *args, **kwargs) -> Optional[dict]: """Compute loss function.""" return None
[docs] def clear_cache(self): r"""Clear the caching done by :math:`\texttt{select}` and :math:`\texttt{connect}`.""" self._so_cached = None self._pooled_edge_index = None self._pooled_edge_weight = None
@property def is_precoarsenable(self) -> bool: r"""Returns :obj:`True` if the pooler is precoarsenable.""" if isinstance(self, Precoarsenable): return not self.is_trainable else: return False
[docs] @classmethod def get_signature(cls) -> Signature: """Get signature of the pooler's ``__init__`` function.""" return foo_signature(cls)
[docs] @classmethod def get_forward_signature(cls) -> Signature: """Get signature of the pooler's ``forward`` function.""" return foo_signature(cls.forward)
[docs] @staticmethod def data_transforms(): """Transforms to apply to the dataset before passing it to the model.""" return None
def __repr__(self) -> str: out = [f"{self.__class__.__name__}("] out.append(f"\tselect={self.selector}") out.append(f"\treduce={self.reducer}") out.append(f"\tlift={self.lifter}") out.append(f"\tconnect={self.connector}") for k, v in self.extra_repr_args().items(): out.append(f"\t{k}={v}") out.append(")") return "\n".join(out) def extra_repr_args(self) -> dict: """Add extra arguments to :meth:`~tgp.src.SRCPooling.__repr__`.""" return {}
[docs] class DenseSRCPooling(SRCPooling): r"""A base class for *dense* pooling layers that extends :class:`~tgp.src.SRCPooling`. It provides a preprocessing function that transform a batch of graphs in sparse representation into a batch of dense graphs. When ``batched=True``, dense poolers accept either raw sparse inputs (which are converted internally) or already-dense padded tensors. In the latter case, an external input-node validity mask can be provided to mark real (non-padded) nodes; otherwise a full-ones mask is assumed. Args: selector (:class:`~tgp.select.Select`): The *dense* :math:`\texttt{select}` operator. reducer (:class:`~tgp.reduce.Reduce`): The *dense* :math:`\texttt{reduce}` operator. lifter (:class:`~tgp.lift.Lift`): The *dense* :math:`\texttt{lift}` operator. connector (:class:`~tgp.connect.Connect`): The *dense* :math:`\texttt{connect}` operator. cached (bool, optional): If set to :obj:`True`, will cache the :class:`~tgp.select.Select` output and the :class:`~tgp.connect.Connect` output. (default: :obj:`False`) cache_preprocessing (bool, optional): If set to :obj:`True`, will cache the dense adjacency produced by :meth:`~tgp.src.DenseSRCPooling.preprocessing`. This should only be enabled when the same graph is reused across iterations (e.g., transductive single-graph tasks). (default: :obj:`False`) adj_transpose (bool, optional): If :obj:`True`, the preprocessing step and the :class:`~tgp.connect.DenseConnect` operation returns transposed adjacency matrices, so that they could be passed "as is" to the dense message-passing layers. (default: :obj:`True`) batched (bool, optional): If :obj:`True`, sparse inputs are converted to batched dense tensors internally. If :obj:`False`, the pooler expects unbatched dense assignments without padding. (default: :obj:`True`) sparse_output (bool, optional): If :obj:`True`, the pooled outputs are returned as block-diagonal sparse representations. If :obj:`False`, outputs are returned in batched dense form. (default: :obj:`False`) """ def __init__( self, selector: Select = None, reducer: Reduce = None, lifter: Lift = None, connector: Connect = None, cached: bool = False, adj_transpose: bool = False, batched: bool = True, sparse_output: bool = False, cache_preprocessing: bool = False, ): super().__init__( selector=selector, reducer=reducer, lifter=lifter, connector=connector, cached=cached, ) self.batched = batched self.sparse_output = sparse_output self.adj_transpose = adj_transpose self.cache_preprocessing = cache_preprocessing self.preprocessing_cache = None
[docs] def preprocessing( self, x: Tensor, edge_index: Adj, edge_weight: Optional[Tensor] = None, batch: Optional[Tensor] = None, max_num_nodes: Optional[int] = None, batch_size: Optional[int] = None, use_cache: bool = False, **kwargs, ) -> Tuple[Tensor, Tensor, Tensor]: r"""Preprocess inputs for dense pooling methods. Transform a batch of graphs in sparse representation into a batch of graphs with dense representation. Args: x (~torch.Tensor): The node features. A tensor of shape :math:`[N, F]`, where :math:`N` is the total number of nodes in the batch and :math:`F` is the number of node features. edge_index (~torch.Tensor): The edge indices. A tensor of of shape :math:`[2, E]`, where :math:`E` is the number of edges in the batch. 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`) max_num_nodes (int, optional): The maximum number of nodes of a graph in the batch. (default: :obj:`None`) batch_size (int, optional): The number of graphs in the batch. (default: :obj:`None`) use_cache (bool, optional): If :obj:`True`, it stores the preprocessed adjacency matrix in ``preprocessing_cache``. This is intended for static, single-graph inputs. (default: :obj:`False`) Returns: tuple[~torch.Tensor, ~torch.Tensor, ~torch.Tensor]: A tuple ``(x, adj, mask)`` where ``x`` contains batched node features of shape :math:`[B, N_\text{max}, F]`, ``adj`` contains batched dense adjacencies of shape :math:`[B, N_\text{max}, N_\text{max}]`, and ``mask`` marks real versus padded nodes with shape :math:`[B, N_\text{max}]`. """ if use_cache and self.preprocessing_cache is not None: adj = self.preprocessing_cache else: edge_index, edge_weight = connectivity_to_edge_index( edge_index, edge_weight ) adj = to_dense_adj( edge_index=edge_index, edge_attr=edge_weight, max_num_nodes=max_num_nodes, batch=batch, batch_size=batch_size, ) if self.adj_transpose: adj = adj.transpose(-1, -2) if use_cache: self.preprocessing_cache = adj x, mask = to_dense_batch( x=x, batch=batch, max_num_nodes=max_num_nodes, batch_size=batch_size ) return x, adj, mask
def _ensure_batched_inputs( self, x: Tensor, edge_index: Optional[Adj], edge_weight: Optional[Tensor], batch: Optional[Tensor], mask: Optional[Tensor], use_cache: Optional[bool] = None, ) -> Tuple[Tensor, Tensor, Optional[Tensor]]: if edge_index is None: raise ValueError("edge_index cannot be None when batched=True.") if use_cache is None: use_cache = self.cache_preprocessing if use_cache and batch is not None and batch.numel() > 0: # Disable cache for multiple graphs in the batch batch_min = int(batch.min().item()) batch_max = int(batch.max().item()) use_cache = batch_min == batch_max if is_dense_adj(edge_index): x = x.unsqueeze(0) if x.dim() == 2 else x if mask is None: mask = x.new_ones(x.size(0), x.size(1), dtype=torch.bool) adj = edge_index if use_cache: self.preprocessing_cache = adj return x, adj, mask x, adj, mask = self.preprocessing( x=x, edge_index=edge_index, edge_weight=edge_weight, batch=batch, use_cache=use_cache, ) return x, adj, mask
[docs] def clear_cache(self): r"""Clear the caching done by :math:`\texttt{select}` and :math:`\texttt{connect}` and the preprocessing cache. """ super().clear_cache() self.preprocessing_cache = None
def _finalize_sparse_output( self, x_pool: Tensor, adj_pool: Tensor, batch: Optional[Tensor], batch_pooled: Optional[Tensor], so: SelectOutput, ) -> Tuple[Tensor, Tensor, Tensor, Optional[Tensor]]: """Convert batched dense outputs to block-diagonal sparse representation. Uses ``so.out_mask`` when available so only valid supernodes and edges between them are included. When ``so.out_mask`` is :obj:`None`, all pooled nodes are kept. """ B, K = adj_pool.size(0), adj_pool.size(1) x_flat = x_pool.reshape(-1, x_pool.size(-1)) out_mask = so.out_mask if batch_pooled is None and batch is not None: batch_pooled = self.reducer.reduce_batch(so, batch) if batch_pooled is None and B > 1: batch_pooled = build_pooled_batch(B, K, x_pool.device) if batch_pooled is None and out_mask is not None: # Single graph, no batch, dense path batch_pooled = torch.zeros(B * K, dtype=torch.long, device=x_pool.device) if out_mask is not None: valid_flat = out_mask.reshape(-1) valid_indices = valid_flat.nonzero(as_tuple=True)[0] num_valid = valid_indices.size(0) x_pool = x_flat[valid_indices] batch_pooled = batch_pooled[valid_flat] # Zero out edges at padded positions so dense_to_block_diag only sees valid edges adj_masked = ( adj_pool * out_mask.unsqueeze(-1).to(adj_pool.dtype) * out_mask.unsqueeze(-2).to(adj_pool.dtype) ) edge_index, edge_weight = dense_to_block_diag(adj_masked) # Remap node indices from [0 .. B*K-1] to compact [0 .. num_valid-1] old_to_new = torch.full( (B * K,), -1, dtype=torch.long, device=x_pool.device ) old_to_new[valid_indices] = torch.arange(num_valid, device=x_pool.device) keep = (old_to_new[edge_index[0]] >= 0) & (old_to_new[edge_index[1]] >= 0) edge_index = torch.stack( [ old_to_new[edge_index[0][keep]], old_to_new[edge_index[1][keep]], ], dim=0, ) edge_weight = edge_weight[keep] else: edge_index, edge_weight = dense_to_block_diag(adj_pool) x_pool = x_flat return x_pool, edge_index, edge_weight, batch_pooled
class Precoarsenable: def precoarsening( self, **kwargs, ) -> PoolingOutput: """Precompute a coarsened graph from the original graph. Must be implemented by the poolers that support precoarsening. """ raise NotImplementedError("Precoarsening is not supported by this pooler.") def multi_level_precoarsening( self, levels: int, edge_index: Optional[Adj] = None, edge_weight: Optional[Tensor] = None, *, batch: Optional[Tensor] = None, num_nodes: Optional[int] = None, **kwargs, ) -> List[PoolingOutput]: r"""Precompute multiple coarsening levels. The default implementation performs a greedy rollout by repeatedly calling :meth:`precoarsening`. Poolers can override this method to implement method-specific multi-level strategies. Notes: The rollout clears pooling caches before each level when available. This avoids stale :class:`~tgp.select.SelectOutput` reuse when :obj:`cached=True` and the graph size changes between levels. """ if levels < 1: raise ValueError(f"'levels' must be >= 1, got {levels}.") clear_cache = getattr(self, "clear_cache", None) pooled_levels = [] current_edge_index = edge_index current_edge_weight = edge_weight current_batch = batch current_num_nodes = num_nodes for _ in range(levels): if callable(clear_cache): clear_cache() pooled = self.precoarsening( edge_index=current_edge_index, edge_weight=current_edge_weight, batch=current_batch, num_nodes=current_num_nodes, **kwargs, ) pooled_levels.append(pooled) pooled_data = pooled.as_data() current_edge_index = pooled_data.edge_index current_edge_weight = pooled_data.edge_weight current_batch = pooled_data.batch current_num_nodes = pooled_data.num_nodes if callable(clear_cache): clear_cache() return pooled_levels class BasePrecoarseningMixin(Precoarsenable): r"""A mixin class for pooling layers that implements the pre-coarsening strategy. """ def _precoarsening_from_select_output( self, so: SelectOutput, edge_index: Adj, edge_weight: Optional[Tensor] = None, *, batch: Optional[Tensor] = None, **kwargs, ) -> PoolingOutput: # Reduce batch vector according to the select output. if batch is None: batch = so.batch if getattr(so, "batch", None) is not None else None if batch is None: n_nodes = so.num_nodes batch = torch.zeros(n_nodes, dtype=torch.long, device=so.s.device) so.batch = batch batch_pooled = self.reducer.reduce_batch(select_output=so, batch=batch) # Compute pooled adj matrix through the connect operator. connector = getattr(self, "preconnector", self.connector) edge_index_pooled, edge_weight_pooled = connector( so=so, edge_index=edge_index, edge_weight=edge_weight, batch=batch, batch_pooled=batch_pooled, **kwargs, ) return PoolingOutput( edge_index=edge_index_pooled, edge_weight=edge_weight_pooled, batch=batch_pooled, so=so, ) def precoarsening( self, edge_index: Optional[Adj] = None, edge_weight: Optional[Tensor] = None, *, batch: Optional[Tensor] = None, num_nodes: Optional[int] = None, **kwargs, ) -> PoolingOutput: if edge_index is None: raise ValueError("edge_index cannot be None for precoarsening.") so = self.select( edge_index=edge_index, edge_weight=edge_weight, batch=batch, num_nodes=num_nodes, **kwargs, ) return self._precoarsening_from_select_output( so=so, edge_index=edge_index, edge_weight=edge_weight, batch=batch, **kwargs, )