电商推荐系统实战:如何用知识图谱预训练提升商品排序效果(附代码)
电商推荐系统实战:如何用知识图谱预训练提升商品排序效果(附代码)
最近和几个做电商推荐的朋友聊天,大家普遍有个感觉:传统的协同过滤和深度模型好像越来越“卷”不动了。用户行为稀疏、新商品冷启动、长尾效应这些问题,单靠用户-商品交互数据去硬啃,天花板越来越明显。我们团队去年开始尝试把商品背后的知识体系——也就是知识图谱——引入到排序模型里,不是简单地把属性当特征喂进去,而是先对图谱本身做一次大规模的预训练,让模型先“理解”商品之间的复杂关系。折腾了大半年,线上A/B测试的点击率和GMV提升都挺可观。今天我就把这套从图谱构建、预训练到模型融合的实战经验,结合具体的代码,拆开揉碎了讲给你听。
1. 从零构建电商商品知识图谱:不只是属性关联
很多人一提到商品知识图谱,第一反应就是把商品的类目、品牌、材质这些属性连起来,形成一个“属性网络”。这当然没错,但如果我们只做到这一步,图谱的价值可能只发挥了30%。一个真正有“理解”能力的商品图谱,必须深度融合用户行为语义和商品内在逻辑。
1.1 定义图谱的核心关系与实体
实体不只有“商品”本身。在我们的实践中,定义了四类核心实体:
- 商品(Item):图谱的中心节点,每个SKU对应一个实体。
- 概念(Concept):包括类目、品牌、标签、风格关键词等。例如,“连衣裙”是一个类目概念,“复古风”是一个风格概念。
- 用户行为模式(Behavior Pattern):这不是指单个用户,而是从群体行为中抽象出的模式,如“点击了A又点击B”、“收藏了X后购买了Y”。我们将高频的共现行为序列抽象为模式实体。
- 场景(Session Context):从用户单次会话中提取的关键词、意图标签。例如,一次搜索“送男友生日礼物”的会话,可以抽象出“礼物”、“男性”、“生日”等场景实体。
关系则更为丰富,我们将其分为显式关系和隐式关系两大类:
| 关系类型 | 具体关系 | 说明 | 数据来源 |
|---|---|---|---|
| 显式关系 | belongs_to | 商品属于某个类目/品牌 | 商品后台数据 |
has_tag | 商品带有某个属性标签 | 商品后台数据 | |
also_bought | 同时购买(强关联) | 订单数据 | |
also_viewed | 详情页浏览(替代关联) | 点击流数据 | |
| 隐式关系 | semantic_similar | 语义相似(嵌入模型计算) | 商品标题/描述文本向量 |
complementary | 互补品(如手机与壳) | 跨类目共现分析 | |
sequence_next | 行为流中的前后序 | 用户会话序列挖掘 |
提示:隐式关系的挖掘是提升图谱质量的关键。我们使用Sentence-BERT对商品文本信息编码,通过余弦相似度筛选出
semantic_similar关系;利用关联规则挖掘(如Apriori算法)跨会话发现complementary关系。
1.2 图谱构建的工程化流程与代码示例
构建十亿级(Billion-scale)的商品图谱,手动操作是天方夜谭。我们设计了一套自动化的流水线。核心步骤包括数据清洗、实体链接、关系抽取和图存储。
首先,我们需要将原始数据(商品表、用户行为日志)转化为三元组(头实体,关系,尾实体)的格式。以下是一个简化的数据预处理Python脚本片段:
import pandas as pd
import json
from typing import List, Tuple
def generate_triples_from_behavior(log_df: pd.DataFrame) -> List[Tuple]:
"""
从用户行为日志中生成共现关系三元组。
例如,同一session内先后点击itemA和itemB,生成 (itemA, 'sequence_next', itemB)
"""
triples = []
# 按会话分组
for session_id, group in log_df.groupby('session_id'):
items = group['item_id'].tolist()
# 生成连续点击序列关系
for i in range(len(items) - 1):
triples.append((f"Item:{items[i]}", 'sequence_next', f"Item:{items[i+1]}"))
# 生成会话内共现关系(简化版)
unique_items = list(set(items))
for i in range(len(unique_items)):
for j in range(i+1, len(unique_items)):
triples.append((f"Item:{unique_items[i]}", 'co_occur_in_session', f"Item:{unique_items[j]}"))
return list(set(triples)) # 去重
# 假设log_data是读取的行为日志DataFrame
behavior_triples = generate_triples_from_behavior(log_data)
接下来,需要将三元组存入图数据库以便后续查询和采样。我们选用Neo4j,因为它对关系查询非常友好。以下是使用py2neo驱动入库的示例:
from py2neo import Graph, Node, Relationship
class KnowledgeGraphBuilder:
def __init__(self, uri, user, password):
self.graph = Graph(uri, auth=(user, password))
def create_entity_node(self, entity_type, entity_id, properties=None):
"""创建实体节点"""
label = entity_type.capitalize()
node = Node(label, id=entity_id, **properties)
self.graph.create(node)
return node
def create_relationship(self, head_node, relation_type, tail_node):
"""创建关系边"""
rel = Relationship(head_node, relation_type, tail_node)
self.graph.create(rel)
# 使用示例
builder = KnowledgeGraphBuilder("bolt://localhost:7687", "neo4j", "password")
item_node = builder.create_entity_node('Item', '12345', {'name': '某品牌连衣裙'})
category_node = builder.create_entity_node('Category', 'cat_001', {'name': '连衣裙'})
builder.create_relationship(item_node, 'BELONGS_TO', category_node)
2. 知识图谱预训练:让模型学会“商品语言”
图谱建好了,怎么用?传统做法是直接拿图神经网络(GNN)跑一遍,得到商品嵌入(embedding)然后当成特征输入排序模型。但我们发现,在电商这种动态变化、关系复杂的场景下,直接端到端训练GNN效果不稳定,尤其是对新商品不友好。预训练的思路就是:先让模型在海量、静态的图谱结构数据上“自学”商品和关系的通用表示,形成一个强大的“商品语言模型”,然后再针对具体的排序任务进行微调。
2.1 预训练任务设计:超越TransE和TransR
早期知识图谱表示学习喜欢用TransE、TransR这类基于距离的模型,它们假设关系是头尾实体向量在某个空间的翻译。但在电商图谱里,关系太复杂了。also_bought(也买)和complementary(互补)在语义上很接近,但强度不同;belongs_to(属于)具有明显的层次结构。
我们借鉴了自然语言处理中掩码语言模型(MLM)的思想,为图谱设计了两个预训练任务:
- 关系感知的实体掩码预测:随机掩码掉图谱中某个实体的邻居(或关系),让模型根据上下文(相连的其他实体和关系)预测被掩码的部分。这迫使模型理解局部图结构。
# 伪代码:构造掩码样本 # 给定一个三元组 (h, r, t),随机选择掩码头实体h、关系r或尾实体t # 例如,掩码尾实体t,输入为 (h, r, [MASK]),模型需要从所有实体中预测出t。 - 对比学习任务:这是提升模型区分能力的关键。对于一个三元组
(h, r, t),我们通过以下方式构造负样本:- 实体替换:随机替换头实体或尾实体为图谱中其他实体。
- 关系替换:随机将关系替换为其他关系。
- 硬负样本挖掘:用已训练的简单模型(如TransE)找出那些距离正样本很近,但实际错误的实体对作为负样本,增加难度。 模型的目标是拉近正样本三元组的表示距离,推远负样本的距离。
2.2 预训练模型架构与代码核心
我们采用图注意力网络(GAT)作为编码器主干,因为它能对不同邻居分配不同权重,更适合电商图谱中关系重要性各异的特点。预训练模型的核心是一个多任务学习框架。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch_geometric.nn import GATConv
class GraphPretrainModel(nn.Module):
def __init__(self, num_entities, num_relations, embedding_dim, gnn_layers=2):
super().__init__()
self.entity_embed = nn.Embedding(num_entities, embedding_dim)
self.relation_embed = nn.Embedding(num_relations, embedding_dim)
# GNN编码器
self.gnn_layers = nn.ModuleList()
for _ in range(gnn_layers):
self.gnn_layers.append(GATConv(embedding_dim, embedding_dim))
# 用于掩码预测的MLP头
self.mlm_head = nn.Linear(embedding_dim, embedding_dim)
# 用于对比学习的投影头
self.projection_head = nn.Sequential(
nn.Linear(embedding_dim, embedding_dim),
nn.ReLU(),
nn.Linear(embedding_dim, 128) # 投影到对比学习空间
)
def forward(self, entity_ids, edge_index, edge_type):
# 获取初始嵌入
x = self.entity_embed(entity_ids)
# 通过GNN层传递消息
for conv in self.gnn_layers:
x = conv(x, edge_index)
x = F.relu(x)
return x # 返回所有实体的最终表示
def compute_contrastive_loss(self, anchor_emb, positive_emb, negative_embs, temperature=0.1):
"""计算InfoNCE对比损失"""
# anchor: 正样本头实体表示
# positive: 正样本尾实体表示
# negative_embs: 一批负样本尾实体表示
pos_sim = F.cosine_similarity(anchor_emb, positive_emb, dim=-1) / temperature
neg_sims = torch.stack([F.cosine_similarity(anchor_emb, neg, dim=-1) / temperature for neg in negative_embs])
logits = torch.cat([pos_sim.unsqueeze(1), neg_sims.T], dim=1)
labels = torch.zeros(logits.size(0), dtype=torch.long).to(logits.device) # 正样本在0位置
loss = F.cross_entropy(logits, labels)
return loss
预训练完成后,entity_embed这个嵌入层就包含了经过海量图谱结构知识熏陶的商品通用表示。我们可以把它保存下来,作为下游排序模型的静态先验知识输入。
3. 融合预训练图谱表示与排序模型
预训练得到的商品嵌入是静态的、通用的。而排序任务需要动态的、与当前用户上下文高度相关的表示。如何融合这两者,是决定效果上限的关键。直接拼接(concatenate)是一种方式,但更优雅的方式是让它们进行“对话”。
3.1 设计融合架构:门控与交叉注意力
我们尝试了三种融合方式,最终线上采用的是门控特征融合与交叉注意力机制的结合。
-
门控特征融合:为预训练嵌入(
E_pt)和实时用户-商品交互特征(E_rt)分别学习一个权重。class GatedFusion(nn.Module): def __init__(self, dim): super().__init__() self.gate = nn.Linear(dim * 2, dim) self.transform = nn.Linear(dim * 2, dim) def forward(self, pt_emb, rt_emb): combined = torch.cat([pt_emb, rt_emb], dim=-1) gate_value = torch.sigmoid(self.gate(combined)) fused = gate_value * pt_emb + (1 - gate_value) * rt_emb # 再加一个非线性变换 output = torch.tanh(self.transform(torch.cat([fused, rt_emb], dim=-1))) return output这个门控网络让模型自己决定,对于当前预测,应该更相信通用的知识表示,还是更相信实时交互信号。
-
交叉注意力机制:将用户当前的行为序列(例如最近点击的5个商品)作为Query,将候选商品及其在图谱中的邻居(通过预训练嵌入表示)作为Key和Value,进行注意力计算。这相当于让用户行为去“查询”知识图谱,找出最相关的知识信息来辅助决策。
# 伪代码思路 # user_seq_emb: 用户行为序列嵌入 [seq_len, dim] # candidate_kg_emb: 候选商品及其图谱邻居的嵌入 [num_neighbors+1, dim] # 计算注意力分数,加权聚合图谱信息,得到增强后的候选商品表示。
3.2 下游排序模型与训练技巧
融合后的特征,可以输入任何你喜欢的排序模型,如DeepFM、DIN、BST等。这里以DeepFM为例,展示如何接入:
class DeepFMWithKGPretrain(nn.Module):
def __init__(self, feature_columns, pt_embedding_matrix, hidden_units):
super().__init__()
# 1. 常规特征处理部分
self.dense_feature_layer = ... # 处理数值特征
self.sparse_embedding_layers = ... # 处理类别特征
# 2. 知识图谱预训练嵌入层(冻结或微调)
self.pt_embedding = nn.Embedding.from_pretrained(pt_embedding_matrix, freeze=False) # 我们选择微调
# 3. 融合模块
self.fusion_module = GatedFusion(dim=pt_embedding_matrix.size(1))
# 4. DeepFM组件
self.fm = FMComponent()
self.dnn = DNN(input_dim=fused_dim + other_feature_dim, hidden_units=hidden_units)
def forward(self, inputs):
# 提取常规特征
dense_input, sparse_ids, candidate_item_id = inputs
other_feat = self._process_regular_features(dense_input, sparse_ids)
# 提取知识特征
candidate_pt_emb = self.pt_embedding(candidate_item_id) # [bs, dim]
# 假设我们通过图数据库实时查询到了候选商品的实时交互特征(简化)
candidate_rt_emb = self._get_realtime_embedding(candidate_item_id)
# 融合
fused_kg_emb = self.fusion_module(candidate_pt_emb, candidate_rt_emb)
# 拼接所有特征
all_feat = torch.cat([fused_kg_emb, other_feat], dim=1)
# DeepFM计算
fm_output = self.fm(all_feat)
dnn_output = self.dnn(all_feat)
final_output = torch.sigmoid(fm_output + dnn_output)
return final_output
注意:预训练嵌入层是否微调(
freeze=False)是一个需要AB测试的超参数。对于数据丰富的头部商品,微调可能更好;对于长尾商品,冻结预训练权重可能更能保持其泛化性。
训练时,由于引入了预训练表示,模型收敛速度通常会更快。建议采用渐进式学习率预热(Warmup)和在排序损失中加入正则化项,防止微调过程“遗忘”预训练中学到的宝贵知识。我们使用了以下正则化:
# 在总损失中加入一个约束,让微调后的商品嵌入不要偏离预训练初始值太远
def knowledge_preservation_loss(current_emb, initial_pt_emb, lambda=0.01):
return lambda * F.mse_loss(current_emb, initial_pt_emb.detach())
total_loss = ranking_loss + knowledge_preservation_loss(candidate_pt_emb, initial_embedding)
4. 实战效果分析与部署考量
这套方案在我们两个主要的电商场景(首页信息流和搜索排序)上线后,进行了为期一个月的A/B测试。核心指标对比如下:
| 指标 | 对照组(原模型) | 实验组(图谱预训练模型) | 相对提升 |
|---|---|---|---|
| 点击率(CTR) | 基准 | +3.7% | 显著 |
| 转化率(CVR) | 基准 | +2.1% | 显著 |
| 人均GMV | 基准 | +5.4% | 显著 |
| 长尾商品曝光占比 | 基准 | +15.2% | 非常显著 |
最让我们兴奋的不是整体指标的提升,而是长尾商品曝光和转化的大幅增加。这验证了我们的初衷:知识图谱预训练提供的先验知识,极大地缓解了冷启动和数据稀疏问题。一个没有任何历史行为的新款连衣裙,因为其类目、品牌、风格标签在图谱中与热门商品有紧密连接,也能获得不错的初始排序分数。
在部署层面,这套系统带来了新的挑战和优化点:
- 图谱更新:商品和关系是动态变化的。我们采用了增量更新策略。每天凌晨,用前一天的新数据更新图谱三元组,并启动一个轻量级的“增量预训练”任务,只对新出现的实体和关系进行少量epoch的训练,快速更新嵌入表,而不需要全量重训。
- 服务化:排序模型实时服务需要快速获取候选商品的预训练嵌入。我们将十亿级别的商品嵌入存储在高性能的向量数据库(如Milvus或Faiss)中,通过商品ID实现毫秒级查询。图谱关系查询则依赖Neo4j,对于实时请求,我们只查询一跳内的核心邻居,确保延迟可控。
- 计算成本:预训练阶段确实耗费资源,但这是一次性的、离线的成本。上线后,线上推理的额外开销主要在于一次向量检索和轻量的融合计算,对于大多数公司是可以接受的。我们通过嵌入量化(如FP16)和模型蒸馏,将融合模型的体积压缩了40%,基本消除了对线上延迟的影响。
踩过最大的一个坑,是初期盲目地将所有挖掘出的隐式关系都塞进图谱,导致图谱噪声过大,预训练效果反而下降。后来我们引入了关系置信度分数,并设置了阈值过滤,只保留高置信度的关系,模型的鲁棒性才得到保障。另一个经验是,预训练任务的设计比模型结构更重要,一个好的对比学习负样本构造策略,带来的提升可能比换一个更复杂的GNN编码器还要大。
更多推荐
所有评论(0)