前面 36 期讲的模型几乎都假设输入是序列(文本)或网格(图像)。但你最熟悉的很多数据其实是图:服务调用拓扑、社交关系、知识图谱、数据库外键网络。这些数据的核心信息藏在连接里,不是藏在单个节点里。今天讲机器学习怎么直接吃下"图"这种结构——它的核心机制只有一个:让节点和邻居交换信息。
消息传递就是分布式系统里的 gossip 协议:每一轮,每个节点把自己的状态发给所有邻居,同时收集邻居发来的状态,聚合(aggregate)后更新自己。跑 K 轮,每个节点就"知道"了 K 跳以内整个邻域的信息——和 gossip 协议靠多轮传播让全网状态最终收敛,是同一个套路。
图数据有个要命的特性:没有固定顺序,节点的邻居数量还不一样。文本能排成序列喂给 Transformer,图像能排成网格喂给 CNN,但图——节点 A 有 3 个邻居、节点 B 有 500 个邻居,你没法把它塞进一个固定形状的张量。早期做法是手工提取图特征(度数、中心性…),费力且丢信息。
消息传递神经网络(MPNN,Gilmer 等 2017)把所有图神经网络统一成一个框架:每一层做三件事——
关键设计是聚合函数必须对顺序不敏感(permutation-invariant)——求和、平均、取最大,这些不管邻居以什么顺序进来,结果都一样。这正好解决了"图没有顺序"的难题。这就是所有 GNN 的母机制:GCN、GAT 不过是把"消息"和"聚合"两步换了不同公式而已。
# 纯 numpy 演示一层消息传递的本质(不依赖框架) import numpy as np # A: 邻接矩阵(谁连谁) H: 每个节点的特征向量 A = np.array([[0,1,1,0],[1,0,1,0],[1,1,0,1],[0,0,1,0]]) H = np.random.randn(4, 8) # 4 个节点, 每个 8 维 W = np.random.randn(8, 16) # 可学习的变换矩阵 # ① Message + ② Aggregate: A @ H 就是"把每个邻居的特征加起来" agg = A @ H # (4,4)@(4,8) = (4,8) 邻居特征求和 # ③ Update: 线性变换 + 非线性激活 H_next = np.maximum(0, agg @ W) # ReLU, 得到 (4,16) 新表示 # 叠两层 → 每个节点就融合了 2 跳邻域的信息
GCN 的聚合就是带归一化的邻居平均——像数据库里对一对多关系做 AVG() 聚合,但多了一步按"双方度数"加权。直觉:一个邻居如果自己连了 1000 个节点,它发给你的消息可信度应该打折(它对谁都说同样的话);一个只连你的邻居,消息更"专属"、权重更高。
GCN(Kipf & Welling 2016)是把复杂的"谱图卷积"理论一路简化到极致,最后得到一个干净到惊人的公式。上一张卡里我们用 A @ H 做朴素求和,但它有两个毛病:(1) 没把节点自己算进去;(2) 高度数节点的聚合值会爆炸。GCN 的修正只有一行:
H⁽ˡ⁺¹⁾ = σ( D̃-½ Ã D̃-½ H⁽ˡ⁾ W )
别被公式吓到,逐个拆——Ã = A + I:邻接矩阵加上单位矩阵,就是"把自己也当成自己的邻居"(self-loop),这样更新时不会丢掉自身信息。D̃ 是度矩阵(每个节点连了几条边)。D̃-½ Ã D̃-½ 这一坨叫对称归一化:给每条边 i→j 的权重除以 √(deg_i · deg_j),让高度数节点的影响被压下去。W 是可学习权重,σ 是激活函数。整句话翻译成人话:每个节点 = 激活( 归一化加权的邻居(含自己)平均,再做一次线性变换 )。
# PyTorch Geometric: 工业界最常用的图学习库 import torch from torch_geometric.nn import GCNConv from torch_geometric.datasets import Planetoid dataset = Planetoid(root="/tmp/Cora", name="Cora") # 论文引用图 data = dataset[0] # 2708 篇论文, 边=引用关系 class GCN(torch.nn.Module): def __init__(self): super().__init__() self.c1 = GCNConv(dataset.num_features, 16) self.c2 = GCNConv(16, dataset.num_classes) def forward(self, x, edge_index): x = torch.relu(self.c1(x, edge_index)) # 第1跳 return self.c2(x, edge_index) # 第2跳→分类 # 仅 2 层就能在 Cora 上达到 ~81% 节点分类准确率
如果说 GCN 是"按固定规则平均所有邻居",GAT 就是给每个邻居装了一个智能加权的负载均衡器:权重不再由度数写死,而是根据"我和这个邻居有多相关"动态算出来。这正是 Day 1 学过的 attention 机制——只不过这次的"序列"是你的邻居集合。
GCN 的死穴:所有邻居权重被结构固定。但现实里邻居重要性千差万别——你的社交网络里,最亲密的 1 个朋友比 200 个点赞之交对你的影响大得多。GAT(Veličković 等 2017)把 Transformer 的注意力搬到图上:对节点 i 的每个邻居 j,先算一个注意力系数 αᵢⱼ,表示"j 对 i 有多重要",再用它加权聚合。
机制三步:(1) 把 i、j 的特征拼起来过一个小网络,得到原始分数 eᵢⱼ;(2) 对 i 的所有邻居的分数做 softmax 归一化(保证权重和为 1,跟 attention 一模一样);(3) 按 αᵢⱼ 加权求和邻居特征。和 GCN 最大的区别:
和 Transformer 一样,GAT 也用多头注意力(multi-head):并行跑多套独立的注意力再拼接,稳定训练、捕捉邻居的多种关系。代价是计算量比 GCN 大——天下没有免费的自适应。
from torch_geometric.nn import GATConv # 把上一张卡的 GCNConv 直接换成 GATConv 即可 # heads=8: 8 个注意力头并行(类比 Transformer 多头) self.c1 = GATConv(in_dim, 8, heads=8) # 输出 8×8=64 维 self.c2 = GATConv(64, num_classes, heads=1) # 最后一层单头 # forward 与 GCN 完全相同; 区别全在内部: # GATConv 会为每条边学一个注意力系数 α, 而非用度数固定 # 训练后可导出 α 看模型"重点关注了哪些邻居"——天然可解释
图嵌入就是给每个节点算一个稠密向量(embedding),让"图里挨得近的节点,向量空间里也挨得近"——本质和 Day 22 的语义搜索一样:把对象映射到向量空间,用距离表示相似度。区别只在"相似"的定义来自图结构而非文本语义。算出来的向量可以直接喂给任何下游模型(分类器、推荐系统、最近邻检索)。
有时你不想训练端到端的 GNN,只想要一份现成的节点向量。node2vec(Grover & Leskovec 2016)的思路极其优雅,直接复用了 word2vec:在图上做随机游走(random walk)生成大量"节点序列",把序列当成"句子"、节点当成"词",套 word2vec 训练——经常一起出现在游走路径里的节点,向量就接近。它还用两个参数 p、q 控制游走偏向 BFS(广度,抓局部社区结构)还是 DFS(深度,抓全局角色)——你可以直接类比成爬虫的两种遍历策略。
但 node2vec 有个根本局限叫直推式(transductive):它为训练时见过的每个节点查表取向量,来一个新节点就抓瞎——必须整个重训。这在动态图(每天新增用户)里是致命的。GraphSAGE(Hamilton 等 2017)解决了它:不再为每个节点存向量,而是学一个聚合函数(采样邻居 + 聚合,本质还是消息传递),新节点来了,用它的邻居现场算向量即可——这叫归纳式(inductive)。
from torch_geometric.nn import Node2Vec import torch # 在图上做随机游走, 学每个节点的 embedding model = Node2Vec( data.edge_index, embedding_dim=128, walk_length=20, # 每条游走走 20 步 context_size=10, # word2vec 式的窗口 p=1.0, q=1.0, # p小偏BFS(局部), q小偏DFS(全局) ) loader = model.loader(batch_size=128, shuffle=True) # ... 标准训练循环 ... emb = model() # (N, 128) 每个节点一个向量 # 拿到向量后: 余弦相似度找"结构相似"的节点