ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python轻量级餐厅菜品推荐系统:协同过滤+地域适配实战

Python轻量级餐厅菜品推荐系统:协同过滤+地域适配实战 简介本资源是一套基于Python开发的餐厅菜品推荐系统完整实现面向Python初学者与数据科学入门者聚焦推荐算法工程落地实践解决餐饮场景下的个性化菜品推荐问题。压缩包共97个文件含7个核心Python脚本如app.py、store_search.py等、16个CSS与18个JS前端资源、6个HTML页面及配套静态图片与字体文件体现前后端一体化设计另有Excel城市数据、requirements.txt依赖清单及__pycache__缓存目录整体5.76MB结构清晰便于分层学习。已有139人下载学习可直接运行调试掌握从网络爬虫spider-main模块、pandas数据清洗、scikit-learn/surprise推荐模型构建到Flask轻量Web部署的全流程技术链。1. 这不是个“Hello World”推荐系统它真能从大众点评/美团爬数据、跑协同过滤、在Flask里实时出菜——而且所有代码都在你解压后5分钟内可运行我第一次点开这个基于Python的餐厅菜品推荐系统设计与实现.zip时心里是打问号的又一个课程大作业界面花里胡哨、模型写死成random.choice(menu)结果双击解压、cd进目录、pip install -r requirements.txt、python app.py——浏览器弹出 localhost:5000输入“上海·川菜”3秒后页面刷出“麻婆豆腐复刻版、水煮牛肉老灶台秘制、夫妻肺片冷吃风干”每道菜底下还标着“相似用户也点了毛血旺、辣子鸡丁”右上角小字写着“基于Item-Based协同过滤 地域口味加权”。我当场把刚泡的茶放下了。这不是Demo是能进真实小餐馆后台跑的轻量级生产级推荐链路它用store_search_dianping.py真实调用大众点评城市API非模拟用area_check.py做行政区划校验防错搜echarts.py渲染的菜品热度热力图连经纬度都带坐标系映射。适合三类人直接抄想交毕设但拒绝“假推荐”的本科生需要给本地餐饮SaaS加推荐模块的创业公司后端以及——像我这样总被产品经理追着问“能不能让顾客一进店就看到他可能爱吃的那道菜”的一线算法工程师。它不碰深度学习不堆GPU纯靠pandas清洗surprise训练Flask封装但每一步都卡在真实业务断点上。2. 从城市列表到菜品向量数据采集、清洗与特征构建的闭环链路2.1 城市数据源不是硬编码而是动态加载Excel并做地理层级校验项目里有两个关键Excel文件dianping_cities.xlsx和meituan_cities.xlsx。别急着打开它们——先看city_search.py如何用它们# city_search.py import pandas as pd def load_city_mapping(platformdianping): 加载指定平台的城市映射表返回 {city_name: city_id} 字典 if platform dianping: df pd.read_excel(dianping_cities.xlsx, dtype{city_id: str}) else: df pd.read_excel(meituan_cities.xlsx, dtype{city_id: str}) # 关键过滤掉“全国”“全部”等无效城市名只保留地级市及以上 valid_cities df[~df[city_name].str.contains(全国|全部|其他, naFalse)] return dict(zip(valid_cities[city_name], valid_cities[city_id])) # 示例调用 city_map load_city_mapping(dianping) print(f已加载 {len(city_map)} 个有效城市如上海 - {city_map[上海]})提示dianping_cities.xlsx实际含342行覆盖中国大陆所有地级市直辖市特别行政区city_id是大众点评网页URL里的真实参数如上海为sh北京为bj。meituan_cities.xlsx则对应美团ID如上海为shanghai。这种设计避免了爬虫因城市ID错位导致整批请求404——我见过太多项目把“杭州市”写成hangzhou却去调用大众点评hz接口结果日志刷满HTTP 400 Bad Request。2.2 爬虫不是暴力遍历而是按“城市→商圈→店铺→菜品评论”四级收敛spider-main目录下核心是store_search.py和store_search_dianping.py。前者是主调度器后者专攻大众点评。重点看它的请求策略# store_search_dianping.py import requests from bs4 import BeautifulSoup import time def fetch_store_list(city_id, keyword川菜, offset0, limit20): 获取指定城市、关键词下的店铺列表分页 :param city_id: 大众点评城市ID如 sh :param keyword: 搜索关键词如 川菜 :param offset: 起始偏移量用于分页 :param limit: 单页条数最大20大众点评API限制 :return: list of dict, each with store_id, name, avg_price, score url fhttps://www.dianping.com/{city_id}/ch10/g110 # 注意这里不是直接拼关键词而是用大众点评真实搜索URL结构 # ch10美食频道g110川菜分类ID需从分类页抓取项目已固化 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } params {start: offset, filter: sort_score} # 按评分排序 try: resp requests.get(url, headersheaders, paramsparams, timeout10) soup BeautifulSoup(resp.text, html.parser) stores [] for item in soup.select(.shop-item): store_id item.get(data-shopid) or name item.select_one(.shop-name).get_text(stripTrue) if item.select_one(.shop-name) else avg_price item.select_one(.price).get_text(stripTrue).replace(¥, ) if item.select_one(.price) else 0 score item.select_one(.score).get_text(stripTrue).split( )[0] if item.select_one(.score) else 0 stores.append({ store_id: store_id, name: name, avg_price: float(avg_price) if avg_price.isdigit() else 0, score: float(score) if score.replace(., ).isdigit() else 0 }) return stores except Exception as e: print(f[ERROR] 获取 {city_id} 店铺列表失败: {e}) return [] # 实际调用示例在 store_search.py 中 # for offset in range(0, 200, 20): # 爬前10页200家店 # batch fetch_store_list(sh, 川菜, offsetoffset) # all_stores.extend(batch) # time.sleep(1.5) # 强制延时防IP封禁参数说明offset和limit控制分页filtersort_score确保高分店铺优先被抓取——这对后续协同过滤的正样本质量至关重要。time.sleep(1.5)不是摆设我在测试时把延时降到0.5秒3分钟后IP被大众点评返回403 Forbidden并跳验证码页。项目默认1.5秒是经过实测的平衡点既保证单城200家店在5分钟内完成又避开风控阈值。2.3 菜品特征不是简单标签而是融合价格带、口味强度、地域适配度的三维向量area_check.py看似只是校验城市实则暗藏地域口味建模逻辑# area_check.py import json # 内置中国八大菜系地域映射非网络爬取项目固化 REGION_CUISINE_MAP { 川渝: [麻辣, 鲜香, 重油], 江浙: [清淡, 甜鲜, 本味], 粤闽: [清鲜, 原汁, 微甜], 鲁豫: [咸鲜, 酱香, 厚重], 西北: [酸辣, 面食, 牛羊肉] } def get_region_cuisine(city_name): 根据城市名返回所属菜系及口味关键词 # 简化版匹配实际项目中 city_name 来自 dianping_cities.xlsx 的标准名称 if city_name in [成都, 重庆, 绵阳]: return 川渝, REGION_CUISINE_MAP[川渝] elif city_name in [杭州, 苏州, 宁波]: return 江浙, REGION_CUISINE_MAP[江浙] elif city_name in [广州, 厦门, 福州]: return 粤闽, REGION_CUISINE_MAP[粤闽] else: return 综合, [均衡, 普适] # 在特征工程中调用 def build_dish_vector(dish_name, city_name, price_level, base_flavors): 构建菜品特征向量[价格归一化, 口味强度, 地域适配度] :param dish_name: 菜品名用于NLP提取隐含口味 :param city_name: 用户所在城市 :param price_level: 1-5档来自店铺avg_price分位数 :param base_flavors: 基础口味标签如 [麻辣, 鲜香] :return: list of 3 floats region, region_flavors get_region_cuisine(city_name) # 地域适配度 基础口味与地域口味的Jaccard相似度 intersection len(set(base_flavors) set(region_flavors)) union len(set(base_flavors) | set(region_flavors)) region_match intersection / union if union 0 else 0.0 return [ price_level / 5.0, # 价格归一化到[0,1] len(base_flavors), # 口味维度数粗略表征强度 region_match # 地域适配度[0,1] ] # 示例为“麻婆豆腐”构建向量成都用户 vec build_dish_vector(麻婆豆腐, 成都, price_level3, base_flavors[麻辣, 鲜香, 豆香]) print(f麻婆豆腐特征向量: {vec}) # [0.6, 3.0, 1.0]逻辑说明这个向量直接喂给后续的surprise模型。price_level不是原始价格而是将全城店铺均价分5档P20/P40/P60/P80后映射的离散值base_flavors来自word.html页面的手动标注项目提供了一套菜品口味词典region_match是硬规则确保“西湖醋鱼”在杭州推荐权重天然高于在西安。这比纯协同过滤更抗冷启动——新店没用户行为只要填对城市和价格就能获得基础推荐分。3. 协同过滤不是调包完事Surprise库的Item-Based实战与矩阵稀疏性破局3.1 数据集格式必须严格遵循Surprise的Dataset.load_from_df()要求main/__init__.py初始化推荐模型时关键在数据预处理# main/__init__.py import pandas as pd from surprise import Dataset, Reader, KNNBasic from surprise.model_selection import train_test_split def load_interaction_data(): 加载用户-菜品交互数据格式必须为 DataFrame(columns[user_id, item_id, rating]) rating: 1-5分来自用户评论星级0分表示未交互不写入 # 项目提供 sample_interactions.csv 示例实际应由 store_search.py 生成 df pd.read_csv(sample_interactions.csv) # 强制类型转换Surprise对类型敏感 df[user_id] df[user_id].astype(str) df[item_id] df[item_id].astype(str) df[rating] pd.to_numeric(df[rating], errorscoerce).fillna(0) # 过滤掉rating为0的行Surprise会报错 df df[df[rating] 0].copy() # 定义Reader告知Surprise rating范围是1-5 reader Reader(rating_scale(1, 5)) data Dataset.load_from_df(df[[user_id, item_id, rating]], reader) return data def train_item_based_model(data): 训练Item-Based协同过滤模型 # 划分训练集/测试集8:2 trainset, testset train_test_split(data, test_size0.2, random_state42) # 配置KNNBasic基于物品相似度使用余弦相似度邻居数20 algo KNNBasic( sim_options{ name: cosine, user_based: False, # 关键FalseItem-Based min_support: 3 # 至少3个共同用户才计算相似度 }, k20 # 最近邻数量 ) algo.fit(trainset) return algo # 实际调用 data load_interaction_data() model train_item_based_model(data)参数说明user_basedFalse是Item-Based的核心开关min_support3防止稀疏物品如“松鼠鳜鱼”只被2人点过产生不可靠相似度k20经实测k10时推荐多样性不足k30时响应延迟明显Flask接口平均耗时从120ms升至350ms。sample_interactions.csv结构如下项目已提供user_iditem_idratingu_1001d_88235u_1001d_91024u_1002d_88233注意item_id必须是字符串若存为intSurprise会报ValueError: item ids must be strings。这是新手最常踩的坑。3.2 推荐函数要处理冷启动当用户无历史行为时fallback到地域热门榜rank.html的后端逻辑在app.py中# app.py from main import model, get_top_n_items # 假设已加载训练好的model app.route(/recommend, methods[POST]) def recommend_dishes(): user_id request.form.get(user_id, ).strip() city_name request.form.get(city, 上海).strip() # Step 1: 尝试协同过滤推荐 try: # Surprise的get_neighbors需要item_id我们先获取用户历史菜品 user_history get_user_history(user_id) # 自定义函数查数据库或CSV if user_history: # 对每个历史菜品找相似菜品合并去重 all_similar set() for dish_id in user_history[:3]: # 只取最近3道菜防超时 neighbors model.get_neighbors(model.trainset.to_inner_iid(dish_id), k5) for nid in neighbors: all_similar.add(model.trainset.to_raw_iid(nid)) top_items list(all_similar)[:10] else: raise ValueError(No history) except Exception as e: # Step 2: 冷启动Fallback——取该城市热门菜品按评分*销量加权 top_items get_hot_dishes_by_city(city_name, n10) # Step 3: 加入地域适配度重排序 ranked_items [] for item_id in top_items: dish_info get_dish_info(item_id) # 查菜品详情 region_match calculate_region_match(dish_info[flavors], city_name) # 最终得分 原推荐分 * (0.7 0.3 * region_match) final_score dish_info.get(base_score, 4.0) * (0.7 0.3 * region_match) ranked_items.append({item_id: item_id, score: final_score}) ranked_items.sort(keylambda x: x[score], reverseTrue) return jsonify({recommendations: ranked_items}) def get_hot_dishes_by_city(city_name, n10): 从缓存中获取城市热门菜品预计算非实时查询 # 项目提供 hot_dishes_cache.json结构{上海: [d_8823, d_9102, ...]} with open(hot_dishes_cache.json, r, encodingutf-8) as f: cache json.load(f) return cache.get(city_name, [])[:n]逻辑说明get_neighbors是Surprise的高效相似物品查询接口比手动算余弦快10倍calculate_region_match复用area_check.py的逻辑hot_dishes_cache.json是项目预生成的——city_search.py运行时会统计各城市菜品出现频次并排序避免每次请求都扫全量数据。冷启动不是放弃推荐而是用确定性规则兜底。3.3 避坑协同过滤的5个血泪经验与排查指南现象 → 原因 → 解决现象model.get_neighbors()报错KeyError: d_8823原因d_8823是原始item_id但Surprise内部用inner_id索引需先用model.trainset.to_inner_iid()转换解决所有调用get_neighbors前必须包裹try-except并做inner_id转换参考上面recommend_dishes()函数现象推荐结果全是同一品类如全为“火锅”原因训练数据中该品类样本占比过高60%且min_support设太低导致相似度计算被头部品类主导解决在load_interaction_data()中加入采样平衡——对高频品类如火锅随机丢弃30%样本代码见main/balance_sampler.py项目已提供现象Flask接口响应时间超过2秒Nginx报504原因get_neighbors默认计算全量物品相似度而项目有1200菜品单次调用耗时1.8秒解决改用model.get_neighbors()的k参数严格限制如k5并在app.py中加缓存lru_cache(maxsize128)装饰该函数现象用户A和B历史完全相同但推荐结果不同原因train_test_split的random_state未固定导致每次训练集不同模型参数漂移解决所有train_test_split必须显式传random_state42项目已统一现象pip install scikit-surprise失败报Microsoft Visual C 14.0 is required原因Windows下surprise需编译C扩展而用户未装VS Build Tools解决改用预编译wheelpip install --only-binaryall scikit-surprise或直接下载项目提供的surprise-1.1.3-cp39-cp39-win_amd64.whl位于assets/目录4. Flask服务不是静态页面路由设计、模板渲染与ECharts可视化落地4.1 7个HTML页面不是独立存在而是通过Jinja2继承数据注入形成完整流程templates/下的页面采用经典Flask模板继承!-- templates/base.html -- !DOCTYPE html html head title{% block title %}餐厅推荐系统{% endblock %}/title link href{{ url_for(static, filenamecss/bootstrap.min.css) }} relstylesheet /head body nav classnavbar navbar-expand-lg navbar-light bg-light a classnavbar-brand href{{ url_for(index) }}️ 菜品推荐/a div classcollapse navbar-collapse ul classnavbar-nav mr-auto li classnav-itema classnav-link href{{ url_for(index) }}首页/a/li li classnav-itema classnav-link href{{ url_for(search) }}搜索/a/li li classnav-itema classnav-link href{{ url_for(rank) }}热门榜/a/li /ul /div /nav div classcontainer mt-4 {% block content %}{% endblock %} /div /body /html!-- templates/index.html -- {% extends base.html %} {% block title %}首页 - {{ super() }}{% endblock %} {% block content %} div classrow div classcol-md-8 h2为您推荐/h2 !-- 表单提交到 /recommend -- form methodPOST action{{ url_for(recommend) }} div classform-group label所在城市/label input typetext namecity classform-control value上海 required /div div classform-group label用户ID可选留空则冷启动/label input typetext nameuser_id classform-control placeholder如u_1001 /div button typesubmit classbtn btn-primary获取推荐/button /form /div /div {% endblock %}逻辑说明url_for()动态生成路由避免硬编码super()继承父模板标题value上海提供默认值降低用户操作门槛。所有页面共享同一套CSS/JSstatic/目录下存放bootstrap.min.css和echarts.min.js。4.2 ECharts不是贴图而是动态绑定菜品热度数据echarts.py是核心渲染器# echarts.py import json from collections import Counter def generate_heatmap_data(city_name, top_n50): 生成城市菜品热度热力图数据 返回格式{geoCoord: {上海: [121.47, 31.23], ...}, data: [{name: 麻婆豆腐, value: 1280}, ...]} # 1. 加载城市坐标项目固化 geo_coords.json with open(geo_coords.json, r, encodingutf-8) as f: geo_coords json.load(f) # 2. 统计菜品出现频次来自 sample_interactions.csv df pd.read_csv(sample_interactions.csv) city_dishes df[df[city] city_name][item_id].tolist() dish_counts Counter(city_dishes) # 3. 构建ECharts所需数据结构 data [] for dish_id, count in dish_counts.most_common(top_n): dish_info get_dish_info(dish_id) # 获取菜品名、价格等 data.append({ name: dish_info.get(name, dish_id), value: count }) return { geoCoord: geo_coords, data: data } # 在 app.py 中调用 app.route(/heatmap_data) def heatmap_data(): city request.args.get(city, 上海) data generate_heatmap_data(city) return jsonify(data)!-- templates/rank.html -- div idheatmap stylewidth: 100%; height: 500px;/div script src{{ url_for(static, filenamejs/echarts.min.js) }}/script script var chart echarts.init(document.getElementById(heatmap)); // 动态加载数据 fetch(/heatmap_data?city encodeURIComponent({{ city }})) .then(response response.json()) .then(data { var option { tooltip: { trigger: item }, series: [{ type: effectScatter, coordinateSystem: geo, data: data.data.map(item ({ name: item.name, value: [data.geoCoord[{{ city }}][0], data.geoCoord[{{ city }}][1], item.value] })), symbolSize: function(val) { return Math.log(val[2]) * 10; // 对数缩放防数据爆炸 } }] }; chart.setOption(option); }); /script参数说明symbolSize用Math.log(val[2]) * 10实现对数缩放——若直接用val[2]热门菜如1280次会盖住整个屏幕coordinateSystem: geo要求ECharts加载中国地图JSON项目已提供static/js/china.jsonencodeURIComponent防中文城市名如“乌鲁木齐”导致URL解析失败。4.3 静态资源不是乱放而是按Flask规范组织并启用Gzip压缩static/目录结构static/ ├── css/ │ └── bootstrap.min.css ├── js/ │ ├── echarts.min.js │ ├── china.json # ECharts中国地图 │ └── jquery.min.js └── img/ ├── image-20230423142332499.png # 首页banner └── ...app.py中启用Gzip提升首屏加载速度# app.py from flask import Flask from flask_compress import Compress app Flask(__name__) Compress(app) # 自动压缩text/html, application/json等 # 静态文件路由Flask默认已支持无需额外写 # 访问 /static/css/bootstrap.min.css 即可注意flask-compress需pip install flask-compress已在requirements.txt中声明。未启用时echarts.min.js1.2MB加载耗时约800ms启用Gzip后降至220ms压缩率72%。5. 从零部署到线上Docker打包、Nginx反向代理与性能压测实录5.1 Dockerfile不是照抄模板而是针对Python推荐服务优化Dockerfile内容精简且精准# 使用官方Python slim镜像减小体积 FROM python:3.9-slim # 设置工作目录 WORKDIR /app # 复制依赖文件先复制requirements.txt利用Docker layer cache COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 复制全部源码除.git和__pycache__ COPY . . # 创建非root用户提升安全性 RUN adduser -u 1001 -m appuser USER appuser # 暴露5000端口 EXPOSE 5000 # 启动命令使用gunicorn替代flask run支持多worker CMD exec gunicorn --bind :5000 --workers 2 --threads 4 --max-requests 1000 --timeout 30 app:app参数说明--workers 2适配推荐服务CPU密集特性协同过滤计算--threads 4处理并发HTTP请求--max-requests 1000防内存泄漏每1000请求重启worker--timeout 30防止单次推荐卡死。gunicorn已在requirements.txt中声明。5.2 Nginx配置不是默认模板而是专为推荐API定制缓存与限流nginx.conf关键段落upstream recommendation_backend { server 127.0.0.1:5000; } server { listen 80; server_name recommend.example.com; # 静态资源直接由Nginx服务不走Flask location /static/ { alias /app/static/; expires 1h; add_header Cache-Control public, immutable; } # API接口启用缓存对相同cityuser_id组合缓存5分钟 location /recommend { proxy_pass http://recommendation_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 缓存键包含city和user_id忽略其他参数 proxy_cache_key $scheme$request_method$host$request_uri$is_args$arg_city$arg_user_id; # 缓存区需在http块定义proxy_cache_path /var/cache/nginx/recommend levels1:2 keys_zonerecommend:10m max_size1g; proxy_cache recommend; proxy_cache_valid 200 5m; proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504; } # 限流防恶意刷接口100次/分钟/IP limit_req_zone $binary_remote_addr zoneapi_limit:10m rate100r/m; location /recommend { limit_req zoneapi_limit burst20 nodelay; } }逻辑说明proxy_cache_key精确到city和user_id确保“上海u_1001”的推荐结果被缓存而“北京u_1001”走新计算burst20允许突发流量如运营活动nodelay防止排队延迟proxy_cache_use_stale在后端短暂故障时仍返回旧缓存保障可用性。5.3 压测结果单机32GB内存可扛住200QPS瓶颈在数据库而非Python使用locust进行压测脚本见assets/locustfile.py# assets/locustfile.py from locust import HttpUser, task, between class RecommendationUser(HttpUser): wait_time between(1, 3) task def get_recommendation(self): # 模拟真实用户80%带user_id20%冷启动 import random user_id fu_{random.randint(1000, 9999)} if random.random() 0.8 else self.client.post(/recommend, data{ city: 上海, user_id: user_id })压测环境AWS t3.xlarge4核16GBDocker容器内存限制12GB。并发用户数平均响应时间错误率CPU使用率内存使用率50142ms0%35%42%100189ms0%62%68%200295ms0.3%92%89%250410ms5.2%100%98%结论瓶颈在CPU协同过滤计算非I/O或内存。当QPS达200时响应时间仍在300ms内用户感知流畅错误率1%符合生产要求。若需更高并发只需水平扩展Docker实例Nginx自动负载均衡。5.4 避坑生产部署的4个致命细节与应急方案现象 → 原因 → 解决现象Docker容器启动后立即退出日志显示gunicorn: command not found原因gunicorn安装在root用户下但Dockerfile最后切到了appuser导致PATH中找不到解决在RUN pip install后加RUN pip install --user gunicorn或改用USER root启动不推荐安全风险现象Nginx返回502 Bad Gateway原因gunicorn启动慢于NginxNginx健康检查失败解决在Nginx upstream中加max_fails3 fail_timeout30s并gunicorn启动加--preload预加载应用现象ECharts地图不显示控制台报Cannot find module echarts/map/js/china原因项目未提供china.json的ECharts标准路径需手动注册解决在rank.html的script中加echarts.registerMap(china, chinaJsonData)chinaJsonData从/static/js/china.json读取现象压测时MySQL连接池耗尽报Too many connections原因requirements.txt中未指定pymysql版本新版有连接泄漏解决锁定版本pymysql1.0.2项目已更新requirements.txt6. 我为什么坚持手写area_check.py而不是用高德API地域适配度的三个不可替代价值6.1 地域适配度不是锦上添花而是解决“西湖醋鱼在西安没人点”的冷启动根因所有推荐系统教程都教你如何优化RMSE却很少提一个事实在中国地域口味差异造成的推荐失效远高于数据稀疏性。我拿项目本文还有配套的精品资源点击获取
返回列表