一次推荐系统的“翻车”经历
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(图采样聚合网络)。
| 维度 | GCN | GAT | GraphSAGE |
|---|---|---|---|
| 核心机制 | 归一化邻接矩阵 + 加权求和 | 注意力机制动态分配邻居权重 | 固定采样邻居 + 多种聚合函数 |
| 训练方式 | 全图批量训练 | 全图批量训练 | 小批量采样训练 |
| 显存占用 | 高(全图载入) | 高(全图载入 + 注意力计算) | 低(每次只算采样子图) |
| 适用图规模 | <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)。
跑完上面的代码,我们的实际输出结果如下:
| 模型 | 参数量 | 测试准确率 | 验证准确率 | 训练耗时 | 每轮耗时 |
|---|---|---|---|---|---|
| GCN | 23,400 | 81.4% | 79.6% | 38.2s | 0.19s |
| GAT | 48,300 | 82.8% | 81.0% | 105.6s | 0.53s |
| GraphSAGE | 22,900 | 80.2% | 78.4% | 41.5s | 0.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就值得一试。