图神经网络实战——图卷积网络和 GraphSAGE

721 阅读1小时+

本章内容包括:

  • 介绍 GraphSAGE 和图卷积网络(GCNs)
  • 应用卷积图神经网络(GCNs)生成亚马逊产品捆绑
  • 图卷积网络(GCNs)和 GraphSAGE 的关键参数和设置
  • 更多的理论见解,包括卷积和消息传递

在本书的前两章中,我们探讨了与图和图表示学习相关的基本概念。所有这些内容为第二部分的学习打下了基础,在这一部分,我们将探索不同类型的图神经网络(GNN)架构,包括卷积 GNN、图注意力网络(GATs)和图自编码器(GAEs)。

本章的目标是理解和应用图卷积网络(GCNs)和 GraphSAGE [1, 2]。这两种架构是更大类 GNN 的一部分,采用卷积操作来处理图数据。

卷积操作在深度学习模型中相对常见,特别是对于依赖于卷积神经网络(CNNs)的图像任务。为了进一步了解 CNN 和它们在计算机视觉中的应用,我们建议查阅《Deep Learning with Python》(Manning,2024)或《Deep Learning with PyTorch》(Manning,2023)。

我们将在本章稍后提供卷积的简要介绍,但本质上,卷积操作可以理解为在实体之间执行空间或局部平均。例如,在图像中,CNN 层在逐步增大的像素子域中形成表示。对于 GCN,我们将应用相同的局部平均思想,但它是基于节点的邻域。

在本章中,您将学习如何将卷积 GNN 应用于节点预测问题,GCN 和 GraphSAGE 的关键参数和设置,优化卷积 GNN 性能的方法,以及相关的理论主题,包括图卷积和消息传递。此外,我们还将探索亚马逊产品数据集。本章的结构如下:首先,我们进入产品类别预测问题并创建基准模型(第 3.1 节);然后,我们使用邻域聚合调整模型(第 3.2 节);接下来,我们使用通用深度学习方法优化模型(第 3.3 节);随后,我们更详细地解释相关理论(第 3.4 节);最后,我们深入探讨本章使用的亚马逊产品数据集,以及本书后续章节中使用的相同数据集(第 3.5 节)。

本章旨在让您立即进入卷积 GNN 的应用,提供有效部署这些模型所需的基本知识。最初的部分为您提供了实践中理解卷积 GNN 的最低工具包。

然而,在面对挑战性建模问题时,深入理解变得极为宝贵。章节的后半部分涵盖了之前介绍的层、设置和参数的基本原理。这些内容旨在增强您的概念理解,确保您的实践技能得到透彻的理论支持。这种全面的方式不仅旨在让您应用 GNN,还旨在让您创新并将其适应现实世界问题的细微需求。

备注:尽管 GraphSAGE 是指一个特定的架构,但有可能会混淆的是,GCN 也指一个特定的架构,而不是整个基于卷积的 GNN 类。因此,在本章中,我们将使用卷积 GNN 来指代这一整个 GNN 类,包括 GraphSAGE 和 GCN。我们将使用 GCN 来指代由 Thomas Kipf 和 Max Welling 提出的单独架构 [1]。

备注:本章的代码可以在 GitHub 仓库中以 notebook 形式找到(mng.bz/wJMW)。本章的 Colab 链接和数据也可以在相同位置访问。

3.1 预测消费者产品类别

让我们通过一个产品管理问题来开始探索卷积 GNN,使用亚马逊产品数据集(见表 3.1)。假设你是一个产品经理,旨在通过识别和推广新兴趋势的产品捆绑来提升销售。你有一个来自亚马逊产品联合购买网络的数据集,包含了基于客户购买行为的丰富的产品关系数据。你的任务是利用有关产品类别和联合购买模式的洞察力,发现那些能够引起客户共鸣的潜在和有吸引力的产品捆绑。

表 3.1 亚马逊产品数据集概述
项目说明
亚马逊联合购买(按产品类别组织)
节点数量(产品)~2,500,000
节点特征100
节点类别47
总边数~61,900,000

为了应对这个问题,我们引入了 GCN 和 GraphSAGE —— 两种卷积 GNN 架构。本节将引导你通过在亚马逊产品数据集上训练这些模型。我们将重点关注两个任务:识别产品类别和通过分析训练模型生成的产品嵌入之间的相似性来发现产品捆绑集。

备注:如果你想深入了解 GCN 和 GraphSAGE 背后的理论,参考第 3.4 节。有关亚马逊产品数据集的详细信息,请参阅第 3.5 节。

在我们的模型训练过程之后,本节将执行以下内容:

  • 预处理数据集——我们将获取亚马逊产品数据集并减少其大小,以便能够在资源较少的系统上工作。
  • 构建模型类——我们将专注于两种卷积 GNN:GCN 和 GraphSAGE。我们将最初创建模型类,并使用默认参数实例化它们。
  • 编码训练和验证循环——我们将训练模型并为每个纪元进行验证步骤。为了比较这两个模型,我们将同时用相同的批次训练它们。
  • 评估模型性能——我们将查看训练曲线。然后,我们将使用传统的分类指标,并观察模型预测特定类别的能力。

我们的直接目标是开发训练模型的初步版本。因此,在这一阶段,重点不是性能优化,而是涵盖实现基准模型的基本步骤。后续部分将进一步优化这些方法,提高性能和效率。

3.1.1 加载和处理数据

我们首先从 Open Graph Benchmark(OGB)网站(ogb.stanford.edu/)下载亚马逊产品数据集… 1.3 GB。它包含 250 万个节点(产品)和 6190 万条边(联合购买)。

为了使得这个数据集能够在内存较小、处理器较弱的系统上处理,我们将减少它的大小。我们只选择原始图中前 10,000 个节点,并基于这些节点创建一个子图。根据你的问题,创建子图的策略还有其他选择。在第 8 章中,我们将深入探讨如何创建子图。

在创建子图时,通常需要进行一些登记工作,以确保我们的节点子集具有一致且合逻辑的排序,并且连接到正确的标签和特征。我们还需要过滤掉连接到子集外节点的边。最后,我们希望确保在需要回调有用信息时能够找回原始索引;例如,对于亚马逊产品数据集,我们可以使用节点的原始索引来访问每个节点的 SKU(亚马逊标准识别号,ASIN)和产品类别。

因此,我们重新标记节点以确保一致的排序。然后,我们重新分配相应的节点特征和标签,以便与新索引相对应。即使我们选择了前 10,000 个节点,这在某些特定情况下也可能有所不同。下面是我们将通过四个步骤来细化和准备数据进行建模的过程:

  1. 初始化子集图——我们创建一个新的图对象,存储我们从原始图中提取的子集数据。这个图将包含原始图中索引为 0–9,999 的节点的边、特征和标签。
  2. 重新标记节点索引——为了确保一致性并避免索引不匹配,我们在子集图中重新标记节点索引。这个重新标记至关重要,因为 GNN 中的操作高度依赖于索引来处理节点和边信息。
  3. 特征和标签分配——我们将节点特征(x)和标签(y)分配给新的图对象。这些特征和标签是从原始数据集中切片出来的,且与我们指定的子集索引相对应。
  4. 使用边掩码——在子图提取过程中使用的 return_edge_mask 选项,让我们识别在子图创建过程中选择了哪些边。这对于追溯到原始图的结构或进行后续的结构分析非常有用。

通过这种方式重新组织数据,我们不仅使其易于处理,还特别为随后的图基学习任务高效处理定制了数据结构。这个设置是我们在接下来的章节中构建和评估 GNN 模型的基础。以下是实现该过程的代码。

列表 3.1 读取数据并创建子图
dataset = PygNodePropPredDataset(name='ogbn-products',\
 root=root)   #1
data = dataset[0]   #2

subset_indices = torch.arange(0, 10000)   #3

subset_edge_index, edge_attr, edge_mask = \
subgraph(subset_indices, data.edge_index, \
None, relabel_nodes=True, num_nodes=\
data.num_nodes, return_edge_mask=True)   #4

subset_features = data.x[subset_indices]   #5
subset_labels = data.y[subset_indices]   #6

subset_graph = data.__class__()   #7
subset_graph.edge_index = subset_edge_index   #8
subset_graph.x = subset_features   #9
subset_graph.y = subset_labels   #10
  • #1 从指定的根目录加载数据集,并指定 ogbn-products 以指示加载的数据集。
  • #2 选择数据集中的第一个图对象进行处理。
  • #3 创建前 10,000 个节点的索引数组,这定义了我们实验的子集。
  • #4 使用 subset_indices 调用 subgraph 函数,提取与这些索引相关的边和属性。节点被重新标记以保持在新图中一致的零基础索引。
  • #5 根据 subset_indices 索引原始数据中的节点特征,确保只有相关特征被转移到新图。
  • #6 类似地,索引节点标签以保持与子集特征的一致性。
  • #7 创建一个新的数据类实例来存储我们的子集图。
  • #8 将在子图提取过程中创建的边索引数组分配给新图。
  • #9 将子集特征分配给新图的节点特征矩阵。
  • #10 将与子集对应的节点标签分配给新图。

3.1.2 创建我们的模型类

在设置好数据集并准备好可管理的子图之后,我们进入图机器学习流程的核心部分:定义模型。在本节中,我们将重点关注 PyTorch Geometric (PyG) 库提供的两种流行 GNN 类型:GCN 和 GraphSAGE。

理解我们的模型架构

PyG 通过模块化的层对象简化了 GNN 的构建,每个层封装了一种特定类型的图卷积。这些层可以堆叠并与其他 PyTorch 模块集成,以构建适用于各种基于图的任务的复杂架构。

GCN 模型

GCN 模型使用 GCNConv 层,该层实现了 Kipf 和 Welling 在其开创性论文中描述的图卷积操作 [1]。它利用图的谱特性促进节点之间的信息流动,使模型能够学习嵌入局部图结构和节点特征的表示。

在列表 3.2 中,GCN 类设置了一个两层模型。每层由 GCNConv 模块表示,该模块通过应用卷积操作直接利用图的结构来处理图数据。

简而言之,从一组输入的节点特征和图结构(edge_index)开始,网络将通过聚合各自节点的邻域信息来更新节点特征。在第一层之后,我们应用了一个修正线性单元(ReLU)激活函数,为模型增加了非线性。第二层进一步优化这些特征。

如果我们想直接查看节点嵌入——例如,为了可视化它们或将它们用于其他分析——我们可以在第二层之后立即返回它们。否则,我们应用另一个激活函数——在这种情况下是 softmax 函数——来规范化输出,用于我们的分类问题。

