"""Interaction Network and PropagationNet GNN layers used by Neural-LAM."""
# Standard library
# Third-party
import torch
import torch_geometric as pyg
from torch import nn
# Local
from . import utils
[docs]
class InteractionNet(pyg.nn.MessagePassing):
"""
Implementation of a generic Interaction Network,
from Battaglia et al. (2016)
"""
# pylint: disable=arguments-differ
# Disable to override args/kwargs from superclass
def __init__(
self,
edge_index: torch.Tensor,
input_dim: int,
update_edges: bool = True,
hidden_layers: int = 1,
hidden_dim: int | None = None,
edge_chunk_sizes: list[int] | None = None,
aggr_chunk_sizes: list[int] | None = None,
aggr: str = "sum",
) -> None:
"""
Create a new InteractionNet.
Parameters
----------
edge_index : torch.Tensor
Edge connectivity tensor in PyG format.
Shape ``(2, num_edges)``.
input_dim : int
Dimensionality of both node and edge input representations.
update_edges : bool, optional
If ``True``, compute and return updated edge representations in
addition to node representations. Default is ``True``.
hidden_layers : int, optional
Number of hidden layers in each MLP. Default is ``1``.
hidden_dim : int or None, optional
Width of hidden layers. If ``None``, defaults to ``input_dim``.
edge_chunk_sizes : list[int] or None, optional
Chunk sizes for splitting edge representations across separate
MLPs. ``None`` uses a single shared MLP.
aggr_chunk_sizes : list[int] or None, optional
Chunk sizes for splitting aggregated node representations across
separate MLPs. ``None`` uses a single shared MLP.
aggr : {"sum", "mean"}, optional
Message aggregation method. Default is ``"sum"``.
Raises
------
ValueError
If ``aggr`` is not one of ``"sum"`` or ``"mean"``.
"""
if aggr not in ("sum", "mean"):
raise ValueError(f"Unknown aggregation method: {aggr}")
super().__init__(aggr=aggr)
if hidden_dim is None:
# Default to input dim if not explicitly given
hidden_dim = input_dim
self.num_rec = edge_index[1].max() + 1
# edge_index is expected to be zero-based and local:
# edge_index[0]: sender indices in [0 .. num_snd-1]
# edge_index[1]: receiver indices in [0 .. num_rec-1]
# The edge indices used in this GNN layer are defined as:
# receivers → [0 .. num_rec-1]
# senders → [num_rec .. num_rec+num_snd-1]
# Hence, sender indices from the input edge_index are offset
# by num_rec to obtain the indices used in this layer.
edge_index = torch.stack(
(edge_index[0] + self.num_rec, edge_index[1]), dim=0
)
self.register_buffer("edge_index", edge_index, persistent=False)
# Create MLPs
edge_mlp_recipe = [3 * input_dim] + [hidden_dim] * (hidden_layers + 1)
aggr_mlp_recipe = [2 * input_dim] + [hidden_dim] * (hidden_layers + 1)
if edge_chunk_sizes is None:
self.edge_mlp = utils.make_mlp(edge_mlp_recipe)
else:
self.edge_mlp = SplitMLPs(
[utils.make_mlp(edge_mlp_recipe) for _ in edge_chunk_sizes],
edge_chunk_sizes,
)
if aggr_chunk_sizes is None:
self.aggr_mlp = utils.make_mlp(aggr_mlp_recipe)
else:
self.aggr_mlp = SplitMLPs(
[utils.make_mlp(aggr_mlp_recipe) for _ in aggr_chunk_sizes],
aggr_chunk_sizes,
)
self.update_edges = update_edges
[docs]
def forward(
self,
send_rep: torch.Tensor,
rec_rep: torch.Tensor,
edge_rep: torch.Tensor,
) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]:
"""
Apply the interaction network to update receiver node
representations, and optionally edge representations.
Parameters
----------
send_rep : torch.Tensor
Sender node representations.
Shape ``(num_send, input_dim)``.
rec_rep : torch.Tensor
Receiver node representations.
Shape ``(num_rec, input_dim)``.
edge_rep : torch.Tensor
Edge representations.
Shape ``(num_edges, input_dim)``.
Returns
-------
rec_rep : torch.Tensor
Updated receiver node representations.
Shape ``(num_rec, input_dim)``.
edge_rep : torch.Tensor
Updated edge representations.
Shape ``(num_edges, input_dim)``.
Only returned when ``update_edges=True``.
"""
# Always concatenate to [rec_nodes, send_nodes] for propagation,
# but only aggregate to rec_nodes
node_reps = torch.cat((rec_rep, send_rep), dim=-2)
edge_rep_aggr, edge_diff = self.propagate(
self.edge_index, x=node_reps, edge_attr=edge_rep
)
rec_diff = self.aggr_mlp(torch.cat((rec_rep, edge_rep_aggr), dim=-1))
# Residual connection for receiver nodes
rec_rep = self.node_residual_target(rec_rep, edge_rep_aggr) + rec_diff
if self.update_edges:
edge_rep = edge_rep + edge_diff
return rec_rep, edge_rep
return rec_rep
[docs]
def node_residual_target(
self, rec_rep: torch.Tensor, edge_rep_aggr: torch.Tensor
) -> torch.Tensor:
"""
Return the base tensor for the node residual connection.
InteractionNet uses the original receiver representation.
"""
return rec_rep
[docs]
def message(
self, x_j: torch.Tensor, x_i: torch.Tensor, edge_attr: torch.Tensor
) -> torch.Tensor:
"""Compute messages from node ``j`` to ``i``."""
return self.edge_mlp(torch.cat((edge_attr, x_j, x_i), dim=-1))
# pylint: disable-next=signature-differs
[docs]
def aggregate(
self,
inputs: torch.Tensor,
index: torch.Tensor,
ptr: torch.Tensor | None,
dim_size: int | None,
) -> tuple[torch.Tensor, torch.Tensor]:
"""
Overridden aggregation function to:
* return both aggregated and per-edge messages,
* only aggregate to the number of receiver nodes (``self.num_rec``)
rather than to ``dim_size``.
"""
aggr = super().aggregate(inputs, index, ptr, self.num_rec)
return aggr, inputs
[docs]
class PropagationNet(InteractionNet):
"""
Alternative version of InteractionNet that incentivizes the propagation
of information from sender nodes to receivers.
"""
# pylint: disable=arguments-differ
# Disable to override args/kwargs from superclass
def __init__(
self,
edge_index: torch.Tensor,
input_dim: int,
update_edges: bool = True,
hidden_layers: int = 1,
hidden_dim: int | None = None,
edge_chunk_sizes: list[int] | None = None,
aggr_chunk_sizes: list[int] | None = None,
aggr: str = "sum",
) -> None:
"""Initialise the :class:`PropagationNet` layer.
Parameters share the meaning of :class:`InteractionNet.__init__`; see
that class for the full description. The propagation variant overrides
``aggr`` defaults internally to favour stability of the propagation
residual.
"""
# Use mean aggregation in propagation version to avoid instability
super().__init__(
edge_index,
input_dim,
update_edges=update_edges,
hidden_layers=hidden_layers,
hidden_dim=hidden_dim,
edge_chunk_sizes=edge_chunk_sizes,
aggr_chunk_sizes=aggr_chunk_sizes,
aggr="mean",
)
[docs]
def node_residual_target(
self, rec_rep: torch.Tensor, edge_rep_aggr: torch.Tensor
) -> torch.Tensor:
"""
Return the base tensor for the node residual connection.
PropagationNet uses the aggregated edge messages, propagating
sender information to receiver nodes.
"""
return edge_rep_aggr
[docs]
def message(
self, x_j: torch.Tensor, x_i: torch.Tensor, edge_attr: torch.Tensor
) -> torch.Tensor:
"""
Compute messages from node j to node i.
"""
# Residual connection is to sender node, propagating information
# to edge
return x_j + self.edge_mlp(torch.cat((edge_attr, x_j, x_i), dim=-1))
GNN_TYPES = {
"InteractionNet": InteractionNet,
"PropagationNet": PropagationNet,
}
[docs]
def get_gnn_class(gnn_type: str) -> type[pyg.nn.MessagePassing]:
"""
Look up a GNN class by name.
Parameters
----------
gnn_type : str
One of the keys in GNN_TYPES (currently "InteractionNet" or
"PropagationNet")
Returns
-------
type[pyg.nn.MessagePassing]
The corresponding GNN class.
"""
if gnn_type not in GNN_TYPES:
raise ValueError(
f"Unknown GNN type '{gnn_type}'. "
f"Available types: {list(GNN_TYPES.keys())}"
)
return GNN_TYPES[gnn_type]
[docs]
class SplitMLPs(nn.Module):
"""
Module that feeds chunks of input through different MLPs.
Split up input along dim -2 using given chunk sizes and feeds
each chunk through separate MLPs.
"""
def __init__(self, mlps: list[nn.Module], chunk_sizes: list[int]) -> None:
"""
Create a module that dispatches chunks of the input to separate MLPs.
Parameters
----------
mlps : list of nn.Module
Sequence of MLPs to apply to each chunk.
chunk_sizes : list of int
Sizes used when splitting the input along ``dim=-2``.
Raises
------
AssertionError
If the number of ``mlps`` and ``chunk_sizes`` differ.
"""
super().__init__()
assert len(mlps) == len(chunk_sizes), (
"Number of MLPs must match the number of chunks"
)
self.mlps = nn.ModuleList(mlps)
self.chunk_sizes = chunk_sizes
[docs]
def forward(self, x: torch.Tensor) -> torch.Tensor:
"""
Split input along dim -2 and feed each chunk through its MLP.
Parameters
----------
x : torch.Tensor
Shape ``(..., N, d)``. Input tensor where
``N = sum(chunk_sizes)`` and ``d`` is the feature dimension.
Returns
-------
torch.Tensor
Shape ``(..., N, d)``. Concatenated outputs from all MLPs.
"""
chunks = torch.split(x, self.chunk_sizes, dim=-2)
chunk_outputs = [
mlp(chunk_input) for mlp, chunk_input in zip(self.mlps, chunks)
]
return torch.cat(chunk_outputs, dim=-2)