0


图卷积神经网络GCN、GAT的原理及Pytorch实现

一、前言

ICLR作为机器学习方向的顶会,最近看了ICLR2023 Openreview的论文投稿分析,通过2022和2023年论文关键词、标题高频词等信息的可视化比较。根据前十的关键词频率排名频率来看,基本上和去年保持一致,大火的领域依旧大火。但是可以明显看到前五名关键词的频率差距逐渐减少。 有意思的是representation learning这一关键词终于又重回前三,再次为「国际学习表征会议」(ICLR)正名。graph neural network这一关键词则是掉了一名,与representation learning交换了位置,但相比于去年的频率仍然火爆。GCN作为GNN的变种,依然是一个发论文的热门。
Keyword20222023reinforcement learning11deep learning22representation learning43graph neural network34transformer55federate learning76self-supervised learning67contrastive learning108robustness99generative model810
从排名变化上来看,尽管graph neural network在关键词频率排名降低了一名,但是在标题中graph却涨了一名。
Title20222023representation11graph32data63reinforcement24transformer75training56image107efficient98language159federate1410

二、图的概念

在讨论GNN之前,我们先来了解一下什么是。在计算机科学中,图是由节点和边两部分组成的一种数据结构。图G可以通过节点集合V和它包含的边E来进行描述。如下图所示:

三、GNN图神经网络

GNN全称----图神经网络,它是一种直接作用于图结构上的神经网络。我们可以把图中的每一个节点 V 当作个体对象,而每一条边 E 当作个体与个体间的某种联系,所有节点组成的关系网就是最后的图 U。

二元组的定义
图G是一个有序二元组(V,E),其中V称为顶集(Vertices Set),E称为边集(Edges set),E与V不相交。它们亦可写成V(G)和E(G)。
E的元素都是二元组,用(x,y)表示,其中x,y∈V。

三元组的定义
图G是指一个三元组(V,E,I),其中V称为顶集,E称为边集,E与V不相交;I称为关联函数,I将E中的每一个元素映射到 。如果e被映射到(u,v),那么称边e连接顶点u,v,而u,v则称作e的端点,u,v此时关于e相邻。同时,若两条边i,j有一个公共顶点u,则称i,j关于u相邻。

我们利用图神经网络的目的就是整合特征

GNN的主要目的就是用图结构提取特征,最终是做节点分类还是关联预测由我们自己决定。

四、GNN与CNN、RNN的区别

都是提取特征的神经网络,那为什么要利用图模型来提取呢?CNN的卷积和RNN的递归方式不行吗?
答案还真不行,或者说十分麻烦。

因为GNN面向的输入对象其实都是结构不规则、不固定的数据结构,而CNN面向的图像数据和RNN面向的文本数据的格式都是固定的,所以自然不能混为一谈。因此,面对本身结构、彼此关系都不固定的节点特征,必须需要借助图结构来表征它们的内在联系。

五、GNN原理

5.1 邻接矩阵

首先引入邻接矩阵(Adjacency Matrix)的概念,它来表示节点与节点间的连接关系,即Edge的关系,矩阵的具体样式如下图所示:

5.2 聚合操作

GNN的输入一般是每个节点的起始特征向量和表示节点间关系的邻接矩阵,有了这两个输入信息,接下来就是聚合操作了。所谓的聚合,其实就是将周边与节点 Vi 有关联的节点{Va , Vb , . . .}加权到Vi上,当作一次特征更新。同理,对图中的每个节点进行聚合操作,更新所有图节点的特征。

聚合操作的方式多种多样,可根据任务的不同自由选择,如下图所示:

当然对这个图节点进行完了一次聚合操作后,还需要再进行一波 w 的加权,这里的 w 需要网络自己学习。

5.3 多层迭代

CNN,RNN都可以有多个层,那么GNN也当然可以。一次图节点聚合操作与 w ww 加权,可以理解为一层,后面再重复进行聚合、加权,就是多层迭代了。一般GNN只要3~5层即可,所以训练GNN对算力要求很低。如下图所示:

六、GCN图卷集神经网络

论文:Semi-Supervised Classification with Graph Convolutional Networks(ICLR2017)

https://arxiv.org/abs/1609.02907)

GCN,图卷积神经网络,实际上跟CNN的作用一样,就是一个特征提取器,只不过它的对象是图数据。GCN精妙地设计了一种从图数据中提取特征的方法,从而让我们可以使用这些特征去对图数据进行节点分类(node classification)、图分类(graph classification)、边预测(link prediction),还可以顺便得到图的嵌入表示(graph embedding),可见用途广泛。因此现在人们脑洞大开,让GCN到各个领域中发光发热。

GCN的核心部分是什么样子:

假设我们手头有一批图数据,其中有N个节点(node),每个节点都有自己的特征,我们设这些节点的特征组成一个N×D维的矩阵X,然后各个节点之间的关系也会形成一个N×N维的矩阵A,也称为邻接矩阵(adjacency matrix)。X和A便是我们模型的输入。

如下图:

Labeled graph(标签图),图的路径和节点标签

Degree matrix(度矩阵) ,每个节点的度,即与每个节点连接的节点数量

Adjacency matrix(邻接矩阵),每个节点连接的节点位置

Laplacian matrix(拉普拉斯矩阵),类比离散拉普拉斯算子定义图上拉普拉斯算子为节点与邻居节点特征信息f差异的和

L = D - A

GCN也是一个神经网络层,它的层与层之间的传播方式是:

这个公式中:

  • A波浪=A+I,I是单位矩阵
  • D波浪是A波浪的度矩阵(degree matrix),公式为
  • H是每一层的特征,对于输入层的话,H就是X
  • σ是非线性激活函数