列表 3.2 GCN 类
class GCN(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super(GCN, self).__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)  #1
        self.conv2 = GCNConv(hidden_channels, out_channels)

    def forward(self, x, edge_index, return_embeds=False):  #2
        x = self.conv1(x, edge_index)
        x = torch.relu(x)  #3
        x = self.conv2(x, edge_index)
        if return_embeds:  #4
            return x

        return torch.log_softmax(x, dim=1)  #5
  • #1 初始化第一个图卷积层,将输入特征(in_channels)转换为隐藏特征(hidden_channels)。
  • #2 前向方法,定义了数据从输入到输出在模型中的流动方式。
  • #3 在第一个卷积层之后应用 ReLU 激活函数,为模型增加非线性。
  • #4 可选地返回网络的原始嵌入,这对那些需要原始节点表示而非分类任务的任务(例如可视化或进一步处理)非常有用。
  • #5 对最终层的输出应用对数 softmax 激活。

GraphSAGE 模型

与 GCN 模型类似,GraphSAGE 模型类在代码中也设置了一个两层网络,但使用的是 SAGEConv 层。尽管在结构上与 GCN 相似,GraphSAGE 在理论上与 GCN 有显著的不同。与 GCN 完全依赖于整个图的邻接矩阵不同,GraphSAGE 设计上从随机采样的邻域数据中学习,使其特别适合大规模图。该采样方法使得 GraphSAGE 能够通过关注图的局部区域有效地扩展。

GraphSAGE 使用 SAGEConv 层,支持多种聚合函数——均值、池化和长短期记忆(LSTM)——提供了在聚合节点特征时的灵活性。每个 SAGEConv 层之后,类似于 GCN 模型,都会应用一个非线性函数。如果需要直接使用节点嵌入进行任务,例如可视化或进一步分析,可以在第二层之后立即返回它们。否则,将应用 softmax 函数来规范化分类任务的输出。

PyG 实现这些模型的关键区别在于它们在处理大数据集时的效率和可扩展性。这两个模型都学习节点表示,但 GraphSAGE 在涉及非常大图的实际应用中提供了显著的优势。与 GCN 不同,GCN 可以在稀疏数据表示上运行,但仍然处理整个图的结构信息,GraphSAGE 不需要整个邻接矩阵。相反,它通过采样局部邻域来处理图,这使得它能够有效地处理庞大的网络,而不会因加载整个图的表示而占用过多内存资源。

列表 3.3 GraphSAGE 类
class GraphSAGE(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels):
        super(GraphSAGE, self).__init__()
        self.conv1 = SAGEConv(in_channels, hidden_channels)  #1
        self.conv2 = SAGEConv(hidden_channels, out_channels)

    def forward(self, x, edge_index, return_embeds=False):  #2
        x = self.conv1(x, edge_index)
        x = torch.relu(x)  #3
        x = self.conv2(x, edge_index)
        if return_embeds:  #4
            return x

        return torch.log_softmax(x, dim=1)  #5
  • #1 初始化第一个图卷积层,将输入特征(in_channels)转换为隐藏特征(hidden_channels)。
  • #2 前向方法,定义了数据从输入到输出在模型中的流动方式。
  • #3 在第一个卷积层之后应用 ReLU 激活函数,为模型增加非线性。
  • #4 可选地返回网络的原始嵌入,这对那些需要原始节点表示而非分类任务的任务(例如可视化或进一步处理)非常有用。
  • #5 对最终层的输出应用对数 softmax 激活。

集成和自定义

虽然在这个入门示例中我们使用了默认设置,但这两个模型都是高度可定制的。可以调整的参数包括层数、隐藏维度以及聚合函数类型(对于 GraphSAGE),以优化特定数据集或任务的性能。接下来,我们将训练这些模型,并在我们的子集图上评估它们的性能,以展示它们的实际应用和效果。

3.1.3 模型训练

数据准备好并且模型已经设置好后,让我们进入训练过程。训练相对直接,因为它遵循典型的机器学习流程,只不过是应用于图数据。我们将同时训练两个模型——GCN 和 GraphSAGE——每个纪元喂给它们相同的数据。这样的并行训练允许我们直接比较这两种模型类型在相同条件下的表现和效率。以下是训练循环的简要概述:

  • 初始化优化器——设置 Adam 优化器,学习率为 0.01。这样可以有效地微调模型权重。
  • 训练和验证循环——对于每个纪元,运行训练函数,将数据通过模型处理以计算损失并更新权重。同时,在未见过的数据上验证模型,以监控过拟合并相应调整训练策略。
  • 跟踪进展——记录训练和验证阶段的损失,以可视化学习曲线并根据需要调整参数。
  • 最后进行测试——训练完成后,在单独的测试集上评估模型,以衡量其泛化能力。

通过保持一致的训练流程,我们确保性能差异可以归因于模型架构的不同,而非训练条件的变化。以下是我们的训练逻辑的代码(带注释)。

列表 3.4 训练循环
gcn_model = GCN(in_channels=dataset.num_features, hidden_channels=64, out_channels=dataset.num_classes)  #1
graphsage_model = GraphSAGE(in_channels=dataset.num_features, hidden_channels=64, out_channels=dataset.num_classes)  #2

optimizer_gcn = torch.optim.Adam(gcn_model.parameters(), lr=0.01)   #3
optimizer_sage = torch.optim.Adam(graphsage_model.parameters(), lr=0.01)   #4
criterion = torch.nn.CrossEntropyLoss()   #5

def train(model, optimizer, data):  #6
    model.train()  
    optimizer.zero_grad() 
    out = model(data.x, data.edge_index)  
    loss = criterion(out[data.train_mask], data.y[data.train_mask].squeeze()) 
    loss.backward()  
    optimizer.step()  
    return loss.item()  

def validate(model, data):  #7
    model.eval()  
    with torch.no_grad():  
        out = model(data.x, data.edge_index)  
        val_loss = criterion(out[data.val_mask], data.y[data.val_mask].squeeze())  
    return val_loss.item()  

train_loss_gcn = []   #8
val_loss_gcn = []  
train_loss_sage = []  
val_loss_sage = []  

for epoch in range(200):  #9
    loss_gcn = train(gcn_model, optimizer_gcn, subset_graph)  
    train_loss_gcn.append(loss_gcn)  
    val_loss_gcn.append(validate(gcn_model, subset_graph))  

    loss_sage = train(graphsage_model, optimizer_sage, subset_graph)  
    train_loss_sage.append(loss_sage)  
    val_loss_sage.append(validate(graphsage_model, subset_graph))  

    if epoch % 10 == 0:  
        print(f'Epoch {epoch}, GCN Loss: {loss_gcn:.4f}, GraphSAGE Loss: {loss_sage:.4f}, GCN Val Loss: {val_loss_gcn[-1]:.4f}, GraphSAGE Val Loss: {val_loss_sage[-1]:.4f}')
  • #1 初始化 GCN 和 GraphSAGE 模型。
  • #2 初始化 GCN 和 GraphSAGE 模型。
  • #3 设置 GCN 和 GraphSAGE 模型的优化器。
  • #4 设置 GCN 和 GraphSAGE 模型的优化器。
  • #5 初始化用于分类任务的交叉熵损失函数。
  • #6 每个纪元使用的训练函数。
  • #7 每个纪元使用的验证函数。
  • #8 设置用于捕捉每个模型损失的数组。
  • #9 训练和验证循环。

现在我们已经设置并训练好了模型,接下来是查看它们的表现。下一节将查看训练和验证损失曲线,了解模型如何随时间学习。它将查看准确率、精度、召回率和 F1 分数等关键指标,以评估我们的模型如何根据图数据预测产品类别。所有这些都旨在帮助我们理解模型,并在后续章节中找出可以改进的地方。

3.1.4 模型性能分析

在下一节中,我们将分析 GCN 和 GraphSAGE 模型的性能。我们将首先检查训练曲线,并指出我们需要在后续章节中改善过拟合问题。然后,我们将查看 F1 和对数损失分数,最后检查产品类别的准确性。

训练曲线

在训练过程中,我们保存了每个模型每个纪元的损失。损失是衡量模型正确预测能力的指标,值越低表示效果越好。

使用 Matplotlib,我们利用这些数据绘制了训练损失和验证损失曲线,如图 3.1 所示。这些曲线跟踪了模型在训练数据集和验证数据集上的表现,随着训练过程的进行,理想情况下,两条损失曲线应该随着时间的推移逐渐下降。然而,在我们的曲线中,我们看到大约在第 20 个纪元时出现了分歧。验证损失曲线达到了最低点后开始上升。与此同时,训练损失则继续下降。我们的模型在训练数据上的表现继续改善,但在验证数据上却在某个最优点之后开始退化。这是典型的过拟合问题,我们将在后续章节中解决这个问题。

image.png

在我们的训练过程中,我们保存了表现最好的模型实例,即验证损失最低的实例。接下来,我们将查看两个分类指标来评估模型的性能:对数损失和 F1 分数。

分类性能:F1 和对数损失

鉴于前面展示的过拟合问题,我们转向评估模型的分类性能,以为我们的改进工作建立基准。我们使用验证集来建立 F1 和对数损失分数,如表 3.2 所示。(F1 分数是加权的,针对每个类别单独计算 F1 分数,然后对它们进行平均,平均时根据每个类别在总数据中的比例加权。)

中等的分数表明模型还有很大的改进空间。我们的 F1 分数没有超过 80%,而对数损失分数也没有低于 1.25。

表 3.2 模型的分类性能(按 F1 分数和对数损失)
模型F1 分数对数损失
GCN0.7811.25
GraphSAGE0.7331.88

在这种情况下,GCN 在两个指标上都表现得更好。为了提高多类问题的分数,我们可以更深入地了解模型对单个类别的预测能力,并检查模型在类别不平衡时的表现。

按类别评估模型性能

亚马逊产品数据集附带了两个有用的文件,一个是将每个节点与其类别映射,另一个是将每个节点与其单独的亚马逊产品编号(ASIN)映射。为了按类别评估基准模型的性能,我们获取节点类别信息并创建一个表,如图 3.2 所示,总结了包含最多物品的 25 个类别的预测准确性。

除了准确性外,在这个表格中,我们还查看了每个类别中最大的误预测。根据这些信息,我们可以做出一些高层次的观察:

  • 按类别的表现——两种模型在不同产品类别上的预测准确性表现出差异。书籍类别和 CD & 黑胶类别的准确率较高。这可能是因为这些类别的样本数相对较多。也可能表明这些类别更加明确或易于区分,使得模型更容易识别它们。第一个因素,即样本数量,容易调整,因为我们使用了 10,000 个产品节点,并且可以从数据集中提取更多的样本。你可以通过调整提供代码中的子集大小来尝试这一点。

    为了改善不太明显的类别,我们需要更深入地探索节点特征,以确定这些类别相对其他类别的显著性,并思考如何增强这些特征,以突出它们的新颖性。

  • 按模型的表现——查看所有类别,GraphSAGE 在大多数类别中的表现普遍优于 GCN,从较高的正确预测百分比可以看出。这表明 GraphSAGE 通过从节点邻域聚合特征的方法,可能更适合这个数据集。

  • 误分类——常见的误分类通常发生在可能共享相似特征或经常一起购买的类别之间。例如,书籍与电影 & 电视之间的误分类,或者电子产品与手机及配件之间的误分类,表明这些类别中的物品可能共享重叠的特征,或者是常由类似的客户群体购买的商品。

