import copy
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Callable, Optional, Union
import torch
from torch import Tensor
from torch_geometric.typing import Adj
from tgp.imports import is_sparsetensor
from tgp.utils.ops import (
connectivity_to_edge_index,
get_assignments,
get_mask_from_dense_s,
pseudo_inverse,
)
def cluster_to_s(
cluster_index: Tensor,
node_index: Optional[Tensor] = None,
weight: Optional[Tensor] = None,
as_edge_index: bool = False,
num_nodes: Optional[int] = None,
num_supernodes: Optional[int] = None,
):
r"""Converts a cluster assignment vector to a sparse assignment matrix.
Args:
cluster_index (~torch.Tensor):
The cluster indices.
node_index (~torch.Tensor, optional):
The node indices. (default: :obj:`None`)
weight (~torch.Tensor, optional):
A weight vector, denoting the membership of a node to
each cluster. (default: :obj:`None`)
as_edge_index (bool, optional):
If set to :obj:`True`, will return the edge indices of the assignment matrix.
(default: :obj:`False`)
num_nodes (int, optional):
The number of nodes. (default: :obj:`None`)
num_supernodes (int, optional):
The number of clusters. (default: :obj:`None`)
"""
if num_nodes is None:
num_nodes = cluster_index.size(0)
if num_supernodes is None:
num_supernodes = int(cluster_index.max().item()) + 1
if node_index is None:
node_index = torch.arange(
num_nodes, dtype=torch.long, device=cluster_index.device
)
if as_edge_index:
return torch.stack([node_index, cluster_index], dim=0), weight
else:
# Sort indices for compatibility with subgraph operation in sparse_connect
# which requires sorted node indices for proper edge relabeling
node_index, perm = torch.sort(node_index)
cluster_index = cluster_index[perm]
indices = torch.stack([node_index, cluster_index], dim=0)
values = (
weight[perm]
if weight is not None
else torch.ones(indices.size(1), device=indices.device)
)
return torch.sparse_coo_tensor(
indices=indices,
values=values,
size=(num_nodes, num_supernodes),
is_coalesced=True,
)
# @torch.jit.script
[docs]
@dataclass(init=False)
class SelectOutput:
r"""The output of a :class:`~tgp.select.Select` method, which holds an assignment
from selected nodes to their respective cluster(s).
Args:
node_index (~torch.Tensor):
The indices of the selected nodes.
num_nodes (int):
The number of nodes.
cluster_index (~torch.Tensor):
The indices of the clusters each node in :obj:`node_index` is assigned to.
num_supernodes (int):
The number of clusters.
weight (~torch.Tensor, optional):
A weight vector, denoting the membership of a node to
each cluster. (default: :obj:`None`)
"""
s: Tensor
s_inv: Tensor = None
batch: Optional[Tensor] = None
def __init__(
self,
s: Tensor = None,
s_inv: Tensor = None,
node_index: Tensor = None,
num_nodes: int = None,
cluster_index: Tensor = None,
num_supernodes: int = None,
weight: Optional[Tensor] = None,
s_inv_op: Optional[str] = "transpose",
batch: Optional[Tensor] = None,
in_mask: Optional[Tensor] = None,
**extra_args,
):
super().__init__()
if isinstance(s, Tensor):
if s.is_sparse: # Sparse assignment
s = s.coalesce()
assert cluster_index is None, (
"'cluster_index' cannot be set if 's' is not None"
)
assert node_index is None, (
"'node_index' cannot be set if 's' is not None"
)
if weight is not None:
s = torch.sparse_coo_tensor(
indices=s.indices(),
values=weight,
size=s.size(),
dtype=s.dtype,
device=s.device,
is_coalesced=True,
).coalesce()
if num_nodes is not None or num_supernodes is not None:
_N, _C = s.size()
size = (num_nodes or _N, num_supernodes or _C)
s = torch.sparse_coo_tensor(
indices=s.indices(),
values=s.values(),
size=size,
dtype=s.dtype,
device=s.device,
is_coalesced=True,
).coalesce()
else: # Dense assignment (s.is_sparse is False)
assert cluster_index is None, (
"'cluster_index' cannot be set if 's' is a dense Tensor"
)
assert node_index is None, (
"'node_index' cannot be set if 's' is a dense Tensor"
)
assert num_nodes is None, (
"'num_nodes' cannot be set if 's' is a dense Tensor"
)
assert num_supernodes is None, (
"'num_supernodes' cannot be set if 's' is a dense Tensor"
)
assert weight is None, "'weight' cannot be set if 's' is a dense Tensor"
elif s is None: # Make sparse assignment from other data
assert cluster_index is not None, (
"'cluster_index' cannot be None if 's' is None"
)
s = cluster_to_s(
cluster_index,
node_index=node_index,
num_supernodes=num_supernodes,
num_nodes=num_nodes,
weight=weight,
)
else:
raise ValueError(
"Either a sparse or dense assignment matrix is provided "
"through 's' or a cluster assignment vector must be "
"provided thorough 'cluster_index'."
)
self.s = s
self.s_inv = s_inv
if s_inv is None:
self.set_s_inv(s_inv_op)
self.batch = batch
self.in_mask = self._validate_in_mask(in_mask)
self._extra_args = set()
if self.in_mask is not None:
self._extra_args.add("in_mask")
for k, v in extra_args.items():
setattr(self, k, v)
self._extra_args.add(k)
def _validate_in_mask(self, in_mask: Optional[Tensor]) -> Optional[Tensor]:
if in_mask is None:
return None
if in_mask.dim() != 2:
raise ValueError(
"SelectOutput.in_mask must be 2D with shape [B, N] (batched representations only)."
)
if not self.is_dense or self.s.dim() != 3:
raise ValueError(
"SelectOutput.in_mask is only supported for batched dense assignments "
"with shape [B, N, K]."
)
expected_shape = self.s.shape[:2]
if in_mask.shape != expected_shape:
raise ValueError(
f"SelectOutput.in_mask must have shape {tuple(expected_shape)}, "
f"got {tuple(in_mask.shape)}."
)
return in_mask.to(torch.bool)
@property
def is_expressive(self) -> bool:
"""Check if the assignment matrix is produced by an expressive pooling
method. An assignment matrix is expressive if all rows sum to the same
constant and that constant is non-zero.
"""
row_sum = self.s.sum(dim=-1)
# Convert sparse tensor to dense for comparison
if isinstance(row_sum, Tensor) and row_sum.is_sparse:
row_sum = row_sum.to_dense()
if self.in_mask is not None:
row_sum = row_sum[self.in_mask]
if row_sum.numel() == 0:
return False
constant = row_sum[0]
return torch.allclose(
row_sum, constant.expand_as(row_sum)
) and not torch.allclose(
constant, torch.tensor(0, dtype=constant.dtype, device=constant.device)
)
@property
def out_mask(self) -> Optional[Tensor]:
r"""Boolean validity mask on pooled supernodes with shape :math:`[B, K]`.
This is inferred from dense assignment matrix ``s`` and marks
supernodes that have at least one assigned node.
For ``s.dim() == 3`` (:math:`[B, N, K]`), there is one mask row per
graph.
For ``s.dim() == 2`` (:math:`[N, K]`) with ``batch``, there is one
row per graph id in ``batch``.
For ``s.dim() == 2`` without ``batch``, the result has shape
:math:`[1, K]`.
Returns :obj:`None` for sparse assignments.
"""
if not isinstance(self.s, Tensor) or self.s.is_sparse:
return None
if self.s.dim() in (2, 3):
return get_mask_from_dense_s(self.s, self.batch)
return None
@property
def is_sparse(self) -> bool:
"""Return :obj:`True` if ``s`` is a sparse tensor."""
return isinstance(self.s, Tensor) and self.s.is_sparse
@property
def is_dense(self) -> bool:
"""Return :obj:`True` if ``s`` is a dense tensor."""
return isinstance(self.s, Tensor) and not self.s.is_sparse
@property
def num_nodes(self) -> int:
"""Return the number of input nodes represented by ``s``."""
return self.s.size(-2)
@property
def num_supernodes(self) -> int:
"""Return the number of pooled nodes represented by ``s``."""
return self.s.size(-1)
@property
def node_index(self) -> Optional[Tensor]:
"""Return sparse row indices (node ids) when ``s`` is sparse."""
return self.s.indices()[0] if self.is_sparse else None
@property
def cluster_index(self) -> Optional[Tensor]:
"""Return sparse column indices (supernode ids) when ``s`` is sparse."""
return self.s.indices()[1] if self.is_sparse else None
@property
def weight(self) -> Optional[Tensor]:
"""Return sparse assignment values when ``s`` is sparse."""
return self.s.values() if self.is_sparse else None
[docs]
def set_s_inv(self, method):
"""Compute and store ``s_inv`` from ``s`` using the given strategy."""
if method == "transpose":
if self.is_sparse:
self.s_inv = self.s.t()
else:
self.s_inv = self.s.transpose(-1, -2)
elif method == "inverse":
self.s_inv = pseudo_inverse(self.s)
else:
raise ValueError()
def __repr__(self):
out = (
f"{self.__class__.__name__}("
f"num_nodes={self.num_nodes}, "
f"num_supernodes={self.num_supernodes}"
)
if len(self._extra_args):
out += f", extra={self._extra_args}"
out += ")"
return out
@staticmethod
def _apply_to_value(value: Any, func: Callable) -> Any:
if isinstance(value, Tensor):
return func(value)
if isinstance(value, list):
return [SelectOutput._apply_to_value(v, func) for v in value]
if isinstance(value, tuple):
return tuple(SelectOutput._apply_to_value(v, func) for v in value)
if isinstance(value, Mapping):
return {k: SelectOutput._apply_to_value(v, func) for k, v in value.items()}
return value
[docs]
def apply(self, func: Callable) -> "SelectOutput":
r"""Applies ``func`` to tensors in ``s``, ``s_inv``, and tensor-valued extra attributes."""
self.s = func(self.s)
if self.s_inv is not None:
self.s_inv = func(self.s_inv)
for attr_name in self._extra_args:
if hasattr(self, attr_name):
value = getattr(self, attr_name)
setattr(self, attr_name, self._apply_to_value(value, func))
return self
[docs]
def clone(self) -> "SelectOutput":
r"""Performs a deep-copy of the object."""
return copy.deepcopy(self)
[docs]
def to(self, device: Union[int, str], non_blocking: bool = False) -> "SelectOutput":
r"""Performs tensor dtype and/or device conversion for both ``s`` and
``s_inv``.
"""
self.apply(lambda x: x.to(device=device, non_blocking=non_blocking))
if self.batch is not None:
self.batch = self.batch.to(device=device, non_blocking=non_blocking)
return self
[docs]
def cpu(self) -> "SelectOutput":
r"""Copies attributes to CPU memory for both ``s`` and ``s_inv``."""
self.apply(lambda x: x.cpu())
if self.batch is not None:
self.batch = self.batch.cpu()
return self
[docs]
def cuda(
self, device: Optional[Union[int, str]] = None, non_blocking: bool = False
) -> "SelectOutput":
r"""Copies attributes to CUDA memory for both ``s`` and ``s_inv``."""
self.apply(lambda x: x.cuda(device, non_blocking=non_blocking))
if self.batch is not None:
self.batch = self.batch.cuda(device, non_blocking=non_blocking)
return self
[docs]
def detach_(self) -> "SelectOutput":
r"""Detaches attributes from the computation graph for both ``s``
and ``s_inv``.
"""
return self.apply(lambda x: x.detach_())
[docs]
def detach(self) -> "SelectOutput":
r"""Detaches attributes from the computation graph by creating a new
tensor for both ``s`` and ``s_inv``.
"""
return self.apply(lambda x: x.detach())
[docs]
def requires_grad_(self, requires_grad: bool = True) -> "SelectOutput":
r"""Tracks gradient computation for both ``s`` and ``s_inv``."""
return self.apply(lambda x: x.requires_grad_(requires_grad=requires_grad))
[docs]
def assign_all_nodes(
self,
adj: Optional[Adj] = None,
weight: Optional[Tensor] = None,
max_iter: int = 5,
batch: Optional[Tensor] = None,
closest_node_assignment: bool = True,
) -> "SelectOutput":
r"""Extends a sparse selection to assign ALL nodes to the selected supernodes.
This method converts a sparse selection (where only a subset of nodes
are initially selected, e.g. top-k selection) into a complete assignment where every node in the
graph is assigned to one of the selected supernodes.
Args:
adj (~torch_geometric.typing.Adj, optional): Graph connectivity matrix.
Can be an edge_index tensor of shape :math:`(2, E)` or SparseTensor.
Required for ``"closest_node"`` strategy. (default: :obj:`None`)
weight (~torch.Tensor, optional): Node-level weights for the assignment.
Must have shape :math:`(N,)` where :math:`N` is the total number of nodes.
Note: This is different from edge weights. (default: :obj:`None`)
max_iter (int, optional): Maximum number of message passing iterations
for the ``"closest_node"`` strategy. Higher values allow assignment
of more distant nodes through graph connectivity. Must be ``> 0``
for ``"closest_node"`` strategy. (default: ``5``)
batch (~torch.Tensor, optional): Batch assignment vector of shape :math:`(N,)`
indicating which graph each node belongs to. When provided, ensures
nodes are only assigned to supernodes within the same graph.
(default: :obj:`None`)
closest_node_assignment (bool, optional): If True, assign unlabeled nodes to the
closest supernode. If False, use random assignment to supernodes.
(default: :obj:`True`)
Returns:
SelectOutput: A new SelectOutput with complete node-to-supernode assignments.
The returned object has ``num_nodes`` assignments (one per node) and
:obj:`num_supernodes` supernodes (same as the original selection).
Raises:
AssertionError: If ``adj`` is :obj:`None` for ``"closest_node"`` strategy.
AssertionError: If ``max_iter <= 0`` for ``"closest_node"`` strategy.
ValueError: If :obj:`weight` size doesn't match the number of nodes.
ValueError: If ``adj`` has an invalid type.
ValueError: If ``strategy`` is not recognized.
Example:
>>> # Convert sparse top-k selection to full assignment
>>> # Assume we have a SelectOutput from top-k selection
>>> sparse_output = topk_selector(x, edge_index) # Only k nodes selected
>>> print(sparse_output.node_index.size(0)) # k nodes
>>> # Extend to assign all nodes using graph connectivity
>>> full_output = sparse_output.assign_all_nodes(
... adj=edge_index, closest_node_assignment=True, max_iter=5
... )
>>> print(full_output.node_index.size(0)) # N nodes (all nodes)
>>> print(full_output.num_supernodes) # Still k supernodes
"""
# Get the kept nodes indices from the original SelectOutput
kept_nodes = self.node_index
# If all nodes are already kept, no assignment is needed
if len(kept_nodes) == self.num_nodes:
return self
if closest_node_assignment:
assert adj is not None, "adj must be provided for closest_node_assignment"
assert max_iter > 0, (
"max_iter must be greater than 0 for closest_node_assignment"
)
# Convert adjacency to edge_index format if needed
if is_sparsetensor(adj) or (isinstance(adj, Tensor) and adj.is_sparse):
edge_index, _ = connectivity_to_edge_index(adj)
elif isinstance(adj, Tensor):
edge_index = adj
else:
raise ValueError(f"Invalid adjacency type: {type(adj)}")
# Handle the weight parameter if provided
if weight is not None:
if weight.size(0) != self.num_nodes:
raise ValueError(
f"Weight tensor size ({weight.size(0)}) must match the number of nodes ({self.num_nodes})"
)
# Use get_assignments with graph-aware assignment
assignments = get_assignments(
kept_nodes,
edge_index=edge_index if closest_node_assignment else None,
max_iter=max_iter if closest_node_assignment else 0,
batch=batch,
)
# Create new SelectOutput with updated cluster assignments
new_select_output = SelectOutput(
cluster_index=assignments[1],
s_inv_op=getattr(self, "s_inv_op", "transpose"),
weight=weight,
)
# Copy any additional attributes from the original SelectOutput
for attr_name in self._extra_args if hasattr(self, "_extra_args") else []:
if hasattr(self, attr_name):
setattr(new_select_output, attr_name, getattr(self, attr_name))
return new_select_output
[docs]
class Select(torch.nn.Module):
r"""An abstract base class implementing a sparse :math:`\texttt{select}` operator
that maps the nodes of an input graph to supernodes of the pooled one.
It returns a :class:`~tgp.select.SelectOutput` containing the sparse
supernode assignment matrix :math:`\mathbf{S} \in \mathbb{R}^{N \times K}`.
"""
is_dense: bool = False
def reset_parameters(self):
pass
[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):
The node feature matrix of shape :math:`[N, F]`,
where :math:`N` is the number of nodes in the batch and
:math:`F` is the number of node features.
(default: :obj:`None`)
edge_index (~torch.Tensor, optional):
The edge indices. Is a tensor of of shape :math:`[2, E]`,
where :math:`E` is the number of edges in the batch.
(default: :obj:`None`)
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`)
num_nodes (int, optional):
The total number of nodes of the graphs in the batch.
(default: :obj:`None`)
Returns:
:class:`~tgp.select.SelectOutput`: The output of :math:`\texttt{select}` operator.
"""
raise NotImplementedError
def __repr__(self) -> str:
return f"{self.__class__.__name__}()"