GCN入门代码实战
本文是《深入浅出图神经网络——GNN原理解析》第5.6节的代码,使用GCN实现对节点的分类
代码地址:
https://github.com/FighterLYL/GraphNeuralNetwork/tree/master/chapter5
前言
最近多模态领域进展神速,CV和NLP领域融合的趋势越来越明显,例如何凯明大神的MAE,以及亚研院和北大联合发布的女娲模型,效果都非常惊艳。看着领域内进展神速,自己却一直沉落在平庸的业务做着增删改查,默默留下羡慕的口水。
(我总认为,一个人的价值,就在于Ta能何种程度的改变世界、改善生活,而不是在于什么职级、期权、总包、title。)
深度学习,从数据形式的角度讲,可以分为NLP,CV,其实还有一种数据结构,那就是图结构,不是图像的图,而是图形的图。比如社交网络、知识图谱等。
所以这里计划从头开始写一篇图嵌入(graph embedding)的专栏,从Trans系列讲起。时间精力缘故,一直没有动笔,今天这篇,从一段入门代码开始吧。
数据集介绍
该代码使用的是Cora数据集,包括2708篇论文,论文间引用关系5429条。论文即节点,引用关系即边。节点分为7类。每篇论文的特征是通过词袋模型得到的,维度为1433,每一个维度表示一个词,1表示该词在这篇文章中出现过,0表示未出现。
@property
def data(self):
"""返回Data数据对象,包括x, y, adjacency, train_mask, val_mask, test_mask"""
return self._data
可以看到,数据集的核心内容,包括x,y,adjacent,train_mask,val_mask和test_mask。
x的维度是2708*1433,即所有2708个节点,每个节点维度是1433。
y是这2708个节点的类别,标签。
adjacent是2708*2708的稀疏矩阵,根据论文之间的引用关系,描述了一个引用图。从实际意义角度讲,互相引用的论文,一般是相同领域的继承研究,或者是对比研究。这个数据内容,是图网络的核心体现。
train_mask,val_mask,test_mask,代码中的默认数量是140,500,1000,分别表示了训练集、验证集、测试集的数量。这个数量划分也挺有意思的。
核心代码
- GCN层的定义
class GraphConvolution(nn.Module):
def __init__(self, input_dim, output_dim, use_bias=True):
"""图卷积:L*X*\theta
Args:
----------
input_dim: int
节点输入特征的维度
output_dim: int
输出特征维度
use_bias : bool, optional
是否使用偏置
"""
super(GraphConvolution, self).__init__()
self.input_dim = input_dim
self.output_dim = output_dim
self.use_bias = use_bias
self.weight = nn.Parameter(torch.Tensor(input_dim, output_dim))
if self.use_bias:
self.bias = nn.Parameter(torch.Tensor(output_dim))
else:
self.register_parameter('bias', None)
self.reset_parameters()
def reset_parameters(self):
init.kaiming_uniform_(self.weight)
if self.use_bias:
init.zeros_(self.bias)
def forward(self, adjacency, input_feature):
"""邻接矩阵是稀疏矩阵,因此在计算时使用稀疏矩阵乘法
Args:
-------
adjacency: torch.sparse.FloatTensor
邻接矩阵
input_feature: torch.Tensor
输入特征
"""
support = torch.mm(input_feature, self.weight)
output = torch.sparse.mm(adjacency, support)
if self.use_bias:
output += self.bias
return output
def __repr__(self):
return self.__class__.__name__ + ' (' \
+ str(self.input_dim) + ' -> ' \
+ str(self.output_dim) + ')'
这份代码中,亮点还是挺多的。其中最核心,最体现GCN计算过程的,就是第41行。在这里,上一层的结果,和邻接矩阵相乘,之后作为输出。torch.sparse.mm是torch的稀疏矩阵相乘函数。
- 模型层的定义
class GcnNet(nn.Module):
"""
定义一个包含两层GraphConvolution的模型
"""
def __init__(self, input_dim=1433):
super(GcnNet, self).__init__()
self.gcn1 = GraphConvolution(input_dim, 16)
self.gcn2 = GraphConvolution(16, 7)
def forward(self, adjacency, feature):
h = F.relu(self.gcn1(adjacency, feature))
logits = self.gcn2(adjacency, h)
return logits
包含了两层图卷积层,简洁清晰,没有什么可说的。
实验和结果
- 默认参数
可以看到,大约50轮以后,模型就收敛到比较稳定的状态了,acc大约是0.8。
上图是TSNE降维图,可以看到一些不合群的点,可能是错误分类的点。如果这些点录入到neo4j中,结合图数据库可视化,可能会有一些有趣的点(也可能页面炸了)。
- 一些尝试
因为acc=80%,所以尝试了一些感兴趣的超参,看看是否会有提升,以及为什么。
实验组 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
test_acc | train=140, val=500, test=1000 Lr=0.1 | train=140, val=500, test=1000 Lr = 0.01 | train=140, val=500, test=1000 Lr = 0.001 | Random train=140, val=500, test=1000 Lr = 0.01 | train=1000, val=500, test=1000 Lr = 0.01 | Random, train=1000 val=500, test=1000 Lr = 0.01 | Random, train=1000 val=500, test=1000 Lr = 0.01 gcn1_output=32 | Random, train=1000 val=500, test=1000 Lr = 0.01 gcn1_output=64 |
use_bias=True | 80.8% | 81.8% | 79.5% | 84.1% | 87.3% | 87.8% | 87.99% | 87.5% |
use_bias=False | 80.8% | 80.5% | 78.5% | 83.9% | 87.3% | 87.8% | 87.8% | 87.6% |
首先测试了Learning rate。当调低学习率时,收敛需要的训练也更多。前三组实验可以看出,Lr = 0.01时,学习效果较好,后面的实验也都使用了该参数。
然后尝试调整数据集。因为原始代码中,使用了前140个作为训练集,中间500个作为验证集,后1000个作为测试集,大体比例约为1:3:6。所以先简单做了一个随机抽样,按照数据编号模10的余数,伪随机的抽取,作为数据集。实验4中看出,模型效果有较大的提高。
实验5、6继续尝试调整数据集。实验5,取前1000条数据作为训练集,中间500条作为验证集,后1000条作为测试集。实验6,按照2:1:2的比例,伪随机的抽取,作为训练集、验证集、测试集。相比其它组的实验,这两组的效果有较大提高。其中伪随机的抽取,相比分段截取,仍有一定的提高。
实验7、8,尝试调整GCN层的参数,就是第一层的输出、第二层的输入的维度。原维度是16,现分别尝试32、64。其他参数与实验6相同。可以看出维度为32的时候,略有提高,维度为64的时候,反而有些下降。可能是过拟合了(这个结论需要更仔细的论证)。
最后对照了是否使用bias,整体上使用bias会有更好的表现。
后续
这是一个非常好的入门示例,代码中的细节是非常多的。我最关心的是,如果数据量大的话,效果、性能如何。其实如果动手能力强的话,可以爬取更多的论文,及其之间的引用关系,然后整理成数据集,进行实验。时间关系,这里就算挖一个坑,有机会再填。
另外,这个GCN的效果,相对于Trans系列,相对于游走系列的效果如何,更适合什么样的数据,也值得做一下对照实验。