image.png

尽管我们通常不希望发生误分类,但观察最可能被误分类为其他类别的类别可以帮助我们了解客户在不同产品类别之间的常见认知或混淆情况,从而为营销和产品陈列策略提供潜在的改进方向。

接下来的两节将通过利用我们 GNN 模型的特性(第 3.2 节)和使用广泛应用的深度学习方法(第 3.3 节)来改进模型的性能。为了结束本节,让我们使用我们的模型为产品经理建议一个产品捆绑。

3.1.5 我们的第一个产品捆绑

在本节的开始,我们讨论了一个产品经理的用例,他希望通过推出产品捆绑来提升销售。让我们使用我们新训练的一个模型来建议一个特定产品的捆绑。我们将把那些嵌入与选定节点最相似的节点分组,基于它们的相似性形成一个捆绑。随着我们对模型的改进,我们将在本章后面重新回到这个练习。

备注:代码在此处不会详细回顾,但可以在代码库中找到。

节点 ID 到产品编号

亚马逊产品数据集提供的一个关键文件是一个逗号分隔值(CSV)文件,将节点索引映射到亚马逊产品 ID(ASIN)。在代码库中,使用该文件创建一个 Python 字典,将节点 ID(键)映射到 ASIN(值)。使用节点的 ASIN,我们可以通过以下格式的 URL 访问该产品的信息:www.amazon.com/dp/{ASIN}。(由于数据集的年代,部分 ASIN 当前没有网页,但我们测试的大多数在写作时都有网页。)

为了创建产品捆绑,我们使用节点嵌入。我们选择一个单独的产品节点,然后找到与其最相似的六个产品。这个过程包括四个步骤:

  1. 通过运行训练好的 GNN 对节点进行嵌入。
  2. 使用节点嵌入创建相似性矩阵。
  3. 按与选定产品的相似性排序前五个嵌入。
  4. 将这些前五个嵌入的节点索引转换为产品 ID。

可以设置一个种子值以确保结果的可重复性。否则,每次运行程序时,你的结果会有所不同。

生成节点嵌入

与第 2 章一样,我们通过将节点输入模型来生成嵌入,而不是进行预测。与第 2 章的不同之处在于,我们现在有一个训练好的模型,它已经从节点特征和数据集中的联合购买关系中学习。为此,我们将模型设置为评估模式(eval()),禁用支持反向传播的梯度计算(no_grad()),然后将图数据通过模型进行前向传递。在之前定义模型类时,我们启用了一个选项来返回嵌入或预测(return_embeds):

gcn_model.eval()

with torch.no_grad():
     gcn_embeddings = gcn_model(subset_graph.x, subset_graph.edge_index, return_embeds=True)

创建相似性矩阵

相似性矩阵是一组数据,通常是表格形式,包含了集合中所有项对之间的相似度。在我们的例子中,我们使用余弦相似度,并比较集合中所有节点的嵌入。SciKit Learn 的 cosine_similarity 函数完成了这一任务:

gcn_similarity_matrix = cosine_similarity(gcn_embeddings.cpu().numpy())

列出与选定节点相似的产品

为了识别与特定节点最相似的产品,我们首先选择一个节点——通过其索引表示为 product_idx。使用余弦相似性矩阵,我们按降序排序相似度,从而查看每个节点与选定节点的相关性。通过这种排序,我们得到的前几个条目(特别是前六个,top_k 设置为 6)代表了与我们选定节点最相似的节点。值得注意的是,这个列表包括选定的节点本身,因此,实际上,我们考虑下一个五个节点,以有效地创建一个相似物品的捆绑:

product_idx = 123
top_k = 6
top_k_similar_indices_gcn = np.argsort(-gcn_similarity_matrix[product_idx])[:top_k]

将节点索引转换为产品ID

从这里开始,使用节点到 ASIN 的字典可以根据节点索引识别产品捆绑。完成这个步骤后,让我们随机选择一个产品节点,并围绕它生成一个产品捆绑。

产品捆绑演示

我们随机选择节点 #123。使用我们的节点到 ASIN 字典,我们得到 ASIN:B00BV1P6GK。这个 ASIN 属于产品 Funko POP Television: Adventure Time Marceline Vinyl Figure,如图 3.3 所示。该产品的类别是 玩具和游戏

image.png

Marceline,百岁以上的吸血鬼女王,是热门动画系列《冒险时间》中的主要角色之一。Marceline 以其摇滚明星形象、对音乐的热爱以及经常演奏贝斯吉他而闻名,这通常是她出场时的一个焦点。她的形象在这个小雕像中得到了体现,雕像微笑并且摆出了轻松自信的姿势。

《冒险时间》是一部动画系列,讲述了一个名叫 Finn 的男孩和他魔法狗 Jake 在神秘的 Ooo 国度中的超现实且史诗般的冒险故事,这个世界充满了公主、吸血鬼、冰雪之王和许多其他奇异的角色。

对于基于《冒险时间》系列的收藏品,可以预期会有各种代表该剧的独特角色的乙烯基人偶。让我们看看我们的系统生成了什么。

使用之前概述的过程,图 3.4 中展示的捆绑产品被生成了。捆绑中包含了一款《冒险时间》的乙烯基人偶。其他选择乍一看似乎与之无关,但也许这个套装是一个非直观的捆绑。让我们更仔细地看一下:

  • 第一排名相似度:Funko POP Television: Adventure Time Finn with Accessories—Finn 是《冒险时间》的核心角色,这是我们预期的推荐。这表明 Marceline 的粉丝可能也会喜欢或收集来自同一剧集的其他主要角色相关的商品。
  • 第二排名相似度:Funko My Little Pony: DJ Pon-3 Vinyl Figure—这件商品乍一看可能显得有些不合适,但它可能表示对动画系列的交叉兴趣。《我的小马驹》中的 DJ Pon-3,或叫 Vinyl Scratch,是一个像 Marceline 一样的音乐角色,吸引那些喜欢与音乐相关的角色的观众。

image.png

  • 第三排名相似度:Funko My Little Pony: Twilight Sparkle Vinyl Figure—与 DJ Pon-3 相似,《我的小马驹》中的 Twilight Sparkle 代表了与另一部受欢迎的动画系列的联系。这个选择可能会吸引那些喜欢幻想主题和强大女性角色的收藏者。

  • 第四和第五排名相似度:海盗主题配件(金币、纹身、带木盒的手持黄铜望远镜)——这些物品与《冒险时间》或《我的小马驹》没有直接关联,但它们增强了冒险和探索的主题,而这正是两个系列的一个重要元素。

总的来说,这个捆绑包从我们的基准模型来看并不算差!结束本节关于模型训练和评估的介绍部分后,我们现在已经为理解和使用 GNN 打下了坚实的基础。这一理解至关重要,因为我们将在第 3.2 节中更深入地探讨邻域聚合,这是一种提升性能的有效工具。然后,在第 3.3 节中,我们将借鉴一般深度学习方法,进一步优化模型的表现。

3.2 聚合方法

在本节中,我们将深入分析前一节中的产品类别分析,并进一步探讨影响 GNN 在产品分类等任务上的表现的特征。具体来说,我们将探讨聚合方法,这些方法对卷积 GNN 的表现有很大影响。邻域聚合使节点能够从它们的局部邻域收集并整合特征信息,从而在更大的网络中捕捉上下文相关性。

我们从简单的聚合方法开始,包括均值(mean)、求和(sum)和最大值(max),这些方法应用于模型的所有层。接着,我们将介绍 PyG 中的一些更高级的实现:每层应用的独特聚合、列表聚合、聚合函数,以及一种层级聚合方法,称为跳跃知识网络(JK-Nets)。最后,我们将提供一些应用这些方法的指南。

3.2.1 邻域聚合

图数据结构的一个不同之处在于,节点通过边相互连接,形成一个网络,其中节点可以直接连接,也可以通过多个度数进行连接。这种空间布局意味着任何给定的节点可能与某些其他节点较为接近,形成我们所称的“邻域”。节点邻域的概念至关重要,因为它通常包含了有关节点特征和整个图结构的关键信息。

在卷积 GNN 中,节点邻域通过一个称为邻域聚合的过程来使用。该技术涉及从节点的直接邻居收集并结合特征信息,以捕获邻居的个体属性和集体特性。通过这种方式,节点的表示被丰富了它周围环境提供的上下文信息,从而增强了模型在图中学习更复杂和细微模式的能力。

邻域聚合基于这样一个前提:相互接近的节点之间的影响通常比远离的节点之间的影响要大。这对于那些节点之间的关系和交互能预测它们的行为或属性的任务尤其有利。

PyG 中的邻域聚合

在 PyG 的 GCN(GCNconv)和 GraphSAGE(SAGEConv)层中,邻域聚合的实现方式有所不同。在 GCN 中,内置了加权平均聚合;如果你想调整它,必须创建该层的自定义版本。在本节中,我们将主要关注 GraphSAGE,它允许通过一个参数来设置聚合。接下来的章节将探讨 GCN 中使用的层级聚合。

在 SAGEConv 中,aggr 参数指定聚合类型。可选的聚合方式包括但不限于以下几种:

  • 求和聚合——一种简单的聚合方法,将所有邻居节点的特征相加。
  • 均值聚合——计算邻居节点特征的均值。这通常因其简洁性和有效性而被使用,有助于平滑数据中的异常值。
  • 最大值聚合——取所有邻居节点在每个特征维度上的最大特征值。当最显著的特征比平均特征更具信息量时,这种方法能够帮助捕捉来自邻居的最重要信号。
  • LSTM 聚合——一种计算和内存密集型的方法,使用 LSTM 网络处理邻居节点的特征。它考虑了节点的顺序,这对于任务中节点处理顺序影响结果的情况至关重要。因此,必须特别小心地安排数据集中的节点和边以便进行训练。

选择这些类型中的哪种将取决于给定图的特性以及预测目标。如果你不确定哪种方法对你的图和用例更有效,尝试不同方法并通过反复试验来选择合适的聚合方法。此外,虽然一些聚合选项可以直接使用,但其他方法(如 LSTM 聚合,依赖于训练过的 LSTM 网络)则需要在数据准备过程中进行一些思考。

为了查看不同聚合方法的效果,我们将 aggr 参数添加到我们的模型类中,然后按照第 3.1 节中的方式进行训练,替换均值、求和和最大值聚合。需要注意的是,均值聚合是 SAGEConv 层的默认设置,因此它等同于我们的 GraphSAGE 基准模型。创建带有聚合参数的 GraphSAGE 类如下所示。

