ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

知识图谱+GNN实战:食物推荐系统从建模到评估全流程

知识图谱+GNN实战:食物推荐系统从建模到评估全流程 简介面向推荐系统研究与Python开发者的食物推荐项目资源利用知识图谱结构化实体关系并通过图神经网络学习节点嵌入实现更精准、更具情境感知的个性化饮食推荐。压缩包共26个文件以15个Python脚本、2个Jupyter Notebook和2个Markdown文档为主同时包含模型权重、检查点及索引文件整体约3.76MB目录结构清晰代码按KGCN、GATNE_tf2、MKR_tensorflow2、GCN_tf2等目录组织涵盖数据预处理、知识图谱构建、GNN模型训练、损失优化、评估与可视化完整流程整体从数据到推荐链路清晰。内容既有可运行的模型实现和训练脚本也有Notebook演示、说明文档与模型参数文件便于对照学习图神经网络在推荐系统中的应用细节理解超参数调优、嵌入向量学习与推荐列表生成等关键环节适合作为入门参考或二次开发基础。已有224人学习下载。1. 基于知识图谱和 GNN 的食物推荐这个 Notebook 项目到底在解决什么问题这套《基于知识图谱和 GNN 的食物推荐》Python/Jupyter Notebook 项目解决的是推荐系统里一个很具体又很棘手的问题用户口味是一个说不清的“隐变量”用户之间、用户和食物之间没有天然稠密的共现关系。传统协同过滤在行为数据稀疏时表现很差而单纯做内容匹配又会忽略“辣、清淡、高蛋白、过敏”这类语义联系。项目思路很直接用知识图谱把用户、食物、菜系、食材、标签织成一张有向图再让 GNN 在图结构上做信息聚合把“谁吃过什么、和什么相似、接下来该推什么”变成可计算的向量打分。适合已经在跑推荐、想做冷启动和可解释性改进的工程师也适合把“知识图谱 GNN”这套组合当作入门项目精读的算法学习者。用 Jupyter Notebook 组织全程每一步都能肉眼确认不会让模型变成黑匣子。2. 把“吃”变成一张图食物知识图谱的领域建模与三元组落地2.1 食物推荐里的实体、属性和关系应该怎么定很多图谱项目从一开始就栽在“我要建一个完整本体”上。食物推荐不是一个通用的百科知识库它只需要把和用户决策相关的实体提炼出来。我一般会先把最小实体集控制在 5 类以内用户、食物菜谱或商品、菜系、食材、标签。再高级一点可以加“忌口”和“营养属性”但加实体意味着加边、加数据清洗成本图谱构建这一步会立刻变重。在领域建模上参考的是标准三层做法概念层、关系层、实例层。概念层对应本体建模中的类比如“食物”“食材”“菜系”关系层定义谓词比如“属于”“包含”“产生过敏风险”实例层才是真正灌进图里的每一条数据。这五个词——本体、本体建模、知识图谱、语义层、知识管理——在项目里的分工分别是本体是模型骨架本体建模负责约定类与关系知识图谱是生产出来的数据资产语义层是给上层推荐模型用的统一表示知识管理则负责数据更新和版本演进。建图前先把这张职责表想清楚后面 PyTorch Geometric 喂进模型的特征、边索引才不会乱。实体与关系的最小表如下实体典型属性为什么必须存在Useruser_id、口味偏好、历史行为数推荐的主体冷启动研究的核心Foodfood_id、名称、热量、主要食材被推荐的对象GNN 的打分目标Category中餐、日料、减脂餐提供跨食物的粗粒度语义连接Ingredient辣椒、鸡肉、花生支撑食材级相似度和过敏过滤DietTag辛辣、清淡、高蛋白、低卡直接对应搜索和筛选里的高频词关系上我建议分成两类一类是“交互边”用户点击、评分、下单另一类是“语义边”食物属于某菜系、包含某种食材、带有某个标签。交互边是监督信号语义边是让 GNN 能在冷启动用户之间“搭桥”的关键。缺少语义边知识图谱就退化成一张二分图GNN 的表现并不会比协同过滤好到哪里去。2.2 在 Jupyter Notebook 里用 Python 构建三元组并导入图数据库拿到原始数据后第一步不是急着写 CSV而是先统一 ID。食物推荐项目里最常翻车的点就是 ratings.csv 里的 food_id 和 foods.csv 里的 food_id 根本不是同一个编码体系前者是 int后者是 str或者前者是商品条码后者是菜谱内部编号。我会先做一个映射字典后面所有代码都走同一套映射。# 基础依赖 import pandas as pd from collections import defaultdict ratings pd.read_csv(./data/ratings.csv) foods pd.read_csv(./data/foods.csv) # 统一实体 ID这一步是后面所有图结构的“地基” food_id_map {fid: idx for idx, fid in enumerate(foods[food_id])} user_id_map {uid: idx for idx, uid in enumerate(ratings[user_id].unique())} # 构建语义三元组食物 - 菜系、食物 - 主要食材 triples [] for _, row in foods.iterrows(): triples.append((food, row[food_id], belongs_to, category, row[category])) triples.append((food, row[food_id], contains, ingredient, row[main_ingredient])) # 交互三元组用户 - 评分 - 食物 for _, row in ratings.iterrows(): triples.append((user, user_id_map[row[user_id]], rates, food, food_id_map[row[food_id]], row[rating])) print(f用户实体数{len(user_id_map)}食物实体数{len(food_id_map)}) print(f三元组总数{len(triples)})这里的关键点是把用户和食物的原始 ID 全部替换成从 0 开始的连续整数。GNN 框架不关心你的 food_id 是 UUID 还是自增主键它只关心节点索引。映射字典还要放在 Notebook 里保留下来后续从图数据库导出数据或者做线上召回时都需要反向映射回真实 ID。三元组的顺序我固定为“头实体、关系、尾实体、属性值”这是目前开源图谱库比较通用的约定后面要转成边索引时可以直接复用。图谱本体在建模阶段可以用 py2neo 直接 Nebula 或 Neo4j 导入。常见做法是在 Notebook 里连接本地 Neo4j先建约束再批量 MERGEfrom py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) # 给关键实体建唯一约束重复跑不会产生脏节点 graph.run(CREATE CONSTRAINT food_id IF NOT EXISTS FOR (f:Food) REQUIRE f.id IS UNIQUE) # MERGE 是幂等写入二次运行安全 for _, row in foods.head(500).iterrows(): food_node Node(Food, idrow[food_id], namerow[food_name]) category_node Node(Category, namerow[category]) graph.merge(food_node, Food, id) graph.merge(category_node, Category, name) graph.merge(Relationship(food_node, BELONGS_TO, category_node))这段代码逻辑不复杂但需要留意两点。第一约束必须在大量写入前执行否则重复执行 Notebook 单元格会生成一堆重复节点图谱变成“同义词大杂烩”。第二边用merge而不是createcreate每次运行都会新增一条边跑两轮训练前预处理数据时边数直接翻倍图结构统计完全失真。一般到这一步整个 Notebook 的可视化阶段就结束了后续 GNN 训练并不会直接读 Neo4j而是把三元组导出成边列表再喂给图学习框架直接连图数据库训练反而会因为网络接口解析拖慢迭代速度。2.3 工业场景下的知识图谱设计先窄后宽别被“全量本体”拖死很多从课程项目过来的同学上来就想把食物营养数据库、餐厅位置、天气、时令全部建成本体。我的观点是工业场景下的知识图谱设计第一原则是“为推荐动作服务”第二原则才是“知识完整性”。这个食物推荐项目里冷启动需要的语义无非是三条路径用户历史吃过辣味食物所以推荐同类辣味用户点过低卡餐所以推荐同热量区间的食材组合用户对花生过敏所以把所有含花生的边直接裁剪掉。除此之外的实体都是给图增加噪声还会让 GNN 的邻居采样分布变得极其稀疏。图谱构建的落地步骤我一般控制在三件事定实体和关系、洗 ID、导出三元组文件。本体建模不要超过一天知识管理反而要长期做——定期更新食材库、替换失效的商品 ID、维护被用户举报的错误推荐边。语义层则需要设计成“给 GNN 喂特征时先做映射”也就是把“辣味”映射成 0/1 向量维度把热量分桶成低中高三档。完成这一步知识图谱才真正从可视化图库变成推荐模型可消费的语义层。3. 从图谱到 GNN模型选型、图数据组织和一个最小训练循环3.1 为什么推荐场景里 GNN 比纯协同过滤更值得用协同过滤的本质是“共现矩阵 隐向量”用户和物品只在有共同交互时才产生相似性。问题是冷启动用户只有一两次点击或者新食物还没有累积评分这些节点在矩阵里几乎全是零向量模型根本没有弹药可用。知识图谱的意义在于用户即使只有一次点击也能顺着“吃过-属于川菜-川菜包含辣椒”这条路径连接到一个有丰富邻居的食物节点。GNN 做的正是把这种多跳关系压缩成向量表示。和双塔模型对比差异更清楚。双塔模型把用户特征和物品特征分别编码成向量最后做内积虽然也能加入知识图谱特征但两个塔之间没有图结构上的交互。GNN 在图上的每一层卷积本质都是让节点去“听”邻居的消息用户节点直接聚合了历史交互食物的嵌入食物节点则聚合了同菜系、同食材的其他食物。这是一种结构上的端到端学习比手工拼特征更平滑。当然这不是说 GNN 万能但要解决“行为少但有关系可挖”的冷启动问题它确实比矩阵分解和双塔更贴合场景。3.2 把三元组装成 PyTorch Geometric 能吃的 Data 对象三元组是给人类看的GNN 只认两样东西节点特征矩阵x和边索引edge_index。第一步要做的就是把上一节导出的三元组映射成由“源节点 ID、目标节点 ID”组成的边列表。由于用户和食物是两种实体ID 从 0 开始可能冲突我会用偏移量把两类节点错开。import torch from torch_geometric.data import Data num_users len(user_id_map) num_foods len(food_id_map) food_offset num_users # 食物节点从 num_users 开始编号 # 读取评分边源是用户索引目标是食物索引 偏移量 rating_pairs ratings[[user_id, food_id]].drop_duplicates().values src torch.tensor([user_id_map[uid] for uid in rating_pairs[:, 0]], dtypetorch.long) dst torch.tensor([food_offset food_id_map[fid] for fid in rating_pairs[:, 1]], dtypetorch.long) # 图卷积一般使用无向边所以把正反两个方向都加进去 edge_index torch.stack([torch.cat([src, dst]), torch.cat([dst, src])], dim0) # 节点特征所有实体统一维度用户和食物各走各的特征编码 feature_dim 32 x torch.zeros(num_users num_foods, feature_dim) # 用户特征口味偏好向量 for uid, idx in user_id_map.items(): user_history ratings[ratings[user_id] uid] if len(user_history): x[idx] torch.tensor(history_preference_vector(uid), dtypetorch.float32) # 食物特征类别 one-hot 与热量分桶 for fid, idx in food_id_map.items(): x[food_offset idx] food_feature_vector(fid) data Data(xx, edge_indexedge_index) print(data)这段代码把图结构落到了 PyTorch Geometric 的标准对象里。edge_index的 shape 必须是[2, num_edges]第一行全是源节点第二行全是目标节点类型必须为torch.long。这是 PyTorch Geometric 里最容易被忽略的细节Python 默认的 int 是 64 位如果直接从列表转 tensor 时没指定 dtype后面图卷积内部索引时会报类型错误。节点特征矩阵的行号必须和边索引里的节点编号严格对应也就是说第 0 行是用户 0 的特征第food_offset行是食物 0 的特征。这里一旦错位模型训练时就是“张冠李戴”而且 loss 还照样下降。3.3 GNN 层怎么选GCN、GraphSAGE、GAT 的取舍GNN 不是只有一种这个食物推荐项目里最常见的选择是 GCN、GraphSAGE 和 GAT。GCN 最简单每一层把邻居特征加权求和并做归一化适合小规模、同质性高的图。问题是 GCN 对邻居的权重是固定的所有节点都使用同样的传播规则当图的度分布差异大时热门食物会变成超级节点把推荐结果全部吸过去。GraphSAGE 是我在行为数据稀疏时更常选的结构。它对每个节点采样固定数量的邻居然后做 mean、max 或 LSTM 聚合训练时不需要暴露整张图这在 Notebook 里跑大图时非常友好。GAT 引入了注意力权重能学习“哪些邻居更该被参考”比如同样是食物邻居用户历史点过的比纯语义相似的更有说服力。GAT 的问题是计算量更大在小数据集上容易过拟合需要更强的 dropout 和 weight decay。在这个项目的典型数据规模下我的顺序是先跑 GCN 作为基线如果验证集 HitK 不理想再换 GraphSAGE最后才考虑 GAT。永远不要在第一个版本就堆最复杂模型。GNN 的层数也值得克制食品图谱的语义路径一般 2 到 3 跳就能覆盖“用户-食物-食材-其他食物”这条完整推导链层数再深就开始过平滑效果反而下降。3.4 边预测训练循环的最小写法推荐任务在 GNN 里的标准表达式是“边预测”给定用户节点和候选食物节点预测它们之间存在正向交互的概率。模型本身输出每个节点的嵌入再用用户嵌入和食物嵌入做点积得到打分。import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import GCNConv class GNNRec(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim): super().__init__() self.conv1 GCNConv(in_dim, hidden_dim) self.conv2 GCNConv(hidden_dim, out_dim) self.dropout nn.Dropout(0.3) def forward(self, x, edge_index): x self.conv1(x, edge_index).relu() x self.dropout(x) return self.conv2(x, edge_index) model GNNRec(feature_dim, 64, 32) optimizer torch.optim.Adam(model.parameters(), lr0.01) for epoch in range(50): model.train() emb model(data.x, data.edge_index) # 正样本边用户-食物 pos_score (emb[src] * emb[dst]).sum(-1) # 负样本边随机替换食物构成不存在的交互 neg_score (emb[neg_src] * emb[neg_dst]).sum(-1) logits torch.cat([pos_score, neg_score]) labels torch.cat([torch.ones_like(pos_score), torch.zeros_like(neg_score)]) loss F.binary_cross_entropy_with_logits(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 10 0: print(fepoch {epoch:02d} loss{loss.item():.4f})这里的关键设计是打分函数。点积是内积相似度只能捕捉线性关系换成余弦相似度会稳定数值但梯度在某些区域容易饱和换成 MLP 打分理论上更强但参数多在小图上容易过拟合。我一般先在点积上跑通全流程确认指标能复现后再做替换。负样本的构造决定了模型学的边界在哪里如果负样本全是随机食物模型只会学会“见过的不讨厌没见过的都讨厌”没有难度区分收敛出来的嵌入区分度也差。后续章节会专门讲负采样细节。4. 在 Jupyter Notebook 里跑通端到端流程文件组织、负采样与参数设置4.1 单文件 Notebook 的组织方式每个单元格只干一件事下载下来的项目至少包含一个主 Notebook 或一套拆分的 Notebook 文件。我自己的复现习惯是强制把 Notebook 改造成八个连续单元格配置项、数据读取、三元组构建、图对象构建、数据划分、负采样、模型训练、离线评估。不要把数据清洗和模型训练塞进同一个长单元格哪怕代码能跑后面调参时你要反复重跑整个单元格光是中间特征打印就会刷掉你的耐心。配置项单元格是我最看重的。所有路径、偏移量、学习率、层数、负采样比例全部放在一个字典里后面的单元格只读取配置CFG { rating_path: ./data/ratings.csv, food_path: ./data/foods.csv, embedding_dim: 64, hidden_dim: 64, lr: 0.01, weight_decay: 5e-4, dropout: 0.3, gnn_layers: 2, neg_ratio: 1, epochs: 50, random_seed: 42, }这样做的收益很大每次想调参数不需要翻代码找硬编码只需要在第一个单元格改数字然后从训练单元格往下重跑。Jupyter Notebook 的另一个特点是变量会保留在内存里如果你从头到尾重跑上一轮训练出来的模型和边索引会被这次新的覆盖容易造成“我改的是参数 A但效果变差是因为模型还在用上一个版本的边索引”的幻觉。配置字典把所有状态摊开了排错时先打印CFG确认当前环境。4.2 负采样逻辑一个最容易忽略正确性的环节边预测任务需要一个明确的监督信号正边是用户真正评分过或点击过的食物负边是用户从未接触过的食物。但“从未接触过”不等于“用户不喜欢”这里存在观测偏差但作为训练信号只能退而求其次。负采样的质量直接决定 GNN 嵌入的判别力。常见做法是按一定比例随机生成负边边数通常是正边数的 1 倍到 2 倍。我这里给出一段容易复现的负采样代码import random random.seed(CFG[random_seed]) def generate_negative_edges(src, dst, num_users, num_foods, food_offset, ratio1.0): positive_set set(zip(src.tolist(), dst.tolist())) expected int(len(src) * ratio) neg_src, neg_dst [], [] while len(neg_src) expected: u random.randrange(num_users) f food_offset random.randrange(num_foods) if (u, f) not in positive_set: neg_src.append(u) neg_dst.append(f) return torch.tensor(neg_src), torch.tensor(neg_dst) neg_src, neg_dst generate_negative_edges(src, dst, num_users, num_foods, food_offset)两个参数需要特别说明。第一个是ratio负样本太少模型倾向把所有边都预测为正负样本太多模型则倾向于拒绝任何推荐1 到 1.5 之间是经验安全区。第二个是“去重”代码里用set保证负样本不会和正样本重叠如果随机生成到已经存在交互的边却不剔除模型会收到互相矛盾的监督信号表现为 loss 震荡不收敛。更细的做法是“结构化负采样”优先选择和正样本同类目但不同实例的食物作为负样本让模型区分“相似的不好”和“不同的不好”。这种做法会让训练难度上升但离线指标往往更真实。在项目初期我建议先用随机负采样跑通指标稳定后再切换。4.3 一套能上手的超参数基线这套食物推荐项目我实际复现过多次第一版参数不要追求花哨。嵌入维度 32 到 64 足够表达食物图谱中的实体语义维度再高在小数据上只会过拟合。GNN 层数锁定为 2第一层聚合“直接邻居”第二层聚合“邻居的邻居”已经可以覆盖用户到食物的两跳语义路径。学习率是另一个关键点GNN 在边预测任务上使用 0.01 的 Adam 学习率是稳妥起点。如果 loss 在 20 个 epoch 后还在剧烈波动优先把学习率降到 0.005而不是增加训练轮数。weight_decay 设定为 5e-4 可以抑制高维特征过拟合dropout 在 0.2 到 0.4 之间调整。训练轮数 50 到 80 轮足够更长的训练不会显著提升 HitK反而可能过平滑。数据划分是隐藏参数。不要用随机划分三元组食物推荐是时间序列行为用随机划分等于让模型从未来数据里学到用户偏好离线指标虚高。正确做法是把每个用户的交互按时间戳排序取最后一个交互做测试集前 80% 做训练中间一部分做验证。这个细节比模型结构对结果的影响更大。5. 避坑自查食物推荐 GNN 常见的五个翻车现场5.1 实体 ID 对不上图谱白建模型白训现象三元组构建的时候统计数量都正常训练跑起来也没有报错但最终评估 HitK 接近 0甚至和随机推荐差不多。检查节点嵌入也没看出规律用户向量和所有食物向量的点积得分分布非常均匀。原因我排查时碰到的真实原因是ratings.csv里的food_id是字符串而foods.csv里的food_id是整数两者即使数值一样在 Python 字典里也是完全不同的键。于是用户到食物的边绝大多数指向了一个不存在的食物节点或者指向了一个 ID 映射错误的节点。解决在建三元组之前强制做一次 ID 交叉校验打印不在food_id_map里的评分记录数量并统一转成字符串再建立映射missing set(ratings[food_id].astype(str)) - set(foods[food_id].astype(str)) print(f缺失食物 ID 数量{len(missing)}) foods[food_id] foods[food_id].astype(str) ratings[food_id] ratings[food_id].astype(str)5.2 测试集划分不当造成的评估泄漏指标虚高一倍现象模型训练完后Hit10 高得离谱比如 0.9 以上。看起来效果很好但上线后实际推荐点击率远达不到这个水平。原因这是数据划分错误。如果直接把所有交互边混合后随机切出训练边和测试边GNN 在消息传递时会让测试用户看到训练阶段出现的测试边邻居信息等价于测试阶段“提前偷看了答案”。在时间序列推荐里尤其明显随机划分让模型学到了用户未来的行为。解决按用户切分时间每个用户最早的行为进训练集最近一条交互进测试集构建edge_index时用 mask 控制哪些边参与消息传递test_mask torch.zeros(data.edge_index.size(1), dtypetorch.bool) # 这里根据时间戳阈值给测试边打标 valid_mask ~test_mask训练时只在valid_mask的边上计算消息测试时再使用全部图结构。评估指标会立刻掉到正常范围但那个数字才是真实水平。5.3 GNN 层数加深效果反而全崩过平滑现象模型从两层 GCN 换成四层、六层后loss 下降挺快但 HitK 一路下滑。你以为模型还没收敛把训练轮数加倍结果更糟。原因GCN 的每一层都在做拉普拉斯平滑多层叠加后所有节点嵌入会趋于一致尤其食物图谱里度分布不均匀热门食物节点会把周围节点的特征全部“吸平”。这不是过拟合是图卷积的过平滑现象。很多公开代码为了演示“深模型能力”硬堆层数实际项目反而吃亏。解决第一选择是把层数压回 2 到 3 层第二选择是改用 GraphSAGE它通过采样邻居和聚合函数能在一定程度上缓解过平滑第三选择是加跳跃连接把第一层输出和最后一层输出拼起来h1 self.conv1(x, edge_index).relu() h2 self.conv2(h1, edge_index).relu() return torch.cat([h1, h2], dim-1)5.4 安装 PyTorch Geometric 时内核崩溃版本错配是主因现象在 Jupyter Notebook 里执行import torch_geometric单元格直接卡死或者内核直接重启没有任何报错。命令行下进入 Python 交互环境可能没问题一旦进 Notebook 就崩溃。原因PyTorch Geometric 对 PyTorch 版本有严格绑定底层依赖 torch-scatter、torch-sparse 这些 C 扩展如果扩展版本和 torch 版本不一致解释器会在动态加载时直接退出常见的做法是与系统架构和 CUDA 版本相关。解决按 PyTorch 安装时对应的 CUDA 版本从官方对应 whl 地址安装配套的扩展包而不是直接pip install torch-geometric一把梭。安装完成后进 Notebook 前先在终端跑python -c import torch_geometric确认无报错再启动 Jupyter。这个习惯能帮你区分“环境问题”和“代码问题”。5.5 每次重启 Notebook 都要重新构建图状态没保存现象你调完训练参数关掉电脑第二天重新打开 Notebook所有变量都没了。重跑全流程又是十几分钟而且因为上次随机种子没固定生成的图和上一次不同模型效果对不上。原因图数据、三元组、映射字典都只存在内存里没有序列化。Notebook 的交互式环境让人误以为“运行过就有了”但重启后一切归零。解决在图构建完成后立即保存训练完模型也保存给调试留一个“后悔药”torch.save(data, ./artifacts/graph_data.pt) torch.save(model.state_dict(), ./artifacts/gnnrec.pt) indices {user_id_map: user_id_map, food_id_map: food_id_map} torch.save(indices, ./artifacts/id_maps.pt)之后每次调参直接从保存的图对象加载只重跑训练部分迭代速度会提高很多。6. 离线评估和上线前验证用 HitK、NDCG 给推荐把关6.1 用测试集复现打分HitK 的实现HSE 模型训练完之后不要只看 loss。推荐任务的离线指标必须回到排序质量上。最基本的是 HitK对每个测试用户取他真正有交互的食物作为正例把模型对所有食物的打分从高到低排序如果正例排在前 K 位记一次命中最终命中数除以用户总数。代码实现很直接def hit_at_k(model, data, test_edges, k10): model.eval() with torch.no_grad(): emb model(data.x, data.edge_index) hits 0 for uid, pos_food_idx in test_edges: user_emb emb[uid] # 用点积对全部食物打分 scores (user_emb * emb[food_offset:food_offset num_foods]).sum(-1) top_k scores.argsort(descendingTrue)[:k] if pos_food_idx in top_k.tolist(): hits 1 return hits / len(test_edges)这版代码复用了训练时的模型输出不再做二次前向所以要注意model.eval()和torch.no_grad()缺一不可。预测阶段出来的嵌入应该是全图的最终表示不需要 dropout也不需要计算梯度。HitK 只看“在不在榜单里”不关心正例排在榜单第几位所以还要配一个 NDCG 来惩罚“排得太后”的情况。NDCG 对每个命中的位置施加对数折扣排名越靠后收益越低。如果 HitK 高但 NDCG 低说明推荐结果里也混进了大量不相关食物用户的真实点击率依然不会好看。6.2 从向量到推荐理由给结果补上“为什么”图模型比纯召回模型多出的一个优势是可以从图谱的路径里找回推荐理由。用户被推荐了一道川菜通常是因为他最近点过火锅而火锅和川菜共享“辣味”这个语义标签。做法是把 GNN 之前的邻接路径提取出来按推荐得分排序后保留前三跳路径def explain(user_id, food_id, k3): paths [] for neighbor in direct_neighbors(user_id): if food_id in reverse_semantic_neighbors(neighbor): paths.append(neighbor) return paths[:k]这种做法不算严格意义上的 GNN 可解释性它只是把图谱里的路径和预测结果对齐但已经足够让运营人员在审核推荐时理解为什么给一位最近常点轻食的用户推了鸡胸肉沙拉而不是突然推一份毛血旺。食物推荐的特殊性在于推荐结果直接关系到用户健康体验一个热量超标或包含过敏原的推荐即使点击率高也会带来差评。我最后会把知识图谱里的“过敏原”“热量”属性作为硬过滤条件放在 GNN 排序之后预测分数再高也不能越过这条红线。这套项目跑通之后我最大的教训是知识图谱和 GNN 都不是银弹它们能解决稀疏交互下的语义关联但数据质量和数据划分决定模型真实上限。每次调参前先把 ID 映射、时间戳划分、负采样逻辑检查一遍能省掉大量“模型玄学”时间。落实到食物推荐场景建模的终点不应该是“预测得准”而是“推荐出来敢给用户吃”。希望这些实操细节能帮你少踩几个坑顺利把你的第一套知识图谱加 GNN 食物推荐模型跑通并落到真实应用里。本文还有配套的精品资源点击获取
返回列表