原文:annas-archive.org/md5/be12ac19810306fa2043558635436763

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:从异质图中学习

在上一章中,我们尝试生成包含不同类型节点(原子)和边(键)的真实分子。我们在其他应用中也观察到了这种行为,比如推荐系统(用户和物品)、社交网络(关注者和被关注者)、或者网络安全(路由器和服务器)。我们称这些图为异质图,与只涉及一种类型的节点和一种类型的边的同质图相对。

在本章中,我们将回顾关于同质 GNN 的所有知识。我们将引入消息传递神经网络框架,以概括迄今为止我们所看到的架构。这个总结将帮助我们理解如何扩展我们的框架到异质网络。我们将从创建我们自己的异质数据集开始。然后,我们将同质架构转化为异质架构。

在最后一节中,我们将采取不同的方法,讨论一种专门为处理异质网络设计的架构。我们将描述它的工作原理,以更好地理解这种架构与经典 GAT 之间的差异。最后,我们将在 PyTorch Geometric 中实现它,并将我们的结果与前面的方法进行比较。

在本章结束时,您将深入理解同质图与异质图之间的差异。您将能够创建自己的异质数据集,并将传统模型转换为适用于此情境的模型。您还将能够实现专门为最大化异质网络优势而设计的架构。

在本章中,我们将讨论以下主要内容:

  • 消息传递神经网络框架

  • 引入异质图

  • 将同质 GNN 转换为异质 GNN

  • 实现分层自注意力网络

技术要求

本章中的所有代码示例可以在 GitHub 上找到,网址是 github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter12

在本书的前言中,您可以找到在本地机器上运行代码所需的安装步骤。

消息传递神经网络框架

在探索异质图之前,让我们回顾一下我们对同质 GNN 的了解。在前几章中,我们看到了一些用于聚合和组合来自不同节点的特征的不同函数。如在 第五章中所示,最简单的 GNN 层由对邻近节点(包括目标节点本身)特征的线性组合求和,并使用权重矩阵。前述求和的输出会替代之前的目标节点嵌入。

节点级别的操作符可以写作如下:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_001.jpg

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_002.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_003.png 节点的邻居节点集合(包括自身),https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_004.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_005.png 节点的嵌入,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_006.png 是一个权重矩阵:

GCN 和 GAT 层为节点特征添加了固定和动态权重,但保持了相同的思想。即便是 GraphSAGE 的 LSTM 操作符或 GIN 的最大聚合器,也没有改变 GNN 层的主要概念。如果我们查看这些变种,可以将 GNN 层概括为一个通用框架,称为 消息传递神经网络MPNNMP-GNN)。这个框架由 Gilmer 等人于 2017 年提出[1],它包含三个主要操作:

  • Message:每个节点使用一个函数为每个邻居创建一个消息。这个消息可以简单地由它自己的特征组成(如前面的例子),也可以考虑邻居节点的特征和边的特征。

  • Aggregate:每个节点使用一个置换等变函数(如前面的例子中的求和)来聚合来自邻居的消息。

  • Update:每个节点使用一个函数更新其特征,将其当前特征与聚合的消息结合。在前面的例子中,我们引入了自环来聚合 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_007.png 节点的当前特征,就像一个邻居一样。

这些步骤可以总结为一个公式:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_008.jpg

这里,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_009.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_010.png 节点的嵌入,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_011.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_012.png 链接的边嵌入,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_013.png 是消息函数,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_014.png 是聚合函数,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_015.png 是更新函数。你可以在下图中找到这个框架的示意图:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_12_001.jpg

图 12.1 – MPNN 框架

PyTorch Geometric 直接通过 MessagePassing 类实现了这个框架。例如,以下是如何使用这个类实现 GCN 层:

  1. 首先,我们导入所需的库:

    import torch
    from torch.nn import Linear
    from torch_geometric.nn import MessagePassing
    from torch_geometric.utils import add_self_loops, degree
    
  2. 我们声明继承自 MessagePassing 的 GCN 类:

    class GCNConv(MessagePassing):
    
  3. 这需要两个参数 – 输入的维度和输出(隐藏)维度。MessagePassing 被初始化为“加法”聚合。我们定义了一个没有偏置的单个 PyTorch 线性层:

        def __init__(self, dim_in, dim_h):
            super().__init__(aggr='add')
            self.linear = Linear(dim_in, dim_h, bias=False)
    
  4. forward() 函数包含了逻辑。首先,我们向邻接矩阵中添加自环以考虑目标节点:

        def forward(self, x, edge_index):
            edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))
    
  5. 然后,我们使用之前定义的线性层进行线性变换:

            x = self.linear(x)
    
  6. 我们计算归一化因子 – https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_016.png

            row, col = edge_index
            deg = degree(col, x.size(0), dtype=x.dtype)
            deg_inv_sqrt = deg.pow(-0.5)
            deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0
            norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]
    
  7. 我们调用propagate()方法,传入更新后的edge_index(包括自环)和存储在norm张量中的归一化因子。内部,该方法会调用message()aggregate()update()。我们不需要重新定义update(),因为我们已经包括了自环。aggregate()函数已在步骤 3中通过aggr='add'进行指定:

            out = self.propagate(edge_index, x=x, norm=norm)
            return out
    
  8. 我们重新定义了message()函数,以便使用norm对邻接节点特征x进行归一化:

        def message(self, x, norm):
            return norm.view(-1, 1) * x
    
  9. 现在我们可以初始化并使用这个对象作为 GCN 层:

    conv = GCNConv(16, 32)
    

这个示例展示了如何在 PyTorch Geometric 中创建自己的 GNN 层。你还可以查看 GCN 或 GAT 层在源代码中的实现方式。

MPNN 框架是一个重要的概念,它将帮助我们将 GNN 转化为异构模型。

引入异构图

异构图是一个强大的工具,可以表示不同实体之间的一般关系。拥有不同类型的节点和边创建了更复杂的图结构,但同时也更难以学习。特别是,异构网络的一个主要问题是,来自不同类型的节点或边的特征未必具有相同的含义或维度。因此,合并不同特征会破坏大量信息。而在同质图中,每个维度对每个节点或边都有相同的含义。

异构图是一种更通用的网络,可以表示不同类型的节点和边。形式上,它被定义为一个图,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_017.png,包含https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_018.png,一组节点,和https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_019.png,一组边。在异构设置中,它与一个节点类型映射函数https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_020.png相关联(其中https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_021.png表示节点类型的集合),以及一个链接类型映射函数https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_022.png(其中https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_023.png表示边类型的集合)。

以下图为异构图的示例。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_12_002.jpg

图 12.2 – 一个包含三种节点类型和三种边类型的异构图示例

在这个图中,我们看到三种类型的节点(用户、游戏和开发者)和三种类型的边(关注开发)。它表示一个涉及人(用户和开发者)和游戏的网络,可以用于各种应用,例如游戏推荐。如果这个图包含数百万个元素,它可以用作图结构化的知识数据库,或者知识图谱。知识图谱被 Google 或 Bing 用于回答类似“谁玩Dev 1开发的游戏?”这样的问题。

类似的查询可以提取有用的同质图。例如,我们可能只想考虑玩Game 1的用户。输出将是User 1User 2。我们还可以创建更复杂的查询,例如,“谁是玩由Dev 1开发的游戏的用户?”结果是一样的,但我们通过两种关系来获取我们的用户。这种查询被称为元路径(meta-path)。

在第一个例子中,我们的元路径是User → Game → User(通常表示为UGU),在第二个例子中,我们的元路径是User → Game → Dev → Game → User(或UGDGU)。请注意,起始节点类型和终止节点类型是相同的。元路径是异质图中的一个重要概念,常用于度量不同节点之间的相似性。

现在,让我们看看如何使用 PyTorch Geometric 实现前面的图。我们将使用一个叫做HeteroData的特殊数据对象。以下步骤创建一个数据对象,用于存储图 12.2中的图:

  1. 我们从torch_geometric.data导入HeteroData类,并创建一个data变量:

    from torch_geometric.data import HeteroData
    data = HeteroData()
    
  2. 首先,我们来存储节点特征。我们可以访问用户特征,例如data['user'].x。我们将其传入一个具有[num_users, num_features_users]维度的张量。内容在这个例子中不重要,因此我们为user 1创建填充了 1 的特征向量,为user 2创建填充了 2 的特征向量,为user 3创建填充了 3 的特征向量:

    data['user'].x = torch.Tensor([[1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3]])
    
  3. 我们使用gamesdevs重复这个过程。请注意,特征向量的维度不相同;这是异质图在处理不同表示时的一个重要优势:

    data['game'].x = torch.Tensor([[1, 1], [2, 2]])
    data['dev'].x = torch.Tensor([[1], [2]])
    
  4. 让我们在节点之间创建连接。连接具有不同的含义,因此我们将创建三组边的索引。我们可以通过一个三元组声明每一组!,例如data['user', 'follows', 'user'].edge_index。然后,我们将连接存储在一个具有[2, number of edges]维度的张量中:

    data['user', 'follows', 'user'].edge_index = torch.Tensor([[0, 1], [1, 2]]) # [2, num_edges_follows]
    data['user', 'plays', 'game'].edge_index = torch.Tensor([[0, 1, 1, 2], [0, 0, 1, 1]])
    data['dev', 'develops', 'game'].edge_index = torch.Tensor([[0, 1], [0, 1]])
    
  5. 边也可以具有特征——例如,plays边可以包括用户玩对应游戏的时长。下面假设user 1玩了game 1 2 小时,user 2玩了game 1 30 分钟,game 2玩了 10 小时,user 3玩了game 2 12 小时:

    data['user', 'plays', 'game'].edge_attr = torch.Tensor([[2], [0.5], [10], [12]])
    
  6. 最后,我们可以打印data对象以查看结果:

    HeteroData(
      user={ x=[3, 4] },
      game={ x=[2, 2] },
      dev={ x=[2, 1] },
      (user, follows, user)={ edge_index=[2, 2] },
      (user, plays, game)={
        edge_index=[2, 4],
        edge_attr=[4, 1]
      },
      (dev, develops, game)={ edge_index=[2, 2] }
    )
    

如你所见,在这个实现中,不同类型的节点和边并不共享相同的张量。实际上,这是不可能的,因为它们的维度也不同。这引出了一个新问题——我们如何使用 GNN 从多个张量中聚合信息?

到目前为止,我们只关注单一类型。实际上,我们的权重矩阵已经具有适当的大小,可以与预定义维度相乘。但是,当我们得到不同维度的输入时,如何实现 GNN?

将同质 GNN 转化为异质 GNN

为了更好地理解问题,让我们以真实数据集为例。DBLP 计算机科学文献提供了一个数据集,[2-3],包含四种类型的节点 – 论文(14,328),术语(7,723),作者(4,057),和会议(20)。该数据集的目标是将作者正确分类为四个类别 – 数据库、数据挖掘、人工智能和信息检索。作者的节点特征是一组词袋(“0”或“1”),包含他们在出版物中可能使用的 334 个关键字。以下图表总结了不同节点类型之间的关系。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_12_003.jpg

图 12.3 – DBLP 数据集中节点类型之间的关系

这些节点类型的维度和语义关系并不相同。在异构图中,节点之间的关系至关重要,这就是为什么我们要考虑节点对。例如,我们不再将作者节点直接馈送到 GNN 层,而是考虑一对,如(作者, 论文)。这意味着现在我们需要根据每一种关系创建一个 GNN 层;在这种情况下,“to”关系是双向的,所以我们会得到六层。

这些新层具有独立的权重矩阵,适合每种节点类型的大小。不幸的是,我们只解决了问题的一半。实际上,现在我们有六个不同的层,它们不共享任何信息。我们可以通过引入跳跃连接共享层跳跃知识等方法来解决这个问题 [4]。

在将同质模型转换为异构模型之前,让我们在 DBLP 数据集上实现一个经典的 GAT。GAT 不能考虑不同的关系;我们必须为它提供一个连接作者的唯一邻接矩阵。幸运的是,我们现在有一种技术可以轻松生成这个邻接矩阵 – 我们可以创建一个元路径,如作者-论文-作者,它将连接同一篇论文中的作者。

注意

通过随机游走,我们还可以构建一个良好的邻接矩阵。即使图形是异构的,我们也可以探索并连接在相同序列中经常出现的节点。

代码有点冗长,但我们可以按以下方式实现常规的 GAT:

  1. 我们导入所需的库:

    from torch import nn
    import torch.nn.functional as F
    import torch_geometric.transforms as T
    from torch_geometric.datasets import DBLP
    from torch_geometric.nn import GAT
    
  2. 我们使用特定语法定义我们将使用的元路径:

    metapaths = [[('author', 'paper'), ('paper', 'author')]]
    
  3. 我们使用AddMetaPaths转换函数自动计算我们的元路径。我们使用drop_orig_edge_types=True从数据集中删除其他关系(GAT 只能考虑一个):

    transform = T.AddMetaPaths(metapaths=metapaths, drop_orig_edge_types=True)
    
  4. 我们加载DBLP数据集并打印它:

    dataset = DBLP('.', transform=transform)
    data = dataset[0]
    print(data)
    
  5. 我们获得了以下输出。请注意我们的转换函数创建的(作者, metapath_0, 作者)关系:

    HeteroData(
      metapath_dict={ (author, metapath_0, author)=[2] },
      author={
        x=[4057, 334],
        y=[4057],
        train_mask=[4057],
        val_mask=[4057],
        test_mask=[4057]
      },
      paper={ x=[14328, 4231] },
      term={ x=[7723, 50] },
      conference={ num_nodes=20 },
      (author, metapath_0, author)={ edge_index=[2, 11113] }
    )
    
  6. 我们直接创建一个带有in_channels=-1的单层 GAT 模型来进行惰性初始化(模型将自动计算值),并且out_channels=4,因为我们需要将作者节点分类为四类:

    model = GAT(in_channels=-1, hidden_channels=64, out_channels=4, num_layers=1)
    
  7. 我们实现了Adam优化器,并将模型和数据存储在 GPU 上(如果可能的话):

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  8. test()函数衡量预测的准确性:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')]).argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  9. 我们创建了一个经典的训练循环,在这个循环中,节点特征(author)和边索引(authormetapath_0author)经过精心选择:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict['author'], data.edge_index_dict[('author', 'metapath_0', 'author')])
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  10. 我们在测试集上进行测试,输出如下:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 73.29%
    

我们通过元路径将异构数据集转化为同构数据集,并应用了传统的 GAT。我们得到了 73.29%的测试准确率,这为与其他技术的比较提供了一个良好的基准。

