文章目录
- 1. 图神经网络
- 1.1 GCN图卷积网络
- 1.1.1 计算过程
- 1.1.2 公式的物理原理
- 1.1.3 GCN代码实现
- 1.2 GAT图注意力网络
- 1.2.1 计算过程与原理
- 1.2.2 GAT代码实现
- 1.3 消息传递
- 1.4 图采样介绍
- 1.5 图采样算法:GraphSAGE
- 1.6 图采样算法:PinSAGE
- 2. 参考
1. 图神经网络
图神经网络(Graph Neural Networks,GNN)是近几年兴起的学科,用作推荐算法也是相当好,下面来对图神经网络来一个全面的了解。
1.1 GCN图卷积网络
图卷积网络(Graph Convolutionnal Networks,GCN)提出于2017年。GCN的出现标志着图神经网路的出现。深度学习最常用的网络结构是CNN和RNN。GCN与CNN仅名字相似,其实理解起来也很类似,都是特征提取器。不同的是,CNN提取的是张量数据特征,而GCN提取的是图结构数据特征。
1.1.1 计算过程
本质上GCN的公式非常简单,但最初的研究者为了从数学上严谨地推导该公式是有效的,所以涉及诸如傅里叶变换,以及拉普拉斯算子等知识。
- 傅里叶变换
- 拉普拉斯算子
其实对于使用者而言,可以绕开这些知识,并且毫无影响地理解GCN。
以下是GCN网络层的基础公式:
其中,
- H(l) 指第l层的输入特征
- H(l+1) 指输出特征
- W(l)指线性变换矩阵
- 𝛔(·)是非线性激活函数,如ReLU和Sigmoid
所以重点是那些A、D是什么。
首先说A~
,通常邻接矩阵用A表示,在A上面加波浪线的A~
叫做“有自连的邻接矩阵”,以下简称自连邻接矩阵,定义:
其中,I是单位矩阵,A是邻接矩阵。因为一对于邻接矩阵的定义,是矩阵中的值为对应位置节点与节点之间的关系,而矩阵对角线的位置是节点与自身的关系,但是节点与自身无边相连,所以链接矩阵中对角线自然都为0,但是如果接受这一设定进行下游计算,则无法在邻接矩阵中区分“自身节点”与“无连接节点”,所以将A加上一个单位矩阵I得到A~
,便能使对角线为1,就好比添加了自连的设定,如下图所示:
D~
是自连矩阵的度矩阵,定义如下:
如果仍然用上图数据:
D~^-1/2
是在自连度矩阵的基础上开平方根取逆。在无向无权图中,度矩阵描述的是节点邻居的数量;若是有向图则是出度的数量;若是有权图,则是目标节点与每个邻居连接边的权重。而对于自连度矩阵,是在度矩阵的基础上加上一个单位矩阵,即每个节点度加一。
GCN公式中的D~^-1/2·A~·D~^-1/2
其实都是从邻接矩阵计算而来的,所以甚至可以把这些看作一个常量。模型需要学习的仅仅是W(l)这个权重矩阵。
正如之前所讲,GCN神经网络层的计算过程很简单,如果懂了那个公式,则只需构建一张图,统计出邻接矩阵,直接带入公式即可实现GCN网络。
1.1.2 公式的物理原理
下面来理解一下GCN公式的物理原理。首先来看A~H^l
这一计算的意义,如下图:
在自连邻接矩阵满足上图的数据场景时,下一层第1个节点的向量表示是当前层节点h1、h2、h3、h5这些节点向量表示的和。这一过程的可视化意义如下图:
这一操作像在卷积神经网络中进行卷积操作,然后进行一个求和池化(Sum Pooling)。这其实是一条消息传递的过程,Sum Pooling是一种消息聚合的操作,当然也可以采取平均、Max等池化操作。总之经消息传递的操作后,下一层的节点1就聚合了它的一节邻居和自身的信息,这就很有效地保留了图结构承载的信息。
接下来看度矩阵D在这里起到的作用。节点的度代表它的一阶邻居的数量,所以乘以度矩阵的逆,会稀释掉度很大的节点的重要程度。这其实很好理解,例如保险经理张三的好友有2000个,当然你也是其中之一,而你幼时的青梅竹马小红加上你仅有10个好友,则张三与小红对于定义你的权重自然就不该一样。(这是一个加权求和操作,度越大权重就越低)
下列公式与可视化意义:
上图中每条边的权重坟分母左边的数字根号4
是节点1自身度的逆平方根。上述内容可以简单理解GCN公式的计算意义。
图神经网络之所有有效,是因为它很好地利用了图结构的信息。它的起点是别人的终点。本身无监督统计图数据信息已经可以给预测带来很高的准确率。此时只需一点少量的标注数据进行有监督的训练就可以媲美大数据训练的神经网络模型。
1.1.3 GCN代码实现
GCN作为图神经网络中最基础的算法,各个图神经网络库自然都集成了现成的API。我们以Pytorch Geometric(PyG)为例做介绍。
PyG中有自带的开源数据集供学习调用。这里以Cora数据集为例,读取PyG自带的Core数据集的Python文件如下:
from torch_geometric.datasets import Planetoid
import os
def loadData():
path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '..', 'data', 'Cora')
dataset = Planetoid(path, 'Cora')
return dataset.data, dataset.num_classes, dataset.num_features
if __name__ == '__main__':
path = os.path.join( os.path.dirname( os.path.realpath(__file__) ), '..', 'data', 'Cora')
dataset = Planetoid( path, 'Cora' )
print( '类别数:', dataset.num_classes )
print( '特征维度:', dataset.num_features )
print( dataset.data )
print(dataset.data.train_mask)
print( sum( dataset.data.train_mask ) )
print( sum( dataset.data.val_mask ) )
print( sum( dataset.data.test_mask ) )
调用后:
类别数是分类任务重类别的数量,这里为了巩固图神经网络的基础。分类任务往往是机器学习中最简单的任务,所以由分类任务入手去学习图神经网络是再合适不过的由浅入深的学习过程。
得到的Data包含了所有训练及预测所需要的信息,edge_index是边集,x是节点特征向量,总共2708个节点,每个节点表示是1433维的向量,y是对应的类别标注。
traini_mask、val_mask、test_mask分别对应训练集、验证集、测试集的位置遮盖列表,他们都是True和False的列表,列表索引对应着节点索引。PyG的默认数据集利用位置遮盖的方式区分训练集与测试集。例如train_mask中维True的位置代表训练该节点,否则不训练。
由打印出来的数据可以知道,总计2708个节点中训练数据仅仅只有140个,而测试集即由999个。这就印证了“图神经网络仅需少量的标注数据训练出来的模型,就可以达到由规模训练数据训练的普通神经网络模型一样甚至更好的效果。”此言并不虚。
完整的GCN模型核心代码:
class GCN( torch.nn.Module ):
def __init__( self, n_classes, dim ):
'''
:param n_classes: 类别数
:param dim: 特征维度
'''
super( GCN, self ).__init__( )
self.conv1 = GCNConv( dim, 16 )
self.conv2 = GCNConv( 16, n_classes )
def forward( self,data ):
x, edge_index = data.x, data.edge_index
x = F.relu( self.conv1( x, edge_index ) )
x = F.dropout( x )
x = self.conv2( x, edge_index )
return F.log_softmax( x, dim = 1 )
可以看到现成的方法非常简单,只需定义GCNConv的输入维度和输出维度,在前向传播时输入特征向量即可表示图的边集。
外部调用的代码如下:
def train( epochs = 200, lr = 0.01 ):
data, n_class, dim = pygDataLoader.loadData()
net = GCN( n_class, dim )
optimizer = torch.optim.AdamW( net.parameters(), lr = lr )
for epoch in range(epochs):
net.train( )
optimizer.zero_grad( )
logits = net( data )
#仅用训练集计算loss
loss = F.nll_loss( logits[data.train_mask], data.y[data.train_mask] )
loss.backward( )
optimizer.step( )
train_acc, val_acc, test_acc = eva( net, data )
log = 'Epoch: {:03d}, Train: {:.4f}, Val: {:.4f}, Test: {:.4f}'
print( log.format( epoch, train_acc, val_acc, test_acc ) )
注意计算loss时仅用train_mask中为True的那些位置节点。
中间设计的eva测试方法如下:
@torch.no_grad()
def eva( net, data ):
net.eval()
logits, accs = net(data), []
for _, mask in data('train_mask', 'val_mask', 'test_mask'):
pred = logits[mask].max(1)[1]
acc = pred.eq(data.y[mask]).sum().item() / mask.sum().item()
accs.append( acc )
return accs
完整代码:
import torch
import torch.nn.functional as F
from chapter4 import pygDataLoader
from torch_geometric.nn import GCNConv
class GCN( torch.nn.Module ):
def __init__( self, n_classes, dim ):
'''
:param n_classes: 类别数
:param dim: 特征维度
'''
super( GCN, self ).__init__( )
self.conv1 = GCNConv( dim, 16 )
self.conv2 = GCNConv( 16, n_classes )
def forward( self,data ):
x, edge_index = data.x, data.edge_index
x = F.relu( self.conv1( x, edge_index ) )
x = F.dropout( x )
x = self.conv2( x, edge_index )
return F.log_softmax( x, dim = 1 )
@torch.no_grad()
def eva( net, data ):
net.eval()
logits, accs = net(data), []
for _, mask in data('train_mask', 'val_mask', 'test_mask'):
pred = logits[mask].max(1)[1]
acc = pred.eq(data.y[mask]).sum().item() / mask.sum().item()
accs.append( acc )
return accs
def train( epochs = 200, lr = 0.01 ):
data, n_class, dim = pygDataLoader.loadData()
net = GCN( n_class, dim )
optimizer = torch.optim.AdamW( net.parameters(), lr = lr )
for epoch in range(epochs):
net.train( )
optimizer.zero_grad( )
logits = net( data )
#仅用训练集计算loss
loss = F.nll_loss( logits[data.train_mask], data.y[data.train_mask] )
loss.backward( )
optimizer.step( )
train_acc, val_acc, test_acc = eva( net, data )
log = 'Epoch: {:03d}, Train: {:.4f}, Val: {:.4f}, Test: {:.4f}'
print( log.format( epoch, train_acc, val_acc, test_acc ) )
if __name__ == '__main__':
train( )
在该代码中,所有节点的Embedding都会伴随模型的正向传播去更新,即GCN公式中的H,并不是仅将其作为训练数据的结点Embedding输入GCN网络层,而反向传播仅且只能更新有指定位置的数据。通过mask列表的操作,可以很方便地区分训练集、验证集、测试集。
1.2 GAT图注意力网络
图注意力网络(Graph Attention Networks,GAT)提出于2018年,顾名思义,GAT是加入注意力机制的图神经网络。
GCN中消息传递的权重仅仅考虑了节点的度,是固定不变的,而GAT则采用注意力将消息传递的权重以注意力权重参数的形式也跟着模型参数一起迭代更新。
1.2.1 计算过程与原理
在了解了GAT的计算过程前,得把GCN的那个公式忘记。因为GAT的公式并非是从GCN出发的。
下图简单地展示了GAT消息传递的形式:
节点1、2、3、5通过各自的权重a12、a13、a15、a11衰减或增益后将信息传递给了节点1。设aij为节点j到节点i的消息传递注意力权重,则:
与常规的注意力机制一样,在计算出eij后,对其进行一个Softmax操作使aij在0-1.
其中Ni是指节点i的一阶邻居集。至于eij如果得到,公式如下:
在GAT论文中LeakyReLU的负值斜率取值0.2。hi和hj是当前输入层的节点i与节点j的特征向量表示,W是线性变换矩阵,它的形状是W∈RFxF’,其中F是输入特征的维度,是hi和hj的维度。F’是输出特征的维度,以下用hi‘表示当前层节点i的输出特征,并且其维度为F’。||是向量拼接操作,原本维度为F的hi和hj经过W线性变换后维度均变为F’,经过拼接后得到维度为2F’的向量。此时再点乘一个维度为2F’的单层矩阵a的转置,最终LeakyReLu激活后得到唯一的eij。
所以再通过对eij进行Softmax操作就可以得到节点j到节点i的消息传递注意力权重aij。计算节点i的在当前GAT网络层的输出向量hi‘即可描述为:
其中,𝛔(·)代表任意激活函数,Ni代表节点i的一阶邻居集,W与注意力计算中的W是一样的。至此是一条消息传递并用加权求和的方式进行消息聚合的计算过程。在GAT中,可以进行多次消息传递操作,这种称为多头注意力(Multi-Head Attention),计算公式如下:
所以每一层的输出特征是总共K个单头消息传递后拼接起来的向量。或者进行求和平均操作,公式如下:
下图展示了多头注意力消息传递的过程:
上图可以很直观地观察到节点1、 2、 3、 5分别进行了三次不同权重的消息传递。产生了3个节点1的输出特征被记作1’‘,最终节点1的输出特征等于3个1’‘向量的拼接或者求平均所得。这么做的好处便是进一步提高了泛化能力。在GAT的论文中建议在GAT网络中间的隐藏层采取拼接操作,而在最后一层采取平均操作。
1.2.2 GAT代码实现
GAT的网络层在PyG中也有现成的API可调用:
from torch_geometric.nn import GATConv
以GAT网络层组成的GAT模型类的代码如下:
import torch
import torch.nn.functional as F
from chapter4 import pygDataLoader
from torch_geometric.nn import GATConv
class GAT(torch.nn.Module):
def __init__(self, n_classes, dim):
'''
:param n_classes: 类别数
:param dim: 特征维度
'''
super(GAT, self).__init__( )
self.conv1 = GATConv( dim, 16 )
self.conv2 = GATConv( 16, n_classes )
def forward( self, data ):
x, edge_index = data.x, data.edge_index
x = F.relu( self.conv1( x, edge_index ) )
x = F.dropout( x )
x = self.conv2( x, edge_index )
return F.log_softmax( x, dim = 1 )
完整代码:
import torch
import torch.nn.functional as F
from chapter4 import pygDataLoader
from torch_geometric.nn import GATConv
class GAT(torch.nn.Module):
def __init__(self, n_classes, dim):
'''
:param n_classes: 类别数
:param dim: 特征维度
'''
super(GAT, self).__init__( )
self.conv1 = GATConv( dim, 16 )
self.conv2 = GATConv( 16, n_classes )
def forward( self, data ):
x, edge_index = data.x, data.edge_index
x = F.relu( self.conv1( x, edge_index ) )
x = F.dropout( x )
x = self.conv2( x, edge_index )
return F.log_softmax( x, dim = 1 )
def train( data, n_class, dim, lr = 0.01 ):
net = GAT( n_class, dim )
optimizer = torch.optim.AdamW(net.parameters(), lr=lr)
for epoch in range(1, 201):
net.train()
optimizer.zero_grad()
logits = net(data)
loss = F.nll_loss(logits[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
train_acc, val_acc, test_acc = eva(net,data)
log = 'Epoch: {:03d}, Train: {:.4f}, Val: {:.4f}, Test: {:.4f}'
print(log.format(epoch, train_acc, val_acc, test_acc))
return net
@torch.no_grad()
def eva(net,data):
net.eval()
logits, accs = net(data), []
for _, mask in data('train_mask', 'val_mask', 'test_mask'):
pred = logits[mask].max(1)[1]
acc = pred.eq(data.y[mask]).sum().item() / mask.sum().item()
accs.append(acc)
return accs
if __name__ == '__main__':
data, n_class , dim = pygDataLoader.loadData()
net = train(data,n_class, dim)
与1.1.3节GCN的唯一区别是GCNConv变成了GATConv,外部如何调用该模型做训练其实跟GCN也完全一样。
1.3 消息传递
这里的内容将详细介绍上文中提到的消息传递(Message Passing)。消息传递可以被理解为在一张图网络中节点间传到信息的通用操作。首先来看对单个节点v进行消息传递的范式:
其中,hv’是节点v的在当前层的输出特征,hv是输入特征,𝜑(·)表达对某个节点进行消息传递动作,Nv是节点v的邻居集。hu|u∈Nv代表遍历节点v的邻居集,相当于邻居节点消息发送的动作,而g(·)是一条消息聚合的函数,例如Sum、Avg、Max。g(·)在GCN的网络层中,是一个基于度的加权求和,而在GAT中是基于注意力的加权求和。f(·)表示对消息集合后的节点特征进行深度学习的常规操作,例如进行一次或多次线性变化或者非线性激活函数。
下图展示了上面的过程:
一个GNN层的计算范式可以表达为:
其中,Hl代表第l层所有节点的特征矩阵,Hl+1表示第l层输出的特征矩阵,V表示所有节点,𝜑(v)表示对节点v进行消息传递操作。该公式表达的含义是遍历图网络中所有的节点对其进行消息传递操作,以便更新所有节点的特征向量。
所以特神经网络的本质是通过节点间的消息传递从而泛化图结构的信息。
1.4 图采样介绍
图神经网络中还有一个重要概念,即图采样。如果数据量过大,则是否可以仿照传统深度学习的小批量训练方式呢?答案是不可以,因为普通深度学习中的训练样本之间并不依赖,但是图结构的数据中,节点与节点之间有依赖关系,如下图:
普通深度学习的训练样本在空间中是一些散点,可以随意小批量采样,无论如何采样得到的训练样本并不会丢失什么信息。
而图神经网络训练样本之间存在边的依赖,也正是因为有边的依赖,也正是因为有边的依赖,所以才被称为图结构数据,这样才可用图神经网络的模型算法来训练,如果随意采样,则破坏了样本之间的关系信息。
所以如果进行图采样成为一门学科,下面介绍两个基础图采样算法GraphSAGE和pinSAGE。
1.5 图采样算法:GraphSAGE
GraphSAGE是第一张图采样算法,也是最基础的。其提出年份与GCN同年,也是2017年。其实中心思想概括成一句话就是:小批量采样原有大图的子图。如下图所示:
- 步骤1:随机选取一个或者若干个节点作为0号节点。
- 步骤2:在0号节点的一阶邻居中随机选取若干个节点作为1号节点。
- 步骤3:在刚刚选取的1号节点的一阶邻居中,不回头地随机选取若干个节点作为2号节点,不回头是指不再回头取0号节点。该步骤亦可认为是随机选取0号节点通过1号节点连接的二阶邻居。
- 步骤4:一次类推,在上图中的k是GraphSAGE的超参,可认为是0号节点的邻居阶层数,若将k设定为5,则代表总共可以取0号节点的第5阶邻居。
- 步骤5:将采样获得的所有节点保留边的信息后组成子图并作为一次小批量样本输入图神经网络中进行下游任务。
另外,其实图采样得到的子图是从作为中心节点的0号节点开始扩散,所以在消息传递时可以自外而内地进行。假如在上图中,可以先将那些2号节点的特征向量聚合到对应位置的1号节点中,再由更新过后的1号节点消息传递至0号节点,然后将消息聚合在0号节点。仅输出更新完成的0号节点特征向量,作为图神经网络层的输入特征向量进行训练更新。
1.6 图采样算法:PinSAGE
相比于最基础的GraphSAGE,2018年斯坦福大学提出的PinSAGE更具想象力。PinSAGE的中心思想可以概括成一句话,即采样通过随机游走经过的高频节点生成的子图。接下来,结合下图,来理解PinSAGE的采样过程:
- 步骤1:随机选取一个或者若干个节点作为0号节点。
- 步骤2:以0号节点作为起始节点开始随机游走生成序列,游走方式可以采取DeepWalk或者Node2Vec。
- 步骤3:统计随机游走中高频出现的节点作为0号节点的邻居,以便生成一个新的子图。出现的频率可作为超参设置。
- 步骤4:将新子图中的边界节点(如上图中的1、9、13)作为新的起始节点,重复步骤2开始随机游走。
- 步骤5:统计新一轮随机游走的高频节点,作为新节点在原来子图中接上。注意每个新高频节点仅接在他们原有的起始节点中(如节点1作为起始节点随机游走,所以生成节点序列中的高频节点仅作为节点1的邻居接在新子图中)。
- 步骤6:重复上述过程k次,k为超参。将生成的新子图作为一次小批量样本输入神经网络中进行下游任务。或者进行自外而内的消息传递后,输出聚合了子图的所有信息的0号节点向量。
PinSAGE的优势在于可以快速地收集到远端节点,并且生成的子图经过一次频率筛选所获得的样本表达能力更强也更具有泛化能力。
2. 参考
- 《动手学推荐系统——基于pytorch的算法实现》
- 《深度学习推荐系统》
- Kipf T N, Welling M. Semi-supervised classification with graph convolutional networks[J]. arXiv preprint arXiv:1609.02907, 2016.
- Veličković P, Cucurull G, Casanova A, et al. Graph attention networks[J]. arXiv preprint arXiv:1710.10903, 2017.
- Ying R, He R, Chen K, et al. Graph convolutional neural networks for web-scale recommender systems[C]//Proceedings of the 24th ACM SIGKDD International Conference on Knowledge Discovery & Data Mining. 2018: 974-983.