AM-GCN 网络系列
- 代码实践部分
- 1. dataprocess.py
- 1.1 模块导入
- 1.2 特征文件生成
- 1.3 KNN构图
- 2. configparser.py
- 3. layers.py
- 4. models.py
- 5. utils.py
- 6. main.py
- 总结
代码实践部分
本专栏致力于深入探讨图神经网络模型相关的学术论文,并通过具体的编程实验来深化理解。读者可以根据个人兴趣选择相关内容进行学习。在上一节中详细解读了 “AM-GCN” 这篇论文以及如何运行和代码文件的整体情况。对于那些对传统图神经网络感兴趣的读者,可以通过点击此处查阅更多相关内容。
在本章节中我们将讲解该论文模型的主体代码。我会按照文件的划分设定章节,各位可按需求进行跳转。
这个原文的代码地址感兴趣的读者自行下载即可 https://github.com/2578562306/AM-GCN
😃当然要是觉得还不错的话,烦请点赞,收藏➕关注👍
1. dataprocess.py
首先介绍这个代码文件的原因是因为它独立于模型的其余运行部分。
执行这个代码模型就会对数据集提供的[‘y’, ‘ty’, ‘ally’,‘x’, ‘tx’, ‘allx’,‘graph’]文件进行预处理,生成符合模型需要的文件数据。
即上一节讲解数据集解压得到的文件,就是当前文件代码生成的。当然好奇Cora数据是如何变成[‘y’, ‘ty’, ‘ally’,‘x’, ‘tx’, ‘allx’,‘graph’]文件的读者同样可以点击这里看我的这个博文。对其进行深入的研究。
明确了这个代码的主要能力— 将原始数据转换成模型需要的数据形式。 然后我们再来看着部分的代码具体是如何实现这个能力的呢???下面我们详细解读各部分的功能和执行逻辑:
可以看到文件分为五个部分,导入各种模块的部分和文件代码下的四个函数。
1.1 模块导入
我们首先讲解,使用了哪些函数他们具备的功能:
import sys
import pickle as pkl
import numpy as np
import scipy.sparse as sp
from sklearn.metrics.pairwise import cosine_similarity as cos
from sklearn.metrics import pairwise_distances as pair
from utils import normalize
最后一个导入的是通过当前AMGCN文件下utils构建的正则化我们后续讲解其文件再解释,这里对上面常用的包进行解释:
import sys
- 功能:该模块提供了一些针对Python运行环境的函数和变量。常用于与Python解释器交互或访问由解释器使用或维护的变量。
- 用途:在这个代码中,
sys
模块可能被用来访问系统相关的信息,比如Python版本信息(sys.version_info
)。这在处理版本兼容问题时尤其有用。
import pickle as pkl
- 功能:
pickle
是一个序列化和反序列化Python对象结构的模块。序列化过程将Python对象转换为字节流,而反序列化过程恢复字节流回Python对象。 - 用途:在这段代码中,
pickle
模块用来加载保存在文件中的Python对象。这对于读取那些在之前某个时刻被序列化并存储下来的Python对象(例如,数据集特征、标签、图结构等)特别重要。
import numpy as np
- 功能:
numpy
是Python的一个强大的数值计算扩展。此库支持高阶大量维度数组与矩阵运算,此外也针对数组运算提供大量的数学函数库。 - 用途:
numpy
在处理任何形式的数值数据时几乎是必需的。在处理图数据或机器学习数据时,常用于数据的转换、标准化,以及执行各种数学运算。
import scipy.sparse as sp
- 功能:
scipy.sparse
提供了对稀疏矩阵的支持。稀疏矩阵是大部分元素为0的矩阵,使用稀疏矩阵可以在存储和计算上大大节省空间和时间。 - 用途:在图数据处理中,邻接矩阵往往是稀疏的,使用
scipy.sparse
可以高效地处理这些数据。
from sklearn.metrics.pairwise import cosine_similarity as cos
- 功能:从Scikit-Learn库中导入
cosine_similarity
函数,用于计算数据点间的余弦相似度。 - 用途:计算节点或数据点之间的相似度,常用于构建基于特征相似度的图结构,如在生成KNN图中使用。
from sklearn.metrics import pairwise_distances as pair
# 这个在论文代码中未使用。
- 功能:导入
pairwise_distances
函数,该函数用于计算成对数据点之间的距离。 - 用途:虽然在此代码段中未直接使用,但这个函数通常用于评估数据点间的距离,有助于完成例如聚类、KNN等基于距离的机器学习任务。
这些模块和函数为数据的加载、处理和图的构造提供了基础设施,是数据科学和机器学习应用的常用工具。
1.2 特征文件生成
了解所用库的功能是非常重要的,因为它为我们之后对函数实际应用的理解打下基础。 在这个文件中提供的四个函数可以逻辑上分为两组,主要基于它们的功能是否依赖进行分组。这种分组方法有助于更清晰地理解每组函数的专门用途,并展示它们是如何协同工作来处理数据和构建图。
函数 parse_index_file
和 process_data
在数据处理流程中具有不同的功能层次,并且相互依赖。以下是对这两个函数之间关系的更详细阐述:
函数:parse_index_file
- 功能:这个函数专门用来解析存储索引的文件,返回一个整型列表。这些索引通常代表重要的数据分割点,例如标识出数据集中哪些是测试节点。
- 特点:
parse_index_file
本身并不进行复杂的数据处理,而是提供必要的辅助功能,使得process_data
函数能够正确地引用和处理特定的数据部分,如测试数据集。
函数:process_data
- 功能:这个函数是数据预处理的核心,负责加载、整理、和预处理数据。它处理包括特征、标签、图结构等多种类型的数据,并将它们结构化成适合进一步机器学习和数据分析操作的格式。
- 依赖关系:
process_data
函数依赖parse_index_file
来获取正确的测试节点索引。这是因为数据处理中往往需要特别处理某些数据片段(如区分训练集和测试集),而这些片段是通过解析索引文件得到的。
功能组合
- 在这个功能组合中,
parse_index_file
虽然功能较为简单,但它对process_data
的成功执行至关重要。这种层次和依赖关系体现了一个大的、复杂功能(数据处理)如何依赖于较小、专一的子功能(解析索引)来实现。 - 通过将
parse_index_file
作为process_data
的一个子功能来看待,我们可以更清楚地理解数据处理步骤中的内部逻辑和流程,确保数据的正确加载和处理。
具体的可以看到process_data
在代码内容要使用函数parse_index_file
实现的功能对文件进行处理。
接下来通过代码注释进行讲解:
def parse_index_file(filename):"""Parse index file."""index = [] # 初始化一个空列表用来存储索引。for line in open(filename): # 打开索引文件,并迭代每一行。index.append(int(line.strip())) # 移除每行两端的空白符,并将其转换为整数后添加到列表中。return index # 返回索引列表。def process_data(dataset):names = ['y', 'ty', 'ally', 'x', 'tx', 'allx', 'graph'] # 定义数据类型名称列表。objects = [] # 初始化一个空列表用来存储各数据类型的对象。for i in range(len(names)): # 遍历数据类型名称列表。with open("../data/cache/ind.{}.{}".format(dataset, names[i]), 'rb') as f: # 打开各数据类型文件。if sys.version_info > (3, 0): # 检查Python版本。objects.append(pkl.load(f, encoding='latin1')) # 使用Python 3的方式读取pickle文件。else:objects.append(pkl.load(f)) # 使用Python 2的方式读取pickle文件。y, ty, ally, x, tx, allx, graph = tuple(objects) # 将读取的数据分配到对应变量。
# a = [1, 2, 3]
# b, c, d = a
# y, ty, ally, x, tx, allx, graph = tuple(objects) 中的 tuple(objects)
# 是为了确保 objects 是一个元组类型,tuple(objects) 实现,它将列表 objects 转换成了一个元组。print(graph) # 打印图结构。# ---------------------------------------------------
# 下面开始使用上面构建的函数了
# ---------------------------------------------------test_idx_reorder = parse_index_file("../data/cache/ind.{}.test.index".format(dataset)) # 解析测试节点索引文件。test_idx_range = np.sort(test_idx_reorder) # 对测试节点索引进行排序。if dataset == 'citeseer': # 特定于'citeseer'数据集的处理。test_idx_range_full = range(min(test_idx_reorder), max(test_idx_reorder) + 1) # 创建一个完整的测试索引范围。tx_extended = sp.lil_matrix((len(test_idx_range_full), x.shape[1])) # 初始化一个新的大小为完整测试范围的稀疏矩阵。tx_extended[test_idx_range - min(test_idx_range), :] = tx # 在对应位置填充原有的测试特征数据。tx = tx_extended # 更新测试特征数据集。ty_extended = np.zeros((len(test_idx_range_full), y.shape[1])) # 初始化一个全零的标签数组。ty_extended[test_idx_range - min(test_idx_range), :] = ty # 在对应位置填充原有的测试标签数据。ty = ty_extended # 更新测试标签数据集。labels = np.vstack((ally, ty)) # 垂直堆叠训练和测试标签数据。labels[test_idx_reorder, :] = labels[test_idx_range, :] # 重新排列标签数据以匹配原测试索引。
# ---------------------------------------------------
# 下面详细讲了上面的一行代码问题,目的是确保使用的数据和GCN中论文是一致的,测试的节点也是一样的
# --------------------------------------------------- features = sp.vstack((allx, tx)).tolil() # 垂直堆叠训练和测试特征数据,并转换为LIL格式。features[test_idx_reorder, :] = features[test_idx_range, :] # 重新排列特征数据以匹配原测试索引。features = features.toarray() # 将特征数据转换为普通数组。print(features) # 打印特征数据。f = open('../data/{}/{}.adj'.format(dataset, dataset), 'w+') # 打开文件以写入邻接表。这个仅仅是写入了边信息for i in range(len(graph)):adj_list = graph[i] # 获取每个节点的邻接列表。for adj in adj_list:f.write(str(i) + '\t' + str(adj) + '\n') # 将每个邻接关系写入文件。f.close() # 关闭文件。label_list = [] # 初始化一个空列表用来存储最终的标签。for i in labels:label = np.where(i == np.max(i))[0][0] # 找到每个标签向量中最大值的索引。label_list.append(label) # 将索引添加到列表中。np.savetxt('../data/{}/{}.label'.format(dataset, dataset), np.array(label_list), fmt='%d') # 保存标签数据。np.savetxt('../data/{}/{}.test'.format(dataset, dataset), np.array(test_idx_range), fmt='%d') # 保存测试索引。np.savetxt('../data/{}/{}.feature'.format(dataset, dataset), features, fmt='%f') # 保存特征数据。**
这个代码我在此详细解释下
features[test_idx_reorder, :] = features[test_idx_range, :] # 重新排列特征数据以匹配原测试索引。
操作还是过于抽象了,我在这里解释下其核心目的:
确实,这段代码的操作可能显得有点晦涩,主要是因为这里涉及到索引的重新排序,这会改变数据的原有排列顺序。让我们通过一个例子来具体解释这段代码的作用。
假设我们有一些数据,并且这些数据已经被分割成训练集和测试集。这里的操作主要是关注如何根据测试集的索引来重排整个数据集,以保证测试数据可以按照一定的顺序进行处理。
原始数据示例
假设我们有以下的数据标签(标签示例简化为数字):
ally
= [10, 20, 30] (训练数据的标签)ty
= [40, 50, 60, 70] (测试数据的标签)
合并后的标签数组 labels
为:
[
[10, 20, 30, 40, 50, 60, 70]
]
假设 test_idx_reorder
= [5, 3, 6, 4],这是一些从数据读取或其他途径获得的测试数据索引,表明这些索引处的数据是用于测试的。就是最后的四个节点用于测试并且顺序被打乱了
重排序操作解释
-
排序:
test_idx_range = np.sort(test_idx_reorder) # [3, 4, 5, 6]
-
重排操作:
初始labels
= [10, 20, 30, 40, 50, 60, 70]对
labels
的索引进行操作,其中test_idx_reorder
指的是原始的测试索引,例如我们希望按照这个索引取数据,
test_idx_range
是排序后的索引,这个排序的目的是希望能获取按顺序排列的测试数据。这里的关键操作是:
labels[test_idx_reorder, :] = labels[test_idx_range, :]
- 这个操作把
labels
在test_idx_range
索引处的项(即 [40, 50, 60, 70])取出来, - 然后将这些项按
test_idx_reorder
的顺序重新放入labels
中。
执行后,
labels
看起来像这样:- 步骤一: 从
labels
中按test_idx_range
([3, 4, 5, 6])顺序取值 --> [40, 50, 60, 70] - 步骤二: 按照
test_idx_reorder
的顺序[5, 3, 6, 4]放回labels
- 结果:
labels
= [10, 20, 30, 50, 70, 40, 60]
- 这个操作把
1.3 KNN构图
分析模式应用于函数 construct_graph
和 generate_knn
函数:construct_graph
- 功能:此函数的主要目标是根据提供的特征数据构建一个基于K近邻(KNN)的图结构。它利用特征之间的相似度(在此示例中为余弦相似度)来确定节点之间的连接。
- 特点:
construct_graph
直接操作数值数据以构建图的邻接列表。它独立处理每一个特征向量,确定与之相似度最高的topk
个节点,并记录这些关系。因而,此函数执行了关键的图结构构建任务,但依赖于外部提供的准确和适当预处理的特征数据。
函数:generate_knn
- 功能:作为数据处理流程的控制中心,该函数负责生成不同
topk
值的KNN图。它循环调用construct_graph
函数,以多种邻居数量构建多个图版本,这对于评估不同K值在图模型性能中的影响非常有用。 - 依赖关系:
generate_knn
依赖于construct_graph
来为每个topk
设置实现具体的图构建。此外,它还依赖于从文件中加载处理后的特征数据,以及控制文件的读写来存储生成的图数据。
功能组合
- 在这组功能中,
construct_graph
并不是简单的函数,它实现了图构建的全部逻辑,处理复杂的数学计算以及邻接关系的确定。尽管如此,它作为generate_knn
中的构建步骤被重复调用,体现了在更大框架下的专一性和重要性。 generate_knn
函数则扮演了更高层次的角色,它不仅控制图的构建过程,还管理着数据的加载和终构建图数据的保存。这显示了它在数据处理流程中的核心地位,它将construct_graph
的输出整合并展开为成熟的数据产品——即为分析或机器学习任务准备的图结构。
具体的可以看到generate_knn
在代码内容要使用函数construct_graph
实现的功能对文件进行处理。
接下来通过代码注释进行讲解:
def construct_graph(dataset, features, topk, knn_directory):# 构建完整的文件路径用于保存KNN图fname = os.path.join(knn_directory, 'tmp.txt')# 以写模式打开文件with open(fname, 'w') as f:# 计算特征之间的余弦相似度cosine_distances = cos(features)# 对每个节点,确定其topk相似的邻居节点for i in range(cosine_distances.shape[0]):indices = np.argpartition(cosine_distances[i], -(topk + 1))[-(topk + 1):]
'''
array = np.array([10, 7, 4, 3, 2, 2, 5, 9, 0, 4, 6, 0])
#返回一个索引,比原数组第5大(从0开始)的数小的数在这个数之前,比这个数大的数在它之后。
index = np.argpartition(array, 4)
#输出,新索引
print(index)
#[ 4 11 8 5 3 2 9 6 1 10 7 0]
#按这个新索引可以重新排列数组
print(array[index])
#[ 2 0 0 2 3 4 4 5 7 6 9 10]
#第5大的数是3,比3小的在3之前,比3大的在3之后
#还是上边那个数组,输出top5
array = np.array([10, 7, 4, 3, 2, 2, 5, 9, 0, 4, 6, 0])
array[np.argpartition(array, -5)[-5:]]
#输出:[ 5, 7, 6, 9, 10]
'''# 在文件中记录每个节点与其邻居的关系,排除自身for index in indices:if index != i:f.write(f"{i} {index}\n")# 打印确认KNN图已成功构建并保存print(f"Graph constructed and saved in {fname}")def generate_knn(dataset):# 基本路径设置base_path = "/Users/wangyang/Desktop/图神经网络实验代码/AM-GCN-master/data"# 数据集的路径dataset_path = f"{base_path}/{dataset}"# 确定KNN图存储的具体目录knn_path = f"{dataset_path}/knn"# 如不存在KNN目录,则创建目录if not os.path.exists(knn_path):os.makedirs(knn_path)# 拼接特征数据文件路径feature_path = os.path.join(dataset_path, f"{dataset}.feature")# 加载特征数据features = np.loadtxt(feature_path, dtype=float)# 遍历不同的topk值for topk in range(2, 10):# 调用construct_graph函数构建KNN图construct_graph(dataset, features, topk, knn_path)# 构建临时文件路径和最终文件路径tmp_file_path = os.path.join(knn_path, "tmp.txt")output_file_path = os.path.join(knn_path, f"c{topk}.txt")if os.path.exists(tmp_file_path):# 读取临时文件,并将结果写入最终文件,确保只写入有向边with open(tmp_file_path, 'r') as f1, open(output_file_path, 'w') as f2:for line in f1:start, end = line.strip().split()if int(start) < int(end):f2.write(f"{start} {end}\n")# 打印确认KNN图已生成并保存print(f"KNN graph for topk={topk} generated and saved in {output_file_path}")else:# 如果临时文件不存在,打印错误信息print(f"File not found: {tmp_file_path}")# 调用函数以生成cora数据集的KNN图
generate_knn('cora')# 生成一个包含140个训练索引的列表,并保存到文件
idx_train = [i for i in range(140)]
np.savetxt('train20.txt', idx_train, fmt='%d')
有一个细节非常关键。在 generate_knn
函数中,tmp.txt
文件的确是被反复重写而不是每次都创建一个新的文件。这里的逻辑是针对每个 topk
值都调用一次 construct_graph
函数,每次调用都会打开相同的 tmp.txt
文件并以写模式('w'
)打开,这意味着文件的内容会在每次打开时被清空。
因此,tmp.txt
文件在每次迭代中都被替换掉了,最终只保存了最后一次的内容,即对应于最后一个 topk
值的数据。这也解释了为什么即使没有明确的删除操作,tmp.txt
文件也不会包含之前 topk
值的数据。
这种方式具有一定的优点:
- 简化文件管理
- 节约磁盘空间
2. configparser.py
使用这种方法编写代码的主要好处是通过一个类的实例化来集中管理所有相关的配置参数。这种方式使得只需要通过外部配置文件来调整参数,从而可以在不修改代码的情况下,按需加载和修改不同的配置设置。
以下是详细注释和解释提供的Config
类代码:
class Config(object):def __init__(self, config_file):# 通过 configparser 模块实例化 ConfigParser 对象conf = configparser.ConfigParser()# 尝试读取配置文件,如果不成功则打印错误信息try:conf.read(config_file)except:print(f"loading config: {config_file} failed")# 从配置文件中读取和设置模型的超参数self.epochs = conf.getint("Model_Setup", "epochs") # 读取训练周期数self.lr = conf.getfloat("Model_Setup", "lr") # 读取学习率self.weight_decay = conf.getfloat("Model_Setup", "weight_decay") # 读取权重衰减参数self.k = conf.getint("Model_Setup", "k") # 读取 KNN 的 K 值self.nhid1 = conf.getint("Model_Setup", "nhid1") # 读取第一隐藏层维数self.nhid2 = conf.getint("Model_Setup", "nhid2") # 读取第二隐藏层维数self.dropout = conf.getfloat("Model_Setup", "dropout") # 读取 dropout 参数self.beta = conf.getfloat("Model_Setup", "beta") # 读取 beta 参数self.theta = conf.getfloat("Model_Setup", "theta") # 读取 theta 参数self.no_cuda = conf.getboolean("Model_Setup", "no_cuda") # 确定是否使用 CUDA self.no_seed = conf.getboolean("Model_Setup", "no_seed") # 确定是否设置随机种子self.seed = conf.getint("Model_Setup", "seed") # 设置随机种子# 从配置文件中读取数据集相关的配置self.n = conf.getint("Data_Setting", "n") # 数据集中图的结点数self.fdim = conf.getint("Data_Setting", "fdim") # 特征维度self.class_num = conf.getint("Data_Setting", "class_num") # 类别数self.structgraph_path = conf.get("Data_Setting", "structgraph_path") # 结构图路径self.featuregraph_path = conf.get("Data_Setting", "featuregraph_path") # 特征图路径self.feature_path = conf.get("Data_Setting", "feature_path") # 特征数据路径self.label_path = conf.get("Data_Setting", "label_path") # 标签数据路径self.test_path = conf.get("Data_Setting", "test_path") # 测试集数据路径self.train_path = conf.get("Data_Setting", "train_path") # 训练集数据路径
这种设计模式(封装所有配置于类中并通过配置文件进行管理)的优势在于提高了代码的可维护性和可扩展性,并允许快速调整参数进行不同的实验,而无需每次进入代码深层进行硬编码。感兴趣的同学麻烦催我我后续会补上这个地方的官方解释博文。
3. layers.py
这个文件就是GCN中的常用图卷积层,感兴趣的读者可以点这里看到我对GCN的详细讲解。这里我仅仅对GCN的代码进行注释便于各位理解:
class GraphConvolution(Module):"""Simple GCN layer, similar to https://arxiv.org/abs/1609.02907"""def __init__(self, in_features, out_features, bias=True):super(GraphConvolution, self).__init__()self.in_features = in_featuresself.out_features = out_featuresself.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)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.biaselse:return outputdef __repr__(self):return self.__class__.__name__ + ' (' \+ str(self.in_features) + ' -> ' \+ str(self.out_features) + ')'
对代码的不通过功能简单的说一下:
这段代码定义了一个简单的图卷积网络(Graph Convolutional Network, GCN)层,基于论文 Semi-Supervised Classification with Graph Convolutional Networks 中描述的模型。下面是代码的逐行解释和相关概念的讲解:
class GraphConvolution(Module)
- 定义:该类继承自
torch.nn.modules.module.Module
,为自定义的图卷积层提供一个基础的网络层结构。
def init(self, in_features, out_features, bias=True):
- 参数:
in_features
: 输入特征的数量。out_features
: 输出特征的数量。bias
: 布尔值,指示是否在图卷积层中添加偏置项。
- 功能:
- 初始化图卷积层。设置输入特征数、输出特征数,并根据
bias
参数决定是否添加偏置项。 self.weight
: 使用torch.nn.parameter.Parameter
为层权重创建一个可训练的参数。self.bias
: 如果启用了偏置,则同样创建一个可训练的偏置参数。
- 初始化图卷积层。设置输入特征数、输出特征数,并根据
def reset_parameters(self):
- 功能:初始化权重和偏置参数。
- 权重和偏置通过均匀分布初始化,分布范围是
[-stdv, stdv]
,其中stdv
为1 / sqrt(self.weight.size(1))
。这是为了权重初始化提供合适的标准差,以保证模型的稳定性。
- 权重和偏置通过均匀分布初始化,分布范围是
def forward(self, input, adj):
- 参数:
input
: 输入特征矩阵,维度为(N, in_features)
,其中N
是节点数。adj
: 邻接矩阵,通常为稀疏格式,维度为(N, N)
。
- 功能:
- 执行图卷积操作。首先计算支持矩阵
support = input @ self.weight
。这是特征输入和权重矩阵的矩阵乘法。 - 使用稀疏矩阵乘法
torch.spmm
将邻接矩阵adj
与支持矩阵support
相乘,得到输出特征。 - 如果定义了偏置,则在输出上加上偏置。
- 执行图卷积操作。首先计算支持矩阵
def repr(self):
- 功能:定义了类的字符串表示,用于打印和调试。它会显示类名和层的输入到输出特征的转换大小。
4. models.py
模型主体文件了,这也是文章的主要创新点。大家看到这里需要注意了,来大活了。
import torch.nn as nn
import torch.nn.functional as F
from layers import GraphConvolution # 导入之间layer中设计的图卷积层
from torch.nn.parameter import Parameter
import torch
import mathclass GCN(nn.Module): # 在这里构建了传统的图卷积神经网络def __init__(self, nfeat, nhid, out, dropout):super(GCN, self).__init__()self.gc1 = GraphConvolution(nfeat, nhid) #实例化两个图卷积层self.gc2 = GraphConvolution(nhid, out)self.dropout = dropoutdef forward(self, x, adj): # 定义网络的前向传播x = F.relu(self.gc1(x, adj)) # 第一次卷积x = F.dropout(x, self.dropout, training = self.training)x = self.gc2(x, adj) # 第二次卷积后输出全部节点的特征return xclass Attention(nn.Module): # 注意力分数的计算def __init__(self, in_size, hidden_size=16): # 输入尺寸是节点特征的尺寸,即GCN最后一层的大小super(Attention, self).__init__()self.project = nn.Sequential(nn.Linear(in_size, hidden_size), # 对输入特征进行特征选择nn.Tanh(), # 激活nn.Linear(hidden_size, 1, bias=False) #再来一个线性层,将一个节点的特征矩阵及性能映射成实数)def forward(self, z):w = self.project(z) #z是一个矩阵这个矩阵是每一行是同一个节点的不同嵌入表示所以三行,映射成了一个向量beta = torch.softmax(w, dim=1) # 向量变成了百分比return (beta * z).sum(1), beta #然后使用百分比加权求和,输出百分比情况。class SFGCN(nn.Module):def __init__(self, nfeat, nclass, nhid1, nhid2, n, dropout):super(SFGCN, self).__init__()self.SGCN1 = GCN(nfeat, nhid1, nhid2, dropout)self.SGCN2 = GCN(nfeat, nhid1, nhid2, dropout)self.CGCN = GCN(nfeat, nhid1, nhid2, dropout)
# 上面实例化三个GCN网络,论文中一致,一个是传统形态的GCN一个是KNn图的,一个是被称为共享权重的GCN。就是一个GCN被两次复用了self.dropout = dropoutself.a = nn.Parameter(torch.zeros(size=(nhid2, 1)))# 这是一个向量,注意力向量参考GAT论文中的a共享注意力向量nn.init.xavier_uniform_(self.a.data, gain=1.414) self.attention = Attention(nhid2) # 和nhid2一致就是节点特征在GCN卷积后的维度一致self.tanh = nn.Tanh() # 激活函数self.MLP = nn.Sequential(nn.Linear(nhid2, nclass),nn.LogSoftmax(dim=1))def forward(self, x, sadj, fadj):emb1 = self.SGCN1(x, sadj) # 输出每个节点的特征com1 = self.CGCN(x, sadj) # 输出每个节点的特征com2 = self.CGCN(x, fadj) # 输出每个节点的特征emb2 = self.SGCN2(x, fadj) # 输出每个节点的特征Xcom = (com1 + com2) / 2 # 将共享的特征做均值处理,两个矩阵想加然后除二##attentionemb = torch.stack([emb1, emb2, Xcom], dim=1) # emb, att = self.attention(emb)output = self.MLP(emb)return output, att, emb1, com1, com2, emb2, emb
其实论文中说起来比较特别的共享注意力模块仅仅是一个网络的两次复用而已。
新鲜一点的就是这个注意力机制的实现。
相同的节点特征,通过各种各样的图结构生成了相同节点的不同嵌入表示:
emb1 = self.SGCN1(x, sadj) # 输出每个节点的特征
com1 = self.CGCN(x, sadj) # 输出每个节点的特征
com2 = self.CGCN(x, fadj) # 输出每个节点的特征
emb2 = self.SGCN2(x, fadj) # 输出每个节点的特征
具体的一个节点有四种嵌入表示怎么融合呢???
文中将四个先变成了三个:
Xcom = (com1 + com2) / 2 # 将共享的特征做均值处理,两个矩阵想加然后除二
然后将这三个矩阵进行堆叠:
emb = torch.stack([emb1, emb2, Xcom], dim=1) #
这里我讲讲这个 torch.stack的操作,下面是官网给出的例子:
>>> x = torch.randn(2, 3)
>>> x
tensor([[ 0.3367, 0.1288, 0.2345],[ 0.2303, -1.1229, -0.1863]])
>>> torch.stack((x, x)) # same as torch.stack((x, x), dim=0) # 不指定堆叠维度的情况下,仅仅是增加一个维度进行堆叠
tensor([[[ 0.3367, 0.1288, 0.2345],[ 0.2303, -1.1229, -0.1863]],[[ 0.3367, 0.1288, 0.2345],[ 0.2303, -1.1229, -0.1863]]])
>>> torch.stack((x, x)).size()
torch.Size([2, 2, 3])
>>> torch.stack((x, x), dim=1) # 指定维度,根据维度变成不同的堆叠方式。而文中使用的则是 dim=1,即相同的行组成一个新的组。
tensor([[[ 0.3367, 0.1288, 0.2345],[ 0.3367, 0.1288, 0.2345]],[[ 0.2303, -1.1229, -0.1863],[ 0.2303, -1.1229, -0.1863]]])
>>> torch.stack((x, x), dim=2)
tensor([[[ 0.3367, 0.3367],[ 0.1288, 0.1288],[ 0.2345, 0.2345]],[[ 0.2303, 0.2303],[-1.1229, -1.1229],[-0.1863, -0.1863]]])
>>> torch.stack((x, x), dim=-1)
tensor([[[ 0.3367, 0.3367],[ 0.1288, 0.1288],[ 0.2345, 0.2345]],[[ 0.2303, 0.2303],[-1.1229, -1.1229],[-0.1863, -0.1863]]])
然后通过注意力层计算权重对相同节点的不同嵌入表示进行加权求和从而完成特征的聚合。
emb, att = self.attention(emb)
计算得到一个节点的不同嵌入表示的注意力分数att和按照注意力加权求和的节点特征向量。最终送入到一个简单的MLP网络进行分类。最终返回output, att, emb1, com1, com2, emb2, emb。这里为什么还要输出emb1, com1, com2, emb2, emb呢?????记不记的那几个约束的问题。因此损失不仅仅通过output控制,这种设计可以通过多个输出共同优化,使得模型不仅在主任务上表现良好,同时在其他如特征表示的保留和利用上也进行优化,后面讲到了再细聊。
5. utils.py
模型训练运行需要的各种各样杂乱的工具函数都被存放在这个代码文件下,我们对其使用到的函数进行逐个分析。不过仅仅是通过函数名称也能对其观察出其主要的功能。
以下分别对每个函数进行详细解释:
common_loss(emb1, emb2)
# 这就是论文中提到的一致性约束,详情可参考博文第3.4节的内容
计算两组嵌入之间的方差损失,用于模型训练中使嵌入更加一致。
emb1
,emb2
: 输入的两组节点嵌入。- 先对嵌入进行中心化和归一化处理。
- 计算两组嵌入的协方差矩阵,并求这两个协方差矩阵的Frobenius范数的平方。
loss_dependence(emb1, emb2, dim)
# 同样这是差异性约束参考论文详解博文的第3.4节的内容
计算两组嵌入之间的HSIC(Hilbert-Schmidt Independence Criterion)损失,用于评估它们的统计独立性。
emb1
,emb2
: 输入的两组节点嵌入。dim
: 嵌入的维度。- 使用投影矩阵消除均值的影响,并计算两个核矩阵的乘积的迹。
为了便于展示我对函数进行逐行注释便于各位理解
accuracy(output, labels)
计算模型预测的准确率。
output
: 模型对样本的输出(通常是经过softmax的概率)。labels
: 真实的标签。- 返回预测正确的比例。
def accuracy(output, labels):preds = output.max(1)[1].type_as(labels) # 从输出中取得每行最大值的索引,这些索引即为预测类别。correct = preds.eq(labels).double() # 比较预测和真实标签,转化为double类型计算正确的数目。correct = correct.sum() # 求和得到正确预测的总数。return correct / len(labels) # 计算准确率。
sparse_mx_to_torch_sparse_tensor(sparse_mx)
将scipy的稀疏矩阵转换为PyTorch的稀疏张量。
sparse_mx
: scipy的稀疏矩阵。- 返回一个PyTorch的稀疏张量。
def sparse_mx_to_torch_sparse_tensor(sparse_mx):"""将scipy稀疏矩阵转换为torch稀疏张量。"""sparse_mx = sparse_mx.tocoo().astype(np.float32) # 将矩阵转换为COO格式。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) # 创建并返回PyTorch稀疏张量。
sample_mask(idx, l)
创建掩码数组。
idx
: 需要标记为1的索引列表。l
: 掩码的长度。- 返回一个布尔数组,指示元素是否属于索引列表。**
sparse_to_tuple(sparse_mx)
将scipy的稀疏矩阵转换为元组形式。
sparse_mx
: 可以是单个稀疏矩阵或稀疏矩阵列表。- 返回的元组或列表包含坐标、值和形状。
def sparse_to_tuple(sparse_mx):"""将稀疏矩阵转换为元组表示(坐标、值、形状)。"""def to_tuple(mx):if not sp.isspmatrix_coo(mx):mx = mx.tocoo()coords = np.vstack((mx.row, mx.col)).transpose() # 坐标数组。values = mx.data # 值数组。shape = mx.shape # 形状。return coords, values, shapeif isinstance(sparse_mx, list):return [to_tuple(mx) for mx in sparse_mx]else:return to_tuple(sparse_mx)
normalize(mx)
行标准化稀疏矩阵。
mx
: 输入的稀疏矩阵。- 返回行标准化后的矩阵。
def normalize(mx):"""对稀疏矩阵行进行归一化。"""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
load_data(config)
# 从dataprocess文件创建的文件进行导入
用于加载并处理数据集。
config
: 配置对象,含有数据路径等配置。- 加载特征、标签和训练/测试索引。
- 返回处理后的特征张量和相关索引。
def load_data(config):# 加载节点特征f = np.loadtxt(config.feature_path, dtype=float)# 加载节点标签l = np.loadtxt(config.label_path, dtype=int)# 加载测试集索引test = np.loadtxt(config.test_path, dtype=int)# 加载训练集索引train = np.loadtxt(config.train_path, dtype=int)# 将特征数据转换为稀疏矩阵格式features = sp.csr_matrix(f, dtype=np.float32)# 将特征数据转换为稠密张量features = torch.FloatTensor(np.array(features.todense()))# 转换测试集和训练集索引为列表idx_test = test.tolist()idx_train = train.tolist()# 将测试集和训练集索引转换为PyTorch张量idx_train = torch.LongTensor(idx_train)idx_test = torch.LongTensor(idx_test)# 将标签转换为PyTorch张量label = torch.LongTensor(np.array(l))# 返回特征、标签、训练索引和测试索引return features, label, idx_train, idx_test
load_graph(dataset, config)
加载和处理图结构数据。
dataset
: 数据集名称。config
: 包含配置信息的对象。- 加载并处理两种类型的图(特征图和结构图)。
- 返回归一化后的图的邻接矩阵的PyTorch稀疏张量。
这些函数涵盖数据加载、预处理、损失计算以及图结构的加载和处理,为GCN及其变种的实现提供基础支持。
def load_graph(dataset, config):# 构造特征图边的文件路径featuregraph_path = config.featuregraph_path + str(config.k) + '.txt'# 加载特征图的边列表feature_edges = np.genfromtxt(featuregraph_path, dtype=np.int32)# 将边列表转换为数组形式进行处理fedges = np.array(list(feature_edges), dtype=np.int32).reshape(feature_edges.shape)# 创建特征图的邻接矩阵fadj = sp.coo_matrix((np.ones(fedges.shape[0]), (fedges[:, 0], fedges[:, 1])), shape=(config.n, config.n), dtype=np.float32)# 确保邻接矩阵是对称的fadj = fadj + fadj.T.multiply(fadj.T > fadj) - fadj.multiply(fadj.T > fadj)# 归一化邻接矩阵,并且添加自环nfadj = normalize(fadj + sp.eye(fadj.shape[0]))# 同理处理结构图的边数据struct_edges = np.genfromtxt(config.structgraph_path, dtype=np.int32)sedges = np.array(list(struct_edges), dtype=np.int32).reshape(struct_edges.shape)sadj = sp.coo_matrix((np.ones(sedges.shape[0]), (sedges[:, 0], sedges[:, 1])), shape=(config.n, config.n), dtype=np.float32)sadj = sadj + sadj.T.multiply(sadj.T > sadj) - sadj.multiply(sadj.T > sadj)nsadj = normalize(sadj + sp.eye(sadj.shape[0]))# 将稀疏矩阵转换为PyTorch的稀疏张量格式nsadj = sparse_mx_to_torch_sparse_tensor(nsadj)nfadj = sparse_mx_to_torch_sparse_tensor(nfadj)# 返回特征图和结构图的处理后的邻接矩阵return nsadj, nfadj
一共构造了两种图结果,一个是导入原始图结构,即通过数据集中边信息构建的,一个是导入KNN函数构建的图结构,用于GCN中的特征聚合。
6. main.py
啰嗦了半天终于要看到最终的训练文件可以执行代码了,这是一个完整的图卷积网络 (GCN) 通过使用注意力机制及联合训练策略进行模型训练和测试的 Python 脚本。下面是对这个代码的逐行注释和解释:
导入所需库和模块
from __future__ import division # 确保除法在Python 2与Python 3中表现一致
from __future__ import print_function # 确保print函数在Python 2与Python 3中表现一致
import torch
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
from utils import * # 导入辅助函数,如数据加载、预处理等
from models import SFGCN # 导入定义的模型
import numpy
from sklearn.metrics import f1_score
import os
import torch.nn as nn
import argparse
from config import Config # 导入配置处理类
环境设定和命令行参数解析
if __name__ == "__main__":os.environ["CUDA_VISIBLE_DEVICES"] = "2" # 设置CUDA设备编号parse = argparse.ArgumentParser() # 创建命令行解析器parse.add_argument("-d", "--dataset", help="dataset", type=str, required=True) # 添加数据集参数parse.add_argument("-l", "--labelrate", help="labeled data for train per class", type=int, required=True) # 添加标签率参数args = parse.parse_args() # 解析命令行参数config_file = "./config/" + str(args.labelrate) + str(args.dataset) + ".ini" # 构建配置文件路径config = Config(config_file) # 加载配置
CUDA设置和初始随机种子设置
cuda = not config.no_cuda and torch.cuda.is_available() # 判断是否使用CUDAuse_seed = not config.no_seedif use_seed:np.random.seed(config.seed) # 设置NumPy的随机种子torch.manual_seed(config.seed) # 设置PyTorch的随机种子if cuda:torch.cuda.manual_seed(config.seed) # 设置CUDA的随机种子
加载图和数据
sadj, fadj = load_graph(args.labelrate, config) # 加载图结构数据features, labels, idx_train, idx_test = load_data(config) # 加载特征和标签数据
初始化模型和优化器
model = SFGCN(nfeat=config.fdim, nhid1=config.nhid1, nhid2=config.nhid2, nclass=config.class_num, n=config.n, dropout=config.dropout) # 实例化模型if cuda:model.cuda() # 如果使用CUDA,则将模型转移到GPUfeatures = features.cuda() # 转移数据到GPUsadj = sadj.cuda()fadj = fadj.cuda()labels = labels.cuda()idx_train = idx_train.cuda()idx_test = idx_test.cuda()optimizer = optim.Adam(model.parameters(), lr=config.lr, weight_decay=config.weight_decay) # 初始化优化器
定义训练和测试函数
def train(model, epochs):model.train() # 设置模型为训练模式optimizer.zero_grad() # 清空之前的梯度output, att, emb1, com1, com2, emb2, emb = model(features, sadj, fadj) # 前向传播loss_class = F.nll_loss(output[idx_train], labels[idx_train]) # 计算分类损失loss_dep = (loss_dependence(emb1, com1, config.n) + loss_dependence(emb2, com2, config.n))/2 # 计算依赖损失loss_com = common_loss(com1, com2) # 计算通用损失loss = loss_class + config.beta * loss_dep + config.theta * loss_com # 总损失acc = accuracy(output[idx_train], labels[idx_train]) # 计算训练准确率loss.backward() # 反向传播optimizer.step() # 更新权重acc_test, macro_f1, emb_test = main_test(model) # 测试模型print('epoch:{}'.format(epochs),'loss_train: {:.4f}'.format(loss.item()),'acc_train: {:.4f}'.format(acc.item()),'acc_test: {:.4f}'.format(acc_test.item()),'f1_test:{:.4f}'.format(macro_f1.item())) # 打印训练信息return loss.item(), acc_test.item(), macro_f1.item(), emb_test # 返回训练损失和测试性能def main_test(model):model.eval() # 设置模型为评估模式output, att, emb1, com1, com2, emb2, emb = model(features, sadj, fadj) # 前向传播acc_test = accuracy(output[idx_test], labels[idx_test]) # 计算测试准确率label_max = []for idx in idx_test:label_max.append(torch.argmax(output[idx]).item()) # 预测标签labelcpu = labels[idx_test].data.cpu()macro_f1 = f1_score(labelcpu, label_max, average='macro') # 计算F1分数return acc_test, macro_f1, emb # 返回测试性能
模型训练和结果输出
acc_max = 0f1_max = 0epoch_max = 0for epoch in range(config.epochs):loss, acc_test, macro_f1, emb = train(model, epoch) # 训练模型if acc_test >= acc_max:acc_max = acc_test # 更新最高准确率f1_max = macro_f1 # 更新最高F1分数epoch_max = epoch # 更新最佳轮数print('epoch:{}'.format(epoch_max),'acc_max: {:.4f}'.format(acc_max),'f1_max: {:.4f}'.format(f1_max)) # 打印最佳训练结果
总结
讲到这里我们的AMGCN论文讲解接近尾声了,大家可以按照自己的需求构建自己希望的模型。其实本人理解这个作者的工作主要体现在其集成架构的理解上,做了大量的探索。我还采用cora数据集,验证了一下AMGCN的效果,效果表明人家论文中没用是对的,没啥效果。当然其在论文中给出的其他数据集下优越性都是可见的。欢迎各位和我共同学习我的实验部分。
对了对了,对这些内容感兴趣的朋友们,通过点赞、收藏和关注来表达你们的支持是对我的极大鼓励,如果你感觉还不错的话也可以打赏一杯咖啡钱,非常感谢大家!有任何问题或建议,欢迎随时通过私信与我交流。期待你们的积极参与和反馈。
下一小节将在pytorch中复现AMGCN模型在Cora数据集下的实验结果,👏欢迎大家观看哦