[机翻·转载]Hands-on Graph Neural Networks with PyTorch & PyTorch Geometric

在我的上一篇文章中,我介绍了图神经网络(GNN)的概念和它的一些最新进展。由于这个主题变得越来越夸张,我决定制作本教程,介绍如何在项目中轻松实现您的图神经网络。您将学习如何使用PyTorch Geometric构建自己的GNN,以及如何使用GNN来解决一个现实世界的问题(Recsys Challenge 2015)。

在这篇博客文章中,我们将使用PyTorch和PyTorch Geometric (PyG),这是一个建立在PyTorch之上的运行速度非常快的图神经网络框架。它比最著名的GNN框架DGL快好几倍。

除了惊人的速度之外,PyG还附带了一系列实现良好的GNN模型,这些模型在各种论文中都有说明。因此,用PyG再现这些实验将是非常方便的。

在这里插入图片描述
鉴于其在速度和方便方面的优势,毫无疑问,PyG是最受欢迎和使用最广泛的GNN库之一。让我们深入讨论这个话题,亲身实践一下吧

Requirements

  • PyTorch — 1.1.0
  • PyTorch Geometric — 1.2.0

PyTorch Geometric Basics

本节将带您了解PyG的基础知识。基本上,它将覆盖torch_geometric.data和torch_geometric.nn。您将学习如何将几何数据传递到您的GNN,以及如何设计一个自定义MessagePassing层,GNN的核心。

Data

torch_geometric.data模块包含一个data类,该类允许您非常容易地从数据创建图形。您只需要指定:

  1. 与每个节点关联的属性/特性
  2. 每个节点的连通性/邻接性(边缘索引)

每个节点的连通性/邻接性(边缘索引)
在这里插入图片描述
图中有4个节点,v1 v4,每个节点都与一个二维特征向量相关联,标签y表示它的类别。这两个可以表示为FloatTensors:

x = torch.tensor([[2,1], [5,6], [3,7], [12,0]], dtype=torch.float)
y = torch.tensor([0, 1, 0, 1], dtype=torch.float)

图的连通性(边索引)应限于COO格式,即第一个列表包含源节点的索引,第二个列表指定目标节点的索引。

edge_index = torch.tensor([[0, 1, 2, 0, 3],
                           [1, 0, 1, 3, 2]], dtype=torch.long)

注意,边缘索引的顺序与创建的Data对象无关,因为这些信息仅用于计算邻接矩阵。因此,上面的edge_index表示的信息与下面的相同。

edge_index = torch.tensor([[0, 2, 1, 0, 3],
                           [3, 1, 0, 1, 2]], dtype=torch.long)

将它们放在一起,我们可以创建如下所示的Data对象:

import torch
from torch_geometric.data import Data


x = torch.tensor([[2,1], [5,6], [3,7], [12,0]], dtype=torch.float)
y = torch.tensor([0, 1, 0, 1], dtype=torch.float)

edge_index = torch.tensor([[0, 2, 1, 0, 3],
                           [3, 1, 0, 1, 2]], dtype=torch.long)


data = Data(x=x, y=y, edge_index=edge_index)
>>> Data(edge_index=[2, 5], x=[4, 2], y=[4])

Dataset

数据集创建过程不是很简单,但是对于那些使用过火炬视觉的人来说,它可能看起来很熟悉,因为PyG遵循它的约定。PyG提供了两种不同类型的数据集类,InMemoryDataset和dataset。正如字面上所示,前者用于适合RAM的数据,而后者用于更大的数据。因为它们的实现非常相似,所以我只讨论InMemoryDataset。

要创建一个InMemoryDataset对象,您需要实现4个函数

  • raw_file_names()

它返回一个列表,其中显示了一个原始的、未处理的文件名列表。如果您只有一个文件,那么返回的列表应该只包含1个元素。实际上,您可以简单地返回一个空列表,然后在process()中指定您的文件。

  • processed_file_names()

