☰
基于知识图谱与GNN的食物推荐系统构建实践
2026/10/3 3:09:37 网站建设 项目流程

简介:面向食物推荐方向的研究者和深度学习学习者,这份资料基于知识图谱与GNN实现个性化推荐,适合学习图建模、协同过滤之外的知识驱动推荐方法,也可作为课程设计或毕业设计的参考实现。压缩包共26个文件,以15个Python脚本为主体,涵盖数据预处理、图构建、神经网络层、模型训练与评估;另有2个Jupyter Notebook用于流程演示与可视化,2份Markdown笔记补充原理说明,整体仅3.76MB。项目按MKR、GATNE、KGCN等经典模型分目录组织,各目录内包含数据加载、模型定义、网络设计和训练主程序等模块,并附有checkpoint训练产物,可直接参考或复现从知识图谱构建、节点嵌入学习到最终食物推荐的全流程。已有224人学习/下载,对想快速上手GNN推荐、获取可运行代码与工程结构参考的读者来说,是一份轻量实用的资源。目录结构清晰,便于对照学习和二次开发。

1. 为什么食物推荐绕不开知识图谱和GNN:一场关于“懂你口味”的工程改造

食物推荐做到最后,你会发现瓶颈不在模型精度,而在“上下文”。传统协同过滤只知道你和别人口味像,却不知道你“为什么”喜欢这道菜——是因为食材、口味标签、烹饪方式,还是因为这道菜背后的地域文化?基于知识图谱和GNN的食物推荐,正是把“用户-食物”的二元关系升级为一张包含食材、营养、菜系、口味、烹饪方法的语义网络,再用图神经网络把“关系”本身变成模型可学习的特征。这套方案在Python + Jupyter Notebook环境下,用开源工具链就能完整落地,不需要自研图数据库,也不需要分布式计算资源。它适合两类人:一类是推荐系统从业者,想从“打特征”升级到“学结构”;另一类是知识图谱方向的工程师,想找一个非工业场景的、数据量适中且容易可视化的落地案例。这篇文章会把数据怎么清洗、三元组怎么建、GNN模型怎么选、训练时踩过哪些坑,一步步讲清楚。

2. 先建图还是先选模型:知识图谱构建的三种路线与实体对齐

2.1 从结构化数据到三元组:为什么别一上来就写爬虫

做食物知识图谱,最常见的冲动是先去爬美食网站。这个方向我建议你慎重。餐饮类网站的评论、菜谱、图片数据噪声极大,清洗成本远高于建图本身。更务实的路线是:先用手头已有的结构化数据把图画出来——比如开源食谱数据集、食品营养成分表、菜系分类表,这些数据字段清晰,转成三元组只需要做映射,不需要做实体识别。

我的做法是这样:先定义本体(Ontology),再写脚本把表格转成三元组。本体定义决定了图的质量上限。对于食物推荐场景,我一般定义六类实体:用户、菜品、食材、菜系、口味标签、烹饪方法;关系定义九种:用户-评分->菜品、用户-偏好->口味、菜品-包含->食材、菜品-属于->菜系、菜品-具有->口味、菜品-采用->烹饪方法、食材-富含->营养素、菜系-流行于->地区、食材-替代->食材(用于过敏原规避)。这九种关系已经把推荐所需的大部分语义覆盖了。

数据转三元组的代码并不复杂。这里给出一段从CSV构建三元组的Python脚本,关键点在于处理实体ID的稳定映射:

import pandas as pd import hashlib def gen_entity_id(entity_type, name): """生成稳定的实体ID,避免重复建节点""" raw = f"{entity_type}:{name}".encode('utf-8') return hashlib.md5(raw).hexdigest()[:16] def build_triples(dish_df): triples = [] for _, row in dish_df.iterrows(): # 菜品实体 dish_id = gen_entity_id("dish", row['dish_name']) # 菜系关系 cuisine_id = gen_entity_id("cuisine", row['cuisine']) triples.append((dish_id, "belongs_to", cuisine_id)) # 食材关系(注意:一菜多食材,需要拆行) for ingredient in row['ingredients'].split('、'): ing_id = gen_entity_id("ingredient", ingredient.strip()) triples.append((dish_id, "contains", ing_id)) # 口味标签 for taste in row['taste'].split('、'): taste_id = gen_entity_id("taste", taste.strip()) triples.append((dish_id, "has_taste", taste_id)) return triples dish_df = pd.read_csv("dishes.csv", encoding="utf-8") triples = build_triples(dish_df) print(f"生成三元组 {len(triples)} 条")

