简介:面向推荐系统研究与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 喂进模型的特征、边索引才不会乱。
实体与关系的最小表如下:
| 实体 | 典型属性 | 为什么必须存在 |
|---|---|---|
| User | user_id、口味偏好、历史行为数 | 推荐的主体,冷启动研究的核心 |
| Food | food_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,先建约束再批量 MERGE:
from 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", id=row["food_id"], name=row["food_name"]) category_node = Node("Category", name=row["category"]) graph.merge(food_node, "Food", "id") graph.merge(category_node, "Category", "name") graph.merge(Relationship(food_node, "BELONGS_TO", category_node))这段代码逻辑不复杂,但需要留意两点。第一,约束必须在大量写入前执行,否则重复执行 Notebook 单元格会生成一堆重复节点,图谱变成“同义词大杂烩”。第二,边用merge而不是create,create每次运行都会新增一条边,跑两轮训练前预处理数据时边数直接翻倍,图结构统计完全失真。一般到这一步,整个 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]], dtype=torch.long) dst = torch.tensor([food_offset + food_id_map[fid] for fid in rating_pairs[:, 1]], dtype=torch.long) # 图卷积一般使用无向边,所以把正反两个方向都加进去 edge_index = torch.stack([torch.cat([src, dst]), torch.cat([dst, src])], dim=0) # 节点特征:所有实体统一维度,用户和食物各走各的特征编码 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), dtype=torch.float32) # 食物特征:类别 one-hot 与热量分桶 for fid, idx in food_id_map.items(): x[food_offset + idx] = food_feature_vector(fid) data = Data(x=x, edge_index=edge_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 作为基线,如果验证集 Hit@K 不理想,再换 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(), lr=0.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(f"epoch {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, ratio=1.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 轮足够,更长的训练不会显著提升 Hit@K,反而可能过平滑。
数据划分是隐藏参数。不要用随机划分三元组!食物推荐是时间序列行为,用随机划分等于让模型从未来数据里学到用户偏好,离线指标虚高。正确做法是把每个用户的交互按时间戳排序,取最后一个交互做测试集,前 80% 做训练,中间一部分做验证。这个细节比模型结构对结果的影响更大。
5. 避坑自查:食物推荐 GNN 常见的五个翻车现场
5.1 实体 ID 对不上:图谱白建,模型白训
现象:三元组构建的时候统计数量都正常,训练跑起来也没有报错,但最终评估 Hit@K 接近 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 测试集划分不当造成的评估泄漏:指标虚高一倍
现象:模型训练完后,Hit@10 高得离谱,比如 0.9 以上。看起来效果很好,但上线后实际推荐点击率远达不到这个水平。
原因:这是数据划分错误。如果直接把所有交互边混合后随机切出训练边和测试边,GNN 在消息传递时会让测试用户看到训练阶段出现的测试边邻居信息,等价于测试阶段“提前偷看了答案”。在时间序列推荐里尤其明显,随机划分让模型学到了用户未来的行为。
解决:按用户切分时间,每个用户最早的行为进训练集,最近一条交互进测试集;构建edge_index时用 mask 控制哪些边参与消息传递:
test_mask = torch.zeros(data.edge_index.size(1), dtype=torch.bool) # 这里根据时间戳阈值给测试边打标 valid_mask = ~test_mask训练时只在valid_mask的边上计算消息,测试时再使用全部图结构。评估指标会立刻掉到正常范围,但那个数字才是真实水平。
5.3 GNN 层数加深,效果反而全崩:过平滑
现象:模型从两层 GCN 换成四层、六层后,loss 下降挺快,但 Hit@K 一路下滑。你以为模型还没收敛,把训练轮数加倍,结果更糟。
原因: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. 离线评估和上线前验证:用 Hit@K、NDCG 给推荐把关
6.1 用测试集复现打分:Hit@K 的实现
HSE 模型训练完之后,不要只看 loss。推荐任务的离线指标必须回到排序质量上。最基本的是 Hit@K:对每个测试用户,取他真正有交互的食物作为正例,把模型对所有食物的打分从高到低排序,如果正例排在前 K 位,记一次命中,最终命中数除以用户总数。代码实现很直接:
def hit_at_k(model, data, test_edges, k=10): 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(descending=True)[:k] if pos_food_idx in top_k.tolist(): hits += 1 return hits / len(test_edges)这版代码复用了训练时的模型输出,不再做二次前向,所以要注意model.eval()和torch.no_grad()缺一不可。预测阶段出来的嵌入应该是全图的最终表示,不需要 dropout,也不需要计算梯度。Hit@K 只看“在不在榜单里”,不关心正例排在榜单第几位,所以还要配一个 NDCG 来惩罚“排得太后”的情况。NDCG 对每个命中的位置施加对数折扣,排名越靠后收益越低。如果 Hit@K 高但 NDCG 低,说明推荐结果里也混进了大量不相关食物,用户的真实点击率依然不会好看。
6.2 从向量到推荐理由:给结果补上“为什么”
图模型比纯召回模型多出的一个优势,是可以从图谱的路径里找回推荐理由。用户被推荐了一道川菜,通常是因为他最近点过火锅,而火锅和川菜共享“辣味”这个语义标签。做法是把 GNN 之前的邻接路径提取出来,按推荐得分排序后保留前三跳路径:
def explain(user_id, food_id, k=3): 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 食物推荐模型跑通并落到真实应用里。
本文还有配套的精品资源,点击获取