标签: 图神经网络 , 图注意力网络 , 注意力机制 , GAT , tensorflow
本文内容分为四个部分:
- GAT原理扫盲
- GAT源码阅读(tensorflow1)
- GAT源码链路分析
- GAT的GraphSAGE策略实现分析
原理初步理解
(1)从GNN,GCN到GAT
- GNN学习的是邻居节点聚合到中心的方式,传统的GNN对于邻居节点采用 求和/求平均 的方式,各个邻居的 权重相等为1
- GCN进行了改造邻居聚合方式为 邻接矩阵做对称归一化 ,也是类似求平均,但是它考虑到了节点的度大小,度越大权重往小了修正,是一种避免单节点链接巨量节点导致计算失真的调整方式,仅仅通过 度+规则 对权重做了修改,而没有考虑到因为节点的影响大小去调整权重的大小。
- GAT认为(1) 不同邻居对中心节点的影响是不一样的,且它想通过注意力自动地去学习这个权重参数 ,从而提升表征能力(2)GAT使用 邻居和中心节点各自的特征属性来确定权重 ,中心节点的所有邻居的权重相加等于1

GAT示意图
视频作者举例两个节点的权重需要基于两个节点的特征
(2)注意力机制

key-value注意力机制
在注意力机制中 Source 代表需要处理的信息, Query 代表某种条件或者先验信息, Attention Value 是通过先验信息和Attention机制从Source中提取的信息,Source中的信息通过 key-value 对的形式表达出来,可以将key类比为信息的摘要,value类比为信息的全部内容。注意力机制的定义如下

注意力机制公式
从公式来看,注意力机制就是 先计算出前提条件和每个要接受的信息的摘要部分的相关程度,以相关程度为权重再学习要接受的每个全部信息,最后每条信息加权求和得到结果
(3)GNN中的注意力机制
类比Key-Value注意力机制,再图结构中, 中心节点就是Query,所有邻居节点的信息就是Source,Attention Value就是中心节点经过聚合之后的特征向量,Key和Value相同,就是邻居节点的特征向量 。目标就是针对中心节点(Query)学习邻居节点(Source)的权重,再加权求和汇总到中心节点上形成新的特征向量表达(Attention Value)。

GAT示意图公式
这个公式先简单理解一下,这个图旋转90度就是个逻辑回归一样的全连接。 hi 和 hj 代表的节点的特征向量或者当下的特征表达,i为中心节点,j为邻居节点,目标是计算 eij 两个节点之间的权重,Whi代表使用一个模型自己学习的 共享的W向量 来对原始特征向量做维度转换,比如原始是(512, 128),W为(128, 64),最终转化为(512, 64),i和j都转化之后拼接,再用一个 全连接 作为相似计算函数,激活函数为 LeakyRelu ,此时全连接之后产出一个值,所有的全连接值再做 softmax 归一化得到最终ij节点的权重值。为了保留图结构的连接关系,注意力只再中心节点和邻居节点之间计算,且一个注意力机制的 a,W是共享的 。
(4)多头注意力机制
在计算出节点间的attention权重值之后新的中心节点表达如下,每个邻居的特征向量点乘一个维度转换向量参数之后,再乘上attention的权重,最后加权求和套一个激活函数输出下一层中心节点的特征表达

带有注意力机制的中心节点表达
为了防止Attention过拟合,引入多个Attention,引入多套W和a,使得模型更加稳定

多头注意力机制GNN图示
如图所示h1有h2,h3,h4,h5,h6这几个邻居,每个都算了三套三种颜色的Attention权重,最后 拼接/平均 得出下一层的h1表达