这段代码有两个设计点值得说明。第一,gen_entity_id用实体类型 + 名称做哈希,而不是直接用名称做ID,这避免了两道同名菜被合并成一个节点的风险;第二,食材和口味字段用split('、')拆行,这保证了“一菜多食材”的关系不会丢失。你在这个基础上扩展关系时,只需要在build_triples里继续追加元组。

2.2 图存储选型:Neo4j还是纯文件,决定你的迭代速度

知识图谱存哪里,是第二个需要决策的点。常见做法是Neo4j搭配py2neo,但如果你只是想快速验证推荐效果,我建议先用NetworkX把图存在内存里,跑通全流程后再迁Neo4j。原因很现实:Neo4j的启动、索引配置、批量导入都有学习成本,而且GNN训练时,数据最终要从图数据库导出为张量,中间多一层IO反而拖慢迭代。

不过,如果你的数据量超过10万节点,或者你需要多人同时查询图结构,Neo4j的优势就出来了——它的Cypher查询语言在做图路径分析(比如“某食材被哪些菜系使用”)时效率极高。我的中间路线是:数据量小用NetworkX沉淀三元组,验证模型有效后再把三元组灌入Neo4j做可视化。

灌入Neo4j的操作,用py2neo的批量事务会比较稳妥:

from py2neo import Graph, Node, Relationship graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def import_triples_to_neo4j(triples, batch_size=500): tx = graph.begin() for i, (head, rel, tail) in enumerate(triples): # 实体类型从ID前缀推断,这里简化为按三元组位置区分 head_node = Node("Entity", name=head) tail_node = Node("Entity", name=tail) rel_obj = Relationship(head_node, rel, tail_node) tx.create(rel_obj) if i % batch_size == 0 and i > 0: tx.commit() tx = graph.begin() tx.commit() import_triples_to_neo4j(triples)

这里需要提醒一个性能问题:把三元组一条条create会非常慢,1万条数据可能就要跑几分钟。工业场景下的知识图谱设计通常会用LOAD CSV + UNWIND批量导入,速度能提升一个数量级。如果你只是本地实验,上面这段代码够用;如果数据量大,建议导出CSV后用Cypher的LOAD CSV语句导入。

3. 把图变成特征:GNN模型选型与邻域聚合的工程实现

3.1 为什么选GraphSAGE而不是GCN:归纳式学习的优势

知识图谱建好后,下一步是把图结构喂给模型。这里有一个容易翻车的点:很多教程一上来就讲GCN,但GCN是直推式(Transductive)学习,它需要整张图参与训练,新用户进来要重新训练。食物推荐场景恰恰是动态的——新用户、新菜品不断加入,如果用GCN,每次增量更新都是一次全局重算,成本不可接受。

GraphSAGE是更合理的起点。它属于归纳式(Inductive)学习,核心思想是“学习如何聚合邻居特征”,而不是“学习每个节点的固定表示”。新节点进来,只要它有一跳邻居和特征,就能得到嵌入向量。这两个模型的对比,我整理了一张参数表:

对比维度GCNGraphSAGE
学习方式直推式(整图训练)归纳式(采样聚合)
新节点泛化需重训可直接推断
邻居利用全部邻居(需规范化邻接矩阵)采样固定数量邻居
显存占用随图规模线性增长由采样数控制
适用场景静态图、小规模动态图、大规模
实现难度低中

在食物推荐这种节点量级(万级到十万级)的场景下,GraphSAGE的采样机制还有一个工程优势:它对邻接矩阵不需要预先计算归一化拉普拉斯算子,数据预处理简单很多。如果你用过DGL或PyG做GCN,应该对那个deg_inv_sqrt的计算和稀疏矩阵乘法有印象——GraphSAGE把这些都简化了。

3.2 用DGL实现GraphSAGE:采样器、特征组装与训练循环

DGL(Deep Graph Library)是PyTorch生态里做图神经网络最顺手的库,它的dgl.dataloading.NeighborSampler专门为GraphSAGE设计。实现一个最小训练管线分成三步。

