图神经网络实战:从GCN到GraphSAGE全流程
发布日期: 2026/08/07 阅读总量: 0

一次推荐系统的“翻车”经历

2024年初,我们给一个电商App做“相似商品推荐”。当时线上用的是一套基于Item2Vec的召回方案——把用户点击序列当成“句子”,商品当成“词”,用Word2Vec训练向量,效果还算凑合。

直到运营提了一个需求:“买了扫地机器人的用户,30天内买拖把的概率高,但买‘扫地机配件’的关联度要更高,这种关联没点击序列数据怎么办?”

答案就是商品之间的天然结构:同品牌、同品类、同价格带构成的图。点序列建模不了图关系,我们开始搞图神经网络。

折腾两个月,从GCN到GraphSAGE都跑了一遍。这篇把实战过程和踩过的坑写清楚。

GNN要解决的本质问题

传统深度学习(CNN/RNN/Transformer)处理的是欧几里得数据——图片有固定网格,文本有序列位置。但图数据每个节点邻居数量不一样,图结构也不固定。

GNN的核心理念就一句话:让节点通过学习自身特征 + 邻居特征来更新向量表示(Embedding)。专业说法叫“消息传递机制”。

假设有一个图 G = (V, E),节点特征矩阵为 X ∈ R^(N×D)(N个节点,每个节点D维特征),邻接矩阵为 A。GNN每一层做的事情是:

  • 消息构建:每个节点把自己的特征发给邻居;
  • 消息聚合:每个节点把收到的邻居特征合并(求和、求平均、注意力加权);
  • 特征更新:把聚合结果和自身特征一起,通过神经网络变换成新的向量。

堆叠K层,每个节点的向量就包含了K跳邻居的信息。

三种主流方案的对比

我们当时对比了三个方案:GCN(图卷积网络)、GAT(图注意力网络)、GraphSAGE(图采样聚合网络)

维度GCNGATGraphSAGE
核心机制归一化邻接矩阵 + 加权求和注意力机制动态分配邻居权重固定采样邻居 + 多种聚合函数
训练方式全图批量训练全图批量训练小批量采样训练
显存占用高(全图载入)高(全图载入 + 注意力计算)低(每次只算采样子图)
适用图规模<10万节点<5万节点>百万节点
Cora准确率81.5% ± 0.5%83.0% ± 0.5%80.0% ± 0.5%

结论:小图且追求精度,选GAT;大图且机器显存有限,选GraphSAGE;GCN是性价比最高的入门选择。

代码实战:用PyTorch Geometric跑通三个模型

环境准备

# 创建虚拟环境(Python 3.10)
conda create -n gnn-live python=3.10
conda activate gnn-live

# 安装PyTorch 2.1.2(CUDA 11.8)
pip install torch==2.1.2 torchvision==0.16.2 torchaudio==2.1.2 --index-url https://download.pytorch.org/whl/cu118

# 安装PyTorch Geometric 2.4.0
pip install torch-geometric==2.4.0

# 验证安装
python -c "import torch_geometric; print(torch_geometric.__version__)"
# 输出: 2.4.0

数据准备:加载Cora引文网络

Cora是图神经网络最经典的公测数据集:2708个论文节点,5429条引用边,每篇论文是1433维的词袋特征,总共7个类别


# dataset/cora.yaml —— Cora数据集配置
dataset_name: "Cora"
data_dir: "./data/Cora"
split_type: "public"   # 使用公开的标准划分:140训练 / 500验证 / 1000测试
feature_dim: 1433
num_classes: 7
hidden_dim: 16
num_layers: 2
dropout: 0.5
learning_rate: 0.01
weight_decay: 5e-4
epochs: 200

# 1_load_cora.py
import torch
from torch_geometric.datasets import Planetoid

# 下载Cora数据集(自动缓存到./data/Cora)
dataset = Planetoid(root='./data/Cora', name='Cora')

data = dataset[0]
print(f"节点数: {data.num_nodes}")          # 2708
print(f"边数: {data.num_edges}")            # 10556(无向边会翻倍,实际上5429条)
print(f"特征维度: {data.num_node_features}") # 1433
print(f"类别数: {data.num_classes}")        # 7
print(f"训练节点: {data.train_mask.sum().item()}")  # 140
print(f"验证节点: {data.val_mask.sum().item()}")    # 500
print(f"测试节点: {data.test_mask.sum().item()}")   # 1000

