学习心得

(1)这次学习首先是了解​​InMemoryDataset​​​基类和继承该基类的​​InMemory​​​数据集类及其实例运行过程,注意子类​​InMemory​​​的四个方法的使用:
​​​raw_file_names()​​​:这是一个属性方法,返回一个数据集原始文件的文件名列表
​​​processed_file_names()​​​。这是一个属性方法,返回一个存储处理过的数据的文件的文件名列表
​​​download()​​​: 下载数据集原始文件到raw_dir文件夹。
​​​process():​​ 处理数据,保存处理好的数据到processed_dir文件夹下的文件。

——因为需要阅读并创建Data对象列表并将其保存到​​processed_dir​​​中,又保存庞大的python列表很慢,所以我们需要在保存前将列表整理为一个庞大的​​Data​​对象。

(2)对于节点预测(分类)是用到(1)中的​​PlanetoidPubMed​​类实例化获得对应的数据;节点的预测——在节点预测任务中,我们拥有一个图,图上有很多节点,部分节点的预测标签已知,部分节点的预测标签未知。我们的任务是根据节点的属性(可以是类别型、也可以是数值型)、边的信息、边的属性(如果有的话)、已知的节点预测标签,对未知标签的节点做预测。

(3)很多推荐系统是基于链路预测(边预测)的,知识图谱补全中对实体关系的预测也是一个链路预测问题。在边预测中,为了构建边预测任务,我们需要生成一些负样本,即采样一些不存在边的节点对作为负样本边,正负样本数量应平衡

学习了一个​​Sequential​​​序列容器,能够批量生成多个层,然后让模型按照顺序进行构建模型.
注意边预测任务中的数据集划分(划分为训练集、验证集和测试集)以及训练、验证与测试三个阶段使用的边

(4)还是需要要多看源码,如PyG的源码​​Data​​​类中​​test_mask​​等属性用法等,多在代码上写注释和理解。

文章目录

第一部分:数据完全存于内存的数据集类

1.引言

对于占用内存有限的数据集,我们可以将整个数据集的数据都存储到内存里。PyG为我们提供了方便的方式来构造数据完全存于内存的数据集类(简称为​​InMemory​​​数据集类)。在此小节我们就将学习构造​​InMemory​​数据集类的方式。

内容安排如下:

  • 首先,我们将学习PyG规定的使用数据的一般过程;
  • 其次,我们将学习​​InMemoryDataset​​基类;
  • 接着,我们将学习一个简化的​​InMemory​​数据集类;
  • 最后,我们将学习一个​​InMemory​​数据集类实例,以及使用该数据集类时会发生的一些过程。

2.使用数据集的一般过程

PyG定义了使用数据的一般过程

  1. 从网络上下载数据原始文件;
  2. 对数据原始文件做处理,为每一个图样本生成一个​​Data​​对象;
  3. 对每一个​​Data​​对象执行数据处理,使其转换成新的​​Data​​对象;
  4. 过滤Data对象
  5. 保存Data对象到文件
  6. 获取​​Data​​​对象,在每一次获取​​Data​​​对象时,都先对​​Data​​​对象做数据变换(于是获取到的是数据变换后的​​Data​​对象)。

实际中并非需要严格执行每一个步骤,

以上步骤在特定的条件下可以被跳过,具体内容在下文里会学到。

3.InMemoryDataset基类简介

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

class InMemoryDataset(root: Optional[str] = None, transform: Optional[Callable] = None, pre_transform: Optional[Callable] = None, pre_filter: Optional[Callable] = None)

​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​​,表示不做数据检查,保留所有的数据。

四个重要方法

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

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

4.一个简化的InMemory数据集类

通过继承InMemoryDataset类来构造一个我们自己的数据集类
以下是一个简化的自定义的数据集类的例子:

import torch
from torch_geometric.data import InMemoryDataset, download_url

