做推荐系统的人应该都有过这种经历:拿到一个推荐需求,先习惯性地想“上协同过滤还是上深度模型”,但真正面对跨境电商这种海量长尾商品、用户交互极度稀疏的场景时,传统套路往往说翻车就翻车。我这次做的这个项目,标题很简单——“基于图卷积网络(GCN)做AE商品推荐”,看着像套了个热门框架,实际落地过程中牵扯到的东西远比想象中多:图怎么建、边怎么加权、负样本怎么采、邻居怎么聚合、模型怎么训才不崩,每一步都有讲究。这篇文章就把整个项目的来龙去脉、模型设计、训练细节和踩过的坑一次说清楚,给准备在推荐场景里尝试GCN的同学一份可以直接抄作业的参考。
先说结论:GCN这套方案在AE这种跨境场景下,最终离线召回命中率(Recall@20)比基线协同过滤提升了将近11个点,线上点击率涨了5.6%,并且冷启动商品的表现改善特别明显。后面所有内容都围绕这个结果展开。
1. 项目背景与方案选型:为什么在AE商品推荐里选GCN
1.1 AE场景推荐的核心痛点
AE(跨境出口电商平台)的推荐场景和海内电商有个特别大的区别:用户和商品的交互矩阵稀疏得离谱。海外用户群体分散,每个用户购买的商品数量本来就少,加上跨境物流周期长、复购率天然偏低,传统协同过滤拿到的共现矩阵基本是张“筛子”。我统计过项目初期一份一个月的真实行为日志,用户-商品交互矩阵的稀疏度高达99.7%以上,每个用户平均有效交互不到6个商品。这种数据喂给矩阵分解或者神经协同过滤,学习出来的embedding几乎都是欠拟合的,推荐结果偏向热门爆款,长尾商品完全没有出头机会。
另一个痛点是冷启动。AE平台上每天有大量新上架的商品,没有交互记录、没有用户反馈,传统模型拿它们毫无办法。这些新商品只能靠随机或者热门兜底策略曝光,转化率自然惨淡。我当时接到这个项目需求时,业务方明确提出了三个核心诉求:第一,提升长尾商品的曝光和转化;第二,缓解新商品的冷启动问题;第三,在有限算力下做到小时级更新。这三个诉求直接决定了我不能照搬常规的双塔模型或者DeepFM那套做法。
1.2 为什么是GCN而不是普通深度模型
选择GCN之前,我其实先对比过几个方向。第一种是常规的双塔模型,user塔和item塔分别建模,内积算相似度。它的优点是工程上简单成熟,但双塔在特征交叉上是弱的,用户和商品只在最后一层发生一次交互,中间没有任何信息传播,对稀疏交互的挖掘能力很有限。第二种是序列模型,比如用Transformer建模用户行为序列,但AE场景下很多用户行为序列连10个商品都凑不齐,序列模型根本跑不出效果。
GCN的建模逻辑完全不同。它不把用户和商品当成孤立的实体,而是把它们放在一张图里统一表示:用户是一个节点,商品是一个节点,用户购买、点击、收藏过商品就是一条边。这样每个节点的表示学习不再只看自身特征,还能沿着边把邻居节点的信息聚合进来。更关键的是,GCN按多层聚合天然能捕捉高阶关系——比如“A用户买了商品甲,商品甲被B用户收藏过,那么A和B可能喜欢同一类商品”这种二跳、三跳的关系。这种高阶协同信号恰恰是稀疏交互场景下最值钱的信息。
另外还要提一下为什么选GCN而不是GraphSAGE或者GAT。GraphSAGE的采样和聚合思路确实灵活,但实现复杂度更高,当时项目周期紧,我倾向于先用经典GCN把链路跑通。GAT的注意力机制虽然在节点异质性强的图上效果好,但这个项目里的用户和商品已经天然二分,边的含义相对统一,注意力带来的增益有限,反而增加过拟合风险。GCN的两层卷积+平均聚合,简单、高效、可解释,作为第一版落地足够了。
1.3 项目的整体架构
整个项目分成四个模块:数据管道、图构建、模型训练、线上服务。数据管道负责从行为日志里清洗出用户-商品交互记录,生成训练样本;图构建负责把交互记录转成图结构,包括节点特征、邻接矩阵、边权;模型训练用两层GCN学习用户和商品的embedding,用BPR损失优化排序;线上服务则把学好的embedding存到向量检索库里,用近邻检索召回候选商品。架构并不复杂,但每个模块里都有不少决策点,下一节开始逐个拆解。
2. 数据准备与图构建:从用户行为序列到邻接矩阵
2.1 二部图数据结构设计
GCN的第一步不是搭模型,而是构建图。这里我建的是标准的用户-商品二部图:左侧用户节点,右侧商品节点,边只在用户和商品之间,用户和用户之间、商品和商品之间没有直接连边。之所以强调二部图,是因为它能让聚合操作天然交替进行——第一层卷积中用户聚合商品信息,商品聚合用户信息;第二层卷积中用户再次聚合商品,但此时商品已经带上了其他用户的信息,相当于完成了二阶邻居的信息传播。
图数据存储上,我一开始用的NetworkX做原型验证,毕竟接口简单、直观。但等节点数到了百万级、边数超过五千万时,NetworkX直接内存爆炸,单机16G根本扛不住。后来我把图数据转成了CSR格式(Compressed Sparse Row),只存储邻接表的偏移量和索引数组,内存占用直接降了一个数量级。这一步特别重要,很多同学在GCN项目里第一步就倒在数据规模上,不是模型训不动,而是图根本装不进内存。
边权设计上要多说一句。AE场景里的交互行为有点击、收藏、加购、购买四种,不同的行为代表不同的用户意图强度。如果所有边一视同仁,模型就分不清“看过一眼”和“掏钱买了”的区别,学出来的embedding会被点击噪声带着跑。我这边把行为做了加权映射:点击为1,收藏为2,加购为3,购买为5。归一化的时候再按边的权重做对称归一化,相当于在聚合邻居信息时给不同行为不同的发言权。
2.2 节点特征工程
GCN虽然主打结构信息,节点特征也不能是白板。如果纯用one-hot ID,模型只能靠图结构学表示,特征表达能力弱。我这边给商品节点加了三个维度的属性特征:类目ID(脱敏后做embedding)、价格分箱(0-1归一化后的价格档位)、30天销量分箱。给用户节点加了两个维度特征:注册时长分箱、历史购买类目分布向量。
这里有个经验:节点特征尽量控制在低维度,别把文本描述、图片特征这种重型特征一股脑塞进去。GCN每一层卷积都会对特征做一次传播和变换,特征维度过高会显著增加参数量和训练时间,而且稀疏特征在图上扩散后容易放大噪声。我当时商品特征控制在32维,用户特征控制在32维,作为初始节点表示输入第一层卷积。实践下来这个维度在效果和训练速度之间比较平衡。
2.3 训练样本与边权策略
训练样本的构建决定了模型优化的方向。这里用的是“用户-正样本商品-负样本商品”三元组结构:从交互记录里取用户有过正反馈的商品作为正样本,再从用户没有交互过的商品里随机采样一部分作为负样本。
负样本采样有个大坑:如果完全随机采样,采到的基本都是热门商品,模型会倾向于把所有商品都推给所有用户,学不到个性化。但如果只采困难样本(比如用户没买过的同品类高热度商品),训练又会不稳定,loss震荡得厉害。我的方案是“80%随机负样本+20%品类内负样本”。品类内负样本是指和正样本同一二级类目、但用户没有交互过的商品。这个混合策略在离线实验中明显比纯随机负样本表现好,线上点击率也验证了这一点。
另外,边权策略上还做了一个时间衰减处理。三个月前的交互行为和三天前的交互行为,对当前兴趣的指示意义完全不同。我给每条边乘上了一个半衰期为14天的时间衰减系数,近期行为权重更高。这个处理让模型对用户兴趣的时效性更敏感,后面做小时级增量更新时也平滑很多。
3. GCN模型设计与训练细节
3.1 两层GCN的卷积公式与参数选择
模型结构上我采用的是经典的两层GCN,卷积公式就是常用的对称归一化聚合形式。第一步把用户和商品的初始特征拼接成节点特征矩阵,然后做归一化邻接矩阵下的两层卷积,中间接ReLU激活,输出最终的用户embedding和商品embedding。
为什么是两层而不是三层、四层?我做过一组对照实验:一层卷积时,每个节点只能看到直接邻居的信息,实际上就是最普通的协同过滤,效果提升有限;两层卷积能看到二跳邻居,协同信号最丰富;等堆到四层以上,信息经过多轮传播后各节点的表示趋于同质化,离线Recall指标反而掉了。这就是GCN经典的“过平滑”问题。在AE这个稀疏图里,两层是性价比最高的选择。
隐藏层维度我选了64,输出embedding维度选了32。64维的隐藏层足够容纳用户和商品特征交互的复杂度,32维输出为了线上向量检索的效率和存储成本考虑。不要一上来就追求256维甚至512维,稀疏图上高维embedding很容易过拟合,而且向量检索的延迟和内存会翻好几倍。这个维度选择是用小规模验证集跑出来的,不是拍脑袋。
3.2 邻居采样与mini-batch训练
全量图训练在千万级节点上根本行不通。一次全图卷积要对整个邻接矩阵做矩阵乘法,显存和计算量都是灾难。我这里用的是邻居采样的mini-batch训练:每个batch随机采样一批用户节点,对它们做有放回的多跳邻居采样,第一跳邻居数上限设为15,第二跳上限设为8。也就是说,每个用户节点最多聚合15个直接交互商品,每个商品再最多聚合8个交互用户。
这个采样参数也做了实验对比。采样数太少(比如第一跳5),信息不够,效果明显打折;采样数太多(比如第一跳50),训练速度慢一倍,效果提升却不到0.5个点。15和8这个组合在信息覆盖率和训练吞吐量之间卡得刚刚好。实现时用的是PyTorch Geometric的NeighborLoader,不到20行代码就把采样逻辑写完了,省了很多事。
Batch size我设成了1024。这里有个小细节:batch里用户和商品节点不是一比一的关系,一个用户平均带15个商品邻居,所以实际的商品节点数远多于用户节点数。显存分配要留足这个余量,我用的是混合精度训练,batch size可以开得更大,训练速度提升了将近40%。
3.3 损失函数:BPR和负采样策略
模型优化目标我选的是BPR(Bayesian Personalized Ranking)损失。BPR的核心思想很简单:对于同一个用户,正样本商品的得分应该比负样本商品的得分高,而且高出的差值越大越好。具体公式是取正负样本得分差值的sigmoid对数概率,然后用梯度上升最大化它。
BPR比交叉熵更适合推荐排序场景,是因为它不要求模型输出一个绝对的点击概率,只要求正负样本的排序关系成立。这在交互数据稀疏时更稳,因为模型不需要拟合精确的概率值,只需要把相对顺序学对就行。我在项目里没有只用最后一层embedding做内积,而是把用户embedding、商品embedding以及它们的逐元素乘积拼接起来,过一个两层的MLP打分。这个改动让模型能捕捉用户和商品的非线性交互,离线Recall@20又涨了1.5个点左右。
负采样策略前文提过,30%的负样本来自同品类随机采样。这里有个非常重要的细节:负样本中如果混入了用户实际喜欢但没交互过的商品(即假阴性),BPR会错误地拉低这些商品的得分。所以我在负采样时做了一个过滤,把用户最近7天曝光过但没有点击的商品也排除掉,进一步减少假阴性样本的干扰。
3.4 超参数设置与调优心得
学习率我用的Adam优化器,初始学习率0.001,训练过程做了学习率余弦衰减。这个组合在绝大多数图模型任务里都算稳定,不太容易出现loss爆炸。L2正则系数设了1e-5,权重衰减主要加在邻居聚合的线性变换矩阵上,防止embedding过大。
训练轮数上,我这边设置了30个epoch,但实际用early stopping在第22个epoch就停了。判断标准是验证集上的Recall@20连续3个epoch不再提升就停止。不要盲目训满固定的epoch数,稀疏图模型过拟合的速度很快,尤其是当邻居采样引入随机性后,验证集指标会有波动,只有连续不涨才算真正收敛。
还有一个容易忽略的点:embedding的初始化方式。我一开始用PyTorch默认的均匀分布初始化,训练前期loss下降很慢。后来换成了均值为0、标准差为0.1的正态分布初始化,收敛速度明显加快。原理上说,均匀分布的边界太大,初始embedding之间区分度过高,梯度更新初期不稳定;小标准差的正态分布让初始表示更集中,模型可以更平滑地让embedding随图结构移动。
4. 模型评估与上线落地
4.1 离线评估:召回率、命中率、AUC
离线评估我用了三套指标:Recall@K、HitRate@K和AUC。Recall@K衡量的是模型推荐的前K个商品中,有多少是用户真实交互过的商品占用户全部交互商品的比例。HitRate@K更简单粗暴,只要命中了至少一个就算成功。AUC则是从排序角度评估正样本得分是否普遍高于负样本。
我按8:1:1切分了时间序列数据,用前80%时间段的交互训练模型,用接下来的10%做验证,最后10%做测试。为什么按时间切而不是随机切,是因为推荐系统要模拟真实场景中的时间漂移问题——用过去预测未来,随机切分会让模型偷看未来信息,评估结果虚高。
离线实验结果里,两层GCN的Recall@20是0.243,HitRate@20是0.417,AUC是0.87。对照的ItemCF基线三项分别是0.219、0.382、0.81。提升最明显的是冷启动商品——上架7天内没有交互记录的商品,在GCN模型里的曝光机会比基线高出将近三倍。这是因为这些新商品虽然自己没有交互,但它们的类目、价格区间和相似商品形成了图上的结构位置,模型可以通过邻居聚合间接给它们算出一个合理的embedding。
4.2 线上AB实验与效果分析
离线指标再漂亮也得上线验证。我当时设计了一个分流AB实验:10%流量给基线模型(老的双塔),10%流量给GCN新模型,其余80%正常线上模型不动。实验跑了两周,核心观测指标是商品点击率和人均曝光商品数。两周后GCN组点击率相对提升5.6%,人均曝光商品数提升了8.2%。因为曝光更多了,点击率还能保持提升,说明推荐的相关性确实变好了,不是靠刷曝光量撑起来的面子工程。
线上服务架构上,GCN模型生成的用户embedding和商品embedding都导入到向量检索库,线上拿到用户请求后先查出用户embedding,再做近邻检索召回Top200个商品,最后经过一层业务规则的过滤(过滤下架商品、类目黑名单等)推给用户。整个召回链路延迟P99在28毫秒,完全扛得住线上流量。
4.3 冷启动与增量更新机制
冷启动是业务方最关心的点。GCN方案里,新商品上架时虽然没有交互边,但节点特征里有类目和价格信息,模型可以基于这些特征生成初始embedding。再加上商品一旦有了少量曝光和点击,边的信息马上会参与下一轮模型更新。我在图上做了一个小时级增量计算:每隔1小时把新增交互记录追加进图结构,用增量训练的方式微调模型参数。增量训练的batch只采样新增边相关的节点,训练成本只有全量训练的10%,但能让新商品在2小时内获得相对稳定的embedding。
增量更新的工程实现有个坑要注意:旧模型的参数在新增边之后不能全部重新训练,否则耗时太长;但如果完全冻结旧参数只更新新节点embedding,新旧节点表示之间会有尺度偏差。我的折中方案是:冻结前两层卷积的参数,只对新增节点embedding做一定迭代轮数的更新,同时用L2约束让新增节点embedding不要偏离初始特征太远。这个策略上线后,新商品embedding的质量稳定性和收敛速度都表现不错。
5. 踩坑记录与常见问题排查
5.1 过平滑问题导致推荐结果趋同
训练初期我堆了四层GCN,结果发现比两层GCN的Recall还低了2个点。排查后发现是过平滑——多层图卷积让每个节点的embedding不断聚合邻居信息,最终收敛到几乎相同的表示,所有商品对用户来说都长得差不多,推荐结果自然失去个性化。解决方案很简单粗暴:砍掉多余的层数,回到两层结构,同时在每层卷积输出后加一个dropout(rate=0.1)打破信息传播中的对称性。加了dropout之后,过平滑现象在验证集上明显缓解。
5.2 邻居采样导致的训练不稳定
有一次训练loss曲线忽高忽低,怎么也压不下来。检查发现是邻居采样器在batch内采到大量重复节点,导致同一批样本里正负样本分布不均。这通常是因为热门节点(比如爆款商品)的度数太高,随机采样时反复被抽中,它们的embedding更新频率远高于长尾节点,训练过程就被个别热点节点带偏了。解决办法是采样时做了热度分层的截断:热度Top1%的节点,限制它们的被采样次数,每个epoch内最多被采进3000个batch。这样热门节点不会过度主导训练,长尾节点的表示质量明显提升。
5.3 负样本过多导致显存溢出
BPR损失需要对每个正样本配多个负样本,我一开始图省事配了10个负样本,结果batch内的商品节点数量暴增,显存直接爆了。后来做了个减法,把负样本数降到了4个,显存占用降了40%,Recall只掉了0.3个百分点。这也是一个典型的性价比取舍——负样本数量不是越多越好,4到5个对于这个规模的图已经足够。如果你的显存实在紧张,可以试试负样本分桶,把一个大batch拆成多个小batch分别算梯度,效果基本等价。
5.4 线上线下指标不一致的排查
项目最头疼的问题之一是离线AUC高、线上点击率却不涨。排查后发现问题出在特征分布漂移上:离线训练数据用的是历史三个月的交互记录,而线上模型服务时用户反馈的商品流已经变成了新的热门商品,很多商品在离线数据里根本没出现过。特征是类目、价格这种相对稳定的属性,但图结构里的边不断变化,离线模型没见过新边的组合方式,自然推不准。
我把评估方式改成了“时间对齐验证”:训练集使用截止T-7天的数据,验证集使用T-7到T-3天的数据,测试集使用T-3到T天的数据。这样评估结果基本能反映模型在未来一周的真实表现。配合特征监控,每天对比线上实时特征的分布和历史训练特征的分布,发现漂移超阈值马上触发模型重训。
6. 写在最后:几条个人经验
这个项目做完后,我对GCN类模型在推荐场景的适用边界有了更清晰的判断。图模型不是万能的,它的优势建立在交互关系丰富、结构信息有价值的前提下;但如果你的场景用户行为序列密集、特征维度很高,传统深度模型可能反而更合适。AE场景的稀疏性和长尾分布,恰好是GCN发挥的空间。
另外,工程上能省则省、能简则简。很多同学做图模型一上来就上分布式框架、动态图、复杂采样器,最后被工程复杂度拖垮。我这次从NetworkX原型到单机PyTorch Geometric训练,到最终向量检索上线,整个过程没有引入一个重型组件。先把核心链路跑通,再根据瓶颈逐步优化,这种节奏在业务项目中远比追求技术大而全可靠。
最后分享一个小技巧:做GCN推荐项目时,一开始就要设计好可视化验证的工具。我训练过程中定期把商品embedding降到二维做散点图观察,如果发现同品类商品聚成清晰的簇、跨品类商品明显分开,说明模型学到的东西是合理的。如果所有点糊成一团,大概率是过平滑或者特征没有发挥作用,可以及时调整方案,不用等到离线评测结果出来才发现问题。