列表 3.5 带有聚合参数的 GraphSAGE 类
class GraphSAGE(torch.nn.Module):
    def __init__(self, in_channels, hidden_channels, out_channels, agg_func='mean'):  #1
        super(GraphSAGE, self).__init__()
        self.conv1 = SAGEConv(in_channels, hidden_channels, aggr=agg_func)   #2
        self.conv2 = SAGEConv(hidden_channels, out_channels, aggr=agg_func)   #3

    def forward(self, x, edge_index):
         x = self.conv1(x, edge_index)
         x = F.relu(x)
         x = self.conv2(x, edge_index)

    return F.log_softmax(x, dim=1)
  • #1 设置聚合的关键字参数。
  • #2 第一层 GraphSAGE,使用指定的聚合方式。
  • #3 第二层 GraphSAGE,使用指定的聚合方式。

使用均值、最大值和求和聚合的结果

表 3.3 比较了使用 F1 分数和对数损失作为性能指标的模型。表中显示,使用最大值聚合的模型在这两个指标下表现最佳。使用最大值聚合的模型在 F1 分数上取得了最高的 0.7449,并且在对数损失上取得了最低的 2.1039,这表明最大值聚合在识别和使用预测任务中最有影响力的特征方面略有优势。使用均值聚合的模型与第 3.1 节训练的模型相同。我们观察到,最大值聚合的表现优于其他两种。总体而言,使用不同聚合方法的性能与我们的基准 GraphSAGE 模型非常相似。

表 3.3 不同邻域聚合设置下 GraphSAGE 模型的分类性能
聚合类型F1 分数对数损失
均值(默认)0.74062.1214
求和0.73842.2496
最大值0.74492.1039

如果分别选择 F1 分数和对数损失最高的模型,该选择将是什么呢?例如,如果最大值聚合模型在 F1 分数上最高,但均值聚合模型在对数损失上最高,该如何选择?这将取决于你应用的背景、预测需求和潜在错误的后果。

在医疗保健领域,例如预测患者出院后 30 天内的再入院情况,选择模型可能会显著影响患者的结果和资源分配。具有高 F1 分数的模型具有更平衡的精确度和召回率,使其在遗漏再入院预测可能造成高成本或危险的情况下表现更好。这样的模型预计能够识别更多处于风险中的患者,从而允许及时干预。然而,这也可能导致更高的假阳性率,导致不必要的治疗和增加的成本。

另一方面,表现出低对数损失的模型在其预测中具有高置信度,优先考虑每个预测的准确性,而不是检测到的正例数量。当资源分配需要精确或治疗具有显著副作用时,这种模型会非常有用。

回到我们的产品经理,他正在决定将哪些产品和产品捆绑分配营销预算,具有更高置信度的预测会有助于防止浪费营销资源。较低的假阳性概率有助于高效地使用资源,但也有可能由于保守的预测而错过一些创收的捆绑配置。

在本节中,我们为 aggr 参数使用了一个简单的字符串参数。然而,PyG 提供了广泛的工具集,可以将各种聚合方法融入到你的模型中。我们将在下一节中探讨这些工具。

3.2.2 高级聚合工具

本节探讨了 PyG 中更高级的聚合工具。我们首先为多层架构中的不同层分配不同的聚合方法。接着,我们探索了在单层中组合多种聚合策略——例如 'mean'、'max' 和 'sum'。最后,我们重新审视 GCN,研究跳跃知识(JK)方法。

跨层使用多种聚合方法

在一个多层 GraphSAGE 模型中,你当然可以在每一层独立地调整聚合函数。例如,你可以在第一层使用均值聚合来平滑特征,但在后续层切换到最大值聚合,以突出最显著的邻居特征。

作为我们探索的第一次练习,让我们应用几种聚合的排列方式,看看这些配置是否能够超越我们之前的结果。我们使用之前的代码,交换 conv1 和 conv2 的 aggr 设置。在一个模型中,我们使用均值聚合作为第一层,最大值聚合作为第二层。在另一个模型中,我们使用求和聚合作为第一层,最大值聚合作为第二层。表 3.4 总结了结果。

表 3.4 不同邻域聚合设置下的 GraphSAGE 模型分类性能
聚合类型F1 分数对数损失
均值(默认)0.74062.1214
求和0.73842.2496
最大值0.74492.1039
层级:均值 → 最大值0.73162.2041
层级:求和 → 最大值0.73442.345

对于我们的数据集,结果处于中等水平。仅使用最大值聚合的模型在表现上超越了其他新模型。接下来,我们将尝试在每一层中组合几种聚合方法。

列表聚合和聚合函数

在 PyG 中,使用列表来指定聚合函数的概念,使得你可以同时使用多种聚合策略来定制模型。这一特性非常重要,因为它使模型能够利用图数据的不同方面,通过捕捉图的各种属性来潜在地提高模型的性能。从某种意义上说,你是在对聚合进行聚合。例如,你可以在单一层中结合 'mean'、'max' 和 'sum' 聚合,以捕捉邻域的平均值、最显著值和结构性总和属性。

在 PyG 中,这可以通过将聚合函数列表(无论是作为字符串还是作为聚合模块实例)传递给 MessagePassing 类来实现。PyG 会根据预定义的聚合函数集解析这些字符串,或者直接将聚合函数作为 aggr 参数使用。例如,使用关键字 'mean' 会调用 MeanAggregation() 函数。

有许多组合可以尝试,但我们尝试两个例子来演示,分别混合常见的聚合方法 'max'、'sum' 和 'mean',以及一些更为特殊的聚合方法,如 SoftmaxAggregationStdAggregation [3]。它们可以应用于我们的 conv1 层,如下所示(表 3.5 将这些结果与之前的结果进行了比较):

self.conv1 = SAGEConv(in_channels, hidden_channels, aggr=['max', 'sum', 'mean'])

self.conv1 = SAGEConv(in_channels, hidden_channels, aggr=[SoftmaxAggregation(), StdAggregation()])
表 3.5 添加列表聚合后的 GraphSAGE 模型分类性能
聚合类型F1 分数对数损失
均值(默认)0.74062.1214
求和0.73842.2496
最大值0.74492.1039
层级:均值 → 最大值0.73162.2041
层级:求和 → 最大值0.73442.345
列表(标准)0.74842.622
列表(特殊)0.7452.156
图 3.5 可视化了表 3.5 中的性能比较。虽然 F1 分数非常相似,但“标准”列表聚合的 F1 分数略有提高,尽管其对数损失大大增加。

image.png

根据我们对这些聚合方法在GraphSAGE层上应用的快速调查结果,您可能会得出结论,通常保持默认设置是最佳选择。然而,通过量身定制的聚合策略潜在的性能提升,表明进一步探索可能是有益的。

在接下来的3.2.3节中,我们将回顾应用这些聚合方法时需要考虑的一些因素。在此之前,我们将回到GCN层,检查JK聚合方法。

JUMPING KNOWLEDGE NETWORKS

跳跃知识(JK)是一种针对图上节点表示学习的新方法,解决了现有模型(如GCN和GraphSAGE)的局限性[4]。它侧重于克服邻域聚合模型中的一个问题,即这些模型对图的结构敏感,导致不同图部分之间学习质量的不一致。

跳跃知识网络(JK-Nets)允许为每个节点灵活地使用不同的邻域范围,从而适应局部邻域特性和任务特定的要求。这种适应性通过使模型能够根据节点和子图的上下文选择性地使用来自不同邻域深度的信息,从而提高了节点表示。JK已在PyG中实现用于GCN层,如列出在清单3.6所示。

其主要参数mode指定用于结合来自不同层的输出的聚合方案。选项如下:

  • 'cat'——沿特征维度连接所有层的输出。这种方法保留了每一层的所有信息,但增加了输出的维度。
  • 'max'——对层输出应用最大池化。这种方法对于每个特征,从所有层中取最大值,有助于捕捉图中最重要的特征,同时对不太有信息的信号具有鲁棒性。
  • 'lstm'——使用双向LSTM来学习每一层输出的注意力得分。然后根据这些学习到的注意力权重结合输出,使模型能够根据输入图结构动态地关注最相关的层。

清单3.6 GCN类与JumpingKnowledge层

class CustomGCN(torch.nn.Module):
   def __init__(self, in_channels, hidden_channels, out_channels):
       super(CustomGCN, self).__init__()
       self.conv1 = GCNConv(in_channels, hidden_channels)
       self.conv2 = GCNConv(hidden_channels, out_channels)

       self.jk = JumpingKnowledge(mode='cat')  #1

   def forward(self, x, edge_index):
       layer_outputs = []  #2

       x1 = self.conv1(x, edge_index)
       x1 = F.relu(x1)
       layer_outputs.append(x1)  #3

       x2 = self.conv2(x1, edge_index)
       layer_outputs.append(x2) 

       x = self.jk(layer_outputs)  #4

       return x

#1 使用拼接模式初始化JK #2 列表用于保存每一层的输出,以便用于JK #3 将每一层的输出添加到列表中 #4 对收集到的层输出应用JK聚合

在清单中,JumpingKnowledge层在初始化时,mode设置为'cat'(拼接),表示将每一层的特征拼接起来,形成最终的节点表示。

在前向传播中,layer_outputs被初始化为空列表,用来存储每个卷积层的输出。这个列表将由JumpingKnowledge层使用。

第一个卷积层处理输入x和图结构edge_index,并应用ReLU激活函数引入非线性。

第一个层的输出(x1)随后被添加到layer_outputs列表中。

第二个卷积层的输出(x2)也被添加到layer_outputs列表中。

接下来,JumpingKnowledge层接收所有前一层输出的列表,并根据指定的模式('cat')进行聚合。在拼接模式下,每一层的特征向量沿着特征维度拼接。

表3.6 GCN模型的分类性能比较

模型F1 ScoreLog Loss
基线GCN0.7811.42
JK (GCN)0.6991.36

结果显示,在基线模型和JK版本之间的选择涉及到更高的召回率/精度与更高的预测确定性之间的权衡。根据任务的具体需求和目标,应该仔细考虑这种权衡。在接下来的3.2.3节中,我们将回顾一些应用这些聚合方法时需要有效考虑的因素。

3.2.3 应用聚合方法的实际考虑

选择合适的聚合方法是一个技术决策,应根据当前数据集的具体特征和需求以及使用场景来决定。对于那些局部邻域结构至关重要的数据集,使用均值或和的聚合可能会模糊关键信息。相比之下,最大值聚合可能有助于突出关键属性。例如,在社交网络图中,影响者检测是关键任务,最大值聚合可能更有效。另一方面,如果我们想要表示典型特征,最大值聚合可能会过度强调异常值。在金融交易数据集中,如果我们希望理解典型用户行为,最大值聚合可能会扭曲常见的行为特征,偏向于一个或两个大而不常见的交易。

