基于图神经网络的节点表征学习
在图 节点预测或 边预测任务中,首先需要 生成节点表征。高质量节点表征应该能用于衡量节点的 相似性,然后基于节点表征可以实现高准确性的节点预测或边预测,因此节点表征的生成是图节点预测和边预测任务成功的关键。基于图神经网络的节点表征学习可以理解为对图神经网络进行 基于监督学习的训练,使得图神经网络学会产生高质量的节点表征。
在节点预测任务中,对于一个图上的很多节点,部分节点的标签已知,剩余节点的标签未知。将节点的属性(x
)、边的端点信息(edge_index
)、边的属性(edge_attr
,如果有的话)输入到多层图神经网络,经过图神经网络每一层的一次节点间消息传递,图神经网络为节点生成高质量的节点表征。然后就可以据此对标签未知的节点做预测。
我们以Cora
数据集为例进行节点预测的说明,任务是推断每个文档的类别(共7类)。Cora
是一个论文引用网络,节点代表论文,如果两篇论文存在引用关系,那么认为对应的两个节点之间存在边,每个节点由一个1433维的词包特征向量描述。
我们会利用MLP、GCN和GAT这三个网络进行学习,并比较三者之间的差异,进而有更深的体会。
1 数据准备
首先下载数据集,并查看数据
from torch_geometric.datasets import Planetoid
from torch_geometric.transforms import NormalizeFeatures
dataset = Planetoid(root='data/Planetoid', 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
从数据集信息中可以看到该数据集只包含一张图,每个节点包含1433个属性特征,节点共有7类。从数据信息可以看到有10556条边,有2708节点,平均每个节点有3.9的节点度,其中140个节点(每类20个)用于训练。有标签的节点的比例只占到5%。同时我们可以看到这是一张无向图,同时没有自环,并且不存在孤立的节点。数据转换是在将数据输入到神经网络之前修改数据,这一功能可用于实现数据规范化或数据增强。这里我们使用NormalizeFeatures
,进行节点特征归一化,使各节点特征总和为1
。
为了更直观的感受这一图,我们将其可视化,为了实现节点表征分布的可视化,我们先利用TSNE将高维节点表征嵌入到二维平面空间,然后在二维平面空间画出节点,由此可以看到数据的分布。
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()
out = data.x
visualize(out,data.y)
2 利用MLP进行图网络学习
接下来我们利用MLP对图节点进行分类,首先我们搭建一个具有两个全连接层、一个ReLU和一个DropOut层的MLP模型,将1433维的节点特征映射到一个低维的特征(hidden_channels)上,然后输出为类别数。
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(
(lin1): Linear(in_features=1433, out_features=16, bias=True)
(lin2): Linear(in_features=16, out_features=7, bias=True)
)
我们看一下在训练前节点的分布情况,可以看到数据是很分散的:
model.eval()
out = model(data.x)
visualize(out, color=data.y)
接下来我们使用交叉熵计算损失,利用Adam进行优化,对数据集中的140个训练节点进行200epoch的训练。
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 optimizer.
def train():
model.train()
optimizer.zero_grad() # Clear gradients.
out = model(data.x[data.train_mask]) # Perform a single forward pass.
loss = criterion(out, 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.
model.eval()
val_out = model(data.x[data.val_mask])
val_loss = criterion(val_out, data.y[data.val_mask])
return loss, val_loss
for epoch in range(1, 201):
loss, val_loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, valid loss: {val_loss:.4f}')
Epoch: 195, Loss: 0.4015, Valid Loss: 1.2982
Epoch: 196, Loss: 0.3615, Valid Loss: 1.2994
Epoch: 197, Loss: 0.3985, Valid Loss: 1.3002
Epoch: 198, Loss: 0.4664, Valid Loss: 1.3015
Epoch: 199, Loss: 0.3714, Valid Loss: 1.3029
Epoch: 200, Loss: 0.3810, Valid Loss: 1.3029
训练得到模型后我们进行测试,利用数据集中的测试节点进行测试。
def test():
model.eval()
out = model(data.x[data.test_mask])
pred = out.argmax(dim=1) # Use the class with highest probability.
test_correct = pred == 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.5900
训练后数据分布情况如下,可以看到有一些聚集,但是聚集情况并不好:
可以看到测试准确率较低,大约只有59%的准确性,而且训练损失和评估损失相差很大。其中一个重要原因是它的训练节点很少,只占全部节点的5%,导致过拟合,泛化性能较差。还有一个原因是MLP模型只用到了节点的属性特征,将每个节点作为一个样本,而没有用到图的结构信息,导致学习效果较差。
3 利用GCN进行图网络学习
图卷积神经网络来源于论文“Semi-supervised Classification with Graph Convolutional Network”。
3.1 GCN原理
类比于传统卷积神经网络,计算方式为卷积核在图像上对应位置相乘后累加求和,数学表达形式为:
S
(
i
,
j
)
=
σ
(
K
∗
I
)
=
σ
(
(
K
∗
I
)
(
i
,
j
)
)
=
σ
(
∑
m
∑
n
I
(
i
−
m
,
j
−
n
)
K
(
m
,
n
)
)
S(i,j)=\sigma(K*I)=\sigma ((K*I)(i,j))=\sigma (\sum_m\sum_nI(i-m,j-n)K(m,n))
S(i,j)=σ(K∗I)=σ((K∗I)(i,j))=σ(m∑n∑I(i−m,j−n)K(m,n))
其中
I
I
I为图像,
K
K
K为卷积核,
σ
\sigma
σ为激活函数。
那么图卷积也可以采用类似的方式进行卷积,首先定义度为
D
D
D,邻接矩阵为
A
A
A,拉普拉斯矩阵为
L
=
D
−
A
L=D-A
L=D−A或者
L
=
I
n
+
A
L=I_n+A
L=In+A:
任何一个图卷积层都可以表示为:
H
l
+
1
=
=
σ
(
H
l
A
)
H^{l+1} ==\sigma(H^lA)
Hl+1==σ(HlA)
由于
A
A
A没有考虑自身节点的信息,因此我们考虑使用拉普拉斯矩阵
L
L
L:
H
l
+
1
=
σ
(
L
H
l
Θ
l
)
H^{l+1} =\sigma(LH^l\mathbf{\Theta}^l)
Hl+1=σ(LHlΘl)
通过拉普拉斯矩阵引入度以后解决了自身信息传递的问题,但是我们可以继续优化,对其矩阵进行归一化:
H
l
+
1
=
=
σ
(
D
−
1
2
A
^
D
−
1
2
H
l
Θ
l
)
H^{l+1} ==\sigma(D^{-\frac{1}{2}}\hat{A}D^{-\frac{1}{2}}H^l\mathbf{\Theta}^l)
Hl+1==σ(D−21A^D−21HlΘl)
其中
A
^
=
D
−
A
\hat{A}=D-A
A^=D−A也有论文或博客写为
A
^
=
I
n
+
A
\hat{A}=I_n+A
A^=In+A,进一步可以表示为:
h
i
l
+
1
=
Θ
l
∑
j
∈
N
(
v
)
∪
{
i
}
e
j
,
i
d
^
j
d
^
i
h
j
l
h^{l+1}_i = \mathbf{\Theta}^l \sum_{j \in \mathcal{N}(v) \cup\{ i \}} \frac{e_{j,i}}{\sqrt{\hat{d}_j \hat{d}_i}} h_j^l
hil+1=Θlj∈N(v)∪{i}∑d^jd^iej,ihjl
在GCNCov中,图卷积定义为:
X
′
=
D
^
−
1
/
2
A
^
D
^
−
1
/
2
X
Θ
,
\mathbf{X}^{\prime} = \mathbf{\hat{D}}^{-1/2} \mathbf{\hat{A}}\mathbf{\hat{D}}^{-1/2} \mathbf{X} \mathbf{\Theta},
X′=D^−1/2A^D^−1/2XΘ,
其中
A
^
=
I
n
+
A
\hat{A}=I_n+A
A^=In+A,
D
^
i
i
=
∑
j
=
0
A
^
i
j
\hat{D}_{ii} = \sum_{j=0} \hat{A}_{ij}
D^ii=∑j=0A^ij。它的节点式表述为:
x
i
′
=
Θ
∑
j
∈
N
(
v
)
∪
{
i
}
e
j
,
i
d
^
j
d
^
i
x
j
\mathbf{x}^{\prime}_i = \mathbf{\Theta} \sum_{j \in \mathcal{N}(v) \cup \{ i \}} \frac{e_{j,i}}{\sqrt{\hat{d}_j \hat{d}_i}} \mathbf{x}_j
xi′=Θj∈N(v)∪{i}∑d^jd^iej,ixj
其中,
d
^
i
=
1
+
∑
j
∈
N
(
i
)
e
j
,
i
\hat{d}_i = 1 + \sum_{j \in \mathcal{N}(i)} e_{j,i}
d^i=1+∑j∈N(i)ej,i,
e
j
,
i
e_{j,i}
ej,i表示从源节点
j
j
j到目标节点
i
i
i的边的对称归一化系数(默认值为1.0)。
3.2 PyG中GCNConv
模块说明
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
, A ^ = A + 2 I \mathbf{\hat{A}} = \mathbf{A} + 2\mathbf{I} A^=A+2I,其目的在于增强中心节点自身信息;cached
:是否存储 D ^ − 1 / 2 A ^ D ^ − 1 / 2 \mathbf{\hat{D}}^{-1/2} \mathbf{\hat{A}} \mathbf{\hat{D}}^{-1/2} D^−1/2A^D^−1/2的计算结果以便后续使用,这个参数只应在归纳学习(transductive learning)的景中设置为true
;add_self_loops
:是否在邻接矩阵中增加自环边;normalize
:是否添加自环边并在运行中计算对称归一化系数;bias
:是否包含偏置项。
该类的前向传播函数如下
def forward(self, x: Tensor, edge_index: Adj,edge_weight: OptTensor = None) -> Tensor:
x
:节点的属性矩阵[node_numbers, features]
edge_index
:边的端点的索引edge_weight
:边的属性(或者说是权重)
3.3 基于GCN的图节点分类
前边我们已经实现了MLP的图节点分类模型,在GCN中,我们只需要将torch.nn.Linear
换为PyG中的GCNConv
就行。
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)
GCN(
(conv1): GCNConv(1433, 16)
(conv2): GCNConv(16, 7)
)
我们先看一下未训练前网络对图节点的表征:
model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)
从图中我们可以看到一些节点聚集的情况,那么就让我们来训练它吧:
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.
model.eval()
val_out = model(data.x, data.edge_index)
val_loss = criterion(val_out[data.val_mask],data.y[data.val_mask])
return loss, val_loss
for epoch in range(1, 201):
loss, val_loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Valid Loss: {val_loss:.4f}')
Epoch: 195, Loss: 0.1868, Valid Loss: 0.7158
Epoch: 196, Loss: 0.2282, Valid Loss: 0.7164
Epoch: 197, Loss: 0.2012, Valid Loss: 0.7121
Epoch: 198, Loss: 0.2089, Valid Loss: 0.7112
Epoch: 199, Loss: 0.2182, Valid Loss: 0.7122
Epoch: 200, Loss: 0.1918, Valid Loss: 0.7148
利用训练好的模型进行测试
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.8080
可以看到预测准确率得到大幅提升,从59%上升到80%,而且从损失来看,评估损失与训练损失的差距也减小了很多,过拟合得到缓解。这表明节点的邻接信息在取得更好的准确率方面起着关键作用。
最后我们可视化分类后的节点的表征,以便与分类器的进行对比:
model.eval()
out = model(data.x, data.edge_index)
visualize(out, color=data.y)
可以看到相比于模型训练前,训练后节点的表征有明显的聚集。
4 利用GAT进行图网络学习
图注意网络(GAT)来源于论文 Graph Attention Networks。
其数学定义表示如下:
x
i
′
=
α
i
,
i
Θ
x
i
+
∑
j
∈
N
(
i
)
α
i
,
j
Θ
x
j
,
\mathbf{x}^{\prime}_i = \alpha_{i,i}\mathbf{\Theta}\mathbf{x}_{i} +\sum_{j \in \mathcal{N}(i)} \alpha_{i,j}\mathbf{\Theta}\mathbf{x}_{j},
xi′=αi,iΘxi+j∈N(i)∑αi,jΘxj,
其中第一部分为对自身的注意,第二部分是对邻接节点的注意,注意力系数
α
i
,
j
\alpha_{i,j}
αi,j的计算方法为:
α
i
,
j
=
exp
(
L
e
a
k
y
R
e
L
U
(
a
⊤
[
Θ
x
i
∥
Θ
x
j
]
)
)
∑
k
∈
N
(
i
)
∪
{
i
}
exp
(
L
e
a
k
y
R
e
L
U
(
a
⊤
[
Θ
x
i
∥
Θ
x
k
]
)
)
.
\alpha_{i,j} =\frac{\exp\left(\mathrm{LeakyReLU}\left(\mathbf{a}^{\top}[\mathbf{\Theta}\mathbf{x}_i \, \Vert \, \mathbf{\Theta}\mathbf{x}_j]\right)\right)}{\sum_{k \in \mathcal{N}(i) \cup \{ i \}}\exp\left(\mathrm{LeakyReLU}\left(\mathbf{a}^{\top}[\mathbf{\Theta}\mathbf{x}_i \, \Vert \, \mathbf{\Theta}\mathbf{x}_k]\right)\right)}.
αi,j=∑k∈N(i)∪{i}exp(LeakyReLU(a⊤[Θxi∥Θxk]))exp(LeakyReLU(a⊤[Θxi∥Θxj])).
4.1 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
(int
ortuple
):输入数据维度。元组对应于源维度和目标维度的大小。out_channels
(int
) :输出数据维度。heads
(int
) :在GATConv
使用多少个注意力模型(Number of multi-head-attentions),默认是1。concat
(bool
) :如为true
,不同注意力模型得到的节点表征被拼接到一起(表征维度翻倍),否则对不同注意力模型得到的节点表征求均值,默认为true
。negative_slope
(float
) :LeakyReLU
负斜率的角度,默认为0.2。- 其他参数可参考官方文档
4.2 基于GAT的图节点分类
类比于GCN,这一次我们将MLP中的torch.nn.Linear
换为PyG中的GATConv
。
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)
print(model)
GAT(
(conv1): GATConv(1433, 16, heads=1)
(conv2): GATConv(16, 7, heads=1)
)
同样我们先看一下未训练前网络对图节点的表征:
可以看到也是有少量的聚集,那么我接下来训练模型,看一下会有怎样的不同:
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
criterion = torch.nn.CrossEntropyLoss()3
for epoch in range(1, 201):
loss, val_loss = train()
print(f'Epoch: {epoch:03d}, Loss: {loss:.4f}, Valid Loss: {val_loss:.4f}')
Epoch: 195, Loss: 0.1980, Valid Loss: 0.9999
Epoch: 196, Loss: 0.1634, Valid Loss: 0.9999
Epoch: 197, Loss: 0.1879, Valid Loss: 0.9970
Epoch: 198, Loss: 0.1658, Valid Loss: 0.9929
Epoch: 199, Loss: 0.1657, Valid Loss: 0.9891
Epoch: 200, Loss: 0.1926, Valid Loss: 0.9844
对测试集进行测试:
test_acc = test()
print(f'Test Accuracy: {test_acc:.4f}')
Test Accuracy: 0.7380
可以发现准确率比GDN的低,这可能是由于训练数据少,而模型更加复杂造成过拟合,从损失上我们也能看出这一点,相比于GCN,训练损失更低,而评估损失更高,两者差距加大,所以可能造成过拟合。
那么我们看一下分类后节点的聚集情况吧:
可以看到蓝、绿黄三种的聚集情况并不好,存在很多重叠。
5 总结
在节点表征的学习中,MLP节点分类器只考虑了节点自身属性,忽略了节点之间的连接关系,它的结果是最差的;而GCN与GAT节点分类器,同时考虑了节点自身属性与周围邻居节点的属性,它们的结果优于MLP节点分类器。从中可以看出邻居节点的信息对于节点分类任务的重要性。
基于图神经网络的节点表征的学习遵循消息传递范式:
- 在邻居节点信息变换阶段,GCN与GAT都对邻居节点做归一化和线性变换(两个操作不分前后);
- 在邻居节点信息聚合阶段都将变换后的邻居节点信息做求和聚合;
- 在中心节点信息变换阶段只是简单返回邻居节点信息聚合阶段的聚合结果。
GCN与GAT的区别在于采取的归一化方法不同:
- 前者根据中心节点与邻居节点的度计算归一化系数,后者根据中心节点与邻居节点的相似度计算归一化系数。
- 前者的归一化方式依赖于图的拓扑结构,不同节点其自身的度不同、其邻居的度也不同,在一些应用中可能会影响泛化能力。
- 后者的归一化方式依赖于中心节点与邻居节点的相似度,相似度是训练得到的,因此不受图的拓扑结构的影响,在不同的任务中都会有不同的泛化表现。
参考: