Loading

半监督学习图神经网络节点分类实践

参考https://andyguo.blog.csdn.net/article/details/117969648

一、为什么要在图上进行神经网络学习

在过去的深度学习应用中,我们接触的数据形式主要是这四种:矩阵、张量、序列(sequence)和时间序列(time series)。然而来自现实世界应用的数据更多地是图的结构,如社交网络、交通网络、蛋白质与蛋白质相互作用网络、知识图谱和大脑网络等。图提供了一种通用的数据表示方法,众多其他类型的数据也可以转化为图的形式。

1.1、问题的分类

大量的现实世界的问题可以作为图上的一组小的计算任务来解决。推断节点属性、检测异常节点(如垃圾邮件发送者)、识别与疾病相关的基因、向病人推荐药物等,都可以概括为节点分类问题。
推荐、药物副作用预测、药物与目标的相互作用识别和知识图谱的完成等,本质上都是边预测问题。同一图的节点存在连接关系,这表明节点不是独立的。

然而,传统的机器学习技术假设样本是独立且同分布的,因此传统机器学习方法不适用于图计算任务。

图机器学习研究如何构建节点表征,节点表征要求同时包含节点自身的信息和节点邻接的信息,从而我们可以在节点表征上应用传统的分类技术实现节点分类。图机器学习成功的关键在于如何为节点构建表征。

二、将神经网络应用于图的挑战

传统的深度学习是为规则且结构化的数据设计的,图像、文本、语音和时间序列等都是规则且结构化的数据。但图是不规则的,节点是无序的,节点可以有不同的邻居节点。
规则数据的结构信息是简单的,而图的结构信息是复杂的,特别是在考虑到各种类型的复杂图,它们的节点和边可以关联丰富的信息,这些丰富的信息无法被传统的深度学习方法捕获。
图深度学习是一个新兴的研究领域,它将深度学习技术与图数据连接起来,推动了现实中的图预测应用的发展。然而,此研究领域也面临着前所未有的挑战。

以上内容整理自Deep Learning on Graphs: An Introduction

三、简单图论

3.1、图的表示

先介绍几种图概念

1、同构图:在图里面,节点的类型和边的类型只有一种的图,举个例子,像社交网络中只存在一种节点类型,用户节点和一种边的类型,用户-用户之间的连边。

2、异构图:在图里面,节点的类型+边的类型>2的一种图,举个例子,论文引用网络中,存在着作者节点和paper节点,边的关系有作者-作者之间的共同创作关系连边,作者-论文之间的从属关系,论文-论文之间的引用关系。

3、属性图:图的节点上存在着初始属性attribute,可以用作后续节点的特征

4、动态图:图中的节点或者边都是随着时间变化的,可能增加或减少,一般是图的构成是按照时间片来构成,每一个时间片一个图的表示,例如t1时刻的图是初始图,t2时刻的图就是节点或连边变化后的图一直到tn时刻

5、关系图:图表示了一种节点之间的隐含关系,举个例子 知识图谱

3.2、属性

1、拉普拉斯矩阵, Laplacian Matrix

  • 给定一个图 \(\mathcal{G}=\{\mathcal{V}, \mathcal{E}\}\), 其邻接矩阵为 \(A\), 其拉普拉斯矩阵定义为 \(\mathbf{L}=\mathbf{D}-\mathbf{A}\), 其中

\[\mathbf{D}=\operatorname{diag}\left(\mathbf{d}\left(\mathbf{v}_{\mathbf{1}}\right), \cdots, \mathbf{d}\left(\mathbf{v}_{\mathbf{N}}\right)\right) \]

2、对称归一化的拉普拉斯矩阵, Symmetric normalized Laplacian

  • 给定一个图 \(\mathcal{G}=\{\mathcal{V}, \mathcal{E}\}\), 其邻接矩阵为 \(A\) ,其规范化的拉普拉斯矩阵定义为

\[\mathbf{L}=\mathbf{D}^{-\frac{1}{2}}(\mathbf{D}-\mathbf{A}) \mathbf{D}^{-\frac{1}{2}}=\mathbf{I}-\mathbf{D}^{-\frac{1}{2}} \mathbf{A} \mathbf{D}^{-\frac{1}{2}} \]

四、图结构数据上的机器学习

  1. 节点预测:预测节点的类别或某类属性的取值
    例子:对是否是潜在客户分类、对游戏玩家的消费能力做预测
  2. 边预测:预测两个节点间是否存在链接
    例子:Knowledge graph completion、好友推荐、商品推荐
  3. 图的预测:对不同的图进行分类或预测图的属性
    例子:分子属性预测
  4. 节点聚类:检测节点是否形成一个社区
    例子:社交圈检测

