Train an All-Set-Transformer TNN#
In this notebook, we will create and train a two-step message passing network named AllSetTransformer (Chien et al., 2021) in the hypergraph domain. We will use a benchmark dataset, shrec16, a collection of 3D meshes, to train the model to perform classification at the level of the hypergraph.
Following the “awesome-tnns” github repo.
🟧 \(\quad m_{\rightarrow z}^{(\rightarrow 1)} = AGG_{y \in \mathcal{B}(z)} (h_y^{t, (0)}, h_z^{t,(1)}) \quad \text{with attention}\)
🟦 \(\quad h_z^{t+1,(1)} = \text{LN}(m_{\rightarrow z}^{(\rightarrow 1)} + \text{MLP}(m_{\rightarrow z}^{(\rightarrow 1)} ))\)
Edge to vertex:
🟧 \(\quad m_{\rightarrow x}^{(\rightarrow 0)} = AGG_{z \in \mathcal{C}(x)} (h_z^{t+1,(1)}, h_x^{t,(0)}) \quad \text{with attention}\)
🟦 \(\quad h_x^{t+1,(0)} = \text{LN}(m_{\rightarrow x}^{(\rightarrow 0)} + \text{MLP}(m_{\rightarrow x}^{(\rightarrow 0)} ))\)
Additional theoretical clarifications#
Given a hypergraph \(G=(\mathcal{V}, \mathcal{E})\), let \(\textbf{X} \in \mathbb{R}^{|\mathcal{V}| \times F}\) and \(\textbf{Z} \in \mathbb{R}^{|\mathcal{E}| \times F'}\) denote the hidden node and hyperedge representations, respectively. Additionally, define \(V_{e, \textbf{X}} = \{\textbf{X}_{u,:}: u \in e\}\) as the multiset of hidden node representations in the hyperedge \(e\) and \(E_{v, \textbf{Z}} = \{\textbf{Z}_{e,:}: v \in e\}\) as the multiset of hidden representations of hyperedges containing \(v\).
🔷 \(\textbf{Z}_{e,:}^{(t+1)} = f_{\mathcal{V} \rightarrow \mathcal{E}}(V_{e, \textbf{X}^{(t)}}; \textbf{Z}_{e,:}^{(t)})\)
🔷 \(\textbf{X}_{v,:}^{(t+1)} = f_{\mathcal{E} \rightarrow \mathcal{V}}(E_{v, \textbf{Z}^{(t+1)}}; \textbf{X}_{v,:}^{(t)})\)
in which \(f_{\mathcal{V} \rightarrow \mathcal{E}}\) and \(f_{\mathcal{E} \rightarrow \mathcal{V}}\) are two permutation invariant functions with respect to their first input. The matrices \(\textbf{Z}_{e,:}^{(0)}\) and \(\textbf{X}_{v,:}^{(0)}\) are initialized with the hyperedge and node features respectively, if available, otherwise they are set to be all-zero matrices.
In the practical implementation of the model, \(f_{\mathcal{V} \rightarrow \mathcal{E}}\) and \(f_{\mathcal{E} \rightarrow \mathcal{V}}\) are parametrized and \(learnt\) for each dataset and task, and the information of their second argument is not utilized. The option achieving the best results makes use of attention-based layers, giving rise to the so-called AllSetTransformer architecture.
1️⃣ \(\textbf{K}^{(i)} = \text{MLP}^{K, i}(\textbf{S}), \textbf{V}^{(i)} = \text{MLP}^{V, i}(\textbf{S})\), where \(i \in \{1, ..., h\},\)
2️⃣ $ \textbf{O}`^{(i)} = :nbsphinx-math:omega (:nbsphinx-math:theta`{(i)}(:nbsphinx-math:textbf{K}``{(i)})^{T}) :nbsphinx-math:`textbf{V}`^{(i)},$
3️⃣ $:nbsphinx-math:theta `:nbsphinx-math:overset{Delta}{=}` \mathbin\Vert_{i=1}^{h} :nbsphinx-math:`theta`^{(i)}, $
4️⃣ $ \text{MH}{h, :nbsphinx-math:`omega`}(:nbsphinx-math:`theta`, :nbsphinx-math:`textbf{S}`, :nbsphinx-math:`textbf{S}`) = :nbsphinx-math:`mathbin`:nbsphinx-math:`Vert`{i=1}^{h} :nbsphinx-math:`textbf{O}`^{(i)}, $
5️⃣ $ \textbf{Y} = \text{LN} (\theta `+ :nbsphinx-math:text{MH}`_{h, \omega}(\theta, \textbf{S}, \textbf{S})), $
6️⃣ \(f_{\mathcal{V} \rightarrow \mathcal{E}}(\textbf{S}) = f_{\mathcal{E} \rightarrow \mathcal{V}}(\textbf{S}) = \text{LN} (\textbf{Y} + \text{MLP}(\textbf{Y}))\).
The elements and operations used in these steps are defined as follows:
🔶 \(\text{LN}\) means layer normalization (Ba et al., 2016),
🔶 \(\mathbin\Vert\) represents concatenation,
🔶 \(\theta \in \mathbb{R}^{1 \times hF_{h}}\) is a learnable weight,
🔶 \(\text{MH}_{h, \omega}\) denotes a multihead attention mechanism with \(h\) heads and activation function \(\omega\) (Vaswani et al., 2017),
🔶 all \(\text{MLP}\) modules are multi-layer perceptrons that operate row-wise, so they are applied identically and independently to each multiset element of \(\textbf{S}\).
In [12]:
"""
This module contains the AllSetTransformer class for hypergraph-based neural networks.
The AllSet class implements a specific hypergraph-based neural network architecture
used for solving certain types of problems.
Author: Your Name
"""
import numpy as np
import torch
import torch_geometric.datasets as geom_datasets
from torch_geometric.utils import to_undirected
from topomodelx.nn.hypergraph.allset_transformer import AllSetTransformer
torch.manual_seed(0)
If GPU’s are available, we will make use of them. Otherwise, this will run on CPU.
In [13]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
cpu
Pre-processing#
The first step is to import the dataset, Cora, a benchmark classification datase. We then lift the graph into our domain of choice, a hypergraph.
In [14]:
cora = geom_datasets.Planetoid(root="tmp/", name="cora")
data = cora.data
x_0s = data.x
y = data.y
edge_index = data.edge_index
train_mask = data.train_mask
val_mask = data.val_mask
test_mask = data.test_mask
Now we retrieve the neighborhood structure (i.e. their representative matrice) that we will use to send messges from node to hyperedges. In the case of this architecture, we need the boundary matrix (or incidence matrix) \(B_1\) with shape \(n_\text{nodes} \times n_\text{edges}\).
In citation Cora dataset we lift graph structure to the hypergraph domain by creating hyperedges from 1-hop graph neighbourhood of each node.
In [15]:
# Ensure the graph is undirected (optional but often useful for one-hop neighborhoods).
edge_index = to_undirected(edge_index)
# Create a list of one-hop neighborhoods for each node.
one_hop_neighborhoods = []
for node in range(data.num_nodes):
# Get the one-hop neighbors of the current node.
neighbors = data.edge_index[1, data.edge_index[0] == node]
# Append the neighbors to the list of one-hop neighborhoods.
one_hop_neighborhoods.append(neighbors.numpy())
# Detect and eliminate duplicate hyperedges.
unique_hyperedges = set()
hyperedges = []
for neighborhood in one_hop_neighborhoods:
# Sort the neighborhood to ensure consistent comparison.
neighborhood = tuple(sorted(neighborhood))
if neighborhood not in unique_hyperedges:
hyperedges.append(list(neighborhood))
unique_hyperedges.add(neighborhood)
Additionally we print the statictis associated with obtained incidence matrix
In [16]:
# # Calculate hyperedge statistics.
# hyperedge_sizes = [len(he) for he in hyperedges]
# min_size = min(hyperedge_sizes)
# max_size = max(hyperedge_sizes)
# mean_size = np.mean(hyperedge_sizes)
# median_size = np.median(hyperedge_sizes)
# std_size = np.std(hyperedge_sizes)
# num_single_node_hyperedges = sum(np.array(hyperedge_sizes) == 1)
# # Print the hyperedge statistics.
# print(f"Hyperedge statistics: ")
# print("Number of hyperedges without duplicated hyperedges", len(hyperedges))
# print(f"min = {min_size}, ")
# print(f"max = {max_size}, ")
# print(f"mean = {mean_size}, ")
# print(f"median = {median_size}, ")
# print(f"std = {std_size}, ")
# print(f"Number of hyperedges with size equal to one = {num_single_node_hyperedges}")
Construct incidence matrix
In [17]:
max_edges = len(hyperedges)
incidence_1 = np.zeros((x_0s.shape[0], max_edges))
for col, neighibourhood in enumerate(hyperedges):
for row in neighibourhood:
incidence_1[row, col] = 1
assert all(incidence_1.sum(0) > 0) is True, "Some hyperedges are empty"
assert all(incidence_1.sum(1) > 0) is True, "Some nodes are not in any hyperedges"
incidence_1 = torch.Tensor(incidence_1).to_sparse_coo()
Create the Neural Network#
Define the network that initializes the base model and sets up the readout operation. Different downstream tasks might require different pooling procedures.
In [18]:
class Network(torch.nn.Module):
"""Network class that initializes the AllSet model and readout layer.
Base model parameters:
----------
Reqired:
in_channels : int
Dimension of the input features.
hidden_channels : int
Dimension of the hidden features.
Optitional:
**kwargs : dict
Additional arguments for the base model.
Readout layer parameters:
----------
out_channels : int
Dimension of the output features.
task_level : str
Level of the task. Either "graph" or "node".
"""
def __init__(
self, in_channels, hidden_channels, out_channels, task_level="graph", **kwargs
):
super().__init__()
# Define the model
self.base_model = AllSetTransformer(
in_channels=in_channels, hidden_channels=hidden_channels, **kwargs
)
# Readout
self.linear = torch.nn.Linear(hidden_channels, out_channels)
self.out_pool = task_level == "graph"
def forward(self, x_0, incidence_1):
# Base model
x_0, x_1 = self.base_model(x_0, incidence_1)
# Pool over all nodes in the hypergraph
x = torch.max(x_0, dim=0)[0] if self.out_pool is True else x_0
return self.linear(x)
Initialize the model
In [19]:
# Base model hyperparameters
in_channels = x_0s.shape[1]
hidden_channels = 128
heads = 4
n_layers = 1
mlp_num_layers = 2
# Readout hyperparameters
out_channels = torch.unique(y).shape[0]
task_level = "graph" if out_channels == 1 else "node"
model = Network(
in_channels=in_channels,
hidden_channels=hidden_channels,
out_channels=out_channels,
n_layers=n_layers,
mlp_num_layers=mlp_num_layers,
task_level=task_level,
).to(device)
Train the Neural Network#
We specify the model, the loss, and an optimizer.
In [20]:
# Optimizer and loss
opt = torch.optim.Adam(model.parameters(), lr=0.01)
# Categorial cross-entropy loss
loss_fn = torch.nn.CrossEntropyLoss()
# Accuracy
def acc_fn(y, y_hat):
return (y == y_hat).float().mean()
In [21]:
x_0s = torch.tensor(x_0s)
x_0s, incidence_1, y = (
x_0s.float().to(device),
incidence_1.float().to(device),
torch.tensor(y, dtype=torch.long).to(device),
)
/tmp/ipykernel_909051/276484184.py:1: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).
x_0s = torch.tensor(x_0s)
/tmp/ipykernel_909051/276484184.py:5: UserWarning: To copy construct from a tensor, it is recommended to use sourceTensor.clone().detach() or sourceTensor.clone().detach().requires_grad_(True), rather than torch.tensor(sourceTensor).
torch.tensor(y, dtype=torch.long).to(device),
The following cell performs the training, looping over the network for a low amount of epochs. We keep training minimal for the purpose of rapid testing.
In [22]:
test_interval = 1
num_epochs = 1
epoch_loss = []
for epoch_i in range(1, num_epochs + 1):
model.train()
opt.zero_grad()
# Extract edge_index from sparse incidence matrix
y_hat = model(x_0s, incidence_1)
loss = loss_fn(y_hat[train_mask], y[train_mask])
loss.backward()
opt.step()
epoch_loss.append(loss.item())
if epoch_i % test_interval == 0:
model.eval()
y_hat = model(x_0s, incidence_1)
loss = loss_fn(y_hat[train_mask], y[train_mask])
print(f"Epoch: {epoch_i} ")
print(
f"Train_loss: {np.mean(epoch_loss):.4f}, acc: {acc_fn(y_hat[train_mask].argmax(1), y[train_mask]):.4f}",
flush=True,
)
loss = loss_fn(y_hat[val_mask], y[val_mask])
print(
f"Val_loss: {loss:.4f}, Val_acc: {acc_fn(y_hat[val_mask].argmax(1), y[val_mask]):.4f}",
flush=True,
)
loss = loss_fn(y_hat[test_mask], y[test_mask])
print(
f"Test_loss: {loss:.4f}, Test_acc: {acc_fn(y_hat[test_mask].argmax(1), y[test_mask]):.4f}",
flush=True,
)
Epoch: 10
Train_loss: 0.9825, acc: 1.0000
Val_loss: 0.9895, Val_acc: 0.7380
Test_loss: 0.8713, Test_acc: 0.7740