from typing import List, Optional, Union
import torch
from torch import Tensor
from torch_geometric.typing import Adj
from torch_geometric.utils import scatter
from tgp.connect import DenseConnect
from tgp.lift import BaseLift
from tgp.reduce import BaseReduce
from tgp.select import MLPSelect, SelectOutput
from tgp.src import DenseSRCPooling, PoolingOutput
from tgp.utils.losses import (
hosc_orthogonality_loss,
mincut_loss,
orthogonality_loss,
sparse_ho_mincut_loss,
sparse_mincut_loss,
unbatched_hosc_orthogonality_loss,
unbatched_orthogonality_loss,
)
from tgp.utils.ops import connectivity_to_edge_index, postprocess_adj_pool_dense
from tgp.utils.typing import LiftType, SinvType
[docs]
class HOSCPooling(DenseSRCPooling):
r"""The high-order pooling operator from the paper
`"Higher-order clustering and pooling for Graph Neural Networks"
<http://arxiv.org/abs/2209.03473>`_ (Duval & Malliaros, CIKM 2022)..
+ The :math:`\texttt{select}` operator is implemented with :class:`~tgp.select.MLPSelect`.
+ 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`.
This layer optimizes a combination of the following auxiliary losses:
+ the mincut loss (:class:`~tgp.utils.losses.mincut_loss`),
+ the orthogonality loss (:class:`~tgp.utils.losses.orthogonality_loss`),
+ the hosc orthogonality loss (:class:`~tgp.utils.losses.hosc_orthogonality_loss`),
Args:
in_channels (int, list of int):
Number of hidden units for each hidden layer in the MLP
of the :math:`\texttt{select}` operator.
The first integer must match the size of the node features.
k (int):
Number of clusters or supernodes in the pooler graph.
act (str or Callable, optional):
Activation function in the hidden layers of the MLP
of the :math:`\texttt{select}` operator.
dropout (float, optional):
Dropout probability in the MLP of the :math:`\texttt{select}` operator.
(default: ``0.0``)
mu (float, optional):
A scalar that controls the importance given to regularization loss.
(default: ``0.1``)
alpha (float, optional):
A scalar in [0,1] controlling the importance granted
to higher-order information in the loss function.
(default: ``0.5``)
hosc_ortho (bool, optional):
Specifies either to use the hosc_orthogonality_loss or the
orthogonality_loss.
(default: :obj:`False`)
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`)
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`)
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}`.
"""
def __init__(
self,
in_channels: Union[int, List[int]],
k: int,
act: str = None,
dropout: float = 0.0,
mu: float = 0.1,
alpha: float = 0.5,
hosc_ortho: bool = False,
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,
):
super().__init__(
selector=MLPSelect(
in_channels=in_channels,
k=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.mu = mu
self.alpha = alpha
self.hosc_ortho = hosc_ortho
[docs]
def forward(
self,
x: Tensor,
adj: Optional[Adj] = None,
edge_weight: Optional[Tensor] = None,
so: Optional[SelectOutput] = None,
mask: Optional[Tensor] = None,
batch: Optional[Tensor] = None,
batch_pooled: Optional[Tensor] = None,
lifting: bool = False,
**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`)
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. Only used
when inputs are already dense/padded. (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`)
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,
)
so = self.select(x=x, mask=mask)
x_pooled, batch_pooled = self.reduce(x=x, so=so, batch=batch)
adj_pool = self.connector.dense_connect(adj=adj, s=so.s)
adj_pool = postprocess_adj_pool_dense(
adj_pool,
remove_self_loops=self.connector.remove_self_loops,
degree_norm=self.connector.degree_norm,
adj_transpose=self.connector.adj_transpose,
edge_weight_norm=self.connector.edge_weight_norm,
)
loss = self.compute_loss(adj, so.s, adj_pool, mask)
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 ===
so = self.select(x=x, batch=batch)
return_batched = not self.sparse_output
x_pooled, batch_pooled = self.reduce(
x=x, so=so, batch=batch, return_batched=return_batched
)
edge_index_pooled, edge_weight_pooled = self.connect(
edge_index=adj,
so=so,
edge_weight=edge_weight,
batch=batch,
batch_pooled=batch_pooled,
)
loss = self.compute_sparse_loss(
adj,
edge_weight,
so.s,
batch,
adj_pool=edge_index_pooled,
edge_weight_pool=edge_weight_pooled,
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: Tensor, S: Tensor, adj_pool: Tensor, mask: Optional[Tensor] = None
) -> Optional[dict]:
r"""Computes the auxiliary loss terms.
Args:
adj (~torch.Tensor): The dense adjacency matrix.
S (~torch.Tensor): The dense assignment matrix.
adj_pool (~torch.Tensor): The pooled adjacency matrix.
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. (default: :obj:`None`)
Returns:
dict: A dictionary with the different terms of
the auxiliary loss.
"""
# Motif adj matrix - not sym. normalised
motif_adj = torch.matmul(torch.matmul(adj, adj), adj)
motif_adj_pool = torch.matmul(torch.matmul(S.transpose(1, 2), motif_adj), S)
cut_loss = ho_cut_loss = 0
# 1st order MinCUT loss
if self.alpha < 1:
cut_loss = mincut_loss(adj, S, adj_pool)
cut_loss = 1 / self.k * cut_loss
# Higher order cut
if self.alpha > 0:
ho_cut_loss = mincut_loss(motif_adj, S, motif_adj_pool)
ho_cut_loss = 1 / self.k * ho_cut_loss
# Combine ho and fo mincut loss.
hosc_loss = (1 - self.alpha) * cut_loss + self.alpha * ho_cut_loss
# Orthogonality loss
if self.mu == 0:
ortho_loss = torch.tensor(0)
elif self.hosc_ortho:
# Hosc orthogonality regularization
ortho_loss = hosc_orthogonality_loss(S, mask, batch_reduction="mean")
else:
# Standard orthogonality regularization of MinCutPool
ortho_loss = orthogonality_loss(S, batch_reduction="mean")
return {"hosc_loss": hosc_loss, "ortho_loss": self.mu * ortho_loss}
[docs]
def compute_sparse_loss(
self,
edge_index: Adj,
edge_weight: Optional[Tensor],
S: Tensor,
batch: Optional[Tensor],
adj_pool: Optional[Adj] = None,
edge_weight_pool: Optional[Tensor] = None,
batch_pooled: Optional[Tensor] = None,
) -> dict:
r"""Computes the auxiliary loss terms for unbatched (sparse) mode.
This method is used when ``batched=False`` and operates on sparse
adjacency matrices.
Args:
edge_index (~torch_geometric.typing.Adj): Graph connectivity in sparse format.
edge_weight (~torch.Tensor, optional): Edge weights of shape :math:`(E,)`.
S (~torch.Tensor): The dense assignment matrix of shape :math:`(N, K)`.
batch (~torch.Tensor, optional): Batch vector of shape :math:`(N,)`.
adj_pool (~torch_geometric.typing.Adj, optional): The postprocessed pooled
adjacency. When ``self.sparse_output=True``, an ``edge_index`` of
shape :math:`(2, E_\\text{pool})` over the block-diagonal supernode
graph. When ``self.sparse_output=False``, a dense tensor of shape
:math:`(B, K, K)`. Required when ``alpha < 1``.
edge_weight_pool (~torch.Tensor, optional): Edge weights of the
postprocessed pooled adjacency, of shape :math:`(E_\\text{pool},)`.
Required when ``self.sparse_output=True`` and ``alpha < 1``.
batch_pooled (~torch.Tensor, optional): Batch vector for the pooled
supernodes. Required when ``self.sparse_output=True``,
``alpha < 1`` and the input contains multiple graphs.
Returns:
dict: A dictionary with ``'hosc_loss'`` and ``'ortho_loss'``.
"""
edge_index_conv, edge_weight_conv = connectivity_to_edge_index(
edge_index, edge_weight
)
num_nodes = S.size(0)
device = S.device
if batch is None:
batch = torch.zeros(num_nodes, dtype=torch.long, device=device)
batch_size = int(batch.max().item()) + 1
cut_loss = torch.tensor(0.0, device=device, dtype=S.dtype)
ho_cut_loss = torch.tensor(0.0, device=device, dtype=S.dtype)
if self.alpha < 1:
if self.sparse_output:
# Sparse block-diagonal pooled adj: Tr per graph =
# sum of self-loop weights, scattered over `batch_pooled`.
src, dst = adj_pool[0], adj_pool[1]
loop_mask = src == dst
contrib = edge_weight_pool.view(-1) * loop_mask.to(
edge_weight_pool.dtype
)
if batch_pooled is None:
edge_batch = torch.zeros(
src.numel(), dtype=torch.long, device=device
)
else:
edge_batch = batch_pooled[src]
num_per_graph = scatter(
contrib, edge_batch, dim=0, dim_size=batch_size, reduce="sum"
)
else:
# Dense [B, K, K] pooled adj: Tr per graph = sum of diagonals.
num_per_graph = torch.diagonal(adj_pool, dim1=-2, dim2=-1).sum(dim=-1)
cut_loss = sparse_mincut_loss(
edge_index_conv,
S,
edge_weight_conv,
batch,
batch_reduction="mean",
num_per_graph=num_per_graph,
)
cut_loss = cut_loss / self.k
if self.alpha > 0:
ho_cut_loss = sparse_ho_mincut_loss(
edge_index_conv,
S,
edge_weight_conv,
batch,
batch_reduction="mean",
)
ho_cut_loss = ho_cut_loss / self.k
hosc_loss = (1 - self.alpha) * cut_loss + self.alpha * ho_cut_loss
if self.mu == 0:
ortho_loss = torch.tensor(0.0, device=device, dtype=S.dtype)
elif self.hosc_ortho:
ortho_loss = unbatched_hosc_orthogonality_loss(
S, batch, batch_reduction="mean"
)
else:
ortho_loss = unbatched_orthogonality_loss(S, batch, batch_reduction="mean")
return {"hosc_loss": hosc_loss, "ortho_loss": self.mu * ortho_loss}
def extra_repr_args(self) -> dict:
return {
"batched": self.batched,
"mu": self.mu,
"alpha": self.alpha,
"hosc_ortho": self.hosc_ortho,
}