现在,让我们创建一个异构版本的 GAT 模型。按照我们之前描述的方法,我们需要六个 GAT 层,而不是一个。我们不必手动完成这项工作,因为 PyTorch Geometric 可以通过to_hetero()to_hetero_bases()函数自动完成。to_hetero()函数有三个重要参数:

  • module:我们想要转换的同构模型

  • metadata:关于图的异构性质的信息,通过元组表示,(``node_types, edge_types)

  • aggr:用于合并由不同关系生成的节点嵌入的聚合器(例如,summaxmean

下图展示了我们的同构 GAT(左)及其异构版本(右),是通过to_hetero()获得的。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_12_004.jpg

图 12.4 – 同构 GAT(左)和异构 GAT(右)在 DBLP 数据集上的架构

如以下步骤所示,异构 GAT 的实现类似:

  1. 首先,我们从 PyTorch Geometric 导入 GNN 层:

    from torch_geometric.nn import GATConv, Linear, to_hetero
    
  2. 我们加载了DBLP数据集:

    dataset = DBLP(root='.')
    data = dataset[0]
    
  3. 当我们打印出这个数据集的信息时,您可能已经注意到会议节点没有任何特征。这是一个问题,因为我们的架构假设每种节点类型都有自己的特征。我们可以通过生成零值作为特征来解决这个问题,方法如下:

    data['conference'].x = torch.zeros(20, 1)
    
  4. 我们创建了自己的 GAT 类,包含 GAT 和线性层。请注意,我们再次使用懒初始化,采用(-1, -1)元组:

    class GAT(torch.nn.Module):
        def __init__(self, dim_h, dim_out):
            super().__init__()
            self.conv = GATConv((-1, -1), dim_h, add_self_loops=False)
            self.linear = nn.Linear(dim_h, dim_out)
        def forward(self, x, edge_index):
            h = self.conv(x, edge_index).relu()
            h = self.linear(h)
            return h
    
  5. 我们实例化模型并通过to_hetero()进行转换:

    model = GAT(dim_h=64, dim_out=4)
    model = to_hetero(model, data.metadata(), aggr='sum')
    
  6. 我们实现了Adam优化器,并将模型和数据存储在 GPU 上(如果可能的话):

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  7. 测试过程非常相似。这次,我们不需要指定任何关系,因为模型会考虑所有关系:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict, data.edge_index_dict)['author'].argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  8. 对训练循环来说也是如此:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict, data.edge_index_dict)['author']
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  9. 我们获得了以下的测试准确率:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 78.42%
    

异构 GAT 获得了 78.42%的测试准确率。这比同构版本提高了 5.13%,但我们能做得更好吗?在接下来的部分,我们将探讨一种专门设计用于处理异构网络的架构。

实现一个层次化自注意力网络

在本节中,我们将实现一个设计用于处理异构图的 GNN 模型——层次自注意力网络HAN)。该架构由 Liu 等人于 2021 年提出[5]。HAN 在两个不同的层次上使用自注意力机制:

  • 节点级别注意力用于理解在给定元路径中邻近节点的重要性(如同质设置下的 GAT)。

  • 在某些任务中,game-user-game 可能比 game-dev-game 更相关,例如预测玩家数量。

在接下来的部分中,我们将详细介绍三个主要组件——节点级别注意力、语义级别注意力和预测模块。该架构如图 12.5所示。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_12_005.jpg

图 12.5 – HAN 的架构及其三个主要模块

类似于 GAT,第一步是将节点映射到每个元路径的统一特征空间。接着,我们使用第二个权重矩阵计算在同一元路径中节点对(两个投影节点的连接)的权重。然后对该结果应用非线性函数,并通过 softmax 函数进行归一化。归一化后的注意力分数(重要性)表示为从https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_025.png节点到https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_026.png节点的计算方式如下:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_027.jpg

在这里,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_028.png表示https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_029.png节点的特征,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_030.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_031.png元路径的共享权重矩阵,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_032.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_033.png元路径的注意力权重矩阵,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_034.png是非线性激活函数(如 LeakyReLU),而https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_035.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_036.png节点在https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_031.png元路径中的邻居集合(包括其自身)。

也会执行多头注意力机制以获得最终的嵌入表示:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_038.jpg

通过语义级别注意力,我们对每个元路径的注意力分数(记作https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_039.png)执行类似的处理。每个给定元路径中的节点嵌入(记作https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_040.png)输入到 MLP 中,MLP 应用非线性变换。我们将此结果与一个新的注意力向量https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_041.png进行比较,作为相似度度量。然后我们对该结果求平均,以计算给定元路径的重要性:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_042.jpg

在这里,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_043.png(MLP 的权重矩阵)、https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_044.png(MLP 的偏置)和https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_045.png(语义级别的注意力向量)在所有元路径之间共享。

我们必须对这个结果进行归一化,以比较不同的语义级注意力分数。我们使用 softmax 函数来获取我们的最终权重:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_046.jpg

结合节点级和语义级注意力的最终嵌入,如下所示:https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_047.png

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_12_048.jpg

最后一层,例如 MLP,用于微调模型以完成特定的下游任务,如节点分类或链接预测。

让我们在 PyTorch Geometric 上的 DBLP 数据集中实现这个架构:

  1. 首先,我们导入 HAN 层:

    from torch_geometric.nn import HANConv
    
  2. 我们加载 DBLP 数据集并为会议节点引入虚拟特征:

    dataset = DBLP('.')
    data = dataset[0]
    data['conference'].x = torch.zeros(20, 1)
    
  3. 我们创建了 HAN 类,包括两层 - 使用 HANConvHAN 卷积和用于最终分类的 linear 层:

    class HAN(nn.Module):
        def __init__(self, dim_in, dim_out, dim_h=128, heads=8):
            super().__init__()
            self.han = HANConv(dim_in, dim_h, heads=heads, dropout=0.6, metadata=data.metadata())
            self.linear = nn.Linear(dim_h, dim_out)
    
  4. forward() 函数中,我们必须指定我们对作者感兴趣:

        def forward(self, x_dict, edge_index_dict):
            out = self.han(x_dict, edge_index_dict)
            out = self.linear(out['author'])
            return out
    
  5. 我们使用懒初始化(dim_in=-1)初始化我们的模型,因此 PyTorch Geometric 自动计算每个节点类型的输入尺寸:

    model = HAN(dim_in=-1, dim_out=4)
    
  6. 我们选择了 Adam 优化器,并在可能的情况下将数据和模型转移到 GPU:

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.001)
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    data, model = data.to(device), model.to(device)
    
  7. test() 函数计算分类任务的准确率:

    @torch.no_grad()
    def test(mask):
        model.eval()
        pred = model(data.x_dict, data.edge_index_dict).argmax(dim=-1)
        acc = (pred[mask] == data['author'].y[mask]).sum() / mask.sum()
        return float(acc)
    
  8. 我们将模型训练了 100 个 epochs。与同质 GNN 的训练循环唯一的区别在于,我们需要指定我们对作者节点类型感兴趣:

    for epoch in range(101):
        model.train()
        optimizer.zero_grad()
        out = model(data.x_dict, data.edge_index_dict)
        mask = data['author'].train_mask
        loss = F.cross_entropy(out[mask], data['author'].y[mask])
        loss.backward()
        optimizer.step()
        if epoch % 20 == 0:
            train_acc = test(data['author'].train_mask)
            val_acc = test(data['author'].val_mask)
            print(f'Epoch: {epoch:>3} | Train Loss: {loss:.4f} | Train Acc: {train_acc*100:.2f}% | Val Acc: {val_acc*100:.2f}%')
    
  9. 训练给出了以下输出:

    Epoch:   0 | Train Loss: 1.3829 | Train Acc: 49.75% | Val Acc: 37.75%
    Epoch:  20 | Train Loss: 1.1551 | Train Acc: 86.50% | Val Acc: 60.75%
    Epoch:  40 | Train Loss: 0.7695 | Train Acc: 94.00% | Val Acc: 67.50%
    Epoch:  60 | Train Loss: 0.4750 | Train Acc: 97.75% | Val Acc: 73.75%
    Epoch:  80 | Train Loss: 0.3008 | Train Acc: 99.25% | Val Acc: 78.25%
    Epoch: 100 | Train Loss: 0.2247 | Train Acc: 99.50% | Val Acc: 78.75%
    
  10. 最后,我们在测试集上测试我们的解决方案:

    test_acc = test(data['author'].test_mask)
    print(f'Test accuracy: {test_acc*100:.2f}%')
    Test accuracy: 81.58%
    

HAN 在测试中获得了 81.58% 的准确率,比我们在异质 GAT(78.42%)和经典 GAT(73.29%)中获得的要高。这显示了构建良好的表示形式以聚合不同类型的节点和关系的重要性。异质图技术高度依赖于应用程序,但尝试不同的选项是值得的,特别是当网络中描述的关系具有意义时。

摘要

在本章中,我们介绍了 MPNN 框架,以三个步骤 - 消息、聚合和更新,来推广 GNN 层。在本章的其余部分,我们扩展了这个框架以考虑异质网络,由不同类型的节点和边组成。这种特殊类型的图允许我们表示实体之间的各种关系,这比单一类型的连接更具见解。

此外,我们看到如何通过 PyTorch Geometric 将同质 GNN 转换为异质 GNN。我们描述了在我们的异质 GAT 中使用的不同层,这些层将节点对作为输入来建模它们的关系。最后,我们使用 HAN 实现了一种特定于异质的架构,并在 DBLP 数据集上比较了三种技术的结果。这证明了利用这种网络中所表示的异质信息的重要性。

第十三章 时间图神经网络 中,我们将看到如何在图神经网络中考虑时间。这个章节将开启很多新的应用,得益于时间图,比如交通预测。它还将介绍 PyG 的扩展库——PyTorch Geometric Temporal,帮助我们实现专门设计用于处理时间的新模型。

进一步阅读

  • [1] J. Gilmer, S. S. Schoenholz, P. F. Riley, O. Vinyals, 和 G. E. Dahl. 量子化学的神经消息传递. arXiv, 2017. DOI: 10.48550/ARXIV.1704.01212. 可用: arxiv.org/abs/1704.01212.

  • [2] Jie Tang, Jing Zhang, Limin Yao, Juanzi Li, Li Zhang, 和 Zhong Su. ArnetMiner:学术社交网络的提取与挖掘. 载于第十四届 ACM SIGKDD 国际知识发现与数据挖掘大会论文集(SIGKDD’2008). pp.990–998. 可用: dl.acm.org/doi/abs/10.1145/1401890.1402008.

  • [3] X. Fu, J. Zhang, Z. Meng, 和 I. King. MAGNN:用于异构图嵌入的元路径聚合图神经网络. 2020 年 4 月. DOI: 10.1145/3366423.3380297. 可用: arxiv.org/abs/2002.01680.

  • [4] M. Schlichtkrull, T. N. Kipf, P. Bloem, R. van den Berg, I. Titov, 和 M. Welling. 用图卷积网络建模关系数据. arXiv, 2017. DOI: 10.48550/ARXIV.1703.06103. 可用: arxiv.org/abs/1703.06103.

  • [5] J. Liu, Y. Wang, S. Xiang, 和 C. Pan. HAN:一种高效的层次化自注意力网络,用于基于骨架的手势识别. arXiv, 2021. DOI: 10.48550/ARXIV.2106.13391. 可用: arxiv.org/abs/2106.13391.

第十三章:时序图神经网络

在前面的章节中,我们仅考虑了边和特征不发生变化的图。然而,在现实世界中,有许多应用场景并非如此。例如,在社交网络中,人们会关注和取消关注其他用户,帖子会变得病毒式传播,个人资料随着时间变化。这种动态性无法通过我们之前描述的 GNN 架构来表示。相反,我们必须嵌入一个新的时间维度,将静态图转换为动态图。这些动态网络将作为新一类 GNN 的输入:时序图神经网络T-GNNs),也称为时空 GNNs

在本章中,我们将描述两种包含时空信息的动态图。我们将列出不同的应用,并重点介绍时间序列预测,在该领域中,时序 GNNs 得到了广泛应用。第二节将专注于我们之前研究的一个应用:网页流量预测。这次,我们将利用时间信息来提高结果并获得可靠的预测。最后,我们将描述另一种为动态图设计的时序 GNN 架构,并将其应用于疫情预测,预测英国不同地区的 COVID-19 病例数。

到本章结束时,您将了解两种主要类型的动态图之间的区别。这对于将数据建模为正确类型的图非常有用。此外,您将学习两种时序 GNN 的设计和架构,并了解如何使用 PyTorch Geometric Temporal 来实现它们。这是创建自己的时序信息应用程序的关键步骤。

在本章中,我们将讨论以下主要内容:

  • 引入动态图

  • 网页流量预测

  • COVID-19 病例预测

技术要求

本章的所有代码示例都可以在 GitHub 上找到,网址是github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter13

在本书的前言部分,您可以找到在本地计算机上运行代码所需的安装步骤。

引入动态图

动态图和时序 GNNs 开启了多种新的应用场景,如交通和网页流量预测、动作分类、流行病预测、链接预测、电力系统预测等。时间序列预测在这种图结构中尤为流行,因为我们可以利用历史数据来预测系统的未来行为。

在本章中,我们将重点讨论具有时间维度的图。它们可以分为两类:

  • 带有时间信号的静态图:底层图结构不变,但特征和标签随着时间推移而变化。

  • 具有时间信号的动态图:图的拓扑结构(节点和边的存在性)、特征和标签随时间演变。

在第一种情况下,图的拓扑结构是静态的。例如,它可以表示一个国家内城市的网络用于交通预测:特征随时间变化,但连接保持不变。

在第二个选项中,节点和/或连接是动态的。它有助于表示一个社交网络,其中用户之间的链接可以随时间出现或消失。这种变体更为通用,但实现起来更加困难。

在接下来的几节中,我们将看到如何使用 PyTorch Geometric Temporal 处理这两种具有时间信号的图形。

预测网络流量

在本节中,我们将使用时间 GNN 来预测维基百科文章的流量(作为具有时间信号的静态图的示例)。这种回归任务已经在第六章引入图卷积网络中进行了讨论。然而,在那个版本的任务中,我们使用静态数据集进行了流量预测,没有时间信号:我们的模型没有任何关于先前实例的信息。这是一个问题,因为它无法理解流量当前是增加还是减少,例如。现在我们可以改进这个模型,以包含关于过去实例的信息。

我们将首先介绍具有其两个变体的时间 GNN 架构,然后使用 PyTorch Geometric Temporal 实现它。

引入 EvolveGCN

对于这个任务,我们将使用EvolveGCN架构。由 Pareja 等人[1]在 2019 年提出,它提议了 GNN 和递归神经网络RNNs)的自然组合。以前的方法,如图卷积递归网络,应用 RNN 与图卷积操作符来计算节点嵌入。相比之下,EvolveGCN 将 RNN 应用于 GCN 参数本身。顾名思义,GCN 随时间演变以生成相关的时间节点嵌入。以下图展示了这个过程的高层视图。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_001.jpg

图 13.1 – EvolveGCN 的架构,用于生成具有时间信号的静态或动态图的节点嵌入

这种架构有两个变体:

  • EvolveGCN-H,其中递归神经网络考虑先前的 GCN 参数和当前的节点嵌入

  • EvolveGCN-O,其中递归神经网络仅考虑先前的 GCN 参数

EvolveGCN-H 通常使用 门控递归单元 (GRU) 代替普通的 RNN。GRU 是长短期记忆 (LSTM) 单元的简化版,能够在使用更少参数的情况下实现类似的性能。它由重置门、更新门和细胞状态组成。在这种架构中,GRU 在时间 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_002.png 时刻更新 GCN 的权重矩阵,具体过程如下:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_003.jpg

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_004.png 表示在层 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_008.png 和时间 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_006.png 生成的节点嵌入,而 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_007.png 是来自上一个时间步的层 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_0081.png 的权重矩阵。

生成的 GCN 权重矩阵随后被用来计算下一层的节点嵌入:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_009.png

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_010.png

其中,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_011.png 是包含自环的邻接矩阵,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_012.png 是包含自环的度矩阵。

这些步骤在下图中进行了总结。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_002.jpg

图 13.2 – 带有 GRU 和 GNN 的 EvolveGCN-H 架构