多头注意力机制GNN公式
公式里面K就是几套注意力机制,||代表向量拼接,下面一种是求平均
源码跟读
从github上*载下**一个项目下来GAT代码链接,简单地跑一下看能不能跑通
root@ubuntu:/home/myproject/git/GAT# python execute_cora.py
Dataset: cora
----- Opt. hyperparams -----
lr: 0.005
l2_coef: 0.0005
----- Archi. hyperparams -----
nb. layers: 1
nb. units per layer: [8]
nb. attention heads: [8, 1]
residual: False
nonlinearity: <function elu at 0x7fd6bfeaf950>
model: <class 'models.gat.GAT'>
(2708, 2708)
(2708, 1433)
...
Training: loss = 1.13271, acc = 0.60000 | Val: loss = 1.00691, acc = 0.80200
Training: loss = 1.17835, acc = 0.55000 | Val: loss = 1.01134, acc = 0.79800
Early stop! Min loss: 0.9928045868873596 , Max accuracy: 0.8199998140335083
Early stop model validation loss: 1.026117205619812 , accuracy: 0.8199998140335083
Test loss: 0.9934213757514954 ; Test accuracy: 0.8299991488456726
可以跑通,启动命令发现他是基于cora数据集的(论文分类)。下面看execute_cora.py这个脚本,挑重点的说
adj, features, y_train, y_val, y_test, train_mask, val_mask, test_mask = process.load_data(dataset)
features, spars = process.preprocess_features(features)
这两行第一行在读取数据,获得邻接矩阵(2078, 2078),所有节点的特征向量(2078, 1433),然后y_train, y_val, y_test是(2078,7)矩阵记录了分割数据集之后的y值(obehot),其中train0140行是onehot,val是140640,还有1000个test,mask(2708, )是对应有onehot的时True没有的时False第二行对特征向量做行归一化,返回稠密和稀疏两种方式。下面全部升了一个维度,可能要引入多组该对象就是batch_size接着往下看
features = features[np.newaxis] # shape=(1, 2708, 1433)
adj = adj[np.newaxis] # (1, 2708, 2708)
y_train = y_train[np.newaxis] # (1, 2708, 7)
y_val = y_val[np.newaxis] # (1, 2708, 7)
y_test = y_test[np.newaxis] # (1, 2708, 7)
train_mask = train_mask[np.newaxis] # (1, 2708)
val_mask = val_mask[np.newaxis] # (1, 2708)
test_mask = test_mask[np.newaxis] # (1, 2708)
下面这个方法调用会为后面的softmax使用,为了将attention的softmax计算限制在有1度连接关系的节点对内,简单而言他 创建了一个(1, 2708, 2708)的矩阵,其中(2708,2708)部分所有对角和有邻接矩阵部分值是0,其他全是一个负的极大值,这样softmax对于其他这些负极大值分母都为0
# 所有有链接的(包括自身)为0,否则为一个负极大值
biases = process.adj_to_bias(adj, [nb_nodes], nhood=1)
下面到模型部分
logits = model.inference(ftr_in, nb_classes, nb_nodes, is_train,
attn_drop, ffd_drop,
bias_mat=bias_in,
hid_units=hid_units, n_heads=n_heads, # [8, 1]
residual=residual, activation=nonlinearity)
以上代码灌入了一堆占位符和模型参数,跟进inference
class GAT(BaseGAttN):
def inference(inputs, nb_classes, nb_nodes, training, attn_drop, ffd_drop,
bias_mat, hid_units, n_heads, activation=tf.nn.elu, residual=False):
attns = []
for _ in range(n_heads[0]): # 8
attns.append(layers.attn_head(inputs, bias_mat=bias_mat,
out_sz=hid_units[0], activation=activation,
in_drop=ffd_drop, coef_drop=attn_drop, residual=False))
该类集成BaseGAttN,BaseGAttN中只是包含loss,train,以及acc等通用方法,GAT相关实现都在子类GAT。子类GAT先定义了一个attns列表存储每套注意力机制之后的中心节点聚合结果,每次计算基于 layers.attn_head 方法,跟进,一行一行细细品味
seq_fts = tf.layers.conv1d(seq, out_sz, 1, use_bias=False) # [num_graph, num_node, out_sz]
首先引入卷积操作 完成Wh,既对所有2708个节点的特征向量做了维度转化F => F’,一套注意力之下这个W是共享的 ,这个地方看似是个卷积操作实际上就是个全连接,[1, 2708, 1433] * [1433, 8] => [1, 2708, 8],卷积操作一共有1433 * 8个参数需要训练,在conv1d函数中就是8个卷积,每个卷积尺寸是1 * 1433,一个卷积核映射为[2708, 1],8个就是[2708, 8]
tf.layers.conv1d 一维卷积常用来处理序列数据
inputs:输入,通常是[batch_size, seq_length, embedding_size]格式filters:卷积核个数,有多少种不同的卷积核,和输出的个数对应kernel_size:一个卷积核的尺寸,只要设置一个数值,代表纵向尺寸,横向尺寸和embedding_size一致
卷积核在seq_length上从上往下滑,左右由于横向尺寸和embedding_size一致不滑动,效果图类似如下