与上一个函数类似,它还返回一个包含所有处理数据的文件名的列表。调用process()之后,通常,返回的列表应该只有一个元素,存储唯一处理过的数据文件名。

  • download()

这个函数应该将您正在处理的数据下载到self.raw_dir中指定的目录中。如果您不需要下载数据,只需访问

pass

在函数中。

  • process()

这是数据集最重要的方法。您需要将数据收集到data对象列表中。然后,调用self.collate()来计算DataLoader对象将使用的片。下面是一个来自PyG官方网站的自定义数据集的示例。

import torch
from torch_geometric.data import InMemoryDataset


class MyOwnDataset(InMemoryDataset):
    def __init__(self, root, transform=None, pre_transform=None):
        super(MyOwnDataset, self).__init__(root, transform, pre_transform)
        self.data, self.slices = torch.load(self.processed_paths[0])

    @property
    def raw_file_names(self):
        return ['some_file_1', 'some_file_2', ...]

    @property
    def processed_file_names(self):
        return ['data.pt']

    def download(self):
        # Download to `self.raw_dir`.

    def process(self):
        # Read data into huge `Data` list.
        data_list = [...]

        if self.pre_filter is not None:
            data_list [data for data in data_list if self.pre_filter(data)]

        if self.pre_transform is not None:
            data_list = [self.pre_transform(data) for data in data_list]

        data, slices = self.collate(data_list)
        torch.save((data, slices), self.processed_paths[0])

我将在本文后面展示如何从RecSys Challenge 2015中提供的数据创建自定义数据集。

DataLoader

DataLoader类允许您轻松地将数据批处理地提供到模型中。要创建DataLoader对象,只需指定Dataset和所需的批处理大小。

loader = DataLoader(dataset, batch_size=512, shuffle=True)

DataLoader对象的每次迭代都会产生一个Batch对象,它非常类似于Data对象,但具有一个Batch属性。它表示每个节点与哪个图相关联。由于DataLoader将来自不同样本/图的x、y和edge_index聚合到batch中,因此GNN模型需要这些batch信息来知道在一个batch中哪些节点属于同一个图,从而执行计算。

for batch in loader:
    batch
    >>> Batch(x=[1024, 21], edge_index=[2, 1568], y=[512], batch=[1024])

MessagePassing

消息传递是GNN的本质,它描述了如何学习节点嵌入。我在上一篇文章中已经讨论过了,所以我将简要地介绍一下符合PyG文档的术语。
在这里插入图片描述
x表示节点嵌入,e表示边缘特性,휙表示消息函数,表示聚合函数,훾表示更新功能。如果图中的边除了连通性外没有其他特征,e本质上就是图的边索引。上标表示层的索引。当k=1时,x表示每个节点的输入特征。下面我将说明每个函数是如何工作的

  • propagate(edge_index, size=None, **kwargs):

它接受边缘索引和其他可选信息,如节点特征(嵌入)。调用此函数将因此调用消息和更新。

  • message(**kwargs):

您可以指定如何为每个节点对(x_i, x_j)构造消息。由于它遵循propagate的调用,因此它可以接受传递给propagate的任何参数。需要注意的一点是,可以使用_i和_j定义从参数到特定节点的映射。因此,在命名该函数的参数时必须非常小心。

  • update(aggr_out, **kwargs)

它接受传递到propagate中的聚合消息和其他参数,为每个节点分配一个新的嵌入值。

Example

让我们看看如何实现SageConv层从论文归纳表示学习大图。SageConv的消息传递公式定义为:
在这里插入图片描述
这里,我们使用最大池作为聚合方法。因此,第一行右边可以写成
在这里插入图片描述
它说明了消息是如何构造的。每个相邻节点的嵌入都乘以一个权重矩阵,加上一个偏置,并通过一个激活函数。这可以很容易地用torch.nn.Linear完成。

class SAGEConv(MessagePassing):
    def __init__(self, in_channels, out_channels):
        super(SAGEConv, self).__init__(aggr='max')
        self.lin = torch.nn.Linear(in_channels, out_channels)
        self.act = torch.nn.ReLU()
        
    def message(self, x_j):
        # x_j has shape [E, in_channels]

        x_j = self.lin(x_j)
        x_j = self.act(x_j)
      
        return x_j

更新部分对聚合消息和当前节点嵌入进行聚合。然后再乘以另一个权矩阵,再应用另一个激活函数。

class SAGEConv(MessagePassing):
    def __init__(self, in_channels, out_channels):
        super(SAGEConv, self).__init__(aggr='max')
        self.update_lin = torch.nn.Linear(in_channels + out_channels, in_channels, bias=False)
        self.update_act = torch.nn.ReLU()
        
    def update(self, aggr_out, x):
        # aggr_out has shape [N, out_channels]
        
        new_embedding = torch.cat([aggr_out, x], dim=1)
        new_embedding = self.update_lin(new_embedding)
        new_embedding = torch.update_act(new_embedding)
        
        return new_embedding

把它放在一起,我们有下面的SageConv层。

import torch
from torch.nn import Sequential as Seq, Linear, ReLU
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import remove_self_loops, add_self_loops
class SAGEConv(MessagePassing):
    def __init__(self, in_channels, out_channels):
        super(SAGEConv, self).__init__(aggr='max') #  "Max" aggregation.
        self.lin = torch.nn.Linear(in_channels, out_channels)
        self.act = torch.nn.ReLU()
        self.update_lin = torch.nn.Linear(in_channels + out_channels, in_channels, bias=False)
        self.update_act = torch.nn.ReLU()
        
    def forward(self, x, edge_index):
        # x has shape [N, in_channels]
        # edge_index has shape [2, E]
        
        
        edge_index, _ = remove_self_loops(edge_index)
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
        
        
        return self.propagate(edge_index, size=(x.size(0), x.size(0)), x=x)

    def message(self, x_j):
        # x_j has shape [E, in_channels]

        x_j = self.lin(x_j)
        x_j = self.act(x_j)
        
        return x_j

    def update(self, aggr_out, x):
        # aggr_out has shape [N, out_channels]


        new_embedding = torch.cat([aggr_out, x], dim=1)
        
        new_embedding = self.update_lin(new_embedding)
        new_embedding = self.update_act(new_embedding)
        
        return new_embedding

A Real-World Example — RecSys Challenge 2015

2015年RecSys挑战赛向数据科学家发起挑战,要求他们建立一个基于会议的推荐系统。这项挑战要求参与者完成两项任务:

  1. 预测是否会出现一个购买事件,然后是一连串的点击
  2. 预测会买什么东西

首先,我们从RecSys Challenge 2015官网下载数据,构建Dataset。我们将从第一个任务开始,因为它比较容易。

上述官网挂了(2021年11月28日),但是可以从Kaggle上下载。

该挑战提供两组主要的数据,分别包含单击事件和购买事件,即yoochoose-click .dat和yoochoose-buy .dat。让我们快速浏览一下数据:
在这里插入图片描述
在这里插入图片描述

Preprocessing

下载数据之后,我们对其进行预处理,以便将其提供给我们的模型。item_id被分类编码,以确保编码后的item_id(稍后将映射到嵌入矩阵)从0开始。

from sklearn.preprocessing import LabelEncoder
import pandas as pd

df = pd.read_csv('../input/yoochoose-clicks.dat', header=None)
df.columns=['session_id','timestamp','item_id','category']

buy_df = pd.read_csv('../input/yoochoose-buys.dat', header=None)
buy_df.columns=['session_id','timestamp','item_id','price','quantity']

item_encoder = LabelEncoder()
df['item_id'] = item_encoder.fit_transform(df.item_id)
df.head()

在这里插入图片描述
由于数据相当大,为了更容易演示,我们对其进行了子采样。

#randomly sample a couple of them
sampled_session_id = np.random.choice(df.session_id.unique(), 1000000, replace=False)
df = df.loc[df.session_id.isin(sampled_session_id)]
df.nunique()

在这里插入图片描述
为了确定基本事实,即是否有任何购买事件的给定会话,我们只需检查是否在yoochoose-click .dat中的session_id也出现在yoochoose-buy .dat中。

df['label'] = df.session_id.isin(buy_df.session_id)
df.head()

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nJci56Pz-1638088147507)(https://miro.medium.com/max/422/1*A5jaCXd41plzf8Fvva3_tA.png#pic_center)]

Dataset Construction

预处理步骤完成后,数据就可以转换为Dataset对象了。在这里,我们将会话中的每个项视为节点,因此同一会话中的所有项形成一个图。为了构建数据集,我们通过session_id对预处理数据进行分组,并遍历这些组。在每次迭代中,对每个组中的item_id再次进行分类编码,因为对于每个图,节点索引应该从0开始计数。因此,我们有以下内容:

import torch
from torch_geometric.data import InMemoryDataset
from tqdm import tqdm

class YooChooseBinaryDataset(InMemoryDataset):
    def __init__(self, root, transform=None, pre_transform=None):
        super(YooChooseBinaryDataset, self).__init__(root, transform, pre_transform)
        self.data, self.slices = torch.load(self.processed_paths[0])

    @property
    def raw_file_names(self):
        return []
    @property
    def processed_file_names(self):
        return ['../input/yoochoose_click_binary_1M_sess.dataset']

    def download(self):
        pass
    
    def process(self):
        
        data_list = []

        # process by session_id
        grouped = df.groupby('session_id')
        for session_id, group in tqdm(grouped):
            sess_item_id = LabelEncoder().fit_transform(group.item_id)
            group = group.reset_index(drop=True)
            group['sess_item_id'] = sess_item_id
            node_features = group.loc[group.session_id==session_id,['sess_item_id','item_id']].sort_values('sess_item_id').item_id.drop_duplicates().values

            node_features = torch.LongTensor(node_features).unsqueeze(1)
            target_nodes = group.sess_item_id.values[1:]
            source_nodes = group.sess_item_id.values[:-1]

            edge_index = torch.tensor([source_nodes, target_nodes], dtype=torch.long)
            x = node_features

            y = torch.FloatTensor([group.label.values[0]])

            data = Data(x=x, edge_index=edge_index, y=y)
            data_list.append(data)
        
        data, slices = self.collate(data_list)
        torch.save((data, slices), self.processed_paths[0])

在构建数据集之后,我们调用shuffle()来确保它已经被随机洗牌,然后将它分成三个集进行训练、验证和测试。

dataset = dataset.shuffle()
train_dataset = dataset[:800000]
val_dataset = dataset[800000:900000]
test_dataset = dataset[900000:]
len(train_dataset), len(val_dataset), len(test_dataset)

Build a Graph Neural Network

下面的自定义GNN引用了PyG官方Github存储库中的一个示例。我将GraphConv层改为上面所示的自实现SAGEConv层。此外,输出层也被修改以匹配二进制分类设置。

embed_dim = 128
from torch_geometric.nn import TopKPooling
from torch_geometric.nn import global_mean_pool as gap, global_max_pool as gmp
import torch.nn.functional as F
class Net(torch.nn.Module):
    def __init__(self):
        super(Net, self).__init__()

        self.conv1 = SAGEConv(embed_dim, 128)
        self.pool1 = TopKPooling(128, ratio=0.8)
        self.conv2 = SAGEConv(128, 128)
        self.pool2 = TopKPooling(128, ratio=0.8)
        self.conv3 = SAGEConv(128, 128)
        self.pool3 = TopKPooling(128, ratio=0.8)
        self.item_embedding = torch.nn.Embedding(num_embeddings=df.item_id.max() +1, embedding_dim=embed_dim)
        self.lin1 = torch.nn.Linear(256, 128)
        self.lin2 = torch.nn.Linear(128, 64)
        self.lin3 = torch.nn.Linear(64, 1)
        self.bn1 = torch.nn.BatchNorm1d(128)
        self.bn2 = torch.nn.BatchNorm1d(64)
        self.act1 = torch.nn.ReLU()
        self.act2 = torch.nn.ReLU()        
  
    def forward(self, data):
        x, edge_index, batch = data.x, data.edge_index, data.batch
        x = self.item_embedding(x)
        x = x.squeeze(1)        

        x = F.relu(self.conv1(x, edge_index))

        x, edge_index, _, batch, _ = self.pool1(x, edge_index, None, batch)
        x1 = torch.cat([gmp(x, batch), gap(x, batch)], dim=1)

        x = F.relu(self.conv2(x, edge_index))
     
        x, edge_index, _, batch, _ = self.pool2(x, edge_index, None, batch)
        x2 = torch.cat([gmp(x, batch), gap(x, batch)], dim=1)

        x = F.relu(self.conv3(x, edge_index))

        x, edge_index, _, batch, _ = self.pool3(x, edge_index, None, batch)
        x3 = torch.cat([gmp(x, batch), gap(x, batch)], dim=1)

        x = x1 + x2 + x3

        x = self.lin1(x)
        x = self.act1(x)
        x = self.lin2(x)
        x = self.act2(x)      
        x = F.dropout(x, p=0.5, training=self.training)

        x = torch.sigmoid(self.lin3(x)).squeeze(1)

        return x

Training

训练我们的自定义GNN非常简单,我们简单地迭代从训练集构造的DataLoader,并反向传播损失函数。这里,我们使用Adam作为优化器,学习率设置为0.005,二进制交叉熵作为损失函数。

def train():
    model.train()

    loss_all = 0
    for data in train_loader:
        data = data.to(device)
        optimizer.zero_grad()
        output = model(data)
        label = data.y.to(device)
        loss = crit(output, label)
        loss.backward()
        loss_all += data.num_graphs * loss.item()
        optimizer.step()
    return loss_all / len(train_dataset)
    
device = torch.device('cuda')
model = Net().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.005)
crit = torch.nn.BCELoss()
train_loader = DataLoader(train_dataset, batch_size=batch_size)
for epoch in range(num_epochs):
    train()