EvolveGCN-H 可以通过 GRU 来实现,GRU 接收两个扩展:

这些扩展对于 EvolveGCN-O 变体来说并非必需。实际上,EvolveGCN-O 基于 LSTM 网络来建模输入输出关系。我们不需要给 LSTM 提供隐藏状态,因为它已经包含了一个记忆先前值的细胞。这个机制简化了更新步骤,可以写成如下形式:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_014.jpg

生成的 GCN 权重矩阵以相同方式使用,以产生下一层的节点嵌入:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_015.jpghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_016.jpg

这个实现更为简单,因为时间维度完全依赖于一个普通的 LSTM 网络。下图展示了 EvolveGCN-O 如何更新权重矩阵 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_017.png 并计算节点嵌入 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_018.png

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_003.jpg

图 13.3 – 带有 LSTM 和 GCN 的 EvolveGCN-O 架构

那么我们应该使用哪个版本呢?正如在机器学习中常见的那样,最佳解决方案依赖于数据:

  • 当节点特征至关重要时,EvolveGCN-H 的表现更好,因为它的 RNN 明确地融合了节点嵌入。

  • 当图的结构起重要作用时,EvolveGCN-O 表现得更好,因为它更侧重于拓扑变化。

请注意,这些备注主要是理论性的,因此在您的应用程序中测试这两种变体可能会有所帮助。这正是我们通过实现这些模型来进行网络流量预测时所做的。

实现 EvolveGCN

在本节中,我们希望在带有时间信号的静态图上预测网络流量。WikiMaths数据集由 1,068 篇文章表示为节点。节点特征对应于过去每天的访问数量(默认情况下有八个特征)。边是加权的,权重表示从源页面到目标页面的链接数量。我们希望预测 2019 年 3 月 16 日至 2021 年 3 月 15 日之间这些 Wikipedia 页面的每日用户访问量,共有 731 个快照。每个快照是一个描述系统在特定时间状态的图。

图 13*.4*展示了使用 Gephi 制作的 WikiMaths 表示,其中节点的大小和颜色与其连接数成比例。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_004.jpg

图 13.4 – WikiMaths 数据集作为无权图(t=0)

PyTorch Geometric 本身不支持带有时间信号的静态或动态图。幸运的是,一个名为 PyTorch Geometric Temporal 的扩展[2]解决了这个问题,并且实现了多种时间序列 GNN 层。在 PyTorch Geometric Temporal 开发过程中,WikiMaths 数据集也被公开。在本章中,我们将使用这个库来简化代码并专注于应用:

  1. 我们需要在包含 PyTorch Geometric 的环境中安装此库:

    pip install torch-geometric-temporal==0.54.0
    
  2. 我们导入 WikiMaths 数据集,名为WikiMathDatasetLoader,带有temporal_signal_split的时间感知训练-测试划分,以及我们的 GNN 层EvolveGCNH

    from torch_geometric_temporal.signal import temporal_signal_split
    from torch_geometric_temporal.dataset import WikiMathsDatasetLoader
    from torch_geometric_temporal.nn.recurrent import EvolveGCNH
    
  3. 我们加载了 WikiMaths 数据集,这是一个StaticGraphTemporalSignal对象。在这个对象中,dataset[0]描述了时间点https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_019.png的图(在此上下文中也称为快照),而dataset[500]描述了时间点https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_020.png的图。我们还创建了一个训练集和测试集的划分,比例为0.5。训练集由较早时间段的快照组成,而测试集则重新组织了较晚时间段的快照:

    dataset = WikiMathsDatasetLoader().get_dataset() train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.5)
    dataset[0]
    Data(x=[1068, 8], edge_index=[2, 27079], edge_attr=[27079], y=[1068])
    dataset[500]
    Data(x=[1068, 8], edge_index=[2, 27079], edge_attr=[27079], y=[1068])
    
  4. 该图是静态的,因此节点和边的维度不会改变。然而,这些张量中包含的值是不同的。由于有 1,068 个节点,很难可视化每个节点的值。为了更好地理解这个数据集,我们可以计算每个快照的均值和标准差值。移动平均值也有助于平滑短期波动。

    import pandas as pd
    mean_cases = [snapshot.y.mean().item() for snapshot in dataset]
    std_cases = [snapshot.y.std().item() for snapshot in dataset]
    df = pd.DataFrame(mean_cases, columns=['mean'])
    df['std'] = pd.DataFrame(std_cases, columns=['std'])
    df['rolling'] = df['mean'].rolling(7).mean()
    
  5. 我们使用matplotlib绘制这些时间序列,以可视化我们的任务:

    plt.figure(figsize=(15,5))
    plt.plot(df['mean'], 'k-', label='Mean')
    plt.plot(df['rolling'], 'g-', label='Moving average')
    plt.grid(linestyle=':')
    plt.fill_between(df.index, df['mean']-df['std'], df['mean']+df['std'], color='r', alpha=0.1)
    plt.axvline(x=360, color='b', linestyle='--')
    plt.text(360, 1.5, 'Train/test split', rotation=-90, color='b')
    plt.xlabel('Time (days)')
    plt.ylabel('Normalized number of visits')
    plt.legend(loc='upper right')
    

这产生了图 13**.5

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_005.jpg

图 13.5 – WikiMaths 的平均归一化访问次数与移动平均

我们的数据呈现周期性模式,希望时间 GNN 能够学习到这些模式。现在我们可以实现它并看看它的表现。

  1. 时间 GNN 接收两个参数作为输入:节点数(node_count)和输入维度(dim_in)。GNN 只有两个层次:一个 EvolveGCN-H 层和一个线性层,后者输出每个节点的预测值:

    class TemporalGNN(torch.nn.Module):
        def __init__(self, node_count, dim_in):
            super().__init__()
            self.recurrent = EvolveGCNH(node_count, dim_in)
            self.linear = torch.nn.Linear(dim_in, 1)
    
  2. forward()函数将两个层应用于输入,并使用 ReLU 激活函数:

        def forward(self, x, edge_index, edge_weight):
            h = self.recurrent(x, edge_index, edge_weight).relu()
            h = self.linear(h)
            return h
    
  3. 我们创建一个TemporalGNN实例,并为其提供 WikiMaths 数据集的节点数和输入维度。我们将使用Adam优化器进行训练:

    model = TemporalGNN(dataset[0].x.shape[0], dataset[0].x.shape[1])
    optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
    model.train()
    
  4. 我们可以打印模型,以观察EvolveGCNH中包含的层:

    model
    TemporalGNN(
      (recurrent): EvolveGCNH(
        (pooling_layer): TopKPooling(8, ratio=0.00749063670411985, multiplier=1.0)
        (recurrent_layer): GRU(8, 8)
        (conv_layer): GCNConv_Fixed_W(8, 8)
      )
      (linear): Linear(in_features=8, out_features=1, bias=True)
    )
    

我们看到三个层次:TopKPooling,它将输入矩阵总结为八列;GRU,它更新 GCN 权重矩阵;以及GCNConv,它生成新的节点嵌入。最后,一个线性层输出每个节点的预测值。

  1. 我们创建一个训练循环,在训练集的每个快照上训练模型。对于每个快照,损失都会进行反向传播:

    for epoch in range(50):
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
            loss = torch.mean((y_pred-snapshot.y)**2)
            loss.backward()
            optimizer.step()
            optimizer.zero_grad()
    
  2. 同样,我们在测试集上评估模型。MSE 在整个测试集上取平均,以生成最终得分:

    model.eval()
    loss = 0
    for i, snapshot in enumerate(test_dataset):
        y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
        mse = torch.mean((y_pred-snapshot.y)**2)
        loss += mse
    loss = loss / (i+1)
    print(f'MSE = {loss.item():.4f}')
    MSE = 0.7559
    
  3. 我们得到的损失值是 0.7559。接下来,我们将绘制我们模型在之前图表上的平均预测值进行解读。过程很简单:我们需要对预测值取平均并将它们存储在一个列表中。然后,我们可以将它们添加到之前的图表中:

     y_preds = [model(snapshot.x, snapshot.edge_index, snapshot.edge_attr).squeeze().detach().numpy().mean() for snapshot in test_dataset]
    plt.figure(figsize=(10,5))
    plt.plot(df['mean'], 'k-', label='Mean')
    plt.plot(df['rolling'], 'g-', label='Moving average')
    plt.plot(range(360,722), y_preds, 'r-', label='Prediction')
    plt.grid(linestyle=':')
    plt.fill_between(df.index, df['mean']-df['std'], df['mean']+df['std'], color='r', alpha=0.1)
    plt.axvline(x=360, color='b', linestyle='--')
    plt.text(360, 1.5, 'Train/test split', rotation=-90, color='b')
    plt.xlabel('Time (days)')
    plt.ylabel('Normalized number of visits')
    plt.legend(loc='upper right')
    

这给我们带来了图 13**.6

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_006.jpg

图 13.6 – 预测的平均归一化访问次数

我们可以看到预测值遵循数据中的一般趋势。考虑到数据集的规模有限,这是一个很好的结果。

  1. 最后,让我们创建一个散点图,展示预测值和真实值在单一快照中的差异:

    import seaborn as sns
    y_pred = model(test_dataset[0].x, test_dataset[0].edge_index, test_dataset[0].edge_attr).detach().squeeze().numpy()
    plt.figure(figsize=(10,5))
    sns.regplot(x=test_dataset[0].y.numpy(), y=y_pred)
    

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_007.jpg

图 13.7 – WikiMaths 数据集的预测值与真实值对比

我们观察到预测值与真实值之间存在适度的正相关关系。我们的模型虽然没有特别准确,但前面的图表显示它很好地理解了数据的周期性特征。

实现 EvolveGCN-O 变体非常相似。我们不使用 PyTorch Geometric Temporal 中的EvolveGCNH层,而是将其替换为EvolveGCNO。这个层不需要节点数,因此我们只提供输入维度。实现如下:

from torch_geometric_temporal.nn.recurrent import EvolveGCNO
class TemporalGNN(torch.nn.Module):
    def __init__(self, dim_in):
        super().__init__()
        self.recurrent = EvolveGCNO(dim_in, 1)
        self.linear = torch.nn.Linear(dim_in, 1)
    def forward(self, x, edge_index, edge_weight):
        h = self.recurrent(x, edge_index, edge_weight).relu()
        h = self.linear(h)
        return h
model = TemporalGNN(dataset[0].x.shape[1])

平均来说,EvolveGCN-O 模型的结果相似,平均 MSE 为 0.7524。在这种情况下,使用 GRU 或 LSTM 网络不会影响预测。这是可以理解的,因为节点特征(EvolveGCN-H)中包含的过去访问次数和页面之间的连接(EvolveGCN-O)都至关重要。因此,这种 GNN 架构特别适用于此交通预测任务。

现在我们已经看到了一个静态图的例子,让我们来探讨如何处理动态图。

预测 COVID-19 病例

本节将重点介绍一个新的应用——流行病预测。我们将使用英格兰 Covid 数据集,这是一个带有时间信息的动态图,由 Panagopoulos 等人于 2021 年提出[3]。尽管节点是静态的,但节点之间的连接和边的权重随时间变化。该数据集表示 2020 年 3 月 3 日至 5 月 12 日间,英格兰 129 个 NUTS 3 地区报告的 COVID-19 病例数。数据来源于安装了 Facebook 应用并共享其位置历史的手机。我们的目标是预测每个节点(地区)一天内的病例数。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_008.jpg

图 13.8 – 英格兰的 NUTS 3 区域以红色标出

该数据集将英格兰表示为一个图!。由于该数据集具有时间性,因此它由多个图组成,每个图对应研究期间的每一天!。在这些图中,节点特征表示该地区过去!天的病例数。边是单向的并且加权:边!的权重!表示在时间!从区域!到区域!的人数。这些图还包含自环,表示在同一地区内移动的人。

本节将介绍一种专门为此任务设计的新型 GNN 架构,并展示如何一步步实现它。

介绍 MPNN-LSTM

如其名所示,MPNN-LSTM架构依赖于将 MPNN 和 LSTM 网络相结合。像英格兰 Covid 数据集一样,它也是由 Panagopoulos 等人于 2021 年提出的[3]。

输入的节点特征与相应的边索引和权重被送入 GCN 层。我们对这个输出应用批量归一化层和 dropout。这个过程会在第一次 MPNN 的输出结果基础上重复第二次。它生成一个节点嵌入矩阵 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_029.png。我们通过对每个时间步应用这些 MPNN,创建一个节点嵌入表示序列 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_030.png。这个序列被送入一个 2 层 LSTM 网络,以捕捉图中的时间信息。最后,我们对该输出应用线性变换和 ReLU 函数,生成一个在 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_13_031.png 的预测结果。

以下图显示了 MPNN-LSTM 架构的高级视图。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_009.jpg

图 13.9 – MPNN-LSTM 的架构

MPNN-LSTM 的作者指出,它并不是在英国 Covid 数据集上表现最好的模型(带有二级 GNN 的 MPNN 才是)。然而,它是一个有趣的方法,在其他场景中可能表现更好。他们还表示,它更适用于长期预测,比如预测未来 14 天,而不是像我们在此数据集版本中所做的单日预测。尽管存在这个问题,我们还是为了方便使用后者,因为它不影响解决方案的设计。

实现 MPNN-LSTM

首先,重要的是要可视化我们想要预测的病例数。与上一节相同,我们将通过计算均值和标准差来总结组成数据集的 129 个不同时间序列:

  1. 我们从 PyTorch Geometric Temporal 导入 pandasmatplotlib、英国 Covid 数据集以及时间序列训练-测试分割函数:

    import pandas as pd
    import matplotlib.pyplot as plt
    from torch_geometric_temporal.dataset import EnglandCovidDatasetLoader
    from torch_geometric_temporal.signal import temporal_signal_split
    
  2. 我们加载包含 14 个滞后期的数据集,滞后期对应于节点特征的数量:

    dataset = EnglandCovidDatasetLoader().get_dataset(lags=14)
    
  3. 我们执行时间信号分割,训练比例为 0.8

    train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.8)
    
  4. 我们绘制了以下图表,以展示报告病例的均值标准化数量(这些病例大约每天报告一次)。代码可以在 GitHub 上找到,并且适配了我们在上一节中使用的代码片段。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_010.jpg

图 13.10 – 英国 Covid 数据集的均值标准化病例数

这个图表显示了大量的波动性和较少的快照数量。这就是为什么我们在本例中使用 80/20 的训练-测试划分。尽管如此,在这样一个小数据集上获得良好的性能可能会具有挑战性。

