图神经网络入门实例讲解 (图神经网络做知识图谱)

标签: 图神经网络 图注意力网络 注意力机制 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的图里面,可以搞,全文完。