Train a Hypergraph Message Passing Neural Network (HMPNN)#

In this notebook, we will create and train a Hypergraph Message Passing Neural Network in the hypergraph domain. This method is introduced in the paper Message Passing Neural Networks for Hypergraphs by Heydari et Livi 2022. We will use a benchmark dataset, Cora, a collection of 2708 academic papers and 5429 citation relations, to do the task of node classification. There are 7 category labels, namely Case_Based, Genetic_Algorithms, Neural_Networks, Probabilistic_Methods, Reinforcement_Learning, Rule_Learning and Theory.

Each document is initially represented as a binary vector of length 1433, standing for a unique subset of the words within the papers, in which a value of 1 means the presence of its corresponding word in the paper.

 In [1]:
import torch
import torch_geometric.datasets as geom_datasets
from sklearn.metrics import accuracy_score

from topomodelx.nn.hypergraph.hmpnn import HMPNN

torch.manual_seed(0)

If GPU’s are available, we will make use of them. Otherwise, this will run on CPU.

 In [2]:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(device)
cuda

Pre-processing#

Here we download the dataset. It contains initial representation of nodes, the adjacency information, category labels and train-val-test masks.

 In [3]:
dataset = geom_datasets.Planetoid(root="tmp/", name="cora")[0]

Below, we construct the incidence matrix (\(B_1\)) which is of shape \(n_\text{nodes} \times n_\text{edges}\).

 In [4]:
dataset["incidence_1"] = torch.sparse_coo_tensor(
    dataset["edge_index"], torch.ones(dataset["edge_index"].shape[1]), dtype=torch.long
)
dataset = dataset.to(device)
 In [5]:
x_0s = dataset["x"]
y = dataset["y"]
incidence_1 = dataset["incidence_1"]

Train the Neural Network#

We then specify the hyperparameters and construct the model, the loss and optimizer.

 In [6]:
class Network(torch.nn.Module):
    """Network class that initializes the base 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 = HMPNN(
            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, x_1, incidence_1):
        # Base model
        x_0, x_1 = self.base_model(x_0, x_1, 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 [7]:
# Base model hyperparameters
in_channels = x_0s.shape[1]
hidden_channels = 128
n_layers = 1

# 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,
    task_level=task_level,
).to(device)
 In [8]:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = torch.nn.CrossEntropyLoss()


train_mask = dataset["train_mask"]
val_mask = dataset["val_mask"]
test_mask = dataset["test_mask"]

Now it’s time to train the model, looping over the network for a low amount of epochs. We keep training minimal for the purpose of rapid testing.

Note: The number of epochs below have been kept low to facilitate debugging and testing. Real use cases should likely require more epochs.

 In [9]:
torch.manual_seed(0)
test_interval = 5
num_epochs = 5


initial_x_1 = torch.zeros_like(x_0s)
for epoch in range(1, num_epochs + 1):
    model.train()
    optimizer.zero_grad()
    y_hat = model(x_0s, initial_x_1, incidence_1)
    loss = loss_fn(y_hat[train_mask], y[train_mask])
    loss.backward()
    optimizer.step()

    train_loss = loss.item()
    y_pred = y_hat.argmax(dim=-1)
    train_acc = accuracy_score(y[train_mask].cpu(), y_pred[train_mask].cpu())

    if epoch % test_interval == 0:
        model.eval()

        y_hat = model(x_0s, initial_x_1, incidence_1)
        val_loss = loss_fn(y_hat[val_mask], y[val_mask]).item()
        y_pred = y_hat.argmax(dim=-1)
        val_acc = accuracy_score(y[val_mask].cpu(), y_pred[val_mask].cpu())

        test_loss = loss_fn(y_hat[test_mask], y[test_mask]).item()
        y_pred = y_hat.argmax(dim=-1)
        test_acc = accuracy_score(y[test_mask].cpu(), y_pred[test_mask].cpu())
        print(
            f"Epoch: {epoch + 1} train loss: {train_loss:.4f} train acc: {train_acc:.2f} "
            f" val loss: {val_loss:.4f} val acc: {val_acc:.2f}"
            f" test loss: {test_acc:.4f} val acc: {test_acc:.2f}"
        )
Epoch: 6 train loss: 1.2665 train acc: 0.82  val loss: 1.9848 val acc: 0.23 test loss: 0.2270 val acc: 0.23
Epoch: 11 train loss: 0.8439 train acc: 0.99  val loss: 1.7569 val acc: 0.38 test loss: 0.3970 val acc: 0.40
Epoch: 16 train loss: 0.5119 train acc: 1.00  val loss: 1.6846 val acc: 0.39 test loss: 0.4160 val acc: 0.42
Epoch: 21 train loss: 0.2717 train acc: 1.00  val loss: 1.5872 val acc: 0.43 test loss: 0.4500 val acc: 0.45
Epoch: 26 train loss: 0.1571 train acc: 1.00  val loss: 1.6143 val acc: 0.41 test loss: 0.4230 val acc: 0.42
Epoch: 31 train loss: 0.0816 train acc: 1.00  val loss: 1.5894 val acc: 0.45 test loss: 0.4490 val acc: 0.45
Epoch: 36 train loss: 0.0478 train acc: 1.00  val loss: 1.6020 val acc: 0.46 test loss: 0.4630 val acc: 0.46
Epoch: 41 train loss: 0.0298 train acc: 1.00  val loss: 1.6153 val acc: 0.47 test loss: 0.4670 val acc: 0.47
Epoch: 46 train loss: 0.0214 train acc: 1.00  val loss: 1.6499 val acc: 0.47 test loss: 0.4720 val acc: 0.47
Epoch: 51 train loss: 0.0160 train acc: 1.00  val loss: 1.6764 val acc: 0.48 test loss: 0.4830 val acc: 0.48
Epoch: 56 train loss: 0.0149 train acc: 1.00  val loss: 1.6986 val acc: 0.48 test loss: 0.4900 val acc: 0.49
Epoch: 61 train loss: 0.0123 train acc: 1.00  val loss: 1.6888 val acc: 0.47 test loss: 0.4920 val acc: 0.49
Epoch: 66 train loss: 0.0097 train acc: 1.00  val loss: 1.6670 val acc: 0.48 test loss: 0.4970 val acc: 0.50
Epoch: 71 train loss: 0.0078 train acc: 1.00  val loss: 1.6547 val acc: 0.49 test loss: 0.5030 val acc: 0.50
Epoch: 76 train loss: 0.0072 train acc: 1.00  val loss: 1.6484 val acc: 0.49 test loss: 0.5030 val acc: 0.50
Epoch: 81 train loss: 0.0066 train acc: 1.00  val loss: 1.6378 val acc: 0.49 test loss: 0.5100 val acc: 0.51
Epoch: 86 train loss: 0.0064 train acc: 1.00  val loss: 1.6507 val acc: 0.49 test loss: 0.5110 val acc: 0.51
Epoch: 91 train loss: 0.0060 train acc: 1.00  val loss: 1.6745 val acc: 0.50 test loss: 0.5100 val acc: 0.51
Epoch: 96 train loss: 0.0051 train acc: 1.00  val loss: 1.6682 val acc: 0.50 test loss: 0.5150 val acc: 0.52
Epoch: 101 train loss: 0.0047 train acc: 1.00  val loss: 1.6412 val acc: 0.50 test loss: 0.5190 val acc: 0.52
 In [ ]: