图神经网络/GNN(四)-节点预测与边预测任务实践

Task4概览

本次任务主要包括两大块,分别是数据完全存于内存的数据集类、节点/边任务预测的实践。其中前者可将占用内存有限的数据集全部存于内存中,方便实际的运算;同时,在定义好的数据集类的基础上,后者进行了节点预测以及边预测的任务实践。

一、数据完全存于内存的数据集类

对于占用内存有限的数据集,我们可以将整个数据集的数据都存储到内存里,这样在计算的过程中运算速度会更加快。

幸运的是,PyG为我们提供了方便的构造数据完全存储于内存的数据集类,简称为Inmemory数据集类,在此小节将学习构造Inmemory数据集类的方式。

此外,如果数据集比较大,不适合一次性存储于内存,一般不会直接加载到内存中,此种情况下在构建数据集的时候需要继承父类Dataset,本节不会涉及这一部分。

1.1 InMemoryDataset基类简介

我们可以通过继承PyG中的InMemoryDataset类来自定义一个数据可全部存储到内存的数据集类。

InMemoryDataset类初始化方法说明:

  • root:字符串类型,存储数据集的文件夹的路径。该文件夹下有两个文件夹:

  • 一个文件夹为记录在raw_dir,它用于存储未处理的文件,从网络上下载的数据集原始文件 会被存放到这里;

  • 另一个文件夹记录在processed_dir ,处理后的数据被保存到这里,以后从此文件夹下加载文件即可获得Data对象。

  • 注:raw_dir和processed_dir是属性方法,我们可以自定义要使用的文件夹。

  • transform:函数类型,一个数据转换函数,它接收一个Data对象并返回一个转换后的Data对象。此函数在每一次数据获取过程中都会被执行。获取数据的函数首先使用此函数对Data对象做转换,然后才返回数据。此函数应该用于数据增广(Data Augmentation)。该参数默认值为None,表示不对数据做转换。

  • pre_transform:函数类型,一个数据转换函数,它接收一个Data对象并返回一个转换后的Data对象。此函数在Data对象被保存到文件前调用。因此它应该用于只执行一次的数据预处理。该参数默认值为None,表示不做数据预处理。

  • pre_filter:函数类型,一个检查数据是否要保留的函数,它接收一个Data对象,返回此Data对象是否应该被包含在最终的数据集中。此函数也在Data对象被保存到文件前调用。该参数默认值为None,表示不做数据检查,保留所有的数据。

参数 作用
root 存储数据的路径,raw_dir和processed_dir
transform 数据转换函数——用于数据增广(每次都会执行)
pre_transform 数据转换函数——用于数据预处理(保存到文件前调用,仅执行一次)
pre_filter 数据过滤(保存到文件前调用)

通过继承InMemoryDataset类来构造一个我们自己的数据集类,我们需要实现四个基本方法

方法 作用
raw_file_names() 返回一个数据集原始文件的文件名列表,应该能在raw_dir文件夹中找到,否则调用download()函数下载文件到raw_dir文件夹
processed_file_names() 返回一个存储处理过的数据的文件的文件名列表,存储处理过的数据的文件应该能在processed_dir文件夹中找到,否则调用process()函数对样本做处理,然后保存处理过的数据到processed_dir文件夹下的文件
download() 下载数据集原始文件到raw_dir文件夹
process() 处理数据,保存处理好的数据到processed_dir文件夹下的文件

1.2 InMemoryDataset数据集类实例

本小节以公开数据集PubMed为例子,进行InMemoryDataset数据集实例分析。

PubMed 数据集存储的是文章引用网络,文章对应图的结点,如果两篇文章存在引用关系(无论引用与被引),则这两篇文章对应的结点之间存在边。

PyG中的Planetoid数据集类包含了数据集PubMed的使用,因此我们直接基于Planetoid类进行修改,得到PlanetoidPubMed数据集类。

1.2.1 PlanetoidPubMed数据集类的构造

PlanetoidPubMed数据集类如下所示:

import os.path as osp

import torch
from torch_geometric.data import (InMemoryDataset, download_url)
from torch_geometric.io import read_planetoid_data

class PlanetoidPubMed(InMemoryDataset):
    r""" 节点代表文章,边代表引文关系。
         训练、验证和测试的划分通过二进制掩码给出。
    参数:
        root (string): 存储数据集的文件夹的路径
        transform (callable, optional): 数据转换函数,每一次获取数据时被调用。
        pre_transform (callable, optional): 数据转换函数,数据保存到文件前被调用。
    """

    url = 'https://github.com/kimiyoung/planetoid/raw/master/data'

    def __init__(self, root, transform=None, pre_transform=None):

        super(PlanetoidPubMed, self).__init__(root, transform, pre_transform)
        self.data, self.slices = torch.load(self.processed_paths[0])

    @property
    def raw_dir(self):
        return osp.join(self.root, 'raw')

    @property
    def processed_dir(self):
        return osp.join(self.root, 'processed')

    @property
    def raw_file_names(self):
        names = ['x', 'tx', 'allx', 'y', 'ty', 'ally', 'graph', 'test.index']
        return ['ind.pubmed.{}'.format(name) for name in names]

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

    def download(self):
        for name in self.raw_file_names:
            download_url('{}/{}'.format(self.url, name), self.raw_dir)

    def process(self):
        data = read_planetoid_data(self.raw_dir, 'pubmed')
        data = data if self.pre_transform is None else self.pre_transform(data)
        torch.save(self.collate([data]), self.processed_paths[0])

    def __repr__(self):
        return '{}()'.format(self.name)

除了1.1节中提及的方法外,代码中还实现了raw_dir()processed_dir()两个属性方法,通过修改返回值我们就可以修改要使用的文件夹。

1.2.2 新建数据集类的使用

在我们生成一个PlanetoidPubMed类的对象时,程序运行流程 如下:

  • 首先,检查数据原始文件是否已下载

    - 检查self.raw_dir目录下是否存在raw_file_names()属性方法返回的每个文件,

    - 如有文件不存在,则调用download()方法执行原始文件下载。

    - self.raw_dirosp.join(self.root, 'raw')

  • 其次,检查数据是否经过处理

    - 首先,检查之前对数据做变换的方法 :检查self.processed_dir目录下是否存在pre_transform.pt文件:

        - 如果存在,意味着之前进行过数据变换,接着需要加载该文件,以获取之前所用的数据变换的方法,并检查它与当前pre_transform参数指定的方法是否相同,

            - 如果不相同则会报出一个警告,“The pre_transform argument differs from the one used in ……”。

        - self.processed_dirosp.join(self.root, 'processed')

    - 其次,检查之前的样本过滤的方法 :检查self.processed_dir目录下是否存在pre_filter.pt文件:

        - 如果存在,则加载该文件并获取之前所用的样本过滤的方法,并检查它与当前pre_filter参数指定的方法是否相同,

            - 如果不相同则会报出一个警告,“The pre_filter argument differs from the one used in ……”。

    - 接着,检查是否存在处理好的数据 :检查self.processed_dir目录下是否存在self.processed_file_names属性方法返回的所有文件,如有文件不存在,则需要执行以下的操作:

        - 调用process()方法,进行数据处理。

        - 如果pre_transform参数不为None,则调用pre_transform()函数进行数据处理。

        - 如果pre_filter参数不为None,则进行样本过滤(此例子中不需要进行样本过滤,pre_filter参数为None)。

        - 保存处理好的数据到文件,文件存储在**processed_paths()**属性方法返回的文件路径。如果将数据保存到多个文件中,则返回的路径有多个。

            - processed_paths() 属性方法是在基类中定义的 ,它对self.processed_dir文件夹与processed_file_names()属性方法的返回每一个文件名做拼接,然后返回。

        - 最后保存新的pre_transform.pt文件和pre_filter.pt文件,它们分别存储当前使用的数据处理方法和样本过滤方法。

最后让我们来查看这个数据集

dataset = PlanetoidPubMed('Dataset【Nutstore】/PlanetoidPubMed')
print(dataset.num_classes)
print(dataset[0].num_nodes)
print(dataset[0].num_edges)
print(dataset[0].num_features)

# 3
# 19717
# 88648
# 500

可以看到这个数据集包含三个分类任务,共19,717个结点,88,648条边,节点特征维度为500。

二、节点预测任务实践(自定义Pipline流程)

本节将通过第一节内容构建的数据完整存储于内存的数据集类(PlanetoidPubMed数据集类),同时建立一个多层的图神经网络,来实现节点预测任务。

2.1 构建多层GNN网络

在之前的任务中,我们了解了几种常用的图卷积层模型,包括GCN、GAT、SAGE等等。

本小节使用GAT图卷积层来构建多层GNN网络。

由于神经网络是由多个GATConv顺序相连而构成,为了方便定义GAT神经网络,使其能够通过参数来定义GATConv的层数,以及每一层GATConv的out_channels。我们使用torch_geometric.nn.Sequential容器,具体操作可以官方文档。

构建的GAT图神经网络代码如下:

class GAT(torch.nn.Module):
    def __init__(self, num_features, hidden_channels_list, num_classes):
        super(GAT, self).__init__()
        torch.manual_seed(12345)
        hns = [num_features] + hidden_channels_list
        conv_list = []
        for idx in range(len(hidden_channels_list)):
            conv_list.append((GATConv(hns[idx], hns[idx+1]), 'x, edge_index -> x'))
            conv_list.append(ReLU(inplace=True),)

        self.convseq = Sequential('x, edge_index', conv_list)
        self.linear = Linear(hidden_channels_list[-1], num_classes)

    def forward(self, x, edge_index):
        x = self.convseq(x, edge_index)
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.linear(x)
        return x

此网络由多个GATConv层组成,通过hidden_channels_list参数来设置每一层GATConv的out_channel,同时hidden_channels_list的长度即为GATConv的层数。

2.2 定义训练、测试函数

# 定义训练、测试函数
def train():
    model.train()
    optimizer.zero_grad()  # Clear gradients.
    out = model(data.x, data.edge_index)  # Perform a single forward pass.
    # Compute the loss solely based on the training nodes.
    loss = criterion(out[data.train_mask], data.y[data.train_mask])
    loss.backward()  # Derive gradients.
    pred = out.argmax(dim=1)
    correct = float(pred[data.train_mask].eq(data.y[data.train_mask]).sum().item())
    acc = correct / data.train_mask.sum().item()
    optimizer.step()  # Update parameters based on gradients.
    return loss.cpu().detach().data.item(), acc

def test():
    model.eval()
    out = model(data.x, data.edge_index)
    pred = out.argmax(dim=1)  # Use the class with highest probability.
    test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
    test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
    return test_acc

2.3 定义生成结果函数

# 定义生成结果的函数
def result(train, test, epoch=101, per=5):
    train_loss = []
    train_acc = [];test_acc=[]
    for i in range(1, epoch):
        loss, acc = train()
        train_loss.append(loss)
        train_acc.append(acc)
        if i % per == 0:
            print('[Epoch {}/{}] Loss {:.4f}, train acc {:.4f}'.format(i, epoch-1, loss, acc))
            acc = test()
            test_acc.append(acc)
            print('Accuracy: {:.4f}'.format(acc))
        
    return (train_loss, train_acc, test_acc)

2.4 主程序运行

# 输入模型相关参数
hidden_channels_list = [200, 100]
model = GAT(num_features=dataset.num_features, hidden_channels_list=hidden_channels_list, num_classes=dataset.num_classes).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

# 导入数据,并使用GPU加速
dataset = PlanetoidPubMed(root='...', transform=NormalizeFeatures())
print('dataset.num_features:', dataset.num_features)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
data = dataset[0].to(device)

# 生成迭代运行结果
(train_loss, train_acc, test_acc) = result(train, test, epoch=15, per=3)

# 画出结果图像
plt.figure(figsize=(18,4))
plt.subplot(1,3,1);plt.plot(train_loss);plt.title('loss')
plt.subplot(1,3,2);plt.plot(train_acc);plt.title('train acc')
plt.subplot(1,3,3);plt.plot(test_acc);plt.title('test acc')                    
plt.show()

2.5 【作业】节点分类任务实验对比

2.5.1 不同卷积层实验结果对比

我们使用了GAT、GCN、FeaSt三种卷积层进行对比实验,隐藏层为双层[100, 50],其在训练集与测试集的结果如下所示:

图神经网络/GNN(四)-节点预测与边预测任务实践_第1张图片

从训练过程的Loss可以看出,GAT与GCN有着相似的下降情况,FeaSt收敛得比较慢。

从测试集的效果可以看出,三种卷积层的性能相似。

2.5.2 不同层数对比

为了探究不同隐藏层数量对于节点分类任务的影响,本小节在三种不同卷积层(GAT、GCN、FeaSt)上进行了不同卷积层数目(2-5层)的实验,实验结果如下:

图神经网络/GNN(四)-节点预测与边预测任务实践_第2张图片

从总体变化趋势上可以看出,卷积层数目越多,节点分类效果反而越差,两层隐藏层是效果最好。

其中,当隐藏层为5时,在GAT卷积网络上发生了异常数值的情况,具体原因尚在分析中。

因此,在实际使用隐藏层时,不用为了实验效果而刻意追求多加隐藏层的数量。

三、边预测任务实践

边预测任务是另一类常见的图神经网络任务,目标是预测两个节点之间是否存在边。

一般的数据集中edge_index中存储的是有连接关系的节点对,可以称之为正样本。为了构建边预测任务,需要生成一些负样本,即采样一些不存在边的节点对作为负样本边,保持正负样本的平衡。

此外要将样本分为训练集、验证集和测试集三个集合。

3.1 获取数据集并分析

import os.path as osp
from torch_geometric.utils import negative_sampling
from torch_geometric.datasets import Planetoid
import torch_geometric.transforms as T
from torch_geometric.utils import train_test_split_edges
from sklearn.metrics import roc_auc_score

dataset = Planetoid(root = 'E:\Dataset', name = 'Cora', transform=T.NormalizeFeatures())
data = dataset[0]
data.train_mask = data.val_mask = data.test_mask = data.y = None # 不再有用

print(data.edge_index.shape)
# torch.Size([2, 10556])

data = train_test_split_edges(data)

for key in data.keys:
    print(key, getattr(data, key).shape)

# x torch.Size([2708, 1433])
# val_pos_edge_index torch.Size([2, 263])
# test_pos_edge_index torch.Size([2, 527])
# train_pos_edge_index torch.Size([2, 8976])
# train_neg_adj_mask torch.Size([2708, 2708])
# val_neg_edge_index torch.Size([2, 263])
# test_neg_edge_index torch.Size([2, 527])

我们观察到训练集、验证集和测试集中正样本边的数量之和不等于原始边的数量。这是因为,现在所用的Cora图是无向图,在统计原始边数量时,每一条边的正向与反向各统计了一次,训练集也包含边的正向与反向,但验证集与测试集都只包含了边的一个方向。

为什么训练集要包含边的正向与反向,而验证集与测试集都只包含了边的一个方向?

这是因为,训练集用于训练,训练时一条边的两个端点要互传信息,只考虑一个方向的话,只能由一个端点传信息给另一个端点,而验证集与测试集的边用于衡量检验边预测的准确性,只需考虑一个方向的边即可。

3.2 构建边预测图神经网络

import torch
from torch_geometric.nn import GCNConv

class Net(torch.nn.Module):
    def __init__(self, in_channels, out_channels):
        super(Net, self).__init__()
        self.conv1 = GCNConv(in_channels, 128)
        self.conv2 = GCNConv(128, out_channels)

    def encode(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        return self.conv2(x, edge_index)

    def decode(self, z, pos_edge_index, neg_edge_index):
        edge_index = torch.cat([pos_edge_index, neg_edge_index], dim=-1)
        return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=-1)

    def decode_all(self, z):
        prob_adj = z @ z.t()
        return (prob_adj > 0).nonzero(as_tuple=False).t()

用于做边预测的神经网络主要由两部分组成:其一是编码(encode),它与我们前面介绍的节点表征生成是一样的;其二是解码(decode),它根据边两端节点的表征生成边为真的几率(odds)。
可以看到,该神经网络编码部分含两个卷积层,一个ReLU层;解码部分首先将正负样本拼接,再对output_channel的计算结果进行加和求解。

3.3 定义训练、测试函数

def get_link_labels(pos_edge_index, neg_edge_index):
    num_links = pos_edge_index.size(1) + neg_edge_index.size(1)
#     ic(pos_edge_index.size(1))
    link_labels = torch.zeros(num_links, dtype=torch.float)
    link_labels[:pos_edge_index.size(1)] = 1.
#     ic(link_labels.shape)
    return link_labels

def train(data, model, optimizer):
    model.train()

    neg_edge_index = negative_sampling(
        edge_index=data.train_pos_edge_index,
        num_nodes=data.num_nodes,
        num_neg_samples=data.train_pos_edge_index.size(1))

    optimizer.zero_grad()
    z = model.encode(data.x, data.train_pos_edge_index)
    link_logits = model.decode(z, data.train_pos_edge_index, neg_edge_index)
    link_labels = get_link_labels(data.train_pos_edge_index, neg_edge_index).to(data.x.device)
    loss = F.binary_cross_entropy_with_logits(link_logits, link_labels)
    loss.backward()
    optimizer.step()

    return loss


@torch.no_grad()
def test(data, model):
    model.eval()

    z = model.encode(data.x, data.train_pos_edge_index)

    results = []
    for prefix in ['val', 'test']:
        pos_edge_index = data[f'{prefix}_pos_edge_index']
        neg_edge_index = data[f'{prefix}_neg_edge_index']
        link_logits = model.decode(z, pos_edge_index, neg_edge_index)
        link_probs = link_logits.sigmoid()
        link_labels = get_link_labels(pos_edge_index, neg_edge_index)
        results.append(roc_auc_score(link_labels.cpu(), link_probs.cpu()))
    return results

