图神经网络:GAT图注意力网络原理和源码解读(tensorflow)

标签:图神经网络图注意力网络注意力机制GATtensorflow

本文内容分为三块:

  • GAT原理扫盲
  • GAT源码阅读(tensorflow)
  • GAT源码链路分析
  • GAT的GraphSAGE策略实现分析

原理初步理解

(1)从GNN,GCN到GAT

先看个哔哩哔哩视频理解一下,链接地址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度就是个逻辑回归一样的全连接。hihj代表的节点的特征向量或者当下的特征表达,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/gp/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: 
model: 
(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 = 91712
a(conv1d):8 × 1 × 2 × 8 = 128
a的b部分(conv1d):1 × 1 × 2 × 8 = 16
h的b部分(add_bais): 8 × 8 = 64
第二层
W(conv1d):64 × 7 × 1 = 448
a(conv1d):7 × 1 × 2 × 1 = 14
a的b部分(conv1d):1 × 1 × 2 × 8 = 16
h的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的图里面,可以搞,全文完。

你可能感兴趣的:(图神经网络:GAT图注意力网络原理和源码解读(tensorflow))