先说个背景:我们团队内部有个跨境场景的商品推荐项目,日常就叫它 AE 项目,下面我一直用 AE 指代。最近一次大版本迭代,我把推荐召回层从“ItemCF + 物品向量”的组合方案,整体换成了一层基于图卷积网络(GCN)的端到端模型。这个决定不是拍脑袋,而是我把传统协同过滤、矩阵分解、随机游走图嵌入都试了一圈之后,被数据逼着走过来的。
这篇文章不打算复述 GCN 的论文公式,也不会给你堆一堆“在学术数据集上效果很好”的漂亮话。我想完整记录从行为日志建图、邻接矩阵归一化、邻居采样训练,到线上向量检索和 AB Test 的完整过程,重点写那些 Paper 里不会告诉你的工程细节,以及我踩过的几个比较大的坑。如果你正在调研图卷积网络做电商商品推荐,想知道落地到底难不难、收益到底怎么样,这篇应该能给你一份比较接地气的参考。
1. 为什么放着协同过滤不用,非要选图卷积网络
1.1 传统协同过滤的隐性天花板
做推荐的人对 ItemCF 和 UserCF 都不陌生。ItemCF 的思路很直接:先算商品两两之间的相似度,然后根据用户历史点击过的商品找相似商品做推荐。这个方案实现简单、解释性强,但它有一个硬伤——相似度矩阵只能刻画一阶共现关系,用户点击了 A 和 B,B 和 C 也经常一起被点击,但 A 和 C 的关系需要靠“传递”才能发现,ItemCF 对这种多跳关系基本无能为力。
后来大家普遍转向矩阵分解(MF)。MF 把用户和商品各自压成一个隐向量,用内积拟合交互概率。这个思路比 ItemCF 强在能捕捉一些潜在因子,可它本质上还是线性模型,而且对稀疏交互矩阵非常敏感。在 AE 这种跨境场景里,大部分用户可能只有寥寥几次点击,矩阵分解训练出来的向量质量很难保证,长尾商品的向量基本就是噪声。
我做了一组很简单的离线对比实验:在同样一份 30 天行为数据上,ItemCF 的 Recall@20 大约是 0.08 出头,MF 能跑到 0.11 左右,看起来进步不小,但天花板很快就摸到了。问题在于,这两种方法都在强行用“表格思维”理解推荐,而用户和商品的交互关系,本质上是一张图,不是一张表。
1.2 商品交互的本质是一张图
把用户和商品当成两类节点,用户点击、收藏、购买过某个商品就形成一条边,整个推荐场景立刻变成一张标准的二部图。在这个视角下:共同点击过同一商品的用户之间有二阶路径,相似商品之间有“用户搭桥”的二阶/三阶路径,新用户只要有一条交互边,就能通过图结构找到一批潜在兴趣商品。
这也是我最终选择图卷积网络(GCN)的核心原因——GCN 的原生活动场景就是图,它的消息传递机制天然支持多跳关系挖掘。打个比方,ItemCF 只告诉你“和你直接接触的邻居在买什么”,GCN 会不断聚合“邻居的邻居”的口碑,把整个社区的兴趣信号慢慢传到目标节点上。
当然,深层的生物学价值先不谈,仅从推荐系统的角度看,GCN 能同时做三件事:第一,把用户和商品的属性特征自然接入模型;第二,通过邻域聚合把协同信号和内容信号统一到一个向量空间;第三,基于局部子图做归纳式推理,新节点也能通过邻居快速得到合理的 embedding。这三件事,传统协同过滤分开做很别扭,GCN 做起来是“顺路”的。
1.3 从随机游走到图卷积,到底区别在哪
可能有人会问:同样是为了学习图上的节点表示,用 DeepWalk、Node2vec 这些随机游走方法不行吗?我也试过。随机游走方法确实能捕捉到图结构信息,但它们有两个问题在电商场景里很明显。
一是随机游走只利用图的拓扑信息,商品的价格、类目、标题语义这些“节点属性”很难塞进模型。二是游走序列本质上还是离散的上下文,需要用 Word2Vec 那套 CBOW/Skip-Gram 训练,整个过程绕了一圈,本质上没有把“图卷积”的平滑归纳能力发挥出来。GCN 直接把节点特征和邻域聚合放进一个前向传播里,从训练到推理都是端到端的,模型结构本身就在做特征交互,而不是先生成路径再学 embedding。
用测试数据说话:同样的数据准备,Node2vec 的 Recall@20 能到 0.10 左右,而后面我做的两层 GCN 跑到了 0.15 上下。这就是为什么我没有在随机游走这条线上继续深挖。
2. 从原始行为日志到图数据,这一步决定模型上限
算法工程师圈子里有句话叫“垃圾进,垃圾出”。GCN 模型再强,图建得烂,训练出来也是一堆意义不明的向量。很多同学复现论文的时候直接把公开数据集里的 user-item-rating 拿过来当图,但线上生产环境根本没有这么干净的数据。我把建图流程拆成四步:行为筛选与边权重定义、时间窗口切分、特征构造、稀疏邻接矩阵生成。
2.1 行为定义与边权重设计
AE 场景里的用户行为类型很多,有点击、收藏、加购、下单、支付、搜索曝光等。如果把这些行为全部无差别地当成边,模型会被曝光这类弱信号淹没。我的做法是先做行为分级,给每类行为一个合理权重,再加一个“行为置信度”衰减因子。
| 行为类型 | 权重 | 说明 |
|---|---|---|
| 点击 | 1.0 | 弱信号,量大,权重最低 |
| 收藏 | 3.0 | 表达明确兴趣,可信度高 |
| 加购 | 5.0 | 强购买意图 |
| 下单支付 | 10.0 | 最强信号,但注意退单/退款噪声 |
另一个容易被忽略的细节是:同一天内同一用户反复点击同一商品,不应该叠加权重。连续 30 分钟内的多次相同点击只保留最大权重的一次,否则模型会被“机械式点击”带偏。这里有一个挺深的教训:一开始我直接对原始行为做聚合,结果热门商品的邻居数量膨胀得吓人,导致热门 embedding 严重趋同,对个性化推荐几乎是灾难。
2.2 时间窗口切分与图存储
建图时我按“过去 14 天行为”为训练窗口,“第 15 到 21 天”为验证窗口,“第 22 到 28 天”为测试窗口做严格切分。注意,训练图的边只允许来自训练窗口内的行为,绝对不能把测试期的交互混进邻接矩阵,否则就是时间泄漏,离线指标虚高,线上必崩。
图数据存储方面,很多人第一反应是用图数据库。我的实际经验是:对于千万级商品、亿级用户的二部图,图数据库的查询性能和服务化能力都撑不住大规模训练时的频繁采样,而且引入额外组件只会增加链路复杂度。最终我选择直接生成边列表,用稀疏矩阵 COO 格式存储,训练时按需采样子图。
核心 pipeline 大致是:
- 清洗行为日志,做去重和时长过滤;
- 映射用户 ID 和商品 ID 为连续整数编号;
- 按行为权重生成带权边列表;
- 按时间窗口切分训练/验证/测试;
- 统计节点度,过滤掉度异常高的点和完全孤立的点;
- 输出稀疏邻接矩阵和节点特征表。
2.3 节点特征的构造细节
GCN 如果不带节点特征,退化成纯拓扑模型,效果大概率跑不过调优后的 ItemCF,所以特征工程非常关键。
商品侧特征我主要用了四类:类目序列编码、价格档位、标题文本向量、统计类特征(近 30 天点击量、转化率分桶)。用户侧特征包括:历史行为序列向量、活跃度分桶、消费力分段、主要关注类目分布。
构造好原始特征后有两步处理必须做。第一步是连续特征归一化,我统一用 z-score,异常值先截断到上下 3 个标准差。第二步是类别特征映射为 embedding,维度统一设成 16 或 32,不要直接 one-hot,否则节点特征矩阵会厚得离谱。最终把多类特征 concat 成统一维度的初始节点向量,GCN 的第一层输入就是它。
这里有个经验值:初始节点向量维度不宜太高,128 到 256 之间足够。维度太高,图卷积聚合后反而容易过拟合,训练速度也会明显变慢。
3. GCN 消息传播的直觉和一份可以抄的轻量实现
3.1 消息传递机制的直觉
GCN 的数学形式很多文章讲过,我想先讲清楚它的直觉。每一层图卷积做的事情可以概括为三步:
- 每个节点把自己当前的 embedding 复制一份,准备发给邻居;
- 节点收集所有邻居发来的消息,做一个加权求和;
- 把汇总后的消息和自身信息融合,经过激活函数和线性变换,得到本层新 embedding。
这个过程很像现实里的“邻居口碑传递”:你评估一家餐厅好不好吃,不会只看它自己怎么说,还要听几个朋友推荐,朋友的推荐里还包含着他们从别的朋友那里听到的信息。堆叠两层图卷积,就相当于你可以听到“朋友的朋友”的评价,信息传播的半径正好覆盖二度邻域。
3.2 为什么要做对称归一化
图卷积的经典公式是:
H^(l+1) = σ( D^{-1/2} (A + I) D^{-1/2} H^(l) W^(l) )
很多人看这个公式觉得就是“乘一个矩阵”,其实里面的门道很多。A + I 是把自环加进去,让节点在聚合时保留自身信息;D^{-1/2} 是度矩阵的负平方根,用来做对称归一化。这个归一化的作用是平衡节点度差异:一个拥有 1 万个邻居的商品,如果不对消息做衰减,它的 embedding 数值会被邻居消息冲到极大,训练就会不稳定。
对称归一化和普通 D^{-1} 的区别在于更好的对称性,聚合后的特征分布不会偏向源节点或目标节点。在 AE 数据上,我用 D^{-1} 试过,训练 loss 波动很大;换成 D^{-1/2} 之后十分钟内就收敛得比较平稳。
3.3 一个可以快速跑起来的 GCN 核心实现
我直接给出核心模型代码,这个版本我已经在项目里验证过,重量很轻,适合二次开发:
import torch import torch.nn as nn import torch.nn.functional as F class TwoLayerGCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout=0.1): super().__init__() self.gc1 = GCNLayer(in_dim, hidden_dim) self.gc2 = GCNLayer(hidden_dim, out_dim) self.dropout = nn.Dropout(dropout) def forward(self, x, adj_norm): h = self.gc1(x, adj_norm) h = F.relu(h) h = self.dropout(h) h = self.gc2(h, adj_norm) return h class GCNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.w = nn.Linear(in_dim, out_dim, bias=False) def forward(self, x, adj_norm): # adj_norm 是预先算好的对称归一化稀疏邻接矩阵 message = torch.sparse.mm(adj_norm, x) out = self.w(message) return out注意这里我把对称归一化矩阵提前算好,而不是在每轮 forward 里反复计算,能省下大量训练时间。构建邻接矩阵时,我直接用 scipy 生成 COO 格式,再转成 PyTorch 的 sparse tensor:
import scipy.sparse as sp import torch def build_adj_norm(edge_index, num_nodes): row, col = edge_index adj = sp.coo_matrix( (torch.ones(len(row)), (row.numpy(), col.numpy())), shape=(num_nodes, num_nodes) ) deg = np.array(adj.sum(axis=1)).flatten() d_inv_sqrt = np.power(deg + 1e-6, -0.5) d_inv_sqrt = sp.diags(d_inv_sqrt) adj_norm = d_inv_sqrt @ adj @ d_inv_sqrt return torch.sparse_coo_tensor( torch.tensor(np.array([adj_norm.row, adj_norm.col])), torch.tensor(adj_norm.data, dtype=torch.float32), adj_norm.shape )3.4 过平滑问题:为什么推荐场景通常只用两层
随着图卷积层数增加,节点的 embedding 经过反复聚合,会逐渐收敛到彼此相似的状态,最后所有节点变成一坨不可区分的向量,这是图卷积里著名的“过平滑”问题。我在实验里对比了 2 层、3 层和 4 层的效果,结论很明显:3 层还可以,4 层离线指标明显下滑,长尾商品的召回能力几乎崩溃。
所以最终使用的就是两层 GCN,感受野正好覆盖二度邻居。这一点在商品推荐里也说得通:用户对一个商品的兴趣,受“我买过的东西的相似品”和“和我相似的人买的东西”影响最大,三跳之外的信息基本就是噪声了。
有人可能会说,既然普通 GCN 只有两层,那它和 NGCF、LightGCN 这些变体比有什么优势?我的看法是:如果你的项目刚起步,经典两层 GCN 足够拿到基线收益,复杂的变体后续再根据不足逐步引入,不要一上来就上重型模型。
4. 训练工程里的硬骨头:邻居采样、负样本与时序泄漏
4.1 整图训练不现实,邻居采样怎么设计
AE 的图规模是千万级商品、亿级用户。整张图直接塞进显存这种操作想都不要想,必须用 GraphSAGE 式的邻居采样方案。核心做法是:在每个 batch 里,先随机选一批目标节点,然后递归采样它们的邻居,每层固定采样数量。比如两层 GCN,第一层每个目标节点采样 20 个邻居,第二层再对每个一阶邻居采样 10 个邻居,这样 batch 的实际节点规模是可控的。
这个方案的核心代码逻辑大概是这样:
def sample_neighbors(seed_nodes, adj_indptr, adj_indices, num_neighbors): sampled = [] for node in seed_nodes: neighbors = adj_indices[adj_indptr[node]:adj_indptr[node + 1]] if len(neighbors) > num_neighbors: neighbors = np.random.choice(neighbors, num_neighbors, replace=False) elif len(neighbors) < num_neighbors and len(neighbors) > 0: neighbors = np.random.choice(neighbors, num_neighbors, replace=True) sampled.extend(neighbors) return np.unique(sampled).tolist()邻居采样有一个容易忽略的点:训练时固定每次采样的随机种子没有意义,因为采样的目的是增加子图多样性,而不是可复现性。我不固定种子,但会把“训练图”和“验证图”分开,验证阶段要固定采样种子,保证验证集评估稳定。
4.2 负样本策略:随机、曝光胞内、还是热门打压
图卷积训练一般用 BPR 损失或者带负样本的 Softmax 交叉熵。负样本选得不好,模型学出来的 embedding 会严重偏移。我对比了三种负样本策略:
| 策略 | 做法 | 特点 |
|---|---|---|
| 全局随机负采样 | 从全量商品中随机抽 N 个 | 简单,但容易被热门商品主导 |
| 曝光未点击负采样 | 从用户曝光过但没点的商品里采 | 更贴近真实“不喜欢”,难度适中 |
| 热门打压负采样 | 提高热门商品被选为负样本的概率 | 抑制头部商品过拟合,长尾更好 |
最后线上采用的是“曝光未点击负样本为主,随机负样本为辅,热门商品做 mini-batch 内负样本”的混合策略。曝光未点击负样本能告诉模型“见过但不感兴趣”这个信号,比纯随机负样本信息量大得多。
4.3 时间泄漏和标签泄漏是我踩过最大的坑
建图的时候我特意做了时间窗口切分,但训练代码里还是差点漏了两个地方。
第一个坑是邻居采样阶段没有检查采样范围。有一次我把验证窗口的商品也放进采样列表里,离线指标立刻暴涨了 4 个点,我当时还挺高兴,后来发现是泄漏了,等于考试偷看了答案。这个问题排查了整整一个下午,最后定位到采样器用了全量索引,没有按时间窗口过滤。修复后指标回到正常值,虚高的 4 个点当场蒸发。
第二个坑是标签泄漏。特征里有一项是“用户最近交互过的商品向量”,构造的时候如果不加时间约束,很容易把用户下一个时刻要购买的商品提前变成特征,模型学到的是“看到答案再选答案”,线上必然失效。教训只有一句话:所有特征和边相关的数据,都必须带着时间戳,实时构造时永远只取 t 时刻之前的信息。
5. 从训练好的图卷积模型到线上召回服务
5.1 离线产出 Embedding,线上只做 ANN 检索
GCN 训练完成后,最直接的上线方式是把所有商品节点跑一遍前向传播,导出商品 embedding 表,然后灌入向量检索库供线上召回使用。用户侧则不需要实时跑全图,可以离线算好一批活跃用户的 embedding,配合实时的轻量增量来更新。
召回流程大致是:
- 离线定时任务每 6 小时重算商品 embedding;
- 全量商品 embedding 写入检索库索引;
- 线上请求到达时,先拿到用户 embedding;
- 在向量检索库里做 TopK ANN 召回;
- 召回结果进入粗排/精排链路。
5.2 实时用户 Embedding 怎么算
最朴素的做法是:用户每次请求都重新跑一遍 GCN 前向传播。但线上 QPS 一高,这个做法根本扛不住,全图 forward 一次要好几百毫秒,用户链路哪能等这么久。
我的处理方案是“局部增量聚合”:用户最近产生点击/加购行为的 K 个商品,这些商品在离线已经算好了 embedding,把它们的 embedding 按行为权重做加权平均,再拼上用户的静态特征向量,经过一层轻量映射后作为用户实时 embedding。这一步本质上是 GCN 聚合思想的极简版,实验下来效果和完整局部子图推理非常接近,但耗时只有 1 到 2 毫秒。
5.3 服务层设计与性能压测
线上服务我拆成了两个模块:一个是 embedding 管理服务,负责定期刷新索引;一个是召回服务,负责实时请求路由。压测时重点关注两个指标:P99 延迟和召回新鲜度。
实测数据供参考:单机 16 核 CPU、纯内存检索,召回 Top 50 的 P99 延迟在 8ms 左右,QPS 支撑到 800 没什么压力。这里的关键优化是两层缓存:第一层是用户 5 分钟内的请求结果缓存,第二层是热门商品 embedding 的本地缓存。加了这两层之后,回源比例降到 20% 左右,服务稳得很。
6. 离线评估与 AB Test,数据不会说谎
6.1 评估指标:别只看一个 Recall
离线评估我用三件套:Recall@K、NDCG@K、HitRate@K。Recall 看的是召回覆盖能力,NDCG 看的是排序质量,HitRate 对冷门商品更敏感。K 我取了 20 和 50 两个档位。
数据切分严格执行时间顺序,我上面已经提过,这里再强调一次:绝对不能用随机切分,否则模型“看到未来”的风险极大。AE 这种跨境场景还有时区问题,行为日志统一按用户本地时间戳对齐,避免跨天统计出现偏差。
6.2 与基线的对比结果
这里放一组我在相同数据窗口下跑出来的离线结果,数据已经脱敏,趋势是真实的:
| 方法 | Recall@20 | NDCG@20 | HitRate@20 |
|---|---|---|---|
| ItemCF | 0.0821 | 0.0712 | 0.0693 |
| 矩阵分解(BPR-MF) | 0.1174 | 0.0958 | 0.0877 |
| 随机游走图嵌入(DeepWalk) | 0.1036 | 0.0891 | 0.0822 |
| 两层 GCN(本文方案) | 0.1482 | 0.1217 | 0.1134 |
| 四层 GCN | 0.1396 | 0.1105 | 0.1012 |
两层 GCN 相比 BPR-MF,Recall@20 提升约 26%,这个提升主要来自长尾商品和多跳协同信号。四层 GCN 反而掉点,也印证了前面说的过平滑问题。
6.3 AB Test 的真实收益
离线指标过了不代表线上就稳。线上 AB Test 我跑了 14 天,实验组和对照组各覆盖 20% 流量,从结果看,实验组在成交转化率上相对提升 6.3%,人均浏览商品数提升 12.1%,整体 GMV 提升 4.7 个百分点左右。最让我意外的是人均浏览商品数提升明显,说明 GCN 的召回链路的“探索性”确实比旧方案强,用户更容易看到以前不容易被发现的商品。
当然,AB Test 期间也有翻车风险,比如模型灰度上线前 3 天,新用户转化率一度下降。后来排查发现是新用户几乎没有交互历史,图卷积聚不到有效邻居。这直接催生了第 7 章的两个补救动作。
7. 冷启动、热门偏差和其他实战补救
7.1 新商品冷启动:给图卷积加辅助邻居
新商品没有交互边,意味着没有邻居消息,GCN 算出来的 embedding 基本等于初始特征向量经过一层线性变换,效果很弱。这个问题在电商场景里比学术界数据集严重得多。
我的补救方案有两个。第一个是给商品增加内容相似邻居:如果两个商品同属一个三级类目、价格带相近、标题向量余弦相似度超过阈值,就在图里加一条“内容相似边”。这条边不是用户行为产生的,但对冷启动商品至关重要,相当于告诉 GCN“这个新商品和那些热卖商品长得很像”。第二个是加了一个“全局匿名节点”,让所有商品都和它连一条低权重边,相当于给所有商品一个兜底邻居,防止完全孤立节点在归一化时除零。
7.2 热门商品 embedding 趋同的问题
训练中后期我观察到一个现象:热门商品的 embedding 越来越像,互相之间的余弦相似度甚至能到 0.9 以上。原因是热门商品的邻居数量巨大,聚合时被海量用户信号“平均”成了大众脸。
解决办法是在采样时加一个度惩罚:邻居节点的采样概率除以 sqrt(degree),降低高热度节点被反复选中的概率。另外还可以把边权重做幂律压缩,对边权重取 log 或开根号,减弱超级头部商品的绝对权重。这两个操作配合之后,热门 embedding 的差异化明显改善,长尾商品在召回结果里的占比也从 10% 提升到了 18% 左右。
7.3 全量重训与增量更新策略
图数据每天都在增长,全量重训不可能一天几次。我最后定的是每天凌晨全量重训一次模型和全量 embedding,训练任务控制在 2 小时内完成,白天用增量日志更新用户的实时聚合向量。
增量更新的实现比较简单:新行为日志进入消息队列后,实时计算增量用户的“局部聚合向量”更新缓存,商品侧 embedding 保持到当天晚上。如果遇到大促这类短期强信号场景,可以临时加一次中午的全量重训,代价是几台训练机多跑一小时,收益却是实打实的召回质量提升。
8. 结尾:说几句掏心窝的经验
项目做下来,我的最大体会是:GCN 做推荐,模型本身不是最大的瓶颈,数据链路才是。图建得干净、边权重设计合理、采样不泄漏,效果自然就出来了;这三件事只要有一件做糊,换再强的图模型都救不回来。
如果你们团队刚开始调研这个方向,我建议不要一上来就复现各种高阶图神经网络变体。先把“用户-商品二部图 + 两层 GCN + 曝光未点击负样本”这条最小闭环跑通,拿到一份可以解释的线上收益,再逐步往多行为建模、时间衰减、知识图谱辅助邻居这些方向迭代。图卷积带给我的收获,不只是那几点 GMV 提升,更是一种思考方式:把复杂问题拆成一张干净的图,让结构自己说话,很多答案会自然浮现。