注:

  1. 在训练阶段,我们应该只见训练集,对验证集与测试集都是不可见的。所以我们没有使用所有的边,而是只用了训练集正样本边。

  2. 在验证与测试阶段,我们也应该只见训练集,对验证集与测试集都是不可见的。所以在验证与测试阶段,我们依然只用训练集正样本边。

3.4 主程序运行

def main(epoch, per_step_show):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    dataset = 'Cora'
    path = osp.join(osp.dirname(osp.realpath('E:')), 'Dataset', dataset)
    dataset = Planetoid(path, dataset, transform=T.NormalizeFeatures())
    data = dataset[0]
    ground_truth_edge_index = data.edge_index.to(device)
    data.train_mask = data.val_mask = data.test_mask = data.y = None
    data = train_test_split_edges(data)
    data = data.to(device)

    hidden_channels_list2 = [200,100]
    model = Net(dataset.num_features, 64).to(device)
    optimizer = torch.optim.Adam(params=model.parameters(), lr=0.01)

    best_val_auc = test_auc = 0
    train_loss = []; val_list = []; tmp_test_list = []; test_list = []   # 新建空列表
    
    
    for i in range(1, epoch):
        loss = train(data, model, optimizer)
        val_auc, tmp_test_auc = test2(data, model)
        if val_auc > best_val_auc:
            best_val_auc = val_auc
            test_auc = tmp_test_auc
        train_loss.append(loss); val_list.append(val_auc)
        tmp_test_list.append(tmp_test_auc); test_list.append(test_auc)
        
        if i % per_step_show == 0:
            print(f'[Epoch: {i:03d}/{epoch}], Loss: {loss:.4f}, Val: {val_auc:.4f}, '
                  f'Test: {test_auc:.4f}')

#             print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Val: {val_auc:.4f}, '
#                   f'Test: {test_auc:.4f}')

    z = model.encode(data.x, data.train_pos_edge_index)
    final_edge_index = model.decode_all(z)

if __name__ == "__main__":
    main(epoch=21, per_step_show=3)

3.5 【作业】使用Sequential容器构造图神经网络

import torch
from torch_geometric.nn import GCNConv

class Net2(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels_list, out_channels):
        super(Net2, self).__init__()
        hns = [in_channels] + hidden_channels_list
        conv_list = []
    
        for idx in range(len(hidden_channels_list)):         
            conv_list.append((GCNConv(hns[idx], hns[idx+1]), 'x, edge_index -> x'))
            conv_list.append(ReLU(inplace=True),)
        
#         ic(conv_list)
        self.convseq = Sequential('x, edge_index', conv_list)
        self.linear = Linear(hidden_channels_list[-1], out_channels)
        

    def encode(self, x, edge_index):
        x = self.convseq(x, edge_index)
        x = self.linear(x)
        return x

    def decode(self, z, pos_edge_index, neg_edge_index):
        edge_index = torch.cat([pos_edge_index, neg_edge_index], dim=-1)
        return (z[edge_index[0]] * z[edge_index[1]]).sum(dim=-1)

    def decode_all(self, z):
        prob_adj = z @ z.t()
        return (prob_adj > 0).nonzero(as_tuple=False).t()

3.6 【作业】负采样边是否会影响最终的学习效果

假设节点有n个,节点与节点间的关系可以存在n的平方种。

以Cora数据集举例,共有2708个节点,假设每个节点都与其他所有节点相连(包括本身),那么共存在2708*2708=7333264条边,而真实相连的边为8976条。

所以负样本是从其余的七百多万条边中去采样,因此即使采样到的负样本是验证集或测试集中的正样本,那么这种巧合的事情发生概率非常低,即使采集到了正样本,也可以作为噪声样本来加强模型的鲁棒性,甚至可能会提升模型的整体泛化能力。

Task4学习心得

本次任务的代码比较多,逐行理解起来花费了很多时间。恰逢前几天来到新的实验室,各种事务缠身,又不想随便凑合完成作业,导致完成作业的进度被拖后,太难了。。。

这次的内容还是比较有干货的,小数据集可以实现完全存储于内存,同时也完整进行了节点和边的预测任务。最重要收获的是大概熟悉了这些基本任务的流程,建立了一套自己的Pipline。
希望后面的任务能继续坚持下去,加油~

参考资料:

  1. Datawhale组队学习【图神经网络】
  2. InMemoryDataset
  3. https://github.com/chenjiyan2001/Datawhale-TeamLearning-StudyNotes/blob/main/2021/06/GNN/
  4. torch_geometric.nn.Sequential容器

你可能感兴趣的:(深度学习,人工智能,大数据,神经网络)