我们先不用考虑为什么要这样去设计一个公式。我们现在只用知道:

这个部分,是可以事先算好的,因为D波浪由A计算而来,而A是我们的输入之一。

所以对于不需要去了解数学原理、只想应用GCN来解决实际问题的人来说,你只用知道:哦,这个GCN设计了一个牛逼的公式,用这个公式就可以很好地提取图的特征。这就够了,毕竟不是什么事情都需要知道内部原理,这是根据需求决定的。

为了直观理解,我们用论文中的一幅图:

上图中的GCN输入一个图,通过若干层GCN每个node的特征从X变成了Z,但是,无论中间有多少层,node之间的连接关系,即A,都是共享的。

假设我们构造一个两层的GCN,激活函数分别采用ReLU和Softmax,则整体的正向传播的公式为:

最后,我们针对所有带标签的节点计算cross entropy损失函数:

就可以训练一个node classification的模型了。由于即使只有很少的node有标签也能训练,作者称他们的方法为半监督分类。

当然,你也可以用这个方法去做graph classification、link prediction,只是把损失函数给变化一下即可。

最终的层特征传播公式:

因为即使不训练,完全使用随机初始化的参数W,GCN提取出来的特征就以及十分优秀了!这跟CNN不训练是完全不一样的,后者不训练是根本得不到什么有效特征的。

我们看论文原文:

然后作者做了一个实验,使用一个俱乐部会员的关系网络,使用随机初始化的GCN进行特征提取,得到各个node的embedding,然后可视化:

可以发现,在原数据中同类别的node,经过GCN的提取出的embedding,已经在空间上自动聚类了。而这种聚类结果,可以和DeepWalk、node2vec这种经过复杂训练得到的node embedding的效果媲美了。

说的夸张一点,比赛还没开始,GCN就已经在终点了。还没训练就已经效果这么好,那给少量的标注信息,GCN的效果就会更加出色。

其他关于GCN的点滴:

  1. 对于很多网络,我们可能没有节点的特征,这个时候可以使用GCN吗?答案是可以的,如论文中作者对那个俱乐部网络,采用的方法就是用单位矩阵 I 替换特征矩阵 X
  2. 我没有任何的节点类别的标注,或者什么其他的标注信息,可以使用GCN吗?当然,就如前面讲的,不训练的GCN,也可以用来提取graph embedding,而且效果还不错。
  3. GCN网络的层数多少比较好?论文的作者做过GCN网络深度的对比研究,在他们的实验中发现,GCN层数不宜多,2-3层的效果就很好了。

七、GCN的Pytorch实现

7.1 数据集介绍

1. 数据集结构
论文中所使用的数据集合是Cora数据集下载地址https://linqs-data.soe.ucsc.edu/public/lbc/cora.tgz

总共有三部分构成:cora.content cora.cites 和README。

README: 对数据集内容的描述;

cora.content: 里面包含有每一篇论文各自独立的信息;

31336   0  0  0  0  0  0  0  0  0  0  0  0  0   ... Neural_Networks
1061127 0  0  0  0  0  0  0  0  0  0  0  0  1   ... Rule_Learning

......

该文件总共包含2078行,每一行代表一篇论文,由论文编号、论文词向量(1433维)和论文的类别三个部分组成

cora.cites: 里面包含有各论文之间的相互引用记录

该文件总共包含5429行,每一行是两篇论文的编号,表示右边的论文引用左边的论文。

35  1033
35 103482
35 103515
35 1050679
35 1103960
35 1103985
35 1109199
35 1112911
35 1113438
35 1113831
35 1114331
35 1117476

2. 数据集内容分析
该数据集总共有2078个样本,而且每个样本都为一篇论文。根据README可知,所有的论文被分为了7个类别,分别为:

基于案列的论文 Case_Based

基于遗传算法的论文 Genetic_Algorithms

基于神经网络的论文 Neural_Networks

基于概率方法的论文 Probabilistic_Methods

基于强化学习的论文 Reinforcement_Learning

基于规则学习的论文 Rule_Learning

理论描述类的论文 Theory

此外,为了区分论文的类别,使用一个1433维的词向量,对每一篇论文进行描述,该向量的每个元素都为一个词语是否在论文中出现,如果出现则为“1”,否则为“0”。

3. 数据流变化

(1) idx_features_labels (数据包含id,features和labels,维度为2708*1433)

idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
                                        dtype=np.dtype(str))

为content里面的内容,包含idx(论文id),features(论文词向量表示),labels(论文标签)

(2) features(节点的特征,维度为 2708 * 1433,类型为 np.ndarray,稀疏矩阵样式为行列,值)

features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)

(3) labels(节点的标签,维度为2708*7,总共包括7个类别,类型为 np.ndarray)

labels = encode_onehot(idx_features_labels[:, -1])

构建图

(4) idx (节点的id,维度为2708*1)

idx = np.array(idx_features_labels[:, 0], dtype=np.int32)

(5) idx_map (对每个id进行编号,dict:2708)

idx_map = {j: i for i, j in enumerate(idx)}