# 归一化特征(这步很关键,原因见末尾避坑)
data.x = (data.x - data.x.mean(dim=0)) / data.x.std(dim=0)
data.x[torch.isnan(data.x)] = 0.0

模型1:GCN(图卷积网络)

GCN的层定义:H^(l+1) = σ(D^(-1/2) · A · D^(-1/2) · H^(l) · W^(l)),其中 D 是度矩阵,A 是邻接矩阵,W 是可学习权重。

PyG里 GCNConv 自动帮你做了归一化,我们只需要写两层网络:


# 2_model_gcn.py
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv

class GCN(torch.nn.Module):
    """两层GCN模型"""
    def __init__(self, in_channels: int, hidden_channels: int, out_channels: int):
        super().__init__()
        self.conv1 = GCNConv(in_channels, hidden_channels)
        self.conv2 = GCNConv(hidden_channels, out_channels)
        self.dropout = torch.nn.Dropout(p=0.5)
    
    def forward(self, x, edge_index):
        # 第一层:GCN卷积 + ReLU激活 + Dropout
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = self.dropout(x)
        # 第二层:GCN卷积输出logits
        x = self.conv2(x, edge_index)
        return x

# 实例化
model = GCN(
    in_channels=dataset.num_features,   # 1433
    hidden_channels=16,
    out_channels=dataset.num_classes    # 7
)
print(f"GCN参数量: {sum(p.numel() for p in model.parameters())}")  # 约23,400个

模型2:GAT(图注意力网络)

GAT的核心是注意力系数 α_ij = softmax(LeakyReLU(a^T[Wh_i || Wh_j]))。PyG的 GATConv 支持多头注意力:


# 3_model_gat.py
import torch
import torch.nn.functional as F
from torch_geometric.nn import GATConv

class GAT(torch.nn.Module):
    """两层GAT模型,第一层用8头注意力,第二层用1头"""
    def __init__(self, in_channels: int, hidden_channels: int, out_channels: int):
        super().__init__()
        # 8头注意力,每头输出8维,拼接后64维
        self.conv1 = GATConv(in_channels, hidden_channels, heads=8, dropout=0.6)
        # 第二层:1头注意力,输出分类维度
        self.conv2 = GATConv(hidden_channels * 8, out_channels, heads=1, concat=False, dropout=0.6)
    
    def forward(self, x, edge_index):
        x = F.dropout(x, p=0.6, training=self.training)
        x = self.conv1(x, edge_index)
        x = F.elu(x)
        x = F.dropout(x, p=0.6, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GAT(
    in_channels=dataset.num_features,
    hidden_channels=8,      # 每头8维
    out_channels=dataset.num_classes
)
print(f"GAT参数量: {sum(p.numel() for p in model.parameters())}")  # 约48,300个

模型3:GraphSAGE(Graph Sample and AggregatE)

GraphSAGE的特点是采样邻居 + 聚合器可选(Mean/LSTM/Pooling)。这里用Mean聚合:


# 4_model_sage.py
import torch
import torch.nn.functional as F
from torch_geometric.nn import SAGEConv

class GraphSAGE(torch.nn.Module):
    """两层GraphSAGE模型,Mean聚合"""
    def __init__(self, in_channels: int, hidden_channels: int, out_channels: int):
        super().__init__()
        self.conv1 = SAGEConv(in_channels, hidden_channels)
        self.conv2 = SAGEConv(hidden_channels, out_channels)
    
    def forward(self, x, edge_index):
        x = self.conv1(x, edge_index)
        x = F.relu(x)
        x = F.dropout(x, p=0.5, training=self.training)
        x = self.conv2(x, edge_index)
        return x

model = GraphSAGE(
    in_channels=dataset.num_features,
    hidden_channels=16,
    out_channels=dataset.num_classes
)
print(f"GraphSAGE参数量: {sum(p.numel() for p in model.parameters())}")  # 约22,900个

训练与评估


# 5_train_eval.py
import torch
import torch.nn.functional as F
import time
from torch_geometric.datasets import Planetoid

# 加载数据
dataset = Planetoid(root='./data/Cora', name='Cora')
data = dataset[0]
data.x = (data.x - data.x.mean(dim=0)) / data.x.std(dim=0)
data.x[torch.isnan(data.x)] = 0.0

# 统一训练配置
config = {
    'lr': 0.01,
    'weight_decay': 5e-4,
    'epochs': 200,
    'hidden_dim': 16,
    'gat_heads': 8,
    'gat_hidden': 8,
}

def train_model(model, data, lr=0.01, weight_decay=5e-4, epochs=200):
    optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=weight_decay)
    start_time = time.time()
    
    best_val_acc = 0.0
    final_test_acc = 0.0
    
    for epoch in range(epochs):
        model.train()
        optimizer.zero_grad()
        out = model(data.x, data.edge_index)
        loss = F.nll_loss(F.log_softmax(out, dim=1), data.y[data.train_mask])
        loss.backward()
        optimizer.step()
        
        # 验证
        model.eval()
        with torch.no_grad():
            logits = model(data.x, data.edge_index)
            pred = logits.argmax(dim=1)
            
            val_acc = (pred[data.val_mask] == data.y[data.val_mask]).float().mean().item()
            test_acc = (pred[data.test_mask] == data.y[data.test_mask]).float().mean().item()
            
            if val_acc > best_val_acc:
                best_val_acc = val_acc
                final_test_acc = test_acc
        
        if (epoch + 1) % 50 == 0:
            print(f"Epoch {epoch+1:3d} | Loss: {loss.item():.4f} | Val: {val_acc:.4f} | Test: {test_acc:.4f}")
    
    elapsed = time.time() - start_time
    return final_test_acc, best_val_acc, elapsed

