图卷积网络(Graph Convolutional Networks, GCNs)彻底改变了基于图的机器学习领域,使得深度学习能够应用于非欧几里得结构,如社交网络、引文网络和分子结构。本文将解释GCN的直观理解、数学原理,并提供代码片段帮助您理解和实现基础的GCN。
定义图G = (V, E),其中:
其中, N N N是节点数量, F F F是每个节点的输入特征数量。
邻接矩阵是表示图中节点之间连接(边)的一种方式。
节点特征矩阵存储图中每个节点的特征(属性)。
两者共同构成了图卷积网络的基本输入:
GCN层的核心公式如下:
H ( l + 1 ) = σ ( D ~ − 1 / 2 A ~ D ~ − 1 / 2 H ( l ) W ( l ) ) H^{(l+1)} = \sigma(\tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} H^{(l)} W^{(l)}) H(l+1)=σ(D~−1/2A~D~−1/2H(l)W(l))
这个公式包含了很多信息,我们将在下面详细解析:
考虑一个简单的3节点图:
节点0连接到节点1
节点1连接到节点0和2
节点2连接到节点1
添加自环后:
A = [[1, 1, 0],
[1, 1, 1],
[0, 1, 1]]
D = [[2, 0, 0],
[0, 3, 0],
[0, 0, 2]] # 度数:2, 3, 2
D^(-1/2) = [[1/√2, 0, 0 ],
[0, 1/√3, 0 ],
[0, 0, 1/√2]]
归一化后的矩阵为:
D^(-1/2)AD^(-1/2) =
[[1/2, 1/√6, 0 ],
[1/√6, 1/3, 1/√6 ],
[0, 1/√6, 1/2 ]]
在每一层,节点都会聚合来自其邻居(包括自身)的信息。网络越深,信息传播得越远。每个节点的新表示是其自身特征和邻居特征的加权平均。权重通过训练过程学习得到。归一化确保具有许多邻居的节点不会主导学习过程。
在社交网络中,每个人(节点)都有一些特征(如年龄、兴趣等),GCN层让每个人根据其朋友的信息更新自己的理解。归一化确保受欢迎的人(有很多朋友)不会主导学习过程。
Cora数据集是一个引文网络,其中节点代表学术论文,边代表引用关系。每篇论文都有一组特征(如作者、标题、摘要)和一个标签(如论文主题)。总共有2,780篇论文(节点)和5,429条引用(边)。每篇论文由一个二进制词向量表示,表示1,433个唯一词典单词的存在(1)或不存在(0)。论文被分为7个类别(如神经网络、概率方法等)。目标是根据每篇论文的特征和引用关系预测其类别。
GCN模型有2层:
class GCN(torch.nn.Module):
def __init__(self):
super(GCN, self).__init__()
self.conv1 = GCNConv(dataset.num_node_features, 16) # 输入到隐藏层
self.conv2 = GCNConv(16, dataset.num_classes) # 隐藏层到输出
第一层GCN将输入特征(1,433维)降维到16维。第二层GCN将16维降维到7维(类别数)。
def forward(self):
x, edge_index = data.x, data.edge_index
x = self.conv1(x, edge_index) # 第一层GCN
x = F.relu(x) # 非线性激活
x = F.dropout(x, training=self.training) # 可选的dropout
x = self.conv2(x, edge_index) # 第二层GCN
return F.log_softmax(x, dim=1) # 每个类别的对数概率
x = self.conv1(x, edge_index)
做了几件事:它向图中添加自环,计算归一化邻接矩阵 D ~ − 1 / 2 A ~ D ~ − 1 / 2 \tilde{D}^{-1/2} \tilde{A} \tilde{D}^{-1/2} D~−1/2A~D~−1/2,与输入特征和权重 H ( l ) W ( l ) H^{(l)} W^{(l)} H(l)W(l)相乘,并应用归一化和聚合。基本上,所有复杂的数学运算都由GCNConv层处理了。F.relu(x)
应用ReLU激活函数,F.dropout(x, training=self.training)
应用dropout来防止过拟合。第二层GCN x = self.conv2(x, edge_index)
做同样的事情,但是使用不同的权重 H ( l ) W ( l ) H^{(l)} W^{(l)} H(l)W(l)。
model = GCN()
data = dataset[0] # 获取第一个图对象
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
model.train()
for epoch in range(200):
optimizer.zero_grad()
out = model(data)
loss = F.nll_loss(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
我们使用带权重衰减的Adam优化器。Adam是一种自适应学习率优化算法,它结合了AdaGrad和RMSProp的优点。它维护每个参数的学习率,并使用梯度的移动平均和梯度平方的移动平均。由于稀疏梯度在GNN中很常见,使用Adam是合理的。
它有两个主要参数:lr
是学习率,weight_decay
是L2正则化参数。权重衰减通过向损失函数添加惩罚项来防止过拟合,并将模型权重推向较小的值,防止任何单个权重变得过大。使用L2时,原始损失 L ( θ ) L(\theta) L(θ)变为 L ( θ ) + λ ∑ θ i 2 L(\theta) + \lambda \sum \theta_i^2 L(θ)+λ∑θi2,其中 λ \lambda λ是权重衰减参数。weight_decay=5e-4
意味着 λ = 0.0005 \lambda = 0.0005 λ=0.0005。它通过保持权重较小来防止过拟合,并使模型对未见过的数据更具泛化能力。
loss = F.nll_loss(...)
是负对数似然损失(NLL),通常用于分类任务。它衡量模型的预测概率与真实标签的匹配程度。对于单个样本,它表示为 − log ( p 真实类别 ) -\log(p_{\text{真实类别}}) −log(p真实类别)。如果模型对正确类别100%确信,则损失为0。data.train_mask
是一个布尔掩码,指示哪些节点在训练集中。data.y
是每个节点的标签。我们只使用train_mask
为True的节点进行训练。val_mask
用于验证的节点,test_mask
用于最终评估的节点。
与许多图数据集一样,标签仅对节点的一个小子集可用,模型通过有监督损失从标记节点学习,并通过图结构从未标记节点学习。因此,这是半监督学习。在Cora数据集中,总共有2,708个节点,其中约140个节点(5%)用于训练,500个用于验证,1000个用于测试。GCN假设相连的节点可能相似。这被称为同质性假设,它被编码到学习算法中。GCN的消息传递直接编码了这些偏差。
model.eval()
pred = model().argmax(dim=1) # 获取预测类别
correct = pred[data.test_mask] == data.y[data.test_mask]
accuracy = int(correct.sum()) / int(data.test_mask.sum())
完整代码如下。首先,安装必要的包:
pip install torch-geometric
import torch
import torch.nn.functional as F
from torch_geometric.datasets import Planetoid
from torch_geometric.nn import GCNConv
# 加载数据
dataset = Planetoid(root='/tmp/Cora', name='Cora')
data = dataset[0]
# 定义GCN模型
class GCN(torch.nn.Module):
def __init__(self):
super(GCN, self).__init__()
self.conv1 = GCNConv(dataset.num_node_features, 16)
self.conv2 = GCNConv(16, dataset.num_classes)
def forward(self):
x, edge_index = data.x, data.edge_index
x = self.conv1(x, edge_index)
x = F.relu(x)
x = self.conv2(x, edge_index)
return F.log_softmax(x, dim=1)
# 训练循环
model = GCN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4)
for epoch in range(200):
model.train()
optimizer.zero_grad()
out = model()
loss = F.nll_loss(out[data.train_mask], data.y[data.train_mask])
loss.backward()
optimizer.step()
if epoch % 20 == 0:
print(f'Epoch {epoch}, Loss: {loss.item():.4f}')
# 评估
model.eval()
pred = model().argmax(dim=1)
correct = pred[data.test_mask] == data.y[data.test_mask]
accuracy = int(correct.sum()) / int(data.test_mask.sum())
print(f'测试准确率: {accuracy:.4f}')
运行结果:
Epoch 0, Loss: 1.9515
Epoch 20, Loss: 0.1116
Epoch 40, Loss: 0.0147
Epoch 60, Loss: 0.0142
Epoch 80, Loss: 0.0166
Epoch 100, Loss: 0.0155
Epoch 120, Loss: 0.0137
Epoch 140, Loss: 0.0124
Epoch 160, Loss: 0.0114
Epoch 180, Loss: 0.0107
测试准确率: 0.8100
我们可以看到,模型在只看到少量标记节点的情况下就能达到相当不错的准确率(81%)。这展示了图结构与节点特征结合的力量。在下一篇博客中,我们将介绍EvolveGCN,这是一个可以处理动态图数据的动态GCN模型。