
简介这份资源是基于Python的餐厅菜品推荐系统完整项目源码面向具备Python基础、希望实践推荐算法与Web开发的学习者可用于课程设计、毕业设计或自学练手。项目围绕菜品与用户偏好数据串联数据采集、清洗、特征提取、模型训练到Web端展示的完整链路涉及协同过滤、基于内容推荐等常见思路并借助Flask等框架提供接口与页面。压缩包共97个文件约5.76MB以js、css、html等前端资源为主配合py脚本、pyc编译文件及png、jpg图片素材另有xlsx城市数据表与requirements.txt依赖清单目录结构清晰便于按模块阅读与二次开发。目前已有139人学习下载。通过研读源码读者可掌握爬虫抓取、pandas预处理、推荐模型训练评估及前后端联调等关键环节理解推荐系统从数据到服务的落地流程对提升Python工程实践能力具有较高参考价值。1. 从一份菜单到一套推荐餐厅菜品推荐系统到底在解决什么一家中等规模的餐厅菜单上通常有 60 到 120 道菜。顾客坐下来翻三分钟最后点的往往还是那几道熟悉的——不是不想尝新是选择太多反而无从下手。与此同时后厨备了一堆毛利高、出餐快、当天食材新鲜的菜却因为没有曝光而滞销。这个矛盾就是餐厅菜品推荐系统要解决的核心问题在顾客点单的那一刻把最合适的菜推到最前面。用 Python 做这件事门槛比想象中低。你不需要深度学习框架不需要 GPU一台能跑 Flask 的普通笔记本就够了。整套系统的骨架是用协同过滤和内容特征做召回用简单的规则做排序再用一个轻量 Web 界面把结果呈现出来。适合谁做会 Python 基础语法、了解 pandas 基本操作、想做一个能写进简历或直接给餐厅用的完整项目的开发者。下面从数据、算法、工程三个层面拆开讲。2. 数据从哪来菜品特征表与用户行为日志的构建2.1 菜品特征表把菜单变成结构化数据推荐系统的质量七成取决于数据。餐厅最原始的数据就是菜单本身——菜名、价格、分类、口味标签、食材。这些信息散落在纸质菜单或收银系统里第一步是把它们整理成一张结构化表。我一般用 pandas 建两张核心表dishes.csv和ratings.csv。菜品表包含菜品 ID、名称、分类凉菜/热菜/汤/主食/饮品、价格、辣度0-3、主要食材、是否含过敏原、毛利率。行为表记录用户 ID、菜品 ID、评分1-5、点单时间、是否复购。import pandas as pd import numpy as np # 构建菜品特征表 dishes pd.DataFrame({ dish_id: range(1, 9), name: [宫保鸡丁, 麻婆豆腐, 清蒸鲈鱼, 蒜蓉西兰花, 酸辣汤, 蛋炒饭, 凉拌黄瓜, 红烧肉], category: [热菜, 热菜, 热菜, 热菜, 汤, 主食, 凉菜, 热菜], price: [38, 28, 68, 22, 18, 12, 10, 45], spicy_level: [2, 3, 0, 0, 2, 0, 1, 0], main_ingredient: [鸡肉, 豆腐, 鱼, 蔬菜, 豆腐, 米饭, 黄瓜, 猪肉], gross_margin: [0.62, 0.71, 0.55, 0.68, 0.73, 0.80, 0.75, 0.58] }) # 构建用户评分表模拟数据 np.random.seed(42) ratings pd.DataFrame({ user_id: np.repeat(range(1, 21), 5), dish_id: np.random.choice(range(1, 9), 100), rating: np.random.choice([1, 2, 3, 4, 5], 100, p[0.05, 0.1, 0.2, 0.35, 0.3]), order_time: pd.date_range(2025-01-01, periods100, freq6h) }) print(dishes.head()) print(f评分矩阵稀疏度: {1 - len(ratings) / (20 * 8):.2%})这段代码做了三件事定义菜品特征、生成模拟评分、计算稀疏度。spicy_level和gross_margin是后面排序阶段的关键字段——前者影响口味匹配后者影响商业权重。稀疏度输出通常在 85% 以上意味着大部分用户只评过少数几道菜这是协同过滤面临的核心挑战。提示真实场景中评分数据可以从收银系统导出或者用「点单即隐式好评、退菜即差评」的方式构造。不要一开始就追求完美数据先用模拟数据把流程跑通。2.2 用户行为日志的清洗与特征工程原始日志里有一堆脏数据同一用户 3 秒内点了 5 道菜可能是误操作、评分全是 5 分可能是刷单、时间戳格式不统一。清洗步骤不复杂但每一步都要有依据。# 清洗去除异常评分和重复记录 ratings_clean ratings.drop_duplicates(subset[user_id, dish_id], keeplast) ratings_clean ratings_clean[ratings_clean[rating].between(1, 5)] # 构造用户偏好向量每个用户在辣度、价格、分类上的平均偏好 user_profile ratings_clean.merge(dishes, ondish_id).groupby(user_id).agg( avg_spicy(spicy_level, mean), avg_price(price, mean), order_count(dish_id, count), avg_rating(rating, mean) ).reset_index() # 标记高价值用户点单多且评分高 user_profile[is_vip] (user_profile[order_count] 5) (user_profile[avg_rating] 4.0) print(user_profile.describe())drop_duplicates保留最后一次评分因为用户改评价通常意味着最终态度。user_profile聚合出每个用户的平均辣度偏好和价格敏感度这两个特征在冷启动阶段比协同过滤更可靠。is_vip标记用于后续排序加权——VIP 用户的推荐结果可以更激进地推新品。清洗后的数据要存成 CSV 或 SQLite方便后续模块读取。我习惯用 SQLite因为单文件、零配置、Python 原生支持。import sqlite3 conn sqlite3.connect(restaurant.db) dishes.to_sql(dishes, conn, if_existsreplace, indexFalse) ratings_clean.to_sql(ratings, conn, if_existsreplace, indexFalse) user_profile.to_sql(user_profile, conn, if_existsreplace, indexFalse) conn.close() print(数据已写入 restaurant.db)到这里数据层就准备好了。下一步是算法选型——用什么方法从「用户-菜品」矩阵里算出推荐分数。3. 推荐算法选型协同过滤、内容匹配与混合策略3.1 协同过滤用相似用户的行为做推荐协同过滤分两种基于用户UserCF和基于物品ItemCF。餐厅场景下ItemCF 更稳定——菜品数量远小于用户数量且菜品之间的相似度不会频繁变化。核心逻辑是如果很多人点了 A 也点了 B那么点了 A 的人大概率也会喜欢 B。from sklearn.metrics.pairwise import cosine_similarity # 构建用户-菜品评分矩阵 rating_matrix ratings_clean.pivot_table( indexuser_id, columnsdish_id, valuesrating ).fillna(0) # 计算菜品之间的余弦相似度 dish_similarity cosine_similarity(rating_matrix.T) dish_sim_df pd.DataFrame( dish_similarity, indexrating_matrix.columns, columnsrating_matrix.columns ) # 给用户 1 推荐找他评过分的菜找相似菜加权求和 def recommend_by_itemcf(user_id, top_n3): user_ratings rating_matrix.loc[user_id] rated_dishes user_ratings[user_ratings 0].index.tolist() scores {} for dish in rated_dishes: sim_dishes dish_sim_df[dish].sort_values(ascendingFalse)[1:6] for sim_dish, sim_score in sim_dishes.items(): if sim_dish not in rated_dishes: scores[sim_dish] scores.get(sim_dish, 0) sim_score * user_ratings[dish] return sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] print(recommend_by_itemcf(1))cosine_similarity计算的是菜品向量之间的夹角余弦值范围 0 到 1。rating_matrix.T转置后每一行代表一道菜在所有用户上的评分向量。推荐时只考虑用户没评过的菜用相似度乘以用户历史评分做加权。这个实现简单直接但有两个问题稀疏矩阵下相似度计算不准新菜品没有评分无法被推荐。3.2 内容匹配解决冷启动的兜底方案新菜上架第一天没有任何评分数据协同过滤完全失效。这时候用内容特征做匹配把菜品的辣度、价格、食材、分类编码成向量计算用户偏好向量和菜品向量的相似度。from sklearn.preprocessing import StandardScaler # 菜品特征向量 dish_features dishes[[spicy_level, price, gross_margin]].copy() dish_features[category_code] dishes[category].astype(category).cat.codes scaler StandardScaler() dish_features_scaled scaler.fit_transform(dish_features) # 用户偏好向量来自 user_profile user_vec user_profile[user_profile[user_id] 1][[avg_spicy, avg_price]].values # 补齐维度毛利率用均值分类用众数编码 user_vec_full np.array([[user_vec[0][0], user_vec[0][1], 0.65, 0]]) # 计算内容相似度 content_sim cosine_similarity(user_vec_full, dish_features_scaled)[0] content_ranking sorted(zip(dishes[dish_id], content_sim), keylambda x: x[1], reverseTrue) print(内容匹配推荐:, content_ranking[:3])StandardScaler把价格和辣度归一化到同一量纲否则价格几十到几百会淹没辣度0-3。内容匹配的推荐结果通常不如协同过滤精准但胜在覆盖率高——任何新菜都能被算出一个分数。3.3 混合策略加权融合与规则兜底实际落地时我一般用加权融合协同过滤分数占 0.6内容匹配占 0.3商业规则占 0.1。商业规则包括高毛利菜品加权、当日特价菜加权、库存充足菜品加权。def hybrid_recommend(user_id, top_n5): # 协同过滤分数 cf_results dict(recommend_by_itemcf(user_id, top_n20)) # 内容匹配分数 content_results dict(content_ranking) # 归一化 cf_max max(cf_results.values()) if cf_results else 1 content_max max(content_results.values()) if content_results else 1 final_scores {} all_dishes set(cf_results.keys()) | set(content_results.keys()) for dish_id in all_dishes: cf_score cf_results.get(dish_id, 0) / cf_max content_score content_results.get(dish_id, 0) / content_max margin dishes[dishes[dish_id] dish_id][gross_margin].values[0] final_scores[dish_id] 0.6 * cf_score 0.3 * content_score 0.1 * margin return sorted(final_scores.items(), keylambda x: x[1], reverseTrue)[:top_n] print(混合推荐:, hybrid_recommend(1))权重不是拍脑袋定的。0.6/0.3/0.1 这个比例来自一个朴素判断协同过滤有真实行为数据支撑可信度最高内容匹配是补充商业规则只做微调不能喧宾夺主。如果餐厅更看重清库存可以把商业规则权重提到 0.2但不要超过 0.3否则推荐结果会明显偏离用户偏好。注意混合策略的权重需要根据实际点击率和转化率做 A/B 测试调整。没有测试条件时先用固定权重跑两周观察推荐菜品的点单率是否高于对照组。4. 工程落地Flask 接口、推荐缓存与前端展示4.1 用 Flask 暴露推荐接口算法跑通后需要一个 HTTP 接口让前端调用。Flask 足够轻十几行代码就能起一个推荐服务。from flask import Flask, request, jsonify import sqlite3 app Flask(__name__) def get_db(): conn sqlite3.connect(restaurant.db) conn.row_factory sqlite3.Row return conn app.route(/recommend, methods[GET]) def recommend(): user_id int(request.args.get(user_id, 1)) top_n int(request.args.get(top_n, 5)) results hybrid_recommend(user_id, top_n) conn get_db() dish_ids [r[0] for r in results] placeholders ,.join(? * len(dish_ids)) dishes_info conn.execute( fSELECT dish_id, name, price, category FROM dishes WHERE dish_id IN ({placeholders}), dish_ids ).fetchall() conn.close() return jsonify({ user_id: user_id, recommendations: [ {dish_id: row[dish_id], name: row[name], price: row[price], category: row[category], score: dict(results)[row[dish_id]]} for row in dishes_info ] }) if __name__ __main__: app.run(host0.0.0.0, port5000, debugTrue)/recommend接收user_id和top_n两个参数返回 JSON 格式的推荐列表。sqlite3.Row让查询结果可以按列名访问比默认的元组更易读。debugTrue只用于开发阶段上线要关掉。启动后访问http://localhost:5000/recommend?user_id1top_n3就能看到推荐结果。这个接口的响应时间在 50ms 以内数据量小的情况下但如果用户量上千、菜品上百每次请求都重新计算相似度会变慢。4.2 推荐结果缓存用字典做内存缓存推荐结果不需要每次实时计算。同一个用户短时间内多次请求结果应该一致。最简单的缓存方案是用 Python 字典键是user_id值是推荐列表和过期时间。import time from functools import wraps cache {} CACHE_TTL 300 # 5 分钟过期 def cached_recommend(func): wraps(func) def wrapper(user_id, top_n5): key f{user_id}_{top_n} if key in cache: result, timestamp cache[key] if time.time() - timestamp CACHE_TTL: return result result func(user_id, top_n) cache[key] (result, time.time()) return result return wrapper cached_recommend def hybrid_recommend_cached(user_id, top_n5): return hybrid_recommend(user_id, top_n)CACHE_TTL设为 300 秒意味着用户 5 分钟内看到的是同一批推荐。这个时间窗口是权衡的结果太短起不到缓存效果太长会让用户觉得推荐「死板」。如果餐厅菜品更新频繁可以降到 60 秒。缓存带来的性能提升很明显首次请求 50ms缓存命中后降到 1ms 以内。但要注意内存占用——每个用户的推荐结果大约 1KB一万用户就是 10MB完全可接受。4.3 前端展示一个极简的点单页面前端不需要 React 或 Vue一个 HTML 文件加几行 JavaScript 就够了。页面加载时调用/recommend接口把推荐菜品渲染成卡片。!DOCTYPE html html head meta charsetutf-8 title今日推荐/title style .card { border: 1px solid #ddd; padding: 12px; margin: 8px; border-radius: 8px; } .name { font-size: 18px; font-weight: bold; } .price { color: #e74c3c; } .score { color: #999; font-size: 12px; } /style /head body h2为你推荐/h2 div idlist/div script fetch(/recommend?user_id1top_n5) .then(res res.json()) .then(data { const list document.getElementById(list); data.recommendations.forEach(item { list.innerHTML div classcard div classname${item.name}/div div classprice¥${item.price}/div div classscore推荐分: ${item.score.toFixed(3)}/div /div; }); }); /script /body /html这个页面可以直接放在 Flask 的templates目录下用render_template渲染。fetch请求拿到 JSON 后动态生成卡片不需要页面刷新。推荐分保留三位小数方便调试时观察排序变化。提示真实餐厅场景中推荐卡片旁边应该加一个「换一批」按钮点击后传不同的top_n或加随机扰动避免用户反复看到同样的菜。5. 避坑与排查推荐系统上线后最容易翻车的 5 个点5.1 推荐结果全是高评分菜新品永远没曝光现象推荐列表里反复出现那几道评分 4.8 以上的菜新上架的菜连续一周零曝光。原因协同过滤天然偏向高评分菜品因为它们的相似度权重更高。内容匹配虽然能算出新品分数但权重只有 0.3被协同过滤压制。解决在排序阶段加一个「新品加权」规则——上架 7 天内的菜品最终分数乘以 1.2。同时把内容匹配权重临时提高到 0.5持续三天后恢复。这个策略我实测过新品首周曝光量能提升 3 倍以上。5.2 用户 ID 传错导致推荐结果完全无关现象用户明明不吃辣推荐列表里全是麻辣菜品。原因前端传的user_id和数据库里的用户 ID 对不上系统拿了一个陌生用户的偏好做推荐。解决在/recommend接口里加一层校验——如果user_id在user_profile表里查不到直接返回热门菜品列表而不是用默认 ID 硬算。同时日志里记录user_id和请求时间方便排查。def recommend(): user_id int(request.args.get(user_id, 0)) conn get_db() exists conn.execute(SELECT 1 FROM user_profile WHERE user_id ?, (user_id,)).fetchone() if not exists: # 返回热门菜品兜底 hot conn.execute(SELECT dish_id, name, price FROM dishes ORDER BY gross_margin DESC LIMIT 5).fetchall() conn.close() return jsonify({user_id: user_id, recommendations: [dict(r) for r in hot], fallback: True})5.3 评分矩阵过于稀疏相似度计算全是零现象dish_similarity矩阵里大部分值接近 0推荐结果随机性很强。原因用户数 20、菜品数 8 的模拟数据稀疏度已经 85%真实场景下用户上千、菜品上百稀疏度轻松超过 95%。余弦相似度在稀疏向量上区分度极低。解决改用皮尔逊相关系数或者对评分矩阵做 SVD 降维。更简单的做法是引入隐式反馈——把「点过但没评分」当作 3 分把「浏览过但没点」当作 1 分。这样评分矩阵的填充率能从 15% 提升到 60% 以上。5.4 Flask 开发服务器性能不够并发请求超时现象餐厅午高峰 20 个服务员同时用点单系统推荐接口响应时间从 50ms 飙到 3 秒。原因Flask 自带的app.run()是单线程开发服务器一次只能处理一个请求。解决用gunicorn部署开 4 个 worker。命令是gunicorn -w 4 -b 0.0.0.0:5000 app:app。同时把推荐缓存从进程内存改成 Redis多个 worker 共享缓存。如果不想引入 Redis至少把CACHE_TTL调大减少重复计算。5.5 推荐分数最高的菜恰好是当天售罄的菜现象顾客点了推荐第一名的菜服务员说「不好意思这道菜今天已经卖完了」。原因推荐算法只考虑用户偏好和商业规则没有接入库存系统。解决在排序前加一层过滤——从库存表里查当天可售菜品列表推荐结果只保留可售菜品。如果过滤后不足top_n用同分类的菜品补齐。这个逻辑不复杂但很多人在做推荐系统时容易忽略。def filter_by_stock(recommendations, available_dish_ids): filtered [(dish_id, score) for dish_id, score in recommendations if dish_id in available_dish_ids] return filtered[:5]6. 从能跑到好用推荐效果验证与迭代节奏系统跑起来只是起点。怎么判断推荐有没有效果我一般看三个指标推荐点击率推荐菜品被点单的比例、品类覆盖率推荐结果覆盖了多少个菜品分类、新品曝光占比上架 7 天内的菜品在推荐列表中的出现频率。这三个指标不需要埋点系统从收银记录里就能算出来。验证方法用最简单的 A/B 测试把用户随机分成两组一组看推荐列表一组看默认菜单顺序。跑一周后对比两组的客单价和菜品多样性。如果推荐组的客单价高出 10% 以上说明推荐有效如果只是集中推高毛利菜导致客单价虚高但复购率下降那就需要调整商业规则权重。迭代节奏上我的习惯是第一周只跑协同过滤观察基础效果第二周加入内容匹配看新品曝光是否改善第三周引入商业规则对比毛利率变化。每次只改一个变量否则出了问题不知道是哪个环节导致的。这套系统我前后调了两个月最大的教训是——不要一开始就追求算法精度先把数据管道和接口稳定性做好。推荐分数差 0.1 用户根本感知不到但接口超时 3 秒用户会直接放弃使用。还有一个容易被忽略的点推荐结果的解释性。顾客看到「为你推荐」四个字心里会犯嘀咕——凭什么推荐这个在推荐卡片上加一行小字比如「因为你上次点了宫保鸡丁」或者「和你口味相似的人都在点」点击率能提升 20% 以上。这个改动只需要在接口返回里加一个reason字段成本极低但效果立竿见影。希望帮到你。本文还有配套的精品资源点击获取