{31336: 0, 1061127: 1, 1106406: 2, 13195: 3, 37879: 4, 1126012: 5, 1107140: 6, 1102850: 7, 31349: 8, 1106418: 9, 1123188: 10, 1128990: 11, 109323: 12, 217139: 13, 31353: 14, 32083: 15, 1126029: 16, 1118017: 17, 49482: 18, 753265: 19, 249858: 20, 1113739: 21, 48766: 22, 646195: 23, 1126050: 24, 59626: 25, 340299: 26, 354004: 27, 242637: 28, 1106492: 29, 74975: 30, 1152272: 31, 100701: 32, 66982: 33, 13960: 34, 13966: 35, 66990: 36, 182093: 37, 182094: 38, 13972: 39, 13982: 40, 16819: 41, 273152: 42, 237521: 43, 1153703: 44, 32872: 45, 284025: 46, 218666: 47, 16843: 48, 1153724: 49, 1153728: 50, 158098: 51, 8699: 52, 1134865: 53, 28456: 54, 248425: 55, 1112319: 56, 28471: 57, 175548: 58, 696345: 59, 28485: 60, 1139195: 61, 35778: 62, 28491: 63, 310530: 64, 1153784: 65, 1481: 66, 1153786: 67, 13212: 68, 1111614: 69, 5055: 70, 4329: 71, 330148: 72, 1105062: 73, 4330: 74, 5062: 75, 4335: 76, 158812: 77, 40124: 78, 1103610: 79, 688361: 80, 302545: 81, 20534: 82, 1031453: 83, 5086: 84, 193742: 85, 58268: 86, 424: 87, 40151: 88, 636098: 89, 260121: 90, 950052: 91, 434: 92, 1131270: 93, 1131274: 94, 1131277: 95, 1110947: 96, 662279: 97, 1139928: 98, 153063: 99...

(6) edges_unordered(构建节点间的连接信息,维度为5429*2)

edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
                                    dtype=np.int32)

(7) edges(根据 idx_map和edge_undered构建边的信息,维度为5429*2)

edges = np.array(list(map(idx_map.get, edges_unordered.flatten())),
                     dtype=np.int32).reshape(edges_unordered.shape) 
map函数会根据提供的函数对指定序列做映射,flatten()函数就是降到一维 

(备注:163对应cora.cites文件的第一列的第一个编号,402对应cora.cites文件中的第二列的第一个编号)

(8) adj (构建邻接矩阵,维度为2708*2708)

adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
                        shape=(labels.shape[0], labels.shape[0]),
                        dtype=np.float32)

# build symmetric adjacency matrix
adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

(9) 特征归一化函数

#特征归一化函数
# 归一化函数实现的方式:对传入特征矩阵的每一行分别求和,取到数后就是每一行非零元素归一化的值,然后与传入特征矩阵进行点乘
def normalize(mx):
    """Row-normalize sparse matrix""" #对稀疏矩阵进行正则化处理
    rowsum = np.array(mx.sum(1))      #会得到一个(2708,1)的矩阵
    r_inv = np.power(rowsum, -1).flatten() #数组元素求-1次方,得到(2708,)的元组
    # 在计算倒数的时候存在一个问题,如果原来的值为0,则其倒数为无穷大,因此需要对r_inv中无穷大的值进行修正,更改为0
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx
features = normalize(features)

adj = normalize(adj + sp.eye(adj.shape[0]))

7.2 代码详解

代码总览(后面会上传到github上)

1. utils.py

import numpy as np
import scipy.sparse as sp
import torch

#特征独热码处理
def encode_onehot(labels):
    # 将所有的标签整合成一个不重复的列表
    classes = set(labels)     # set() 函数创建一个无序不重复元素集
    '''enumerate()函数生成序列,带有索引i和值c。
        这一句将string类型的label变为int类型的label,建立映射关系
        np.identity(len(classes)) 为创建一个classes的单位矩阵
        创建一个字典,索引为 label, 值为独热码向量(就是之前生成的矩阵中的某一行)'''
    classes_dict = {c: np.identity(len(classes))[i, :] for i, c in
                    enumerate(classes)}
    # 为所有的标签生成相应的独热码
    # map() 会根据提供的函数对指定序列做映射。
    # 这一句将string类型的label替换为int类型的label
    labels_onehot = np.array(list(map(classes_dict.get, labels)),
                             dtype=np.int32)
    return labels_onehot

#数据加载和处理
def load_data(path="../data/cora/", dataset="cora"):
    """Load citation network dataset (cora only for now)"""
    print('Loading {} dataset...'.format(dataset))
    #数据包含id,features和labels
    idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset),
                                        dtype=np.dtype(str))
    #节点的特征,维度为 2708 * 1433,类型为 np.ndarray
    features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
    #节点的标签,总共包括7个类别,类型为 np.ndarray
    labels = encode_onehot(idx_features_labels[:, -1])

    # build graph
    #节点的id
    idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
    #每个id进行编号
    idx_map = {j: i for i, j in enumerate(idx)}
    #
    edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset),
                                    dtype=np.int32)
    edges = np.array(list(map(idx_map.get, edges_unordered.flatten())),
                     dtype=np.int32).reshape(edges_unordered.shape)   #map会根据提供的函数对指定序列做映射,flatten()就是降到一维
    adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])),
                        shape=(labels.shape[0], labels.shape[0]),
                        dtype=np.float32)

    # build symmetric adjacency matrix
    adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

    features = normalize(features)
    adj = normalize(adj + sp.eye(adj.shape[0]))

    idx_train = range(140)
    idx_val = range(200, 500)
    idx_test = range(500, 1500)

    features = torch.FloatTensor(np.array(features.todense()))
    labels = torch.LongTensor(np.where(labels)[1])
    adj = sparse_mx_to_torch_sparse_tensor(adj)

    idx_train = torch.LongTensor(idx_train)
    idx_val = torch.LongTensor(idx_val)
    idx_test = torch.LongTensor(idx_test)

    return adj, features, labels, idx_train, idx_val, idx_test

#特征归一化函数
def normalize(mx):
    """Row-normalize sparse matrix""" #对稀疏矩阵进行正则化处理
    rowsum = np.array(mx.sum(1))      #会得到一个(2708,1)的矩阵
    r_inv = np.power(rowsum, -1).flatten() #数组元素求-1次方
    # 在计算倒数的时候存在一个问题,如果原来的值为0,则其倒数为无穷大,因此需要对r_inv中无穷大的值进行修正,更改为0
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx

#精度计算函数
def accuracy(output, labels):
    # 使用type_as(tesnor)将张量转换为给定类型的张量
    preds = output.max(1)[1].type_as(labels)
    # 记录等于preds的label eq:equal
    correct = preds.eq(labels).double()
    correct = correct.sum()
    return correct / len(labels)

#将scipy稀疏矩阵转换为Torch稀疏张量
def sparse_mx_to_torch_sparse_tensor(sparse_mx):
    """Convert a scipy sparse matrix to a torch sparse tensor."""
    """
        numpy中的ndarray转化成pytorch中的tensor : torch.from_numpy()
        pytorch中的tensor转化成numpy中的ndarray : numpy()
        """
    sparse_mx = sparse_mx.tocoo().astype(np.float32)
    indices = torch.from_numpy(
        np.vstack((sparse_mx.row, sparse_mx.col)).astype(np.int64))
    values = torch.from_numpy(sparse_mx.data)
    shape = torch.Size(sparse_mx.shape)
    return torch.sparse.FloatTensor(indices, values, shape)

2. models.py

import torch.nn as nn
import torch.nn.functional as F
from pygcn.layers import GraphConvolution

class GCN(nn.Module):
    def __init__(self, nfeat, nhid, nclass, dropout):
        super(GCN, self).__init__()

        self.gc1 = GraphConvolution(nfeat, nhid)   #卷积层1:输入的特征为nfeat,维度是2708,输出的特征为nhid,维度是16
        self.gc2 = GraphConvolution(nhid, nclass)  #卷积层2:输入的特征为nhid,维度是16,输出的特征为nclass,维度是7(即类别的结果)
        self.dropout = dropout

    def forward(self, x, adj):                     #forward是向前传播函数,最终得到网络向前传播的方式为:relu–>fropout–>gc2–>softmax
        x = F.relu(self.gc1(x, adj))
        x = F.dropout(x, self.dropout, training=self.training)
        x = self.gc2(x, adj)
        return F.log_softmax(x, dim=1)

3. layers.py

import math

import torch

from torch.nn.parameter import Parameter
from torch.nn.modules.module import Module

#layers中主要定义了图数据实现卷积操作的层,类似于CNN中的卷积层,只是一个层而已
class GraphConvolution(Module):
    """
    Simple GCN layer, similar to https://arxiv.org/abs/1609.02907
    """
    #GraphConvolution作为一个类,首先需要定义其相关属性。主要定义了其输入特征in_feature、输出特征out_feature两个输入,
    # 以及权重weight和偏移向量bias两个参数,同时调用了其参数初始化的方法
    def __init__(self, in_features, out_features, bias=True):
        super(GraphConvolution, self).__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.weight = Parameter(torch.FloatTensor(in_features, out_features))
        if bias:
            self.bias = Parameter(torch.FloatTensor(out_features))
        else:
            self.register_parameter('bias', None)
        self.reset_parameters()

    #为了让每次训练产生的初始参数尽可能的相同,从而便于实验结果的复现,可以设置固定的随机数生成种子
    def reset_parameters(self):
        stdv = 1. / math.sqrt(self.weight.size(1))  #标准偏差
        self.weight.data.uniform_(-stdv, stdv)
        if self.bias is not None:
            self.bias.data.uniform_(-stdv, stdv)

    #此处主要定义的是本层的前向传播,通常采用的是 A ∗ X ∗ W A * X * WA∗X∗W的计算方法。由于A是一个sparse变量,因此其与X进行卷积的结果也是稀疏矩阵
    def forward(self, input, adj):
        support = torch.mm(input, self.weight)
        output = torch.spmm(adj, support)
        if self.bias is not None:
            return output + self.bias
        else:
            return output
    
    #__repr__()方法是类的实例化对象用来做“自我介绍”的方法,默认情况下,它会返回当前对象的“类名+object at+内存地址”, 
    # 而如果对该方法进行重写,可以为其制作自定义的自我描述信息
    def __repr__(self):
        return self.__class__.__name__ + ' (' \
               + str(self.in_features) + ' -> ' \
               + str(self.out_features) + ')'

4. train.py

from __future__ import division
from __future__ import print_function

import time
import argparse
import numpy as np

import torch
import torch.nn.functional as F
import torch.optim as optim

from pygcn.utils import load_data, accuracy
from pygcn.models import GCN
import matplotlib.pyplot as plt

# Training settings
parser = argparse.ArgumentParser()
parser.add_argument('--no-cuda', action='store_true', default=False,
                    help='Disables CUDA training.')
parser.add_argument('--fastmode', action='store_true', default=False,
                    help='Validate during training pass.')
parser.add_argument('--seed', type=int, default=42, help='Random seed.')
parser.add_argument('--epochs', type=int, default=200,
                    help='Number of epochs to train.')
parser.add_argument('--lr', type=float, default=0.01,
                    help='Initial learning rate.')
parser.add_argument('--weight_decay', type=float, default=5e-4,
                    help='Weight decay (L2 loss on parameters).')
parser.add_argument('--hidden', type=int, default=16,
                    help='Number of hidden units.')
parser.add_argument('--dropout', type=float, default=0.5,
                    help='Dropout rate (1 - keep probability).')

args = parser.parse_args()
args.cuda = not args.no_cuda and torch.cuda.is_available()

np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.cuda:
    torch.cuda.manual_seed(args.seed)

# Load data
adj, features, labels, idx_train, idx_val, idx_test = load_data()

