前几天有位朋友问我一个问题,我觉得很适合作为这篇入门文章的开场。他说:"我已经会了卷积神经网络,但是现在手里的数据不是图片,是一张社交关系网——每个用户是一个点,用户和用户之间有'关注'和'转发'的连边。我该怎么用神经网络去学这堆数据?"
这个问题的本质,就是图神经网络(Graph Neural Network,简称GNN)要解决的事情。传统神经网络擅长处理规则排列的数据,比如图像是像素网格,文本是单词序列;可一旦数据变成不规则的图结构——节点和节点之间有着复杂的关系——常规的卷积和全连接层就会彻底失灵。GNN就是在这样的背景下出现的:它把"邻居关系"直接设计进了网络结构,让模型能够同时学习节点的自身特征和它周边的拓扑信息。
这篇文章很适合两类读者:一类是像我朋友这样已经有一些深度学习基础、但完全没接触过图数据的新手;另一类是做过一些图分析,却始终对GNN"知其然不知其所以然"的人。我会从问题本质讲起,把消息传递机制聊透,再手把手带你把GCN在经典数据集上跑起来,最后把入门阶段最容易踩的坑和选型建议一并说清楚。
1. 先别急着写代码:为什么传统神经网络在图数据上失灵
1.1 图结构和图片结构的本质差异
很多初学者第一次接触GNN时会有一个困惑:神经网络的输入不都是一个矩阵吗?图也可以表示成矩阵,为什么就不能直接把它塞进全连接层里呢?
关键在于,图像和图的"规则性"完全不同。图像是一个规则的欧几里得结构:一张 28×28 的灰度图,像素点在空间上有明确的上下左右关系,每个像素的邻居数量固定是 8 个(边界除外)。卷积神经网络之所以有效,靠的是两个先验:局部连接——每个卷积核只看一个小窗口;权重共享——同一个卷积核扫过整张图。这两个性质在规则网格上成立,所以CNN能提炼出平移不变的局部特征。
图的结构则完全相反。图是一种非欧几里得结构,一个节点有多少个邻居是不确定的,可能节点A只有一个邻居,节点B有五百个邻居。而且图没有天然的空间顺序,你把社交网络里一千个用户随便换个排列顺序,它仍然是同一张图。这意味着,你在像素网格上用的"固定尺寸卷积核+固定邻域"玩法,在图上根本没法直接套用。
可以打个比方:CNN处理图像,就像在一个城市方格的棋盘路网里巡逻,每个路口都知道东南西北四条路;而GNN要处理的数据,更像是拓扑地图里的一堆据点,每个据点通向的路径数量不一样,路径之间也没有统一的方位坐标。你要让每个据点理解自己在整个网络中的位置,就得换一种信息流通的方式。
1.2 邻接矩阵:看起来简单,用起来处处是坑
既然图可以用矩阵表达,最直觉的做法就是把邻接矩阵当成特征喂给神经网络。这个思路看似可行,实际上处处碰壁。
先看维度问题。常用的论文引用数据集Cora有 2708 个节点,邻接矩阵是 2708×2708,差不多 730 万个元素。如果图规模到百万节点,邻接矩阵就是万亿级规模,别说训练,光是存储都受不了。更要命的是,图是极度稀疏的,Cora里全部边只有 5429 条,邻接矩阵里绝大多数位置是 0,拿全连接层去处理这种稀疏矩阵,计算浪费极其严重。
再看置换不变性。图本身没有节点顺序,你把编号打乱,邻接矩阵的行列顺序就会变。同一个图可以有无数种邻接矩阵写法,但语义是一模一样的。普通的神经网络对输入顺序极度敏感,喂给它不同顺序表示同一张图的数据,它学出来的东西完全对不上。一句话:图的关键信息在"谁和谁相连"的结构里,而不是在"哪个编号在第几行"的排列里。
1.3 一个具体的业务场景:推荐系统里的用户关系
抽象的问题终究不如具体场景直观。我拿自己做过的某跨平台系统的推荐模块举例:系统里有大量用户,每个用户有一些行为特征,比如浏览了多少次内容、平均停留时长、点赞倾向等。如果我们只用用户自己的特征做推荐,就完全浪费了用户之间的关注和转发关系。
实际操作中我们发现,两个特征相似的用户,行为却可能差异很大;反而是两个经常互相转发内容的用户,兴趣重合度极高。这说明"关系"本身携带了特征矩阵里没有的信号。但问题也随之而来:关系数据是图结构,用户之间的关系数量从 0 到几千不等,而且没有天然顺序,怎么让模型把邻居的信息纳入每个用户的表示?靠普通神经网络根本做不到。
1.4 GNN的核心思路:让图结构参与计算
GNN给出的答案很直接:不要试图把图"压平"成规则结构,而是让网络的计算过程本身跟着图走。每个节点的表示,由它自己的特征和它的邻居特征共同决定;邻居的邻居也能间接影响它。这样一来,模型学到的每个节点表示,天然就包含了局部的拓扑上下文。
这套思路在论文里有一个专门的名字,叫消息传递框架(Message Passing)。它是几乎所有经典GNN模型——GCN、GraphSAGE、GAT——的共同底座。下面我就把消息传递讲透,因为只要理解了它,后面看任何GNN模型都是在看"消息怎么生成、怎么聚合、怎么更新"这三个环节的不同设计。
2. 消息传递(Message Passing):GNN底层的那一套心法
2.1 一句话理解消息传递
如果你想用最快的方式向别人解释GNN,就说这一句话:每个节点不断向邻居打听消息,把邻居的信息和自己的信息合并,形成自己新的表示。
这个过程和图结构高度耦合,因为"邻居是谁"是由图决定的,不是由网络结构决定的。这跟CNN有本质区别:CNN的邻域是卷积核固定划出来的,跟像素内容无关;GNN的邻域来自图的连边,每个节点都有自己的"定制版感受野"。
2.2 消息传递的三步走:Transform、Aggregate、Update
形式化的消息传递每一层可以拆成三步,几乎所有GNN都是这个套路:
Transform(变换):每个节点把自己的特征向量通过一个线性变换(通常就是乘权重矩阵再加偏置)变成消息。这一步的作用是让原始特征进入一个更适合交互的表示空间。
Aggregate(聚合):每个节点收集所有邻居传来的消息,用某种置换不变函数——比如求和、求平均、取最大值——把它们合并成一个固定维度的向量。注意"置换不变"这个词很关键:不管邻居按什么顺序排列进来,聚合结果必须一样。这是GNN能处理任意图结构的数学基础。
Update(更新):把聚合得到的"邻居信息摘要"和节点自己上一层的表示合并,再经过一次非线性激活,得到这一层的新表示。
写成一个通用公式就是这样:
h_v^(l+1) = Update( h_v^(l), Aggregate( { Transform( h_u^(l), h_v^(l) ), u ∈ N(v) } ) )其中 h_v 是节点 v 在第 l 层的向量表示,N(v) 是 v 的邻居集合。不同GNN模型的区别,基本就集中在Transform和Aggregate函数怎么设计。
讲到这有个比喻特别管用:把节点想象成社区里的住户,每户人家一开始只知道自己的想法(初始特征)。消息传递就像组织了一场社区茶话会,每户人家去邻居家串门,听邻居说完自己的情况后,回家把所有听到的信息汇总,再结合自己的想法更新对社区的认知。第二天再开一场,信息就扩散到了两跳之外的邻居。
2.3 堆层数就是在扩大"朋友圈"半径
消息传递层的层数决定了每个节点能感知到多远的信息。1层GNN,节点只能看到直接邻居;2层GNN,节点能看到邻居的邻居(二阶邻居);3层就能看到三阶。这和CNN里"堆卷积层扩大感受野"非常相似。
但这里有个容易忽略的细节:所谓"看到二阶邻居",并不是直接读取二阶邻居的原始特征,而是通过中间节点转述。每一层都相当于一次信息的中继和再加工。所以层数越深,参与表示的信息范围越大,但同时也可能引入噪声,后面我会专门讲过度堆叠带来的"过平滑"问题。
2.4 为什么消息传递能解决"图没有固定顺序"的问题
刚才提到邻接矩阵最头疼的问题就是节点顺序变化会导致矩阵变化。消息传递框架是怎么绕开这个问题的?答案在于聚合函数被设计成置换不变的。
求和、求平均、取最大值都是置换不变的:你把输入集合的元素洗牌,sum的结果不变、mean的结果不变、max的结果也不变。所以无论图的节点怎么重新编号,每个节点的聚合结果在数学上完全一致。模型学到的表示因此天然具备置换不变性,不会出现"同一个图换了个节点顺序就预测结果变了"的窘境。
这一点是GNN能在图数据上工作的基石。你在阅读GNN论文时看到的所有复杂变体,剥开外壳,内核都没有离开这三步:消息、聚合、更新。
3. 从公式到直觉:GCN、GraphSAGE、GAT三个经典模型
3.1 GCN:把邻居信息做"度数加权"的累加
先看最经典的图卷积网络(GCN),它由某位学者的论文提出后,GNN领域才算真正火起来。GCN每一层的核心公式长这样:
H^(l+1) = σ( D^(-1/2) A D^(-1/2) H^(l) W^(l) )这个公式看着吓人,拆开看其实不复杂。A是邻接矩阵(加上了自环,让节点自己也参与计算),D是度矩阵,对角线上的值D_ii表示节点i的度数,即有多少个邻居。D^(-1/2) A D^(-1/2) 就是对邻接矩阵做对称归一化。
为什么要做归一化?直接拿原始邻接矩阵去乘的话,度数高的节点会把邻居信息累加出一个很大的值,度数低的节点累加值很小,数值尺度差异会导训练不稳定。归一化之后的实质就是:每个邻居对目标节点的贡献,会根据双方度数做一个调整——连接关系少的那条边,信息传递权重反而更高。
用一个例子理解:大V用户有几千个关注者,普通用户只有几个粉丝。如果直接取平均,普通用户的消息很容易被大V的信息淹没。GCN的归一化在某种意义上是做"逆频次加权":让每个节点传递出去的信息被合理稀释,避免了高节点的信息过度主导。
GCN的优势是简洁高效,一个隐藏层就能在Cora这类小规模数据上逼近不错的效果。但它的限制也很明显:它本质上是传导式(transductive)的,训练时需要全图的邻接矩阵参与运算;如果图是动态增长的,新增节点就没有现成的办法生成表示。
3.2 GraphSAGE:先采样邻居,再聚合
GraphSAGE(Graph Sample and Aggregation)解决的核心问题是"图太大,邻居太多,算不动"。GCN每层要遍历节点的所有邻居;社交网络里"顶流"节点可能有几百万个粉丝,全量计算根本吃不消。
GraphSAGE的做法是:每次训练时,对每个节点的邻居进行固定数量的随机采样,比如采样 10 个、 20 个,然后只拿这些采样出来的邻居做聚合。聚合方式有三种可选:Mean Aggregator就是取平均;LSTM Aggregator把邻居向量当作序列输入LSTM;Pooling Aggregator对每个邻居向量做逐维最大池化或平均池化。
因为它是采样驱动的,每次计算只依赖采样到的局部子图,所以天然支持归纳式(inductive)学习——完全没见过的节点,只要知道了它的邻居,就能生成它的表示。这一点对工业界的动态图场景非常重要。
我当时在某个跨平台系统里用GraphSAGE做新用户冷启动推荐:新用户刚注册只有少量行为,但它关注了一批老用户,GraphSAGE可以通过这些老用户的表示计算新用户向量,效果比纯靠行为特征好很多。
3.3 GAT:用注意力机制决定"该听谁的"
图注意力网络(GAT)引入了Transformer风格的注意力机制。之前的GCN把归一化系数写死,GCN的权重是由度和归一化固定算出来的,不随特征变化;而GAT让模型根据节点特征动态计算"邻居的发言权"。
具体来说,GAT在消息传递时,对每个邻居计算一个注意力系数:邻居的表示和目标节点的表示拼接后,经过一个带LeakyReLU的线性映射,再做一个softmax归一化。直观理解就是:目标节点在聚合邻居时,会对"和自己相关性更高的邻居"分配更大的权重。这个权重不是事先定好的,而是网络在训练中学会的。
多头注意力机制在GAT里也成了标配:跑多组独立的注意力头,把结果拼接或平均。这带来了类似CNN多通道的效果,让模型能从不同关系视角看待邻居,稳定性也更好。代价是计算量和参数量上去了。
3.4 三个模型的横向对比
我整理了一张表,方便你快速对比选择:
| 模型 | 聚合方式 | 计算开销 | 学习方式 | 适合场景 |
|---|---|---|---|---|
| GCN | 按度数归一化的固定加权求和 | 低,但依赖全图 | 传导式 | 中小规模静态图、快速搭基线 |
| GraphSAGE | 采样邻居后做mean/LSTM/pooling | 中,采样控制了规模 | 归纳式 | 大规模图、动态新增节点 |
| GAT | 可学习的注意力权重加权求和 | 较高 | 传导式或归纳式 | 邻居重要性差异大的场景 |
需要说明的是,"GCN依赖全图"只影响训练时的计算方式。如果图有 10 亿节点,直接算归一化邻接矩阵是灾难;但对Cora这种几千节点的数据,GCN就是最顺手的入门选择。所以我的建议很直接:想快速上手GNN,先用GCN跑通全流程;遇到规模瓶颈了再切GraphSAGE;需要精细化表达关系重要性时再上GAT。
4. 环境和数据准备:PyTorch Geometric 的安装与数据集细节
4.1 为什么选 PyTorch Geometric
目前主流的GNN框架有两个选择:PyTorch Geometric(简称PyG)和 Deep Graph Library(简称DGL)。我个人的选择是PyG,原因有三点:一是PyG完全基于PyTorch的算子风格,熟悉PyTorch的人几乎零成本上手;二是它内置了丰富的基准数据集和常用模型,入门阶段不用自己写数据加载;三是它的消息传递API设计非常直白,改模型结构很容易。
安装PyG有一条关键经验:版本必须和当前PyTorch版本匹配。如果你用的是PyTorch 2.x,直接执行pip install torch_geometric通常就能装好基础库;但有些扩展算子(比如scatter、sparse)在源码编译阶段容易报错。
这里分享一个稳妥的做法:在安装PyG之前,先确认torch版本,然后再到PyG官网匹配的安装命令中找对应命令。如果你不想折腾编译,也可以直接用预编译的wheel包,那个更省心。装了PyG之后,建议顺手装一个torch-geometric的依赖检查命令,导入时不报错就说明环境正常了。
4.2 Cora数据集:论文引用网就是入门最佳练习场
Cora是一个经典的论文引用网络数据集,常被用来做GNN的"Hello World"。这个数据集包含 2708 篇论文,每篇论文对应一个节点;论文之间的引用关系对应边,一共 5429 条。每篇论文用一个 1433 维的词袋特征向量表示,论文所属的研究方向(总共 7 类)就是要预测的标签。
Cora最方便的地方是它自带标准的数据划分:训练集 20 个节点,验证集 500 个节点,测试集 1000 个节点。注意,训练集只有20个节点,所以模型必须在"只看极少标签"的情况下利用图结构信息去泛化,这正是GNN的价值所在。Cora数据集的规模刚好适中,在普通笔记本CPU上几十秒就能完成一轮像样的训练,非常适合做入门实验。
4.3 PyG里的Data对象到底存了什么
用PyG加载Cora之后,你会得到一个Data对象,里面有四个你需要全部搞清楚的字段:
x:节点特征矩阵,形状是 N×d,N是节点数,d是特征维度。Cora里就是 2708×1433。edge_index:边的索引,形状是 2×E,两行分别是边的起点和终点。它用的是COO格式,也就是"坐标列表",只记录真实存在的边,不存稀疏矩阵里的所有0元素。这是为什么加载大图时内存开销远小于邻接矩阵的关键。y:节点标签,形状是 N×1。train_mask、val_mask、test_mask:布尔型掩码数组,标记了每个节点属于训练集、验证集还是测试集。
我第一次用PyG时在这里犯了个错:习惯性地去找data.adj这种邻接矩阵属性,结果发现根本没有。PyG的图数据默认以edge_index形式存储,而不是邻接矩阵。理解了这个设计,你后面写模型时就知道,GNN层接收的核心参数不再是矩阵乘法里的X,而是x和edge_index这一对数据。
5. 实战:用GCN在Cora上完成节点分类
5.1 完整代码,直接跑
下面的代码我整理成了一份可以直接运行的脚本,除了必要的依赖以外没有任何额外操作。建议你手动敲一遍,不要复制粘贴,敲的过程中你会更快记得edge_index是怎么通过参数传进模型层的。
import torch import torch.nn.functional as F from torch_geometric.nn import GCNConv from torch_geometric.datasets import Planetoid # 1. 加载Cora数据集 dataset = Planetoid(root='./data', name='Cora') data = dataset[0] # 2. 定义GCN模型 class GCN(torch.nn.Module): def __init__(self, in_channels, hidden_channels, out_channels): super().__init__() self.conv1 = GCNConv(in_channels, hidden_channels) self.conv2 = GCNConv(hidden_channels, out_channels) def forward(self, x, edge_index): x = self.conv1(x, edge_index) x = F.relu(x) x = F.dropout(x, training=self.training) x = self.conv2(x, edge_index) return F.log_softmax(x, dim=1) # 3. 初始化模型和优化器 model = GCN(dataset.num_features, 16, dataset.num_classes) optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=5e-4) criterion = torch.nn.CrossEntropyLoss() # 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.item() # 5. 评估函数 @torch.no_grad() def test(): model.eval() out = model(data.x, data.edge_index) pred = out.argmax(dim=1) accs = [] for mask in [data.train_mask, data.val_mask, data.test_mask]: acc = (pred[mask] == data.y[mask]).float().mean() accs.append(acc.item()) return accs # 6. 训练200个epoch for epoch in range(200): loss = train() train_acc, val_acc, test_acc = test() if epoch % 10 == 0: print(f'Epoch {epoch:03d} | Loss {loss:.4f} | ' f'Train Acc {train_acc:.4f} | Val Acc {val_acc:.4f} | ' f'Test Acc {test_acc:.4f}')正常情况下跑到最后几十轮,测试准确率会在 0.80 到 0.82 之间。在只有20个标注节点的情况下拿到这个成绩,靠的完全是图结构信息在发挥威力。
5.2 代码里最值得琢磨的几个地方
仔细看模型定义,你会发现整个forward里没有出现任何显式的"邻接矩阵乘法"。这个操作被完全封装进了GCNConv内部。每个GCNConv做的事情就是:把传入的x做线性变换,然后根据edge_index把邻居变换后的特征加权聚合到目标节点上。你不需要手动维护消息传递逻辑,但要知道它底层干的就是这个事。
关于损失计算,代码里用了CrossEntropyLoss,而模型的输出是log_softmax的结果。理论上CrossEntropyLoss和log_softmax搭配会出现重复计算,但PyTorch的CrossEntropyLoss接受任意数值输入,内部会自行完成softmax和损失计算,所以log_softmax在这里主要是为了评估时能够直接用argmax取概率最大的类别,不影响训练。
还有一个细节是weight_decay=5e-4。这个L2正则项对GNN训练非常重要,因为它能抑制模型对邻居信息的过度拟合。我第一次跑Cora时把这个参数去掉,测试准确率直接掉了两个点,可见正则项在标签极稀疏的场景里有多关键。
5.3 训练集准确率波动大怎么办
如果你运行代码,很可能会看到Train Acc的数值在频繁波动,这是正常现象。Cora的训练掩码只有20个节点,相当于用20个样本训练整个图模型,某一两个样本预测错误就会让训练准确率剧烈跳动。判断模型好坏的主要依据是验证集和测试集准确率,不要被训练集的波动扰乱心态。
还有一个关键经验:如果测试准确率一直卡在 0.5 以下上不去,先检查data.y的类型。PyG加载的数据标签默认是长整型,如果你手动构造数据时把它转成了浮点型,交叉熵损失函数会直接报错或计算出完全没意义的结果。这是入门阶段最常遇到的隐性错误。
6. 入门者最容易踩的坑与选型经验
6.1 坑一:把传导式预测当成归纳式使用
初学者跑通Cora之后,容易产生一种错觉:训练好了的模型可以拿到任何新数据上直接预测。这在GCN场景下是错的。GCN属于传导式学习,训练时全图的节点都属于同一个固定图,测试节点的特征和图结构在训练阶段就已经参与过归一化计算了。它确实能对新节点给出输出,但这个输出依赖的是它已经见过的"整张图的邻居结构"。
如果业务场景是"新用户不断加入、新论文不断出现",用GCN就要谨慎。更合理的做法是换GraphSAGE这类归纳式模型,它不依赖全图结构,只依赖采样到的局部邻域,天然适配动态图。选模型之前先想明白一件事:你的图是静态的还是动态的?这个问题直接决定模型的归纳能力要求。
6.2 坑二:特征没有归一化就开训
图数据里的节点特征来源五花八门:有的是词袋计数,有的是用户行为统计,有的是传感器数值。不同维度之间的尺度差异如果很大,消息传递的聚合结果会被大数值特征主导。很多入门项目效果不好,第一嫌疑就是特征没做归一化。
建议拿到数据集第一件事就是检查各维特征的数值范围。如果差异明显,先做标准化或归一化再进行训练。对Cora这种词袋特征,可以先对每篇论文的向量做L2归一化。这一点虽然简单,但对训练稳定性的提升立竿见影。
6.3 坑三:盲目加深网络,遭遇过平滑
很多从CNN转过来的人,习惯性把网络叠到十几层,却发现GNN层数越深效果反而越差。这不是你的调参问题,而是GNN特有的"过平滑"现象:随着消息传递层数增加,每个节点不断聚合邻居信息,节点的表示会逐渐趋同,最终所有节点变得难以区分。
经验法则是,中小规模图的数据集,2到3层GNN通常就够用了。Cora上两层GCN已经能到80%以上的准确率,硬加到5层反而会掉到70%左右。如果你的任务确实需要很广的信息传播范围,优先考虑跳连(skip connection)等残差机制,而不是单纯增加层数。
6.4 坑四:数据划分时发生信息泄露
在图数据上做划分,跟普通表格数据最大的不同是:节点之间有边相连,如果训练集和测试集的节点直接挨着,测试信息就会通过消息传递悄悄流进训练过程。Cora官方划分已经考虑过这个问题,但你自己构造数据集时一定要想清楚。
举个例子,我之前用某跨平台系统的用户关系图做节点分类,随手按随机比例切分。后来复盘发现,测试集里很多用户和训练集用户直接相连,模型其实是在靠"朋友的标签"做预测,模型上线后效果立刻缩水。解决思路有两种:一是按连通分量划分,让训练和测试尽量分属不同连通区域;二是使用GraphSAGE这类采样式模型,在采样阶段就隔离未知节点。
6.5 选型决策:一个小决策树
最后把选型思路压缩成一个简单的判断流程:图规模小、场景静态、先要一个能跑通的基线,选GCN。图规模大、有新增节点需求,选GraphSAGE。关系复杂、节点特征重要程度差异明显,比如不同邻居对目标节点的语义贡献完全不一样,选GAT。如果你的图本身是同构图且节点类型单一,GCN的性价比最高;如果涉及不同类型节点和关系的异构图,那么可以考虑后续进阶学习GAT或者专门的异构图模型。
我在实际做项目时,还有一个体会想分享:不要一开始就追求复杂模型。先用GCN跑通流程,把特征归一化、数据划分、评估逻辑这些基础环节做扎实,再根据业务瓶颈去升级模型。很多项目最后发现瓶颈根本不是模型能力,而是数据质量或特征工程没做好。GNN的入门核心在于理解消息传递这套思想,模型结构只是思想的不同实现方式。把这一点想明白,你再去读那些花哨的GNN论文,就会发现它们真的只是在"消息怎么生成、怎么聚合"上做文章而已。