任务本身可以决定聚合方法的选择。需要捕捉最具影响力特征的任务可能受益于最大值聚合,而需要一般性表示的任务则可能发现均值聚合足够。例如,在产品推荐系统中,最大值聚合有助于识别驱动购买的最重要产品特征。此外,图的拓扑结构的性质应指导聚合方法的选择。密集连接的图可能需要与稀疏连接图不同的策略,以避免过度平滑或节点特征的代表性不足。例如,具有不同节点连接性的交通网络图可能在不同层次上需要不同的聚合方法。

考虑到数据集的复杂性,实证测试不同的聚合方法至关重要。实验可以帮助识别哪些方法最好地捕捉数据集的关系动态和特征分布。对于一些更为特殊的聚合方法,这一点尤其重要,因为仅凭直觉可能无法确定其有效性。选择的聚合方法的可扩展性,能够高效处理数百万个节点和边,也是至关重要的。特别是在实时应用中(如推荐系统),平衡计算效率与方法的复杂性非常重要。

聚合方法应与其他模型增强技术一起考虑,如特征工程、节点嵌入技术和正则化策略,以解决过拟合问题并提高模型的泛化能力。例如,将有效的聚合方法与先进的嵌入技术(如Node2Vec)结合,或在正则化中引入dropout,可以显著提升模型性能。

尽管没有一种放之四海而皆准的聚合方法,但经过深思熟虑的技术组合,结合实证验证,可以显著提升模型的性能和适用性。这种战略方法不仅有助于准确的产品分类,还能在电子商务环境中打造有效的推荐系统。

本节探讨并应用了不同的聚合方法到我们的模型中。接下来的章节将通过应用正则化和调整模型深度来完善我们对卷积图神经网络(GNN)的探索。我们将把改进整合到最终模型中,并基于Marceline玩偶生成另一个产品捆绑包,以查看是否有改进。

3.3 进一步优化和改进

到目前为止,我们已经通过一个产品管理示例介绍了GCN和GraphSAGE层。在第3.1节中,我们使用默认设置建立了基准。在第3.2节中,我们考察了邻域和层聚合的使用。在本节中,我们将考虑其他可以优化和改进模型的方法。在前两个小节中,我们将介绍另外两种调整:使用dropout和模型深度。Dropout是一种广为人知的正则化技术,能够减少过拟合,而模型深度则是对GNN具有独特意义的调整。

在第3.3.3节中,我们将综合这些见解,开发一个结合多项改进的模型,并观察其累积性能提升。最后,在第3.3.4节中,我们将重新审视我们的产品捆绑问题。我们将使用第3.3.3节中改进后的模型创建一个新的产品捆绑包,并将其性能与第3.1节中创建的捆绑包进行比较。

3.3.1 Dropout

Dropout是一种正则化技术,用于通过在训练过程中随机丢弃神经网络中的单元,来防止过拟合。这有助于模型更好地泛化,减少对特定神经元的依赖。

在PyG中,dropout函数的工作原理类似于标准的PyTorch dropout,这意味着它会在训练过程中将输入张量和隐藏层激活的一些元素随机设置为0。在每次训练中的前向传播中,输入和激活会根据指定的dropout率被设置为0。这有助于防止过拟合,确保模型不会过度依赖任何特定的输入或激活。

图的结构,包括节点(顶点)和边,在dropout期间保持不变。图的拓扑得以保留,只有神经网络的激活受到影响。这一点非常重要,因为它在保持图的完整性的同时,仍然使用dropout来提高模型的鲁棒性。PyG确实有可以在训练期间丢弃节点或边的函数,但GCNConv和SAGEConv中的dropout指的是传统的深度学习dropout。

在PyG中,GraphSAGE和GCN层都将dropout率作为一个参数,默认值为0。图3.6展示了不同dropout率(0%、50%和85%)下GCN模型的性能。如图所示,较高的dropout率有助于缓解过拟合,表现为训练和验证损失之间的差距减小。在85%的情况下,较高的dropout率可能导致模型收敛更慢,或者可能是过拟合的迹象。需要更多的测试来进一步确认。

接下来,让我们探讨模型深度以及它是如何在卷积GNN中实现的。

3.3.2 模型深度

在GNN中,层指的是跳数或消息传递步骤的数量。每一层都允许节点从其直接邻居那里聚合信息,从而有效地将感受野增加一个跳数。例如,一个三层的模型将探查每个节点三跳远的邻域。因此,GNN的深度指的是网络中层的数量,类似于传统深度学习模型中的深度,但由于图结构数据的不同,它具有一些关键的区别。

如果一个GNN的层数过少,它可能无法从图中捕获足够的信息,导致表现学习不佳,因为每个节点只能从有限的邻域聚合信息。相反,增加层数可能导致过度平滑,即节点特征变得过于相似,难以区分不同的节点。随着每增加一层,节点聚合来自更大邻域的信息,从而稀释了单个节点的独特特征。为了解决这一问题,提出了多种度量标准和方法来衡量并减轻这种效应。

image.png

GNN的不同深度性能差异可能很大。通常,2层或3层的GNN在许多任务中表现竞争力,能够平衡足够的邻域信息和避免过度平滑的需求。而更深的GNN理论上可以捕捉到更复杂的模式,但它们往往会遭遇过度平滑和增加计算复杂度的问题。非常深的GNN,例如50层或更多层,可能导致更高的验证损失,表明存在过拟合和/或过度平滑的情况。

图3.7比较了不同深度(例如,2层、10层和50层)的GNN性能。我们可以看到,2层的模型在训练损失和验证损失之间达到了良好的平衡。在10层的GNN中,训练损失有所改善,但也表现出由于较高的验证损失带来的过度平滑迹象。50层的模型则显示出训练和验证损失的退化,表明存在严重的过度平滑或过拟合问题。

image.png

平衡模型深度对于GNN的最佳性能至关重要。层数过少可能导致表示学习不足,而层数过多则可能导致过度平滑,节点特征变得无法区分。在下一节中,我们将应用本章中所学的模型调优方法,从而得到一个改进的模型,超越我们的基准模型。

3.3.3 提升基准模型的性能

根据本章获得的所有见解,让我们训练结合这些学习成果的模型,并与基准模型进行比较。以下是我们将结合的前几节中的一些关键要点:

  • 模型深度—我们将保持较低,设置为两层。
  • 邻域聚合—我们将使用最大值聚合,并尝试两种列表聚合。相同的聚合方法将应用于两个层。
  • Dropout—我们将在两个层上使用50%的dropout。

以下清单显示了一个具有可调dropout、层深度和聚合方法的GraphSAGE类。

清单3.7 GraphSAGE类

class GraphSAGEWithCustomDropout(torch.nn.Module):
   def __init__(self, in_channels, \
hidden_channels, out_channels, num_layers, \
dropout_rate=0.5, aggr='mean'):  #1
       super(GraphSAGEWithCustomDropout, self).__init__()
       self.layers = torch.nn.ModuleList\
([SAGEConv(in_channels, hidden_channels, aggr=aggr)])
       for _ in range(1, num_layers-1):  #2
           self.layers.append(SAGEConv\
(hidden_channels, hidden_channels, aggr=aggr))
       self.layers.append(SAGEConv\
(hidden_channels, out_channels, aggr=aggr))
       self.dropout_rate = dropout_rate

   def forward(self, x, edge_index):
       for layer in self.layers[:-1]:
           x = F.relu(layer(x, edge_index))
           x = F.dropout(x, p=self.dropout_rate, training=self.training)
       x = self.layers[-1](x, edge_index)
       return F.log_softmax(x, dim=1)

#1 层被初始化为指定层数、dropout率和聚合类型 #2 循环将聚合方法应用于每一层

我们使用上述类训练了三个模型:

model_1 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=.5, \
aggr= ‘max’).to(device)

model_2 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=0.5, \
aggr=['max', 'sum', 'mean']).to(device)

model_3 = GraphSAGEWithCustomDropout\
(subset_graph.num_features, 64, \
dataset.num_classes, 2, dropout_rate=0.50,\
 aggr=[SoftmaxAggregation(), \
StdAggregation() ] ).to(device)

表3.7总结了使用不同聚合方法的两层GraphSAGE模型的性能,并与基准模型(使用默认均值聚合)进行对比。结果表明,所有改进的模型在F1分数和log损失上都优于基准模型。特别是,模型2使用“max”、“sum”和“mean”聚合方法的组合,达到了最高的F1分数0.8828。模型3则使用SoftmaxAggregation()和StdAggregation()的组合,表现出最佳的log损失0.5764,表明它在所有测试配置中具有最高的预测确定性。

表3.7 两层GraphSAGE模型,使用50% dropout和不同的聚合类型

GraphSAGE模型聚合类型F1 ScoreLog Loss
模型1'max'0.86740.594
模型2['max', 'sum', 'mean']0.88760.660
模型3[SoftmaxAggregation(), StdAggregation()]0.88290.574
基准模型均值(默认)0.74062.1214

图3.8中的混淆矩阵可视化了使用最大值聚合的模型1的分类性能。大多数值位于对角线上,表示模型正确地分类了大多数实例。然而,矩阵中也存在一些非对角元素,代表错误分类的情况,例如,类别0的实例被错误分类为类别1,反之亦然。错误分类的频率和分布揭示了模型的困难区域。此外,矩阵侧边的条形图显示了每个类别的成员数量,表明数据集中存在类别不平衡的情况,有些类别的数量较高,而其他类别的数量明显较低。

image.png

请注意,在所有这些过程中,我们使用的数据集节点不到1%,这些节点是按索引顺序任意选择的。增加节点数量会提高我们模型的性能。此外,以更有意义的方式选择子图,同时保持节点数量不变,也可能提升性能。

尽管当前模型已经显示出显著的改进,但仍有多种策略可以进一步提升性能。通过使用更大的子集来增加数据集的大小,可以提供更多的训练数据,从而可能改善模型的泛化能力。基于领域知识或使用图采样技术来优化子图选择,可以确保使用更有意义的数据进行训练。超参数优化,通过使用Hyperopt等工具系统地调节超参数,可以帮助找到模型的最佳设置。Hyperopt通过使用贝叶斯优化等算法高效地搜索超参数空间。探索更复杂的聚合函数或定制聚合方法,针对数据集的特定特征进行优化,也能带来改进。此外,实施L2正则化或梯度裁剪等正则化方法,可以稳定训练并防止过拟合。图预处理技术,如规范化、特征工程以及对图特征进行降维,可以提升输入数据的质量,进一步提高模型性能。接下来,我们将选择在log损失上表现最好的模型来生成另一个产品捆绑包。

3.3.4 重新审视Marcelina产品捆绑

相比于第3.1节中的基准模型,模型已经有了显著的提升。让我们重新审视产品捆绑问题,并基于我们之前优化的GraphSAGE模型,推荐一个给我们的产品经理。使用第3.1.5节中的过程,我们得到了图3.9中的捆绑包,并与原始捆绑包进行了对比展示。

image.png

