GAT源码分析——从DEBUG走起

近期的学习中,总会遇到一个问题:好不容易弄懂了一个个数学公式,但是真正在实现的时候不知道代码怎么去写。因此,参考已有的模型代码区复现分析变得重要起来,今天选择了一个相对简单的GAT模型,结合已经掌握的GAT相关知识,从源码级别DEBUG走起。

前置工作

代码结构树

pyGAT

├── data

│ └── cora

│ ├── README

│ ├── cora.cites

│ └── cora.content

├── LICENSE

├── README.md

├── layers.py

├── models.py

├── requirements.txt

├── train.py

├── utils.py

Cora数据集简介

cora.content中包含了七类机器学习的论文,共计2708行,每行以ID起始,中间是1433维的one-hot特征,最后一列是对应的类别

cora.cites中每一行由两个数字构成,第一个数字代表被引用论文的ID。第二个数字代表引用前面论文的那篇论文的ID

加载数据

1a1fc7ff1fac4460b58e8948edd0f381.png

adj是全部文章的引用关系构成的图做过归一化后的邻接矩阵(2708*2708),features为2708篇文章每篇的1433个特征,idx_test,idx_train,idx_val分别是划分出来的测试集训练集与验证集,labels为文章的分类标签

模型定义

GAT初始化

在model处单步步入,看GAT模型类构建的方法

models.py

class GAT(nn.Module):
    def __init__(self, nfeat, nhid, nclass, dropout, alpha, nheads):
        """Dense version of GAT."""
        super(GAT, self).__init__()
        self.dropout = dropout

        self.attentions = [GraphAttentionLayer(nfeat, nhid, dropout=dropout, alpha=alpha, concat=True) for _ in range(nheads)]
        for i, attention in enumerate(self.attentions):
            self.add_module('attention_{}'.format(i), attention)

        self.out_att = GraphAttentionLayer(nhid * nheads, nclass, dropout=dropout, alpha=alpha, concat=False)  # 第二层(最后一层)的attention layer

    def forward(self, x, adj):
        x = F.dropout(x, self.dropout, training=self.training)
        x = torch.cat([att(x, adj) for att in self.attentions], dim=1)  # 将每层attention拼接
        x = F.dropout(x, self.dropout, training=self.training)
        x = F.elu(self.out_att(x, adj))   # 第二层的attention layer
        return F.log_softmax(x, dim=1)

定义第一层GraphAttentionLayer

在第7行,建立了第一个层GraphAttentionLayer,其中nheads参数表示了这是一个多头的GAT

fc38c00bfc8349479aeb7f511d680439.png

接着定义了第一层的GraphAttentionLayer,步入里面,看其中的init方法

layers.py

def __init__(self, in_features, out_features, dropout, alpha, concat=True):
    super(GraphAttentionLayer, self).__init__()
    self.dropout = dropout
    self.in_features = in_features
    self.out_features = out_features
    self.alpha = alpha
    self.concat = concat

    self.W = nn.Parameter(torch.empty(size=(in_features, out_features)))
    nn.init.xavier_uniform_(self.W.data, gain=1.414)
    self.a = nn.Parameter(torch.empty(size=(2*out_features, 1)))  # concat(V,NeigV)
    nn.init.xavier_uniform_(self.a.data, gain=1.414)

    self.leakyrelu = nn.LeakyReLU(self.alpha)

首先是对公式中的W的定义,in_features, out_features分别是(1433,8),也就是说W是将1433维的特征转换为8维

接着定义了α向量,作为Whi与Whj向量拼接后的系数,因为是横向拼接所以2*out_features

最后定义了leakyrelu激活函数

6c685a94a27e46ceb502112407deadaf.png

877d101a0aae4f3790a8dd2f395224d5.png

步出GraphAttentionLayer,进入class GAT(nn.Module)line8的循环,因为样例中定义了8个head,因此循环了8次定义了8个GraphAttentionLayer

self.attentions = [GraphAttentionLayer(nfeat, nhid, dropout=dropout, alpha=alpha, concat=True) for _ in range(nheads)]

903d5b665bf3459c87ed1e60ecdc9e0b.png

定义第二层GraphAttentionLayer

回到class GAT(nn.Module)的init中line11,对第二层(最后一层)GraphAttentionLayer进行定义