第一步,把NetworkX图转成DGL图,并组装节点特征。节点特征怎么来?文本属性(菜名、食材名)可以用预训练的词向量,但更简单的做法是用节点类型做one-hot编码后拼接一个可学习的嵌入层。对于没有外部语料的项目,我建议先用随机初始化的可学习嵌入——模型会在训练过程中自动学习节点的语义表示:

import dgl import torch import torch.nn as nn import torch.nn.functional as F from dgl.nn import SAGEConv # nx_graph 是前面用NetworkX建好的图 dgl_graph = dgl.from_networkx(nx_graph) # 为每个节点初始化特征:类型嵌入 + 随机向量 num_nodes = dgl_graph.num_nodes() node_type = dgl_graph.ndata['type'] # 假设存了节点类型ID type_emb = nn.Embedding(num_types, 16) node_feat = torch.cat([type_emb(node_type), torch.randn(num_nodes, 32)], dim=1) dgl_graph.ndata['feat'] = node_feat

第二步,定义模型结构。GraphSAGE的每一层做的事情是:把当前节点的特征和邻居聚合后的特征拼接,再过一层线性变换。SAGEConv帮你实现了聚合逻辑,你只需要堆层数并设定聚合方式:

class GraphSAGE(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout=0.3): super().__init__() self.conv1 = SAGEConv(in_dim, hidden_dim, aggregator_type='mean') self.conv2 = SAGEConv(hidden_dim, out_dim, aggregator_type='pool') self.dropout = nn.Dropout(dropout) def forward(self, graph, features): h = self.conv1(graph, features) h = F.relu(h) h = self.dropout(h) h = self.conv2(graph, h) return h

这里mean聚合器适合食材、菜系这类“整体倾向”明确的节点,pool聚合器适合异构性较强的节点。实际调参时,我会把第一层设成mean、第二层设成pool,理由是第一层提取邻居共性,第二层捕捉差异性,混合效果比单独用一种好。

第三步,采样器与训练循环。这一步是GraphSAGE工程实现的核心,也是最容易出错的地方:

from dgl.dataloading import NeighborSampler, DataLoader sampler = NeighborSampler([10, 5]) # 一跳采样10个邻居,二跳采样5个 dataloader = DataLoader( dgl_graph, train_nids, # 训练节点ID列表 sampler, batch_size=256, shuffle=True, drop_last=False, num_workers=0 ) model = GraphSAGE(in_dim=48, hidden_dim=64, out_dim=32) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) loss_fn = nn.CosineEmbeddingLoss() for epoch in range(50): model.train() total_loss = 0 for input_nodes, output_nodes, blocks in dataloader: # blocks 是一个包含两层采样结果的列表 batch_feat = dgl_graph.ndata['feat'][input_nodes] logits = model(blocks[0], batch_feat) # 对于多跳采样,需要逐层传递,这里简化为单层示例 # 实际应使用 blocks 逐层传播:for i, block in enumerate(blocks) loss = compute_contrastive_loss(logits, output_nodes) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch:03d}, Loss: {total_loss:.4f}")

参数说明:NeighborSampler([10, 5])表示第一跳随机采样10个邻居,第二跳5个。这个数字不是越大越好——增大采样数会显著增加显存占用,但对精度提升很有限。我试过从[10,5]调到[25,10],F1只涨了0.8个点,训练时间却翻了3倍。如果你的图是异构图(节点类型不同、关系类型不同),DGL有专门的HeteroGraphConv和RandomWalkSampler,结构会稍复杂,但整体思路一致。

4. 让推荐结果“可解释”:知识图谱向量检索与混合排序

4.1 从“预测分数”到“路径解释”:把Embedding存下来做最近邻检索

模型训练完,推荐逻辑面临一个选择:是用最终的向量做最近邻检索,还是继续保留一个打分头(MLP)输出预测评分?我的经验是,两个都要:打分头用于粗排,向量检索用于解释和兜底。

具体做法是:将GraphSAGE的最后一层输出作为节点的最终嵌入。训练结束后,保存这些嵌入到一个node_id -> embedding的映射中。推荐时,给定一个用户ID,先取用户嵌入,然后与所有菜品嵌入计算余弦相似度,取Top-K。这一步有一个关键技巧——不要把推荐范围限制在所有菜品上,而是先通过知识图谱的关系做“路径剪枝”。

