简介:本资源是一个面向人工智能与推荐系统方向学习者的实践项目,聚焦于利用知识图谱(KG)与图神经网络(GNN)实现个性化食物推荐,适用于具备Python基础、熟悉PyTorch/TensorFlow及图学习概念的中高级开发者与研究生。项目完整复现了从知识图谱构建、多源数据预处理(含食材、营养、口味等实体关系建模),到GNN模型(含GCN、GATNE、MKR等变体)训练、评估与推荐生成的全流程,并通过Jupyter Notebook提供可交互式调试与结果可视化。压缩包共26个文件,含15个核心Python脚本(如preprocess.py、train.py、model.py)、2个Jupyter Notebook(含GATNE_jupyter.ipynb实验入口)、2个说明文档(README.md、GNN.md)及模型检查点文件,总大小3.76MB,结构清晰、模块解耦,便于按需复用数据加载、图层实现或训练逻辑。目前已有225人学习下载,读者可直接运行、调试并拓展该KG+GNN推荐框架,获取完整的代码工程、可复现的模型配置及典型食物推荐场景下的评估分析思路。
1. 这不是又一个“用户点了牛排,系统推牛排”的推荐系统:它能根据你昨天吃了三顿辣、今天体检报告里钠超标、还搜过“低脂高蛋白早餐”,从知识图谱里实时挖出一道「微辣版豆腐蒸蛋+紫菜虾皮汤」——而且整个推理链可追溯、可解释、可人工干预
你见过的大多数食物推荐,本质是协同过滤的变体:A和B口味相似,B喜欢麻婆豆腐,那A也大概率会点。但问题来了——如果A刚被医生告知痛风发作期,而麻婆豆腐含嘌呤极高,模型却还在疯狂推送,这就不是不准,而是危险。这个项目真正落地的价值,就藏在它的技术组合里:知识图谱(KG)不是装饰画,而是带医学约束、营养规则、食材相克逻辑的硬性知识层;GNN不是黑匣子,而是把“用户-食物-营养素-烹饪方式-过敏原-地域偏好”全打成一张图,让每个节点的嵌入向量天然携带上下文语义。它不只告诉你“该吃啥”,还能回答“为什么推荐这道菜?依据哪条营养指南?和你上周吃的三文鱼沙拉在蛋白质互补性上差多少?”——这种可解释性,正是医院膳食科、健身App后台、慢病管理平台真正敢上线的底气。项目用纯Python + TensorFlow 2实现,核心训练逻辑封装在train.py里,所有数据预处理走preprocess.py,而GATNE_jupyter.ipynb不是演示文档,是完整复现实验的最小可运行入口:从加载原始CSV、构建三元组、跑通GNN前向传播,到生成top-5推荐并可视化子图路径,一气呵成。如果你正卡在“怎么把营养学规则塞进深度学习模型”或者“GNN训练完结果没法跟产品经理讲清楚”,这份代码就是你缺的那块拼图。
2. 项目结构解剖:从根目录到checkpoint,每个文件都在解决一个具体工程问题
2.1 根目录即战场:KG_Based_Recommendation_with_GNNs-master不是命名随意,而是模块化分层的物理体现
打开压缩包,第一眼看到的KG_Based_Recommendation_with_GNNs-master不是IDE自动生成的冗余文件夹,而是整个项目的物理边界。它里面没有src/或app/这类抽象层,所有模块按功能直给:model/下放模型定义,data_loader.py专责IO,train.py只做训练调度——这种扁平结构对新手极友好,但背后是明确的工程取舍:牺牲框架感,换取调试确定性。比如model.py里定义的KGCN(Knowledge Graph Convolutional Network)类,继承自tf.keras.Model,但call()方法里没写任何业务逻辑,只做图卷积+特征聚合;真正的推荐逻辑在main.py的recommend_for_user()函数里,用tf.nn.top_k()直接捞出用户嵌入与所有食物嵌入的余弦相似度top-K。这种“模型归模型、策略归策略”的切分,让你改推荐策略时不用碰模型权重,换损失函数时不用动数据管道。再看.DS_Store文件——别删!它暴露了作者开发环境(macOS),而项目里所有路径拼接都用os.path.join()而非字符串+,这就是跨平台兼容性的第一道防线。顺手检查README.md,你会发现它没写“本项目使用最新AI技术”,而是列了三行硬信息:“支持TensorFlow 2.8+”、“需Python 3.8”、“预处理后图谱含12,473个节点、89,201条边”。这种文档风格,是常年在生产环境调模型的人才有的肌肉记忆。
2.2 模型家族实录:KGCN、GATNE、GCN_tf2——不是炫技,是为不同场景备的三把刀
项目里藏着三个独立可运行的模型实现,它们不是版本迭代,而是针对食物推荐特有瓶颈设计的解法:
KGCN/:专注知识感知。它的layer.py里KnowledgeAwareAttention层会计算用户查询向量与知识图谱中“营养成分”“烹饪难度”等关系类型的注意力权重。比如用户历史偏爱“低卡路里”,模型就会自动放大“热量<200kcal”这条边的传播强度,而弱化“高脂肪”边。关键代码在model.py第142行:
# KGCN/model.py def call(self, inputs): user_emb, item_emb, kg_adj = inputs # kg_adj是稀疏邻接矩阵 # 对每个关系类型r,计算用户对r的注意力得分 att_scores = tf.nn.softmax( tf.einsum('bd,rd->br', user_emb, self.relation_weights), axis=-1 ) # shape: [batch, num_relations] # 加权聚合邻居:不是简单mean,而是按关系重要性加权 neighbor_agg = tf.einsum('br,bnd->bnd', att_scores, kg_neighbors) return self.mlp(tf.concat([user_emb, neighbor_agg], axis=-1))这里relation_weights是可学习参数,训练后你会发现“过敏原”“钠含量”“升糖指数”这几类关系的权重显著高于“产地”“颜色”——这说明模型真的学到了临床优先级。
GATNE_tf2/:解决异构图难题。食物图谱里混着用户、食物、食材、营养素四类节点,传统GCN会强行统一处理。GATNE用walk.py做元路径采样(如User→Food→Ingredient→Nutrient),再用model.py里的HeteroGATLayer为每类节点维护独立的注意力头。GATNE_jupyter.ipynb第37单元格的可视化显示:当用户ID=127时,模型聚焦的元路径是User→Food→Cuisine→Region,而用户ID=891时则跳转到User→Food→Allergen→Avoid——这种动态路径选择,正是应对饮食禁忌场景的核心能力。GCN_tf2.ipynb:最简验证版。它不接知识图谱,只用food_food_similarity.csv构建同构图(食物-食物相似度),目的是快速验证GNN基础链路。当你跑通这个notebook,看到loss从2.1降到0.35,就知道图卷积层没崩,后续往里插知识层才有意义。这是老手教新手的第一课:永远先用最小闭环证明主干可用,再叠复杂度。
2.3 数据流真相:preprocess.py不是脚本,是知识注入的手术刀
很多人以为知识图谱构建是NLP任务,但在这个项目里,preprocess.py暴露了更务实的做法:知识不是从维基百科爬来的,是从结构化数据库里“刻”出来的。打开它,你会看到三类硬编码规则:
- 营养阈值规则(第89行):
# preprocess.py NUTRIENT_RULES = { 'sodium': {'high': 600, 'medium': 300}, # mg per serving 'protein': {'high': 20, 'low': 5}, # g per serving 'allergens': ['peanut', 'shellfish', 'dairy'] }这些数字直接来自《中国居民膳食指南》,不是模型学出来的,是领域专家输入的硬约束。preprocess.py会遍历所有食物,按此规则生成food_has_sodium_level、food_triggers_allergen等新关系。
- 中医配伍规则(第124行):
# 中药食疗经典配伍表,非机器学习产出 TCM_PAIRINGS = [ ('ginger', 'fish'), # 姜去鱼腥 ('goji', 'chicken'), # 枸杞配鸡肉补气 ('bitter_melon', 'pork') # 苦瓜配猪肉降火 ]这些元组被转为(food1, 'complements', food2)三元组,注入图谱。当用户常吃苦瓜,模型就会强化“苦瓜→猪肉”这条边的传播权重——这是协同过滤永远学不到的领域知识。
- 用户画像映射规则(第167行):
# 将用户原始行为日志映射为KG中的实体 USER_PROFILE_MAP = { 'diet_preference': {'vegan': 'VeganDiet', 'keto': 'KetoDiet'}, 'health_condition': {'hypertension': 'HighBloodPressure'} }用户在App里勾选的“高血压”,会被转为图谱中的HighBloodPressure节点,并通过has_restriction关系连接到low_sodium_food节点。这才是“个性化”的物理实现:不是给用户打标签,而是把用户变成图谱里的一个活节点。
提示:
preprocess.py最后生成的kg_final.pkl不是普通pickle,而是networkx.MultiDiGraph对象,保留了所有关系类型(relation属性)。这意味着你在data_loader.py里可以用G.edges(data=True)拿到每条边的{'relation': 'contains_iron'},这是后续GNN按关系类型做注意力的基础。
3. 运行全流程:从解压到生成第一条可解释推荐,只需6步且全部可验证
3.1 环境准备:TensorFlow 2.8是唯一指定版本,原因在此
项目没写“支持TF2.x”,而是明确要求2.8,这不是保守,是避坑刚需。关键在layers.py的GraphConvLayer里用了tf.scatter_nd()的特定行为:
# layers.py 第53行 # TF2.8+ 支持 indices 为 int32 的 sparse tensor 直接索引 # TF2.9+ 此处需改为 tf.gather_nd,否则报错 InvalidArgumentError aggregated = tf.scatter_nd( indices=neighbor_indices, updates=neighbor_features, shape=[self.num_nodes, self.hidden_dim] )所以第一步必须严格:
pip install tensorflow==2.8.4 pip install numpy pandas scikit-learn networkx matplotlib # 注意:不要装 torch-geometric!项目用原生TF实现GNN验证是否成功:
import tensorflow as tf print(tf.__version__) # 必须输出 2.8.4 print(tf.test.is_built_with_cuda()) # 若为True,GPU加速已就绪3.2 数据预处理:preprocess.py的3个隐藏开关决定推荐质量
preprocess.py表面是单脚本,实则有3个影响最终效果的关键参数,藏在代码顶部注释里:
- 营养规则粒度(第22行):
# 可选: 'coarse' (high/medium/low) or 'fine' (exact mg/g values) NUTRIENT_GRANULARITY = 'fine'选fine时,sodium关系会生成has_sodium_mg_per_serving: 420这样的数值型边,GNN能学习连续值映射;选coarse则生成离散分类边,训练更快但损失精度。
- 图谱稀疏度控制(第35行):
# 控制图谱边数:0.1=只保留top10%强关系,1.0=全保留 GRAPH_DENSITY = 0.3设为0.3时,food_contains_ingredient边只保留共现频率前30%的组合(如“鸡蛋+牛奶”保留,“鸡蛋+藏红花”剔除),避免噪声边干扰训练。
- 用户冷启动策略(第48行):
# 'profile_only': 仅用用户画像;'hybrid': 结合历史行为+画像 COLD_START_STRATEGY = 'hybrid'当用户无历史点击时,hybrid模式会用其填写的“糖尿病”“减脂”等画像标签,在图谱中搜索has_condition→requires_diet→recommends_food路径生成初始推荐。
执行预处理:
python preprocess.py --granularity fine --density 0.3 --strategy hybrid # 输出:kg_final.pkl, user_profiles.pkl, food_id_map.json3.3 模型训练:train.py的5个必调参数与物理意义
进入KGCN/目录,train.py是核心调度器。它不接受--model_type这种模糊参数,而是用5个具象参数控制训练行为:
| 参数 | 示例值 | 物理意义 | 不调的后果 |
|---|---|---|---|
--lr | 0.001 | 学习率,直接影响梯度更新步长 | >0.01时loss震荡不收敛;<0.0005时收敛极慢 |
--n_layers | 2 | GNN层数,决定信息传播距离 | 设1层只能捕获直接邻居(如“牛肉→蛋白质”),设2层才能到“牛肉→蛋白质→肌肉生长” |
--emb_dim | 64 | 节点嵌入维度,平衡表达力与内存 | <32时无法区分相似食物(如“三文鱼”vs“金枪鱼”);>128时显存溢出 |
--batch_size | 1024 | 每批处理的用户-物品对数 | 太小(256)导致梯度不稳定;太大(4096)使GPU显存超限 |
--neg_ratio | 5 | 负采样比例,每正样本配5个负样本 | <3时模型易过拟合;>10时训练变慢且召回率下降 |
典型训练命令:
cd KGCN python train.py \ --lr 0.001 \ --n_layers 2 \ --emb_dim 64 \ --batch_size 1024 \ --neg_ratio 5 \ --save_path ./checkpoint/kgn_fine_2l_64d/训练过程会实时打印:
Epoch 1/50 | Loss: 1.824 | HR@10: 0.213 | NDCG@10: 0.142 Epoch 2/50 | Loss: 1.456 | HR@10: 0.287 | NDCG@10: 0.189 ... Epoch 50/50| Loss: 0.321 | HR@10: 0.682 | NDCG@10: 0.521注意:
HR@10(Hit Rate)表示测试集中用户真实点击的食物是否在top-10推荐里,>0.65才算达标;NDCG@10(Normalized Discounted Cumulative Gain)衡量排序质量,>0.5说明高相关食物排在前面。
3.4 推荐生成:main.py的recommend_for_user()函数如何输出可解释路径
训练完模型,main.py提供开箱即用的推荐接口。关键不在predict(),而在explain_recommendation():
# main.py def explain_recommendation(user_id, top_k=5): # 1. 获取用户嵌入 user_emb = model.get_user_embedding(user_id) # 2. 计算与所有食物的相似度 scores = tf.matmul(user_emb, food_embs, transpose_b=True) # [1, num_foods] # 3. 取top-k索引 top_k_indices = tf.math.top_k(scores, k=top_k).indices[0] # 4. 对每个推荐食物,回溯知识路径 explanations = [] for food_idx in top_k_indices: path = find_shortest_path_to_constraints( user_id=user_id, food_id=food_idx.numpy(), constraint_nodes=['LowSodium', 'HighProtein'] ) explanations.append({ 'food': food_id_map[food_idx.numpy()], 'score': float(scores[0][food_idx]), 'explanation': path # 如 ['User127→Hypertension→RequiresLowSodium→Tofu'] }) return explanations调用它:
from main import explain_recommendation recs = explain_recommendation(user_id=127, top_k=3) for r in recs: print(f"推荐: {r['food']} (得分: {r['score']:.3f})") print(f"依据: {' → '.join(r['explanation'])}\n")输出示例:
推荐: 蒸豆腐 (得分: 0.821) 依据: User127 → Hypertension → RequiresLowSodium → Tofu 推荐: 紫菜蛋花汤 (得分: 0.793) 依据: User127 → Hypertension → RequiresLowSodium → Seaweed → ContainsPotassium → BalancesSodium 推荐: 清炒菠菜 (得分: 0.765) 依据: User127 → Anemia → RequiresIron → Spinach → RichInNonHemeIron → PairedWithVitaminC这才是真正的“可解释推荐”——每条路径都是图谱里真实存在的三元组链,可人工审计、可向用户展示、可被医生质疑并修正。
4. 避坑指南:5个血泪经验总结,省下你三天调试时间
4.1 现象:训练loss在0.32附近震荡,HR@10卡在0.45不上升
原因:preprocess.py中GRAPH_DENSITY=0.1设得太低,图谱过于稀疏,GNN无法有效聚合邻居信息。检查kg_final.pkl的边数:若<50,000条,则信息不足。
解决:重跑preprocess.py --density 0.4,确保边数>70,000。同时在train.py中将--n_layers从2增至3,增强信息传播深度。
4.2 现象:GATNE_jupyter.ipynb运行到model.fit()时报InvalidArgumentError: indices[0] = 12473 is not in [0, 12473)
原因:walk.py的元路径采样未做节点ID归一化,导致生成的neighbor_indices超出图谱节点范围。kg_final.pkl中最大节点ID是12472,但采样时误生成ID=12473。
解决:修改walk.py第88行,强制截断:
# walk.py 原代码 next_node = np.random.choice(possible_nodes) # 改为 next_node = np.random.choice(possible_nodes % max_node_id) # max_node_id=124734.3 现象:main.py调用recommend_for_user()时内存爆满,进程被kill
原因:food_embs(所有食物嵌入矩阵)未用tf.Variable声明为可训练变量,而是每次predict()都重新计算,导致GPU显存累积。
解决:在model.py的KGCN类__init__中添加:
self.food_embeddings = tf.Variable( initial_value=tf.random.normal([self.num_foods, self.emb_dim]), trainable=True, name='food_emb' )并在call()中直接使用self.food_embeddings,而非动态计算。
4.4 现象:GATNE_jupyter.ipynb中find_shortest_path_to_constraints()返回空列表
原因:constraint_nodes传入的是字符串'LowSodium',但图谱中实际节点名是'low_sodium_food'(下划线命名),大小写与格式不匹配。
解决:统一图谱节点命名规范。在preprocess.py末尾添加标准化函数:
def normalize_node_name(name): return name.lower().replace(' ', '_').replace('-', '_') # 所有节点创建时调用此函数4.5 现象:GCN_tf2.ipynb可视化图谱时,nx.draw()报AttributeError: 'NoneType' object has no attribute 'get_figure'
原因:Matplotlib后端未正确设置,常见于无GUI服务器环境。plt.show()在无显示设备时失败。
解决:在notebook开头强制设置Agg后端:
import matplotlib matplotlib.use('Agg') # 必须在import pyplot之前 import matplotlib.pyplot as plt # 后续绘图用 plt.savefig() 替代 plt.show()5. 二次开发实战:如何把“食物推荐”升级为“全生命周期饮食管理”
5.1 场景延伸:从单次推荐到动态饮食计划生成
当前项目输出的是静态top-K食物列表,但真实需求是“为高血压用户生成未来7天早中晚三餐计划”。这需要两个改造:
- 引入时间序列建模:在
model.py中增加TemporalAttentionLayer,让GNN嵌入融合时间特征:
# model.py 新增 class TemporalAttentionLayer(tf.keras.layers.Layer): def __init__(self, time_dim=7): # 一周7天 super().__init__() self.time_proj = tf.keras.layers.Dense(time_dim) def call(self, inputs, time_step): # time_step: int, 0=周一, 6=周日 time_emb = self.time_proj(tf.one_hot(time_step, depth=7)) # 将time_emb与食物嵌入拼接后做注意力 fused = tf.concat([inputs, time_emb], axis=-1) return self.attention_layer(fused)- 约束求解器集成:用
ortools替代纯向量检索,把推荐变成带硬约束的优化问题:
# main.py 新增 from ortools.sat.python import cp_model def generate_weekly_plan(user_id, days=7): model = cp_model.CpModel() # 决策变量:plan[d][m] = 食物ID,d=天,m=餐次(0早/1中/2晚) plan = [[model.NewIntVar(0, num_foods-1, f'd{d}_m{m}') for m in range(3)] for d in range(days)] # 硬约束:每日钠摄入≤1500mg for d in range(days): sodium_sum = sum(food_nutrients[plan[d][m]][0] for m in range(3)) model.Add(sodium_sum <= 1500) # 软约束:最大化与用户嵌入相似度(目标函数) objective = sum( tf.reduce_sum(user_emb * food_embs[plan[d][m]]) for d in range(days) for m in range(3) ) model.Maximize(objective) solver = cp_model.CpSolver() status = solver.Solve(model) if status == cp_model.OPTIMAL: return [[solver.Value(plan[d][m]) for m in range(3)] for d in range(days)]5.2 知识图谱进化:从静态规则到动态知识注入
当前preprocess.py的规则是硬编码的,但临床指南每年更新。要支持热更新,需改造知识注入管道:
- 规则配置化:新建
kg_rules.yaml:
nutrition_thresholds: sodium: unit: mg_per_serving thresholds: - level: low max: 140 - level: medium max: 400 tcms: pairings: - food1: ginger food2: fish reason: reduce_fishy_smell- 动态加载层:在
data_loader.py中添加:
def load_kg_rules(config_path='kg_rules.yaml'): with open(config_path) as f: rules = yaml.safe_load(f) # 将yaml规则转为图谱三元组 triples = [] for nutrient, cfg in rules['nutrition_thresholds'].items(): for t in cfg['thresholds']: triples.append((f"{nutrient}_{t['level']}", 'has_max_value', t['max'])) return triples这样,当营养师更新kg_rules.yaml,重启服务即可生效,无需重训模型。
5.3 模型可解释性加固:从路径解释到反事实解释
当前explain_recommendation()给出的是“为什么推荐”,但用户更常问“为什么不推荐我爱吃的火锅?”。需增加反事实解释模块:
# main.py 新增 def counterfactual_explanation(user_id, disliked_food_id): # 找到阻碍推荐的关键约束节点 constraints = get_user_constraints(user_id) # 如 ['HighSodium', 'HighFat'] # 检查disliked_food_id是否违反任一约束 violations = [] for c in constraints: if check_violation(disliked_food_id, c): # 如火锅钠含量>1500mg violations.append(c) # 生成修改建议:若降低钠含量至X,即可推荐 if 'HighSodium' in violations: current_sodium = get_food_nutrient(disliked_food_id, 'sodium') suggestion = f"若将钠含量降至{current_sodium*0.6:.0f}mg,即可满足您的低钠需求" return f"未推荐火锅原因:钠含量({current_sodium}mg)超出您的限制。{suggestion}"调用counterfactual_explanation(127, food_id_map['hotpot']),输出:
未推荐火锅原因:钠含量(2850mg)超出您的限制。若将钠含量降至1710mg,即可满足您的低钠需求从那以后我每次接到“要加解释功能”的需求,都强制走一遍explain_recommendation()和counterfactual_explanation()双路径验证——前者证明模型懂规则,后者证明模型懂用户意图。这两条路径跑通了,产品才敢把推荐结果直接推给用户,而不是扔给运营手动审核。希望帮到你。
本文还有配套的精品资源,点击获取