五、 消息传递范式介绍

https://andyguo.blog.csdn.net/article/details/118053685

下方图片展示了基于消息传递范式的聚合邻接节点信息来更新中心节点信息的过程:

gnn-gcn

图中黄色方框部分展示的是一次邻接节点信息传递到中心节点的过程:

1、B节点的邻接节点(A,C)的信息经过变换后聚合到B节点,

2、接着B节点信息与邻接节点聚合信息一起经过变换得到B节点的新的节点信息。

3、同时,分别如红色和绿色方框部分所示,遵循同样的过程,C、D节点的信息也被更新。实际上,同样的过程在所有节点上都进行了一遍,所有节点的信息都更新了一遍。

4、这样的“邻接节点信息传递到中心节点的过程”会进行多次。如图中蓝色方框部分所示,A节点的邻接节点(B,C,D)的已经发生过一次更新的节点信息,经过变换、聚合、再变换产生了A节点第二次更新的节点信息。

5、多次更新后的节点信息就作为节点表征。

5.1、公式描述

消息传递图神经网络遵循上述的“聚合邻接节点信息来更新中心节点信息的过程”,来生成节点表征。

\(\mathbf{x}_{i}^{(k-1)} \in \mathbb{R}^{F}\) 表示 \((k-1)\) 层中节点 \(i\) 的节点表征, \(\mathbf{e}_{j, i} \in \mathbb{R}^{D}\) 表示从节点 \(j\) 到节点 \(i\) 的边的属性。

消息传递图神经网络可以描述为表示从节点 j到节点i的边的属性,消息传递图神经网络可以描述为:

\[\mathbf{x}_i^{(k)} = \gamma^{(k)} \left( \mathbf{x}_i^{(k-1)}, \square_{j \in \mathcal{N}(i)} \, \phi^{(k)}\left(\mathbf{x}_i^{(k-1)}, \mathbf{x}_j^{(k-1)},\mathbf{e}_{j,i}\right) \right), \]

其中 $\square $表示可微分的、具有排列不变性,函数输出结果与输入参数的排列无关的函数。

具有排列不变性的函数有,sum()函数、mean()函数和max()函数。
\(\gamma\)\(\phi\)表示可微分的函数,如MLPs(多层感知器)。

5.2、MessagePassing基类初步分析

Pytorch Geometric(PyG)提供了MessagePassing基类,它封装了“消息传递”的运行流程。通过继承MessagePassing基类,可以方便地构造消息传递图神经网络。

构造一个最简单的消息传递图神经网络类,我们只需定义message()方法(\(\phi\))、update()方法( γ \gamma γ),以及使用的消息聚合方案(aggr="add"、aggr="mean"或aggr="max")。

5.2.1、对象初始化方法

MessagePassing(aggr="add", flow="source_to_target", node_dim=-2)

  • aggr:定义要使用的聚合方案(“add”、"mean "或 “max”);
  • flow:定义消息传递的流向("source_to_target "或 “target_to_source”);
  • node_dim:定义沿着哪个维度传播,默认值为-2,也就是节点表征张量(Tensor)的哪一个维度是节点维度。节点表征张量x形状为[num_nodes, num_features],其第0维度(也是第-2维度)是节点维度,其第1维度(也是第-1维度)是节点表征维度,所以我们可以设置node_dim=-2。
  • 注:MessagePassing(……)等同于MessagePassing.init(……)

5.3、举例

我们以继承 MessagePassing 基类的 GCNConv 类为例,学习如何通过继承 MessagePassing 基类来实现一个 简单的图神经网络。
GCNConv 的数学定义为

\[\mathbf{x}_{i}^{(k)}=\sum_{j \in \mathcal{N}(i) \cup\{i\}} \frac{1}{\sqrt{\operatorname{deg}(i)} \cdot \sqrt{\operatorname{deg}(j)}} \cdot\left(\boldsymbol{\Theta} \cdot \mathbf{x}_{j}^{(k-1)}\right) \]

其中,邻接节点的表征 \(\mathbf{x}_{j}^{(k-1)}\) 首先通过与权重矩阵 \(\Theta\) 相乘进行变换,然后按端点的度 \(\operatorname{deg}(i), \operatorname{deg}(j)\) 进行 归一化处理,最后进行求和。这个公式可以分为以下几个步骤:

  1. 向邻接矩阵添加自环边。
  2. 对节点表征做线性转换。
  3. 计算归一化系数。
  4. 归一化邻接节点的节点表征。
  5. 将相邻节点表征相加("求和 "聚合)。

