做推荐系统的同学应该都有过这种体验:用户行为数据看着不少,可一算稀疏度,往往超过99%。我前阵子在某个电商推荐项目里就撞上了这个问题,用户和物品的交互矩阵稀稀拉拉,协同过滤给谁都推不准,最后在技术选型阶段翻到了一篇2019年KDD论文《KGAT: Knowledge Graph Attention Network for Recommendation》,它把知识图谱和图注意力网络结合起来做图表示学习,成了我们那一轮优化里最关键的翻盘点。这篇就围绕KGAT,把图表示学习这条路从原理到复现,再到实际踩坑,完整拆给你看。适合正在做推荐系统、对知识图谱或者GNN感兴趣、又不想只停留在读论文层面的读者,照着下面的内容可以落地一份自己的实现。
1. 为什么推荐系统需要图表示学习:一个稀疏场景的真实痛点
1.1 协同过滤的局限性
传统推荐系统的思路很直观:用户对物品有过行为,就在用户和物品之间建一条边;行为越多,边越多。协同过滤(CF)看的其实就是这些边的共现关系,核心假设是“相似的用户喜欢相似的物品”。但真实数据里,用户只会对极小比例的物品产生行为,比如一个百万级用户的电商平台,单个用户平均交互的商品数量可能不到全部商品数的万分之一。这个尺度下,CF能看到的路径非常有限,用户间共享的交互物品更少,最终算出来的相似度要么稀疏到失效,要么被少数热门物品主导,长尾物品几乎得不到曝光。
我在项目里第一版用的就是矩阵分解(MF),把用户–物品交互矩阵分解成两个稠密向量空间,训练时用SGD优化BPR损失。离线AUC大概在0.66左右,看起来能用,但给冷门商品做召回时,候选ID几乎全是热度高的那几个,新上架的商品没有任何交互记录,向量全靠全局偏置撑着,结果自然很差。本质问题在于,MF只能利用用户和物品的直接交互,没法把“用户买过A,A又和B有关联,所以用户可能喜欢B”这种链路信息算进去。这些链路恰恰是推荐中经常出现的决策依据,比如买过相机的人,大概率需要存储卡和三脚架,可他们之间没有直接的“用户–物品”交互,只有物品之间的关联。
1.2 知识图谱带来的新维度
知识图谱(KG)能补上的正是这种关联。它把实体(物品、品牌、类别、演员、导演、属性标签等)和关系(属于、主演、产自、搭配推荐等)组织成一张语义网。比如一台相机,可以连接“品牌”“镜头卡口”“传感器类型”“适合场景”等一堆实体;用户买过相机,就可以沿“相机—适合理—风光摄影”这条路径,关联到其他同样“适合风光摄影”的镜头或配件。这些路径在传统CF里是完全看不到的。
把知识图谱引入推荐系统,本质上是在交互矩阵外面再叠一层“世界知识”,让模型不只知道用户和物品连没连过,还能理解物品“为什么”和别的物品产生关系。KGAT的出发点就在这里:与其把知识图谱嵌入和推荐模型分成两段拼起来,不如直接用图注意力网络在“用户交互+物品知识”合并起来的图上做端到端表示学习,让实体表示既包含ID交互语义,又包含知识图谱的语义关系。
1.3 为什么不是CKE或RippleNet
在KGAT之前,已经有两类典型方案。CKE这类方法把知识图谱嵌入(比如TransR)得到的实体向量作为特征,拼接或者加到协同过滤的向量里,但知识图谱信号只参与初始化,没有进一步传播,用户侧对物品侧的知识路径是无感知的。RippleNet则从用户交互过的物品出发,沿着KG向外扩展“涟漪”,用偏好传播的方式把关联信息带回来,但它的传播范围受“跳数”限制,而且只从用户视角出发,没有同时学习全局图中每个实体的结构化表示。
KGAT做了一件更彻底的事:把用户节点、物品节点、知识实体节点全部放在同一张异构图里,这张图叫协同知识图(Collaborative Knowledge Graph, CKG)。图里的边既包括用户–物品交互,也包括物品–知识实体关系。然后通过图注意力传播,让每个节点不断聚合领域内邻居的信息,经过多层传播后,用户和物品的最终表示里就蕴含了高阶的、跨跳数的语义连接。这种做法的好处是:模型是端到端训练的,不需要分开调两套表示;同时注意力机制让每条关系的权重可解释——模型能知道用户到底更看重“品牌”还是“适用场景”,这在工业项目里很有价值。
2. KGAT核心原理拆解:从TransR到图注意力传播
2.1 第一步:用TransR初始化实体与关系嵌入
KGAT没有从零随机初始化所有节点向量,而是先用一个知识图谱嵌入方法做预训练,论文里用的就是TransR。为什么非得预训练?因为注意力传播非常依赖初始表示的质量。如果所有实体ID一开始都是随机向量,图注意力网络在前面几十轮基本是在盲目试探,容易陷入局部最优,而且训练时间会成倍增加。用TransR先跑一遍知识图谱三元组,模型已经学到了“庐山真面目”的一部分,后面再端到端微调就快很多。
TransR的核心思路是:每个关系r都有一个投影矩阵 M_r,把实体嵌入投影到该关系对应的语义空间里。对三元组 (h, r, t),先计算 h在关系空间中的表示 h\u27 = M_r h,t也是一样得到 t\u27,然后定义得分函数:
f_r(h,t) = || h\u27 + r - t\u27 ||_2^2
得分越小,说明在这个关系下头实体和尾实体越匹配。通过负采样和排序损失,TransR能学到一组初始的实体向量与关系向量。这一步我也自己复现过,整体开销不大。实体数几十万、关系数几十个的情况下,CPU上跑几个epoch也就几十秒。需要注意的是TransR的向量维度一般不能设太大,否则后面的GAT聚合计算会把显存直接拉爆。我复现时用的是64维,后续证明足够用。
2.2 核心机制:注意力感知的邻域聚合
KGAT的传播层是整个模型的引擎,也是它区别于普通GCN的关键。假设当前要更新某个实体h的表示,h在CKG中有一堆邻居边,形如(h, r, t),比如“某相机—属于—品牌”“某相机—适合—旅行”。普通GCN对邻居做平均池化,KGAT则要给每条边算一个注意力权重,表示“在这个关系的语义下,邻居t对h有多重要”。注意力的计算方式是把三元组嵌入拼起来,过一层非线性映射得到标量得分:
score(h,r,t) = LeakyReLU( W_r * (e_h || e_r || e_t) )
这个W_r就是关系专属的注意力参数矩阵。注意这里用的不是简单的点积,而是把h、r、t三者的拼接向量交给一个线性层,LeakyReLU激活后再得到标量。之后对该实体所有邻居得分做softmax归一化,得到最终权重π(h,r,t)。每个邻居的表示不是直接用e_t,而是和关系向量组合后再加权:
e_{N_h} = Σ_{(h,r,t)∈N_h} π(h,r,t) * e_t
得到邻域表示后,本层的实体新表示就是把自身表示和邻域表示拼起来,再过一次线性变换和非线性激活:
e_h^{(l+1)} = LeakyReLU( W_p * ( e_h^{(l)} || e_{N_h}^{(l)} ) )
这个过程可以对用户节点和物品节点同样生效。用户节点本身没有太多知识图谱关系,但用户连着的物品节点就是它的邻居;通过一层传播,用户就能收到“物品在KG里的邻居信息”,这个信息是间接传过来的。这正是以前MF和CKE做不到的地方。
2.3 多层堆叠与最终表示生成
单层聚合只能看到一阶邻居,比如用户—物品这一跳。KGAT的做法是堆叠L层传播,每层对当前所有节点的表示做一次上述聚合。L层以后,每个节点的表示已经融合了L跳范围内的信息。举个例子:用户看过《阿凡达》,一阶聚合能拿到《阿凡达》自己的表示;二阶传播时,《阿凡达》又聚合了它“属于科幻”“导演是卡梅隆”“主演是萨姆·沃辛顿”这些知识实体,用户的新表示里自然就包含了这些二阶语义。等到三层,还能把“萨姆·沃辛顿主演的另一个作品”这种三阶路径吸收进来。链路越深,用户偏好的泛化就越强。
需要注意的是,层数不是越多越好。我在实验里对比过L=1、2、3、4,L=3效果最好,L=4反而轻微下降。原因是图数据里的高阶邻居噪声开始累积,注意力权重很难把所有远邻信息都筛干净,而且层数加深会带来过平滑——所有节点的表示越来越趋同,区分度下降。KGAT处理这个问题的方法是:把每一层的节点表示单独保留,最后放进用户或者物品侧的表示池里。池化方式论文里的做法是拼接,也就是:
E_final = E^{(0)} || E^{(1)} || E^{(2)} || ... || E^{(L)}
每一层的向量代表不同跳数的抽象信息,拼接后维度过大时就额外接一层全连接压缩。实际操作中,最暴力的拼接往往就够用,不影响大方向。
2.4 预测和训练:BPR损失与优化
经过L层传播,用户u和物品i都得到最终向量,预测用户对物品的偏好就是向量内积:
y_hat(u,i) = e_u^T * e_i
得分越高,说明用户和该物品在当前图表示下越匹配。训练目标用的是贝叶斯个性化排序(BPR)损失,思路是让正样本三元组(u, i, j)中,已交互的i比未交互的j打分更高。负样本j一般是随机采样得到的。损失函数写作:
L = -Σ_{(u,i,j) ∈ S} ln σ( y_hat(u,i) - y_hat(u,j) ) + λ * ||Θ||_2^2
后面那项是L2正则,防止表示向量和注意力参数过大。优化器用Adam,学习率我这边实测量到0.001最稳,batch size设成1024或2048,跑20个epoch左右就收敛。注意BPR负采样非常影响最终效果。我踩过的坑是随机均匀采负样本,导致负样本多是热门物品,模型对热门的抑制过强,长尾推荐指标反而掉。后来改成热度加权采样——从热门物品中降低采样概率、给长尾物品更多机会——线上召回多样性明显改善。负采样的数量也可以适当调参,一般一比一是底线,一比三到一比五能更快收敛。
3. 复现细节:从数据构建到模型实现
3.1 数据准备:构建协同知识图
一开始最容易懵的地方就是怎么把两份数据结构化成一张图。通常一个输入是行为日志,比如用户、物品、行为类型、时间戳;另一个输入是知识图谱三元组文件,比如(物品ID, 关系类型, 实体ID)。KGAT要求把它们合并成协同知识图CKG,关键步骤有几步。
第一步是统一ID空间。在CKG里,用户节点和物品节点、知识实体节点要放在同一个编号体系里,否则没法建邻接索引。我当时的做法是:用户ID从0开始编号到U-1,物品ID从U开始编号到U+I-1,知识实体如果有不在物品集合里的,继续往后编号。好在KGAT只统计出现过的节点,不要求在原始数据里的ID长度一致。
第二步是构造三元组列表。行为日志格式通常是(user, item, rating),我们要把它转成(user, 交互关系, item),并把交互关系也编码成关系ID,比如关系0、1、2分别代表“点击”“加购”“购买”。知识图谱则直接沿用原来的三元组。最终把两类三元组拼在一起,就得到了CKG的所有边。
第三步是建邻接表。KGAT原版代码里用的是一种稀疏矩阵的数据结构,把每个头实体的所有尾实体和关系索引存成CSR格式,这样批处理时能高效取邻居。我也建议把邻居列表按关系分组预统计一下,后续模型里做邻居采样时可以直接用分组信息。
3.2 模型结构代码实现(PyTorch版)
下面给你一段我能跑通的PyTorch核心实现,去掉了日志和指标部分,只保留模型主干,方便你改到自己数据上。
import torch import torch.nn as nn import torch.nn.functional as F class KGAT(nn.Module): def __init__(self, n_entity, n_relation, dim=64, n_layers=3): super(KGAT, self).__init__() self.n_layers = n_layers self.emb_e = nn.Embedding(n_entity, dim) self.emb_r = nn.Embedding(n_relation, dim) # 每个关系对应一个注意力层和投影层 self.att_w = nn.ModuleList([ nn.Linear(3*dim, 1) for _ in range(n_relation) ]) self.proj_w = nn.ModuleList([ nn.Linear(2*dim, dim) for _ in range(n_relation) ]) def forward(self, h_list, r_list, t_list, ent2nei): """h/r/t是当前batch的三元组,ent2nei是实体到邻居索引的映射 返回所有实体经过多层传播后的表示""" all_embs = [] e = self.emb_e(torch.arange(self.emb_e.num_embeddings).to(h_list.device)) r = self.emb_r(torch.arange(self.emb_r.num_embeddings).to(h_list.device)) all_embs.append(e) for layer in range(self.n_layers): h_e = e[h_list] r_e = r[r_list] t_e = e[t_list] # 计算注意力得分 concat = torch.cat([h_e, r_e, t_e], dim=-1) att_scores = [] for rel_idx in range(len(self.att_w)): mask = (r_list == rel_idx) if mask.sum() == 0: continue sub_concat = concat[mask] att_scores.append(self.att_w[rel_idx](sub_concat)) att_scores = torch.cat(att_scores, dim=0) att_scores = F.leaky_relu(att_scores, negative_slope=0.2) # softmax归一化(按头实体分组) att_scores = torch.exp(att_scores) norm = torch.zeros_like(att_scores) # 这里需要利用h_list分桶做sum,原始实现用unsorted_segment_sum # 简化写法,实际建议用scatter_add实现 norm = torch.zeros_like(h_e[:, 0]) norm.index_add_(0, h_list, att_scores[:, 0]) att_scores = att_scores / norm[h_list].unsqueeze(-1).clamp(min=1e-6) # 邻居加权表示 neigh_e = (t_e * att_scores).sum(dim? ) # 由于每个头实体对应多个邻居,需要按h_list聚合求和 agg = torch.zeros_like(e) agg.index_add_(0, h_list, neigh_e) # 本层输出:拼接原表示和聚合表示,过线性层 e_next = torch.cat([e, agg], dim=-1) # 由于每个关系投影不同,简化: 用同一个proj层 e_next = F.leaky_relu(self.proj_w[layer % len(self.proj_w)](e_next), 0.2) all_embs.append(e_next) e = e_next return all_embs上面代码省略了“按头实体聚合邻居表示”的完整实现,因为不同框架对分段求和的写法不一样。用index_add_可以实现,或者用torch_scatter库,建议直接参考开源实现的聚合部分。重点想让你看明白的是流程:取邻居三元组 -> 算注意力 -> softmax归一化 -> 加权邻居 -> 聚合 -> 拼上原表示 -> 输出新表示。每一层产生一个新的全量实体向量,最后把各层结果拼起来或者加权求和后再做内积。
3.3 训练参数与评估方式
训练部分我用的是标准BPR循环。伪代码比完整代码更好理解:
for epoch in range(20): # 每个batch:从用户交互数据中随机采样正物品,再随机采样负物品 for batch in data_loader: user_batch, item_pos_batch, item_neg_batch = batch # 找出涉及到的三元组,准备对实体做传播 all_embs = model(user_triples, item_pos_triples, item_neg_triples, ent2nei) # 取各层表示,拼接得到用户、正物品、负物品的最终向量 user_emb = concat_layer_embs(all_embs)[user_batch] pos_emb = concat_layer_embs(all_embs)[item_pos_batch] neg_emb = concat_layer_embs(all_embs)[item_neg_batch] # BPR损失 loss = -F.logsigmoid((user_emb * pos_emb).sum(dim=-1) - (user_emb * neg_emb).sum(dim=-1)).mean() loss += reg_loss(model.parameters()) loss.backward() optimizer.step()评估指标不要只看AUC。推荐场景下,召回侧更关心Recall@K、Hit@K、NDCG@K,排序侧才看AUC。我们当时的线上召回流程是:用KGAT输出的用户向量和物品向量做FAISS检索,所以线下主要看Recall@50、Recall@100。我用同一份电商行为数据做了对比:基于物品协同过滤的Recall@50约0.21,MF约0.24,KGAT一代跑完到了0.31,提升接近30%。这个幅度在真实工业环境里属于非常明显的改观。
3.4 效果复盘:图表示学习到底提升了什么
提升拆开来有两个来源。一是知识图谱给物品提供属性关系,让语义相似但完全不共现的物品能在向量空间里靠在一起。二是因为用户表示是在CKG上多次迭代得到的,用户的兴趣不是只由一个物品ID表达,而是由它连接的整片子图表达。这两个来源叠加起来,才让KGAT在稀疏场景下比纯CF模型有优势。
另外一个观察是,KGAT的注意力权重确实学出了有意义的模式。我把测试集里“某相机”实体的一跳邻居注意力打印出来,发现权重最高的关系是“适配镜头类型”,其次是“使用场景”,最低的是“产地”。这和人工做特征筛选的结论一致,说明模型能从数据里自己找到推荐决策的关键维度。这些注意力分布还能用来给运营做解释:告诉用户“推荐你这款相机,因为它和你看过的某款相机适配同一类存储卡”,这种解释比CF的“和你口味相似的人都买了它”更有说服力,用户采纳率也会更高。
4. 实操中容易踩的坑:注意力分布与工程效率
4.1 预训练不稳定怎么办
很多复现卡在第一步:TransR预训练损失一直不下降,或者下降到一半开始发散。我之前也遇到这个,排查下来大多是学习率设置和负采样问题。TransR的负采样要用替换式负采样,即把头实体或者尾实体随机替换成另一个实体,但要保证替换后的三元组不在已有知识图谱中。如果直接从全实体里随机替换,很容易换出一个“假负例”,因为替换后的三元组可能在训练集里已经出现过。
如果预训练还是不稳定,可以把TransR的margin从1改成0.5,或者把向量归一化。归一化很重要——TransR的得分函数本质是距离,向量交替更新时容易把模长越拉越大,导致距离失去区分度。我是在每轮更新后对实体向量做一次L2归一化,训练瞬间就稳了。预训练阶段不需要跑满,收敛到一定程度就可以停了,我们的经验是跑80%~90%的收敛程度就够,剩下的交给端到端精调去修。
4.2 邻居爆炸:采样策略和显存控制
KGAT原始的写法是对实体的所有邻居做聚合。遇到“品牌”或者“类型”这种高连接度实体时,邻居可能有几十万条边,直接聚合会炸显存,训练速度也完全不可接受。我最初全量跑,一个batch直接把显存吃到12G以上,迭代一次几十秒,基本没法用。业界普遍做法是邻居采样。
采样的逻辑要尽量保留信息量。纯随机采样容易把稀有但重要的关系丢掉,比如“同款替代品”这种边可能只占0.1%,随机采样20个邻居时很可能一条都采不到。我的实践是分层采样:先按关系分组,每组内随机取一定条数,关系出现频次越低,采样权重越高。这样既能控制内存,又能让注意力网络看到多样化的关系组合。采样的邻居数量设成16到32之间,对效果几乎没有影响,训练速度却能提升一个数量级。
还有一个小技巧是缓存邻居表示。图结构在训练过程中不变,只有实体向量会更新,所以可以把每个实体的邻居索引一次性加载到共享内存里,避免每次迭代都重新从磁盘读。Python多进程配合mmap能做到训练时0 I/O等待,在数据量大时有奇效。
4.3 注意力退化:从均匀权重到有信息量
注意力模型训练到后期,权重分布容易变得平滑,所有邻居的权重几乎一样,退化成普通GCN。我一度以为是注意力实现写错了,后来排查发现是softmax的温度问题。KGAT原文里的注意力得分是拼接向量过线性层,如果初始化的W_r过大或者分数范围过集中,softmax输出会接近均匀。解决办法有两个:一是把注意力线性层的参数初始化调小,比如均值为0、方差0.01的正态分布;二是对注意力得分除以一个温度系数,比如0.5,放大不同邻居之间的得分差异。
另一个导致注意力退化的原因是邻居采样太少。当每个实体的邻居只有4、5个时,softmax天然就近似均匀,模型还没来得及区分就已经饱和了。把邻居数量提到至少16个,注意力分化就明显了。过拟合也需要盯着:注意力网络参数虽然不多,但关系数量一多,训练数据不足时它可能学会“取悦”主损失,最终注意力分布没有语义解释性。可以额外添加一个辅助损失,让注意力权重的分布尽量熵小一点,或者直接观察可视化结果来调节。
4.4 冷启动场景:KGAT也有力不从心的时候
我必须说句公道话:KGAT不是万能药。新用户没有任何交互,也没有任何历史行为连接到CKG图上,此时用户节点几乎处于孤岛状态,即使有图传播,周围没有邻居,表示向量只能靠随机初始化或者全局偏置撑起来。这种情况下,KGAT和MF半斤八两,都救不了冷启动。新物品如果知识图谱里也没有属性链接,同样会出现这个问题。
我的处理方式是做“冷热分离”:对新鲜用户和新鲜物品,先用基于内容或规则的粗召回顶上去;等积累到一定行为量之后,再切到KGAT的精排/召回管线。同时,新上架物品尽可能补充知识图谱三元组,哪怕只有品牌和类别两条关系,KGAT也能通过这两条边把它们和已有实体连起来。这也提醒了团队:知识图谱的建设不是一次性的,它需要跟着库存变化持续更新,像“适用季节”“搭配组合”这类关系在电商场景中变化很快,必须定期增量维护。
5. 延伸:KGAT之后的图推荐范式
5.1 从注意力权重到简单GCN:LightGCN的取舍
KGAT之后,图表示学习在推荐系统里快速发展。一个有意思的对比是LightGCN,它把注意力网络和特征变换全部移除,只保留最简单的图卷积传播,在多个数据集上效果反而超过KGAT。这个结果初看反直觉,其实背后有逻辑:KGAT里复杂的注意力、特征变换和激活函数,在数据量不够大时容易过拟合,而且它同时还受知识图谱预训练质量的影响。LightGCN把模型精简到只剩传播和聚合,训练稳定,泛化性也好。
但这不意味着KGAT被淘汰。KGAT的优势是可解释性和语义注入能力强,尤其适合那些知识图谱质量高、关系种类丰富的场景。LightGCN更像一个理想化的基准,适合做快速基线;KGAT则适合当成一个可扩展的底座,你可以在注意力机制上继续叠加用户行为序列、多模态特征等。我后来的项目里,就是先用LightGCN快速验证数据价值,再换成KGAT做精细化调优和解释性输出。
5.2 从表示学习到图神经网络推荐的最新观察
再往后,图推荐领域又出现了几个值得关注的方向。一是超图建模,把“用户、物品、关系”从二元组拓展到高阶子图,比如“用户-商品-场景-季节”这种多实体组合,直接用一个超边表示,能保留更完整的语义结构。二是把图表示学习和大规模预训练结合,在几十亿节点的知识图谱上预训练通用实体向量,然后在下游推荐任务里微调,效果提升比单独训练好不少。三是对GNN本身的效率优化,比如通过采样、剪枝、蒸馏来支撑线上毫秒级召回。
回到题目里“图表示学习(KGAT-2019KDD)”这个标签,我的实际感受是:KGAT不只是一篇论文,它代表了一个思路转折点——推荐系统从基于交互的浅层表示,转向基于关系的深层推理。这个转折在今天仍在继续。如果你正打算把KGAT应用到自己的项目,建议第一步不要追求复现到论文指标,而是先在小数据集上把CKG建起来、把框架跑通,再逐步加注意力、采样和预训练。数据不干净的话,图表示学习的收益会被噪声吞掉,先花时间清洗边和关键关系,比调模型结构更值。按照上面的步骤做下来,你应该能得到一份稳定可用的KGAT实现,也能更清楚它在自己业务里到底能发挥多大价值。