self.out_att = GraphAttentionLayer(nhid * nheads, nclass, dropout=dropout, alpha=alpha, concat=False)

这里的nhid * nheads为8*8,也就是隐层维度与注意力头数相乘,nclass=7是最后要映射到的7个类别上,对于这一层的GraphAttentionLayer内部同上,不再赘述

模型训练

进行初始化

在train.py中有对cuda的定义,如果设置了CUDA使用GPU算的时要转换为Variable,因为variable是一种可以不断变化的变量,符合反向传播 而tensor不能反向传播

Variable计算时,会逐步生成计算图,这个图将所有的计算节点都连接起来,最后进行loss反向传播时,一次性将所有Variable里的梯度计算出来,然而tensor没有这个能力

train.py

if args.cuda:
    model.cuda()
    features = features.cuda()
    adj = adj.cuda()
    labels = labels.cuda()
    idx_train = idx_train.cuda()
    idx_val = idx_val.cuda()
    idx_test = idx_test.cuda()

features, adj, labels = Variable(features), Variable(adj), Variable(labels)

跟进train()=>model(features, adj)

train.py

def train(epoch):
    t = time.time()
    model.train()
    optimizer.zero_grad()
    output = model(features, adj)  # GAT模块
    loss_train = F.nll_loss(output[idx_train], labels[idx_train])
    acc_train = accuracy(output[idx_train], labels[idx_train])
    loss_train.backward()
    optimizer.step()

再跟进model(features, adj)中的forward

models.py

def forward(self, x, adj):
    x = F.dropout(x, self.dropout, training=self.training)
    x = torch.cat([att(x, adj) for att in self.attentions], dim=1)  # 将每层attention拼接
    x = F.dropout(x, self.dropout, training=self.training)
    x = F.elu(self.out_att(x, adj))   # 第二层的attention layer
    return F.log_softmax(x, dim=1)

也就是在定义好的模型上先进行前向传播,先是第一层的GraphAttentionLayer,在上面line3的self.attentions中步入

走进第一层

layers.py

def forward(self, h, adj):
    Wh = torch.mm(h, self.W) # h.shape: (N, in_features), Wh.shape: (N, out_features)
    a_input = self._prepare_attentional_mechanism_input(Wh)  # 每一个节点和所有节点,特征。(Vall, Vall, feature)
    e = self.leakyrelu(torch.matmul(a_input, self.a).squeeze(2))
    # 之前计算的是一个节点和所有节点的attention,其实需要的是连接的节点的attention系数
    zero_vec = -9e15*torch.ones_like(e)
    attention = torch.where(adj > 0, e, zero_vec)    # 将邻接矩阵中小于0的变成负无穷
    attention = F.softmax(attention, dim=1)  # 按行求softmax。 sum(axis=1) === 1
    attention = F.dropout(attention, self.dropout, training=self.training)
    h_prime = torch.matmul(attention, Wh)   # 聚合邻居函数

    if self.concat:
        return F.elu(h_prime)   # elu-激活函数
    else:
        return h_prime

先看上面line2的h和W,不难看出是通过W参数矩阵对1433维度特征的压缩

5086e52021c64c499559f192311741dd.png

再看上面line3处的self._prepare_attentional_mechanism_input,跟进后发现比较复杂,还好作者给了解释和说明

layers.py

def _prepare_attentional_mechanism_input(self, Wh):
    N = Wh.size()[0] # number of nodes

    # Below, two matrices are created that contain embeddings in their rows in different orders.
    # (e stands for embedding)
    # These are the rows of the first matrix (Wh_repeated_in_chunks): 
    # e1, e1, ..., e1,            e2, e2, ..., e2,            ..., eN, eN, ..., eN
    # '-------------' -> N times  '-------------' -> N times       '-------------' -> N times
    # 
    # These are the rows of the second matrix (Wh_repeated_alternating): 
    # e1, e2, ..., eN, e1, e2, ..., eN, ..., e1, e2, ..., eN 
    # '----------------------------------------------------' -> N times
    # 
    
    Wh_repeated_in_chunks = Wh.repeat_interleave(N, dim=0)  # 复制
    Wh_repeated_alternating = Wh.repeat(N, 1)
    # Wh_repeated_in_chunks.shape == Wh_repeated_alternating.shape == (N * N, out_features)

    # The all_combination_matrix, created below, will look like this (|| denotes concatenation):
    # e1 || e1
    # e1 || e2
    # e1 || e3
    # ...
    # e1 || eN
    # e2 || e1
    # e2 || e2
    # e2 || e3
    # ...
    # e2 || eN
    # ...
    # eN || e1
    # eN || e2
    # eN || e3
    # ...
    # eN || eN

    all_combinations_matrix = torch.cat([Wh_repeated_in_chunks, Wh_repeated_alternating], dim=1)
    # all_combinations_matrix.shape == (N * N, 2 * out_features)

    return all_combinations_matrix.view(N, N, 2 * self.out_features)

