图卷积网络 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 单层消息传递过程 输入层 节点自身特征 邻居特征集 (度归一化) 聚合操作 加权求和 自环特征相加 归一化系数 线性变换 × W 权重 + 偏置(可选) ReLU 激活 输出层 新节点特征 可用于分类 或下一层 注意:聚合操作之前需要对邻接矩阵进行对称归一化(D̂⁻¹ᐟ² · Ã · D̂⁻¹ᐟ²) 防止高度数节点主导邻居特征

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

等等,这个代码是不是太简单了?嗯,没错,核心就三步:

  1. 对输入特征做线性变换(乘以 W)。
  2. 用归一化邻接矩阵做稀疏矩阵乘法——这就是消息聚合。
  3. 没有显式写激活函数,因为通常在层外调用。

实际使用时要加上 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)平滑加权求和对称归一化简单高效,适合直推式学习
GraphSAGEMean / 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 解决一个真实的节点分类任务。