# Model and optimizer
model = GCN(nfeat=features.shape[1],
            nhid=args.hidden,
            nclass=labels.max().item() + 1,
            dropout=args.dropout)
optimizer = optim.Adam(model.parameters(),
                       lr=args.lr, weight_decay=args.weight_decay)

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()

def train():
    loss_history = []
    val_acc_history = []
    t = time.time()
    model.train()
    if not args.fastmode:
        # Evaluate validation set performance separately,
        # deactivates dropout during validation run.
        model.eval()
        output = model(features, adj)

    t_total = time.time()
    for epoch in range(args.epochs):
        optimizer.zero_grad()
        output = model(features, adj)
        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()
        loss_val = F.nll_loss(output[idx_val], labels[idx_val])
        acc_val = accuracy(output[idx_val], labels[idx_val])
        loss_history.append(loss_val.item())
        val_acc_history.append(acc_val.item())
        print('Epoch: {:04d}'.format(epoch+1),
              'loss_train: {:.4f}'.format(loss_train.item()),
              'acc_train: {:.4f}'.format(acc_train.item()),
              'loss_val: {:.4f}'.format(loss_val.item()),
              'acc_val: {:.4f}'.format(acc_val.item()),
              'time: {:.4f}s'.format(time.time() - t))

    print("Optimization Finished!")
    print("Total time elapsed: {:.4f}s".format(time.time() - t_total))
    return loss_history,val_acc_history

def test():
    model.eval()
    output = model(features, adj)
    # test_mask_logits = output[mask]
    loss_test = F.nll_loss(output[idx_test], labels[idx_test])
    acc_test = accuracy(output[idx_test], labels[idx_test])
    print("Test set results:",
          "loss= {:.4f}".format(loss_test.item()),
          "accuracy= {:.4f}".format(acc_test.item()))

#绘制loss和acc曲线
def plot_loss_with_acc(loss_history, val_acc_history):
    fig = plt.figure()
    ax1 = fig.add_subplot(111)
    ax1.plot(range(len(loss_history)), loss_history,
             c=np.array([255, 71, 90]) / 255.)
    plt.ylabel('Loss')

    ax2 = fig.add_subplot(111, sharex=ax1, frameon=False)
    ax2.plot(range(len(val_acc_history)), val_acc_history,
             c=np.array([79, 179, 255]) / 255.)
    ax2.yaxis.tick_right()
    ax2.yaxis.set_label_position("right")
    plt.ylabel('ValAcc')

    plt.xlabel('Epoch')
    plt.title('Training Loss & Validation Accuracy')
    plt.show()

# Train model
loss, val_acc = train()
plot_loss_with_acc(loss, val_acc)

#Testing
test()

# 绘制测试数据的TSNE降维图
output = model(features, adj)
output = output.cpu()
output = output[idx_test].detach().numpy()
print(output)
print(output.shape)
from sklearn.manifold import TSNE
tsne = TSNE()
out = tsne.fit_transform(output)
fig = plt.figure()
labels_test = labels[idx_test].detach().cpu().numpy()
for i in range(7):
    indices = labels_test == i
    # print(indices)
    x, y = out[indices].T
    plt.scatter(x, y, label=str(i))
plt.legend()
plt.show()

7.3 代码运行结果

(1)loss和acc训练曲线

200epochs训练之后,Test set results: **loss= 0.6594 accuracy= 0.8360 **

(2)测试数据的TSNE降维图

八、GAT 图注意力网络 Graph Attention Network

   图神经网络 GNN 把深度学习应用到图结构 (Graph) 中,其中的图卷积网络 GCN 可以在 Graph 上进行卷积操作。但是 GCN 存在一些缺陷:依赖拉普拉斯矩阵,不能直接用于有向图;模型训练依赖于整个图结构,不能用于动态图;卷积的时候没办法为邻居节点分配不同的权重。因此 2018 年图注意力网络 GAT (Graph Attention Network) 被提出,解决 GCN 存在的问题。 

论文下载地址:https://arxiv.org/abs/1710.10903

GCN 存在下面的缺点

GCN 假设图是无向的,因为利用了对称的拉普拉斯矩阵 (只有邻接矩阵 A 是对称的,拉普拉斯矩阵才可以正交分解),不能直接用于有向图。GCN 的作者为了处理有向图,需要对 Graph 结构进行调整,要把有向边划分成两个节点放入 Graph 中。例如 e1、e2 为两个节点,r 为 e1,e2 的有向关系,则需要把 r 划分为两个关系节点 r1 和 r2 放入图中。连接 (e1, r1)、(e2, r2)。

GCN 不能处理动态图,GCN 在训练时依赖于具体的图结构,测试的时候也要在相同的图上进行。因此只能处理 transductive 任务,不能处理 inductive 任务。transductive 指训练和测试的时候基于相同的图结构,例如在一个社交网络上,知道一部分人的类别,预测另一部分人的类别。inductive 指训练和测试使用不同的图结构,例如在一个社交网络上训练,在另一个社交网络上预测。

GCN 不能为每个邻居分配不同的权重,GCN 在卷积时对所有邻居节点均一视同仁,不能根据节点重要性分配不同的权重。

   2018 年图注意力网络 GAT 被提出,用于解决 GCN 的上述问题,论文是《GRAPH ATTENTION NETWORKS》(ICLR2018)。GAT 采用了 Attention 机制,可以为不同节点分配不同权重,训练时依赖于成对的相邻节点,而不依赖具体的网络结构,可以用于 inductive 任务。 

8.1 引入Attention机制

假设 Graph 包含 N 个节点,每个节点的特征向量为 hi,维度是 F,如下所示:

节点特征向量 h

