在图任务中,所谓链接预测,一般有两种含义:在静态网络中,链接预测用于发现缺失的链接,而在动态网络中,链接预测用于预测未来可能出现的链接。
之前学术界关于链接预测的方案主要可以分为三类:启发式方法(Heuristic Methods),Network Embedding的方法和图神经网络方法。
在GNN链接预测中,我们一般将链接预测转换为一个二分类问题:图中存在的边我们称之为正样本,不存在的边我们称之为负样本。
一个现实的问题是:网络中存在的链接数往往都是远小于不存在的链接数的,也就是说图中的正样本数量远小于负样本数量,为了使模型训练较为均衡,我们通常先将正样本分为训练集、验证集和测试集,然后再分别从三个数据集中采样等同数量的负样本参与训练、验证以及测试。
在PyG的链接预测中,一共有4种类型的边:
training message edges
:用于GNN的消息传递和聚合的边。training supervision edges
:分为正样本和负样本,在利用training message edges
得到节点的向量表示后,利用training supervision edges
进行有监督学习。validation edges
:用于验证的边。testing edges
:用于测试的边。需要注意的是:validation edges
和testing edges
不能和训练阶段的两种边有交集。
一个问题:模型训练阶段中的training meaasge edges
和training supervision edges
允许出现相同边吗?会不会造成数据泄露?
一般来说,在消息传递和监督中使用相同的边集可能会在训练阶段导致数据泄漏,但这取决于模型的能力。例如,GAE使用基于GCN的编码器和基于点积的解码器,编码器和解码器的能力都有限,因此模型的数据泄漏能力也有限。
以上内容来自PyG作者在一个issue中的回答:RandomLinkSplit 中的拆分错误 #3668。
这里以CiteSeer网络为例:Citeseer网络是一个引文网络,节点为论文,一共3327篇论文。论文一共分为六类:Agents、AI(人工智能)、DB(数据库)、IR(信息检索)、ML(机器语言)和HCI。如果两篇论文间存在引用关系,那么它们之间就存在链接关系。
加载数据:
dataset = Planetoid('data', name='CiteSeer')
print(dataset[0])
输出:
Data(x=[3327, 3703], edge_index=[2, 9104], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327])
x=[3327, 3703]表示一共有3327个节点,然后节点的特征维度为3703,这里实际上是去除停用词和在文档中出现频率小于10次的词,整理得到3703个唯一词。edge_index=[2, 9104],表示一共9104条edge,数据一共两行,每一行都表示节点编号。
得到数据集后,接下来我们需要划分训练集、验证集以及测试集。
划分的标准为:训练集中不能包含验证集和测试集中存在的链接,验证集中不能包含测试集中存在的链接。
利用PyG封装的RandomLinkSplit我们很容易实现数据集的划分。RandomLinkSplit的具体参数如下所示:
介绍几个常用的参数:
num_val
:验证集中边的比例,默认为0.1。num_test
:测试集中边的比例,默认为0.1。is_undirected
:如果为True
,则假定图是无向图。add_negative_train_samples
:是否为链接预测添加负训练样本,如果模型已经执行了负采样,则该选项应设置为False。一般我们设置为False,也就是训练集中不包含负样本,然后每一轮训练时在训练集中重新采样与正样本相同数量的负样本进行训练,这样可以保证每一轮训练中采样得到的负样本都是不一样的,可以有效提高模型泛化能力。默认为False
,也就是不采样。neg_sampling_ratio
:采样的正负样本的比例,默认为1。即验证集和测试集中正负样本个数一致。disjoint_train_ratio
:如果设置为大于0的值,则不会为消息传递和监督共享训练边。利用RandomLinkSplit对CiteSeer进行划分:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
transform = T.Compose([
T.NormalizeFeatures(),
T.ToDevice(device),
T.RandomLinkSplit(num_val=0.1, num_test=0.1, is_undirected=True,
add_negative_train_samples=False),
])
dataset = Planetoid('data', name='CiteSeer', transform=transform)
train_data, val_data, test_data = dataset[0]
最终我们得到train_data, val_data, test_data
。
输出一下原始数据集和三个被划分出来的数据集:
Data(x=[3327, 3703], edge_index=[2, 9104], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327])
Data(x=[3327, 3703], edge_index=[2, 7284], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327], edge_label=[3642], edge_label_index=[2, 3642])
Data(x=[3327, 3703], edge_index=[2, 7284], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327], edge_label=[910], edge_label_index=[2, 910])
Data(x=[3327, 3703], edge_index=[2, 8194], y=[3327], train_mask=[3327], val_mask=[3327], test_mask=[3327], edge_label=[910], edge_label_index=[2, 910])
从上到下依次为原始数据集、训练集、验证集以及测试集。
可以发现,验证集和测试集中都有:
edge_label=[910], edge_label_index=[2, 910])
也就是说验证集和测试集中都一共包含了910条边,这里edge_label
为01数据,其中正样本标签为1,负样本标签为0,输出一下edge_label
之和:
print(val_data.edge_label.sum())
tensor(455., device='cuda:0')
910是455的两倍,说明验证集和测试集中正负样本的比例为1,这是因为neg_sampling_ratio
默认为1。
对于训练集:
edge_index=[2, 7284], edge_label=[3642], edge_label_index=[2, 3642]
print(train_data.edge_label.sum())
tensor(3642., device='cuda:0')
说明训练集中包含了一共3642条边,并且这些边都是正样本,也就是原始图中存在的边,值得注意的是,由于设置了disjoint_train_ratio
=0,那么训练集中消息传递和监督是会有重叠的,具体来讲就是训练过程中用于监督训练的边edge_label_index
包含在了用于消息传递和聚合的边edge_index
中。我们可以简单验证如下:
train_edge = [(train_data.edge_index[0][i].item(), train_data.edge_index[1][i].item()) for i in range(train_data.edge_index.size(1))]
train_label_edge = [(train_data.edge_label_index[0][i].item(), train_data.edge_label_index[1][i].item()) for i in range(train_data.edge_label_index.size(1))]
s = 0
for x in train_label_edge:
if x in train_edge:
s += 1
print('s=', s)
输出s=3642
,说明edge_label_index
完全包含在了edge_index
中。
因此,原始图中一共9104条边,在0.1验证和0.1测试的比例下,验证集和测试集都分得了910个样本,这其中包括455个正样本和455个负样本,训练集分得了3642个正样本(负样本在训练时进行采样)。
而我们知道,9104的0.8和0.1分别为7283和910,这其中训练集一共得到了7284条边用于训练,这7284条边中的一半也就是3642条边用于有监督学习;测试集和验证集都得到了910条边(这里不全是正样本)。