你怎么看这个新捆绑包?它是否有所改进,更有可能推动购买,相比于之前的捆绑包?这个新捆绑包包含了玩具与游戏、图书以及电影与电视类别的商品,展现了多样化的产品选择。引入《Wild Kratts: Wildest Animal Adventures》DVD、冒险书籍《The Sword of Shannara》和动作人物玩偶,反映了一个转向更加面向家庭和儿童友好的产品组合。

这个新捆绑包推动购买的潜力,源于通过更新后的模型对客户购买行为和偏好更深入的理解。这个捆绑包看起来非常适合作为礼物,既面向流行文化纪念品的收藏者(例如Marceline玩偶和相关收藏品),也迎合了年轻的奇幻冒险故事爱好者。

从一个更为通用的玩具集合到一个更加聚焦、主题化的捆绑包转变,可能会增加其作为购买选择的吸引力。捆绑包中同时包含娱乐性(《Wild Kratts: Wildest Animal Adventures》DVD)和文学性(《The Sword of Shannara》)的元素,除此之外,还有收藏型玩偶,提供了一个围绕冒险与探索的流行主题的更全面的娱乐体验。这可以吸引那些寻求富有教育意义并有趣的礼物的父母,比如《Wild Kratts》中与动物和自然相关的内容。

考虑一个精心策划的捆绑包的心理效应也至关重要。通过将产品与已识别的客户兴趣和交叉销售模式更紧密地对接,捆绑包不仅满足现有需求,还通过如何使捆绑的商品相互补充来增强感知价值,从而鼓励额外购买。

最终,是否这个新捆绑包比原始捆绑包更具改进性,应该通过客户反馈和销售数据来验证。追踪两个捆绑包(以及由人类产品经理建议的捆绑包)的销售表现,并通过调查或A/B测试收集直接的客户见解,将有助于定量评估哪个捆绑包在销售和客户满意度方面表现更好。这种数据驱动的方法将确认新捆绑包创建中使用的先进建模技术的理论优势。

至此,我们结束了本章的实践产品示例。接下来的两节是可选的,它们深入探讨了卷积GNN的理论,并更详细地查看了亚马逊产品数据集。

3.4 内部机制

现在我们已经创建并优化了一个工作中的卷积GNN,让我们深入了解GNN的各个组成部分,以更好地理解它们的工作原理。这些知识在我们想要设计新的GNN或排查GNN问题时非常有帮助。

在第2章中,我们介绍了使用GNN层通过消息传递产生预测或创建嵌入的思路。这里再次展示了那个架构图,如图3.10所示。

让我们深入GNN层的内部,检查其组成部分。然后,我们将这一部分与聚合函数的概念联系起来。

image.png

3.4.1 卷积方法

让我们首先考虑深度学习中最流行的架构之一——卷积神经网络(CNN)。CNN通常用于计算机视觉任务,如图像分割或分类。CNN层可以看作是对输入数据应用一系列操作: 层:滤波器 → 激活函数 → 池化 每一层的输出是一些经过转换的数据,使得后续任务更加简单或成功。这些转换操作包括以下几种:

  • 滤波器(或核操作) ——一个转换输入数据的过程。滤波器用于突出输入数据的某些特定特征,并由可学习的权重组成,这些权重通过目标或损失函数进行优化。
  • 激活函数——应用于滤波器输出的非线性变换。
  • 池化——减少滤波器输出大小的操作,以便于后续的学习任务。

CNN和许多GNN共享一个共同的基础:卷积的概念。当我们讨论CNN中的三个操作时,你就接触到了卷积的概念。在CNN和GNN中,卷积的核心是通过在数据中建立局部模式的层级来进行学习。对于CNN来说,这可能用于图像分类,而卷积GNN(例如GCN)则可能使用卷积来预测节点的特征。为了强调这一点,CNN将卷积应用于一个固定的像素网格,以识别网格中的模式,而GCN模型则将卷积应用于节点的图形,以识别图中的模式。

我在上一段提到卷积的概念,因为卷积可以通过不同的方式实现。从理论上讲,卷积与数学卷积算子相关,接下来我们将更详细地讨论这一点。对于GNN,卷积可以分为空间方法和谱方法[1, 5, 6]:

  • 空间方法——将一个窗口(滤波器)滑动过图。
  • 谱方法——使用谱方法对图信号进行滤波。

空间方法

在传统的深度学习中,卷积过程通过将一个特殊的滤波器(称为卷积核)应用于输入数据来学习数据表示。这个卷积核的大小比输入数据小,通过在输入数据上滑动它来进行应用。图3.11展示了这一过程,我们将卷积核(中心的矩阵)应用于一张狮子的图像。由于我们的卷积核对所有非中心元素的值为负,结果图像被反转。我们可以看到,一些特征被强调了,例如狮子的轮廓。这突出了卷积的过滤功能。

image.png

这种卷积网络的使用在计算机视觉领域尤其常见。例如,在对2D图像进行学习时,我们可以应用一个简单的CNN,包含几层。在每一层,我们将一个2D滤波器(卷积核)应用于每张图像。3×3的卷积核在图像中多次工作,图像的大小是其的多倍。通过在连续的层中执行这一操作,我们可以生成输入图像的学习表示。

对于图,我们希望应用相同的思想,即在数据上滑动一个窗口,但现在我们需要进行调整,以适应数据的关系性和非欧几里得拓扑。对于图像,我们处理的是刚性的二维网格;而对于图,我们处理的是没有固定形状或顺序的数据。由于图中没有节点的预定义顺序,我们使用邻域的概念,它由一个起始节点及其所有一跳邻居(即,所有离中心节点一跳范围内的节点)组成。然后,我们的滑动窗口通过在节点邻域间移动来滑动整个图。

在图3.12中,我们看到一个插图,比较了卷积在网格数据和图数据上的应用。在网格的情况下,像素值围绕着中央像素(用灰点标记)周围的九个像素进行滤波。然而,对于图来说,节点属性是根据所有可以通过一条边连接的节点进行滤波的。一旦我们确定了将要考虑的节点,我们就需要对这些节点执行一些操作。这被称为聚合操作;例如,可能会对一个邻域内所有节点的权重进行平均或求和,或者我们可能取最大值。对于图来说,重要的是这个操作是排列不变的。节点的顺序不应该影响结果。

image.png

谱方法

为了引入第二种卷积方法,让我们先考察一下图信号的概念[6]。在信息处理领域,信号是可以在时间域或频率域中进行分析的序列。当在时间域中研究信号时,我们关注的是其动态性,即信号随时间的变化。而在频率域中,我们则关注信号在各个频带中的分布情况。

我们也可以以类似的方式研究图的信号。为此,我们将图信号定义为节点特征的向量。因此,对于一个给定的图,其节点权重的集合可以用来构建其信号。作为一个视觉示例,在图3.13中,我们有一个图,每个节点上都有与之相关的值,其中每个柱子的高度代表某个节点特征。

image.png

为了对这个图信号进行操作,我们将图信号表示为一个矩阵,其中每一行是与特定节点相关的一组特征。然后,我们可以在图矩阵上应用信号处理的操作。一项关键的操作是傅里叶变换。傅里叶变换可以将图信号及其节点特征集合转化为频率表示。反之,逆傅里叶变换将频率表示还原为图信号。

超越传统深度学习方法对图的限制

为什么我们不能直接将CNN应用于图结构?原因在于图表示存在图像表示所没有的歧义。CNN和传统的深度学习工具通常无法解决这种歧义。能够处理这种歧义的神经网络被称为排列等变或排列不变。

让我们通过考虑之前展示的狮子图像来说明图与图像之间的歧义。这个像素集的简单表示可以是一个二维矩阵(具有高度和宽度的维度)。这个表示是唯一的:如果我们交换图像的两行或两列,那么我们就得不到等效的图像。类似地,如果我们交换图像矩阵表示的两列或两行(如图3.14所示),我们也得不到等效的矩阵。

image.png

这与图不同。图可以通过邻接矩阵表示(在第1章和附录A中有描述),其中每一行和每一列的元素代表两个节点之间的关系。如果一个元素非零,意味着行节点和列节点是相连的。给定这样的矩阵,我们可以像对图像进行实验一样,交换两行。与图像的情况不同,我们最终得到的矩阵仍然代表我们最初的图。我们可以对行和列进行任意次数的排列,最终得到一个代表相同图的矩阵。

回到卷积操作,为了成功地将卷积滤波器或CNN应用于图的矩阵表示,这样的操作或层必须在邻接矩阵的顺序无论如何排列时都能产生相同的结果(因为每种排列都描述相同的内容)。CNN在这方面会失败。

应用于图的卷积滤波器的研究已经通过多种方式得到解决。在本章中,我们考察了两种实现方式:空间方法和谱方法。(关于应用于图的卷积滤波器的更深入讨论和推导,参见[7]。)

3.4.2 消息传递

空间方法和谱方法都描述了我们如何在图上合并数据。空间方法关注图的结构,通过空间邻域合并数据。谱方法则关注图信号,使用信号处理中的方法,如傅里叶变换,来合并图中的数据。这两种方法都隐含着消息传递的思想。

在第3章中,我们介绍了消息传递作为从图中提取更多信息的一种方式。让我们一步步地考虑消息传递的作用。首先,从每个节点或边收集来自邻居节点的消息。其次,我们将这些消息转换为特征向量来编码数据。最后,我们更新节点或边的数据,将这些消息纳入其中。结果是,每个节点或边最终包含了自身的数据以及来自图中其他部分的数据。编码到这些节点中的数据量反映了跳数或消息传递步骤的数量。这与GNN中的层数相同。图3.15展示了消息传递的心理模型。

image.png

每个消息传递层的输出是一个嵌入或特征集。在聚合步骤中,我们从图的邻域收集消息。在转换步骤中,我们对聚合的消息应用一个神经网络。最后,在更新步骤中,我们修改节点或边的特征,以包含消息传递数据。

通过这种方式,GNN层类似于CNN层。它可以被解释为一系列应用于输入数据的操作: 层:聚合 → 转换 → 更新 随着我们在本书中探索不同类型的GNN,我们将多次回到这组操作,因为大多数类型的GNN都可以看作是这些元素的修改。例如,在本章中,您正在学习GCN作为一种特定类型的聚合方法。在下一章中,您将学习GAT,它通过学习如何使用注意力机制聚合消息,结合了转换和聚合步骤。

为了构建这个消息传递步骤,让我们更详细地回顾一下之前的过程。前两步可以理解为一种滤波器,类似于传统神经网络的第一步。首先,我们使用聚合操作符聚合节点或边的数据。例如,我们可以对特征求和、取平均或选择最大值。最重要的是,节点的顺序对最终表示不应产生影响。节点顺序不应产生影响的原因是,我们希望模型具有排列等变性,这意味着减法或除法将不适用。

一旦我们聚合了所有节点的信息或收集了所有消息,我们就通过神经网络和激活函数将它们转换为嵌入。得到这些转换后的嵌入后,我们应用激活函数,然后将它们与节点或边的数据以及之前的嵌入结合起来。