Wh一维卷积操作
继续往下看
f_1 = tf.layers.conv1d(seq_fts, 1, 1) # [num_graph, num_node, 1]
f_2 = tf.layers.conv1d(seq_fts, 1, 1) # [num_graph, num_node, 1]
这个地方是论文aWh部分,分别代表某个Wh作为中心节点和邻居的情况,这个地方和论文有些不一致,对于邻居和中心他分别算了两个a,而不是论文中的Wh拼接之后接一个a,卷积操作中第一个1代表卷积核个数为1,因此从[1, 2708, 8] => [1, 2708, 1],最后每一个节点算出一个值作为aWh。继续往下看
logits = f_1 + tf.transpose(f_2, [0, 2, 1])
此步骤从为[1, 2708, 1] + [1, 1, 2708] => [1, 2708, 2708], 相当于笛卡尔积实现了全图上面每两个节点(包括自身和自身)的aWh的和,就是某点做为中心时的awh结果+某点作为邻居时的awh结果 ,结合之前的两行代码,这个地方和论文在处理aWh的时候存在差异:
- 原论文 :邻居j和中心i节点的特征向量,经过共享的W变换维度之后,拼接,使用一个a特征向量加权求和为一个值作为ij的初步注意力结果
- 作者代码实现 :邻居j和中心i的特征向量,用一个共享的W变换维度之后,分别用一个不一样的a计算为一个值,最后两个值相加得到ij的初步注意力结果
总之就是一个完全加权求和,一个分两组加权求和最终两组再求和, 注意这个[1, 2708, 2708]是非对称的,就是说中心i和邻居j的结果和中心j和邻居i的结果不一样 。继续往下看
coefs = tf.nn.softmax(tf.nn.leaky_relu(logits) + bias_mat)
此处开始对所有初步算出来的ij注意力值做softmax归一化,和论文一样aWh的值先套一个 tf.nn.leaky_relu ,此时这个结果还是[1, 2708, 2708],然后作者让它和bias_mat相加,前面说了bias_mat也是一个[1, 2708, 2708]矩阵,且它的对角位置和邻接位置是0,其他位置是一个负极大值,因此两个矩阵对应位置相加之后,真正有邻接关系的以及自身和自身的ij计算结果会原样保留,而没有邻接关系的ij计算结果全部变为负极大值,因此在算每一行的softmax的时候没有邻接关系的节点对处在分母部分的结果是0,真正是和该节点相邻的节点的计算softmax,并且对于没有连接关系的节点对,softmax的分子就是0,导致eij的结果为0,这个会在下面的矩阵点乘起到作用。如下图真正做到了考虑中心的邻居和中心自身计算attention

eij计算
此步骤结束之后所有的 eij 对以笛卡尔积矩阵的方式产出,矩阵每一行求和为1,这个矩阵随着模型的训练每个eij会变动。继续往下看
vals = tf.matmul(coefs, seq_fts)
这个地方开始eijWh部分,Wh第二次被使用到,就是说 在计算Attention的时候需要用到原始特征节点的维度转化结果,在Attention计算出来之后,真正聚合到中心节点形成新的向量特征时,原始特征节点的维度转化结果也需要用到,此处Wh就是彼处的Wh 。这个矩阵点成操作维度为[1, 2708, 2708] * [1, 2708, 8] => [1, 2708, 8],这一步代码相当关键,这一步代码在做 邻居节点以及中心节点的特征往中心节点上聚合 ,打个比方