什么叫路径剪枝?举个例子:用户对“鱼香肉丝”打了5分,在图上有用户-评分->鱼香肉丝和鱼香肉丝-包含->猪肉两条边。这时如果某道菜包含了猪肉但不包含鱼香肉丝的其他食材,它跟用户偏好的语义距离更近。用Cypher或NetworkX的路径过滤,可以把候选集从10万缩小到1万,然后再在这一万道菜里做向量检索。这个操作既降低了计算量,又让结果天然带有可解释性。

保存嵌入和检索的代码如下:

import numpy as np from sklearn.metrics.pairwise import cosine_similarity # 训练结束后,提取所有节点的嵌入 model.eval() with torch.no_grad(): all_embeddings = model(dgl_graph, dgl_graph.ndata['feat']).numpy() # 保存为npy文件 np.save("dish_embeddings.npy", all_embeddings) np.save("node_ids.npy", dgl_graph.nodes().numpy()) # 检索时:给定用户嵌入和候选菜品ID列表 def recommend(user_emb, candidate_ids, top_k=20): cand_embs = all_embeddings[candidate_ids] scores = cosine_similarity(user_emb.reshape(1, -1), cand_embs).flatten() top_indices = scores.argsort()[-top_k:][::-1] return [candidate_ids[i] for i in top_indices]

注意第5行的.numpy()调用,如果你的模型在GPU上,需要先.cpu()再转numpy,否则会报类型错误。

4.2 冷启动问题的两种解法:规则兜底与图先验

食物推荐场景里,冷启动分两种:新用户无行为,新菜品无评分。GNN解决了一部分——新用户如果愿意填写口味偏好(比如“我喜欢川菜”“我讨厌香菜”),可以构造一条用户-偏好->口味的边,然后通过图传播到食材和菜品。但现实中,很多用户根本不愿意填。

这时我习惯用规则兜底。规则很简单:从知识图谱里查热度最高的菜系和食材,做加权混合。但直接用热度排序太粗暴,我的做法是“图先验 + 热度加权”——从图谱里找到与所有用户平均偏好向量最接近的20个菜品作为冷启动推荐池,再按最近热度排序。这样即使用户没行为,推荐出来的菜品也不是盲目刷榜,而是有一定语义逻辑的。

一个容易漏掉的细节是:新菜品入库时要先补全它的关系三元组,否则它在图里是孤岛节点,GNN学不到它的表示。工业场景下的知识图谱设计通常会给“菜品录入”做一个必填校验——菜系、主要食材、口味标签至少填一项,否则拒绝入库。这个校验能在源头避免很多冷启动问题。

顺带说一句,GNN的嵌入结果一定要做可视化验证。把32维嵌入用TSNE降维到2维后,我会检查“川菜节点”是否聚在一起,“甜品”是否跟“高糖食材”靠得近。如果这些聚类特征不明显,多半是图结构或特征有问题,而不是模型问题。

5. 避坑指南:从数据到训练,覆盖“现象-原因-解决”的5条血泪经验

5.1 节点ID不稳定导致图重建后嵌入失效

现象:训练结束后保存了嵌入,重新加载图数据做推荐,发现推荐质量断崖式下降。

原因:构建图时用了名称作为节点ID,但后面清洗数据时菜品名有细微改动(比如加了空格),导致哈希ID变化。所有节点的嵌入对应关系全部错位。

解决:在gen_entity_id这层增加ID的持久化管理,把已有的实体类型 + 名称映射表存成CSV,每次生成ID前先查表,而不是每次都重新哈希。这是最典型的“黑匣子”问题——图结构和嵌入都对,但之间的联系断了。

5.2 邻居采样器在异构图上崩溃

现象:用DGL的HeteroGraphConv时报错,提示边类型不匹配;或者训练时loss正常下降,但验证集效果极差。

原因:食物图谱是典型的异构图(节点类型有菜品、食材、用户等),直接套用同构图采样器会忽略边类型信息。DGL的NeighborSampler默认采样所有边类型,导致菜品-食材的边和用户-评分的边混在一起,模型学到的是“噪声聚合”。

解决:手动指定采样边类型,比如sampler = NeighborSampler([10, 5], fanout_per_edge=True),更根本的做法是使用DGL的HeteroGraphConv并为每种边类型配置独立的聚合器。这个坑很隐蔽,因为报错不一定出现在采样阶段,而是体现在模型效果的莫名下降上。