步骤1-3通常是在消息传递发生之前计算的。
步骤4-5可以使用MessagePassing基类轻松处理。该层的全部实现如下所示。

import torch
from torch_geometric.nn import MessagePassing
from torch_geometric.utils import add_self_loops, degree

class GCNConv(MessagePassing):
    def __init__(self, in_channels, out_channels):
        super(GCNConv, self).__init__(aggr='add', flow='source_to_target')
        # "Add" aggregation (Step 5).
        # flow='source_to_target' 表示消息从源节点传播到目标节点
        self.lin = torch.nn.Linear(in_channels, out_channels)

    def forward(self, x, edge_index):
        # x has shape [N, in_channels]
        # edge_index has shape [2, E]

        # Step 1: Add self-loops to the adjacency matrix.
        # 第一步:向邻接矩阵添加自环边
        edge_index, _ = add_self_loops(edge_index, num_nodes=x.size(0))

        # Step 2: Linearly transform node feature matrix.
        # 第二步:对节点表征做线性转换
        x = self.lin(x)

        # Step 3: Compute normalization.
        # 第三步:计算归一化系数
        row, col = edge_index
        deg = degree(col, x.size(0), dtype=x.dtype)
        deg_inv_sqrt = deg.pow(-0.5)
        norm = deg_inv_sqrt[row] * deg_inv_sqrt[col]

        # Step 4-5: Start propagating messages.
        # 第四五步:归一化邻接节点的节点表征;将相邻节点表征相加("求和 "聚合)
        return self.propagate(edge_index, x=x, norm=norm)

    def message(self, x_j, norm):
        # x_j has shape [E, out_channels]
        # Step 4: Normalize node features.
        return norm.view(-1, 1) * x_j

六、半监督学习节点分类

6.1、任务

  • 学习实现多层图神经网络的方法
  • 并以节点分类任务为例,学习训练图神经网络的一般过程
  • 我们将以Cora数据集为例子进行说明,Cora是一个论文引用网络,节点代表论文,如果两篇论文存在引用关系,则对应的两个节点之间存在边,各节点的属性都是一个1433维的词包特征向量.
  • 我们要做一些准备工作,即获取并分析数据集、构建一个方法用于分析节点表征的分布。
  • 我们考察MLP神经网络用于节点分类的表现,并观察基于MLP神经网络学习到的节点表征的分布。
  • 我们逐一介绍GCN, GAT这两个图神经网络的理论、对比它们在节点分类任务中的表现以及它们学习到的节点表征的质量。
  • 我们比较三者在节点表征学习能力上的差异。
  1. GAT与GCN的联系与区别

    • 无独有偶,我们可以发现本质上而言:GCN与GAT都是将邻居顶点的特征聚合到中心顶点上(一种aggregate运算),利用graph上的local stationary学习新的顶点特征表达。

    • 不同的是GCN利用了拉普拉斯矩阵,GAT利用attention系数。

    • 一定程度上而言,GAT会更强,因为 顶点特征之间的相关性被更好地融入到模型中。

  2. 为什么GAT适用于有向图?

    我认为最根本的原因是GAT的运算方式是逐顶点的运算(node-wise),这一点可从公式(1)—公式(3)中很明显地看出。每一次运算都需要循环遍历图上的所有顶点来完成。逐顶点运算意味着,摆脱了拉普利矩阵的束缚,使得有向图问题迎刃而解。\

  3. 为什么GAT适用于inductive任务?

  4. GAT中重要的学习参数是 W 与 a(·) ,因为上述的逐顶点运算方式,这两个参数仅与顶点特征相关,与图的结构毫无关系。所以测试任务中改变图的结构,对于GAT影响并不大,只需要改变 Ni,重新计算即可。

  5. 与此相反的是,GCN是一种全图的计算方式,一次计算就更新全图的节点特征。学习的参数很大程度与图结构相关,这使得GCN在inductive任务上遇到困境。

6.2、准备数据

from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures

dataset = Planetoid(root='dataset', name='Cora', transform=NormalizeFeatures())

print()
print(f'Dataset: {dataset}:')
print('======================')
print(f'Number of graphs: {len(dataset)}')
print(f'Number of features: {dataset.num_features}')
print(f'Number of classes: {dataset.num_classes}')

data = dataset[0]  # Get the first graph object.

print()
print(data)
print('======================')