聚合特征到下一层中心节点表征
下面对于聚合之后的的h'每一行的每一个元素增加一个偏置,初始是0的偏置和激活函数 tf.nn.elu ,所有行共享这个b,b参数个数和最终的特征向量长度一致
ret = tf.contrib.layers.bias_add(vals)
return activation(ret)
总结一下,这个 attn_head函数输出了在一套注意力机制下,每个节点在经过一层聚合之后的特征向量表达 ,在本例中是一个[1, 2708, 8]矩阵,这个8由维度转化向量决定, 所有邻居和自身的8维特征全部水平相加还是8维 。这个函数包含两个drop,一个是 in_drop 是对节点原始特征向量输入的dropout,第二个是 coef_drop 是对eij邻接位置的注意力权重做dropout。在一个for循环结束8个多头注意力存储聚合结果到一个list之后,开始concat操作,和论文一致
h_1 = tf.concat(attns, axis=-1)
attns中每个元素是一个[1, 2708, 8]矩阵,-1为最里面一层concat,因此结果是[1, 2708, 64]下面定义了多次聚合操作
for i in range(1, len(hid_units)):
h_old = h_1
attns = []
for _ in range(n_heads[i]):
attns.append(layers.attn_head(h_1, bias_mat=bias_mat,
out_sz=hid_units[i], activation=activation,
in_drop=ffd_drop, coef_drop=attn_drop, residual=residual))
h_1 = tf.concat(attns, axis=-1)
作者默认hid_units=[8]因此次内容不走,实际上就是1层的聚合操作,如果指定为[8, 4]就是两次聚合,第二次聚合的维度转化到4维,第二次聚合的输入是第一次的输出h1,再覆盖h1为最新的聚合结果继续往下
out = []
for i in range(n_heads[-1]):
out.append(layers.attn_head(h_1, bias_mat=bias_mat,
out_sz=nb_classes, activation=lambda x: x,
in_drop=ffd_drop, coef_drop=attn_drop, residual=False))
logits = tf.add_n(out) / n_heads[-1]
n_heads作者设置的是[8, 1],因此这个循环只走一次,相当于又进行了一次带有attention的聚合操作,加上之前的就是2层聚合。注意这个 bias_mat 再次被使用,他是一个在外部训练脚本中传入的placeholder,是跟着一起训练的,所有多次调用就是一直在迭代学习。最后和之前一次注意力聚合不一样的是这个没有激活函数 lambda x: x 直接输出邻居节点加权求和的结果以及增加偏置。 在上一次聚合之后输出维度是h1=[1, 2708, 64],这层的聚合输出维度是nb_classes=7,相当于这最后一层聚合直接去比对y值了,这个输出是没有激活函数的 activation=lambda x: x 。在最后一层图聚合中,多头注意力的聚合方式是求均值 tf.add_n(out) / n_heads[-1] ,由于作者的n_heads[-1]是1因此最后一层就一个注意力求均值就是他自己。logits是最终的节点特征表达维度是[1, 2708, 7]。下面这个函数结束回到训练的主脚本,很明显下面要softmax分类了
# 全部去掉batch_size这个维度
log_resh = tf.reshape(logits, [-1, nb_classes]) # [2708, 7]
lab_resh = tf.reshape(lbl_in, [-1, nb_classes]) # [2708, 7]
msk_resh = tf.reshape(msk_in, [-1]) # [2708]
loss = model.masked_softmax_cross_entropy(log_resh, lab_resh, msk_resh)
accuracy = model.masked_accuracy(log_resh, lab_resh, msk_resh)
以上代码作者去掉了最外面一个维度batch_size然后开始计算loss和acc。下面开始训练,在training中作者加入了参数的l2 loss增加到总loss上一起迭代
train_op = model.training(loss, lr, l2_coef)
下面不用看了,就是常规训练操作了,全代码看完。
代码链路理解

GAT链路分析
从图上而言整个链路不复杂, 两层 图聚合,第一层采用8个注意力,最终结果 横向拼接 作为第一层的输出, 每个注意力会训练一个eij邻接矩阵作为中心节点和中心节点自身以及邻居的权重参数 ,第二层仅有1个注意力,如果有多个则采用向量求平均的方式,第二层的输出直接映射到label计算softmax loss进行模型迭代训练.以下是需要训练的参数:
第一层 :W(conv1d):1433 × 8 × 8 = 91712a(conv1d):8 × 1 × 2 × 8 = 128a的b部分(conv1d):1 × 1 × 2 × 8 = 16h的b部分(add_bais): 8 × 8 = 64 第二层 W(conv1d):64 × 7 × 1 = 448a(conv1d):7 × 1 × 2 × 1 = 14a的b部分(conv1d):1 × 1 × 2 × 8 = 16h的b部分(add_bais): 7 × 1 = 7 总计 :92405个参数,一套注意力机制下的W,a,b共享参数
GAT在GraphSage下实现的思考
作者代码还是基于全图的,当有新的节点的时候这个代码无法跑,因此还是要转化为 GraphSAGE 策略才能用。先看一下 GraphSAGE + GNN ,他是对邻居做一度二度采样,然后所有二度节点采用相加求均值的方式聚合,所以邻居节点的顺序不一样不重要,邻居个数不一样也不重要。

GraphSAGE+GNN
现在换成GAT,以一层聚合为例,GraphSAGE采样之后的输入不变,他的数据流转如下,一定要从全图的角度里面跳出来。以第一层两个头的注意力为例,他的链路应该是这样的

GraphSAGE+GAT
只要保证了训练和预测一层二层采样的数量一致,比如一层10,二层25,训练预测一致,w和a的参数全部在tensorflow的图里面,可以搞,全文完。