激活函数是对转换和聚合后的消息应用的非线性变换。我们需要这个函数是非线性的;否则,不论网络有多少层,模型将是线性的,类似于线性(或在我们的案例中是逻辑)回归模型。这些是人工神经网络中常用的标准激活函数,例如ReLU,它是输入值和零之间的最大值。然后,池化步骤会减少任何图级学习任务的滤波器输出的总体大小。对于节点预测,可以省略这一步骤,这里我们也将这样做。

我们可以将之前的描述合并为一个单一的消息传递操作表达式。首先,假设我们在本章中将处理节点嵌入。我们希望将节点n的数据转换为节点嵌入。我们可以使用以下公式来实现:

image.png

这里,u代表节点。可学习的权重由Wa给出,这些权重将根据损失函数进行调整,σ是激活函数。为了构建嵌入,我们需要将所有节点数据合并成一个单一的向量。这时,聚合函数发挥了作用。对于GCN,聚合操作符是求和。因此,

image.png

其中,对于节点u,hv是来自节点v的数据,v位于节点u的邻域N(u)中。结合这两个方程,我们可以构造出一个通用的节点嵌入构建公式:

image.png

在前面的公式中,我们可以看到节点及其邻域在其中扮演着核心角色。事实上,这也是GNN成功的主要原因之一。我们还看到,我们需要对激活函数和聚合函数做出选择。最后,这些在每个节点上被更新,以包含先前的数据:

image.png

在这里,我们将消息拼接在一起。也可以使用其他方法来更新消息信息,具体选择取决于使用的架构。

这个更新方程是消息传递的本质。对于每一层,我们都使用包含所有聚合消息的转换数据来更新所有的节点数据。如果我们只有一层,那么我们只进行一次此操作,将邻居节点(距离起始节点一跳)的信息聚合起来。如果我们运行这些操作多次,我们将把距离中心节点两跳内的节点信息聚合到节点特征数据中。因此,GNN层的数量直接与我们模型中查询的邻域的大小相关。

这些是消息传递步骤中正在执行的操作的基本原理。聚合或激活函数等方面的变化突出了GNN架构中的关键差异。

3.4.3 GCN聚合函数

GCN和GraphSAGE之间的主要区别在于它们执行不同的聚合操作。GCN是基于谱的GNN,而GraphSAGE是空间方法。为了更好地理解它们之间的区别,让我们看一下如何实现它们。

首先,我们需要理解如何将卷积应用于图。数学上,卷积操作可以表示为两个函数的组合,产生第三个函数:

image.png

其中 f(x) 和 h(x) 是函数,运算符表示逐元素相乘。在卷积神经网络(CNN)中,图像和卷积核矩阵是方程 3.6 中的函数。

image.png

这个数学操作可以解释为卷积核在图像上滑动,就像滑动窗口方法一样。我们可以将之前的描述转换为描述数据的矩阵或张量。为了将方程 3.7 的卷积应用到图上,我们使用以下要素: 图的矩阵表示: 向量 x 作为图信号 邻接矩阵 A 拉普拉斯矩阵 L 拉普拉斯的特征向量矩阵 U 用于权重的参数化矩阵 H 基于矩阵运算的傅里叶变换:UTx 这就得出了图上谱卷积的表达式:

image.png

因为这个操作不是简单的逐元素相乘,我们使用符号 *G 来表示这个操作。基于卷积的几种 GNN 架构都基于方程 3.8,接下来我们将探讨 GCN 版本。

GCN 对卷积方程(3.8)进行了修改,以简化操作并减少计算成本。这些修改包括使用基于多项式的滤波器,而不是一组矩阵,并将跳数限制为 1。这将计算复杂度从二次方降到线性,这一点非常重要。然而,值得注意的是,GCN 更新了我们之前描述的聚合函数。它仍然使用求和,但包含了一个归一化项。

之前,聚合操作符是求和。这可能在节点度数方差较大的图中引发问题。如果一个图包含度数较高的节点,这些节点将占主导地位。为了解决这个问题,一种方法是用平均替代求和。此时,聚合函数可以表示为:

image.png

因此,对于 GCN 的信息传递,我们有:

image.png

其中: h 是更新后的节点嵌入。 sigma,σ,是应用于每个元素的非线性函数(即激活函数)。 W 是训练好的权重矩阵。 |N| 表示图中节点集合中元素的数量。 求和因子,

image.png

这是一个特殊的归一化方法,称为对称归一化。此外,GCN 包括自环,使得节点嵌入既包含邻域数据,也包含起始节点的数据。因此,为了实现 GCN,必须执行以下操作: 图节点调整为包含自环 训练好的权重矩阵与节点嵌入的矩阵乘法 归一化操作,求和对称归一化的各项 在图 3.16 中,我们详细解释了信息传递步骤中使用的每一项。

到目前为止,这一切都是理论性的。接下来我们来看看如何在 PyG 中实现这一点。

image.png

3.4.4 PyTorch Geometric 中的 GCN

在 PyG 文档中,你可以找到实现 GCN 层的源代码,以及 GCN 层的简化实现。接下来,我们将指出源代码是如何实现前述关键操作的。

在表 3.8 中,我们将 GCN 嵌入计算中的关键步骤拆解,并将它们与源代码中的函数对应起来。这些操作通过一个类和一个函数实现:

  • 函数 gcn_norm 执行归一化并向图中添加自环。
  • GCNConv 实例化 GNN 层并执行矩阵运算。

表 3.8 映射 GCN 嵌入公式中的关键计算操作

操作函数/方法
向节点添加自环gcn_norm(),在列表 3.8 中注释
权重和嵌入相乘 W(k)huGCNConv.__init__; GCNConv.forward
对称归一化gcn_norm(),在列表 3.8 中注释

在列表 3.8 中,我们详细展示了 gcn_norm 函数和类的代码,并通过注释突出关键操作。这个归一化函数是 GCN 架构中的关键部分。gcn_norm 函数的参数如下:

  • edge_index — 节点表示为张量或稀疏张量形式。
  • edge_weight — 可选的一维边权重数组。
  • num_nodes — 输入图的维度。
  • improved — 这是一个替代方法,用于从 Graph U-Nets 论文 [8] 中添加自环。
  • add_self_loops — 添加自环是默认操作,但也可以选择不添加。

列表 3.8 gcn_norm 函数

def gcn_norm(edge_index, edge_weight=None, num_nodes=None, improved=False,
             add_self_loops=True, dtype=None):  #1

   fill_value = 2. if improved else 1.  #2

   if isinstance(edge_index, SparseTensor):  #3
       adj_t = edge_index
       if not adj_t.has_value():
           adj_t = adj_t.fill_value(1., dtype=dtype)
       if add_self_loops:
           adj_t = fill_diag(adj_t, fill_value)
       deg = sparsesum(adj_t, dim=1)
       deg_inv_sqrt = deg.pow_(-0.5) 
       deg_inv_sqrt.masked_fill_(deg_inv_sqrt == float('inf'), 0.)
       adj_t = mul(adj_t, deg_inv_sqrt.view(-1, 1))
       adj_t = mul(adj_t, deg_inv_sqrt.view(1, -1))
       return adj_t

   else: 
       num_nodes = maybe_num_nodes(edge_index, num_nodes)

       if edge_weight is None:
           edge_weight = torch.ones((edge_index.size(1), ), dtype=dtype,
                                    device=edge_index.device)

       if add_self_loops:
           edge_index, tmp_edge_weight = add_remaining_self_loops(
               edge_index, edge_weight, fill_value, num_nodes)
           assert tmp_edge_weight is not None
           edge_weight = tmp_edge_weight

       row, col = edge_index[0], edge_index[1]
       deg = scatter_add(edge_weight, col, dim=0, dim_size=num_nodes)
       deg_inv_sqrt = deg.pow_(-0.5)
       deg_inv_sqrt.masked_fill_(deg_inv_sqrt == float('inf'), 0)
       return edge_index, deg_inv_sqrt[row] * edge_weight * deg_inv_sqrt[col]

#1 执行输入图的对称归一化并向输入图添加自环
#2 参数 fill_value 用于替代的自环操作
#3 如果图输入是稀疏张量,代码的第一块会被执行,否则会执行第二块。

在实践中,我们可以通过使用 PyTorch 和 PyG 中的一些函数,显著简化归一化实现。在列表 3.9 中,我们展示了归一化邻接矩阵的简化版本。首先,我们计算每个节点的入度,然后计算其平方根的倒数。接着,我们用它来创建新的边权重,并将度的平方根倒数应用于此权重。最后,我们创建一个表示邻接矩阵的稀疏张量,并将其分配给我们的数据。

列表 3.9 使用 PyTorch 和 PyG 进行归一化

    edge_index = data.edge_index
    num_nodes = edge_index.max().item() + 1  #1

    deg = torch.zeros(num_nodes, \
    dtype=torch.float).to(edge_index.device)       #2
    deg.scatter_add_(0, edge_index[1],            
                 torch.ones(edge_index.size(1))\
.to(edge_index.device))                           

    deg_inv_sqrt = deg.pow(-0.5)  #3
    deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0   #3

    edge_weight = torch.ones(edge_index.size(1))\
    .to(edge_index.device)  #4
    edge_weight = deg_inv_sqrt[edge_index[0]]*edge_weight*\
    deg_inv_sqrt[edge_index[1]]  #5

    num_nodes = edge_index.max().item() + 1   #6

    adj_t = torch.sparse_coo_tensor(indices=edge_index,\
    values=edge_weight, size=(num_nodes, num_nodes))      #7
    data.adj_t = adj_t.coalesce()                         #7

#1 假设节点索引从 0 开始
#2 计算每个节点的入度
#3 计算基于度数的平方根倒数
#4 创建一个新的 edge_weight 张量
#5 将 deg_inv_sqrt 应用于边权重
#6 假设节点索引从 0 开始
#7 创建稀疏张量并分配给数据

在接下来的列表中,我们展示了 GCNConv 类的部分内容,它调用了 gcn_norm 函数以及矩阵运算。

列表 3.10 GCNConv

class GCNConv(MessagePassing):    

    def __init__(self, in_channels: int, out_channels: int,
improved: bool = False, cached: bool = False,
        add_self_loops: bool = True, normalize: bool = True,
        bias: bool = True, **kwargs):    

        self.lin = Linear(in_channels, out_channels, bias=False,
                         weight_initializer='glorot')
    def forward(self, x: Tensor, edge_index: Adj,
                edge_weight: OptTensor = None) -> Tensor:

if self.normalize:  #1
    if isinstance(edge_index, Tensor):
        cache = self._cached_edge_index
            if cache is None:
                edge_index, edge_weight = gcn_norm( 
                edge_index, edge_weight, x.size(self.node_dim),
                self.improved, self.add_self_loops)
                if self.cached:
                    self._cached_edge_index = (edge_index, edge_weight)
                else:
                    edge_index, edge_weight = cache[0], cache[1]

        x = self.lin(x)  #2

        out = self.propagate(edge_index, x=x,\
 edge_weight=edge_weight, size=None)  #3

        if self.bias is not None:  #4
            out += self.bias

        return out