现在我们来实现 MPNN-LSTM 架构。

  1. 我们从 PyTorch Geometric Temporal 导入 MPNNLSTM 层:

    From torch_geometric_temporal.nn.recurrent import MPNNLSTM
    
  2. 时间序列 GNN 接受三个参数作为输入:输入维度、隐藏维度和节点数。我们声明了三个层:MPNN-LSTM 层、一个 dropout 层和一个具有正确输入维度的线性层:

    Class TemporalGNN(torch.nn.Module):
        def __init__(self, dim_in, dim_h, num_nodes):
            super().__init__()
            self.recurrent = MPNNLSTM(dim_in, dim_h, num_nodes, 1, 0.5)
            self.dropout = torch.nn.Dropout(0.5)
            self.linear = torch.nn.Linear(2*dim_h + dim_in, 1)
    
  3. forward() 函数考虑了边的权重,这是该数据集中的关键信息。请注意,我们正在处理动态图,因此每个时间步骤都会提供一组新的 edge_indexedge_weight 值。与之前描述的原始 MPNN-LSTM 不同,我们用 tanh 函数替代了最后的 ReLU 函数。主要的动机是,tanh 输出的值在 -1 和 1 之间,而不是 0 和 1,这更接近我们在数据集中观察到的情况:

        Def forward(self, x, edge_index, edge_weight):
            h = self.recurrent(x, edge_index, edge_weight).relu()
            h = self.dropout(h)
            h = self.linear(h).tanh()
            return h
    
  4. 我们创建了一个隐藏维度为 64 的 MPNN-LSTM 模型,并打印它以观察不同的层:

    model = TemporalGNN(dataset[0].x.shape[1], 64, dataset[0].x.shape[0])
    print(model)
    TemporalGNN(
      (recurrent): MPNNLSTM(
        (_convolution_1): GCNConv(14, 64)
        (_convolution_2): GCNConv(64, 64)
        (_batch_norm_1): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (_batch_norm_2): BatchNorm1d(64, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
        (_recurrent_1): LSTM(128, 64)
        (_recurrent_2): LSTM(64, 64)
      )
      (dropout): Dropout(p=0.5, inplace=False)
      (linear): Linear(in_features=142, out_features=1, bias=True)
    )
    

我们看到 MPNN-LSTM 层包含两个 GCN、两个批归一化层和两个 LSTM 层(但没有 dropout),这与我们之前的描述一致。

  1. 我们使用 Adam 优化器和学习率为 0.001,将该模型训练了 100 轮。本次,我们在每次快照后反向传播损失,而不是在每个实例后反向传播:

    optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
    model.train()
    for epoch in range(100):
        loss = 0
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
            loss = loss + torch.mean((y_pred-snapshot.y)**2)
        loss = loss / (i+1)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
    
  2. 我们在测试集上评估了训练好的模型,并得到了以下的 MSE 损失:

    model.eval()
    loss = 0
    for i, snapshot in enumerate(test_dataset):
        y_pred = model(snapshot.x, snapshot.edge_index, snapshot.edge_attr)
        mse = torch.mean((y_pred-snapshot.y)**2)
        loss += mse
    loss = loss / (i+1)
    print(f'MSE: {loss.item():.4f}')
    MSE: 1.3722
    

MPNN-LSTM 模型的 MSE 损失为 1.3722,似乎相对较高。

我们无法还原对该数据集应用的归一化过程,因此将使用归一化后的病例数。首先,让我们绘制模型预测的平均归一化病例数(代码可在 GitHub 上获取)。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_011.jpg

图 13.11 – 平均归一化病例数,真实值为黑色,预测值为红色

如预期的那样,预测值与真实值不太匹配。这可能是由于数据不足:我们的模型学习到了一个最小化 MSE 损失的平均值,但无法拟合曲线并理解其周期性。

让我们查看对应于测试集第一张快照的散点图(代码可在 GitHub 上获取)。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_13_012.jpg

图 13.12 – 英国 Covid 数据集的预测值与真实值对比

散点图显示了弱相关性。我们看到预测值(y 轴)大多集中在 0.35 左右,变化很小。这与真实值不一致,真实值的范围从 -1.5 到 0.6。根据我们的实验,添加第二个线性层并没有改善 MPNN-LSTM 的预测结果。

可以实施几种策略来帮助模型。首先,更多的数据点会有很大帮助,因为这是一个小数据集。此外,时间序列包含两个有趣的特征:趋势(随时间持续增加或减少)和季节性(可预测的模式)。我们可以添加一个预处理步骤,去除这些特征,它们会为我们想要预测的信号增加噪声。

除了递归神经网络外,自注意力机制是另一种常用的技术,用于创建时序 GNN[4]。注意力机制可以仅限于时序信息,也可以考虑空间数据,通常通过图卷积来处理。最后,时序 GNN 也可以扩展到前一章中描述的异构设置。不幸的是,这种组合需要更多的数据,目前仍是一个活跃的研究领域。

摘要

本章介绍了一种新型的具有时空信息的图。这种时间成分在许多应用中非常有用,主要与时间序列预测相关。我们描述了两种符合此描述的图:静态图,其中特征随时间演变;动态图,其中特征和拓扑可以发生变化。它们都由 PyTorch Geometric Temporal 处理,PyG 的扩展专门用于时序图神经网络。

此外,我们还介绍了时序 GNN 的两个应用。首先,我们实现了 EvolveGCN 架构,该架构使用 GRU 或 LSTM 网络来更新 GCN 参数。我们通过回顾在第六章引入图卷积网络中遇到的网页流量预测任务,应用了这个架构,并在有限的数据集上取得了出色的结果。其次,我们使用 MPNN-LSTM 架构进行疫情预测。我们将其应用于英格兰 Covid 数据集,使用带有时间信号的动态图,但由于其数据量较小,未能获得可比的结果。

第十四章解释图神经网络中,我们将重点讨论如何解释我们的结果。除了我们迄今为止介绍的各种可视化方法外,我们还将看到如何将可解释人工智能XAI)的技术应用于图神经网络。这个领域是构建稳健 AI 系统和推动机器学习应用的重要组成部分。在该章节中,我们将介绍事后解释方法和新的层,以构建从设计上就可以解释的模型。

进一步阅读

  • [1] A. Pareja 等人,EvolveGCN:用于动态图的演化图卷积网络。arXiv,2019. DOI: 10.48550/ARXIV.1902.10191. 可用:arxiv.org/abs/1902.10191

  • [2] B. Rozemberczki 等人,PyTorch Geometric Temporal:使用神经机器学习模型进行时空信号处理,发表于第 30 届 ACM 国际信息与知识管理大会论文集,2021 年,页 4564–4573. 可用:arxiv.org/abs/2104.07788

  • [3] G. Panagopoulos,G. Nikolentzos 和 M. Vazirgiannis。传递图神经网络在疫情预测中的应用。arXiv,2020. DOI: 10.48550/ARXIV.2009.08388. 可用:arxiv.org/abs/2009.08388

  • [4] Guo, S., Lin, Y., Feng, N., Song, C., & Wan, H. (2019). 基于注意力的时空图卷积网络用于交通流量预测. 《人工智能协会会议论文集》,33(01),922-929. doi.org/10.1609/aaai.v33i01.3301922

第十四章:解释图神经网络

神经网络(NN)最常见的批评之一是它们的输出难以理解。不幸的是,GNN 也不例外:除了解释哪些特征很重要外,还必须考虑邻接节点和连接。为了应对这一问题,可解释性(以可解释人工智能XAI的形式)领域开发了许多技术,以更好地理解预测背后的原因或模型的总体行为。部分技术已经被转移到 GNN 上,另一些则利用图结构提供更精确的解释。

在本章中,我们将探讨一些解释技术,了解为什么给定的预测会被做出。我们将看到不同的技术类型,并重点介绍两种最流行的:MUTAG数据集。接着,我们将介绍Captum,一个提供多种解释技术的 Python 库。最后,利用 Twitch 社交网络,我们将实现集成梯度技术,解释节点分类任务中的模型输出。

到本章结束时,你将能够理解并实施几种 XAI 技术在 GNN 上的应用。更具体地说,你将学习如何使用 GNNExplainer 和Captum库(结合集成梯度)来进行图形和节点分类任务。

在本章中,我们将涵盖以下主要主题:

  • 介绍解释技术

  • 使用 GNNExplainer 解释 GNN

  • 使用 Captum 解释 GNN

技术要求

本章的所有代码示例可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter14

在本书的前言中可以找到在本地计算机上运行代码所需的安装步骤。

介绍解释技术

GNN 解释是一个最近的领域,深受其他 XAI 技术的启发[1]。我们将其分为基于单次预测的局部解释和针对整个模型的全局解释。虽然理解 GNN 模型的行为是有意义的,但我们将重点关注更受欢迎和本质的局部解释,以便深入了解预测结果。

在本章中,我们区分了“可解释”和“可解释性”模型。如果一个模型是“可解释的”,则意味着它从设计上就可以被人类理解,例如决策树。另一方面,当一个模型作为黑箱工作,其预测只能通过解释技术事后理解时,才称之为“可解释性”。这通常适用于神经网络(NN):它们的权重和偏置不像决策树那样提供明确的规则,但其结果可以通过间接方式解释。