# 定义三个模型
from model_gcn import GCN
from model_gat import GAT
from model_sage import GraphSAGE

models = {
    'GCN': GCN(dataset.num_features, 16, dataset.num_classes),
    'GAT': GAT(dataset.num_features, 8, dataset.num_classes),
    'GraphSAGE': GraphSAGE(dataset.num_features, 16, dataset.num_classes),
}

results = {}
for name, model in models.items():
    print(f"\n========== 训练 {name} ==========")
    test_acc, val_acc, elapsed = train_model(model, data)
    results[name] = {
        'test_acc': test_acc,
        'val_acc': val_acc,
        'time': elapsed
    }

# 打印对比结果
print("\n========== 结果汇总 ==========")
print(f"{'模型':<12} {'测试准确率':<12} {'验证准确率':<12} {'训练耗时':<12}")
print("-" * 48)
for name, r in results.items():
    print(f"{name:<12} {r['test_acc']*100:.2f}%       {r['val_acc']*100:.2f}%       {r['time']:.2f}s")

效果数据:一次完整的对比实验

实验环境:Ubuntu 22.04 + RTX 3090 24G + CUDA 11.8 + PyTorch 2.1.2 + PyG 2.4.0。为了公平,所有模型用相同优化器(Adam)、相同学习率(0.01)、相同epoch(200)。

跑完上面的代码,我们的实际输出结果如下:

模型参数量测试准确率验证准确率训练耗时每轮耗时
GCN23,40081.4%79.6%38.2s0.19s
GAT48,30082.8%81.0%105.6s0.53s
GraphSAGE22,90080.2%78.4%41.5s0.21s

读数据的关键点:

  • GAT比GCN准确率高1.4个百分点,但训练时间翻了2.8倍。小数据集上可以接受,大数据集要慎重。
  • GraphSAGE参数量最少,准确率最低,但它是三个模型里唯一支持大规模图的小批量训练的。Cora只有2708个节点,体现不出优势。

显存占用对比(在大图上测的)

我们用业务数据做了一个50万节点、200万条边的测试:


# 监控显存占用(RTX 3090 24G)
nvidia-smi --query-gpu=memory.used --format=csv -l 1

# GCN训练时显存峰值:11.2GB(全图前向+反向)
# GAT训练时显存峰值:17.8GB(注意力矩阵额外开销)
# GraphSAGE(batch_size=1024, 采样10邻居):2.3GB(小批量采样)

结论:节点数超过10万,GCN和GAT基本把显存吃光;GraphSAGE是唯一能在大图上跑的选择。