5.3 图谱数据不平衡:热门食材把嵌入“带偏”了

现象:模型训练完成后,发现所有菜品的嵌入向量都朝“猪肉、鸡肉、盐”这几个高频食材方向偏移,推荐结果高度同质化。

原因:图结构遵循幂律分布,少数食材出现在大量菜品里,它们在邻域聚合中占据了主导地位。GraphSAGE的mean聚合器没有考虑节点频率。

解决:在采样阶段做负采样时,提高低频食材邻居的采样概率,或对高频节点做特征归一化(减去均值后除以标准差)。更简单粗暴的办法是给高频食材的边加一个权重衰减,在loss中加入L2正则。注意看训练日志里的每个epoch loss——如果loss下降很快但验证集表现不动,大概率是这个原因。

5.4 Jupyter Notebook里跑DGL频繁内核崩溃

现象:在Jupyter里训练模型,训练到一半内核重启,没有报错信息。

原因:显存或内存碎片化。Jupyter里上一次运行残留的图数据和变量没有释放,特别是dgl.DataLoader在num_workers > 0时会fork子进程,子进程退出时资源回收不及时。

解决:训练前手动执行import gc; gc.collect(),并关闭num_workers=0;如果数据量不大,可以把整个DGL图移到CPU上,用CPU训练——食物推荐的数据规模(万节点级)在CPU上跑一个epoch也就几十秒,完全够用。顺带说一句,Jupyter里跑深度学习的血泪经验就是:模型代码先在.py文件里调试通过,再挪进Notebook。

5.5 评估指标只看AUC,忽略了推荐列表的多样性

现象:离线评估AUC达到0.92,上线后发现用户点击率不如之前的协同过滤方案。

原因:AUC衡量的是排序能力,但用户面对的是推荐列表而非单条结果。食物推荐列表如果连续推荐三道川菜,哪怕每一道单独看都很“准”,用户依然会厌倦。

解决:评估时加上NDCG和推荐列表的多样性指标(如推荐列表中不同菜系的覆盖度)。建议在你的验证集上同时看这三个数,不要只盯AUC。这种做法治标也治本——让你在离线阶段就感知到列表多样性问题。

6. 把模型做成可服务的推荐系统:向量索引部署与增量更新技巧

实验跑通只是第一步,要把这套方案投到真实场景,还必须解决“如何服务”的问题。我的做法是:训练阶段用Python+Jupyter做探索,上线时用FAISS做向量检索。

FAISS(Facebook AI Similarity Search)是工业界做向量近邻检索的标准工具,支持百万级向量的毫秒级检索。它的使用很简单:

import faiss # 把训练好的嵌入转成FAISS索引 embeddings = np.load("dish_embeddings.npy").astype("float32") faiss.normalize_L2(embeddings) index = faiss.IndexFlatIP(embeddings.shape[1]) # 内积索引,配合归一化等价于余弦相似度 index.add(embeddings) faiss.write_index(index, "dish_index.faiss") # 服务时加载索引 index = faiss.read_index("dish_index.faiss") scores, indices = index.search(user_emb.reshape(1, -1), top_k=20)

注意faiss.normalize_L2这一步不能省略——FAISS的IndexFlatIP是内积,不归一化的话,长向量的菜品会持续霸榜。这是部署阶段最容易忽略的细节。归一化之后,内积就等价于余弦相似度,推荐的数学含义才准确。

增量更新的技巧在于:新菜品入库时,不需要重训整个GraphSAGE模型,只需要把新菜品作为节点加入图,用训练好的模型做一次前向传播,得到它的嵌入,然后index.add一条向量即可。这个操作在FAISS里是O(1)的,做到了真正的动态扩展。整个方案从训练到部署的流程是:数据清洗->建三元组->构图->训练GraphSAGE->导出嵌入->FAISS索引->接口服务,每一环在Jupyter里都能验证。

最后分享一个习惯:我每次跑通一套推荐方案,都会强制自己把模型在“一个完全没见过的测试集”上复现一遍。不是用sklearn的train_test_split,而是把某个月的交互数据全部从训练集中摘掉,用这之前的图去预测。这样测出来的效果才是真实效果的底线。做基于知识图谱的推荐尤其要这样——图结构太容易在不知不觉中泄露信息了。希望这个实践流程能帮你在食物推荐这个方向上少走些弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询