简介面向机器学习与推荐系统学习者的饮食推荐项目完整文件包以Jupyter Notebook与Python脚本为核心解决如何基于用户营养需求与食物属性生成个性化饮食推荐的问题适合数据挖掘课程实践、毕业设计或健康数据分析入门者。压缩包共6个文件约429KB含两份csv结构化的食物与营养分布数据、一个Python推荐算法脚本、一个可交互执行的ipynb笔记本以及PDF与Markdown说明文档分别对应数据输入、算法实现、实验复现与项目文档查阅。项目源自知识型专家系统课程体现基于知识的推荐思路读者可通过现成代码与数据快速跑通推荐流程并学习协同过滤、营养规则匹配等机器学习应用技巧。已有217人学习下载对于需要一份轻量完整实战参考的开发者而言是值得收藏的样例工程。1. 用机器学习的饮食推荐最难不是算法而是数据太稀疏用机器学习做饮食推荐系统乍看是个标准的 Python 课程设计找一份食材数据、建一张用户评分表、套一个推荐算法跑出 Top-N 列表。真上手你会发现最难的从来不是模型本身而是用户留下的往往只有一句随便吃点评分矩阵稀疏到让协同过滤直接放弃治疗。这类系统真正要解决三件事把好吃变成可计算的向量在评分极少的情况下给出合理推荐最后按营养约束把结果压进一周食谱。适合正在做课程设计、毕业设计或者想在本地用 Jupyter Notebook 跑通一套完整推荐管线的开发者。整个技术栈很轻一台普通笔记本就能覆盖从数据清洗、特征工程到模型评估的全部环节。2. 建模路线选型为什么饮食推荐不适合裸用协同过滤2.1 先把吃得惯变成特征向量内容推荐的地基饮食推荐和电影推荐最大的区别在于电影的隐因子相对清晰而食物的偏好高度依赖属性组合。你没法指望用户给每条食物都打过分但几乎每道菜都有可描述的结构化属性。常见的做法是把食物表拆成四组特征营养成分热量、蛋白质、脂肪、碳水、口味维度甜、咸、辣、酸、苦、烹饪方式炒、烤、煮、蒸、凉拌、食材类别肉类、蛋奶、蔬菜、主食。这一层特征构造是整条链路的地基。如果直接拿原始的食物 ID 去训练模型学不到任何跨食物的泛化能力一旦换成特征向量就算某道菜从没被评分过只要它的属性向量能被计算就能参与到推荐召回里。把文本标签转成数值向量的最小示例import pandas as pd # 食物表每行一道菜tags 列用逗号分隔标签如 辣,炒,猪肉 foods pd.read_csv(data/foods.csv) food_tags foods[tags].str.get_dummies(sep,) print(food_tags.shape) # (菜品数, 标签种类数) print(food_tags.head(3))str.get_dummies(sep,)会把 tags 列拆成多列每个出现过一次的标签占一列值只能是 0 或 1。这里的sep参数必须和源数据里的分隔符严格一致有的数据集用的是分号或者竖线不改分隔符会把一整段标签当成一个词。有了这个向量之后任意两道菜之间的相似度就可以用余弦距离计算这也是后面内容召回阶段的核心算子。2.2 协同过滤和 SVD矩阵分解对小数据集依然能打基于内容的推荐虽然不怕冷启动但它有个天然缺陷推荐结果容易被同类目锁死用户喜欢辣子鸡系统就只会给辣菜。为了弥补这一点需要引入协同信号。然而饮食场景的评分矩阵通常极度稀疏用户可能只对 3% 到 8% 的食物有过交互记录。在这种矩阵上用传统的 user-based 协同过滤找出来的近邻用户几乎不可靠——两个用户可能只是因为恰好给同一道菜打了分就被算成口味相近。矩阵分解SVD的处理方式不太一样。它把 user-item 矩阵压缩成 K 个隐因子让模型自动从数据中学习用户为什么喜欢这道菜的潜在解释而不是依赖显式的近邻关系。surprise 库里的 SVD 实现专门处理过稀疏矩阵几百条评分的玩具数据集也能跑出有区分度的结果。三条路线的对比很清楚方案依赖数据优点缺点基于内容食物属性新菜也能推荐解释性强推荐结果同质化严重item-based CF用户评分能发现跨类别口味关联长尾食物永远没机会SVD 矩阵分解用户评分隐因子能捕捉深层偏好冷启动用户基本失效实际做选型时我的建议是不要在三者里做单选题而是把内容推荐当成召回层、SVD 当成精排层前后串联。2.3 混合策略内容召回 SVD 精排混合策略的标准流程分两段。召回阶段取用户最近打过分的 5 道菜各自在内容特征向量上找余弦相似度最高的若干个候选合并成候选集合精排阶段用训练好的 SVD 模型预测用户对候选集合里每道菜的评分按预测分从高到低排序取 Top-N。这个拼法的好处在于新用户哪怕只有 1 条评分也能靠内容相似度拉出候选集不至于让推荐接口直接返回空列表老用户则靠 SVD 的长尾能力把隐藏在评分模式里的跨类别偏好挖出来。召回阶段的候选集合大小是个值得调试的参数。候选集合太小子集太窄精排阶段没有排序空间太大则 SVD 要对几百道菜逐个预测Notebook 里跑起来会有明显延迟。我一般会把candidate_size设在 40 到 80 之间让候选数保持在最终推荐数的 10 倍上下。还有一个细节召回阶段的相似度计算要基于标准化后的特征向量否则热量这种量级大的数值列会直接淹没口味标签的 0/1 信号。3. Jupyter Notebook 数据准备从原始表到模型能吃的矩阵3.1 把环境先踩顺依赖安装与目录结构标题既然落在 Jupyter Notebook 上环境部分就不能绕开。我习惯先确认 Python 版本再统一装依赖。surprise 库在 Python 3.11 以上有时会遇到编译问题最稳妥的组合是 Python 3.8 到 3.10 搭配最新版 pip。装依赖的完整过程# 先升级 pip避免旧版本解析依赖时翻车 python -m pip install --upgrade pip pip install pandas numpy scikit-learn surprise matplotlib # 没装 notebook 的机器再补一条 jupyter notebookpandas 管表格读写numpy 处理矩阵运算scikit-learn 提供相似度计算和评估工具surprise 是 SVD 模型的来源matplotlib 用来画分布图看数据质量。启动 notebook 之后第一件事是确认工作目录对不对pd.read_csv(data/foods.csv)用的是 Notebook 当前工作目录而不是 notebook 文件所在目录。# 指定目录启动避免不同系统下相对路径踩坑 jupyter notebook --notebook-dir/你的绝对路径/饮食推荐系统路径里有中文或者空格时用绝对路径更省心。建议目录结构固定成三块data/raw放原始 CSVnotebooks放分析脚本models放训练产物后续清洗和建模都按这个约定读写。3.2 清洗原始表留哪些字段丢哪些字段多数场景下拿到的食物表包含三类信息食物名称、营养素数值列、文本标签列用户评分表则通常是user_id, food_id, rating的长表。清洗的目的是把原始数据整理成模型能识别的形状而不是把所有字段都灌进模型。第一步检查缺失和类型import pandas as pd foods pd.read_csv(data/foods.csv) print(foods.info()) print(foods.isna().sum()) # 营养素列缺失超过30%的整列丢弃标签列缺失则补 unknown foods foods.dropna(threshlen(foods) * 0.7, axis1) foods[tags] foods[tags].fillna(unknown)必须注意的是dropna里的thresh参数它的含义是这一列至少要有多少非空值才保留。len(foods) * 0.7意味着某列必须有超过 70% 的行存在值否则整列删除。这个阈值设得过高会把有用的列删光设得过低则缺失严重的列带入大量空值实践里 60% 到 80% 都是合理区间需要结合字段重要性判断。用户评分表的清洗更简单去掉重复行、过滤评分范围之外的值。ratings pd.read_csv(data/ratings.csv).drop_duplicates() ratings ratings[(ratings[rating] 1) (ratings[rating] 5)]3.3 构建 user-item 矩阵与稀疏度指标清洗之后要做的第一件事不是训练而是把长表转成宽表算出稀疏度因为它直接决定你后续该走哪条模型路线。用pivot_table转矩阵rating_matrix ratings.pivot_table( indexuser_id, columnsfood_id, valuesrating ) sparsity 1 - rating_matrix.notna().sum().sum() / rating_matrix.size print(f评分矩阵稀疏度: {sparsity:.2%})pivot_table的三个核心参数中index决定矩阵的行是用户columns决定列是食物values是评分值。稀疏度达到 90% 是饮食推荐系统的常态。如果稀疏度超过 95% 且总评分数量不足 500 条SVD 仍然能用但一定要配合内容召回否则模型学到的基本是全局均值没有参考价值。4. 训练与调参把 SVD 模型跑通并生成 Top-N 推荐4.1 最小可跑的 SVD 训练流程surprise 库的接口比较固定数据必须组织成user_id, food_id, rating三列的长表列名可以不一样但顺序不能变。训练流程里有一个常见的反直觉点model.test(testset)返回的不是一个 RMSE 数值而是一组 Prediction 对象要算评估分数还得再包一层。from surprise import SVD, Dataset, Reader, accuracy from surprise.model_selection import train_test_split # Reader 声明评分范围越界值会被自动丢弃 reader Reader(rating_scale(1, 5)) data Dataset.load_from_df(ratings[[user_id, food_id, rating]], reader) trainset, testset train_test_split(data, test_size0.2, random_state42) model SVD( n_factors40, # 隐因子数量 n_epochs30, # 训练轮数 lr_all0.008, # 全局学习率 reg_all0.15, # 全局正则化系数 random_state42 # 固定随机种子 ) model.fit(trainset) rmse accuracy.rmse(model.test(testset))Reader里的rating_scale必须和实际数据匹配如果数据里有 0 分而范围写成 1 到 5这些 0 分会被静默丢弃。random_state是必须固定的一项不固定的话同一份数据每次训练结果都不一样后面调参时根本分不清效果变化来自参数还是来自随机性。accuracy.rmse会直接把得分答应到控制台但返回值仍然建议接住方便后续写入日志。4.2 对新用户和老用户生成推荐列表模型的输出是评分预测值不是推荐列表中间还差一个把预测变成排序的步骤。推荐函数要区分两类用户老用户有足够历史可以直接预测所有未评分食物新用户评分太少直接预测会被正则项拉向均值。实践中先走内容召回再用 SVD 精排def recommend_for_user(user_id, top_n5, candidate_size50): # 召回最近喜欢的5道菜各取内容相似度前10 liked ratings[ratings[user_id] user_id] \ .sort_values(rating, ascendingFalse)[food_id].head(5) candidates set() per_food candidate_size // max(len(liked), 1) for fid in liked: sims content_sim[fid].sort_values(ascendingFalse).head(per_food) candidates.update(sims.index) # 过滤已经评过分的菜只预测新候选 rated set(ratings[ratings[user_id] user_id][food_id]) candidates candidates - rated # 精排SVD 打分后排序 scored [(fid, model.predict(user_id, fid).est) for fid in candidates] scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_n]candidate_size是召回上限决定了精排阶段要预测多少个候选per_food把候选预算摊到每道喜欢的菜上防止某一道热门菜的相似结果霸占整个候选集合。model.predict(user_id, fid).est取的是预测估计值不是真实评分它的取值范围会被模型限制在评分区间附近。代码里的content_sim是预先算好的食物相似度矩阵如果一个新用户一条评分都没有这个函数会直接退化所以前端交互上应该强制用户先给至少 1 到 2 道菜打分。4.3 三个必调参数n_factors、n_epochs、reg_allSVD 在 surprise 里的可调参数有十几个但真正决定推荐质量的只有三个。n_factors控制隐因子数量相当于模型用多少维向量去解释用户和食物之间的潜在关系。设太小学不到深层口味关联设太大则过拟合几百条评分的项目里 20 到 60 之间通常够用。n_epochs是训练轮数轮数太少 loss 还没降到底太多则在测试集上不降反升。reg_all是全局正则化系数稀疏数据集里调大它比调小学习率更有效。参数推荐区间过小过大n_factors20 - 60欠拟合推荐结果发散过拟合测试集 RMSE 反弹n_epochs20 - 40未收敛训练集 loss 仍高训练过度reg_all0.02 - 0.2模型被噪声带偏预测值全部缩向均值判断参数合不合适不能只看训练集 RMSE。训练集分数很好看而测试集分数差了一大截基本就是过拟合优先把reg_all往 0.1 以上调而不是急着降n_factors。如果测试集 RMSE 一直降不下来就先查评分矩阵的稀疏度稀疏度过高时调参的收益非常有限。5. 避坑记录冷启动、稀疏矩阵和推荐失衡的排查5.1 冷启动新用户评分太少预测分数全挤在均值附近现象一个只打过 1 到 2 个评分的用户推荐列表里每道菜的预测分差距不到 0.1 分排序结果基本等于随机。原因SVD 的隐因子是从大量共同评分里学出来的样本太少时正则项把预测值强行往全局均值方向拉模型的个性化能力完全失效。解决不要只靠 SVD 输出结果。评分不足 3 条的用户直接跳过精排阶段输出内容召回结果评分达到 5 条以上再进入 SVD 精排。推荐接口可以写成带条件分支的版本但更稳妥的做法是先做一层启发式规则垫底再谈算法。5.2 稀疏矩阵评分率低于 5% 时 SVD 收敛变慢甚至震荡现象训练时 loss 下降得很吃力或者跑完几轮测试 RMSE 忽高忽低。原因矩阵太稀疏可用的共同评分信号太弱梯度更新里大部分样本都是被正则项压制的边模型在噪声和惩罚之间来回拉锯。解决分两步走。第一步是数据筛选把评分少于 3 条的用户和食物过滤掉只保留有足够交互记录的实体第二步是参数适配降低n_factors到 20 上下、把reg_all提高到 0.2 左右牺牲拟合能力换取稳定性。# 过滤低交互实体常见做法是保留评分3的用户和食物 user_counts ratings[user_id].value_counts() food_counts ratings[food_id].value_counts() ratings ratings[ratings[user_id].isin(user_counts[user_counts 3].index)] ratings ratings[ratings[food_id].isin(food_counts[food_counts 3].index)]5.3 评分分布右偏用户普遍打 4 到 5 分模型学不到不喜欢现象推荐列表全是热门菜因为模型把 3 分和 5 分几乎当成一回事排序时优先推了流行度高的食物。原因绝对评分里缺少厌恶信息用户把 3 分当成还行模型也把 3 分当成还行可推荐系统要的是排名不是分类。解决把绝对评分转成相对评分每个用户减去自己的平均打分再训练正分表示这道菜高于他的心理基线负分表示低于基线。这样模型学的是个人的口味偏移而不是全社会对某道菜的流行度共识。ratings[rating_rel] ratings[rating] - ratings.groupby(user_id)[rating].transform(mean)5.4 路径问题Jupyter Notebook 里读到的是另一份同名文件现象满心以为在改最新的 CSV跑完模型结果纹丝不动数据好像被缓存了。原因pd.read_csv(foods.csv)用的是 Notebook 当前工作目录而它跟 notebook 文件所在的目录不一定一致Jupyter 默认保存路径和实际启动时的路径经常分家。解决在每个 notebook 开头先打印当前工作目录避免黑匣子式的路径假设。import os print(os.getcwd()) os.chdir(/你的绝对路径/饮食推荐系统) # 发现问题后手动切目录6. 进阶验证用评估指标和一周食谱把推荐结果落地6.1 不要只看 RMSE加一个 PrecisionK 判定RMSE 衡量的是评分预测误差但推荐系统最终要回答的问题不是预测分准不准而是推荐列表里有没有用户想要的东西。一个模型可能 RMSE 很漂亮推荐出的 Top-5 却全是用户不会主动点的菜。换个指标看推荐质量def precision_at_k(user_id, top_n5, threshold4.0): rec recommend_for_user(user_id, top_ntop_n) if not rec: return 0.0 actual set( ratings[(ratings[user_id] user_id) (ratings[rating] threshold)][food_id] ) rec_items {food_id for food_id, _ in rec} return len(rec_items actual) / top_nthreshold默认取 4.0但评分分布右偏的数据集里这个阈值应该跟着用户均值走比如设成用户平均分 0.5。除了精度还得看覆盖率——推荐列表里去重食物数除以全部食物数覆盖率长期低于 30% 说明模型在偷懒只推热门菜。我有个习惯每次调参后同时打印 RMSE、PrecisionK 和覆盖率三个值只看其中任何一个都容易误导。6.2 把推荐结果压成一周食谱营养价值比口味更容易验证推荐列表只是中间产物真正的落地场景是生成一份可执行的一周食谱。加一层营养约束过滤利用的就是前面特征工程里留下的热量和蛋白质字段def build_week_plan(user_id, days7, meals_per_day3, cal_limit700): rec recommend_for_user(user_id, top_ndays * meals_per_day * 2) plan, used_cal [], 0 for food_id, _ in rec: cal foods.loc[food_id, calories] if used_cal cal cal_limit: plan.append(food_id) used_cal cal return plan[:days * meals_per_day]cal_limit是单餐热量上限top_n放大一倍是为了给约束过滤留出余量防止筛着筛着列表不够用。这个函数突出了数值特征在推荐链条里的真实价值没有热量字段食谱约束就只能是空谈。整套方案跑完你会看到冷启动靠内容召回、个性化靠 SVD、落地靠特征工程每个环节都有明确职责没有玄学成分。希望帮到你。本文还有配套的精品资源点击获取