本地解释技术有四种主要类别:

  • 基于梯度的方法分析输出的梯度,以估计归因分数(例如,集成梯度

  • 基于扰动的方法遮盖或修改输入特征,以测量输出的变化(例如,GNNExplainer

  • 分解方法将模型的预测分解为多个项,以衡量它们的重要性(例如,图神经网络逐层相关性传播GNN-LRP))

  • 替代方法使用简单且可解释的模型,来近似原始模型在某一区域的预测(例如,GraphLIME

这些技术是互补的:它们有时在边和特征的贡献上存在分歧,这可以用于进一步细化预测的解释。传统上,解释技术使用以下指标进行评估:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_006.jpg

  • 稀疏性,衡量被认为重要的特征(节点、边、节点特征)所占的比例。过长的解释更难理解,这也是鼓励稀疏性的原因。它的计算方式如下:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_007.jpg

在这里,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_008.png是重要输入特征的数量,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_009.png是特征的总数量。

除了我们在前几章看到的传统图形外,解释技术通常在合成数据集上进行评估,如BA-ShapesBA-CommunityTree-CyclesTree-Grid [2]。这些数据集是通过图生成算法生成的,用于创建特定的模式。我们在本章中不会使用它们,但它们是一个有趣的替代方案,易于实现和理解。

在接下来的章节中,我们将描述一种基于梯度的方法(集成梯度)和一种基于扰动的技术(GNNExplainer)。

使用 GNNExplainer 解释 GNN

在本节中,我们将介绍我们的第一个 XAI 技术——GNNExplainer。我们将用它来理解 GIN 模型在MUTAG数据集上产生的预测。

引入 GNNExplainer

GNNExplainer 是由 Ying 等人于 2019 年提出的 [2],它是一种旨在解释来自其他 GNN 模型预测的 GNN 架构。在表格数据中,我们希望知道哪些特征对预测最为重要。然而,在图数据中,这还不够:我们还需要知道哪些节点最具影响力。GNNExplainer 通过提供一个子图 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_010.png 和一组节点特征 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_011.png,生成包含这两个组件的解释。下图展示了 GNNExplainer 为给定节点提供的解释:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_14_001.jpg

图 14.1 – 节点 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_012.png 的标签解释,绿色表示 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_013.png,非排除节点特征 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_014.png

为了预测 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_015.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_016.png,GNNExplainer 实现了边掩码(用于隐藏连接)和特征掩码(用于隐藏节点特征)。如果一个连接或特征很重要,删除它应该会显著改变预测。另一方面,如果预测没有变化,说明这个信息是冗余的或完全不相关的。这个原理是基于扰动的技术,如 GNNExplainer 的核心。

在实践中,我们必须仔细设计损失函数,以找到最好的掩码。GNNExplainer 测量预测标签分布 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_017.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_018.png 之间的互依性,也叫做互信息 (MI)。我们的目标是最大化 MI,这等同于最小化条件交叉熵。GNNExplainer 通过找到变量 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_019.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_020.png,最大化预测 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_021.png 的概率来进行训练。

除了这个优化框架,GNNExplainer 还学习了一个二进制特征掩码,并实现了几种正则化技术。最重要的技术是一个项,用于最小化解释的大小(稀疏性)。它是通过求和掩码参数的所有元素并将其添加到损失函数中来计算的。这样可以生成更具用户友好性和简洁性的解释,便于理解和解释。

GNNExplainer 可以应用于大多数 GNN 架构以及不同的任务,如节点分类、链接预测或图分类。它还可以生成类标签或整个图的解释。在进行图分类时,模型会考虑图中所有节点的邻接矩阵的并集,而不是单一的矩阵。在下一节中,我们将应用它来解释图分类。

实现 GNNExplainer

在本例中,我们将探索MUTAG数据集[3]。该数据集中的 188 张图每一张代表一个化学化合物,节点表示原子(有七种可能的原子),边表示化学键(有四种可能的化学键)。节点和边的特征分别表示原子和化学键类型的独热编码。目标是根据化合物对细菌沙门氏菌的致突变效应,将每个化合物分类为两类。

我们将重用第九章中介绍的 GIN 模型进行蛋白质分类。在第九章中,我们可视化了模型的正确和错误分类。然而,我们无法解释 GNN 做出的预测。这一次,我们将使用 GNNExplainer 来理解最重要的子图和节点特征,从而解释分类结果。在此示例中,为了简便起见,我们将忽略边特征。以下是步骤:

  1. 我们从 PyTorch 和 PyTorch Geometric 中导入所需的类:

    import matplotlib.pyplot as plt
    import torch.nn.functional as F
    from torch.nn import Linear, Sequential, BatchNorm1d, ReLU, Dropout
    from torch_geometric.datasets import TUDataset
    from torch_geometric.loader import DataLoader
    from torch_geometric.nn import GINConv, global_add_pool, GNNExplainer
    
  2. 我们加载MUTAG数据集并进行洗牌:

    dataset = TUDataset(root='.', name='MUTAG').shuffle()
    
  3. 我们创建训练集、验证集和测试集:

    train_dataset = dataset[:int(len(dataset)*0.8)]
    val_dataset   = dataset[int(len(dataset)*0.8):int(len(dataset)*0.9)]
    test_dataset  = dataset[int(len(dataset)*0.9):]
    
  4. 我们创建数据加载器以实现小批量训练:

    train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
    val_loader   = DataLoader(val_dataset, batch_size=64, shuffle=True)
    test_loader  = DataLoader(test_dataset, batch_size=64, shuffle=True)
    
  5. 我们使用第九章中的代码创建一个具有 32 个隐藏维度的 GIN 模型:

    class GIN(torch.nn.Module):
    ...
    model = GIN(dim_h=32)
    
  6. 我们训练此模型 100 轮,并使用第九章中的代码进行测试:

    def train(model, loader):
     ...
    model = train(model, train_loader)
    test_loss, test_acc = test(model, test_loader)
    print(f'Test Loss: {test_loss:.2f} | Test Acc: {test_acc*100:.2f}%')
    Test Loss: 0.48 | Test Acc: 84.21%
    
  7. 我们的 GIN 模型已经训练完成,并取得了较高的准确率(84.21%)。现在,让我们使用 PyTorch Geometric 中的GNNExplainer类创建一个 GNNExplainer 模型,并进行 100 轮训练:

    explainer = GNNExplainer(model, epochs=100, num_hops=1)
    
  8. GNNExplainer 可用于解释节点的预测(.explain_node())或整个图的预测(.explain_graph())。在本例中,我们将其应用于测试集中的最后一张图:

    data = dataset[-1]
    feature_mask, edge_mask = explainer.explain_graph(data.x, data.edge_index)
    
  9. 最后一步返回了特征和边掩码。让我们打印特征掩码,查看最重要的值:

    feature_mask
    tensor([0.7401, 0.7375, 0.7203, 0.2692, 0.2587, 0.7516, 0.2872])
    

这些值被标准化到 0(较不重要)和 1(较重要)之间。这七个值对应于数据集中按以下顺序找到的七种原子:碳(C)、氮(N)、氧(O)、氟(F)、碘(I)、氯(Cl)和溴(Br)。特征具有类似的重要性:最有用的是最后一个,表示溴(Br),而最不重要的是第五个,表示碘(I)。

  1. 我们可以通过.visualize_graph()方法将边掩码绘制在图形上,而不是直接打印出来。箭头的透明度表示每个连接的重要性:

    ax, G = explainer.visualize_subgraph(-1, data.edge_index, edge_mask, y=data.y)
    plt.show()
    

这给出了图 14.2

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_14_002.jpg

图 14.2 – 化学化合物的图表示:边的透明度表示每个连接的重要性

最后一张图展示了对预测贡献最大的连接。在这种情况下,GIN 模型正确地对图进行了分类。我们可以看到节点之间的连接 data.edge_attr,以获取与其化学键(芳香键、单键、双键或三键)相关的标签。在此示例中,它对应的是 1619 的边,这些边都是单键或双键。

通过打印 data.x,我们还可以查看节点 678 来获取更多信息。节点 6 代表氮原子,而节点 78 代表两个氧原子。这些结果应该报告给具备相关领域知识的人,以便对我们的模型进行反馈。

GNNExplainer 并没有提供关于决策过程的精确规则,而是提供了对 GNN 模型在做出预测时关注的内容的洞察。仍然需要人类的专业知识来确保这些观点是连贯的,并且与传统领域知识相符。

在下一节中,我们将使用 Captum 来解释新社交网络上的节点分类。

使用 Captum 解释 GNN

在本节中,我们将首先介绍 Captum 和应用于图数据的集成梯度技术。然后,我们将在 Twitch 社交网络上使用 PyTorch Geometric 模型实现这一技术。

介绍 Captum 和集成梯度

Captum (captum.ai) 是一个 Python 库,实施了许多用于 PyTorch 模型的最先进的解释算法。这个库并非专门针对 GNN:它也可以应用于文本、图像、表格数据等。它特别有用,因为它允许用户快速测试各种技术,并比较对同一预测的不同解释。此外,Captum 实现了如 LIME 和 Gradient SHAP 等流行算法,用于主特征、层和神经元的归因。

在本节中,我们将使用它来应用图版本的集成梯度 [4]。这一技术旨在为每个输入特征分配一个归因分数。为此,它使用相对于模型输入的梯度。具体来说,它使用输入 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_022.png 和基线输入 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_023.png(在我们的情况下,所有边的权重为零)。它计算沿着 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_024.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_025.png 之间路径的所有点的梯度,并对其进行累加。

从形式上看,沿着 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_026.png 维度的集成梯度,对于输入 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_027.png 的定义如下:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_14_028.jpg

实际上,我们并不是直接计算这个积分,而是通过离散求和来近似它。

集成梯度是与模型无关的,并基于两个公理:

  • 敏感性:每个对预测有贡献的输入必须获得非零的归因

  • 实现不变性:对于所有输入输出相等的两个神经网络(这些网络被称为功能上等效的),它们的归因必须完全相同。

我们将使用的图版本稍有不同:它考虑节点和边而非特征。因此,您可以看到输出与 GNNExplainer 不同,后者同时考虑节点特征边。这就是为什么这两种方法可以互为补充的原因。

现在,让我们实现这个技术并可视化结果。

实现集成梯度

我们将在一个新的数据集上实现集成梯度:Twitch 社交网络数据集(英文版)[5]。该数据集表示一个用户-用户图,其中节点代表 Twitch 主播,连接代表相互友谊。128 个节点特征表示诸如直播习惯、地点、喜欢的游戏等信息。目标是判断一个主播是否使用过激语言(二分类任务)。

我们将使用 PyTorch Geometric 实现一个简单的两层 GCN 来完成此任务。然后我们将模型转换为 Captum,使用集成梯度算法并解释我们的结果。以下是步骤:

  1. 我们安装captum库:

    !pip install captum
    
  2. 我们导入所需的库:

    import numpy as np
    import matplotlib.pyplot as plt
    import torch.nn.functional as F
    from captum.attr import IntegratedGradients
    import torch_geometric.transforms as T
    from torch_geometric.datasets import Twitch
    from torch_geometric.nn import Explainer, GCNConv, to_captum
    
  3. 让我们固定随机种子,以使计算具有确定性:

    torch.manual_seed(0)
    np.random.seed(0)
    
  4. 我们加载 Twitch 玩家网络数据集(英文版):

    dataset = Twitch('.', name="EN")
    data = dataset[0]
    
  5. 这次,我们将使用一个简单的两层 GCN,并加上dropout

    class GCN(torch.nn.Module):
        def __init__(self, dim_h):
            super().__init__()
            self.conv1 = GCNConv(dataset.num_features, dim_h)
            self.conv2 = GCNConv(dim_h, dataset.num_classes)
        def forward(self, x, edge_index):
            h = self.conv1(x, edge_index).relu()
            h = F.dropout(h, p=0.5, training=self.training)
            h = self.conv2(h, edge_index)
            return F.log_softmax(h, dim=1)
    
  6. 我们尝试在有 GPU 的情况下使用Adam优化器训练模型:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = GCN(64).to(device)
    data = data.to(device)
    optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
    
  7. 我们使用负对数似然损失函数训练模型 200 个周期:

    for epoch in range(200):
        model.train()
        optimizer.zero_grad()
        log_logits = model(data.x, data.edge_index)
        loss = F.nll_loss(log_logits, data.y)
        loss.backward()
        optimizer.step()
    
  8. 我们测试训练好的模型。请注意,我们没有指定任何测试,因此在这种情况下我们将评估 GCN 在训练集上的准确度:

    def accuracy(pred_y, y):
        return ((pred_y == y).sum() / len(y)).item()
    @torch.no_grad()
    def test(model, data):
        model.eval()
        out = model(data.x, data.edge_index)
        acc = accuracy(out.argmax(dim=1), data.y)
        return acc
    acc = test(model, data)
    print(f'Accuracy: {acc*100:.2f}%')
    Accuracy: 79.75%
    

模型达到了79.75%的准确率,考虑到它是在训练集上评估的,这个分数相对较低。

  1. 现在我们可以开始实现我们选择的解释方法:集成梯度。首先,我们必须指定我们想要解释的节点(在本例中为节点0),并将 PyTorch Geometric 模型转换为Captum。在这里,我们还指定我们想使用特征和边掩码,mask_type=node_and_feature

    node_idx = 0
    captum_model = to_captum(model, mask_type='node_and_edge', output_idx=node_idx)
    
  2. 让我们使用Captum创建集成梯度对象。我们将上一步的结果作为输入:

    ig = IntegratedGradients(captum_model)
    
  3. 我们已经拥有需要传递给Captum的节点掩码(data.x),但我们需要为边掩码创建一个张量。在这个例子中,我们希望考虑图中的每一条边,因此初始化一个大小为data.num_edges的全 1 张量:

    edge_mask = torch.ones(data.num_edges, requires_grad=True, device=device)
    
  4. .attribute()方法对节点和边掩码的输入格式有特定要求(因此使用.unsqueeze(0)来重新格式化这些张量)。目标对应于我们目标节点的类别。最后,我们将邻接矩阵(data.edge_index)作为额外的前向参数传递:

    attr_node, attr_edge = ig.attribute(
        (data.x.unsqueeze(0), edge_mask.unsqueeze(0)),
        target=int(data.y[node_idx]),
        additional_forward_args=(data.edge_index),
        internal_batch_size=1)
    
  5. 我们将归一化归因分数,使其范围在01之间:

    attr_node = attr_node.squeeze(0).abs().sum(dim=1)
    attr_node /= attr_node.max()
    attr_edge = attr_edge.squeeze(0).abs()
    attr_edge /= attr_edge.max()
    
  6. 使用 PyTorch Geometric 的Explainer类,我们可视化这些归因的图形表示:

    explainer = Explainer(model)
    ax, G = explainer.visualize_subgraph(node_idx, data.edge_index, attr_edge, node_alpha=attr_node, y=data.y)
    plt.show()
    

这给我们带来了以下输出:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_14_003.jpg

图 14.3 – 节点 0 分类的解释,边和节点归因分数以不同的透明度值表示

节点 0 的子图由蓝色节点组成,这些节点属于同一类别。我们可以看到,节点 82 是最重要的节点(除了 0 以外),这两个节点之间的连接是最关键的边。这是一个直接的解释:我们有一组使用相同语言的四个直播者。节点 082 之间的互相友谊是这一预测的有力依据。

现在让我们来看一下图 14.4 中展示的另一个图,节点 101 分类的解释:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_14_004.jpg

图 14.4 – 节点 101 分类的解释,边和节点归因分数以不同的透明度值表示

在这种情况下,我们的目标节点与不同类别的邻居(节点 53982849)相连。集成梯度赋予与节点 101 同类别的节点更大的重要性。我们还看到,它们的连接是对该分类贡献最大的部分。这个子图更丰富;你可以看到,甚至两跳邻居也有一定的贡献。

然而,这些解释不应被视为灵丹妙药。AI 的可解释性是一个复杂的话题,通常涉及不同背景的人。因此,沟通结果并获得定期反馈尤为重要。了解边、节点和特征的重要性至关重要,但这应该只是讨论的开始。来自其他领域的专家可以利用或完善这些解释,甚至发现可能导致架构变化的问题。

总结

在本章中,我们探讨了应用于图神经网络(GNN)的可解释性人工智能(XAI)领域。可解释性是许多领域的关键组成部分,有助于我们构建更好的模型。我们看到了提供局部解释的不同技术,并重点介绍了 GNNExplainer(一种基于扰动的方法)和集成梯度(一种基于梯度的方法)。我们在两个不同的数据集上使用 PyTorch Geometric 和 Captum 实现了这些方法,以获得图和节点分类的解释。最后,我们对这些技术的结果进行了可视化和讨论。

第十五章使用 A3T-GCN 预测交通流量 中,我们将重新审视时序 GNN,以预测道路网络上的未来交通。在这个实际应用中,我们将看到如何将道路转化为图,并应用一种最新的 GNN 架构来准确预测短期交通。

进一步阅读

  • [1] H. Yuan, H. Yu, S. Gui, 和 S. Ji。图神经网络中的可解释性:一项分类调查。arXiv,2020。DOI: 10.48550/ARXIV.2012.15445。可在arxiv.org/abs/2012.15445获取。

  • [2] R. Ying, D. Bourgeois, J. You, M. Zitnik, 和 J. Leskovec。GNNExplainer:为图神经网络生成解释。arXiv,2019。DOI: 10.48550/ARXIV.1903.03894。可在arxiv.org/abs/1903.03894获取。

  • [3] Debnath, A. K., Lopez de Compadre, R. L., Debnath, G., Shusterman, A. J., 和 Hansch, C.(1991)。突变性芳香族和杂芳香族硝基化合物的结构-活性关系。与分子轨道能量和疏水性的相关性。DOI: 10.1021/jm00106a046。药物化学杂志,34(2),786–797。可在doi.org/10.1021/jm00106a046获取。

  • [4] M. Sundararajan, A. Taly, 和 Q. Yan。深度网络的公理化归因。arXiv,2017。DOI: 10.48550/ARXIV.1703.01365。可在arxiv.org/abs/1703.01365获取。

  • [5] B. Rozemberczki, C. Allen, 和 R. Sarkar。多尺度属性节点嵌入arXiv2019。DOI: 10.48550/ARXIV.1909.13021。可在arxiv.org/pdf/1909.13021.pdf获取。

第四部分:应用

在本书的第四部分,也是最后一部分,我们深入探讨了利用真实世界数据开发全面应用的过程。我们将重点关注前几章中未涉及的方面,如探索性数据分析和数据处理。我们的目标是提供关于机器学习管道的详尽概述,从原始数据到模型输出分析。我们还将强调所讨论技术的优点和局限性。

本节中的项目设计为适应性强且可定制,使读者能够轻松地将其应用于其他数据集和任务。这使其成为希望构建应用程序组合并展示自己工作(如在 GitHub 上的工作)的读者的理想资源。

到本部分结束时,你将学会如何实现 GNNs 用于交通预测、异常检测和推荐系统。这些项目的选择旨在展示 GNNs 在解决现实问题中的多样性和潜力。通过这些项目获得的知识和技能将为读者开发自己的应用程序并为图学习领域作出贡献做好准备。

本部分包括以下章节:

  • 第十五章*,使用 A3T-GCN 预测交通流量*

  • 第十六章*,使用异质图神经网络检测异常*

  • 第十七章*,使用 LightGCN 推荐书籍*

  • 第十八章*,解锁图神经网络在现实世界应用中的潜力*

第十五章:使用 A3T-GCN 进行交通预测

我们在第十三章中介绍了 T-GNNs,但没有详细说明它们的主要应用:交通预测。近年来,智能城市的概念越来越受欢迎。这个概念指的是通过数据来管理和改善城市的运营和服务。在这种背景下,一个主要的吸引力来源是创建智能交通系统。准确的交通预测可以帮助交通管理者优化交通信号、规划基础设施并减少拥堵。然而,由于复杂的时空依赖性,交通预测是一个具有挑战性的问题。

在本章中,我们将应用 T-GNNs(时序图神经网络)到交通预测的一个特定案例。首先,我们将探索并处理一个新的数据集,从原始 CSV 文件中创建一个时序图。接着,我们将应用一种新的 T-GNN 模型来预测未来的交通速度。最后,我们将可视化并将结果与基准解决方案进行比较,以验证我们的架构的有效性。

在本章结束时,您将学会如何从表格数据中创建时序图数据集。特别地,我们将展示如何创建一个加权邻接矩阵,以提供边权重。最后,您将学习如何将 T-GNN 应用于交通预测任务并评估结果。

本章将涵盖以下主要内容:

  • 探索 PeMS-M 数据集

  • 处理数据集

  • 实现时序 GNN

技术要求

本章中的所有代码示例可以在 GitHub 上的github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter15找到。

在本书的前言中可以找到运行代码所需的本地安装步骤。本章需要大量 GPU 资源,您可以通过减少训练集的大小来降低需求。

探索 PeMS-M 数据集

在本节中,我们将探索数据集,以寻找有助于解决任务的模式和洞察。

我们将为此应用使用的数据集是PeMSD7数据集的中型变体[1]。原始数据集通过收集 2012 年 5 月和 6 月期间的工作日交通速度数据,这些数据来自加州交通部门(Caltrans)在 39,000 个传感器站点上的测量,采用了绩效测量系统PeMS)。在中型变体中,我们将只考虑加州第七区的 228 个站点。这些站点提供的 30 秒速度测量被聚合成 5 分钟的时间间隔。例如,以下图展示了加州交通部门 PeMS(pems.dot.ca.gov)的交通速度数据:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_001.jpg

图 15.1 – 来自 Caltrans PeMS 的交通数据,速度超过 60 英里每小时(>60 mph)用绿色表示,速度低于 35 英里每小时(<35 mph)用红色表示

我们可以直接从 GitHub 加载数据集并解压:

from io import BytesIO
from urllib.request import urlopen
from zipfile import ZipFile
url = 'https://github.com/VeritasYin/STGCN_IJCAI-18/raw/master/data_loader/PeMSD7_Full.zip'
with urlopen(url) as zurl:
    with ZipFile(BytesIO(zurl.read())) as zfile:
        zfile.extractall('.')

结果文件夹包含两个文件:V_228.csvW_228.csvV_228.csv文件包含了来自 228 个传感器站点收集的交通速度数据,而W_228.csv文件则存储了这些站点之间的距离。

让我们使用pandas加载数据。我们将使用range()重新命名列,以便更方便地访问:

import pandas as pd
speeds = pd.read_csv('PeMSD7_V_228.csv', names=range(0,228))
distances = pd.read_csv('PeMSD7_W_228.csv.csv', names=range(0,228))

我们希望对这个数据集做的第一件事是可视化交通速度的变化。这是时间序列预测中的经典方法,因为像季节性这样的特征可以极大地帮助分析。另一方面,非平稳时间序列可能在使用之前需要进一步处理。

让我们使用matplotlib绘制交通速度随时间变化的图表:

  1. 我们导入NumPymatplotlib

    import numpy as np
    import matplotlib.pyplot as plt
    
  2. 我们使用plt.plot()为数据框中的每一行创建折线图:

    plt.figure(figsize=(10,5))
    plt.plot(speeds)
    plt.grid(linestyle=':')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed')
    
  3. 我们得到如下图表:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_002.jpg

图 15.2 – 每个 228 个传感器站点的交通速度随时间变化

不幸的是,数据太嘈杂,无法通过这种方法提供任何有价值的见解。相反,我们可以绘制一些传感器站点的数据。但是,这可能并不能代表整个数据集。还有另一种选择:我们可以绘制平均交通速度和标准差。这样,我们可以可视化数据集的摘要。

在实际应用中,我们会同时使用这两种方法,但现在让我们尝试第二种方法:

  1. 我们计算每列(时间步长)的平均交通速度及相应的标准差:

    mean = speeds.mean(axis=1)
    std = speeds.std(axis=1)
    
  2. 我们使用黑色实线绘制平均值:

    plt.plot(mean, 'k-')
    
  3. 我们使用plt.fill_between()在平均值周围绘制标准差,并填充为浅红色:

    plt.fill_between(mean.index, mean-std, mean+std, color='r', alpha=0.1)
    plt.grid(linestyle=':')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed')
    
  4. 代码生成了如下图表:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_003.jpg

图 15.3 – 随时间变化的平均交通速度及其标准差

这个图表更易于理解。我们可以看到时间序列数据中存在明显的季节性(模式),除了大约第 5800 个数据点周围。交通速度有很大的波动,出现了重要的峰值。这是可以理解的,因为这些传感器站点分布在加利福尼亚州第七区:某些传感器可能会发生交通堵塞,而其他传感器则不会。

我们可以通过绘制每个传感器速度值之间的相关性来验证这一点。此外,我们还可以将其与每个站点之间的距离进行比较。相邻的站点应比远距离站点显示出更多的相似值。

让我们在同一图表中比较这两个图:

  1. 我们创建一个包含两个横向子图的图形,并在它们之间添加一些间距:

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 8))
    fig.tight_layout(pad=3.0)
    
  2. 首先,我们使用matshow()函数绘制距离矩阵:

    ax1.matshow(distances)
    ax1.set_xlabel("Sensor station")
    ax1.set_ylabel("Sensor station")
    ax1.title.set_text("Distance matrix")
    
  3. 接着,我们计算每个传感器站的 Pearson 相关系数。我们必须转置速度矩阵,否则我们将得到每个时间步的相关系数。最后,我们将其反转,这样两个图表就更容易比较:

    ax2.matshow(-np.corrcoef(speeds.T))
    ax2.set_xlabel("Sensor station")
    ax2.set_ylabel("Sensor station")
    ax2.title.set_text("Correlation matrix")
    
  4. 我们得到如下图:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_004.jpg