#1 前向传播函数根据输入图是张量还是稀疏张量,执行对称归一化。这里包含了张量输入的源代码。
#2 节点特征矩阵的线性变换
#3 信息传播
#4 输出的可选加性偏置

3.4.5 谱卷积与空间卷积

在上一节中,我们讨论了两种解释卷积的方法:(1)通过将窗口滤波器滑过一个图的局部邻域,进行思维实验;(2)通过滤波器处理图信号数据。我们还讨论了这两种解释如何突出了卷积 GNN 的两种分支:空间方法和谱方法。滑动窗口和其他空间方法依赖于图的几何结构来执行卷积。而谱方法则使用图信号滤波器。

谱方法和空间方法之间没有明确的界限,通常可以将一种类型解释为另一种。例如,GCN 的一个贡献是展示了其谱推导可以以空间的方式进行解释。然而,截止到写作时,空间方法更为常见,因为它们的限制较少,通常计算复杂度较低。我们在表 3.9 中突出显示了谱方法和空间方法的其他方面。

表 3.9 谱卷积和空间卷积方法的比较

谱方法空间方法
操作:使用图的特征值进行卷积操作:在节点邻域内聚合节点特征
必须是无向图操作依赖于节点特征
操作通常计算效率较低不需要是无向图
操作不依赖于节点特征操作通常计算效率较高

3.4.6 GraphSAGE 聚合函数

GraphSAGE 通过限制聚合操作中使用的邻居节点的数量,改进了 GCN 的计算成本。相反,GraphSAGE 从邻域中随机选择一个子集进行聚合。聚合操作符更加灵活(例如,它可以是求和或平均),但考虑的消息现在只是所有消息的一个子集。数学上,我们可以将其表示为:

image.png

其中 Ɐu ϵ S 表示邻域是从总邻域的随机样本 S 中挑选出来的。根据 GraphSAGE 论文 [2],我们得到了通用的嵌入更新过程,该过程在论文中作为算法 1 介绍,这里在图 3.17 中进行了复现。

image.png

这个算法的基本步骤可以描述如下: 对于每一层/迭代和每个节点:

  1. 聚合邻居的嵌入。
  2. 将邻居的嵌入与中心节点的嵌入连接起来。
  3. 将连接后的结果与权重矩阵进行矩阵乘法。
  4. 将结果与激活函数相乘。
  5. 应用归一化。
  6. 使用节点嵌入 h 更新节点特征 z。

让我们更详细地看看这对信息传递步骤意味着什么。我们为 GraphSAGE 定义的信息传递如下:

image.png

如果我们选择均值作为聚合函数,则变为:

image.png

对于实现,我们可以进一步简化为:

image.png

其中 x'i 表示生成的中心节点嵌入,xi 和 xj 分别是中心节点和邻居节点的输入特征。权重矩阵应用于中心节点和邻居节点,如图 3.18 所示,但只有邻居节点有聚合操作符(在此情况下为均值)。

image.png

我们现在已经了解了 GraphSAGE 算法的所有主要特性。接下来,我们将看看如何在 PyG 中实现这一点。

3.4.7 PyTorch Geometric 中的 GraphSAGE

在表 3.10 中,我们拆解了关键操作,并指出它们在 PyG 的 GraphSAGE 类中的位置。关键操作包括邻居嵌入的聚合、将节点的邻居嵌入与该节点的嵌入连接、将权重与连接后的结果相乘,以及应用激活函数。

表 3.10 映射 GCN 嵌入公式中的关键计算操作

操作函数/方法
聚合邻居的嵌入(求和、均值或其他)SAGEConv.message_and_aggregate
将邻居的嵌入与中心节点的嵌入连接SAGEConv.forward
将连接后的结果与权重矩阵相乘SAGEConv.message_and_aggregate
应用激活函数如果 project 参数设置为 True,在 SAGEConv.forward 中完成
应用归一化SAGEConv.forward

对于 GraphSAGE,PyG 还提供了实现此层的源代码,代码片段如下所示。

列表 3.11 GraphSAGE 类

class SAGEConv(MessagePassing):
    …
    def forward(self, x, edge_index, size):
        if isinstance(x, Tensor):
            x: OptPairTensor = (x, x)

        if self.project and hasattr(self, 'lin'):  #1
            x = (self.lin(x[0]).relu(), x[1])

        out = self.propagate(edge_index, x=x, size=size)  #2
        out = self.lin_l(out)  #2
        x_r = x[1]  #3

        if self.root_weight and x_r is not None:  #4
            out += self.lin_r(x_r)  #4

        if self.normalize:  #5
            out = F.normalize(out, p=2., dim=-1)  #5
        return out

    def message(self, x_j):
        return x_j

    def message_and_aggregate(self, adj_t, x):
        adj_t = adj_t.set_value(None, layout=None)  #6
        return matmul(adj_t, x[0], reduce=self.aggr)  #6

#1 如果 project 参数设置为 True,这会对邻居节点应用线性变换并使用激活函数(此处为 ReLU)。
#2 传播消息并应用线性变换。
#3 将根节点分配给一个变量。
#4 如果 root_weight 参数设置为 True 且存在根节点,这将把变换后的根节点特征添加到输出中(连接)。
#5 如果 normalize 参数设置为 True,将对输出特征应用 L2 归一化。
#6 执行带有聚合的矩阵乘法。设置 aggr 参数可以确定聚合方案(如均值、最大值、LSTM;默认是加法)。adj_t 是输入的稀疏矩阵表示,使用这种表示可以加速计算。

3.5 亚马逊产品数据集

在本章和第 5 章中,我们使用了亚马逊产品数据集 [9]。该数据集探索了产品关系,特别是共同购买,即在同一交易中购买的产品。这些共同购买数据是一个很好的数据集,用于基准测试预测节点和边的方法。我们在本节中将提供关于数据集的更多信息。

为了说明共同购买的概念,在图 3.19 中,我们展示了一个在线客户的六个共同购买示例图片。对于每个产品,我们包含了图片、普通文本的产品标签和加粗的类别标签。

image.png

这些共同购买群组中的一些似乎很合理,比如图书购买或服装购买。其他一些共同购买则较难解释,比如苹果 iPod 与速食餐一起购买,或豆子与无线扬声器一起购买。在这些不太明显的群组中,可能存在某种潜在的产品关系,或者也许仅仅是巧合。大规模分析这些数据可能会提供一些线索。

为了展示共同购买图在小规模下的样子,图 3.20 取了前一张图中的一个示例,并将产品表示为节点,节点之间的边表示每次共同购买。对于一个客户和一次购买,这是一个小图,仅有四个节点和六条边。但对于同一客户在一段时间内,或者对于拥有相同食物偏好的更大一批客户,甚至所有客户,可以想象随着更多产品和从这些产品衍生出来的产品连接,这个图将如何扩展。

构建这个数据集本身就是一段漫长的旅程,涉及到图构建以及在此过程中需要做出的决策,以获得有意义且有用的数据集。简而言之,这个数据集来源于亚马逊的购买日志数据,直接显示了共同购买数据,以及来自产品评论的文本数据,用于间接展示产品关系。(关于详细内容,参见 [8])。

image.png

为了探索产品关系,我们可以使用亚马逊产品共同购买图,这是一个包含在同一交易中一起购买的产品数据集(定义为共同购买)。在这个数据集中,产品通过节点表示,节点包含购买的产品类型,即类别标签以及一些特征信息。特征信息通过对产品描述应用自然语言处理(NLP)方法——词袋算法,将字符串转换为数值。然后,为了将其转换为相同的固定长度,数据集的创建者使用主成分分析(PCA)将其转换为长度为 100 的向量。

同时,共同购买通过边表示,指的是一起购买的两个产品。该数据集(ogbn-products)总共有 250 万个节点(产品)和 6190 万条边(共同购买)。该数据集通过开放图基准(OGB)数据集提供,如本章开头所提,并且获得了亚马逊的使用许可。每个节点有 100 个特征。共有 47 个类别作为分类任务中的目标。我们注意到这里的边是无向且无权重的。

在图 3.21 中,我们可以看到节点数量最多的类别是图书(668,950 个节点)、CD 和黑胶唱片(172,199 个节点)以及玩具和游戏(158,771 个节点)。数量最少的是家具和装饰(9 个节点)、数字音乐(6 个节点)以及一个未知类别(#508510),仅有 1 个节点。

image.png

我们还观察到,许多类别在数据集中占比非常低。每个标签/类别的节点平均数量为 52,107;中位数为 3,653。这突出了数据集中存在强烈的类别不平衡。这对于典型的表格数据结果可能构成挑战。

在本章中,我们探讨了图卷积网络(GCNs)和 GraphSAGE 的基础知识,这两种强大的架构可以用于图结构数据的学习。我们将这些模型应用于一个实际的产品分类问题,使用亚马逊产品数据集,展示了如何实现、训练和优化 GNNs。我们还深入探讨了这些模型的理论基础,研究了邻域聚合、信息传递等概念,以及谱卷积和空间卷积方法之间的区别。通过结合实际操作与理论见解,本章为理解和应用卷积 GNNs 解决实际图学习任务提供了全面的基础。在下一章中,我们将研究一种特殊的卷积 GNN,使用注意力机制的图注意力网络(GAT)。

总结

  • GCNs 和 GraphSAGE 是使用卷积的 GNNs,分别通过空间方法和谱方法进行卷积。
  • 这些 GNNs 可用于监督学习和半监督学习问题。我们将它们应用于预测产品类别的半监督问题。
  • 亚马逊产品数据集(ogbn-products)由一组通过同一交易购买(共同购买)连接的产品(节点)组成。每个产品节点都有一组特征,包括其产品类别。这个数据集是图分类问题的一个流行基准。我们还可以研究它是如何构建的,从中获得图创建方法的见解。
  • 基于领域知识选择子图或使用图采样技术,可以确保使用更有意义的数据进行训练。这可以通过聚焦于图的相关部分来提高模型的表现。
  • 不同的聚合方法(如均值、最大值和求和)对模型表现有不同的影响。实验多种聚合策略有助于捕捉图数据的各种属性,可能会提升模型性能。
  • 探索更复杂的聚合函数或针对数据集特定特征定制的自定义聚合可以带来性能提升。例如,SoftmaxAggregation 和 StdAggregation。
  • GNNs 的深度类似于跳数或信息传递步骤的数量。虽然深层模型理论上可以捕捉更复杂的模式,但它们通常会遭遇过度平滑问题,导致节点特征变得过于相似,难以区分不同的节点。
  • 对不同聚合方法和模型配置的实证测试至关重要。实验有助于确定哪些方法最好地捕捉数据集的关系动态和特征分布。