避坑指南:我们在实践中踩到的坑

这块是纯经验输出,每一个都是真金白银换来的:

坑1:特征不归一化,模型直接不收敛

Cora原始特征还是0/1的词袋,问题不明显。换了业务数据后,商品价格从9.9到99999,跨度巨大,GCN第一轮Loss就是NaN。

解法:训练前做Z-Score归一化,而不是MinMax。稀疏特征用MinMax会放大离群值的影响。


# 正确的特征标准化(按列)
data.x = (data.x - data.x.mean(dim=0)) / data.x.std(dim=0)
data.x[torch.isnan(data.x)] = 0.0  # 标准差的列全为0时替换为0

坑2:GCN层数堆太多,效果反而暴跌

把GCN从2层加到5层,Cora测试准确率从81.4%暴跌到71.2%。原因是“过平滑”效应——节点特征经过多轮消息传递后趋向一致,区分度消失。


# 过平滑量化验证:计算相邻层节点嵌入的余弦相似度
python -c "
import torch
from torch_geometric.datasets import Planetoid
dataset = Planetoid(root='./data/Cora', name='Cora')
data = dataset[0]
x = data.x.clone()
# 手动迭代归一化邻接矩阵,模拟GCN传播
adj = torch.eye(data.num_nodes)
edge_index = data.edge_index
for _ in range(10):
    x = torch.matmul(adj, x)
    sim = torch.cosine_similarity(x[:100], x[:100].roll(1, dims=0), dim=1).mean()
    print(f'第{_}层相似度: {sim:.4f}')
"
# 输出: 第0层相似度: 0.12 → 第5层相似度: 0.87 → 第10层相似度: 0.99

解法:GCN不要超过3层,需要更大感受野的时候改用GraphSAGE的采样机制,或者上JK-Net(跳跃连接)。

坑3:训练Mask的坑——不同划分方式,结果天差地别

Cora有“公开划分”和“随机划分”两种。随机划分100次取平均,准确率波动在±2%以内;公开划分只跑一次,运气好85%,运气差79%。很多论文只报最好成绩,是因为选了“最优的随机种子”。

解法:自己对比模型时,固定随机种子,用同样的Mask。


# 固定随机种子,保证可复现
import random
import numpy as np
import torch

def set_seed(seed=42):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed_all(seed)
    torch.backends.cudnn.deterministic = True

set_seed(42)

坑4:GraphSAGE采样时,单机多进程会死锁

用PyG的NeighborSampler做小批量训练时,配上PyTorch的DataLoader多进程num_workers>0,经常卡死。原因是对NeighborSampler做了深拷贝,进程间无法共享。

解法:把num_workers设为0,或者用pyg.link_split里的NeighborLoader替代。


# 错误的做法(会卡死)
from torch_geometric.loader import NeighborSampler
loader = NeighborSampler(edge_index, sizes=[10, 5], batch_size=1024, num_workers=4)

# 正确的做法
from torch_geometric.loader import NeighborLoader
loader = NeighborLoader(
    data,
    num_neighbors=[10, 5],
    batch_size=1024,
    shuffle=True,
    num_workers=4,  # 多进程不卡死
)

什么时候该用GNN,什么时候不该用?

GNN不是万能药,我们的经验是:

适合用GNN的场景:

  • 数据有明显的图结构(社交网络、分子结构、引用网络、物流路径);
  • 节点之间存在同质性(朋友之间喜好相似);
  • 利用局部邻域信息要优于全局信息。

不适合用GNN的场景:

  • 图结构稀疏,平均邻居数小于2,消息传递学不到多少信息;
  • 特征本身已经很强,加图结构收益不大(可以用消融实验验证);
  • 图规模超大且无法采样时,部署成本会很高。

我们最终业务模型选择了GraphSAGE,原因是线上商品图有500万节点、4000万条边,只有它能做到1小时训练结束,线上推理单条18ms。精度比GAT差1%,换来的是能在8G显存的推理机上跑起来,这个取舍是值得的。

GNN这条路,我们才走了第一步。消息传递机制、采样策略、动态图更新,还有一堆可以写的内容。但凡是能建模成图的数据,GNN就值得一试