class MyOwnDataset(InMemoryDataset):
def __init__(self, root, transform=None, pre_transform=None, pre_filter=None):
super().__init__(root=root, transform=transform, pre_transform=pre_transform, pre_filter=pre_filter)
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`.
download_url(url, self.raw_dir)
...

# 实现数据处理的逻辑
def process(self):
# Read data into huge `Data` list.
# 从数据集原始文件中读取样本并生成Data对象,所有样本的Data对象保存在列表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对象合并成一个大Data对象
data, slices = self.collate(data_list)
torch.save((data, slices), self.processed_paths[0])
  • 在​​raw_file_names​​​属性方法里,也就是第11行,写上数据集原始文件有哪些,在此例子中有​​some_file_1​​​,​​some_file_2​​等。
  • 在​​processed_file_names​​​属性方法里,也就是第15行,处理过的数据要保存在哪些文件里,在此例子中只有​​data.pt​​。
  • 在​​download​​​方法里,我们实现下载数据到​​self.raw_dir​​文件夹的逻辑。
  • 在​​process​​方法里,我们实现数据处理的逻辑:
  • 首先,我们从数据集原始文件中读取样本并生成​​Data​​​对象,所有样本的​​Data​​​对象保存在列表​​data_list​​中。
  • 其次,如果要对数据做过滤的话,我们执行数据过滤的过程。
  • 接着,如果要对数据做处理的话,我们执行数据处理的过程。
  • 最后,我们保存处理好的数据到文件。但由于python保存一个巨大的列表是相当慢的,我们需要先将所有​​Data​​​对象合并成一个巨大的​​Data​​​对象再保存。
    ​​​collate()​函数接收一个列表的Data对象,返回合并后的​Data​对象以及用于从合并后的​Data​对象重构各个原始​Data​对象的切片字典​slices​
    最后我们将这个巨大的​​​Data​​​对象和切片字典​​slices​​保存到文件。

5.InMemoryDataset数据集类实例

我们以公开数据集​​PubMed​​​为例子,进行​​InMemoryDataset​​​数据集实例分析。​​PubMed​​​数据集存储的是文章引用网络,文章对应图的结点,如果两篇文章存在引用关系(无论引用与被引用),则这两篇文章对应的结点之间存在边。该数据集来源于论文​​Revisiting Semi-Supervised Learning with Graph Embeddings​​。

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

我们将首先学习​​PlanetoidPubMed​​​数据集类的构造,其次学习使用​​PlanetoidPubMed​​数据集类时会发生的过程。

(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'
# url = 'https://gitee.com/rongqinchen/planetoid/raw/master/data'
# 如果github的链接不可用,请使用gitee的链接

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)

该类初始化方法的参数说明见代码。代码中还实现了​​raw_dir()​​​和​​processed_dir()​​两个属性方法,通过修改返回值,我们就可以修改要使用的文件夹。

(2)该数据集类的使用and运行流程

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

  • 首先,检查数据原始文件是否已下载
  • 检查​​self.raw_dir​​​目录下是否存在​​raw_file_names()​​属性方法返回的每个文件,
  • 如有文件不存在,则调用​​download()​​方法执行原始文件下载。
  • ​self.raw_dir​​​为​​osp.join(self.root, 'raw')​​。
  • 其次,检查数据是否经过处理
  • 首先,检查之前对数据做变换的方法:检查​​self.processed_dir​​​目录下是否存在​​pre_transform.pt​​文件:
  • 如果存在,意味着之前进行过数据变换,接着需要加载该文件,以获取之前所用的数据变换的方法,并检查它与当前​​pre_transform​​参数指定的方法是否相同,
  • 如果不相同则会报出一个警告,“The pre_transform argument differs from the one used in ……”。
  • ​self.processed_dir​​​为​​osp.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/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。

参考资料

第二部分:节点预测与边预测任务实践

引言

在此小节我们将利用在上一小节​​6-1-数据完整存于内存的数据集类​​​中构造的​​PlanetoidPubMed​​数据集类,来实践节点预测与边预测任务。

注:边预测任务实践中的代码来源于​​link_pred.py​​。

一、节点预测任务实践

之前我们学习过由2层​​GATConv​​​组成的图神经网络,现在我们重定义一个GAT图神经网络,使其能够通过参数来定义​​GATConv​​​的层数,以及每一层​​GATConv​​​的​​out_channels​​。我们的图神经网络定义如下:

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

由于我们的神经网络由多个GATConv顺序相连而构成,因此我们使用了​torch_geometric.nn.Sequential​容器,详细内容可见于​​官方文档​​。

我们通过​​hidden_channels_list​​​参数来设置每一层​​GATConv​​​的​​outchannel​​​,所以​​hidden_channels_list​​​长度即为​​GATConv​​​的层数。
通过修改​​​hidden_channels_list​​,我们就可构造出不同的图神经网络。

完整代码

​codes/node_classification.py​​(代码里面url即链接):

import os.path as osp

import torch
import torch.nn.functional as F
from torch_geometric.data import (InMemoryDataset, download_url)
from torch_geometric.nn import GATConv, Sequential
from torch_geometric.transforms import NormalizeFeatures
from torch_geometric.io import read_planetoid_data
from torch.nn import Linear, ReLU

class PlanetoidPubMed(InMemoryDataset):
r"""The citation network datasets "PubMed" from the
`"Revisiting Semi-Supervised Learning with Graph Embeddings"
<https://arxiv.org/abs/1603.08861>`_ paper.
Nodes represent documents and edges represent citation links.
Training, validation and test splits are given by binary masks.

