基于图神经网络的节点表征学习
类似于之前学习任务的特征构造,在图节点预测或者边预测任务中,构造节点表征也是非常重要的一环。
在节点预测任务中,我们拥有一个图,图上有很多节点,部分节点的预测标签已知,部分节点的预测标签未知。我们的任务是根据节点的属性(可以是类别型、也可以是数值型)、边的信息、边的属性(如果有的话)、已知的节点预测标签,对未知标签的节点做预测。
本节会以Cora数据集为例,Cora是一个论文引用网络,节点代表论文,如果两篇论文存在引用关系,那么认为对应的两个节点之间存在边,每个节点由一个1433维的词包特征向量描述。我们的任务是推断每个文档的类别(共7类)。
内容安排:
- 获取并分析数据集、构建一个方法用于分析节点表征的分布;
- 考察MLP用于节点分类的表现,并分析基于MLP学习到的节点表征的分布;
- 逐一介绍GCN, GAT这两个图神经网络的理论、他们在节点分类任务中的表现以及它们学习到的节点表征的分布;
- 比较三者在节点表征学习能力上的差异。
一、准备工作
1、获取并分析数据集
Python3.6新增了一种f-字符串格式化。
格式化的字符串文字前缀为’f’和接受的格式字符串相似str.format()。它们包含由花括号包围的替换区域。替换字段是表达式,在运行时进行评估,然后使用format()协议进行格式化。
print(f"Sum of a and b is {S}, and product is {P}") print(“Sum of a
and b is {}, and product is {}”.format(‘10’, ‘20’))
from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures
dataset = Planetoid(root='dataset/Planetoid', name='Cora')
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]
print(data)
print('===================================')
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()}')
Cora 图有2708个节点和10556个边,平均节点度为 10556/2708=3.9。用于训练的节点为140个,这140个节点均有真实标签(每类20个),占比为 140/2708=5%。本图为无向图,不存在孤立的节点,即每个文档至少有一个引文。
数据转换:在数据输入到神经网络之前修改数据,可实现数据规范化或数据增强。
此例中,我们使用NormalizeFeatures,进行节点特征归一化,使各节点特征总和为1。
文档:https://pytorch-geometric.readthedocs.io/en/latest/modules/transforms.html#torch-geometric-transforms
2、可视化节点表征分布的方法
sklearn.manifold.TSNE
是可视化高维数据的工具。它将数据点之间的相似性转换为联合概率,并尝试最小化低维嵌入和高维数据的联合概率之间的Kullback-Leibler差异。t-SNE的成本函数不是凸的,即使用不同的初始化,我们可以获得不同的结果。
如果特征的数量非常多,强烈建议使用另一种降维方法(例如,对于密集数据使用PCA或对于稀疏数据使用TruncatedSVD)将尺寸数量减少到合理的数量(例如50个)。这将抑制一些噪声并加快样本之间成对距离的计算。
- t-SNE: t-distributed Stochastic Neighbor Embedding,t分布的随机相邻嵌入。
- 输入: N 个高维数据。
- 输出:低维数据 y, y 的维度为 d。
- t-SNE算法过程:
主要不足有四个: - 主要用于可视化,很难用于其他目的。比如测试集合降维,因为他没有显式的预估部分,不能在测试集合直接降维;比如降维到10维,因为t分布偏重长尾,1个自由度的t分布很难保存好局部特征,可能需要设置成更高的自由度。
- t-SNE倾向于保存局部特征,对于本征维数(intrinsic dimensionality)本身就很高的数据集,是不可能完整的映射到2-3维的空间。
- t-SNE没有唯一最优解,且没有预估部分。如果想要做预估,可以考虑降维之后,再构建一个回归方程之类的模型去做。但是要注意,t-sne中距离本身是没有意义,都是概率分布问题。
- 训练太慢。有很多基于树的算法在t-sne上做一些改进
用法:
这个函数的大概用途是将特征弄成一坨,变成一个变量,然后和聚类后的label画图。
https://scikit-learn.org/stable/modules/generated/sklearn.manifold.TSNE.html#sklearn.manifold.TSNE
output.detach().cpu().numpy()
PyTorch深度学习框架在训练时,大多都是利用GPU来提高训练速度,
model.cuda()
img = img.cuda()
output = model(img)
当我们需要对output进行进一步运算时,该运算会被记录在计算图中,从而计算梯度,为反向传播做准备。但事实上,我们只是想要显示他,不需要output进行反传,此时,detach()方法出现了。
output.detach() # 阻断反传
且经过detach()方法后,变量仍然在GPU上。而内存操作可能会找不到该变量,也就是说,show(output)是没办法进行操作的。那么cpu()出现了。
output = output.detach().cpu() # 移至cpu 返回值是cpu上的Tensor。
后续,则可以对该Tensor数据进行一系列操作,其中包括numpy(),该方法主要用于将cpu上的tensor转为numpy数据。
output = output.detach().cpu().numpy() # 返回值为numpy.array()
反向传播:https://zhuanlan.zhihu.com/p/115571464
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
def visualize(h, color):
z = TSNE(n_components=2).fit_transform(out.detach().cpu().numpy())
plt.figure(figsize=(10, 10))
plt.xticks([])
plt.yticks([])
plt.scatter(z[:, 0], z[:, 1], s=70, c=color, cmap="Set2")
plt.show()
为了实现节点表征分布的可视化,我们先利用TSNE将高维节点表征嵌入到二维平面空间,然后在二维平面空间画出节点。
二、MLP在图节点分类中的应用
MLP:多层感知机(MLP,Multilayer Perceptron)也叫人工神经网络(ANN,Artificial Neural Network),除了输入输出层,它中间可以有多个隐层,最简单的MLP只含一个隐层,即三层的结构,如下图:
多层感知机层与层之间是全连接的。多层感知机最底层是输入层,中间是隐藏层,最后是输出层。
理论上,我们应该能够仅根据文件的内容,即它的词包特征表示来推断文件的类别,而无需考虑文件之间的任何关系信息。让我们通过构建一个简单的MLP来验证这一点,该网络只对输入节点的特征进行操作,它在所有节点之间共享权重。
1、MLP 图节点分类器:
nn.Linear():
用于设置网络中的全连接层,需要注意的是全连接层的输入与输出都是二维张量。
一般形状为[batch_size, size],不同于卷积层要求输入输出是四维张量
- in_features指的是输入的二维张量的大小,即输入的[batch_size, size]中的size。batch_size 定义:一次训练所选取的样本数。
- out_features指的是输出的二维张量的大小,即输出的二维张量的形状为[batch_size,output_size],当然,它也代表了该全连接层的神经元个数。
- 从输入输出的张量的shape角度来理解,相当于一个输入为[batch_size,
in_features]的张量变换成了[batch_size, out_features]的输出张量。
ReLU
修正线性单元(rectified linear unit),在 0 和 x 之间取最大值。
因为 sigmoid 和 tanh 容易导致梯度消失,而 ReLU 是非饱和激活函数,不容易发生梯度消失。
ReLU 的函数表达式:
当 x <= 0 时,ReLU = 0
当 x > 0 时,ReLU = x
ReLU 的优点
① 有效缓解过拟合的问题,因为 ReLU 有可能使部分神经节点的输出变为 0,从而导致神经节点死亡,降低了神经网络的复杂度
② 不会发生梯度消失或梯度爆炸,当 x 大于 0 时,ReLU 的梯度恒为 1,不会随着网路深度的加深而使得梯度在累乘的时候变得越来越小或者越来越大,从而不会发生梯度消失或梯度爆炸
③ 计算简单,ReLU 本质上就是计算一次在两个值中取最大值
ReLU 的缺点
① 会导致神经元死亡,当一个神经元在某次的激活值为 0 之后,此后得到的激活值都是 0.
import torch
from torch.nn import Linear
import torch.nn.functional as F
class MLP(torch.nn.Module):
def __init__(self, hidden_channels):
super().__init__()
torch.manual_seed(12345) ## 为CPU设置种子用于生成随机数,以使得结果是确定的
self.lin1 = Linear(dataset.num_features, hidden_channels)
self.lin2 = Linear(hidden_channels, dataset.num_classes)
def forward(self, x):
x = self.lin1(x)
y = x.relu() # 激活函数
x = F.dropout(x, p=0.5, training=self.training) #深度学习架构现在变得越来越深,dropout作为一个防过拟合的手段,使用也越来越普遍。
x = self.lin2(x)
return x
model = MLP(hidden_channels=16)
print(model)
我们的MLP由两个线程层、一个ReLU非线性层和一个dropout操作。第一线程层将1433维的特征向量嵌入(embedding)到低维空间中(hidden_channels=16),第二个线性层将节点表征嵌入到类别空间中(num_classes=7)。
我们利用交叉熵损失和Adam优化器来训练这个简单的MLP网络。
torch.optim.Adam(params, lr=0.001, betas=(0.9, 0.999), eps=1e-08, weight_decay=0)
- params (iterable) – 待优化参数的iterable或者是定义了参数组的dict
- lr (float, 可选) – 学习率(默认:1e-3)。同样也称为学习率或步长因子,它控制了权重的更新比率(如 0.001)。较大的值(如 0.3)在学习率更新前会有更快的初始学习,而较小的值(如 1.0E-5)会令训练收敛到更好的性能。
- betas (Tuple[float, float], 可选) – 用于计算梯度以及梯度平方的运行平均值的系数(默认:0.9,0.999)。beta1: 一阶矩估计的指数衰减率;beta2:二阶矩估计的指数衰减率(如 0.999)。该超参数在稀疏梯度(如在 NLP 或计算机视觉任务中)中应该设置为接近 1 的数。
- eps (float, 可选) – 为了增加数值计算的稳定性而加到分母里的项(默认:1e-8)。该参数是非常小的数,其为了防止在实现中除以零。
- weight_decay (float, 可选) – 权重衰减(L2惩罚)(默认: 0)
model = MLP(hidden_channels=16)
criterion = torch.nn.CrossEntropyLoss() # define loss criterion 交叉熵损失
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) # define optimize ADAM 优化器
def train():
model.train()
optimizer.zero_grad() # clear gradients
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
for epoch in range(1, 201):
loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
训练完模型后,我们进行测试。
# 测试
def test():
model.eval()
out = model(data.x)
pred = out.argmax(dim=1) # 找到每次概率分布最大的index
print(pred)
print("********************************")
print(data.y)
print("********************************")
print(data.test_mask)
test_correct = pred[data.test_mask] == data.y[data.test_mask]
test_acc = int(test_correct.sum())/ int(data.test_mask.sum())
return test_acc
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
测试结果很糟糕,只有55.9%的正确性。原因之一为:用于训练此神经网络的有标签节点数量过少,此神经网络被过拟合,它对未见过的节点泛化性很差。
三、GCN及其在图节点分类任务中的应用
GCNConv构造函数接口:
GCNConv(in_channels: int, out_channels: int, improved: bool = False, cached: bool = False, add_self_loops: bool = True, normalize: bool = True, bias: bool = True, **kwargs)
- in_channels :输入数据维度;
- out_channels :输出数据维度;
- improved :如果为true, 增强中心节点自身信息;
- cached:是否存储称归一化矩阵以便后续使用,这个参数只应在归纳学习(transductive learning)的景中设置为true;
- add_self_loops:是否在邻接矩阵中增加自环边;
- normalize :是否添加自环边并在运行中计算对称归一化系数;
- bias :是否包含偏置项。
from torch_geometric.nn import GCNConv
class GCN(torch.nn.Module):
def __init__(self, hidden_channels):
super().__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)
可视化为未经过训练的GCN网络的节点特征
model = GCN(hidden_channels=16)
model.eval()
out = model(data.x, data.edge_index)
visualize(color=data.y)
# 训练GCN节点分类器
model = GCN(hidden_channels=16)
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
def train():
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
return loss
for epoch in range(1, 201):
loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
在训练过程结束后,我们检测GCN节点分类器在测试集上的准确性:
def test():
model.eval()
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
test_correct = pred[data.test_mask] == data.y[data.test_mask]
test_acc = int(test_correct.sum()) / int(data.test_mask.sum())
return test_acc
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
通过将线形层替换成GCN层,测试准确率从55.9%提高导了79.7%,这表明节点的邻接信息在取得更好的准确率方面起着关键作用。
可视化训练过的模型输出的节点表征
model.eval()
out = model(data.x, data.edge_index)
visualize(color=data.y)
可以看出,同类节点基本聚集在一起。
四、GAT及其在图节点分类任务中的应用
PyG中GATConv 模块说明
GATConv构造函数接口:
GATConv(in_channels: Union[int, Tuple[int, int]], out_channels: int, heads: int = 1, concat: bool = True, negative_slope: float = 0.2, dropout: float = 0.0, add_self_loops: bool = True, bias: bool = True, **kwargs)
- in_channels :输入数据维度;
- out_channels :输出数据维度;
- heads :在GATConv使用多少个注意力模型(Number of multi-head-attentions);
- concat :如为true,不同注意力模型得到的节点表征被拼接到一起(表征维度翻倍),否则对不同注意力模型得到的节点表征求均值;
这一次,我们将MLP例子中的linear层替换为GATConv层,来实现基于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().__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.relu()
x = F.dropout(x, p=0.5, training=self.training)
x = self.conv2(x, edge_index)
return x
model = GAT(hidden_channels=16)
print(model)
未经过训练得GAT分类器可视化:
out = model(data.x, data.edge_index)
visualize(color=data.y)
# 训练GAT节点分类器
model = GAT(hidden_channels=16)
criterion = torch.nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
def train():
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
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)
test_correct = pred[data.test_mask] == data.y[data.test_mask]
test_acc = int(test_correct.sum()) / int(data.test_mask.sum())
return test_acc
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
准确率为75.2%。对训练后的模型结果可视化:
model.eval()
out = model(data.x, data.edge_index)
visualize(color=data.y)
五、结语
- MLP:只考虑了节点自身属性,没有考虑节点之间的连接关系,效果最差。
- GCN和GAT:同时考虑了自身属性和与周围邻接节点的信息,效果比MLP优。
GCN和GAT的相同点:
- 它们都遵循消息传递范式;
- 在邻接节点信息变换阶段,它们都对邻接节点做归一化和线性变换;
- 在邻接节点信息聚合阶段,它们都将变换后的邻接节点信息做求和聚合;
- 在中心节点信息变换阶段,它们都只是简单返回邻接节点信息聚合阶段的聚合结果。
GCN和GAT的不同点:在于采取的归一化方法不同
- 前者根据中心节点与邻接节点的度计算归一化系数,后者根据中心节点与邻接节点的相似度计算归一化系数。
- 前者的归一化方式依赖于图的拓扑结构:不同的节点会有不同的度,同时不同节点的邻接节点的度也不同,于是在一些应用中GCN图神经网络会表现出较差的泛化能力。
- 后者的归一化方式依赖于中心节点与邻接节点的相似度,相似度是训练得到的,因此不受图的拓扑结构的影响,在不同的任务中都会有较好的泛化表现。
作业:
使用 PyG 中不同的图卷积层在 PyG 的不同数据上实现节点分类或回归任务。
SAGEConv
SAGEConv(in_channels: Union[int, Tuple[int, int]], out_channels: int, normalize: bool = False, root_weight: bool = True, bias: bool = True, **kwargs)
- in_channels (int or tuple) – Size of each input sample. A tuple corresponds to the sizes of source and target dimensionalities.
- out_channels (int) – Size of each output sample.
- normalize (bool, optional) – If set to True, output features will be L2-normalized
- root_weight (bool, optional) – If set to False, the layer will not add transformed root node features to the output. (default: True)
- bias (bool, optional) – If set to False, the layer will not learn an additive bias. (default: True)
- **kwargs (optional) – Additional arguments of torch_geometric.nn.conv.MessagePassing
1、定义模型
import torch
from torch.nn import Linear
import torch.nn.functional as F
from torch_geometric.nn import SAGEConv
class SAGE(torch.nn.Module):
def __init__(self, hidden_channels):
super().__init__()
torch.manual_seed(12345)
self.conv1 = SAGEConv(dataset.num_features, hidden_channels)
self.conv2 = SAGEConv(hidden_channels, dataset.num_classes)
def forward(self, x, edge_index):
x = self.conv1(x, edge_index)
x.relu()
x = F.dropout(x, p=0.5, training=self.training)
x = self.conv2(x, edge_index)
return x
model = SAGE(hidden_channels=16)
print(model)
SAGE(
(conv1): SAGEConv(1433, 16)
(conv2): SAGEConv(16, 7)
)
2、未经过训练的模型结果可视化
model.eval()
out = model(data.x, data.edge_index)
visualize(color=data.y)
3、训练模型
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()
def train():
model.train()
optimizer.zero_grad()
out = model(data.x, data.edge_index)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
return loss
for epoch in range(1, 201):
loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}')
4、使用测试集测试模型结果
def test():
model.eval()
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1)
test_correct = pred[data.train_mask] == data.y[data.train_mask]
test_acc = int(test_correct.sum()) / int(data.train_mask.sum())
return test_acc
test_acc = test()
print(f'Accuracy: {test_acc:.4f}')
5、训练好的模型可视化
model.eval()
out = model(data.x, data.edge_index)
visualize(color=data.y)