# Gather some statistics about the graph.
print(f'Number of nodes: {data.num_nodes}')
print(f'Number of edges: {data.num_edges}')
print(f'Average node degree: {data.num_edges / data.num_nodes:.2f}')
print(f'Number of training nodes: {data.train_mask.sum()}')
print(f'Training node label rate: {int(data.train_mask.sum()) / data.num_nodes:.2f}')
print(f'Contains isolated nodes: {data.contains_isolated_nodes()}')
print(f'Contains self-loops: {data.contains_self_loops()}')
print(f'Is undirected: {data.is_undirected()}')

输出的结果为:

Dataset: Cora():
======================
Number of graphs: 1
Number of features: 1433
Number of classes: 7

Data(edge_index=[2, 10556], test_mask=[2708], train_mask=[2708], val_mask=[2708], x=[2708, 1433], y=[2708])
======================
Number of nodes: 2708
Number of edges: 10556
Average node degree: 3.90
Number of training nodes: 140
Training node label rate: 0.05
Contains isolated nodes: False
Contains self-loops: False
Is undirected: True

Cora图拥有2,708个节点,10,556条边,平均节点度为3.9

训练集仅使用了140个节点,占整体的5%。

我们还可以看到,这个图是无向图(undirected),不存在孤立的节点。

数据转换(transform)在将数据输入到神经网络之前修改数据,这一功能可用于实现数据规范化或数据增强。在此例子中,我们使用NormalizeFeatures进行节点特征归一化,使各节点特征总和为1。其他的数据转换方法请参阅torch-geometric-transforms。

6.3、使用MLP神经网络进行节点分类

多层感知机MLP神经网络,其输入层、隐藏层和输出层,MLP神经网络不同层之间是全连接的。

理论上,我们应该能够仅根据文章的内容,即它的词包特征表征来推断文章的类别,而无需考虑文章之间的任何关系信息。接下来,让我们通过构建一个简单的MLP神经网络来验证这一点。
MLP神经网络只对输入节点的表征做变换,它在所有节点之间共享权重。

import torch
from torch.nn import Linear
import torch.nn.functional as F

class MLP(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(MLP, self).__init__()
        torch.manual_seed(12345)
        self.lin1 = Linear(dataset.num_features, hidden_channels)
        self.lin2 = Linear(hidden_channels, dataset.num_classes)
      def forward(self, x):
        x = self.lin1(x)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.lin2(x)
        return x
model = MLP(hidden_channels=16)
print(model)

MLP由两个线性层、一个ReLU非线性层和一个dropout操作组成。

第一个线性层将1433维的节点表征嵌入(embedding)到低维空间中(hidden_channels=16

第二个线性层将节点表征嵌入到类别空间中(num_classes=7)。

6.3.1、MLP神经网络的训练

常见的损失函数有平方损失函数——常用于回归类问题

交叉熵损失——常用于分类问题

交叉熵衡量的是数据标签的真实分布与分类模型预测的概率分布之间的差异程度。

我们利用交叉熵损失和Adam优化器来训练这个简单的MLP神经网络。

model = MLP(hidden_channels=16)
criterion = torch.nn.CrossEntropyLoss()  # 定义损失标准
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)  # 定义Adam优化器

def train():
    model.train()
    optimizer.zero_grad()  # 清除梯度
    out = model(data.x)  # Perform a single forward pass.执行单次向前传播
    loss = criterion(out[data.train_mask], data.y[data.train_mask])  #根据训练节点计算损失
    # Compute the loss solely based on the training nodes.
    loss.backward()  # Derive gradients,获取梯度
    optimizer.step()  # Update parameters based on gradients.根据梯度更新参数
    return loss
  
import pandas as pd
df = pd.DataFrame(columns = ["Loss"])
df.index.name = "Epoch"
for epoch in range(1, 201):
    loss =train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
    df.loc[epoch] = loss.item()
df.plot()

img

6.3.2、测试

# 模型测试
def test():
    model.eval()
    out = model(data.x)
    使用概率最大的作为类别标签
    pred = out.argmax(dim=1)  # Use the class with highest probability.
    # 根据实际的标签进行检查
    test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
    # 得出预测正确的比例
    test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
    # 返回预测正确的比例
    return test_acc

# 输出模型的准确性
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

# 可视化训练过的MLP模型输出的节点表征
model.eval()
out = model(data.x)
visualize(out, color = data.y)

Test Accuracy: 0.5900

为什么MLP没有表现得更好呢
其中一个重要原因是,用于训练此神经网络的有标签节点数量过少,此神经网络被过拟合,它对未见过的节点泛化能力很差。

6.4、卷积图神经网络(GCN)

输入为C个通道,输出为F个通道,\(Y_1\)\(Y_2\) 为节点标签

将上面例子中的torch.nn.Linear替换成torch_geometric.nn.GCNConv,我们就可以得到一个GCN图神经网络,如下方代码所示:

from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(GCN, self).__init__()
        torch.manual_seed(12345)
        self.conv1 = GCNConv(dataset.num_features, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, dataset.num_classes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GCN(hidden_channels=16)
print(model)

6.4.1、可视化由未经训练的GCN图神经网络生成的节点表征

model = GCN(hidden_channels=16)
model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)
def visualize(embedding, value):
    z = TSNE(n_components=2).fit_transform(embedding.detach().cpu().numpy())
    plt.figure(figsize=(16, 12))
    plt.xticks([])
    plt.yticks([])
    plt.scatter(z[:, 0], z[:, 1], s=30, c=value, cmap="Set2", zorder=2)
    plt.show()

image-20210709144549201

经过visualize函数的处理,7维特征的节点被映射到2维的平面上。我们会惊喜地看到“同类节点群聚”的现象。

6.4.2、GCN图神经网络的训练

通过下方的代码我们可实现GCN图神经网络的训练:

model = GCN(hidden_channels=16)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

def train():
      model.train()
      optimizer.zero_grad()  # Clear gradients.
      out = model(data.x, data.edge_index)  # Perform a single forward pass.
      loss = criterion(out[data.train_mask], data.y[data.train_mask])  # Compute the loss solely based on the training nodes.
      loss.backward()  # Derive gradients.
      optimizer.step()  # Update parameters based on gradients.
      return loss

for epoch in range(1, 201):
    loss = train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')

6.4.3、GCN图神经网络的测试

在训练过程结束后,我们检测GCN图神经网络在测试集上的准确性:

def test():
      model.eval()
      out = model(data.x, data.edge_index)
      pred = out.argmax(dim=1)  # Use the class with highest probability.
      test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
      test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
      return test_acc

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')

输出的结果为:

Test Accuracy: 0.8140

与前面的仅获得59%的测试准确率的MLP图神经网络相比,GCN图神经网络准确性要高得多。这表明节点的邻接信息在取得更好的准确率方面起着关键作用。

6.4.4、视化由训练后的GCN图神经网络生成的节点表征

最后我们可视化训练后的GCN图神经网络生成的节点表征,我们会发现“同类节点群聚”的现象更加明显了。这意味着在训练后,GCN图神经网络生成的节点表征质量更高了。

model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)

