GRAPH ATTENTION NETWORKS 代码详解
- 前言
- 一、数据集介绍
- 二、文件整体架构
- 三、GAT代码详解
- 3.1 utils 模块
- 3.1.1 函数 `encode_onehot(labels)`
- 3.1.2 函数 `load_data(path, dataset)`
- 3.1.3 函数 `normalize_adj(mx)`
- 3.1.4 函数 `normalize_features(mx)`
- 3.1.5 函数 `accuracy(output, labels)`
- 3.2 requirements.txt
- 3.3 layers.py
- 3.4 models.py
- 3.5 train.py
- 3.6 visualize_graph.py
- 四、总结
前言
在前文中,我们已深入探讨了图注意力网络(GAT)的理论基础及其运行方法。此外,我们还介绍了如何执行这一实验。然而,这并非算法的全部精髓。本节将利用 PyTorch 这一强大的深度学习框架,通过实战案例详细讲解如何构建和训练图注意力网络。这一过程不仅有助于读者巩固理论知识,更重要的是,它将帮助读者从理论迈向实践,实现在处理图结构数据问题上的质的飞跃。
通过本章节的学习,我们希望读者能够熟练掌握图神经网络的主体代码,并通过实际编码练习来深化对图神经网络工作原理的理解。我们将从数据准备开始,逐步深入探讨模型构建、训练和评估等核心环节,最终实现一个功能完整的图注意力网络项目。
这个原文的代码地址感兴趣的读者自行下载即可https://github.com/Diego999/pyGAT/blob/master/README.md
😃当然要是觉得还不错的话,烦请点赞,收藏➕关注👍
一、数据集介绍
如果你看过我的GCN讲解这部分完全可以跳过。数据集使用的是一致的。
在深入了解模型工作原理之前,首先需要明确模型需要完成的任务。本文使用的是著名的论文引用数据集——Cora数据集,以完成论文分类任务。
Cora数据集主要包括机器学习领域的论文,这些论文被分为以下七个类别:
- 基于案例的方法(Case_Based)
- 遗传算法(Genetic_Algorithms)
- 神经网络(Neural_Networks)
- 概率方法(Probabilistic_Methods)
- 强化学习(Reinforcement_Learning)
- 规则学习(Rule_Learning)
- 理论(Theory)
选取论文的标准是,每篇论文至少被其他论文引用或引用其他论文至少一次。整个数据集共包含2708篇论文。
在对数据进行预处理时,经过词干提取及去除停用词后,形成了包含1433个唯一词汇的词汇表。文档频率小于10的词汇已从数据集中移除。
数据集包括两个主要文件:
-
.content 文件:
- 该文件包含每篇论文的描述,格式为:
<paper_id> <word_attributes>+ <class_label>
。 - 每行的第一部分是论文的唯一标识ID,随后是一系列二进制值,表示词汇表中的每个单词是否在该论文中出现。最后一部分是论文的分类标签。
- 该文件包含每篇论文的描述,格式为:
-
.cites 文件:
- 该文件记录了论文之间的引用关系,格式为:
<ID of cited paper> <ID of citing paper>
。 - 每行两个论文ID,第一个为被引用论文ID,第二个为引用该论文的论文ID,表示引用方向是从右到左。
- 该文件记录了论文之间的引用关系,格式为:
通过这些文件,我们可以构建一个引用网络,以便进行更深入的图结构分析和机器学习任务。
二、文件整体架构
下面是作者github中的文件的目录情况如下所示:
对于本项目的文件结构,有一些文件和文件夹可能不直接涉及到算法的解释,因此在这里不进行详细阐述。首先是 data
文件夹,其中存放的数据已在数据集介绍中详细讲述。然后是 output
文件夹,此处存放的是可视化代码所生成的图形,其细节已在相关论文或文档中说明。因此,这两部分在此不做赘述。还有就是常规的README文件都不进行解释。
另外,有两个与Git相关的文件,即 .gitignore
和 LICENSE
文件:
- .gitignore 文件用于列出不需要加入版本控制系统的文件和目录,帮助我们管理项目时保持仓库的清洁。
- LICENSE 文件则声明了项目的许可证类型,规定了他人可以如何使用这个项目。
剩余的文件我将逐步进行讲解。
三、GAT代码详解
接下来,将根据之前提及的不同文件及其顺序,深入剖析整个图注意力网络(GAT)的代码实现。这一过程将揭示每个组件的功能及其在整个模型中所扮演的角色。
3.1 utils 模块
相信已经阅读过我之前关于GCN代码讲解的博客的朋友们对这个文件夹下的代码会感到相当熟悉。的确,本项目中大部分代码是对之前代码的复用,不过仍然存在一些细微的变动。因此,我将对所有内容进行详尽的解析,熟悉的读者可以根据需要选择性阅读。对于那些容易被忽略却又十分重要的细节,我会用黄色的 highlight 进行标注以强调其重要性。
使用的包还是十分常见的。这里感兴趣的同学可以自行搜索下scipy.sparse的数据存储格式,十分有趣展示了如何将稀疏矩阵的各种方法。
您刚才提供的代码片段涵盖了导入 Python 中常用的科学计算和数据处理库,这些库是执行图神经网络(如图注意力网络GAT)操作所必需的。下面是对各个库作用的概括:
import numpy as np
- NumPy 是 Python 中用于科学计算的基础库。
- 它提供了一个强大的N维数组对象,广泛用于数据的整理、转换以及数学运算。
- NumPy 数组用于存储和处理大量的数据,比 Python 自带的数据结构要高效得多,是数据科学和机器学习中不可或缺的工具。
import scipy.sparse as sp
- SciPy 是基于 NumPy,用于科学计算的核心库之一,它支持高级数学函数、算法和方便的类。
scipy.sparse
模块提供了一组稀疏矩阵类型和相关工具,这些稀疏矩阵广泛用于在科学和工程领域中处理那些大多数元素为零的大型矩阵。- 在图神经网络中,使用稀疏矩阵可以有效地表示大型图的邻接矩阵,从而大幅减小内存使用并提高计算效率。
import torch
- PyTorch 是一个开源机器学习库,广泛用于计算机视觉和自然语言处理等应用程序。
- 它是基于 Torch 库的 Python 实现,提供丰富的API,可以轻松地进行深度学习模型的设计、速度优化和运行。
- PyTorch 支持张量计算以及动态神经网络,并有着优秀的社区支持。
这三个库构成了处理图数据、实现图神经网络模型的基础环境,使得开发者能够专注于模型的构建和实验,而不用过多关注底层细节。这都是属于GNN的三大件了,总是会用到各位简单的看看就可以。下面我对utils.py
中使用到的函数进行逐个解释:
您提供的代码是一个完整的Python脚本,用于加载和预处理Cora数据集,以便用于图注意力网络(GAT)的训练。这段代码包含几个关键功能的实现,我将对每个函数进行简要解释:
3.1.1 函数 encode_onehot(labels)
这个函数用于将类别标签转换为one-hot编码形式。这样做的目的实际上是为了最终的计算损失方便。
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)))# (set(labels)使用set是一种去除重复元素并只保留唯一元素的有效方法,一共7个类别。使用列表保存,进行排序存储下。#这样就保证了其结果的可复现。因为集合类型set不保证元素的顺序,# 每次迭代set可能得到不同的顺序,这在运行机器学习模型时可能导致# 结果不可复现。通过将set转换为list并应用sorted()函数,确保了类别标签的顺序在不同运行中保持一致。classes_dict = {c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)}# 假设有三个类别:"apple", "banana", "cherry",则:# np.identity(3) 生成矩阵 [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]。# 假如排序后的 classes = ["apple", "banana", "cherry"],则 enumerate(classes) 生# 成(0, "apple"),(1, "banana"),(2, "cherry")。# classes_dict 将会是:{"apple": [1, 0, 0], "banana": [0, 1, 0], "cherry": [0, 0, 1]}。# 实际上一个反过来的词典labels_onehot = np.array(list(map(classes_dict.get, labels)), dtype=np.int32)return labels_onehot
下面是该函数具体的作用和步骤解释:
- 便于损失计算:在训练分类模型时,使用 one-hot 编码可以直接与模型的输出进行比较,从而计算损失。这使得模型优化过程中的误差反向传播更加直接有效。
- 标准化标签表示:one-hot 编码将离散的类别标签转换为统一的数值格式,这有助于消除潜在的数值标签大小对模型训练的影响,确保所有类别在模型中得到公平处理。
实现步骤解释:
这里很重要sorted()
,GCN版本的代码中由于这个encode函数没用排序,每次执行或多或少都会发生些变换,不过还好聪明的DJ把这部分修正了,确保了模型的可复现性。
-
类别排序:
- 首先,通过
set(labels)
获取所有不重复的标签,然后用sorted()
对这些标签进行排序。这一步确保了类别的顺序是一致的,使得每次运行代码时类别的索引映射是确定的。
- 首先,通过
-
创建类别字典:
- 使用字典推导式
{c: np.identity(len(classes))[i, :] for i, c in enumerate(classes)}
来为每个类别生成一个唯一的 one-hot 编码。np.identity(len(classes))
创建了一个单位矩阵,每一行代表一个类别的 one-hot 编码(例如,对于三个类的情况,单位矩阵是[[1, 0, 0], [0, 1, 0], [0, 0, 1]]
)。
- 使用字典推导式
-
映射标签到 one-hot 编码:
- 使用
map()
函数将每个原始标签映射到其对应的 one-hot 编码。这是通过在前面创建的classes_dict
字典中查找每个标签来完成的。
- 使用
-
返回结果:
- 最后,将映射结果转换为 NumPy 数组
np.array(..., dtype=np.int32)
并返回。这个数组就是完整的标签 one-hot 编码矩阵。
- 最后,将映射结果转换为 NumPy 数组
通过这个函数,每个类别标签都被有效地转换成了一个固定长度的二进制形式,为后续的模型训练提供了方便。此外,确保标签是按一定顺序处理的,有助于避免任何由标签顺序引起的混淆。
3.1.2 函数 load_data(path, dataset)
这个函数负责加载和预处理数据。
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) # 可以看到去头去尾的打开数据,这样的目的就是,把ID不要以及最后的标签信息。中间的部分才是特征。labels = encode_onehot(idx_features_labels[:, -1]) # 仅仅取到最后一列的标签信息# build graphidx = 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 matrixadj = 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
3.1.3 函数 normalize_adj(mx)
这个函数用于归一化邻接矩阵,使其行和为1。这是通过对每行求和然后应用倒数平方根进行归一化来实现的。
def normalize_adj(mx):"""Row-normalize sparse matrix"""rowsum = np.array(mx.sum(1)) # 按行进行求和r_inv_sqrt = np.power(rowsum, -0.5).flatten() # x1 = np.arange(6)# x1# [0, 1, 2, 3, 4, 5]# np.power(x1, 3)# array([ 0, 1, 8, 27, 64, 125])r_inv_sqrt[np.isinf(r_inv_sqrt)] = 0. # 用来哦按断是否有无穷的因为出现无穷的赋值为0 本身可能是比较小的数值,倒过来就很大r_mat_inv_sqrt = sp.diags(r_inv_sqrt) # 构建对角矩阵return mx.dot(r_mat_inv_sqrt).transpose().dot(r_mat_inv_sqrt) # 做两次归一化
mx.dot(r_mat_inv_sqrt) 将归一化因子矩阵右乘原始的邻接矩阵 mx,得到部分归一化的矩阵。
.transpose() 对结果矩阵进行转置。
再次用 r_mat_inv_sqrt 左乘,完成对称归一化。
最终返回的矩阵是完全归一化后的邻接矩阵,此矩阵用于图神经网络中的信息传播。
3.1.4 函数 normalize_features(mx)
这个函数类似地归一化特征矩阵,确保每个特征的和为1,通过行归一化实现。
def normalize_features(mx):"""Row-normalize sparse matrix"""rowsum = np.array(mx.sum(1))r_inv = np.power(rowsum, -1).flatten() # 这里则是采用的1邻接矩阵是二分之一其他的都是以昂的r_inv[np.isinf(r_inv)] = 0.r_mat_inv = sp.diags(r_inv)mx = r_mat_inv.dot(mx)return mx
3.1.5 函数 accuracy(output, labels)
此函数计算模型输出和真实标签之间的准确率。
def accuracy(output, labels):preds = output.max(1)[1].type_as(labels) # 取到预测的类别correct = preds.eq(labels).double() # 判断相等的个数correct = correct.sum()return correct / len(labels)
函数 accuracy(output, labels)
是用来计算模型在给定输出和实际标签之间的准确率。这是机器学习中常用的性能评估指标之一,特别是在分类任务中。下面通过一个具体的例子来解释这个函数的工作原理和计算过程:
示例说明:
假设我们有一个简单的模型,用于对图像进行数字分类(0-9)。在一个批量中,我们有以下预测输出(通常是模型的最后一层,如 softmax 层)和相应的真实标签。
模型输出(output):
这是一个 5x10 的矩阵,每一行代表一个图像的预测输出,列数表示10个类别的概率(数字0-9)。每行的最大值所在的位置代表该模型对该图像的预测类别:
output = torch.tensor([[0.1, 0.1, 0.1, 0.1, 0.1, 0.2, 0.1, 0.1, 0.05, 0.05],[0.1, 0.1, 0.1, 0.6, 0.05, 0.05, 0.0, 0.0, 0.0, 0.0 ],[0.05, 0.15, 0.1, 0.05, 0.05, 0.05, 0.05, 0.4, 0.1, 0.05],[0.2, 0.2, 0.2, 0.1, 0.1, 0.05, 0.05, 0.05, 0.05, 0.1],[0.1, 0.1, 0.7, 0.05, 0.02, 0.01, 0.01, 0.0, 0.01, 0.0]
])
真实标签(labels):
这是一个包含5个元素的向量,每个元素是对应图像的真实类别索引:
labels = torch.tensor([5, 3, 7, 1, 2])
准确率计算步骤:
-
找出每行最大元素的索引:
- 使用
output.max(1)
获得每个预测中最高概率的索引,这些索引即为预测的类别。.max(1)[1]
表示对第一个维度(行即每个样本)操作,并获取索引([1]
获取的是索引,[0]
是值)。
preds = output.max(1)[1] # tensor([5, 3, 7, 0, 2])
- 使用
-
将预测类型转换与实际标签相同:
- 使用
.type_as(labels)
确保预测结果的数据类型与真实标签一致(尤其在使用不同类型进行比较时重要)。
- 使用
-
计算正确预测的数量:
- 使用
.eq(labels)
比较预测结果和真实标签是否相等,返回一个布尔型张量。 .double()
将布尔型张量转换为双精度浮点数,以便进行求和操作。
correct = preds.eq(labels).double() # tensor([1., 1., 1., 0., 1.])
- 使用
-
求和并计算准确率:
correct.sum()
计算正确预测的总数。- 除以标签总数得到准确率。
accuracy = correct.sum() / len(labels) # 0.8
3.2 requirements.txt
这个文件下存放着模型运行的版本很简单:
numpy==1.15.1
scipy==1.1.0
torch==0.4.1.post2
3.3 layers.py
在上一节中,我们探讨了如何使用 PyTorch 构建模型,其方式类似于将多个积木块逐步堆叠起来。每个“积木块”代表网络中的一个组件或层。在图注意力网络(GAT)中,最核心的组件是图注意力层,它是整个网络的基础并且直接影响到模型的性能和效果。先来看下网络的代码情况。
# 引入必要的库
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 # Dropout概率self.in_features = in_features # 输入特征的维度self.out_features = out_features # 输出特征的维度self.alpha = alpha # LeakyReLU的参数self.concat = concat # 是否在多层GAT中进行拼接# 权重矩阵W,用于特征转换self.W = nn.Parameter(torch.empty(size=(in_features, out_features)))nn.init.xavier_uniform_(self.W.data, gain=1.414) # 使用Xavier初始化权重# 注意力机制的参数aself.a = nn.Parameter(torch.empty(size=(2*out_features, 1)))nn.init.xavier_uniform_(self.a.data, gain=1.414) # 使用Xavier初始化注意力参数# 激活函数self.leakyrelu = nn.LeakyReLU(self.alpha)# 前向传播函数def forward(self, h, adj):Wh = torch.mm(h, self.W) # 对输入特征进行线性变换e = self._prepare_attentional_mechanism_input(Wh) # 计算注意力机制中的e# 创建一个非常小的值,用于在不连接的节点间填充,以便在softmax后归零zero_vec = -9e15*torch.ones_like(e)attention = torch.where(adj > 0, e, zero_vec) # 用邻接矩阵作为掩码选择e或填充值attention = F.softmax(attention, dim=1) # 对注意力分数进行softmax归一化attention = F.dropout(attention, self.dropout, training=self.training) # 应用dropouth_prime = torch.matmul(attention, Wh) # 应用注意力权重# 根据concat标志使用ELU激活函数或返回原值if self.concat:return F.elu(h_prime)else:return h_prime# 辅助函数,用于计算注意力机制中的输入edef _prepare_attentional_mechanism_input(self, Wh):Wh1 = torch.matmul(Wh, self.a[:self.out_features, :]) # 产生e的左半部分Wh2 = torch.matmul(Wh, self.a[self.out_features:, :]) # 产生e的右半部分e = Wh1 + Wh2.T # 广播和转置,生成完整的e矩阵return self.leakyrelu(e) # 应用LeakyReLU激活函数# 重写__repr__方法提供类的字符串描述def __repr__(self):return self.__class__.__name__ + ' (' + str(self.in_features) + ' -> ' + str(self.out_features) + ')'`
客观而言这个代码看起来比较难,主要是的行为就是论文中的卷积行为的实现,我们看模型前向传播中的第一行代码,进行操作的如下图:
Wh = torch.mm(h, self.W) # h.shape: (N, in_features), Wh.shape: (N, out_features)
在图注意力层中,所有节点的特征矩阵与一个权重矩阵相乘,这一步骤类似于传统神经网络中的全连接层。此操作的目的是对节点的原始特征进行转换和提取,以便在图结构数据中捕捉更丰富的信息。举例来说,假设我们的图中有五个节点,每个节点最初具有 3 个特征维度。通过与一个将维度从 3 转变为 7 的权重矩阵相乘,每个节点的特征维度都被扩展到了 7。这不仅增加了特征的维度,而且通过训练得到的权重,实现了对特征的有效映射和提取。这种方式保证了网络能够学习到从原始特征到新特征空间的复杂映射关系,从而为后续的图结构处理任务提供更为丰富和有效的节点表示。
继续看下一行代码:
e = self._prepare_attentional_mechanism_input(Wh)
这里将提取得到的特征矩阵Wh
送入到了函数_prepare_attentional_mechanism_input
中进行计算得到注意力实数e
可以理解成prepare_attentional_mechanism_input
就是在完成公式中的这部分操作:
即使一个东西和 a a a向量相乘。
def _prepare_attentional_mechanism_input(self, Wh):Wh1 = torch.matmul(Wh, self.a[:self.out_features, :]) # 产生e的左半部分Wh2 = torch.matmul(Wh, self.a[self.out_features:, :]) # 产生e的右半部分e = Wh1 + Wh2.T # 广播和转置,生成完整的e矩阵return self.leakyrelu(e) # 应用LeakyReLU激活函数
看起来和这个和文章中公式还是存在很大差异的,当然这是工程和论文之间常有的差异,首先咱们的Wh计算的是全部节点的,而公式中则是仅仅是一个节点的特征和权重矩阵进行乘积。首先看起一行代码做了什么:
Wh1 = torch.matmul(Wh, self.a[:self.out_features, :]) # 产生e的左半部分
通过矩阵乘法,将经过线性变化得到的节点特征 W h Wh Wh和 a a a的一半向量做了一个乘积??????大家是不是有些疑惑?先不考虑 W h Wh Wh这部分差异的问题,为啥这个 a a a也仅仅用了一半。
这仅仅是一个简单的分块矩阵计算:当矩阵 A A A 保持完整,而矩阵 B B B 被水平分为两个子块 B 1 B1 B1和 B 2 B2 B2时,矩阵乘法 C = A × B C = A \times B C=A×B 的操作可以根据如下步骤进行:
矩阵设置:
- A A A是一个完整的矩阵,没有被分块。
- B B B被水平分割为左半部 B B B 和右半部 B 2 B2 B2。
矩阵乘法计算:
由于 B B B 是水平分割的,这影响了如何对待矩阵 A A A和 B B B 的乘积:
-
计算左半部的乘积 C 1 C1 C1:
- C 1 = A × B 1 C1 = A \times B1 C1=A×B1,这个计算涉及到 A A A 与 B B B 左半部 B 1 B1 B1 的乘积。
-
计算右半部的乘积 ( C2 ):
- C 2 = A × B 2 C2 = A \times B2 C2=A×B2,这个计算涉及到 A A A与 B B B 右半部 B 2 B2 B2 的乘积。
结合结果:
- 结果矩阵 C C C可以通过水平拼接 C 1 C1 C1 和 C 2 C2 C2 的结果得到,即
C = [ C 1 C 2 ] C = \begin{bmatrix} C1 & C2 \end{bmatrix} C=[C1C2]
回到上述问题,这样带入一下 W h Wh Wh 就是例子中的 A A A 通过个分块矩阵 B B B 做乘积,这个分块矩阵 B B B 就是注意力向量 a a a ,被分成了两部分来和节点特征矩阵 W h Wh Wh做承接,最终的结果就是:
W h ∗ a = W h ∗ a 1 ∣ ∣ W h ∗ a 2 Wh*a=Wh*a_1||Wh*a_2 Wh∗a=Wh∗a1∣∣Wh∗a2
这里的 a 1 a_1 a1就是注意力向量被分成一半的向量。文中重复使用了 W h Wh Wh 两次分别和特征向量的一半进行了乘积,计算得到了 W h 1 Wh1 Wh1和 W h 2. Wh2. Wh2.
那么现在仅仅是上述公式Whi没计算了,这部分差异就是是实际应用与理论之间的一个典型适配过程。所以先不考虑,只要能计算出来eij
即可。
回到代码部分实际上就是:
特征投影:
Wh = torch.mm(h, self.W)
:首先,每个节点的特征通过权重矩阵W
被投影到新的特征空间,得到新的特征表示Wh
。
注意力系数计算:
Wh1 = torch.matmul(Wh, self.a[:self.out_features, :])
和Wh2 = torch.matmul(Wh, self.a[self.out_features:, :])
:这两步分别计算了转换后的特征Wh
与注意力向量a
的两个切片的乘积。a
被分为两部分,第一部分与每个节点的新特征相乘得到Wh1
,第二部分同理得到Wh2
。
这里仅仅是将特征映射数值问题。这个数值被称为注意力系数,大多数人看这里已经很难受了,仅仅被映射为一个数值就能呗称为注意力吗。诚然最开始这个数值一定是没任何意义的但是随着数值梯度计算的增加,这个数值就变的越来越接近我们需要的注意力情况。
e = Wh1 + Wh2.T # 广播和转置,生成完整的e矩阵
这步就是对全部的数值进行计算得到的重点来了
广播和加法:
e = Wh1 + Wh2.T
:这里涉及到广播机制。虽然Wh1
和Wh2
的转置Wh2.T
本身是两个向量(N1维度),但在相加时,Wh1
中的每个元素(节点i的注意力得分)会与Wh2.T
中的每个元素(所有节点对节点i的影响得分)相加。通过广播,Wh1
中的每个分值被复制N次,与Wh2.T
的每一行相加,最终形成一个完整的NN维度的矩阵。这个矩阵的每一行描述了一个节点相对于所有其他节点的注意力得分。
最终进行线性激活得到最终e
的数值。最终得到了计算公式中要求的eij
可以说是因有尽有要啥有啥的eij
如果上面你没看懂这里在稍微解释下,你要有Transformer的基础可以这样理解这个行为:
在
_prepare_attentional_mechanism_input
函数中,我们首先将节点特征矩阵Wh
与注意力机制向量
a
的两个部分进行矩阵乘法。这里的a
被切分为两部分,每部分独立地与Wh
相乘。这两部乘法分别对应于注意力机制中的查询(query)和键(key)的概念。操作
Wh1 + Wh2.T
的目的是将每一个节点的查询分数与所有其他节点的键分数进行相加。这一步是计算注意力系数的核心,其中
Wh2.T
的转置确保了与Wh1
的每一行都可以相加,形成一个完整的注意力分数矩阵e
。在这个矩阵中,第 (i) 行第
(j) 列的元素表示节点 (i) 对节点 (j) 的初步注意力得分。最后,通过 LeakyReLU
激活函数对注意力分数进行非线性转换,这有助于增强模型处理不同类型节点间关系的能力。此激活函数的轻微负斜率有助于在学习过程中保持数值的稳定性,并助于区分那些不太重要的连接(即接近零的分数)。这种计算方式允许模型动态地对每个节点与其它所有节点之间的关系进行评分,进而在进行节点特征的加权组合时,可以根据这些评分来突出最重要的特征,从而实现有效的特征学习。
回过头来接着聊,这里的注意力数值矩阵e
完成了全部节点的注意力计算。继续看GATlayer前向传播中的代码:
# 创建一个非常小的值,用于在不连接的节点间填充,以便在softmax后归零zero_vec = -9e15*torch.ones_like(e) # 创建一个N*N的矩阵并且其数值很小数值都是一样的attention = torch.where(adj > 0, e, zero_vec) # 用邻接矩阵作为掩码选择e或填充值
'''
举例子不明白 torch.where
>>> import torch
>>> a=torch.randn(3,5)
>>> b=torch.ones(3,5)
>>> a
tensor([[-0.0310, 1.5895, 1.6003, -1.7584, 1.1478],[ 0.6773, 0.7763, 0.5024, 0.4952, 0.4198],[ 1.5132, 0.5185, 0.2956, -0.6312, -1.4787]])
>>> b
tensor([[1., 1., 1., 1., 1.],[1., 1., 1., 1., 1.],[1., 1., 1., 1., 1.]])
>>> torch.where(a>0,a,b) # 满足条件返回a, 不满足条件返回b
tensor([[1.0000, 1.5895, 1.6003, 1.0000, 1.1478],[0.6773, 0.7763, 0.5024, 0.4952, 0.4198],[1.5132, 0.5185, 0.2956, 1.0000, 1.0000]])'''attention = F.softmax(attention, dim=1) # 对注意力分数进行softmax归一化attention = F.dropout(attention, self.dropout, training=self.training) # 应用dropout# 如果要是类别的话,现在的attention实际上和邻接矩阵一样仅仅是其1都变了注意力百分比数值h_prime = torch.matmul(attention, Wh) # 应用注意力权重 # 根据concat标志使用ELU激活函数或返回原值
h_prime = torch.matmul(attention, Wh) # 应用注意力权重
这个就和GCN中的 A X AX AX是一样的,不同的仅仅是通过注意力聚合邻居特征信息。
最后的这个:
在Python中,__repr__
是一个特殊方法,用于定义对象的“官方”字符串表示,这通常可以用来显示详尽的信息,便于开发者理解对象的结构。在一个类中实现此方法,可以使得打印对象时返回定制的信息,而不是默认的不易阅读的内存地址。也是为了更好的debug。确保维度的一致性。
关于 GraphAttentionLayer 类的 __repr__
方法:
这个方法被设计来提供有关 GraphAttentionLayer
对象的直观且有意义的描述,这对于调试和日志记录非常有用。
def __repr__(self):return self.__class__.__name__ + ' (' + str(self.in_features) + ' -> ' + str(self.out_features) + ')'
self.__class__.__name__
: 获取类名称,即GraphAttentionLayer
。str(self.in_features)
: 将输入特征的维数转换为字符串。str(self.out_features)
: 将输出特征的维数转换为字符串。
返回值:
- 该方法返回一个字符串,格式为
"GraphAttentionLayer (输入特征维数 -> 输出特征维数)"
。例如,如果一个GraphAttentionLayer
实例是从 256 维到 64 维的特征转换,那么__repr__
将返回"GraphAttentionLayer (256 -> 64)"
。
便于阅读理解。
3.4 models.py
上一节对GAT模型需要使用的注意力层进行简介现在我们就要堆积木看看最终的模型是如何完成工作的。下面是对这个GAT类的方法和结构的详细解释:
您提供的代码定义了一个基于图注意力网络(GAT)的模型类 GAT
。这个类是借助 PyTorch 框架实现的,并且使用了多头注意力(multi-head attention),这是一种能够增强模型学习能力和性能的技术。下面是对这个类的方法和结构的详细解释:
GAT 类定义
class GAT(nn.Module):def __init__(self, nfeat, nhid, nclass, dropout, alpha, nheads):
nfeat
: 输入特征的维数。nhid
: 隐藏层的维数。nclass
: 输出的类别数,也即是分类任务中的类别数或者一些回归任务的特征维度。dropout
: 在模型中使用的dropout率,用于正则化以防止过拟合。alpha
: 在LeakyReLU激活函数中使用的负斜率。nheads
: 多头注意力的头数,每个头都会输出对应的特征,提高模型的表达能力。
初始化方法 (__init__
)
super(GAT, self).__init__()
self.dropout = dropoutself.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)
- 初始化基类
nn.Module
。 - 创建一个多头注意力层的列表。每个头是一个独立的
GraphAttentionLayer
实例。 - 通过
self.add_module
动态地为模型添加每个注意力头,使得每个注意力头都可以被优化和追踪。 self.out_att
是用来生成最终输出的单一注意力层,其输入维度是所有头输出的总和。
这里简单说下 self.add_module
的问题。
在构建深度学习模型,尤其是在使用 PyTorch 等框架时,确实可以通过直接将层(如 nn.Linear
, nn.Conv2d
等)作为类属性来定义模型。例如,如果你只有几层并且层的结构相对简单,你可以直接在模型的 __init__
函数中声明这些层:
class SimpleModel(nn.Module):def __init__(self):super(SimpleModel, self).__init__()self.layer1 = nn.Linear(10, 20)self.layer2 = nn.Linear(20, 30)def forward(self, x):x = F.relu(self.layer1(x))x = self.layer2(x)return x
在这种情况下,因为 self.layer1
和 self.layer2
是通过直接属性赋值创建的,PyTorch 自动注册这些层及其参数。这种方式是非常清晰和方便的,特别是当层的数量相对固定且不需要在运行时动态变化时。
为什么需要使用 add_module
?
使用 self.add_module
主要是在以下几种情况下:
-
动态添加层: # GAT就是这种情况
如果层数量或类型依赖于某些输入参数或运行时决定的条件,那么使用add_module
可以动态地添加层。例如,在处理可变数量的层时,如在GAT中使用的多头注意力机制:for i in range(nheads):self.add_module('head_{}'.format(i), GraphAttentionLayer(...))
这样可以根据
nheads
的值动态创建不同数量的注意力头。 -
循环或条件语句中创建层:
当层的创建涉及到循环或条件语句,并且层数随参数变化时,直接声明层通常做不到这一点。add_module
允许在这些编程结构中注册层。 -
非标准层结构:
对于一些复杂的或非标准的层结构,可能需要在运行时根据数据或其他模型配置动态调整模型结构。在这种情况下,使用add_module
提供了必要的灵活性。
结论
直接声明层在模型结构固定且相对简单时完全可行,这种方法代码更直观清晰。当涉及到动态或复杂的模型构造时,add_module
提供了更多的灵活性和控制能力。在实际应用中,选择哪种方法应根据具体需求和场景来决定。
前向传播方法 (forward
)
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)
- 应用 dropout 到输入特征。
- 使用列表推导式和
torch.cat
来聚合所有注意力头的输出,将它们在特征维度上拼接。 - 再次应用 dropout。
- 通过最终的输出注意力层和 ELU(Exponential Linear Unit)激活函数处理结果。
- 使用 softmax 函数将输出转换为概率分布形式。这通常用于分类任务的输出。
这个模型类实现了图注意力机制的端到端模型,可以应用于图形结构数据的节点分类任务。每个节点都通过学习其邻接节点的有用特征,以一种注意力驱动的方式计算其表示。通过这种方式,GAT 能够有效地捕获图结构中的特点和模式。
3.5 train.py
现阶段数据模型以及使用的各种工具都准备好了,终于可以点开咱们的这个训练文件看看最终模型时如何成功训练的啊。
python
from __future__ import division # 为了在Python 2.x中支持精确除法
from __future__ import print_function # 为了在Python 2.x中使用Python 3.x风格的print函数import os # 用于处理文件和目录等操作系统任务
import glob # 用于文件路径名的模式匹配
import time # 用于时间相关任务
import random # 提供生成随机数的工具
import argparse # 用于解析命令行参数
import numpy as np # 提供科学计算功能
import torch # PyTorch,一个强大的深度学习框架
import torch.nn as nn # 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 # 导入GAT和SpGAT模型# 设置训练参数
parser = argparse.ArgumentParser() # 创建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() # 判断是否使用CUDA# 设定随机种子以保证可重复性
random.seed(args.seed)
np.random.seed(args.seed)
torch.manual_seed(args.seed)
if args.cuda:torch.cuda.manual_seed(args.seed) # CUDA种子# 加载数据
adj, features, labels, idx_train, idx_val, idx_test = load_data()# 初始化模型和优化器
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)# 如果使用CUDA,将数据和模型转到GPU
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()# 使用Variable包装数据以便于自动求导
features, adj, labels = Variable(features), Variable(adj), Variable(labels)# 定义训练函数
def train(epoch):t = time.time() # 开始计时model.train() # 模型为训练模式可以进行梯度更新optimizer.zero_grad() # 梯度清0output = 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: #判断一下用不用跳过验证集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()# 定义测试函数
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()))# 开始训练模型
t_total = time.time() # 记录训练开始时间,用于计算总训练时间loss_values = [] # 用于存储每个epoch的损失值
bad_counter = 0 # 用于记录连续多少个epoch没有改善效果
best = args.epochs + 1 # 初始化最佳损失值为非常大的值
best_epoch = 0 # 记录达到最佳损失值的epochfor epoch in range(args.epochs): # 循环遍历每一个epochloss_values.append(train(epoch)) # 运行train函数进行一次训练并将损失值加入列表torch.save(model.state_dict(), '{}.pkl'.format(epoch)) # 保存当前模型的状态if loss_values[-1] < best: # 如果当前epoch的损失小于之前记录的最佳损失best = loss_values[-1] # 更新最佳损失best_epoch = epoch # 更新最佳epochbad_counter = 0 # 重置bad_counterelse: # 如果当前epoch的损失没有改善bad_counter += 1 # bad_counter加1if bad_counter == args.patience: # 如果连续多个epoch没有改善(达到用户设定的耐心值)break # 终止训练files = glob.glob('*.pkl') # 获取当前目录下所有.pkl文件for file in files: # 遍历所有保存的模型文件epoch_nb = int(file.split('.')[0]) # 获取文件名中的epoch号if epoch_nb < best_epoch: # 如果该文件不是最佳epochos.remove(file) # 删除该文件files = glob.glob('*.pkl') # 再次获取当前目录下所有.pkl文件
for file in files: # 遍历所有保存的模型文件epoch_nb = int(file.split('.')[0]) # 获取文件名中的epoch号if epoch_nb > best_epoch: # 如果该文件不是最佳epochos.remove(file) # 删除该文件print("Optimization Finished!") # 输出训练完成的信息
print("Total time elapsed: {:.4f}s".format(time.time() - t_total)) # 输出训练总时间# 加载最优模型进行测试
print('Loading {}th epoch'.format(best_epoch)) # 输出加载哪一个epoch的模型
model.load_state_dict(torch.load('{}.pkl'.format(best_epoch))) # 加载模型
compute_test() # 运行测试函数进行测试
代码中使用了两次的文件清理代码,这是为什么呢???
在特定情况下,会多出一个文件。这种情况通常发生在触发早停机制后,即在bad_counter累积达到patience阈值而终止训练循环时。如果在达到patience阈值的同一epoch还进行了模型的保存操作,那么这个模型文件会被保存下来,即使它可能并不是当前最佳的模型。这场景下,最后一个被保存的模型实际上可能没有经过必要的清理。
在早停时的这个epoch,模型依然完成了保存操作,因为保存步骤通常在损失值比较和bad_counter增加之前进行。所以,除最佳模型外,可能会多留下一个文件,它对应于触发早停的那个epoch。
如何避免这种情况?
要彻底避免这种情况,可以通过对保存操作的时机进行微调,确保只在确认当前epoch的模型是值得保留时才进行保存。也可以通过更改保存逻辑,在每次epoch结束时,先检查bad_counter是否已达到patience,若达到则跳过保存。示例代码可调整为:
for epoch in range(args.epochs):loss_val = train(epoch)loss_values.append(loss_val)if bad_counter < args.patience:torch.save(model.state_dict(), '{}.pkl'.format(epoch))if loss_val < best:best = loss_valbest_epoch = epochbad_counter = 0else:bad_counter += 1else:breakfiles = glob.glob('*.pkl')for file in files:epoch_nb = int(file.split('.')[0])if epoch_nb < best_epoch:os.remove(file)# 最后,在训练结束后做最后一次清理服从同样的规则
files = glob.glob('*.pkl')
for file in files:epoch_nb = int(file.split('.')[0])if epoch_nb > best_epoch:os.remove(file)
通过这种方式,模型保存操作仅在模型可能是最佳模型时执行,且在达到patience时适时终止保存,进一步减少了不必要的文件存储。这也使得代码逻辑更为清晰,确保了存储空间的有效利用和更高效的运行。
3.6 visualize_graph.py
现在已经对代码的主体全部文件进行了解读,各位看完这些就可以认真的研究属于自己的模型了,还有最后一个文件是visualize_graph图的可视化。我在这里简要的注释下,各位自行学习:
此代码使用 `graphviz` 工具生成 PyTorch 自动求导图的可视化表示,这对于理解和调试模型的计算图非常有帮助。具体实现在 `make_dot` 函数中,它将生成该模型每个操作或变量的图形表示。以下是该代码段的详细解释和逐行注释:```python
from graphviz import Digraph # 导入图形可视化库
import torch
import models # 假设models模块包含定义的SpGAT模型类def make_dot(var, params):""" 生成PyTorch自动求导图的Graphviz表示蓝色节点表示需要梯度的变量,橙色为为反向传播保存的张量Args:var: 输出变量params: 字典形式的(name, Variable),用于给需要梯度的节点添加名称"""param_map = {id(v): k for k, v in params.items()} # 创建参数ID到名称的映射print(param_map) # 打印参数map,可以看到每个参数的内存地址与其对应的名称node_attr = dict(style='filled',shape='box',align='left',fontsize='12',ranksep='0.1',height='0.2') dot = Digraph(node_attr=node_attr, graph_attr=dict(size="12,12")) # 创建一个Graphviz图对象,设置图的默认节点属性seen = set() # 用于记录已经添加到图中的节点,防止重复添加def size_to_str(size):return '(' + ', '.join(['%d' % v for v in size]) + ')'def add_nodes(var):if var not in seen:if torch.is_tensor(var):dot.node(str(id(var)), size_to_str(var.size()), fillcolor='orange')elif hasattr(var, 'variable'):u = var.variablenode_name = '%s\n %s' % (param_map.get(id(u)), size_to_str(u.size()))dot.node(str(id(var)), node_name, fillcolor='lightblue')else:dot.node(str(id(var)), str(type(var).__name__))seen.add(var)if hasattr(var, 'next_functions'):for u in var.next_functions:if u[0] is not None:dot.edge(str(id(u[0])), str(id(var)))add_nodes(u[0])if hasattr(var, 'saved_tensors'):for t in var.saved_tensors:dot.edge(str(id(t)), str(id(var)))add_nodes(t)add_nodes(var.grad_fn) # 从变量的梯度函数开始递归添加节点和边return dotinputs = torch.randn(100, 50).cuda() # 输入张量
adj = torch.randn(100, 100).cuda() # 邻接矩阵
model = models.SpGAT(50, 8, 7, 0.5, 0.01, 3) # 创建模型实例
model = model.cuda() # 将模型迁移到CUDA
y = model(inputs, adj) # 前向传播g = make_dot(y, model.state_dict()) # 生成计算图
g.view() # 在默认的PDF阅读器中显示生成的图
主要步骤解释:
- 创建图和节点:初始化一个 Graphviz 的图对象,并为加入图的每个 tensor 或 operation 创建节点。
- 图节点属性设置:设定图节点的样式,如填充色、字体大小等。
- 递归添加节点函数:使用递归方式遍历整个计算图,为每个 tensor 和 function 添加节点,并为他们之间的依赖关系添加边。
- 模型和输入:实例化模型,创建输入张量,并执行前馈。
- 生成并查看计算图:调用
make_dot
生成计算图并显示。
这种可视化非常有助于理解模型的内部结构和数据流,尤其是在调试复杂网络结构时。
四、总结
经过对图注意力网络(GAT)的详细解析和代码实践,相信各位加深了对图神经网络的理解。本文档的目的是通过结合理论基础和实践经验,帮助读者更好地理解如何构建和训练图神经网络。在下面章节中我会对graghSage进行实战的论文精读以及代码详解,感兴趣的同学可以催催我加速更新。感谢你的观看。
如果您觉得还不错的话,可以奖励打赏小弟一杯咖啡钱,创作不易。如果你对GNN感兴趣,不妨点赞、收藏并关注,这是对我工作的最大支持和鼓励。非常感谢!如果有任何问题,欢迎随时私信我。期待与你的互动!