做这个"python美食推荐系统"项目,前前后后花了我差不多三周时间。说实话,网上讲推荐算法的教程一抓一大把,但绝大多数都是拿MovieLens或者Book Crossing这种现成数据集跑个模型,跑完就结束,离真实项目总隔着一条鸿沟。这次我换了个思路,把爬虫、ECharts、协同过滤、Django四件事串成一条完整链路:源头数据用爬虫去美食平台上抓,中间用协同过滤算相似店铺和用户偏好,前端用ECharts做可视化大屏,最后用Django把店铺推荐系统整个端出来。做完你会发现,这套东西不是玩具Demo,而是能直接改吧改吧拿去当毕业设计或者简历项目的完整工程。
适合看这篇文章的朋友,大概是这三类:一是准备做大数据方向课程设计、毕业设计的学生,需要一套能讲清楚"数据从哪来、算法怎么算、结果怎么展示"的完整项目;二是想从纯爬虫过渡到数据产品开发的Python学习者,想看看Django怎么和算法模块耦合;三是工作里遇到"推荐系统落地"需求的开发,想找一个轻量但五脏俱全的参考实现。不管你是哪类,我把核心代码思路、踩坑记录、还有那些文档里不会写的细节都放在下面了。
进入正文前先打个预防针:我会把这个项目拆成数据采集、数据可视化、协同过滤算法、Django整合四个模块来讲,每个模块都会给出关键代码片段和设计理由。推荐你跟着思路走,别急着复制粘贴,理清楚每个环节"为什么要这么干"比代码本身值钱得多。
1. 项目设计与技术选型:为什么偏偏是这四件套
1.1 早期Demo频繁翻车的教训:全局架构先行
我一开始做推荐系统的时候,习惯先把协同过滤算法写完,结果每次写到一半都要回头改数据格式,后来学乖了,动手前先把整体架构画清楚。这个项目的架构并不复杂,本质是一条数据管道:
爬虫采集 → 数据清洗入库 → 基于评分矩阵做协同过滤 → Django接口封装 → ECharts可视化展示注意中间有两条数据流。第一条是离线数据流:爬虫把店铺信息、用户评分等数据抓到MySQL,后端在进入算法前先做清洗;第二条是在线数据流:用户在前端页面浏览店铺、打分,Django会实时把新评分写入数据库,协同过滤模块定期重新计算相似度矩阵,把推荐列表暴露给前端。
为什么要把系统拆成这样?因为协同过滤有一个特点——它吃的是"用户-物品"评分矩阵,这个矩阵必须在足够多评分数据下效果才稳定。如果你的系统允许用户实时打分,那么算法就应该在后台定时重算,而不是用户每次刷新页面都跑一遍全量矩阵分解。Django的ORM和缓存机制刚好能覆盖这个需求:ORM负责数据和评分记录,Redis或数据库缓存负责暂存相似度矩阵,避免每次请求都触发高密度计算。
1.2 技术栈选型背后的具体考量
爬虫层用了Python的requests和lxml库。选XPath而不是正则选解析,是因为美食平台页面结构有较强的层级规律,XPath定位路径比正则表达式抗页面微调能力强得多。有人会问用Scrapy行不行,当然行,但Scrapy偏重工程化,做小体量项目有点杀鸡用牛刀的感觉,requests加lxml能让你更清晰地看到"请求、解析、入库"每一步发生了什么,配合调试器也方便。
可视化层用了ECharts。我前后对比过pyecharts和前端原生ECharts,最终选了ECharts标准前端方案。原因一是它更新灵活,图表配置在JavaScript端写,Django后端只提供JSON数据接口,职责分离更干净;原因二是我要做的图表类型比较多,包括店铺分类饼图、评分Top10柱状图、人均消费分布直方图,ECharts生态组件全,遇到地图这种复杂类型也有现成的geo配置。
算法层选了协同过滤而非基于内容的推荐。核心理由是冷启动后的个性化需求:用户对店铺的历史评分数据天然蕴含兴趣偏好,而店铺本身的文本属性(菜系、地址)很难直接表达"哪家店更适合某个具体的人"。协同过滤捕捉的是群体行为的相似性——和你口味相近的人喜欢什么店,这些店就是你的候选推荐,这比只看店铺类别要聪明。
框架层用Django也不是拍脑袋。Django自带Admin后台、ORM、认证体系,我不用为管理系统单独开发页面,后台直接能录入店铺、维护分类,数据可视化页面可以直接套模板。相比Flask的轻量自由,Django在项目结构规范、插件丰富度上胜出,做这类需要多个App协同的系统更省事。
2. 数据采集层:用爬虫把美食数据装进自己的库
2.1 数据需求梳理与字段设计
动手写爬虫之前,先想清楚最终系统要什么数据。我的推荐系统需要三块数据:店铺基础属性、用户评分、用户行为日志。其中"用户评分"是协同过滤算法最核心的原料,我没有现成的评分数据,就在爬虫阶段抓店铺信息和公开评价,再构造初始评分矩阵。
最终字段设计如下(以MySQL为例):
| 字段名 | 类型 | 说明 | 用途 |
|---|---|---|---|
| shop_id | bigint | 店铺ID | 主键关联 |
| name | varchar | 店铺名称 | 展示 |
| category | varchar | 店铺分类(火锅/烧烤/川菜等) | 分类统计与相似度 |
| min_price / avg_price | int | 人均消费档位 | 用户筛选与分布图 |
| region | varchar | 所属商圈/区域 | 区域推荐 |
| rating | decimal | 综合评分 | 评分特征 |
| comment_cnt | int | 评论数量 | 热度指标 |
| address | varchar | 地址 | 地图可视化 |
这里有个容易被忽略的设计点:category和region一定要单独落字段,不要和店铺名混在一起。因为后面算基于物品的协同过滤时,相似度公式要靠这些离散属性构建物品特征向量;而做ECharts地理图时,又需要region做区域聚合。如果一开始全都塞进长文本字段,后面数据清洗会想骂人。
2.2 爬虫主流程:requests + XPath实战
爬虫整体流程就是三步:发请求拿HTML、XPath抽字段、写数据库。
import requests import random import time from lxml import etree from db import insert_shop def get_shop_list(url): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding html = etree.HTML(resp.text) # 以某美食平台店铺列表页为例 names = html.xpath("//div[contains(@class,'shop-name')]//text()") ratings = html.xpath("//span[contains(@class,'rating')]//text()") prices = html.xpath("//span[contains(@class,'price')]//text()") regions = html.xpath("//div[contains(@class,'tag')]//text()") # ... 其他字段类似 return zip(names, ratings, prices, regions) for page in range(1, 21): url = f"https://example-food-platform.com/shops?page={page}" data = get_shop_list(url) insert_shop(data) time.sleep(random.uniform(0.5, 1.5))写XPath时坑很多。最常见的坑是text()会返回包含空格和换行的碎片文本,需要做一次"".join(ele.split())清理。另一个坑是单页数据数量不稳定,一旦某页解析结果为空,不能直接跳过,要检查是不是页面弹窗验证或者页面结构变了。
务必记住一个原则:爬虫代码宁可写得啰嗦,也不要用正则去硬解析HTML结构。XPath里用contains(@class, 'xxx')比精确写@class='xxx'健壮得多,因为很多前端框架会生成复合class,精确匹配会栽跟头。
2.3 反爬应对和采集频率控制
美食平台普遍有反爬机制,常见的拦路虎有请求频率控制、Cookie校验、页面动态渲染。我这边实测下来的应对方案分三层:
第一层是基础伪装。每轮请求前构造随机User-Agent,利用常见的浏览器UA列表随机切换;每次请求之间加间隔,这个间隔不能用固定值,要用random.uniform(0.5, 1.5)这种随机区间,避免形成规律。第二层是Cookie处理。先手动登录一次平台拿到Cookie,保存到本地文件,爬虫启动时读进来,必要时用Session对象保持会话。第三层是错误码监测。如果连续N次请求返回异常验证页面,就自动暂停并加大等待间隔,让程序冷却一会儿,而不是硬着头皮继续跑。
说句实在话,做教学级项目不要花太多精力去绕反爬。把限速、UA随机、容错重试做好就行了。数据量有几千条足够验证推荐算法,没必要追求百万级全量数据,不然爬虫变成主业、推荐变成副业,方向就偏了。
2.4 入库要留个心眼:幂等与去重
数据入库时我吃过一个亏:第一次爬完没做去重,结果数据库里同一个店名出现了三条记录,最后算相似度时店铺ID完全错乱了。为了避免这个问题,我在店铺表加了一个shop_name + region的唯一索引,插入时用INSERT ... ON DUPLICATE KEY UPDATE做合并。现在高版本MySQL语法不太一样,你也可以先用SELECT COUNT(*)判断记录是否存在,再决定插入还是更新,数据量小的时候完全够用,逻辑也更直白。
3. 数据清洗与ECharts可视化:让数据先"开口"
3.1 脏数据是爬虫项目的常态
爬虫拿到的原始数据不会像公开数据集那样干净。我这边遇到的典型脏数据有四种:价格字段混入了"人均¥80"这样的文本前缀,需要正则提取数字;评分字段出现空格或者空值,需要统一填充为默认值或该分类均值;部分店铺被平台下线,数据字段整体为空,直接剔除;还有同一家店在不同页面重复抓取,需要靠唯一索引去重。
import re import pandas as pd df["avg_price"] = df["price_raw"].apply(lambda x: re.search(r"\d+", str(x)).group() if re.search(r"\d+", str(x)) else 0) df["avg_price"] = df["avg_price"].astype(int) df["rating"] = df["rating"].fillna(0).astype(float) df["category"] = df["category"].str.strip()清洗完再做一步标准化:对人均价格做分箱,比如分成20元以下、20-50、50-100、100以上几个档位;对评分保留一位小数。这些处理是为了后续ECharts画图更方便,不然一堆杂乱数值画出来的图没法看。
3.2 ECharts与Django的两种整合方式
ECharts本质上是一套纯前端的JavaScript图表库,它只吃JSON数据,不关心数据是来自Django还是其他后端。所以整合Django的核心问题不是"怎么让ECharts认识Django",而是"Django怎么把自己数据库里的数据变成JSON吐给前端"。
我先做了后端派的整合方式:Django视图函数从数据库查出统计数据,返回到模板,模板中把JSON塞进ECharts的option里。这种方式适合数据量小、图表固定的场景。
# views.py from django.http import JsonResponse from django.db.models import Count from shop.models import Shop def category_stats_api(request): data = ( Shop.objects .values("category") .annotate(total=Count("id")) .order_by("-total") ) return JsonResponse(list(data), safe=False)前端就是常规的Ajax拿到数据再动态更新ECharts实例:
fetch('/api/category_stats/') .then(res => res.json()) .then(data => { chart.setOption({ xAxis: { data: data.map(item => item.category) }, series: [{ data: data.map(item => item.total) }] }); });这两种方式各有各的应用场景。如果你只需要几张静态报表,后端渲染够用;如果你希望图表可以根据用户点击、筛选条件实时联动,必须用接口方式。我最终在店铺推荐项目里采用的是接口方式,因为用户评分后,分类统计图需要立刻反映新数据,这时必然要重新向后端请求。
3.3 什么样的图表对推荐系统最有解释力
你可能会想,ECharts无非画几个饼图柱状图,有什么好讲的。但做推荐系统可视化有一个核心目标:让用户理解"系统为什么推荐这家店"。单纯展示评分最高的店铺列表没有意义,所以我的仪表盘留了三张图:
一是店铺分类占比饼图,展示当前数据库里火锅、烧烤、西餐等类别的店铺数量分布,让用户明白数据库整体结构;二是人均消费分布柱状图,用于辅助解释推荐结果里的价格偏好;三是区域热度图,把店铺按商圈聚合后在地图上标记点的大小和颜色,ECharts里用scatter类型加geo底图就能实现。
这三张图有一个共同作用:把算法"看不见的偏好"变成用户"看得见的分布"。当你看到一个用户平时浏览的店铺集中在某分类,然后推荐系统从该分类里捞店铺出来,图表就可以直观佐证推荐逻辑,这在项目答辩或者给非技术人员演示时特别加分。
4. 协同过滤推荐算法:把"猜你喜欢"变成数学公式
4.1 协同过滤的核心直觉
这一节是整个项目的大脑,我会多讲点原理,因为代码只是原理的翻译。
协同过滤的基本假设很简单:如果用户A和用户B对若干店铺的评分高度相似,那么A喜欢的B没试过的店,大概率B也会喜欢。推荐系统不需要理解店铺的菜品如何、环境如何,只用用户的历史行为矩阵就能算出"哪些用户口味相近",再从相似用户的行为中挖掘候选店铺。
协同过滤通常分为基于用户(UserCF)和基于物品(ItemCF)。UserCF适合用户数相对较少、社交属性强的场景;ItemCF更适合用户多、店铺多的电商场景,因为它可以预计算物品相似度矩阵,线上响应快。我的项目里用户是注册会员,总量不大,所以主推荐策略用UserCF,但店铺详情页的"相似店铺"模块用了ItemCF。
4.2 构建用户-物品评分矩阵
因为起初没有真实用户评分,我设计了一个简单评分策略:爬虫抓到的店铺综合评分直接作为一个"大众匿名用户"的评分,注册用户登录后可以对店铺打分。这样,评分矩阵的行是用户ID,列是店铺ID,值为用户对店铺的打分(0-5分,没打过分的填0或空)。
import numpy as np # 假设从数据库读到 user_id, shop_id, score matrix = np.zeros((len(users), len(shops))) user_idx = {u: i for i, u in enumerate(users)} shop_idx = {s: i for i, s in enumerate(shops)} for user, shop, score in ratings: matrix[user_idx[user]][shop_idx[shop]] = score这里要特别小心稀疏问题。一旦用户打分记录很少,矩阵里绝大多数位置都是0,这会导致相似度计算失真。常见的做法是只保留存在评分的用户对,或者在算相似度时只取并集非空的店铺来做向量。新手最容易踩的坑就是把全0向量也拿去算余弦相似度,结果分子分母都为0,直接NaN爆炸。
4.3 相似度计算与评分预测公式
相似度的标准算法是余弦相似度或皮尔逊相关系数。余弦相似度只看方向不看模长,适合评分尺度统一的情况;皮尔逊相关系数会先做均值中心化,能消除用户打分习惯的差异,比如一个用户严格只给3分,另一个用户习惯给4分,皮尔逊比余弦更能反映真实的"相对偏好"。
我这里是这么实现的:
def cos_sim(v1, v2): # 取两者共同评分的店铺下标 mask = (v1 > 0) & (v2 > 0) if mask.sum() == 0: return 0 a, b = v1[mask], v2[mask] return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)) def pearson(v1, v2): mask = (v1 > 0) & (v2 > 0) if mask.sum() == 0: return 0 a, b = v1[mask], v2[mask] if a.std() == 0 or b.std() == 0: return 0 a_mean = a.mean() b_mean = b.mean() return np.dot(a - a_mean, b - b_mean) / (np.linalg.norm(a - a_mean) * np.linalg.norm(b - b_mean))拿到用户相似度矩阵后,预测用户u对没打过分的店铺i的评分为:
pred(u, i) = ( Σ v∈相似用户N(u) sim(u, v) * rating(v, i) ) / ( Σ v∈N(u) sim(u, v) )注意分母不能省,它是归一化因子,确保不同相似用户数量下预测值有可比性。另外,为了避免推荐结果被打折,通常只取topN个相似用户参与预测,而不是全部用户。
4.4 冷启动、热门兜底与矩阵优化
冷启动是在线推荐绕不开的问题。新用户没有打分记录,协同过滤矩阵里全是0,公式算不出差分。我的处理方式是:当用户评分少于5条时,直接退回"热门店铺推荐",也就是按评论数和评分综合排序返回店铺列表。这在工程上是一个很重要的兜底策略,它保证了系统任何时候都能给出响应,不至于接口空转。
矩阵计算的性能也要提前规划。用户几万人、店铺几千家的规模,相似度矩阵是几万乘几万,全量计算会很慢。我的优化方式是离线计算:推荐结果不需要实时算,每天定时把相似度矩阵算好写到缓存里,用户在线请求时直接读缓存。如果你的环境没有Redis,直接用Django的cache.set()也能达到效果。
5. Django框架下的店铺推荐完整实现
5.1 Django模型设计
Django的Models是整个项目的钢筋骨架。我建了三个核心模型:店铺表Shop、用户表Profile、评分表Rating,评分表承担连接用户和店铺的桥梁作用。
from django.db import models from django.contrib.auth.models import User class Shop(models.Model): name = models.CharField(max_length=128, unique=True) category = models.CharField(max_length=64, db_index=True) region = models.CharField(max_length=64, db_index=True) avg_price = models.IntegerField(default=0) rating = models.FloatField(default=0.0) comment_cnt = models.IntegerField(default=0) address = models.CharField(max_length=256, blank=True) class Rating(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) shop = models.ForeignKey(Shop, on_delete=models.CASCADE) score = models.IntegerField(choices=[(i, i) for i in range(1, 6)]) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ("user", "shop")unique_together这个约束非常重要,它从数据库层面保证同一用户不能对同一店铺重复评分,不然评分矩阵去重逻辑会变得非常恶心。
5.2 推荐接口与算法封装
Django的视图层我拆成了两层:第一层是数据接口,返回JSON;第二层是页面渲染,返回HTML模板。这样前端既能渲染页面,又能通过Ajax局部刷新评分和推荐结果。
# recommender.py 算法模块 def recommend_for_user(user_id, topn=10): ratings = load all ratings from DB matrix = build matrix if user has not enough ratings: return hot shops sim = compute user sim matrix candidate scores = predict scores for unseen shops return sorted candidates topn视图层只需要调用算法模块,再把返回的店铺ID列表用ORM查出来,组装成JSON。
# views.py @login_required def recommend_api(request, topn=10): user = request.user shop_ids = recommender.recommend_for_user(user.id, topn) shops = Shop.objects.filter(id__in=shop_ids) payload = [ {"id": s.id, "name": s.name, "category": s.category, "avg_price": s.avg_price, "rating": s.rating} for s in shops ] return JsonResponse({"code": 0, "data": payload})这套封装的好处是:算法层完全不依赖Django的request对象,你可以单独写单元测试喂假数据;视图层的切片逻辑又很薄,后期即使把算法换成LFM或者DeepFM,视图层完全不用改。
5.3 店铺详情页的"相似店铺"推荐
店铺详情页推荐我用的是基于物品的协同过滤。做法是给每个店铺构造一个特征向量,特征包括分类、区域、评分档位、人均价格档位,然后通过余弦相似度找到最相似的店铺。
def similar_shops(shop, topn=5): features = build_feature_vector(shop) scores = [] all_shops = Shop.objects.all() for s in all_shops: if s.id == shop.id: continue scores.append((features.cosine(similarity), s)) scores.sort(reverse=True) return scores[:topn]注意一个实现细节:如果只想按分类找到相似店铺,其实用Django一条Shop.objects.filter(category=shop.category).exclude(id=shop.id)[:5]就搞定了,但效果也太粗糙。我引入价格、评分维度后,相似度结果明显合理了,比如"同分类但价格差距大的商家"不会被误判为相似,实际展示效果要好得多。
5.4 前端页面与ECharts联动展示
店铺推荐页面我搭了三个区块:顶部是ECharts统计图(分类占比、价格分布、区域分布)占据半屏,下面左侧写推荐店铺卡片流,右侧是用户评分历史。用户在推荐卡上点了评分后,前端先调用评分接口写入数据库,再主动刷新底部的"我的口味相似度分布"图表,形成反馈闭环。
这样一个交互闭环的价值是:你的推荐系统不再是一次性给结果,而是可以根据用户行为不断调整。对于项目展示来说,"我打分之后下一次推荐就变了"这种动态效果,远比一堆静态图表有说服力。
6. 实操中遇到的坑与排查记录
6.1 XPath匹配不到内容的排查思路
爬虫阶段遇到最多的问题是XPath写好了却匹配不到元素。我第一次跑的时候,html.xpath("//div[contains(@class,'shop')]")返回空列表,排查了很久才发现是页面用了异步加载,HTML源码里根本没有店铺数据。解决办法是先用Selenium或抓包工具看真实请求路径,直接请求后端数据接口,而不是解析渲染后的HTML。建议所有爬虫项目开工前先花半小时搞清楚目标数据是静态渲染还是动态加载,这个判断能省你一天时间。
6.2 评分矩阵稀疏和全零向量问题
协同过滤的预测效果在很大程度上取决于评分数据的覆盖度。我早期造数据时让用户只对少数店铺打分,导致很多用户对之间的公共评分数几乎为0,余弦相似度算出来全是0,推荐列表和冷启动没有区别。排查思路是这样:计算相似度时如果公共评分数量小于阈值,比如小于3,就直接把相似度置为0;同时对活跃度太低的用户给一个冷启动标签,让推荐走热门兜底逻辑。这样虽然看似丢了一些数据,但实际推荐体验提升是明显的。
6.3 Django接口响应慢的优化
接口慢主要发生在两个地方:一是每次请求都重新读全量评分表构建矩阵,二是每次请求都重新算相似度。我把这两个操作做了缓存优化,评分矩阵读完后塞进cache.set("rating_matrix", matrix, 600),10分钟内不用反复重建;相似度矩阵同理,设定在每日凌晨批量重算,在线请求只读缓存。实测接口耗时从800ms降到了50ms。另外,ECharts图表的数据接口也要做缓存,图表数据不需要实时更新到秒级,设置5分钟缓存足够。
6.4 页面数据串行化和中文乱码
Django返回JsonResponse时,如果包含了datetime或者Decimal类型,JSON序列化会直接报错。我的处理方案是给JsonResponse传一个自定义encoder,或者手动把日期转成字符串、Decimal转成float再返回。中文乱码问题则要注意MySQL字符集必须为utf8mb4,Django数据库配置中也要写清OPTIONS的字符集,不然爬虫进去的菜品名称显示成问号,排查起来费劲。
7. 最后再唠叨几句
做完这个项目,我最大的体会是:推荐系统的难点从来不在算法本身,而在数据和工程的两头。数据那头,你花在洗数据、处理反爬、规划表结构上的时间,大概率比写协同过滤代码的时间长;工程那头,Django把算法端出去、用ECharts把结果画出来、用缓存把性能扛住,每一步都需要具体落地。算法只要一篇论文加几十行代码就能看懂,但真正让推荐系统"能跑、好看、可用"的,恰恰是这些看起来琐碎的工程细节。
如果你打算在这个项目上继续扩展,我建议三个方向:一是把用户隐式行为(浏览、点击、收藏)加进去,构造隐式反馈矩阵,协同过滤的效果会更细腻;二是引入基于内容的推荐,用店铺菜品名做文本向量,可以解决新店铺的冷启动;三是把推荐结果加上解释,比如"和你口味相似的小明去过这家店",推荐的可信度会高一大截。我自己在项目里只做到了前两步,最后一步的推荐解释还在改,等下一版完善了再单独写一篇聊聊。