图 15.4 – 距离和相关矩阵,颜色较深表示短距离和高相关性,而颜色较浅表示长距离和低相关性

有趣的是,站点之间的长距离并不意味着它们没有高度相关(反之亦然)。如果我们只考虑这个数据集的一个子集,这一点尤其重要:接近的站点可能有非常不同的输出,这使得交通预测更加困难。在本章中,我们将考虑数据集中的每个传感器站。

数据集处理

现在我们对这个数据集有了更多的了解,是时候处理它,然后将其输入到 T-GNN 中。

第一步是将表格数据集转化为时序图。因此,首先,我们需要从原始数据中创建一个图。换句话说,我们必须以有意义的方式连接不同的传感器站。幸运的是,我们可以访问距离矩阵,它应该是连接站点的一个好方法。

有几种方法可以从距离矩阵计算邻接矩阵。例如,当两个站点之间的距离小于平均距离时,我们可以为它们分配一个连接。相反,我们将执行在[2]中介绍的更高级的处理,计算加权邻接矩阵。我们不使用二进制值,而是通过以下公式计算 0(无连接)和 1(强连接)之间的权重:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_001.jpg

在这里,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_002.png表示从节点 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_003.png 到节点 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_004.png 的边的权重,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_005.png 是这两个节点之间的距离,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_006.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_007.png 是两个阈值,用来控制邻接矩阵的分布和稀疏度。[2]的官方实现可以在 GitHub 上找到(github.com/VeritasYin/STGCN_IJCAI-18)。我们将重用相同的阈值 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_008.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_009.png

让我们用 Python 实现并绘制结果的邻接矩阵:

  1. 我们创建一个函数来计算邻接矩阵,该函数需要三个参数:距离矩阵和两个阈值 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_010.pnghttps://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_011.png。像官方实现一样,我们将距离除以 10,000 并计算 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_012.png

    def compute_adj(distances, sigma2=0.1, epsilon=0.5):
        d = distances.to_numpy() / 10000.
        d2 = d * d
    
  2. 在这里,我们希望在权重值大于或等于 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_013.png 时保留这些权重(否则它们应等于零)。当我们测试权重是否大于或等于 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_014.png 时,结果是TrueFalse语句。这就是为什么我们需要一个全是 1 的掩码(w_mask)来将其转换回 0 和 1 值的原因。我们第二次乘以它,以便只获得大于或等于 https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_015.png 的真实权重值:

        n = distances.shape[0]
        w_mask = np.ones([n, n]) - np.identity(n)
        return np.exp(-d2 / sigma2) * (np.exp(-d2 / sigma2) >= epsilon) * w_mask
    
  3. 让我们计算邻接矩阵并打印出一行的结果:

    adj = compute_adj(distances)
    adj[0]
    array(0.       , 0.       , 0.        , 0.       , 0.  ,
           0.       , 0.       , 0.61266012, 0.       , ...
    

我们可以看到0.61266012这个值,代表从节点 1 到节点 2 的边的权重。

  1. 更高效的可视化这个矩阵的方法是再次使用matplotlibmatshow

    plt.figure(figsize=(8, 8))
    cax = plt.matshow(adj, False)
    plt.colorbar(cax)
    plt.xlabel("Sensor station")
    plt.ylabel("Sensor station")
    

我们得到以下图形:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_006.jpg

图 15.6 – PeMS-M 数据集作为图形(每个节点代表一个传感器站)

事实上,许多节点相互连接,因为它们彼此非常接近。然而,尽管如此,我们仍然可以区分出几条可能对应于实际道路的分支。

现在我们有了一个图形,我们可以专注于这个问题的时间序列方面。第一步是对速度值进行归一化处理,以便它们可以输入到神经网络中。在交通预测文献中,许多作者选择了 z-score 归一化(或标准化),我们将在这里实现这一方法:

  1. 我们创建一个函数来计算 z-score:

    def zscore(x, mean, std):
        return (x - mean) / std
    
  2. 我们将其应用于我们的数据集,以创建其标准化版本:

    speeds_norm = zscore(speeds, speeds.mean(axis=0), speeds.std(axis=0))
    
  3. 我们可以检查结果:

    speeds_norm.head(1)
    

我们得到了以下输出:

0 1 2 3 4 5 6
0 0.950754 0.548255 0.502211 0.831672 0.793696 1.193806

图 15.7 – 标准化速度值的示例

这些值已经正确标准化。现在,我们可以使用它们为每个节点创建时间序列。我们希望在每个时间步输入https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_016.png数据样本,https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_017.png,以预测https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_018.png时的速度值。较多的输入数据样本也会增加数据集的内存占用。https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_019.png的值,也称为预测范围,取决于我们想要执行的任务:短期或长期交通预测。

在这个示例中,我们取一个较高的值 48 来预测 4 小时后的交通速度:

  1. 我们初始化变量:lags(输入数据样本数量)、horizon、输入矩阵和真实值矩阵:

    lags = 24
    horizon = 48
    xs = []
    ys = []
    
  2. 对于每个时间步https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_020.png,我们将存储之前的 12 个值(lags)在xs中,当前时刻https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_021.png的值存储在ys中:

    for i in range(lags, speeds_norm.shape[0]-horizon):
        xs.append(speeds_norm.to_numpy()[i-lags:i].T)
        ys.append(speeds_norm.to_numpy()[i+horizon-1])
    
  3. 最后,我们可以使用 PyTorch Geometric Temporal 创建时序图。我们需要提供 COO 格式的边索引以及来自加权邻接矩阵的边权重:

    from torch_geometric_temporal.signal import StaticGraphTemporalSignal
    edge_index = (np.array(adj) > 0).nonzero()
    edge_weight = adj[adj > 0]
    dataset = StaticGraphTemporalSignal(edge_index, adj[adj > 0], xs, ys)
    
  4. 让我们打印第一个图的信息,看看一切是否正常:

    dataset[0]
    Data(x=[228, 12], edge_index=[2, 1664], edge_attr=[1664], y=[228])
    
  5. 让我们不要忘记划分训练集和测试集,来完成我们的数据集准备:

    from torch_geometric_temporal.signal import temporal_signal_split
    train_dataset, test_dataset = temporal_signal_split(dataset, train_ratio=0.8)
    

最终的时序图包含 228 个节点,每个节点有 12 个值和 1,664 条连接。我们现在准备应用 T-GNN 来预测交通状况。

实现 A3T-GCN 架构

在这一节中,我们将训练一个注意力时序图卷积网络A3T-GCN),该网络是为交通预测设计的。这种架构使我们能够考虑复杂的空间和时间依赖性:

  • 空间依赖性是指一个位置的交通状况可能受到周围位置交通状况的影响。例如,交通堵塞往往会蔓延到邻近的道路。

  • 时间依赖性是指一个位置在某一时刻的交通状况可能会受到该位置在之前时刻交通状况的影响。例如,如果某条道路在早高峰时段发生拥堵,那么它很可能会持续到晚高峰。

A3T-GCN 是对时序 GCNTGCN)架构的改进。TGCN 是一个结合了 GCN 和 GRU 的架构,从每个输入时间序列中生成隐藏向量。这两层的组合捕捉了输入中的空间和时间信息。然后,使用注意力模型来计算权重并输出上下文向量。最终的预测基于得到的上下文向量。增加这个注意力模型的动机在于需要理解全局趋势。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_007.jpg

图 15.8 – A3T-GCN 框架

我们现在将使用 PyTorch Geometric Temporal 库来实现它:

  1. 首先,我们导入所需的库:

    import torch
    from torch_geometric_temporal.nn.recurrent import A3TGCN
    
  2. 我们创建一个带有 A3TGCN 层和 32 个隐藏维度的线性层的 T-GNN。edge_attr 参数将存储我们的边权重:

    class TemporalGNN(torch.nn.Module):
        def __init__(self, dim_in, periods):
            super().__init__()
            self.tgnn = A3TGCN(in_channels=dim_in, out_channels=32, periods=periods)
            self.linear = torch.nn.Linear(32, periods)
        def forward(self, x, edge_index, edge_attr):
            h = self.tgnn(x, edge_index, edge_attr).relu()
            h = self.linear(h)
            return h
    
  3. 我们实例化了 T-GNN 和Adam优化器,学习率设置为0.005。由于实现细节,我们将使用 CPU 而不是 GPU 来训练该模型,因为在这种情况下 CPU 更快:

    model = TemporalGNN(lags, 1).to('cpu')
    optimizer = torch.optim.Adam(model.parameters(), lr=0.005)
    
  4. 我们使用loss函数训练模型 30 个 epoch。在每个 epoch 后,loss值会进行反向传播:

    model.train()
    for epoch in range(30):
        loss = 0
        step = 0
        for i, snapshot in enumerate(train_dataset):
            y_pred = model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_attr)
            loss += torch.mean((y_pred-snapshot.y)**2)
            step += 1
        loss = loss / (step + 1)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()
        if epoch % 10 == 0:
            print(f"Epoch {epoch+1:>2} | Train MSE: {loss:.4f}")
    
  5. 我们获得了以下输出:

    Epoch  1 | Train MSE: 1.0209
    Epoch 10 | Train MSE: 0.9625
    Epoch 20 | Train MSE: 0.9143
    Epoch 30 | Train MSE: 0.8905
    

现在我们的模型已经训练完成,我们需要对其进行评估。除了经典的指标如均方根误差RMSE)和平均绝对误差MAE),将我们的模型与时间序列数据的基准解决方案进行比较也特别有帮助。在接下来的列表中,我们将介绍两种方法:

我们首先评估模型在测试集上的预测:

  1. 我们创建了一个函数来反转 z-score 并返回原始值:

    def inverse_zscore(x, mean, std):
        return x * std + mean
    
  2. 我们使用它从归一化值重新计算我们希望预测的速度。以下循环效率不高,但比更优化的代码更容易理解:

    y_test = []
    for snapshot in test_dataset:
        y_hat = snapshot.y.numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        y_test = np.append(y_test, y_hat)
    
  3. 我们对 GNN 做出的预测应用相同的策略:

    gnn_pred = []
    model.eval()
    for snapshot in test_dataset:
        y_hat = model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_weight).squeeze().detach().numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        gnn_pred = np.append(gnn_pred, y_hat)
    
  4. 我们对 RW 和 HA 技术做同样的处理:

    rw_pred = []
    for snapshot in test_dataset:
        y_hat = snapshot.x[:,-1].squeeze().detach().numpy()
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        rw_pred = np.append(rw_pred, y_hat)
    ha_pred = []
    for i in range(lags, speeds_norm.shape[0]-horizon):
        y_hat = speeds_norm.to_numpy()[i-lags:i].T.mean(axis=1)
        y_hat = inverse_zscore(y_hat, speeds.mean(axis=0), speeds.std(axis=0))
        ha_pred.append(y_hat)
    ha_pred = np.array(ha_pred).flatten()[-len(y_test):]
    
  5. 我们创建了计算 MAE、RMSE 和平均绝对百分比误差MAPE)的函数:

    def MAE(real, pred):
        return np.mean(np.abs(pred - real))
    def RMSE(real, pred):
        return np.sqrt(np.mean((pred - real) ** 2))
    def MAPE(real, pred):
        return np.mean(np.abs(pred - real) / (real + 1e-5))
    
  6. 我们在以下模块中评估 GNN 的预测,并对每个技术重复此过程:

    print(f'GNN MAE  = {MAE(gnn_pred, y_test):.4f}')
    print(f'GNN RMSE = {RMSE(gnn_pred, y_test):.4f}')
    print(f'GNN MAPE = {MAPE(gnn_pred, y_test):.4f}')
    

最终,我们得到了以下表格:

RMSE MAE MAPE
A3T-GCN 11.9396 8.3293 14.95%
随机游走 17.6501 11.0469 29.99%
历史平均值 17.9009 11.7308 28.93%

图 15.9 – 预测输出表格

我们看到,在每个指标上,基准技术都被 A3T-GCN 模型超越。这是一个重要的结果,因为基准往往是很难超越的。将这些指标与 LSTM 或 GRU 网络提供的预测进行比较,将有助于衡量拓扑信息的重要性。

最后,我们可以绘制平均预测值,得到类似于图 15.3的可视化:

  1. 我们使用列表推导法获得平均预测值,这种方法比之前的方法稍快(但更难以阅读):

    y_preds = [inverse_zscore(model(snapshot.x.unsqueeze(2), snapshot.edge_index, snapshot.edge_weight).squeeze().detach().numpy(), speeds.mean(axis=0), speeds.std(axis=0)).mean() for snapshot in test_dataset]
    
  2. 我们计算了原始数据集的均值和标准差:

    mean = speeds.mean(axis=1)
    std = speeds.std(axis=1)
    
  3. 我们绘制了带有标准差的平均交通速度,并将其与预测值进行比较(https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Formula_B19153_15_027.png小时):

    plt.figure(figsize=(10,5), dpi=300)
    plt.plot(mean, 'k-', label='Mean')
    plt.plot(range(len(speeds)-len(y_preds), len(speeds)), y_preds, 'r-', label='Prediction')
    plt.grid(linestyle=':')
    plt.fill_between(mean.index, mean-std, mean+std, color='r', alpha=0.1)
    plt.axvline(x=len(speeds)-len(y_preds), color='b', linestyle='--')
    plt.xlabel('Time (5 min)')
    plt.ylabel('Traffic speed to predict')
    plt.legend(loc='upper right')
    

我们得到了以下图形:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_15_008.jpg

图 15.10 – A3T-GCN 模型在测试集上预测的平均交通速度

T-GNN 正确预测了交通高峰并跟踪了总体趋势。然而,预测的速度更接近整体平均值,因为模型由于 MSE 损失而更不容易犯重大错误。尽管如此,GNN 仍然相当准确,并可以进一步调整以输出更极端的值。

总结

本章集中讨论了使用 T-GNNs 进行交通预测的任务。首先,我们探索了 PeMS-M 数据集,并将其从表格数据转换为具有时间信号的静态图数据集。在实践中,我们基于输入的距离矩阵创建了加权邻接矩阵,并将交通速度转换为时间序列。最后,我们实现了一个 A3T-GCN 模型,这是一个为交通预测设计的 T-GNN。我们将结果与两个基准进行了比较,并验证了我们模型的预测。

第十六章《使用 LightGCN 构建推荐系统》中,我们将看到 GNNs 最流行的应用。我们将在一个大规模数据集上实现一个轻量级 GNN,并使用推荐系统中的技术对其进行评估。

进一步阅读

  • [1] B. Yu, H. Yin, 和 Z. Zhu. 时空图卷积网络:面向交通预测的深度学习框架. 2018 年 7 月. doi: 10.24963/ijcai.2018/505. 可在arxiv.org/abs/1709.04875查看。

  • [2] Y. Li, R. Yu, C. Shahabi, 和 Y. Liu. 扩散卷积递归神经网络:数据驱动的交通预测. arXiv, 2017 年. doi: 10.48550/ARXIV.1707.01926. 可在arxiv.org/abs/1707.01926查看。

第十六章:使用异构 GNN 检测异常

在机器学习中,异常检测是一个流行的任务,旨在识别数据中偏离预期行为的模式或观察结果。这是一个在许多现实应用中出现的基本问题,例如检测金融交易中的欺诈、识别制造过程中的缺陷产品以及检测计算机网络中的网络攻击。

GNN 可以通过学习网络的正常行为来训练,然后识别偏离该行为的节点或模式。事实上,它们理解复杂关系的能力使得它们特别适合于检测微弱信号。此外,GNN 可以扩展到大型数据集,使其成为处理海量数据的高效工具。

在本章中,我们将构建一个用于计算机网络异常检测的 GNN 应用程序。首先,我们将介绍CIDDS-001数据集,其中包含计算机网络中的攻击和正常流量。接下来,我们将处理数据集,准备将其输入到 GNN 中。然后,我们将实现一个异构 GNN,以处理不同类型的节点和边。最后,我们将使用处理过的数据集训练网络,并评估结果,查看其在检测网络流量中的异常方面的表现。

在本章结束时,你将知道如何实现一个用于入侵检测的 GNN。此外,你将了解如何构建相关特征以检测攻击,并处理它们以输入到 GNN 中。最后,你将学习如何实现并评估一个异构 GNN 以检测稀有攻击。

本章将涵盖以下主要内容:

  • 探索 CIDDS-001 数据集

  • 预处理 CIDDS-001 数据集

  • 实现一个异构 GNN

技术要求

本章中的所有代码示例都可以在 GitHub 上找到,链接为github.com/PacktPublishing/Hands-On-Graph-Neural-Networks-Using-Python/tree/main/Chapter16

运行代码所需的安装步骤可以在本书的前言中找到。本章需要大量的 GPU,你可以通过减少代码中训练集的大小来降低需求。

探索 CIDDS-001 数据集

本节将探索数据集,并深入了解特征的重要性和缩放。

CIDDS-001数据集[1]旨在训练和评估基于异常的网络入侵检测系统。它提供了现实的流量数据,包括最新的攻击,用于评估这些系统。它通过使用 OpenStack 在虚拟环境中收集并标记了 8,451,520 个流量流量数据。具体来说,每一行代表一个 NetFlow 连接,描述互联网协议IP)流量统计信息,例如交换的字节数。

以下图表提供了 CIDDS-001 中模拟网络环境的概览:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_001.jpg

图 16.1 – CIDDS-001 模拟的虚拟网络概览

我们可以看到四个不同的子网(开发者、办公室、管理和服务器),以及它们各自的 IP 地址范围。所有这些子网都连接到一台通过防火墙与互联网相连的服务器。还存在一个外部服务器,提供两个服务:文件同步服务和网页服务器。最后,攻击者被表示在本地网络之外。

CIDDS-001 中的连接来自本地和外部服务器。该数据集的目标是将这些连接正确地分类为五个类别:正常(无攻击)、暴力破解、拒绝服务、ping 扫描和端口扫描。

让我们下载 CIDDS-001 数据集并探索其输入特征:

  1. 我们下载 CIDDS-001

    from io import BytesIO
    from urllib.request import urlopen
    from zipfile import ZipFile
    url = 'https://www.hs-coburg.de/fileadmin/hscoburg/WISENT-CIDDS-001.zip'
    with urlopen(url) as zurl:
        with ZipFile(BytesIO(zurl.read())) as zfile:
            zfile.extractall('.')
    
  2. 我们导入所需的库:

    import numpy as np
    import pandas as pd
    import matplotlib.pyplot as plt
    import itertools
    from sklearn.model_selection import train_test_split
    from sklearn.preprocessing import PowerTransformer
    from sklearn.metrics import f1_score, classification_report, confusion_matrix
    from torch_geometric.loader import DataLoader
    from torch_geometric.data import HeteroData
    from torch.nn import functional as F
    from torch.optim import Adam
    from torch import nn
    import torch
    
  3. 我们使用 pandas 加载数据集:

    df = pd.read_csv("CIDDS-001/traffic/OpenStack/CIDDS-001-internal-week1.csv")
    
  4. 让我们看看前五个连接对应的数据:

    df.head(5)
    Date first seen Duration Proto Src IP Addr Src Pt Dst IP Addr Dst Pt Packets Bytes Flows Flags Tos class attackType attackID attackDescription
    2017-03-15 00:01:16.632 0.000 TCP 192.168.100.5 445 192.168.220.16 58844.0 1 108 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.552 0.000 TCP 192.168.100.5 445 192.168.220.15 48888.0 1 108 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.551 0.004 TCP 192.168.220.15 48888 192.168.100.5 445.0 2 174 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.631 0.004 TCP 192.168.220.16 58844 192.168.100.5 445.0 2 174 1 .AP... 0 normal --- --- ---
    2017-03-15 00:01:16.552 0.000 TCP 192.168.100.5 445 192.168.220.15 48888.0 1 108 1 .AP... 0 normal --- --- ---
    

有一些有趣的特征我们可以用于我们的模型:

  • “首次看到日期”是一个时间戳,我们可以对其进行处理,以提取关于星期几和一天中时间的信息。通常,网络流量是有季节性的,夜间或非常规日子的连接通常是可疑的。

  • IP 地址(如 192.168.100.5)因其不是数值型且遵循复杂规则而著名,处理起来非常困难。我们可以将它们分为几个类别,因为我们知道本地网络的设置。另一个更常见且具有更好泛化性的解决方案是将其转换为二进制表示(例如,“192”变为“11000000”)。

  • 持续时间、数据包数量和字节数通常呈现重尾分布。因此,如果是这种情况,它们将需要特别处理。

让我们检查这一点,并仔细看看该数据集中的攻击分布:

  1. 我们首先删除本项目中不考虑的特征:端口、流数量、服务类型、类别、攻击 ID 和攻击描述:

    df = df.drop(columns=['Src Pt', 'Dst Pt', 'Flows', 'Tos', 'class', 'attackID', 'attackDescription'])
    
  2. 我们重命名正常类别,并将“首次看到日期”特征转换为时间戳数据类型:

    df['attackType'] = df['attackType'].replace('---', 'benign')
    df['Date first seen'] = pd.to_datetime(df['Date first seen'])
    
  3. 我们统计标签并制作一个饼图,显示三个最多类别(另外两个类别的比例低于 0.1%):

    count_labels = df['attackType'].value_counts() / len(df) * 100
    plt.pie(count_labels[:3], labels=df['attackType'].unique()[:3], autopct='%.0f%%')
    
  4. 我们得到以下图表:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_002..jpg

图 16.2 – CIDDS-001 数据集中每个类别的比例

如你所见,正常流量占据了数据集的绝大多数。相反,暴力破解攻击和 ping 扫描的样本数量相对较少。这个不平衡的学习设置可能会对模型处理稀有类别时的性能产生负面影响。

  1. 最后,我们可以显示持续时间分布、数据包数量和字节数。这让我们能够查看它们是否确实需要特定的重新缩放处理:

    fig, ((ax1, ax2, ax3)) = plt.subplots(1, 3, figsize=(20,5))
    df['Duration'].hist(ax=ax1)
    ax1.set_xlabel("Duration")
    df['Packets'].hist(ax=ax2)
    ax2.set_xlabel("Number of packets")
    pd.to_numeric(df['Bytes'], errors='coerce').hist(ax=ax3)
    ax3.set_xlabel("Number of bytes")
    plt.show()
    

它输出如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_003..jpg

图 16.3 – 持续时间、数据包数量和字节数的分布

我们可以看到,大多数值接近零,但也有一些稀有值沿 x 轴形成了长尾。我们将使用幂变换使这些特征更接近高斯分布,这应该有助于模型训练。

现在我们已经探索了 CIDDS-001 数据集的主要特征,可以进入预处理阶段。

对 CIDDS-001 数据集进行预处理

在上一节中,我们已经确定了一些需要解决的数据集问题,以提高模型的准确性。

CIDDS-001 数据集包括多种类型的数据:我们有持续时间等数值数据、协议(TCP、UDP、ICMP 和 IGMP)等类别特征,以及时间戳或 IP 地址等其他数据。在接下来的练习中,我们将根据前一节的信息和专家知识,选择如何表示这些数据类型:

  1. 首先,我们可以通过从时间戳中提取星期几的信息来进行独热编码。我们将重命名结果列,使其更易读:

    df['weekday'] = df['Date first seen'].dt.weekday
    df = pd.get_dummies(df, columns=['weekday']).rename(columns = {'weekday_0': 'Monday','weekday_1': 'Tuesday','weekday_2': 'Wednesday', 'weekday_3': 'Thursday','weekday_4': 'Friday','weekday_5': 'Saturday','weekday_6': 'Sunday',})
    
  2. 另一个通过时间戳可以获取的重要信息是一天中的时间。我们还将其归一化到 01 之间:

    df['daytime'] = (df['Date first seen'].dt.second +df['Date first seen'].dt.minute*60 + df['Date first seen'].dt.hour*60*60)/(24*60*60)
    
  3. 我们还没有讨论 TCP 标志。每个标志表示 TCP 连接中的特定状态。例如,FFIN 表示 TCP 对端已经完成数据发送。我们可以提取每个标志,并按如下方式对其进行独热编码:

    df = df.reset_index(drop=True)
    ohe_flags = one_hot_flags(df['Flags'].to_numpy())
    ohe_flags = df['Flags'].apply(one_hot_flags).to_list()
    df[['ACK', 'PSH', 'RST', 'SYN', 'FIN']] = pd.DataFrame(ohe_flags, columns=['ACK', 'PSH', 'RST', 'SYN', 'FIN'])
    
  4. 现在,我们处理 IP 地址。在这个示例中,我们将使用二进制编码。我们不会使用 32 位来编码完整的 IPv4 地址,而是仅保留最后 16 位,因为这部分在这里最为重要。实际上,前 16 位要么表示 192.168,如果主机属于内部网络,要么表示其他值,如果它是外部网络:

    temp = pd.DataFrame()
    temp['SrcIP'] = df['Src IP Addr'].astype(str)
    temp['SrcIP'][~temp['SrcIP'].str.contains('\d{1,3}\.', regex=True)] = '0.0.0.0'
    temp = temp['SrcIP'].str.split('.', expand=True).rename(columns = {2: 'ipsrc3', 3: 'ipsrc4'}).astype(int)[['ipsrc3', 'ipsrc4']]
    temp['ipsrc'] = temp['ipsrc3'].apply(lambda x: format(x, "b").zfill(8)) + temp['ipsrc4'].apply(lambda x: format(x, "b").zfill(8))
    df = df.join(temp['ipsrc'].str.split('', expand=True)
                .drop(columns=[0, 17])
                .rename(columns=dict(enumerate([f'ipsrc_{i}' for i in range(17)])))
                .astype('int32'))
    
  5. 我们对目标 IP 地址重复这个过程:

    temp = pd.DataFrame()
    temp['DstIP'] = df['Dst IP Addr'].astype(str)
    temp['DstIP'][~temp['DstIP'].str.contains('\d{1,3}\.', regex=True)] = '0.0.0.0'
    temp = temp['DstIP'].str.split('.', expand=True).rename(columns = {2: 'ipdst3', 3: 'ipdst4'}).astype(int)[['ipdst3', 'ipdst4']]
    temp['ipdst'] = temp['ipdst3'].apply(lambda x: format(x, "b").zfill(8)) + temp['ipdst4'].apply(lambda x: format(x, "b").zfill(8))
    df = df.join(temp['ipdst'].str.split('', expand=True)
                .drop(columns=[0, 17])
                .rename(columns=dict(enumerate([f'ipdst_{i}' for i in range(17)])))
                .astype('int32'))
    
  6. Bytes 特征存在一个问题:百万单位用 m 表示,而不是数值。我们可以通过将这些非数值的数值部分乘以一百万来修复这个问题:

    m_index = df[pd.to_numeric(df['Bytes'], errors='coerce').isnull() == True].index
    df['Bytes'].loc[m_index] = df['Bytes'].loc[m_index].apply(lambda x: 10e6 * float(x.strip().split()[0]))
    df['Bytes'] = pd.to_numeric(df['Bytes'], errors='coerce', downcast='integer')
    
  7. 我们需要编码的最后一类特征是最简单的:如协议和攻击类型等类别特征。我们使用 pandas 中的 get_dummies() 函数:

    df = pd.get_dummies(df, prefix='', prefix_sep='', columns=['Proto', 'attackType'])
    
  8. 我们创建一个训练/验证/测试的分割,比例为 80/10/10:

    labels = ['benign', 'bruteForce', 'dos', 'pingScan', 'portScan']
    df_train, df_test = train_test_split(df, random_state=0, test_size=0.2, stratify=df[labels])
    df_val, df_test = train_test_split(df_test, random_state=0, test_size=0.5, stratify=df_test[labels])
    
  9. 最后,我们需要处理三个特征的缩放问题:持续时间、数据包数量和字节数。我们使用 scikit-learn 中的 PowerTransformer() 来修改它们的分布:

    scaler = PowerTransformer()
    df_train[['Duration', 'Packets', 'Bytes']] = scaler.fit_transform(df_train[['Duration', 'Packets', 'Bytes']])
    df_val[['Duration', 'Packets', 'Bytes']] = scaler.transform(df_val[['Duration', 'Packets', 'Bytes']])
    df_test[['Duration', 'Packets', 'Bytes']] = scaler.transform(df_test[['Duration', 'Packets', 'Bytes']])
    
  10. 让我们绘制新的分布,看看它们如何比较:

    fig, ((ax1, ax2, ax3)) = plt.subplots(1, 3, figsize=(15,5))
    df_train['Duration'].hist(ax=ax1)
    ax1.set_xlabel("Duration")
    df_train['Packets'].hist(ax=ax2)
    ax2.set_xlabel("Number of packets")
    df_train['Bytes'].hist(ax=ax3)
    ax3.set_xlabel("Number of bytes")
    plt.show()
    