Args:
root (string): Root directory where the dataset should be saved.
split (string): The type of dataset split
(:obj:`"public"`, :obj:`"full"`, :obj:`"random"`).
If set to :obj:`"public"`, the split will be the public fixed split
from the
`"Revisiting Semi-Supervised Learning with Graph Embeddings"
<https://arxiv.org/abs/1603.08861>`_ paper.
If set to :obj:`"full"`, all nodes except those in the validation
and test sets will be used for training (as in the
`"FastGCN: Fast Learning with Graph Convolutional Networks via
Importance Sampling" <https://arxiv.org/abs/1801.10247>`_ paper).
If set to :obj:`"random"`, train, validation, and test sets will be
randomly generated, according to :obj:`num_train_per_class`,
:obj:`num_val` and :obj:`num_test`. (default: :obj:`"public"`)
num_train_per_class (int, optional): The number of training samples
per class in case of :obj:`"random"` split. (default: :obj:`20`)
num_val (int, optional): The number of validation samples in case of
:obj:`"random"` split. (default: :obj:`500`)
num_test (int, optional): The number of test samples in case of
:obj:`"random"` split. (default: :obj:`1000`)
transform (callable, optional): A function/transform that takes in an
:obj:`torch_geometric.data.Data` object and returns a transformed
version. The data object will be transformed before every access.
(default: :obj:`None`)
pre_transform (callable, optional): A function/transform that takes in
an :obj:`torch_geometric.data.Data` object and returns a
transformed version. The data object will be transformed before
being saved to disk. (default: :obj:`None`)
"""

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

def __init__(self, root, split="public", num_train_per_class=20,
num_val=500, num_test=1000, transform=None,
pre_transform=None):

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

self.split = split
assert self.split in ['public', 'full', 'random']

if split == 'full':
data = self.get(0)
data.train_mask.fill_(True)
data.train_mask[data.val_mask | data.test_mask] = False
self.data, self.slices = self.collate([data])

elif split == 'random':
data = self.get(0)
data.train_mask.fill_(False)
for c in range(self.num_classes):
idx = (data.y == c).nonzero(as_tuple=False).view(-1)
idx = idx[torch.randperm(idx.size(0))[:num_train_per_class]]
data.train_mask[idx] = True

remaining = (~data.train_mask).nonzero(as_tuple=False).view(-1)
remaining = remaining[torch.randperm(remaining.size(0))]

data.val_mask.fill_(False)
data.val_mask[remaining[:num_val]] = True

data.test_mask.fill_(False)
data.test_mask[remaining[num_val:num_val + num_test]] = True

self.data, self.slices = self.collate([data])

@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)


dataset = PlanetoidPubMed(root='data/PlanetoidPubMed/', 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)

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.
optimizer.step() # Update parameters based on gradients.
return loss

# 在验证集上验证模型的准确率
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


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

model = GAT(num_features=dataset.num_features, hidden_channels_list=[200, 100], num_classes=dataset.num_classes).to(device)
print(model)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

#for epoch in range(1, 201):
# loss = train()
# print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')

###############
import pandas as pd
df = pd.DataFrame(columns = ["Loss"])
df.index.name = "Epoch"
for epoch in range(1, 201):
loss =train()
df.loc[epoch] = loss.item()
df.plot()

# 或者:
# 绘制损失函数曲线
losslist = []
for epoch in range(1, 201):
loss = train()
losslist.append(float(loss))
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
x = range(len(losslist))
plt.plot(x,losslist)
plt.legend(["loss"])
plt.title('loss curve')
##########

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

输出准确率为:

Test Accuracy: 0.7520

loss曲线变化图,可见损失函数收敛得很快。

【GNN】task4-数据完整存储与内存的数据集类+节点预测与边预测任务实践_数据

附:Sequential容器使用

CLASSSequential(args: str, modules: List[Union[Tuple[Callable, str], Callable]])[source]

An extension of the torch.nn.Sequential container in order to define a sequential GNN model. Since GNN operators take in multiple input arguments, torch_geometric.nn.Sequential expects both global input arguments, and function header definitions of individual operators. If omitted, an intermediate module will operate on the output of its preceding module:

from torch.nn import Linear, ReLU
from torch_geometric.nn import Sequential, GCNConv

model = Sequential('x, edge_index', [
(GCNConv(in_channels, 64), 'x, edge_index -> x'),
ReLU(inplace=True),
(GCNConv(64, 64), 'x, edge_index -> x'),
ReLU(inplace=True),
Linear(64, out_channels),
])

where ​​x, edge_index​​​ defines the input arguments of model, and ​​x, edge_index -> x​​ defines the function header, i.e. input arguments and return types, of GCNConv.

还可以使用​​JumpingKnowledge​​​等实现更复杂模型,​​JumpingKnowledge​​于论文《Representation Learning on Graphs with Jumping Knowledge Networks》中提出:虽然图卷积神经网络的计算方式能够适应不同结构的 graph,但是其固定的层级结构以及聚合邻居节点的信息传播方式会给不同邻域结构的节点表达带来比较大的偏差。在指出这个问题的同时,作者也提出自己的解决方法——jumping knowledge (JK) networks。

from torch.nn import Linear, ReLU, Dropout
from torch_geometric.nn import Sequential, GCNConv, JumpingKnowledge
from torch_geometric.nn import global_mean_pool

model = Sequential('x, edge_index, batch', [
(Dropout(p=0.5), 'x -> x'),
(GCNConv(dataset.num_features, 64), 'x, edge_index -> x1'),
ReLU(inplace=True),
(GCNConv(64, 64), 'x1, edge_index -> x2'),
ReLU(inplace=True),
(lambda x1, x2: [x1, x2], 'x1, x2 -> xs'),
(JumpingKnowledge("cat", 64, num_layers=2), 'xs -> x'),
(global_mean_pool, 'x, batch -> x'),
Linear(2 * 64, dataset.num_classes),
])

关于该类的更多介绍见​​官网​​。

二、边预测任务实践

边预测任务,目标是预测两个节点之间是否存在边。拿到一个图数据集,我们有节点属性​​x​​​,边端点​​edge_index​​​。​​edge_index​​​存储的便是正样本。
为了构建边预测任务,我们需要生成一些负样本,即采样一些不存在边的节点对作为负样本边,正负样本数量应平衡。此外要将样本分为训练集、验证集和测试集三个集合。

0.train_test_split_edges进行负样本采用

PyG中为我们提供了现成的采样负样本边的方法,​​train_test_split_edges(data, val_ratio=0.05, test_ratio=0.1)​​​,其参数分别为:
​​​torch_geometric.data.Data​​​对象;验证集所占比例(default: 0.05);测试集所占比例 (default: 0.1)。
该函数将自动地采样得到负样本,并将正负样本分成训练集、验证集和测试集三个集合。它用​​​train_pos_edge_index​​​、​​train_neg_adj_mask​​​、​​val_pos_edge_index​​​、​​val_neg_edge_index​​​、​​test_pos_edge_index​​​和​​test_neg_edge_index​​​,六个属性取代​​edge_index​​属性。

train_test_split_edges(data, val_ratio: float = 0.05, test_ratio: float = 0.1)

(如果有边属性、边特征)If data has edge features named edge_attr, then train_pos_edge_attr, val_pos_edge_attr and test_pos_edge_attr= will be added as well.

Return type(函数返回值):torch_geometric.data.Data
注意​​​train_neg_adj_mask​​与其他属性格式不同,其实该属性在后面并没有派上用场,后面我们仍然需要进行一次训练集负样本采样。

1.获取数据集并进行分析

下面我们使用​​Cora​​​数据集作为例子,进行边预测任务说明。
首先是获取数据集并进行分析

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

dataset = Planetoid('dataset', '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])
# 263 + 527 + 8976 = 9766 != 10556
# 263 + 527 + 8976/2 = 5278 = 10556/2

输出的结果为:

torch.Size([2, 10556])
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​​图是无向图,在统计原始边数量时,每一条边的正向与反向各统计了一次,训练集也包含边的正向与反向,但验证集与测试集都只包含了边的一个方向。

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

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)。
​​​decode_all(self, z)​​用于推理(inference)阶段,我们要对所有的节点对预测存在边的几率。

3.边预测图神经网络的训练

定义单个epoch的训练过程

def get_link_labels(pos_edge_index, neg_edge_index):
num_links = pos_edge_index.size(1) + neg_edge_index.size(1)
link_labels = torch.zeros(num_links, dtype=torch.float)
link_labels[:pos_edge_index.size(1)] = 1.
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

通常,存在边的节点对的数量往往少于不存在边的节点对的数量。我们在每一个​​epoch​​​的训练过程中,都进行一次训练集负样本采样。采样到的样本数量与训练集正样本相同,但不同​​epoch​​中采样到的样本是不同的。

——这样做,我们既能实现类别数量平衡,又能实现增加训练集负样本的多样性。在负样本采样时,我们传递了​​train_pos_edge_index​​​为参数,于是​​negative_sampling()​​​函数只会在训练集中不存在边的节点对中采样。​​get_link_labels()​​函数用于生成完整训练集的标签。

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

定义单个epoch验证与测试过程

@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

运行完整的训练、验证与测试

def main():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

dataset = 'Cora'
path = osp.join(osp.dirname(osp.realpath(__file__)), '..', 'data', 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)

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

best_val_auc = test_auc = 0
for epoch in range(1, 101):
loss = train(data, model, optimizer)
val_auc, tmp_test_auc = test(data, model)
if val_auc > best_val_auc:
best_val_auc = val_auc
test_auc = tmp_test_auc
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: 001, Loss: 0.6930, Val: 0.6901, Test: 0.6964
Epoch: 002, Loss: 0.6818, Val: 0.6835, Test: 0.6964
Epoch: 003, Loss: 0.7158, Val: 0.6921, Test: 0.6958
Epoch: 004, Loss: 0.6775, Val: 0.7144, Test: 0.7202
Epoch: 005, Loss: 0.6856, Val: 0.7309, Test: 0.7464
..........................
Epoch: 091, Loss: 0.4513, Val: 0.8717, Test: 0.9053
Epoch: 092, Loss: 0.4428, Val: 0.8718, Test: 0.9059
Epoch: 093, Loss: 0.4481, Val: 0.8729, Test: 0.9061
Epoch: 094, Loss: 0.4440, Val: 0.8727, Test: 0.9061
Epoch: 095, Loss: 0.4467, Val: 0.8731, Test: 0.9047
Epoch: 096, Loss: 0.4455, Val: 0.8744, Test: 0.9045
Epoch: 097, Loss: 0.4482, Val: 0.8757, Test: 0.9054
Epoch: 098, Loss: 0.4455, Val: 0.8754, Test: 0.9054
Epoch: 099, Loss: 0.4490, Val: 0.8750, Test: 0.9054
Epoch: 100, Loss: 0.4453, Val: 0.8754, Test: 0.9054

完整的代码可见于​​codes/edge_prediction.py​​.

第三部分:作业

1.用不同网络层进行节点分类

尝试使用PyG中的不同的网络层去代替​​GCNConv​​​,以及不同的层数和不同的​​out_channels​​,来实现节点分类任务。

​codes/node_classification.py​​中搭建2层的GCN[200, 100],现在使用GAT图神经网络进行节点预测:

from torch_geometric.nn import GATConv, Sequential
from torch.nn import Linear, ReLU
import torch.nn.functional as F

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

训练和测试的过程:

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.
optimizer.step() # Update parameters based on gradients.
return loss


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

可视化:

import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
%matplotlib inline

def visualize(h, color):
z = TSNE(n_components=2).fit_transform(h.detach().cpu().numpy())
plt.figure(figsize=(10, 10))
plt.xticks([])
plt.yticks([])

plt.scatter(z[:, 0], z[:, 1], s=70, c=color.cpu(), cmap="Set2")
plt.show()

输出结果(loss变化过程、降维后的可视化):

from torch_geometric.transforms import NormalizeFeatures

dataset = PlanetoidPubMed(root='dataset/PlanetoidPubMed/', 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)

model = GAT(num_features=dataset.num_features, hidden_channels_list=[200, 100], num_classes=dataset.num_classes).to(
device)
print(model)
# 定义优化器
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

for epoch in range(1, 201):
loss = train()
if epoch % 10 == 0:
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

model.eval() # 模型为测试状态
out = model(data.x, data.edge_index)
visualize(out, color=data.y)

输出的结果为:

dataset.num_features: 500
GAT(
(convseq): Sequential(
(0): GATConv(500, 200, heads=1)
(1): ReLU(inplace=True)
(2): GATConv(200, 100, heads=1)
(3): ReLU(inplace=True)
)
(linear): Linear(in_features=100, out_features=3, bias=True)
)
Epoch: 010, Loss: 0.8149
Epoch: 020, Loss: 0.1690
Epoch: 030, Loss: 0.0394
Epoch: 040, Loss: 0.0167
Epoch: 050, Loss: 0.0189
Epoch: 060, Loss: 0.0252
Epoch: 070, Loss: 0.0139
Epoch: 080, Loss: 0.0146
Epoch: 090, Loss: 0.0125
Epoch: 100, Loss: 0.0144
Epoch: 110, Loss: 0.0141
Epoch: 120, Loss: 0.0101
Epoch: 130, Loss: 0.0203
Epoch: 140, Loss: 0.0206
Epoch: 150, Loss: 0.0070
Epoch: 160, Loss: 0.0148
Epoch: 170, Loss: 0.0152
Epoch: 180, Loss: 0.0092
Epoch: 190, Loss: 0.0089
Epoch: 200, Loss: 0.0099
Test Accuracy: 0.7560

【GNN】task4-数据完整存储与内存的数据集类+节点预测与边预测任务实践_数据_02

【GNN】task4-数据完整存储与内存的数据集类+节点预测与边预测任务实践_数据集_03

2.用Sequential容器进行边预测

在边预测任务中,尝试用​​torch_geometric.nn.Sequential​​容器构造图神经网络。

import torch.nn as nn
from torch.nn import Linear, ReLU
from torch_geometric.nn import Sequential, GATConv

class Net(nn.Module):
def __init__(self, in_channels, out_channels):
super(Net,self).__init__()

def encode(self,x,edge_index):
model = Sequential('x, edge_index',
[(GCNConv(in_channels, 128), 'x, edge_index -> x'),
ReLU(inplace=True),
(GCNConv(128, 64), 'x, edge_index -> x'),
ReLU(inplace=True),
Linear(64, out_channels),
])
x = model(x,edge_index)
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):
adj_prob = z @ z.t()
return (adj_prb>0).nonzero(as_tuple=False).t()

用于做边预测的神经网络主要由两部分组成:
其一是编码(encode),它与我们前面介绍的节点表征生成是一样的;
其二是解码(decode),它根据边两端节点的表征生成边为真的几率(odds)。
decode_all(self, z)用于推理(inference)阶段,我们要对所有的节点对预测存在边的几率。

3.思考题

如下方代码所示,我们以​​data.train_pos_edge_index​​为实际参数来进行训练集负样本采样,但这样采样得到的负样本可能包含一些验证集的正样本与测试集的正样本,即可能将真实的正样本标记为负样本,由此会产生冲突。但我们还是这么做,这是为什么?

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))

答:概率低,不太影响学习到的模型的泛化能力。

datawhale参考答案(缩减版)
(1)如果使用​​​edge_index​​​为实际参数会怎么样?
如果以​​​edge_index​​​为实际参数,​​negative_sampling()​​函数采样到的是真实的负样本,以真实负样本作为训练集负样本,那么训练集负样本就不会与验证集正样本有交集,也不会与测试集正样本有交集。理论上这种采样方式产生的结果会更好,实际也是如此。

(2)为啥不能采用(1)这种训练集负样本采样方式
在训练阶段,我们应该只见训练集,对验证集与测试集都是不可见的。”如果我们在训练阶段知道了所有真实负样本,我们就相当于知道了训练集与验证集的正样本,没出现在训练集正样本与所有真实负样本里的样本为训练集或验证集的正样本。

采用这种数据采样方式采样得到的数据集,用于神经网络的训练,训练得到的神经网络会在“现在整个数据集”上过拟合,就降低了对将来未知的数据的泛化能力。

于是在训练阶段,我们只能知道训练集的正样本,剩余的样本我们都是未知的。

(3)如果以​​data.train_pos_edge_index​​​为实际参数来进行训练集负样本采样会怎么样?
以​​​data.train_pos_edge_index​​为实际参数来进行训练集负样本采样,即在非训练集正样本中采样。非训练集正样本包含了所有的负样本,和没有出现在训练集中的正样本。虽然包含了没有出现在训练集中的正样本,但其数量相对于所有的负样本的数量要少得多。即便将真实的正样本标记为负样本会产生冲突,但这带来影响相对较小。

综上,我们要以​​data.train_pos_edge_index​​为实际参数来进行训练集负样本采样,也就是我们要在非训练集正样本中采样训练集负样本。

reference