CNN 和 Transformer 分别擅长处理格子和序列数据,但当面对‘关系’数据时,它们都显得无能为力。图神经网络 (GNN) 通过图卷积 (GCN) 技术,成功解决了这一难题。
摘要:前几天我们玩转了 CNN(处理格子的王者)和 Transformer(处理序列的霸主)。但当你环顾四周,会发现这个世界其实是由“关系”构成的:社交网络是人与人的关系,化学分子是原子与原子的关系,知识图谱是实体与实体的关系。这些数据既不是格子也不是序列,而是图 (Graph) 。 CNN 面对这种“杂乱无章”的连接结构直接懵圈,Transformer 虽然能用但没有显式的拓扑结构。 于是,图神经网络 (GNN) 应运而生。今天,我要用最接地气的“村口八卦理论”,带你拆解 GCN (图卷积网络) 的数学本质,并用 PyTorch Geometric (PyG) 手撸一个基于图结构的节点分类任务!
关键词:PyTorch, GNN, GCN, 图论, 邻接矩阵, 消息传递, PyG
1. 为什么要搞个“图”神经网络?
在 MATLAB 的世界里,矩阵是万能的。图片是矩阵,声音是向量。但在现实中,有一种数据结构让传统的深度学习模型非常头大,那就是非欧几里得数据 (Non-Euclidean Data) 。
1.1 什么是“欧几里得”数据?
就是拓扑固定、结构规整的数据,核心特征是“平移不变性”:
- 图片 (Image) :标准的网格结构。每个像素点都有固定的 8 个邻居(上下左右+对角)。CNN 的卷积核可以在上面舒服地滑动,因为结构是固定的。
- 文本 (Text) :标准的序列结构。每个词只有前一个和后一个邻居。Transformer/RNN 处理起来得心应手。
1.2 什么是“图”数据?
就是拓扑无固定规则、节点邻域大小不一的非规整数据:
- 社交网络:普通用户可能只有 3 个好友,但大 V 的好友有千万好友。每个节点的“邻居数量”是不固定的。
- 化学分子:苯环是六边形,甲烷是四面体,无固定“网格/序列”形态;
- 知识图谱:“马云 -> 创立 -> 阿里巴巴 -> 总部 -> 杭州”的关联链,无空间/时序的固定排列。
CNN 的崩溃时刻: 如果你想用 的卷积核去卷一个社交网络,你会发现根本没法卷。
- 节点 A 有 2 个邻居,卷积核怎么对齐?
- 节点 B 有 100 个邻居,卷积核怎么对齐?
- 图里的节点没有“上下左右”之分,只有“连着”和“没连着”。
所以,我们需要一种新的神经网络,它不仅要看节点本身的特征(比如你的年龄、性别),还要看节点之间的连接关系(你的朋友是谁)。这就是 GNN (Graph Neural Network) 。
2. 核心原理:从“邻接矩阵”到“村口八卦”
作为MATLAB用户,你对图的“矩阵表示”绝不陌生——图在MATLAB中核心用邻接矩阵 (Adjacency Matrix) 描述,这也是GCN的数学根基。
2.1 GCN 的直觉:用“村口八卦”理解消息传递
CNN的卷积是“聚合局部网格信息更新自身特征”,GNN的“图卷积”核心逻辑完全一致,业界称之为消息传递 (Message Passing) 。
通俗比喻:村口情报网
假设你是一个村民(节点),你想知道自己的“社会地位”(特征)。你自己可能不太清楚,但你可以问你的邻居。
Step 1 (Aggregate) :你把你所有朋友的“社会地位”收集起来。
Step 2 (Update) :把朋友们的信息和你自己的信息加权平均一下,算出你新的“社会地位”。
这正是GCN的灵魂: “你是你最亲密的N个邻居的加权平均” ——本质是通过拓扑关系实现特征的邻域聚合。
2.2 GCN的数学本质:MATLAB矩阵视角的推导
我们用MATLAB熟悉的矩阵语言拆解这个“八卦过程”,先明确核心符号定义:
| 符号 | 含义 | MATLAB维度解读 |
|---|---|---|
| 第层节点特征矩阵 | (=节点数,=特征维度) | |
| 邻接矩阵 | (表示节点与相连,否则为0) | |
| 单位矩阵 | (对角线为1,其余为0) | |
| 度矩阵 | (对角矩阵,,即节点的度) |
第一步:聚合邻居信息(基础版)
要把邻居特征聚合到自身,在MATLAB中只需一行矩阵乘法:
✅ MATLAB逻辑解读:的第行仅在“与相连的节点列”为1,因此的第行 = 所有与相连的邻居特征之和。
第二步:保留自身信息(加自环)
的对角线默认是0(节点不自连),直接相乘会丢失自身特征——解决方案是给加单位矩阵(自环):
✅ MATLAB实操:A_tilde = A + eye(N);(为节点数),此时,聚合结果同时包含邻居+自身。
第三步:归一化(避免数值爆炸)
还有一个问题:有的人朋友多,有的人朋友少。 如果直接相加,朋友多的特征值会爆炸(因为加了几千个人的),朋友少的特征值很小。这会导致数值不稳定,梯度爆炸。 解决办法:归一化 (Normalization)。我们要除以节点的度 (Degree) 。 在矩阵里,就是乘上度矩阵的逆 。
- :可学习权重矩阵(对应全连接层,MATLAB中为
W = randn(F, F_new);); - :激活函数(如ReLU,MATLAB中为
relu()); - :度矩阵的逆(MATLAB中为
D_inv = diag(1./diag(D));)。
这基本上就是 GCN 的雏形了!意思是:把邻居和自己的特征加权平均,乘个权重,过个激活函数。
✅ MATLAB核心代码(简化版) :
% 假设A是邻接矩阵,H是节点特征矩阵,W是权重矩阵
N = size(A, 1);
A_tilde = A + eye(N); % 加自环
D = diag(sum(A_tilde, 2)); % 计算度矩阵
D_inv = diag(1./diag(D)); % 度矩阵逆
H_new = relu(D_inv * A_tilde * H * W); % 聚合+更新
2.3 标准GCN 公式:Kipf & Welling 的对称归一化
2017 年,Kipf 和 Welling 提出了标准的 GCN 公式。他们觉得简单的“平均” () 不够对称,于是搞了个对称归一化:
📝 MATLAB老鸟专属解读:
- 普通归一化:仅对行归一化(每行除以节点度)(Row Normalization);
- 对称归一化:行+列都除以“度的平方根”,让邻接矩阵更对称;
- MATLAB计算:
D_sqrt_inv = diag(1./sqrt(diag(D)));。
💡 核心结论:无论公式多复杂,GCN的本质只有一个——按拓扑关系加权聚合邻域特征,再通过全连接层更新自身特征。
3. 工具准备:PyTorch Geometric (PyG)
在 PyTorch 世界做图神经网络,不推荐手写矩阵乘法(虽然能写,但处理稀疏矩阵很麻烦,效率低)。业界标准库是 PyTorch Geometric (PyG)——相当于CV领域的torchvision,专为图数据设计 。
3.1 安装 (避坑指南)
PyG 依赖很多底层 C++ 库(scatter, sparse),安装时必须和你的 CUDA 版本严格匹配。 建议去 PyG官网复制对应的 pip 命令。
以下是我用到的安装命令(PyTorch2.7 + cuda11.8):
# CPU版本
pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.7.0+cpu.html
# CUDA 11.8版本(PyTorch 2.7)
pip install pyg_lib torch_scatter torch_sparse torch_cluster torch_spline_conv -f https://data.pyg.org/whl/torch-2.7.0+cu118.html
⚠️ 坑点提示:若安装失败,先升级pip(pip install --upgrade pip),再去PyG官网复制对应版本命令。
3.2 PyG 的核心数据结构:Data对象
MATLAB中用G = graph(s,t)定义图,PyG中用Data对象封装图的所有信息(核心是“边索引”+“节点特征”):
import torch
from torch_geometric.data import Data
# 1. 定义边索引(COO格式:稀疏矩阵的坐标表示)
# 第一行=源节点,第二行=目标节点,无向图需显式添加反向边
edge_index = torch.tensor([
[0, 1, 1, 2], # 源节点
[1, 0, 2, 1] # 目标节点
], dtype=torch.long)
# 2. 定义节点特征(3个节点,每个节点1维特征)
x = torch.tensor([[-1], [0], [1]], dtype=torch.float)
# 3. 封装为Data对象
data = Data(x=x, edge_index=edge_index)
# 查看核心属性
print(f"节点数:{data.num_nodes}") # 输出:3
print(f"边数:{data.num_edges}") # 输出:4(无向图双向计数)
print(f"节点特征维度:{data.num_features}") # 输出:1
✅ 关键解释:COO格式通过“坐标对”存储非零边,相比邻接矩阵节省内存,尤其适合百万级节点的大规模图。
4. 实战:扎克伯格空手道俱乐部 (Karate Club)
这是 GNN 界的 "Hello World":
故事背景:20世纪70年代,一个空手道俱乐部的教练(Mr. Hi)和管理员(Officer)吵架了,俱乐部裂变成了两个帮派。科学家记录了 34 个成员之间的社交关系。
任务:只给你其中几个人的帮派标签,让你预测剩下的所有人站哪边的队?(半监督节点分类)。
4.1 数据加载与可视化
import torch
from torch_geometric.datasets import KarateClub
from torch_geometric.utils import to_networkx
import networkx as nx
import matplotlib.pyplot as plt
# 1. 加载数据集(PyG内置)
dataset = KarateClub()
data = dataset[0] # 该数据集仅包含1张图
# 打印核心信息
print(f'节点数量: {data.num_nodes}') # 34
print(f'边数量: {data.num_edges}') # 156(无向图双向计数)
print(f'节点特征维度: {data.num_features}') # 34(One-hot编码)
print(f'类别数量: {data.y.max().item() + 1}') # 4(4个小帮派)
# 2. 可视化图结构(NetworkX)
def visualize_graph(G, color):
plt.figure(figsize=(7,7))
plt.xticks([])
plt.yticks([])
pos = nx.spring_layout(G, seed=42)
nx.draw_networkx(G, pos=pos, with_labels=False,
node_color=color, cmap="Set2", node_size=300)
plt.show()
plt.close()
G = to_networkx(data, to_undirected=True)
visualize_graph(G, data.y)
4.2 搭建 GCN 模型
PyG的GCNConv已封装好图卷积逻辑,搭建模型和CNN几乎一致:
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
class GCN(nn.Module):
def __init__(self):
super().__init__()
# 第一层图卷积:34维输入 → 16维隐藏特征
self.conv1 = GCNConv(dataset.num_features, 16)
# 第二层图卷积:16维隐藏 → 4维输出(对应4个类别)
self.conv2 = GCNConv(16, dataset.num_classes)
def forward(self, x, edge_index):
# x: 节点特征 [N, F],edge_index: 边索引 [2, E]
# 第一层卷积 + ReLU激活 + Dropout正则化
x = self.conv1(x, edge_index) # 维度:[34,34] → [34,16]
x = F.relu(x)
x = F.dropout(x, p=0.5, training=self.training)
# 第二层卷积(无激活,输出logits)
x = self.conv2(x, edge_index) # 维度:[34,16] → [34,4]
return x
model = GCN()
4.3 训练半监督GCN模型
我们虽然有所有人的标签 (data.y),但我们假装只知道其中几个人的(通过 data.train_mask 来控制)。我们要让 GCN 通过图结构,把这几个人的标签信息“传播”给邻居,从而猜出其他人的标签。也就是通过图拓扑将标签信息“扩散”到未标注节点:
# 定义损失函数和优化器
criterion = nn.CrossEntropyLoss() # 分类任务标配
optimizer = torch.optim.Adam(
model.parameters(),
lr=0.01,
weight_decay=5e-4 # L2正则化,防止过拟合
)
def train():
model.train()
optimizer.zero_grad() # 清空梯度
# 前向传播:输入整张图(GCN默认Full-Batch训练)
out = model(data.x, data.edge_index)
# 仅计算标注节点的损失(半监督核心)
loss = criterion(out[data.train_mask], data.y[data.train_mask])
# 反向传播 + 梯度更新
loss.backward()
optimizer.step()
return loss.item()
def test():
model.eval() # 评估模式,关闭Dropout
with torch.no_grad(): # 禁用梯度计算,节省内存
out = model(data.x, data.edge_index)
pred = out.argmax(dim=1) # 取logits最大的类别为预测结果
# 计算测试集准确率(未标注节点)
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
# 开始训练
print("\n开始训练 GCN...")
for epoch in range(101):
loss = train()
if epoch % 10 == 0:
acc = test()
print(f'Epoch {epoch:03d}, Loss: {loss:.4f}, Test Acc: {acc:.4f}')
print("🎉 训练完成!GCN 成功通过拓扑关系扩散标签信息!")
4.4 结果可视化:Embedding降维分析
用TSNE降维直观展示GCN学习到的节点特征聚类效果:
📌 结果解读:即使仅用少量标注节点,GCN学习到的Embedding仍会让同帮派节点自动聚类——这是图卷积“拓扑特征提取”的核心价值。
5. 面试避坑指南 (GNN 专场)
Q1: GCN 和 CNN 的本质区别是什么?
答:① 处理数据类型:CNN处理欧几里得数据(固定拓扑,如网格),GCN处理非欧几里得数据(无固定拓扑); ② 邻域定义:CNN的邻域是“固定大小的空间窗口”(如3×3),GCN的邻域是“节点的拓扑邻居”(数量不固定); ③ 卷积本质:CNN是“空间局部卷积”(依赖平移不变性),GCN是“拓扑局部卷积”(依赖邻接关系,无平移不变性); ④ 权重共享:两者均共享权重,但GCN多了“邻接矩阵归一化”的聚合步骤。
Q2: 为什么 GCN 不能堆很深?(Over-smoothing 问题)(高频考点)
答:GCN每一层都会聚合更大范围的邻域特征,堆叠几十层后,每个节点会聚合全图所有节点的信息(朋友的朋友的朋友…最后认识了全地球人)。根据拉普拉斯平滑原理,所有节点的特征会逐渐趋同(变得一模一样),无法区分类别——这就是过平滑 (Over-smoothing) 。因此GCN通常仅用2-3层,解决思路包括:加入残差连接、使用GAT(注意力加权聚合)、限制聚合范围。
Q3: 邻接矩阵为什么要加自环 () 和归一化 ()?
答:1. 加自环:避免聚合时丢失节点自身特征,保证“自己的信息”参与特征更新; 2. 归一化:① 防止度大的节点特征值爆炸(如大V聚合千万邻居特征);② 让邻接矩阵满足对称分布,提升训练稳定性;③ 统一不同节点的特征尺度,类似BatchNorm的作用。
Q4: 除了GCN的平均聚合,GNN还有哪些常见的消息传递方式?(高频考点)
答:1. GAT:注意力加权聚合(给不同邻居分配不同权重,解决GCN“平均聚合”的公平性问题); 2. GraphSAGE:采样邻域聚合(随机采样固定数量的邻居,解决大规模图的计算效率问题);3. GIN:逐元素加和聚合(对节点特征做非线性变换后加和,提升表达能力);4. 池化聚合:最大值/最小值聚合(捕捉邻域的极值特征)。
📌 下期预告
恭喜你!你已经掌握了神经网络的三大支柱:CNN (看图) 、Transformer (读文) 、GNN (理关系) 。理论基础已经打得非常牢固了。 从下一篇开始,我们将进入实战阶段!搭建一个能像素级识别马路车道线的模型。这可是自动驾驶的核心模块哦!准备好你的 GPU,我们要搞大事了!