image-20210709144616026

6.5、图注意力神经网络(GAT)

import torch
from torch.nn import Linear
import torch.nn.functional as F

from torch_geometric.nn import GATConv

class GAT(torch.nn.Module):
    def __init__(self, hidden_channels):
        super(GAT, self).__init__()
        torch.manual_seed(12345)
        self.conv1 = GATConv(dataset.num_features, hidden_channels)
        self.conv2 = GATConv(hidden_channels, dataset.num_classes)

    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = x.relu()
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x
model = GAT(hidden_channels=16)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()

def train():
      model.train()
      optimizer.zero_grad()  # Clear gradients.
      out = model(data.x, data.edge_index)  # Perform a single forward pass.
      loss = criterion(out[data.train_mask], data.y[data.train_mask])  # Compute the loss solely based on the training nodes.
      loss.backward()  # Derive gradients.
      optimizer.step()  # Update parameters based on gradients.
      return loss

for epoch in range(1, 201):
    loss = train()
    print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
    
def test():
      model.eval()
      out = model(data.x, data.edge_index)
      pred = out.argmax(dim=1)  # Use the class with highest probability.
      test_correct = pred[data.test_mask] == data.y[data.test_mask]  # Check against ground-truth labels.
      test_acc = int(test_correct.sum()) / int(data.test_mask.sum())  # Derive ratio of correct predictions.
      return test_acc

test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
Test Accuracy: 0.7380

七、GCN图神经网络与GAT图神经网络的区别

在于采取的归一化方法不同:

  • 前者根据中心节点与邻接节点的度计算归一化系数,后者根据中心节点与邻接节点的相似度计算归一化系数。
  • 前者的归一化方式依赖于图的拓扑结构:不同的节点会有不同的度,同时不同节点的邻接节点的度也不同,于是在一些应用中GCN图神经网络会表现出较差的泛化能力。
  • 后者的归一化方式依赖于中心节点与邻接节点的相似度,相似度是训练得到的,因此不受图的拓扑结构的影响,在不同的任务中都会有较好的泛化表现。
posted @ 2021-07-09 15:20  xine  阅读(4809)  评论(4编辑  收藏  举报