对节点特征向量 h 进行线性变换,可以得到新的特征向量 h'i,维度是 F',如下所示,W 为线性变换的矩阵:

节点特征向量线性变换 h'

节点 j 是节点 i 的邻居,则可以使用 Attention 机制计算节点 j 对于节点 i 的重要性,即 Attention Score:

Attention Score

GAT 具体的 Attention 做法如下,把节点 i、j 的特征向量 h'i、h'j 拼接在一起,然后和一个 2F' 维的向量 a 计算内积。激活函数采用 LeakyReLU,公式如下:

GAT Attention 计算方式

Attention 如下图所示:

GAT Attention 示意图

经过 Attention 之后节点 i 的特征向量如下:

Attention 后的特征向量

8.2 采用Multi-Head Attention

GAT 也可以采用 Multi-Head Attention,即多个 Attention,如下图所示:

GAT Multi-Head Attention

如果有 K 个 Attention,则需要把 K 个 Attention 生成的向量拼接在一起,如下:

K 个 Attention 输出结果拼接

但是如果是最后一层,则 K 个 Attention 的输出不进行拼接,而是求平均。

最后一层 Attention 输出结果

8.3 代码实现

数据集与上一节(第七节)中的一致,代码基本一致,只是稍作处理

utils.py

import numpy as np
import scipy.sparse as sp
import torch

def encode_onehot(labels):
    # The classes must be sorted before encoding to enable static class encoding.
    # In other words, make sure the first class always maps to index 0.
    classes = sorted(list(set(labels)))
    classes_dict = {c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)}
    labels_onehot = np.array(list(map(classes_dict.get, labels)), dtype=np.int32)
    return labels_onehot

def load_data(path="./data/cora/", dataset="cora"):
    """Load citation network dataset (cora only for now)"""
    print('Loading {} dataset...'.format(dataset))

    idx_features_labels = np.genfromtxt("{}{}.content".format(path, dataset), dtype=np.dtype(str))
    features = sp.csr_matrix(idx_features_labels[:, 1:-1], dtype=np.float32)
    labels = encode_onehot(idx_features_labels[:, -1])

    # build graph
    idx = np.array(idx_features_labels[:, 0], dtype=np.int32)
    idx_map = {j: i for i, j in enumerate(idx)}
    edges_unordered = np.genfromtxt("{}{}.cites".format(path, dataset), dtype=np.int32)
    edges = np.array(list(map(idx_map.get, edges_unordered.flatten())), dtype=np.int32).reshape(edges_unordered.shape)
    adj = sp.coo_matrix((np.ones(edges.shape[0]), (edges[:, 0], edges[:, 1])), shape=(labels.shape[0], labels.shape[0]), dtype=np.float32)

    # build symmetric adjacency matrix
    adj = adj + adj.T.multiply(adj.T > adj) - adj.multiply(adj.T > adj)

    features = normalize_features(features)
    adj = normalize_adj(adj + sp.eye(adj.shape[0]))

    idx_train = range(140)
    idx_val = range(200, 500)
    idx_test = range(500, 1500)

    adj = torch.FloatTensor(np.array(adj.todense()))
    features = torch.FloatTensor(np.array(features.todense()))
    labels = torch.LongTensor(np.where(labels)[1])

    idx_train = torch.LongTensor(idx_train)
    idx_val = torch.LongTensor(idx_val)
    idx_test = torch.LongTensor(idx_test)

    return adj, features, labels, idx_train, idx_val, idx_test

def normalize_adj(mx):
    """Row-normalize sparse matrix"""
    rowsum = np.array(mx.sum(1))
    r_inv_sqrt = np.power(rowsum, -0.5).flatten()
    r_inv_sqrt[np.isinf(r_inv_sqrt)] = 0.
    r_mat_inv_sqrt = sp.diags(r_inv_sqrt)
    return mx.dot(r_mat_inv_sqrt).transpose().dot(r_mat_inv_sqrt)

def normalize_features(mx):
    """Row-normalize sparse matrix"""
    rowsum = np.array(mx.sum(1))
    r_inv = np.power(rowsum, -1).flatten()
    r_inv[np.isinf(r_inv)] = 0.
    r_mat_inv = sp.diags(r_inv)
    mx = r_mat_inv.dot(mx)
    return mx

def accuracy(output, labels):
    preds = output.max(1)[1].type_as(labels)
    correct = preds.eq(labels).double()
    correct = correct.sum()
    return correct / len(labels)

models.py

import torch
import torch.nn as nn
import torch.nn.functional as F
from layers import GraphAttentionLayer, SpGraphAttentionLayer

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)

    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)
        x = F.dropout(x, self.dropout, training=self.training)
        x = F.elu(self.out_att(x, adj))
        return F.log_softmax(x, dim=1)

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

        self.attentions = [SpGraphAttentionLayer(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 = SpGraphAttentionLayer(nhid * nheads,
                                             nclass,
                                             dropout=dropout,
                                             alpha=alpha,
                                             concat=False)

    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)
        x = F.dropout(x, self.dropout, training=self.training)
        x = F.elu(self.out_att(x, adj))
        return F.log_softmax(x, dim=1)

layers.py

import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

class GraphAttentionLayer(nn.Module):
    """
    Simple GAT layer, similar to https://arxiv.org/abs/1710.10903
    """
    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)))
        nn.init.xavier_uniform_(self.a.data, gain=1.414)

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

    def forward(self, h, adj):
        Wh = torch.mm(h, self.W) # h.shape: (N, in_features), Wh.shape: (N, out_features)
        e = self._prepare_attentional_mechanism_input(Wh)

        zero_vec = -9e15*torch.ones_like(e)
        attention = torch.where(adj > 0, e, zero_vec)
        attention = F.softmax(attention, dim=1)
        attention = F.dropout(attention, self.dropout, training=self.training)
        h_prime = torch.matmul(attention, Wh)

        if self.concat:
            return F.elu(h_prime)
        else:
            return h_prime

    def _prepare_attentional_mechanism_input(self, Wh):
        # Wh.shape (N, out_feature)
        # self.a.shape (2 * out_feature, 1)
        # Wh1&2.shape (N, 1)
        # e.shape (N, N)
        Wh1 = torch.matmul(Wh, self.a[:self.out_features, :])
        Wh2 = torch.matmul(Wh, self.a[self.out_features:, :])
        # broadcast add
        e = Wh1 + Wh2.T
        return self.leakyrelu(e)

    def __repr__(self):
        return self.__class__.__name__ + ' (' + str(self.in_features) + ' -> ' + str(self.out_features) + ')'

class SpecialSpmmFunction(torch.autograd.Function):
    """Special function for only sparse region backpropataion layer."""
    @staticmethod
    def forward(ctx, indices, values, shape, b):
        assert indices.requires_grad == False
        a = torch.sparse_coo_tensor(indices, values, shape)
        ctx.save_for_backward(a, b)
        ctx.N = shape[0]
        return torch.matmul(a, b)

    @staticmethod
    def backward(ctx, grad_output):
        a, b = ctx.saved_tensors
        grad_values = grad_b = None
        if ctx.needs_input_grad[1]:
            grad_a_dense = grad_output.matmul(b.t())
            edge_idx = a._indices()[0, :] * ctx.N + a._indices()[1, :]
            grad_values = grad_a_dense.view(-1)[edge_idx]
        if ctx.needs_input_grad[3]:
            grad_b = a.t().matmul(grad_output)
        return None, grad_values, None, grad_b

class SpecialSpmm(nn.Module):
    def forward(self, indices, values, shape, b):
        return SpecialSpmmFunction.apply(indices, values, shape, b)

    
class SpGraphAttentionLayer(nn.Module):
    """
    Sparse version GAT layer, similar to https://arxiv.org/abs/1710.10903
    """

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

        self.W = nn.Parameter(torch.zeros(size=(in_features, out_features)))
        nn.init.xavier_normal_(self.W.data, gain=1.414)
                
        self.a = nn.Parameter(torch.zeros(size=(1, 2*out_features)))
        nn.init.xavier_normal_(self.a.data, gain=1.414)

        self.dropout = nn.Dropout(dropout)
        self.leakyrelu = nn.LeakyReLU(self.alpha)
        self.special_spmm = SpecialSpmm()

    def forward(self, input, adj):
        dv = 'cuda' if input.is_cuda else 'cpu'

        N = input.size()[0]
        edge = adj.nonzero().t()

        h = torch.mm(input, self.W)
        # h: N x out
        assert not torch.isnan(h).any()

        # Self-attention on the nodes - Shared attention mechanism
        edge_h = torch.cat((h[edge[0, :], :], h[edge[1, :], :]), dim=1).t()
        # edge: 2*D x E

        edge_e = torch.exp(-self.leakyrelu(self.a.mm(edge_h).squeeze()))
        assert not torch.isnan(edge_e).any()
        # edge_e: E

        e_rowsum = self.special_spmm(edge, edge_e, torch.Size([N, N]), torch.ones(size=(N,1), device=dv))
        # e_rowsum: N x 1

        edge_e = self.dropout(edge_e)
        # edge_e: E

        h_prime = self.special_spmm(edge, edge_e, torch.Size([N, N]), h)
        assert not torch.isnan(h_prime).any()
        # h_prime: N x out
        
        h_prime = h_prime.div(e_rowsum)
        # h_prime: N x out
        assert not torch.isnan(h_prime).any()

        if self.concat:
            # if this layer is not last layer,
            return F.elu(h_prime)
        else:
            # if this layer is last layer,
            return h_prime

    def __repr__(self):
        return self.__class__.__name__ + ' (' \
               + str(self.in_features) + ' -> '\
               + str(self.out_features) + ')'

train.py

from __future__ import division
from __future__ import print_function

import os
import glob
import time
import random
import argparse
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.autograd import Variable

from utils import load_data, accuracy
from models import GAT, SpGAT
import matplotlib.pyplot as plt

# Training settings
parser = argparse.ArgumentParser()
parser.add_argument('--no-cuda', action='store_true', default=False, help='Disables CUDA training.')
parser.add_argument('--fastmode', action='store_true', default=False, help='Validate during training pass.')
parser.add_argument('--sparse', action='store_true', default=False, help='GAT with sparse version or not.')
parser.add_argument('--seed', type=int, default=72, help='Random seed.')
parser.add_argument('--epochs', type=int, default=10000, help='Number of epochs to train.')
parser.add_argument('--lr', type=float, default=0.005, help='Initial learning rate.')
parser.add_argument('--weight_decay', type=float, default=5e-4, help='Weight decay (L2 loss on parameters).')
parser.add_argument('--hidden', type=int, default=8, help='Number of hidden units.')
parser.add_argument('--nb_heads', type=int, default=8, help='Number of head attentions.')
parser.add_argument('--dropout', type=float, default=0.6, help='Dropout rate (1 - keep probability).')
parser.add_argument('--alpha', type=float, default=0.2, help='Alpha for the leaky_relu.')
parser.add_argument('--patience', type=int, default=100, help='Patience')

args = parser.parse_args()
args.cuda = not args.no_cuda and torch.cuda.is_available()

random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.cuda:
    torch.cuda.manual_seed(args.seed)

# Load data
adj, features, labels, idx_train, idx_val, idx_test = load_data()