我们得到如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_004..jpg

图 16.4 – 持续时间、数据包数量和字节数的重新缩放分布

这些新分布不是高斯分布,但值分布更加分散,这应该有助于模型。

请注意,我们处理的数据集是纯粹的表格数据。在将其输入到图神经网络(GNN)之前,我们仍然需要将其转换为图数据集。在我们的例子中,没有明显的方法将流量转换为节点。理想情况下,同一计算机之间的流量应该是相连的。可以通过使用具有两种类型节点的异构图来实现:

  • 主机,它们对应于计算机,并使用 IP 地址作为特征。如果我们有更多信息,可以添加其他与计算机相关的特征,例如日志或 CPU 利用率。

  • 流量,它们对应于两个主机之间的连接。它们考虑数据集中的所有其他特征。它们还有我们要预测的标签(良性或恶意流量)。

在这个例子中,流量是单向的,这也是我们定义两种类型的边的原因:主机到流量(源)和流量到主机(目标)。单一图形会占用过多内存,因此我们将其分割成子图,并将它们放入数据加载器中:

  1. 我们定义了批量大小以及我们希望考虑的主机和流节点的特征:

    BATCH_SIZE = 16
    features_host = [f'ipsrc_{i}' for i in range(1, 17)] + [f'ipdst_{i}' for i in range(1, 17)]
    features_flow = ['daytime', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Duration', 'Packets', 'Bytes', 'ACK', 'PSH', 'RST', 'SYN', 'FIN', 'ICMP ', 'IGMP ', 'TCP  ', 'UDP  ']
    
  2. 我们定义一个函数来创建数据加载器。它接受两个参数:我们创建的表格数据框和子图大小(在本例中为1024个节点):

    def create_dataloader(df, subgraph_size=1024):
    
  3. 我们初始化一个名为data的列表来存储我们的子图,并计算将要创建的子图数量:

        data = []
        n_subgraphs = len(df) // subgraph_size
    
  4. 对于每个子图,我们从数据框中检索相应的样本、源 IP 地址列表和目标 IP 地址列表:

        for i in range(1, n_batches+1):
            subgraph = df[(i-1)*subgraph_size:i*subgraph_size]
            src_ip = subgraph['Src IP Addr'].to_numpy()
            dst_ip = subgraph['Dst IP Addr'].to_numpy()
    
  5. 我们创建一个字典,将 IP 地址映射到节点索引:

    ip_map = {ip:index for index, ip in enumerate(np.unique(np.append(src_ip, dst_ip)))}
    
  6. 这个字典将帮助我们从主机到流量、以及反向生成边索引。我们使用一个名为get_connections()的函数,在这之后我们会创建它。

    host_to_flow, flow_to_host = get_connections(ip_map, src_ip, dst_ip)
    
  7. 我们使用迄今为止收集的所有数据,为每个子图创建一个异构图,并将其附加到列表中:

            batch = HeteroData()
            batch['host'].x = torch.Tensor(subgraph[features_host].to_numpy()).float()
            batch['flow'].x = torch.Tensor(subgraph[features_flow].to_numpy()).float()
            batch['flow'].y = torch.Tensor(subgraph[labels].to_numpy()).float()
            batch['host','flow'].edge_index = host_to_flow
            batch['flow','host'].edge_index = flow_to_host
            data.append(batch)
    
  8. 最后,我们返回具有适当批量大小的数据加载器:

    return DataLoader(data, batch_size=BATCH_SIZE)
    
  9. 还有一个函数我们需要实现——get_connections()——它根据源 IP 地址和目标 IP 地址的列表以及它们的对应映射,计算出两个边索引:

    def get_connections(ip_map, src_ip, dst_ip):
    
  10. 我们从 IP 地址(源和目标)中获取索引并将它们堆叠起来:

        src1 = [ip_map[ip] for ip in src_ip]
        src2 = [ip_map[ip] for ip in dst_ip]
        src = np.column_stack((src1, src2)).flatten()
    
  11. 这些连接是独特的,因此我们可以轻松地使用适当的数字范围对其进行索引:

        dst = list(range(len(src_ip)))
        dst = np.column_stack((dst, dst)).flatten()
    
  12. 最后,我们返回以下两个边索引:

    return torch.Tensor([src, dst]).int(), torch.Tensor([dst, src]).int()
    
  13. 现在我们拥有所需的一切,可以调用第一个函数来创建训练、验证和测试数据加载器:

    train_loader = create_dataloader(df_train)
    val_loader = create_dataloader(df_val)
    test_loader = create_dataloader(df_test)
    
  14. 现在,我们有三个数据加载器,分别对应于我们的训练集、验证集和测试集。下一步是实现 GNN 模型。

实现异构 GNN

在本节中,我们将使用GraphSAGE操作符实现异构 GNN。该架构将允许我们同时考虑两种节点类型(主机和流)来构建更好的嵌入。通过跨不同层复制和共享消息来实现这一点,如下图所示。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/Image98417.jpg

图 16.5 – 异构 GNN 的架构

我们将为每种节点类型实现三层SAGEConv,并使用LeakyRELU激活函数。最后,一个线性层将输出一个五维向量,其中每个维度对应一个类别。此外,我们将使用交叉熵损失和Adam优化器以监督方式训练该模型:

  1. 我们从 PyTorch Geometric 中导入相关的神经网络层:

    import torch_geometric.transforms as T
    from torch_geometric.nn import Linear, HeteroConv, SAGEConv
    
  2. 我们定义异构 GNN 的三个参数:隐藏维度的数量、输出维度的数量和层数:

    class HeteroGNN(torch.nn.Module):
        def __init__(self, dim_h, dim_out, num_layers):
            super().__init__()
    
  3. 我们为每个层和边类型定义了一个异构版本的GraphSAGE操作符。在这里,我们可以为每种边类型应用不同的 GNN 层,如GCNConvGATConvHeteroConv()包装器管理层之间的消息传递,如图 16.5所示:

            self.convs = torch.nn.ModuleList()
            for _ in range(num_layers):
                conv = HeteroConv({
                    ('host', 'to', 'flow'): SAGEConv((-1,-1), dim_h, add_self_loops=False),
                    ('flow', 'to', 'host'): SAGEConv((-1,-1), dim_h, add_self_loops=False),
                }, aggr='sum')
                self.convs.append(conv)
    
  4. 我们定义一个线性层,将输出最终的分类结果:

            self.lin = Linear(dim_h, dim_out)
    
  5. 我们创建了forward()方法,该方法计算主机节点和流节点的嵌入(存储在x_dict字典中)。然后,流的嵌入用于预测一个类别:

        def forward(self, x_dict, edge_index_dict):
            for conv in self.convs:
                x_dict = conv(x_dict, edge_index_dict)
                x_dict = {key: F.leaky_relu(x) for key, x in x_dict.items()}
            return self.lin(x_dict['flow'])
    
  6. 我们实例化异构 GNN,设置 64 个隐藏维度、5 个输出(即 5 个类别)和 3 层。如果可用,我们将其放置在 GPU 上,并创建一个学习率为0.001Adam优化器:

    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = HeteroGNN(dim_h=64, dim_out=5, num_layers=3).to(device)
    optimizer = Adam(model.parameters(), lr=0.001)
    
  7. 我们定义test()函数并创建数组来存储预测结果和true标签。我们还希望统计子图的数量和总损失,因此我们创建了相应的变量:

    @torch.no_grad()
    def test(loader):
        model.eval()
        y_pred = []
        y_true = []
        n_subgraphs = 0
        total_loss = 0
    
  8. 我们获取每个批次的模型预测结果,并计算交叉熵损失:

       for batch in loader:
            batch.to(device)
            out = model(batch.x_dict, batch.edge_index_dict)
            loss = F.cross_entropy(out, batch['flow'].y.float())
    
  9. 我们将预测的类别追加到预测列表中,并对true标签执行相同操作:

            y_pred.append(out.argmax(dim=1))
            y_true.append(batch['flow'].y.argmax(dim=1))
    
  10. 我们如下统计子图数量和总损失:

            n_subgraphs += BATCH_SIZE
            total_loss += float(loss) * BATCH_SIZE
    
  11. 批处理循环结束后,我们使用预测和true标签列表计算宏平均F1分数。宏平均F1分数在这种类别不平衡的学习设置中是一个很好的指标,因为它平等对待所有类别,而不管样本数量:

        y_pred = torch.cat(y_pred).cpu()
        y_true = torch.cat(y_true).cpu()
        f1score = f1_score(y_true, y_pred, average='macro')
    
  12. 我们返回最终的损失、宏平均F1分数、预测列表和true标签列表:

        return total_loss/n_subgraphs, f1score, y_pred, y_true
    
  13. 我们创建训练循环,训练模型101个 epochs:

    model.train()
    for epoch in range(101):
        n_subgraphs = 0
        total_loss = 0
    
  14. 我们使用交叉熵损失在每个批次上训练异构 GNN:

        for batch in train_loader:
            optimizer.zero_grad()
            batch.to(device)
            out = model(batch.x_dict, batch.edge_index_dict)
            loss = F.cross_entropy(out, batch['flow'].y.float())
            loss.backward()
            optimizer.step()
            n_subgraphs += BATCH_SIZE
            total_loss += float(loss) * BATCH_SIZE
    
  15. 每经过 10 个 epochs,我们在验证集上评估模型并展示相关指标(训练损失、验证损失和验证的宏平均F1分数):

        if epoch % 10 == 0:
            val_loss, f1score, _, _ = test(val_loader)
            print(f'Epoch {epoch} | Loss: {total_loss/n_subgraphs:.4f} | Val loss: {val_loss:.4f} | Val F1 score: {f1score:.4f}')
    

我们在训练过程中获得如下输出:

Epoch 0 | Loss: 0.1006 | Val loss: 0.0072 | Val F1 score: 0.6044
Epoch 10 | Loss: 0.0020 | Val loss: 0.0021 | Val F1-score: 0.8899
Epoch 20 | Loss: 0.0015 | Val loss: 0.0015 | Val F1-score: 0.9211
...
Epoch 90 | Loss: 0.0004 | Val loss: 0.0008 | Val F1-score: 0.9753
Epoch 100 | Loss: 0.0004 | Val loss: 0.0009 | Val F1-score: 0.9785
  1. 最后,我们在测试集上评估模型。我们还打印scikit-learn的分类报告,其中包括宏平均F1分数:

    _, _, y_pred, y_true = test(test_loader)
    print(classification_report(y_true, y_pred, target_names=labels, digits=4))
                  precision    recall  f1-score   support
          benign     0.9999    0.9999    0.9999    700791
      bruteForce     0.9811    0.9630    0.9720       162
             dos     1.0000    1.0000    1.0000    125164
        pingScan     0.9413    0.9554    0.9483       336
        portScan     0.9947    0.9955    0.9951     18347
        accuracy                         0.9998    844800
       macro avg     0.9834    0.9827    0.9831    844800
    weighted avg     0.9998    0.9998    0.9998    844800
    

我们获得了一个宏观平均的F1分数为0.9831。这个优秀的结果表明我们的模型已经学会了可靠地预测每个类别。

我们采用的方法如果能够获取更多主机相关的特征,将更加相关,但它展示了如何根据需求扩展该方法。GNN 的另一个主要优势是其处理大量数据的能力。当处理数百万条流量时,这种方法显得尤为合理。为了完成这个项目,让我们绘制模型的错误图,以便看到如何改进它。

我们创建了一个数据框来存储预测值(y_pred)和真实标签(y_true)。我们使用这个新的数据框来绘制误分类样本的比例:

df_pred = pd.DataFrame([y_pred.numpy(), y_true.numpy()]).T
df_pred.columns = ['pred', 'true']
plt.pie(df_pred['true'][df_pred['pred'] != df_pred['true']].value_counts(), labels=labels, autopct='%.0f%%')

这给我们带来了以下图表:

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_006..jpg

图 16.6 – 每个误分类类别的比例

如果我们将这个饼图与数据集中的原始比例进行比较,可以看到模型在多数类别上的表现更好。这并不令人惊讶,因为少数类别更难学习(样本较少),且未检测到这些类别的惩罚较轻(例如,700,000 个良性流量与 336 个 ping 扫描)。端口和 ping 扫描的检测可以通过过采样和在训练过程中引入类别权重等技术来改进。

我们可以通过查看混淆矩阵获取更多信息(代码可以在 GitHub 上找到)。

https://github.com/OpenDocCN/freelearn-dl-pt4-zh/raw/master/docs/hsn-gnn-py/img/B19153_16_007..jpg

图 16.7 – 多类流量分类的混淆矩阵

该混淆矩阵显示了有趣的结果,比如对良性类别的偏向,或者在 ping 和端口扫描之间的错误。这些错误可以归因于这些攻击之间的相似性。通过工程化地增加特征,可以帮助模型区分这些类别。

总结

在本章中,我们探讨了使用 GNN(图神经网络)检测新数据集中的异常,具体是CIDDS-001数据集。首先,我们对数据集进行了预处理,并将其转换为图形表示,这使我们能够捕捉网络中不同组件之间的复杂关系。然后,我们实现了一个异质图神经网络(GNN),采用了GraphSAGE操作符。该模型捕捉了图的异质性,并使我们能够将流量分类为良性或恶性。

GNN 在网络安全中的应用已经展示了良好的效果,并为研究开辟了新的方向。随着技术的不断进步和网络数据量的增加,GNN 将成为检测和防止安全漏洞的一个越来越重要的工具。

第十七章,《使用 LightGCN 推荐书籍》中,我们将探讨 GNN 在推荐系统中的最常见应用。我们将在大规模数据集上实现一个轻量级的 GNN,并为特定用户提供书籍推荐。

进一步阅读

  • [1] M. Ring, S. Wunderlich, D. Grüdl, D. Landes, 和 A. Hotho,基于流量的入侵检测基准数据集,载于 第 16 届欧洲网络战争与安全会议论文集(ECCWS),ACPI,2017,页 361–369。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