Source code for tgp.poolers.bnpool

from typing import List, Optional, Union

import torch
from torch import Tensor
from torch.distributions import Beta
from torch_geometric.typing import Adj

from tgp.connect import DenseConnect
from tgp.lift import BaseLift
from tgp.reduce import BaseReduce
from tgp.select import DPSelect, SelectOutput
from tgp.src import DenseSRCPooling, PoolingOutput
from tgp.utils import (
    batched_negative_edge_sampling,
    connectivity_to_edge_index,
    negative_edge_sampling,
)
from tgp.utils.losses import (
    cluster_connectivity_prior_loss,
    kl_loss,
    sparse_bce_reconstruction_loss,
    weighted_bce_reconstruction_loss,
)
from tgp.utils.typing import LiftType, SinvType


[docs] class BNPool(DenseSRCPooling): r"""The BN-Pool operator from the paper `"BN-Pool: Bayesian Nonparametric Graph Pooling" <https://arxiv.org/abs/2501.09821>`_ (Castellana & Bianchi, 2025). BN-Pool implements a Bayesian nonparametric approach to graph pooling using a Dirichlet Process with stick-breaking construction for cluster assignment. The method learns both the number of clusters and their assignments through variational inference. + The :math:`\texttt{select}` operator is implemented with :class:`~tgp.select.DPSelect` to perform variational inference of the stick-breaking process. + The :math:`\texttt{reduce}` operator is implemented with :class:`~tgp.reduce.BaseReduce`. + The :math:`\texttt{connect}` operator is implemented with :class:`~tgp.connect.DenseConnect`. + The :math:`\texttt{lift}` operator is implemented with :class:`~tgp.lift.BaseLift`. The method uses a truncated stick-breaking representation of the Dirichlet Process: .. math:: v_{ik} \sim \text{Beta}(\alpha_{ik}, \beta_{ik}), \quad i = 1, \ldots, N \quad k = 1, \ldots, K-1 .. math:: \pi_{ik} = v_{ik} \prod_{j=1}^{k-1} (1 - v_{ij}) where :math:`\pi_{ik}` represents the probability of assigning node :math:`i` to cluster :math:`k`. The coefficients :math:`\alpha_{ik}` and :math:`\beta_{ik}` are computed by an MLP from node features :math:`\mathbf{x}_i`. The cluster connectivity is modeled through a learnable matrix :math:`\mathbf{K} \in \mathbb{R}^{K \times K}` and the pooled adjacency matrix is computed as: .. math:: \mathbf{A}_{\text{rec}} = \mathbf{S} \mathbf{K} \mathbf{S}^{\top} where :math:`S_{ik} = \pi_{ik}`. This layer optimizes three auxiliary losses: + **Reconstruction loss** (:func:`~tgp.utils.losses.weighted_bce_reconstruction_loss`): Binary cross-entropy loss between the true and reconstructed adjacency matrix :math:`\mathbf{A}_{\text{rec}}`. + **KL divergence loss** (:func:`~tgp.utils.losses.kl_loss`): KL divergence between the prior and posterior variational approximation of the stick-breaking variables. + **Cluster connectivity prior loss** (:func:`~tgp.utils.losses.cluster_connectivity_prior_loss`): Prior regularization on the cluster connectivity matrix :math:`\mathbf{K}`. Args: in_channels (Union[int, List[int]]): The number of input node feature channels. If a list is provided, it specifies the architecture of the MLP in :class:`~tgp.select.DPSelect`. k (int): The maximum number of clusters :math:`K` to be used in the pooling mechanism. The actual number of active clusters is learned through the stick-breaking process. alpha_DP (float, optional): Prior concentration parameter :math:`\alpha` of the Dirichlet Process. Controls the expected number of clusters. Higher values encourage more clusters. (default: ``1.0``) K_var (float, optional): Variance :math:`\sigma^2` of the Gaussian prior on the cluster connectivity matrix :math:`\mathbf{K}`. (default: ``1.0``) K_mu (float, optional): Mean parameter for the cluster connectivity prior. The prior mean matrix is constructed as :math:`\mathbf{K}_{\mu} = \mu \mathbf{I} - \mu (\mathbf{1}\mathbf{1}^{\top} - \mathbf{I})`. (default: ``10.0``) K_init (float, optional): Initial value for the cluster connectivity matrix :math:`\mathbf{K}`. (default: ``1.0``) eta (float, optional): Weights the KL divergence loss term. (default: ``1.0``) train_K (bool, optional): If :obj:`True`, the cluster connectivity matrix :math:`\mathbf{K}` is learnable. If :obj:`False`, :math:`\mathbf{K}` is fixed to its initial value. (default: :obj:`True`) act (str, optional): Activation function for the MLP in :class:`~tgp.select.DPSelect`. (default: :obj:`None`) dropout (float, optional): Dropout rate in the MLP of :class:`~tgp.select.DPSelect`. (default: ``0.0``) remove_self_loops (bool, optional): If :obj:`True`, the self-loops will be removed from the adjacency matrix. (default: :obj:`True`) degree_norm (bool, optional): If :obj:`True`, the adjacency matrix will be symmetrically normalized. (default: :obj:`True`) edge_weight_norm (bool, optional): Whether to normalize the edge weights by dividing by the maximum absolute value per graph. (default: :obj:`False`) adj_transpose (bool, optional): If :obj:`True`, the preprocessing step in :class:`tgp.src.DenseSRCPooling` 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`) lift (~tgp.utils.typing.LiftType, optional): Defines how to compute the matrix :math:`\mathbf{S}_\text{inv}` to lift the pooled node features. - ``"precomputed"`` (default): Use as :math:`\mathbf{S}_\text{inv}` what is already stored in the ``"s_inv"`` attribute of the :class:`tgp.select.SelectOutput`. - ``"transpose"``: Recomputes :math:`\mathbf{S}_\text{inv}` as :math:`\mathbf{S}^\top`, the transpose of :math:`\mathbf{S}`. - ``"inverse"``: Recomputes :math:`\mathbf{S}_\text{inv}` as :math:`\mathbf{S}^+`, the Moore-Penrose pseudoinverse of :math:`\mathbf{S}`. 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}`. cache_preprocessing (bool, optional): If :obj:`True`, caches the dense adjacency produced during preprocessing. This should only be enabled when the same graph is reused across iterations. (default: :obj:`False`) batched (bool, optional): If :obj:`True`, uses the batched dense representation of the input. If :obj:`False`, uses an unbatched representation without padding. (default: :obj:`True`) sparse_output (bool, optional): If :obj:`True`, returns block-diagonal sparse outputs. If :obj:`False`, returns batched dense outputs. (default: :obj:`False`) num_neg_samples (int, optional): Cap on the number of negative edges sampled **per graph** in the unbatched (sparse-loss) path. If :obj:`None`, defaults to matching the number of positive edges. (default: :obj:`None`) """ def __init__( self, in_channels: Union[int, List[int]], k: int, # hyperparameters of the method alpha_DP=1.0, K_var=1.0, K_mu=10.0, K_init=1.0, eta=1.0, train_K=True, # hyperparameters of the selector act: str = None, dropout: float = 0.0, remove_self_loops: bool = True, degree_norm: bool = True, edge_weight_norm: bool = False, adj_transpose: bool = True, lift: LiftType = "precomputed", s_inv_op: SinvType = "transpose", batched: bool = True, sparse_output: bool = False, cache_preprocessing: bool = False, num_neg_samples: Optional[int] = None, ): if alpha_DP <= 0: raise ValueError("alpha_DP must be positive") if K_var <= 0: raise ValueError("K_var must be positive") if eta <= 0: raise ValueError("eta must be positive") if k <= 0: raise ValueError("max_k must be positive") super(BNPool, self).__init__( selector=DPSelect( in_channels, k, batched_representation=batched, act=act, dropout=dropout, s_inv_op=s_inv_op, ), reducer=BaseReduce(), lifter=BaseLift(matrix_op=lift), connector=DenseConnect( remove_self_loops=remove_self_loops, degree_norm=degree_norm, adj_transpose=adj_transpose, edge_weight_norm=edge_weight_norm, sparse_output=sparse_output, ), adj_transpose=adj_transpose, cache_preprocessing=cache_preprocessing, batched=batched, sparse_output=sparse_output, ) self.k = k self.K_init_val = K_init self.alpha_DP = alpha_DP self.K_var_val = K_var self.K_mu_val = K_mu self.train_K = train_K self.eta = eta # coefficient for the kl_loss self.num_neg_samples = num_neg_samples # prior of the Stick Breaking Process self.register_buffer("alpha_prior", torch.ones(self.k - 1)) self.register_buffer("beta_prior", torch.ones(self.k - 1) * alpha_DP) # prior of the cluster-cluster prob. matrix self.register_buffer("K_var", torch.tensor(K_var)) self.register_buffer( "K_mu", K_mu * torch.eye(self.k, self.k) - K_mu * (1 - torch.eye(self.k, self.k)), ) # cluster-cluster prob matrix self.K = torch.nn.Parameter( K_init * torch.eye(self.k, self.k) - K_init * (1 - torch.eye(self.k, self.k)), requires_grad=train_K, ) def reset_parameters(self): super().reset_parameters() self.K.data = self.K_init_val * torch.eye( self.k, self.k, device=self.K.device ) - self.K_init_val * (1 - torch.eye(self.k, self.k, device=self.K.device))
[docs] def forward( self, x: Tensor, adj: Optional[Adj] = None, edge_weight: Optional[Tensor] = None, so: Optional[SelectOutput] = None, batch: Optional[Tensor] = None, batch_pooled: Optional[Tensor] = None, lifting: bool = False, mask: Optional[Tensor] = None, **kwargs, ) -> PoolingOutput: r"""Forward pass. Args: x (~torch.Tensor): Node feature tensor :math:`\mathbf{X} \in \mathbb{R}^{B \times N \times F}`, with batch-size :math:`B`, (maximum) number of nodes :math:`N` for each graph, and feature dimension :math:`F`. adj (~torch_geometric.typing.Adj, optional): The connectivity matrix. In batched mode, this accepts sparse connectivity (``edge_index``, ``torch_sparse.SparseTensor``, or torch COO), which is internally converted to a dense padded tensor :math:`\mathbf{A} \in \mathbb{R}^{B \times N \times N}`, or an already dense adjacency tensor with the same shape. (default: :obj:`None`) edge_weight (~torch.Tensor, optional): Edge weights associated with ``adj`` when sparse connectivity is provided. (default: :obj:`None`) so (~tgp.select.SelectOutput, optional): The output of the :math:`\texttt{select}` operator. (default: :obj:`None`) batch (~torch.Tensor, optional): Batch assignment vector for input nodes. Required in sparse mode and optional in dense mode. (default: :obj:`None`) batch_pooled (~torch.Tensor, optional): Optional precomputed batch assignment for pooled nodes, used when ``lifting=True``. (default: :obj:`None`) lifting (bool, optional): If set to :obj:`True`, the :math:`\texttt{lift}` operation is performed. (default: :obj:`False`) mask (~torch.Tensor, optional): Input-node validity mask :math:`\mathbf{M} \in {\{ 0, 1 \}}^{B \times N}`, where :obj:`True` marks real (non-padded) nodes. Only used when inputs are already dense/padded. (default: :obj:`None`) Returns: ~tgp.src.PoolingOutput: The output of the pooling operator. """ if lifting: # Lift x_lifted = self.lift( x_pool=x, so=so, batch=batch, batch_pooled=batch_pooled ) return x_lifted # === Batched path === if self.batched: x, adj, mask = self._ensure_batched_inputs( x=x, edge_index=adj, edge_weight=edge_weight, batch=batch, mask=mask, ) # Select so = self.select(x=x, mask=mask) # Reduce x_pooled, batch_pooled = self.reduce(x=x, so=so, batch=batch) # Connect adj_pool, _ = self.connect(edge_index=adj, so=so) loss = self.compute_loss(adj, mask, so) if self.sparse_output: x_pooled, edge_index_pooled, edge_weight_pooled, batch_pooled = ( self._finalize_sparse_output( x_pool=x_pooled, adj_pool=adj_pool, batch=batch, batch_pooled=batch_pooled, so=so, ) ) return PoolingOutput( x=x_pooled, edge_index=edge_index_pooled, edge_weight=edge_weight_pooled, batch=batch_pooled, so=so, loss=loss, ) return PoolingOutput(x=x_pooled, edge_index=adj_pool, so=so, loss=loss) # === Unbatched (sparse-loss) path === # Select so = self.select(x=x, batch=batch) loss = self.compute_sparse_loss(adj, batch, so) # Reduce return_batched = not self.sparse_output x_pooled, batch_pooled = self.reduce( x=x, so=so, batch=batch, return_batched=return_batched ) # Connect edge_index_pooled, edge_weight_pooled = self.connect( edge_index=adj, so=so, edge_weight=edge_weight, batch=batch, batch_pooled=batch_pooled, ) return PoolingOutput( x=x_pooled, edge_index=edge_index_pooled, edge_weight=edge_weight_pooled, batch=batch_pooled, so=so, loss=loss, )
def compute_loss(self, adj, mask, so) -> dict: r"""Computes the loss components for BN-Pool training. This method calculates three loss components that guide the learning of cluster assignments and connectivity patterns: 1. **Reconstruction Loss**: Measures how well the learned cluster connectivity matrix :math:`\mathbf{K}` can reconstruct the original adjacency matrix through :math:`\mathbf{A}_{\text{rec}} = \mathbf{S} \mathbf{K} \mathbf{S}^{\top}`. 2. **KL Divergence Loss**: Regularizes the posterior stick-breaking variables :math:`q(v_k)` towards the Dirichlet Process prior :math:`p(v_k)`. 3. **Cluster Connectivity Prior Loss**: Regularizes the learned connectivity matrix :math:`\mathbf{K}` towards the specified prior distribution. All losses are normalized by :math:`N^2` (number of node pairs) to ensure consistent scaling across different graph sizes. Args: adj (~torch.Tensor): True adjacency matrix of shape :math:`(B, N, N)` to reconstruct. mask (~torch.Tensor): Input-node validity mask of shape :math:`(B, N)`, where :obj:`True` marks real (non-padded) nodes. so (:class:`~tgp.select.SelectOutput`): Selection output containing: - :attr:`s`: Soft assignment matrix :math:`\mathbf{S} \in \mathbb{R}^{B \times N \times K}` - :attr:`q_z`: Posterior Beta distributions for stick-breaking variables Returns: dict: Dictionary containing three loss components: - :obj:`'quality'`: Reconstruction loss :math:`\mathcal{L}_{\text{rec}}` (see :func:`~tgp.utils.losses.weighted_bce_reconstruction_loss`) - :obj:`'kl'`: KL divergence loss :math:`\eta \cdot \mathcal{L}_{\text{KL}}` weighted by :attr:`eta` (see :func:`~tgp.utils.losses.kl_loss`) - :obj:`'K_prior'`: Cluster connectivity prior loss :math:`\mathcal{L}_{\mathbf{K}}` (see :func:`~tgp.utils.losses.cluster_connectivity_prior_loss`). Set to ``0.0`` if :attr:`train_K=False`. Note: The total training loss is typically computed as: :math:`\mathcal{L}_{\text{total}} = \mathcal{L}_{\text{rec}} + \mathcal{L}_{\text{KL}} + \mathcal{L}_{\mathbf{K}}` """ s, q_z = so.s, so.q_z rec_adj = self.get_rec_adj(s) if mask is not None: N = mask.sum(-1) # has shape B x 1 else: N = torch.tensor(adj.shape[-1], device=adj.device) # N N_squared = N**2 # Reconstruction loss rec_loss = weighted_bce_reconstruction_loss( rec_adj, adj, mask, balance_links=True, normalizing_const=N_squared, batch_reduction="mean", ) # KL loss alpha_prior = self.get_buffer("alpha_prior") beta_prior = self.get_buffer("beta_prior") prior_dist = Beta(alpha_prior, beta_prior) kl_loss_value = kl_loss( q_z, prior_dist, mask=mask, normalizing_const=N_squared, batch_reduction="mean", ) # K prior loss if self.train_K: K_prior_loss = cluster_connectivity_prior_loss( self.K, self.get_buffer("K_mu"), self.get_buffer("K_var"), normalizing_const=N_squared, batch_reduction="mean", ) else: K_prior_loss = torch.tensor(0.0) # build the output dictionary return { "quality": rec_loss, "kl": self.eta * kl_loss_value, "K_prior": K_prior_loss, }
[docs] def compute_sparse_loss( self, adj: Adj, batch: Optional[Tensor], so: SelectOutput ) -> dict: """Compute BNPool auxiliary losses in unbatched sparse mode.""" node_assignment, q_z = so.s, so.q_z if batch is not None: batch_size = int(batch.max()) + 1 else: batch_size = 1 rec_loss, norm_const = self.get_sparse_rec_loss( node_assignment, adj, batch, batch_size ) alpha_prior = self.get_buffer("alpha_prior") beta_prior = self.get_buffer("beta_prior") prior_dist = Beta(alpha_prior, beta_prior) kl_loss_value = kl_loss( q_z, prior_dist, batch=batch, batch_size=batch_size, normalizing_const=norm_const, batch_reduction="mean", ) if self.train_K: K_prior_loss = cluster_connectivity_prior_loss( self.K, self.get_buffer("K_mu"), self.get_buffer("K_var"), normalizing_const=norm_const, batch_reduction="mean", ) else: K_prior_loss = torch.tensor(0.0) return { "quality": rec_loss, "kl": self.eta * kl_loss_value, "K_prior": K_prior_loss, }
def extra_repr_args(self) -> dict: return { "batched": self.batched, "alpha_DP": self.alpha_DP, "k_prior_variance": self.K_var_val, "k_prior_mean": self.K_mu_val, "k_init_value": self.K_init_val, "eta": self.eta, "train_K": self.train_K, "num_neg_samples": self.num_neg_samples, }
[docs] def get_rec_adj(self, S): """Return the reconstructed dense adjacency logits from assignments.""" return S @ self.K @ S.transpose(-1, -2)
[docs] def get_sparse_rec_loss(self, node_assignment, adj, batch, batch_size): """Compute sparse reconstruction loss using sampled positive/negative edges.""" edge_index, _ = connectivity_to_edge_index(adj) dev = edge_index.device if batch is None: neg_edge_index = negative_edge_sampling( edge_index, num_neg_samples=self.num_neg_samples, force_undirected=True, ) else: neg_edge_index = batched_negative_edge_sampling( edge_index, batch, num_neg_samples=self.num_neg_samples, force_undirected=True, ) num_edges = edge_index.size(1) num_neg_edges = neg_edge_index.size(1) all_edges = torch.cat([edge_index, neg_edge_index], dim=1) edges_batch_id = None if batch is not None: edges_batch_id = batch[all_edges[0]] link_prob_logit = self.get_prob_link_logit(node_assignment, all_edges) pred_y = torch.cat( [torch.ones(num_edges, device=dev), torch.zeros(num_neg_edges, device=dev)], dim=0, ) return sparse_bce_reconstruction_loss( link_prob_logit, pred_y, edges_batch_id=edges_batch_id, batch_size=batch_size, )