图卷积网络 GCN:从邻居聚合到节点表示
图卷积网络,大伙儿都叫它 GCN。我第一次接触的时候,脑子里冒出一个问题:这玩意儿和咱们熟知的 CNN 到底差在哪儿?
CNN 处理的是规规矩矩的网格数据,比如图像,像素点排列得整整齐齐。图数据可没这么乖——节点的邻居数量不固定,结构也不规则。所以,GCN 的核心思想就是:让每个节点学会从它的邻居那里“收集信息”,然后更新自己的状态。
核心直觉:消息传递与邻居聚合
说白了,GCN 每一层做的事就是:我看我的邻居们都藏着什么特征,把它们加权求和一下,再经过一个非线性变换,就得到了我这层的新特征。层数越深,能“看到”的邻居范围越远——这不就是感受野嘛。
举个例子,社交网络里,如果我关注了五个科技大V和一个美食博主,那我新一轮的特征可能更偏科技一些。当然,权重怎么分配,这学问就大了。
数学上到底怎么干?
Kipf & Welling 在 2017 年提出的经典 GCN 公式长这样:
H^{(l+1)} = σ( D̂^{-1/2} · Ã · D̂^{-1/2} · H^{(l)} · W^{(l)} )
别被吓到,我们拆开看:
- Ã = A + IN,就是把原始邻接矩阵加上自环,让自己也能看到自己。
- D̂ 是 Ã 的度矩阵,对角线上每个值就是对应节点的度。
- H^{(l)} 是第 l 层的节点特征矩阵,第一层就是原始特征。
- W^{(l)} 是第 l 层可学习的权重矩阵——这是模型要训练的参数。
- σ 是非线性激活函数,一般用 ReLU。
D̂^{-1/2} · Ã · D̂^{-1/2} 这个组合,叫归一化的邻接矩阵。为什么要归一化?
如果不归一化,度数大的节点(比如微博大V,邻居上万)聚合时会把特征“冲”得特别大,度数小的节点被淹没了。归一化之后,每个节点不管有多少邻居,聚合后的特征尺度差不多。我在做推荐系统项目时吃过这个亏,第一次直接用原始邻接矩阵,训练直接发散……后来加了归一化才稳住。
消息传递的直观理解
可以画成这样一个流程:
输入特征 → 邻居特征加权求和 → 加上自身特征 → 非线性激活 → 输出特征
每一层都做这件事。两层 GCN 就能聚合两跳邻居的信息。你想想看,你只需要两层,就能知道你朋友的朋友在干嘛。
关键点:GCN 的层数决定了信息的传播距离。但层数并不是越多越好——多了会出现“过平滑”现象,所有节点特征趋于一致,反而啥也学不到。我一般建议任务先试 2-3 层。
SVG:GCN 单层消息传递图解
GCN 的层数与感受野
一层 GCN,节点只能看到自己的直接邻居。两层就能看到两跳邻居,也就是邻居的邻居。这有点像 CNN 里的卷积核尺寸。
不过,GCN 有个小毛病:层数一旦超过 5 层,所有节点的特征就会变得差不多——这叫“过平滑”。
避坑指南:我曾经在一个蛋白质图分类任务中堆了 8 层 GCN,测试精度反而比 3 层还低。后来一查,就是过平滑了。建议:对中小图用 2-3 层;如果非要用深层,考虑加残差连接或 DropEdge 技巧。
代码实现:用 PyTorch 写一个 GCN 层
理论说够了,直接上代码。下面是一个最简单的 GCN 层实现:
import torch
import torch.nn as nn
import torch.nn.functional as F
class GCNLayer(nn.Module):
def __init__(self, in_features, out_features, bias=True):
super().__init__()
self.linear = nn.Linear(in_features, out_features, bias=bias)
def forward(self, x, adj_norm):
# x: [N, in_features] 节点特征矩阵
# adj_norm: [N, N] 归一化邻接矩阵
support = self.linear(x) # 线性变换 W*x
out = torch.spmm(adj_norm, support) # 稀疏矩阵乘法:D̃⁻¹/² Ã D̃⁻¹/² * H * W
return out
等等,这个代码是不是太简单了?嗯,没错,核心就三步:
- 对输入特征做线性变换(乘以 W)。
- 用归一化邻接矩阵做稀疏矩阵乘法——这就是消息聚合。
- 没有显式写激活函数,因为通常在层外调用。
实际使用时要加上 ReLU 和 Dropout:
class GCN(nn.Module):
def __init__(self, nfeat, nhid, nclass, dropout=0.5):
super().__init__()
self.gc1 = GCNLayer(nfeat, nhid)
self.gc2 = GCNLayer(nhid, nclass)
self.dropout = nn.Dropout(dropout)
def forward(self, x, adj_norm):
x = F.relu(self.gc1(x, adj_norm))
x = self.dropout(x)
x = self.gc2(x, adj_norm)
return F.log_softmax(x, dim=1)
这个两层 GCN 已经能解决很多节点分类问题了。我记得有一次拿它跑 Cora 数据集,测试准确率 81.5%,比当时的 baselines 都高。
归一化邻接矩阵的计算方式
上面的代码假设 adj_norm 已经算好了。怎么算?看下面:
def normalize_adj(adj):
# adj: scipy sparse csr matrix
adj = adj + sp.eye(adj.shape[0]) # 加自环
d = np.array(adj.sum(1)).flatten() # 计算度
d_inv_sqrt = np.power(d, -0.5)
d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.0
d_mat_inv_sqrt = sp.diags(d_inv_sqrt)
return d_mat_inv_sqrt @ adj @ d_mat_inv_sqrt
这对应了前面公式中 D̂^{-1/2} · Ã · D̂^{-1/2}。注意一定要用稀疏矩阵,否则 N=2708 的 Cora 图,稠密矩阵就 2708×2708,直接内存爆炸。
GCN 和其他图网络变体的对比
为了方便你理解,我整理了一张表:
| 模型 | 聚合方式 | 归一化 | 特点 |
|---|---|---|---|
| GCN (Kipf, 2017) | 平滑加权求和 | 对称归一化 | 简单高效,适合直推式学习 |
| GraphSAGE | Mean / LSTM / Pooling | 无(按邻居数平均) | 归纳式,可处理新节点 |
| GAT | 注意力加权求和 | Softmax 归一化 | 动态学习邻居重要性 |
你看,GCN 是最朴素的一种,但正因为朴素,它的计算效率最高、参数量最少。很多工业场景里,GCN 依然是首选 baseline。
个人习惯:我通常在跑小规模图(节点数 < 100万)时直接用 GCN,又快又稳。如果图特别大或者需要归纳能力,再上 GraphSAGE 或 GAT。
训练 GCN 的常见设置
- 优化器:Adam,学习率 0.01,权重衰减 5e-4
- 早停:验证集 loss 连续 10 轮不下降就 stop
- Batch:全图训练(Full-batch)通常可行,但大图要用邻域采样
- 初始化:Xavier 均匀初始化
嗯,今天就先到这儿。GCN 的原理和实现都不复杂,但细节决定了你能不能跑通。下次见面我们再聊聊如何用 GCN 解决一个真实的节点分类任务。