# Model and optimizer
if args.sparse:
    model = SpGAT(nfeat=features.shape[1], 
                nhid=args.hidden, 
                nclass=int(labels.max()) + 1, 
                dropout=args.dropout, 
                nheads=args.nb_heads, 
                alpha=args.alpha)
else:
    model = GAT(nfeat=features.shape[1], 
                nhid=args.hidden, 
                nclass=int(labels.max()) + 1, 
                dropout=args.dropout, 
                nheads=args.nb_heads, 
                alpha=args.alpha)
optimizer = optim.Adam(model.parameters(), 
                       lr=args.lr, 
                       weight_decay=args.weight_decay)

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)

def train(epoch):
    t = time.time()
    model.train()
    optimizer.zero_grad()
    output = model(features, adj)
    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()

    if not args.fastmode:
        # Evaluate validation set performance separately,
        # deactivates dropout during validation run.
        model.eval()
        output = model(features, adj)

    loss_val = F.nll_loss(output[idx_val], labels[idx_val])
    acc_val = accuracy(output[idx_val], labels[idx_val])
    print('Epoch: {:04d}'.format(epoch+1),
          'loss_train: {:.4f}'.format(loss_train.data.item()),
          'acc_train: {:.4f}'.format(acc_train.data.item()),
          'loss_val: {:.4f}'.format(loss_val.data.item()),
          'acc_val: {:.4f}'.format(acc_val.data.item()),
          'time: {:.4f}s'.format(time.time() - t))

    return loss_val.data.item(),acc_val.data.item()

def compute_test():
    model.eval()
    output = model(features, adj)
    loss_test = F.nll_loss(output[idx_test], labels[idx_test])
    acc_test = accuracy(output[idx_test], labels[idx_test])
    print("Test set results:",
          "loss= {:.4f}".format(loss_test.data.item()),
          "accuracy= {:.4f}".format(acc_test.data.item()))

# Train model
t_total = time.time()
loss_values = []
acc_values = []
bad_counter = 0
best = args.epochs + 1
best_epoch = 0
for epoch in range(args.epochs):
    loss,acc = train(epoch)
    loss_values.append(loss)
    acc_values.append(acc)

    torch.save(model.state_dict(), '{}.pkl'.format(epoch))
    if loss_values[-1] < best:
        best = loss_values[-1]
        best_epoch = epoch
        bad_counter = 0
    else:
        bad_counter += 1

    if bad_counter == args.patience:
        break

    files = glob.glob('*.pkl')
    for file in files:
        epoch_nb = int(file.split('.')[0])
        if epoch_nb < best_epoch:
            os.remove(file)
# print(loss_values)
# print(acc_values)

#绘制loss和acc曲线
def plot_loss_with_acc(loss_history, val_acc_history):
    fig = plt.figure()
    ax1 = fig.add_subplot(111)
    ax1.plot(range(len(loss_history)), loss_history,
             c=np.array([255, 71, 90]) / 255.)
    plt.ylabel('Loss')

    ax2 = fig.add_subplot(111, sharex=ax1, frameon=False)
    ax2.plot(range(len(val_acc_history)), val_acc_history,
             c=np.array([79, 179, 255]) / 255.)
    ax2.yaxis.tick_right()
    ax2.yaxis.set_label_position("right")
    plt.ylabel('ValAcc')

    plt.xlabel('Epoch')
    plt.title('Training Loss & Validation Accuracy')
    # plt.show()

#绘制loss和acc曲线
plot_loss_with_acc(loss_values,acc_values)

files = glob.glob('*.pkl')
for file in files:
    epoch_nb = int(file.split('.')[0])
    if epoch_nb > best_epoch:
        os.remove(file)

print("Optimization Finished!")
print("Total time elapsed: {:.4f}s".format(time.time() - t_total))

# Restore best model
print('Loading {}th epoch'.format(best_epoch))
model.load_state_dict(torch.load('{}.pkl'.format(best_epoch)))

# Testing
compute_test()

# 绘制测试数据的TSNE降维图
output = model(features, adj)
output = output.cpu()
output = output[idx_test].detach().numpy()
# print(output)
# print(output.shape)
from sklearn.manifold import TSNE
tsne = TSNE()
out = tsne.fit_transform(output)
fig = plt.figure()
labels_test = labels[idx_test].detach().cpu().numpy()
for i in range(7):
    indices = labels_test == i
    # print(indices)
    x, y = out[indices].T
    plt.scatter(x, y, label=str(i))
plt.legend()
plt.show()

8.4 模型结果

(1)模型训练曲线图:

Test set results: loss= 0.6537 accuracy= 0.8470 结果比没有加Attention的GCN要好

(2)聚类效果如下图:

参考文献:

  1. ICLR 2023 Open Review投稿文章一览,投稿量暴涨46%,「Diffusion」、「Mask」关键词成为新热点 - 知乎

  2. GNN的理解与研究_江南綿雨的博客-CSDN博客_gnn

  3. 跳出公式,看清全局,图神经网络(GCN)原理详解_kisssfish的博客-CSDN博客_gcn原理

  4. pytorch框架下—GCN代码详细解读_MelvinDong的博客-CSDN博客_gcn代码解读

  5. GNN学习笔记(四):Cora数据集读取与分析_花锄的博客-CSDN博客_cora数据

  6. https://baijiahao.baidu.com/s?id=1671028964544884749 (GAT 图注意力网络)


本文转载自: https://blog.csdn.net/weixin_43734080/article/details/127448525
版权归原作者 Dr.sky_ 所有, 如有侵权,请联系我们删除。

“图卷积神经网络GCN、GAT的原理及Pytorch实现”的评论:

还没有评论