本文主要介绍了如何在昇腾上,使用 pytorch 对经典的图神经网络 GCN 在论文引用 Cora 数据集上进行分类训练的实战讲解。内容包括 GCN 背景介绍、模型特点介绍、GCN 网络架构剖析与 GCN 网络模型代码实战分析等等。
本文的目录结构安排如下所示:
GCN 网络背景介绍
模型特点介绍
GCN 网络架构剖析
GCN 网络用于 Cora 数据集分类实战
GCN 网络背景介绍
多层感知机、卷积神经网络、循环神经网络和自编码器等深度学习模型通过对输入的数据进行逐层的特征提取和筛选,可以完成分类和预测等任务,在计算机视觉和语音识别等领域已被广泛应用。但上述模型只能处理具有固定排列规则和顺序的欧氏结构数据,对于一些非规则排布的非欧式数据显得有些则力不从心。随着非欧式数据结构被越来越多的实际问题应用所需要,针对处理非欧氏结构数据的深度学习模型图神经网络(Graph Neural Networks, GNN)应运而生。
图神经网络的本质就是:图中的任何一个节点,都受到和它相连的其他节点的影响,距离越近影响则越大。一个图中的所有节点间的互动关系和每个节点本身的信息,就构成了这整张图的全部信息。
由于 CNN 已经是个相当成熟的技术了,聚合“邻居”的信息并不是什么少见的思路。显然,从 GNN 出现开始,就必然会有人尝试在 GNN 上进行类似 CNN 的“聚合节点信息”操作。事实上在 GCN 之前,就已经有一些关于类似的研究了。但不外乎存在计算量大、聚合效果差、卷积核复杂的问题。
模型特点介绍
GCN 是 GNN 的一个分支,全称为图卷积神经网络,顾名思义,GCN 是在图上进行“卷积”操作的 GNN,这里用引号是因为,GCN 的操作并不是卷积神经网络里的那个卷积,这里的卷积,是因为 GCN 的运算是在聚合节点周围其他节点的信息,与卷积神经网络(CNN)的行为类似。不过话说回来,CNN 里的“卷积”,也并不是不是数学意义上的卷积。
GCN 的创新之处在于,提出了一种简化到在计算量上可行且效果好的“卷积”计算方案。GCN 利用拉普拉斯变换变化,利用邻接矩阵算出了这个滤波矩阵,然后利用这个滤波矩阵进行层间传播。
其迭代间节点核心更新计算公式如下:
其中D~表示邻接矩阵的度矩阵,A~表示整张图的邻接矩阵(含自回环,也就是加了单位矩阵),X 表示节点在 k-1 层的特征向量,Θ是 k-1 层的卷积参数。
GCN 的上述公式表达的是从整个图的角度来考虑和描述的。从单个节点来说,每个节点的特征向量可以表示为的变换 (前向传播) 的向量形式可以表示为如下:
$$\mathbf{x}{i}^{(k)}=\sum{j \in \mathcal{N}(i) \cup{i}} \frac{1}{\sqrt{\operatorname{deg}(i)} \cdot \sqrt{\operatorname{deg}(j)}} \cdot\left(\boldsymbol{\Theta} \cdot \mathbf{x}_{j}^{(k-1)}\right)$$
其中Θ是权重矩阵 (即模型学习过程中要更新的参数),xi(k)表示节点 i 在第 k 次迭代的特征向量,deg(i)表示节点 i 的度,N(i)表示节点 i 所有邻接节点的集合。
GCN 网络架构剖析
GCN 定义了一个两层的模型,中间隐藏的节点个数可以自设,后面输出层可以结合具体数据集类别设置使用,当然也可以跟训练类别设置不一致,只需要在后面接一个分类器即可(后再接一个全连接层)。
MessagePassing 模块是图神经网络(Graph Neural Networks,GNNs)的一个基础组件,它被设计用来处理图形数据的问题。在图形数据中,数据点(节点)之间的关系(边)是非常重要的信息。MessagePassing 通过在节点之间传递和聚合信息,使得每个节点都能获取其邻居节点的信息,从而更好地理解图形的结构和特性。里面'propagate'函数与'aggregate'函数用于实现节点之间的传播与聚合功能。
# 导入torch及相关库,便于后续搭建网络调用基础算子模块
import torch
import torch.nn.functional as F
from torch_geometric.nn import MessagePassing
复制代码
在图神经网络(GNN)的实现中,对图结构的调整和优化是提升模型性能的关键手段之一。'add_self_loops'函数在 PyTorch 的图处理库中用于向图中添加自环(self-loops),即连接节点自身的边。'degree'用来计算一个无权图的入度矩阵。
from torch_geometric.utils import add_self_loops, degree
复制代码
GCN 实现继承了'MessagePassing'类,线性变换功能在'init'函数中通过'self.lin'为线性变换函数定义,具体特征维度的逻辑在'forward()'中实现,'init'函数中入参'in_channel'是每个节点输入特征的维度,'out_channels'是每个节点输出特征的维度,这一部分对应上述公式中的 XΘ。输入的特征矩阵维度是(N, in_channels),输出的特征矩阵维度是(N, out_channels),其中 N 是节点个数。
在'forward()'函数中'edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))'是给邻接矩阵加上 self loops,也即构造出矩阵 A^=A+I ,在 torch geometric 中,无权图的邻接矩阵表示为 2 维数组(COO 存储格式),第 1 行表示边的起始节点(source 节点),第 2 行表示边的目标节点(target 节点)。
对于'message()'函数而言,函数入参'x_j' 的形状是[E, out_channels],其中 E 表示边的数量。由上面可知,特征矩阵经过线性变换后的输出形状是(N, out_channels),边的矩阵的形状为 [2, E]。
'row, col = edge_index'表示取出所有边的起始节点和目标节点,row 表示边的起始节点的结合,col 表示边的目标节点的集合。在无向图中,这两者是等价的。以 target 节点作为索引,从线性变换后的特征矩阵中索引得到 target 节点的特征矩阵。
class GCNConv(MessagePassing):
def __init__(self, in_channels, out_channels):
# "Add" aggregation.
super(GCNConv, self).__init__(aggr='add')
self.lin = torch.nn.Linear(in_channels, out_channels)
def forward(self, x, edge_index):
# x has shape [N, in_channels]
# edge_index has shape [2, E]
# Step 1: Add self-loops to the adjacency matrix.
edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
# Step 2: Linearly transform node feature matrix.
x = self.lin(x)
# Step 3-5: Start propagating messages.
return self.propagate(edge_index, size=(x.size(0), x.size(0)), x=x)
def message(self, x_j, edge_index, size):
# x_j has shape [E, out_channels]
# edge_index has shape [2, E]
# Step 3: Normalize node features.
row, col = edge_index
# [N, ]
deg = degree(row, size[0], dtype=x_j.dtype)
# [N, ]
deg_inv_sqrt = deg.pow(-0.5)
norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
return norm.view(-1, 1) * x_j
def update(self, aggr_out):
# aggr_out has shape [N, out_channels]
# Step 5: Return new node embeddings.
return aggr_out
复制代码
上述类目前已经集成在 torch_geometric.nn 模块中,也可以使用下述一行代码替换 'from torch_geometric.nn import GCNConv' 导入 GCN 层替换 GCNConv 类定义。
Planetoid 集成了论文引用中 Cora,CiteSeer,PubMed 三个数据集,由于本实验需要用到 Cora 数据集,因此此处需要导入该模块用于加载数据集。
from torch_geometric.datasets import Planetoid
复制代码
定义 GCN_NET 图网络,中间构造一个隐藏层用来辅助实现线性转换过程。
class GCN_NET(torch.nn.Module):
def __init__(self, features, hidden, classes):
super(GCN_NET, self).__init__()
# shape(输入的节点特征维度 * 中间隐藏层的维度)
self.conv1 = GCNConv(features, hidden)
# shaape(中间隐藏层的维度 * 节点类别)
self.conv2 = GCNConv(hidden, classes)
def forward(self, data):
# 加载节点特征和邻接关系
x, edge_index = data.x, data.edge_index
# 传入卷积层
x = self.conv1(x, edge_index)
# 激活函数
x = F.relu(x)
# dropout层,防止过拟合
x = F.dropout(x, training=self.training)
# 第二层卷积层
x = self.conv2(x, edge_index)
# 将经过两层卷积得到的特征输入log_softmax函数得到概率分布
return F.log_softmax(x, dim=1)
复制代码
GCN 网络用于 Cora 数据集分类实战
本实验需要跑在 npu 上,因此需要导入 Npu 相关库,以便于模型能够跑在 Npu 上。
import torch_npu
from torch_npu.contrib import transfer_to_npu
复制代码
Cora 数据集介绍与加载
Cora 数据集包含 2708 篇科学出版物,10556 条边,总共 7 种类别,数据集中的每个出版物都由一个 0/1 值的词向量描述,表示字典中相应词的缺失/存在。该词典由 1433 个独特的词组成,意思就是说每一个出版物都由 1433 个特征构成,每个特征仅由 0/1 表示。
如图,节点大小对应进出边的数量,节点越大表示进出边的数量越多,边的粗细反映对应两节点之间的相似或关联程度越高,也就是对彼此的影响力权重越大。
由于 cora 数据集处理的是无向图,所以'in degree count'与'out degree count'分布图一致,底部的'nodes for a given out-degree'与'node degree'图统计的是数据集中出边的分布情况,可以看到峰值点出现在[2, 4]范围内,说明大多数的节点之间之间与少量的边进行相连,相连节点最多的边是图中绿色节点,约有 169 个节点相连。
下载后的数据集总共包含 8 个文件分别是 ind.cora.x、ind.cora.tx、ind.cora.all、ind.cora.y、ind.cora.ty、ind.cora.ally、ind.cora.graph 与 ind.cora.test.index。
其中:
ind.cora.x:训练集节点特征向量,大小(140,1433)
ind.cora.tx:测试集节点特征向量,实际展开后大小为(1000,1433)
ind.cora.allx:包含标签核无标签的训练节点特征向量(1708,1433)
ind.cora.y:one-hot表示的训练节点的标签
ind.cora.ty:one-hot表示的测试节点的标签
ind.cora.ally:one-hot表示的ind.cora.allx对应的标签
ind.cora.graph:保存节点之间边的信息
ind.cora.test.index:保存测试集节点的索引,用于后面的归纳学习设置
复制代码
上述介绍完 cora 数据集的基本组成情况,接下来我们通过 Planetoid 集成库来下载 cora 数据集,下载好以后对数据集中一些基本的信息进行打印。
import numpy as np
# 加载数据,出错可自行下载,解决方案见下文
print("===== begin Download Dadasat=====\n")
dataset = Planetoid(root='/home/pengyongrong/workspace/data', name='Cora')
print("===== Download Dadasat finished=====\n")
print("dataset num_features is: ", dataset.num_features)
print("dataset.num_classes is: ", dataset.num_classes)
print("dataset.edge_index is: ", dataset.edge_index)
print("train data is: ", dataset.data)
print("dataset0 is: ", dataset[0])
print("train data mask is: ", dataset.train_mask, "num train is: ", (dataset.train_mask ==True).sum().item())
print("val data mask is: ",dataset.val_mask, "num val is: ", (dataset.val_mask ==True).sum().item())
print("test data mask is: ",dataset.test_mask, "num test is: ", (dataset.test_mask ==True).sum().item())
复制代码
===== begin Download Dadasat=====
===== Download Dadasat finished=====
dataset num_features is: 1433
dataset.num_classes is: 7
dataset.edge_index is: tensor([[ 633, 1862, 2582, ..., 598, 1473, 2706],
[ 0, 0, 0, ..., 2707, 2707, 2707]])
train data is: Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])
dataset0 is: Data(x=[2708, 1433], edge_index=[2, 10556], y=[2708], train_mask=[2708], val_mask=[2708], test_mask=[2708])
train data mask is: tensor([ True, True, True, ..., False, False, False]) num train is: 140
val data mask is: tensor([False, False, False, ..., False, False, False]) num val is: 500
test data mask is: tensor([False, False, False, ..., True, True, True]) num test is: 1000
/home/pengyongrong/miniconda3/envs/AscendCExperiments/lib/python3.9/site-packages/torch_geometric/data/in_memory_dataset.py:300: UserWarning: It is not recommended to directly access the internal storage format `data` of an 'InMemoryDataset'. If you are absolutely certain what you are doing, access the internal storage via `InMemoryDataset._data` instead to suppress this warning. Alternatively, you can access stacked individual attributes of every graph via `dataset.{attr_name}`.
warnings.warn(msg)
复制代码
本文用到的实验数据集这里已经下载好并保存在"/home/pengyongrong/workspace/data"目录下,若没有下载好启动该命令会自动下载数据集,整个下载过程可能会比较慢,也可以在https://github.com/D61-IA/FisherGCN/tree/a58c1613f1aca7077ef90af6e51a8021548cdb4c/data 自行选择下载。
从打印的信息可以看出来,每一个节点的特征维度为 1433,也就是'datasat.numfeatures'的取值是 1433;总的类别数是 7,对应'datasat.numclasses';'dataset.edge_index'表示所有边与边之间的互联关系,采用 coo 存储格式,因为这里是无权边,因此只需要二维数组即可完成对应功能。
上述信息介绍完后就剩下训练集、验证集与测试集相关的信息,dataset[0]包含了所有信息,包括输入 x,边信息、标签 y 及'train_mask'、'val_mask'与'test_mask'分别表示 2708 篇刊物中哪些用于训练,哪些用于验证及哪些用于测试。
开启 cora 数据训练过程
接下来就是将 cora 数据集送入搭建好的 GCN 网络模型中进行训练,训练过程中设置设备在 npu 上运行,并定义训练的'epoch=200',迭代次数可以根据需要自行更改,训练完成后对模型的效果进行评估并打印预测的准确率约为 0.8。
#设置成npu
device = 'npu'
print("device is: ", device)
# 构建模型,设置中间隐藏层维度为16
model = GCN_NET(dataset.num_node_features, 16, dataset.num_classes).to(device)
# 加载数据
data = dataset[0].to(device)
# 定义优化函数
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
model.train()
for epoch in range(200):
# 梯度设为零
optimizer.zero_grad()
# 模型输出
out = model(data)
# 计算损失
loss = F.nll_loss(out[data.train_mask], data.y[data.train_mask])
# 反向传播计算梯度
loss.backward()
# 一步优化
optimizer.step()
# 评估模型
model.eval()
# 得到模型输出的类别
_, pred = model(data).max(dim=1)
# 计算正确的个数
correct = int(pred[data.test_mask].eq(data.y[data.test_mask]).sum().item())
# 得出准确率
acc = correct / int(data.test_mask.sum())
# 打印准确率及结果
print('GCN Accuracy: {:.4f}'.format(acc))
复制代码
device is: npu
GCN Accuracy: 0.8040
复制代码
内存使用情况: 整个训练过程的内存使用情况可以通过"npu-smi info"命令在终端查看,因此本文实验只用到了单个 npu 卡(也就是 chip 0),内存占用约 167M,对内存、精度或性能优化有兴趣的可以自行尝试进行优化,这里运行过程中也有其他程序在运行,因此本实验用到的网络所需要的内存已单独框出来。
Reference
[1] Heidari, Negar , L. Hedegaard , and A. Iosifidis . "Graph convolutional networks." Deep Learning for Robot Perception and Cognition (2022).
评论