为了计算两个相邻节点的attention,这里面并没有提取相邻节点,而是把全局所有的N个节点都一一配对,出了N*N个对,得到了如下的向量

c69db7917f7944b3ad8473825f3b8ef5.png

也就是N*N,2*output_feature,也就是所有节点两两拼接的feature,return到forward(self, h, adj)的line3的a_input

a_input = self._prepare_attentional_mechanism_input(Wh)

但是我们要明确的是,需要求的是相邻节点之间的attention,刚刚上面的只是一个全局的初始化

那么,怎么定义相邻节点的attention呢?

先别着急,接着看,line4其实就是eij

e = self.leakyrelu(torch.matmul(a_input, self.a).squeeze(2))

1cb0c8d19654499ab5937ed10355fcd0.png

接着初始化了一个负无穷的zero_vec矩阵

zero_vec = -9e15*torch.ones_like(e)

因为之前定义过一个叫adj的邻接矩阵,储存了节点的相连关系,所以对attention是这样定义的

attention = torch.where(adj > 0, e, zero_vec)

通过这一步,将邻接矩阵adj中大于0(有边的)置为刚刚定义的eij,小于0的变成负无穷,可以看一下效果

e09fa727aaa44cc0a409b1cc3adbf13c.png

但是这并不是最终attention的系数,还要通过softmax归一化

63593b90c04a40eab7039514a258f29d.png

归一化之后的attention,每一个节点对行求和之后系数是1,也就很好的说明了相邻节点对于目标节点的权重系数之和为1

e3fbeb02139844ec882da79710dc50f1.png

求完了attention之后,来求

924e399ecf2546fbb8c44dea644eaaff.png

也就是

h_prime = torch.matmul(attention, Wh) # 聚合邻居函数

得到了表示2708个节点隐层的矩阵

a12f0734568f46ec8f72bf3476f6c9e1.png

又因为定义了8个attention的head,这里循环了8次使用

x = torch.cat([att(x, adj) for att in self.attentions], dim=1) # 将8层attention拼接

也就是

af895bbc164b4323a38d7a5afd0af74e.png

好,Dropout之后,到此,第一层就结束了

走进第二层

再回到model(features, adj)中的forward的line5

x = F.elu(self.out_att(x, adj))

也就是第二层的GraphAttentionLayer,h是刚刚拼好的x

281b52fbba084157a2acc41a3e325c2d.png

因为最后是7分类,所以这里W的维度有所不同

91a4c1d72a804164891acf9ab7fea238.png

其他部分没有什么变化,第二层最终输出的就是,接上一个softmax输出为output

b074fed08de24dbbaa3e792e5b92d396.png

再贴一遍train

train.py

def train(epoch):
    t = time.time()
    model.train()
    optimizer.zero_grad()
    output = model(features, adj)  # GAT模块
    loss_train = F.nll_loss(output[idx_train], labels[idx_train])
    acc_train = accuracy(output[idx_train], labels[idx_train])
    loss_train.backward()
    optimizer.step()

上面的line6的nll_loss怎么理解?其实就是nn.CrossEntropyLoss

b41e3c0534284100b015f9684dc5ac37.png

再loss_train.backward()进行反向传播

打印output,在7列中哪个值最大,也就最后可能是哪一类标签的节点

6577d523d40747198b738b0c01b81ac9.png

这大概也就把GAT的源码部分撸完了,周末快乐!

参考:

Cora数据集介绍 - 知乎

GAT源码剖析_行走天涯的豆沙包的博客-CSDN博客_gat源码

 

  • 9
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值