Validation

这种标签与大量的负面标签高度不平衡,因为大多数会话之后都没有任何购买事件。换句话说,一个愚蠢的模型猜测所有的负面会给你超过90%的准确率。因此,曲线下面积(Area Under Curve, AUC)是这个任务更好的度量标准,因为它只关心正面示例的得分是否高于负面示例。我们使用Sklearn现成的AUC计算函数。

def evaluate(loader):
    model.eval()

    predictions = []
    labels = []

    with torch.no_grad():
        for data in loader:

            data = data.to(device)
            pred = model(data).detach().cpu().numpy()

            label = data.y.detach().cpu().numpy()
            predictions.append(pred)
            labels.append(label)

Result

我对模型进行了1个阶段的训练,并测量了训练、验证和测试AUC分数

for epoch in range(1):
    loss = train()
    train_acc = evaluate(train_loader)
    val_acc = evaluate(val_loader)    
    test_acc = evaluate(test_loader)
    print('Epoch: {:03d}, Loss: {:.5f}, Train Auc: {:.5f}, Val Auc: {:.5f}, Test Auc: {:.5f}'.
          format(epoch, loss, train_acc, val_acc, test_acc))

在这里插入图片描述
只有100万行训练数据(约占所有数据的10%)和1个训练历元,我们可以获得验证和测试集的AUC分数约为0.73。如果使用更多的数据和更大的训练步骤来训练模型,分数很可能会提高。

Conclusion

您已经学习了PyTorch几何的基本用法,包括数据集构造、自定义图层和使用真实数据训练gnn。这篇文章中的所有代码也可以在我的Github repo中找到,在那里你可以找到另一个Jupyter笔记本文件,在这个文件中我解决了2015年RecSys挑战赛的第二个任务。我希望你喜欢这篇文章。如果你有任何问题或意见,请在下方留言!一定要在twitter上关注我,我在那里分享我的博客文章或有趣的机器学习/深度学习新闻!GNN和PyG玩得开心吗

  • 1
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值