做毕设选题目的时候,很多人习惯先想“我要用什么框架、什么新技术”,恨不得把热门前沿的技术名词全塞进题目里。结果往往是项目介绍写得天花乱坠,真到开发阶段才发现数据没有、逻辑混乱、页面丑得自己都不想打开。我当时做这个美食数据可视化平台,思路正好反过来:先定“美食数据”这个领域,再倒推技术链路上的每一个环节该怎么选、怎么落地。用Django做Web框架、Scrapy搭爬虫、ECharts做可视化、再叠一层机器学习的预测能力——这套组合不是为了炫技,而是刚好覆盖了从数据采集、数据管理到数据分析和应用展示的完整闭环。
这篇文章我会把整个项目从设计到落地的过程完整拆开讲,包括每个模块的选型理由、表结构怎么定、爬虫的Item和Pipeline怎么规划、可视化大屏的数据接口怎么设计、机器学习模型怎么训练完再塞回Django里调用,最后再聊聊实际开发中踩过的坑。不管你是正准备做毕设,还是想自己搭一个数据采集分析的小项目,这条路子都能直接参考。
1. 一个完整的美食数据平台,到底应该长什么样
很多人一听到“美食数据可视化平台”,第一反应是先画一堆页面草图,或者先研究某款图表组件库的配置项。但真正动手之前,最重要的事情是把整个平台的功能边界划清楚。我的做法是先列出“这个平台到底要让谁用、解决什么问题”,再从上到下定义模块。
我确定的答案是:这是一个面向普通用户和管理员两类角色的Web平台。普通用户打开后,能看到当前采集到多少家餐厅、热门菜系分布、人均价格区间、评分排行榜、热门区域分布等分析结果,还能输入一些条件预测某家店的评分区间。管理员则通过后台管理爬取到的数据,可以做增删改查、修复明显错误的数据记录。
对应的功能模块只有四个,没有更多:
- 数据采集模块:通过Scrapy爬虫抓取美食相关的公开数据(比如餐厅名称、所在区域、菜系分类、人均价格、评分、评论数量、营业时间),落库备用。
- 数据管理模块:Django后端负责数据的持久化、模型的ORM操作、管理后台的定制。
- 数据分析与可视化模块:基于已有数据做统计分析,并通过可视化大屏展示Top榜单、菜系占比、价格分布、区域热度等信息。
- 智能预测/推荐模块:用机器学习算法训练评分预测模型,并在Django中封装成可调用的API。
要说这套项目最大的价值,不是哪个模块技术复杂,而是它把一整个数据项目的链路串了起来。很多毕设卡在“数据从哪来”这一步,最后只好用造假数据硬凑。而用Scrapy做真实采集,数据天然是有业务含义的,后续做可视化也好、训练模型也好,都不会有一种“空中楼阁”的感觉。
在开发顺序上,我的建议是先做数据采集,再做数据入库和Django模型,然后做可视化和机器学习。因为后两个模块完全依赖前面的数据质量和数量,如果一开始就把时间花在调整图表样式上,后面想换数据结构,你会崩溃到怀疑人生。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术栈选型的底层逻辑,以及为什么不盲目追新
移动互联网时代搞项目,最大的诱惑就是“哪个新用哪个”。但实际上一个毕业设计或者中小型项目,追求的是每个环节都有成熟的、够用的、自己hold得住的方案。我最终选型是:Django + Scrapy + MySQL + ECharts + scikit-learn。这一套没有一个是“最潮”的,但组合在一起,每一环都是最优解。
2.1 Django:自带后台,开发效率直接拉满
为什么用Django而不是Flask、Spring Boot甚至纯前端方案?核心原因是Django自带的东西太全了。ORM不用说了,Django的ORM在快速建模、迁移、查询方面非常省心;Admin后台是自带的管理界面,稍微定制一下就能给管理员用,省得再单独做一个后台管理端;模板系统和表单验证也能少写一堆重复代码。
Flask确实更轻,但对于一个需要大量数据模型、后台管理、API接口、页面渲染的项目来说,Flask把太多选择权交给了你,反而容易陷入“先要自己选个数据库连接池、再选个后台模板”这种无底洞。Spring Boot则对于一个以Python爬虫为主要数据源的项目来说切换成本高了点,语言栈也不够统一。
2.2 Scrapy:为什么不是requests + BeautifulSoup
对比用requests + BeautifulSoup手动写采集代码,Scrapy最大的优势是异步调度和组件化设计。Scrapy底层用Twisted做异步I/O,爬取速度非常可观;同时它天然分好了Spider(解析逻辑)、Item(数据模型)、Pipeline(处理管道)、Middleware(中间件)这些模块,代码结构清晰,后续扩展分布式采集也容易。
另外,Scrapy自带了去重过滤、请求调度、错误重试、日志统计这些功能,这些如果自己用requests手写,最少也要小几百行代码,写法不标准还会出各种bug。既然Scrapy已经帮你把通用能力做完了,为什么不用?
用requests直接写也不是不行,只是当目标页面数量多了之后,你就要先解决很多基础问题,反而偏离了“做数据分析平台”这个核心目标。
2.3 ECharts:可视化界的稳定输出
可视化方案我当时在ECharts和Chart.js之间犹豫过,最后还是选了ECharts。理由有两个:第一,ECharts对中文社区和地图支持非常友好,特别是要做全国/城市区域分布图的时候,地图数据基本零成本;第二,ECharts的交互能力更强,像Tooltip联动、数据缩放、图例筛选这些功能配置起来很成熟。
如果你做的是工业级的监控大屏,也可以考虑Grafana或者Superset,但它们更偏向于直接对接数据源做展示,扩展自定义业务逻辑反而不如ECharts灵活。ECharts本质上是个纯前端图表库,后端只要提供标准JSON数据就能渲染,正好适合Django做接口服务。
2.4 MySQL:一种稳定且不添乱的存储方案
数据库选MySQL,主要看中它生态成熟、运维资料多,出现任何问题都能搜到解决方案。SQLite虽然零配置,但数据量一旦上万,并行读写时的性能会比较吃力;PostgreSQL更好,但对很多人来说没必要在毕设阶段引入学习成本。MySQL的JSON支持虽然一般,但这个项目的数据结构并不复杂,关系型表设计完全够用。
2.5 整套选型的对比结论
| 模块 | 选用方案 | 替代方案 | 选型理由 |
|---|---|---|---|
| Web框架 | Django | Flask, Spring Boot | 自带ORM/Admin/模板,开发效率高 |
| 爬虫框架 | Scrapy | requests + BS4 | 异步调度、组件清晰、易扩展 |
| 存储 | MySQL | SQLite, PostgreSQL | 成熟稳定,中文资料多 |
| 可视化 | ECharts | Chart.js, Superset | 图表丰富、地图友好、交互好 |
| 机器学习 | scikit-learn | TensorFlow, PyTorch | 经典模型够用,学习成本低 |
这套组合的实际开发体验是:每个环节都有清晰的最佳实践,不管是遇到性能问题还是功能扩展,网上都有大量参考案例,不会让你卡死在某个冷门框架的坑里。
3. 数据侧的完整链路:Scrapy爬虫到底怎么写才不算“玩具”
如果说前面的选型是排兵布阵,那爬虫就是真正的粮草先行。这个项目里,数据侧占了至少40%的工作量。很多人把Scrapy简单理解成“写一个parse函数抽字段”,其实用Scrapy真正写一个工程化的爬虫,远不止parse那么简单。我拆开讲讲。
3.1 先规划Item结构,再动笔写爬虫
我在创建项目之后,做的第一件事不是在spiders目录里写爬虫,而是先在items.py里定义数据模型。所谓“先建模型再写代码”,避免的是后面解析字段时东拼西凑。
针对美食数据,我定义了这样的Item结构:
python复制import scrapy
class FoodItem(scrapy.Item):
shop_name = scrapy.Field() # 餐厅名称
category = scrapy.Field() # 菜系分类,如“川菜”“火锅”
region = scrapy.Field() # 所在区域,如“天河区”
address = scrapy.Field() # 详细地址
avg_price = scrapy.Field() # 人均价格
score = scrapy.Field() # 综合评分
review_count = scrapy.Field() # 评论数
business_hours = scrapy.Field() # 营业时间
source_url = scrapy.Field() # 数据来源链接,方便溯源
字段名我刻意定义成和Django模型字段一一对应的形式,这样到后面做数据导入时能省非常多事。实际抓取时如果页面里的字段比这多,我会在Pipeline里做取舍,而不是把原始数据一股脑全存下来。
3.2 Spider解析的常见问题:数据不在同一个页面
写Spider的时候,我遇到的第一个棘手问题不是反爬,而是“列表页和详情页分离”。美食类平台普遍是列表页只展示餐厅名字、评分、人均价格等概览信息,而更详细的营业时间、地址、推荐菜要逐个进入详情页才能拿到。
如果只解析列表页,数据就能用,但维度太浅;如果每个列表页条目都再发一个详情页请求,采集总量会成倍增长。我的做法是折中:列表页的字段直接解析,详情页只对“没有营业时间”或“评分低于某个阈值需要复核”的餐厅追加请求。用Scrapy的Request回调把详情页的解析逻辑串起来,每一条餐厅数据最多两次请求出结果。
python复制def parse_shop_list(self, response):
shop_nodes = response.css(".shop-item")
for node in shop_nodes:
item = FoodItem()
item["shop_name"] = node.css(".name::text").get()
item["avg_price"] = node.css(".price::text").get()
item["score"] = node.css(".score::text").get()
detail_url = node.css("a::attr(href)").get()
# 需要补充详情的才二次请求
yield scrapy.Request(
url=response.urljoin(detail_url),
callback=self.parse_shop_detail,
cb_kwargs={"item": item}
)
def parse_shop_detail(self, response, item):
item["business_hours"] = response.css(".hours::text").get()
item["address"] = response.css(".address::text").get()
yield item
这样既保证了字段完整度,又控制了请求量。核心思路是:数据有梯度,抓取也有梯度,普通信息列表页解决,深度信息详情页补充。
3.3 Pipeline里的数据清洗,比parse函数更关键
很多初学Scrapy的人只把Pipeline当成“传给数据库的最后一棒”,实际上Pipeline才是数据质量管的真正关卡。我在Pipeline里做了这几件事:
格式化处理。从页面抓出来的“人均¥80”这种字符串,直接入库的话,后面做聚合统计时会非常痛苦。我会在Pipeline里统一转成整数:
python复制def clean_price(self, raw):
if not raw:
return 0
digits = re.findall(r"\d+", raw)
return int(digits[0]) if digits else 0
空值兜底。餐厅名特别长的、没有评分的、评论数为0的,这些脏数据如果原样入库,后面分析会有一堆莫名其妙的“无值”记录。我统一把空字符串替换成“未知”,数字字段填0,保证统计数据不会因为None而报错。
去重逻辑。以餐厅名 + 区域做组合判断,重复的数据就不重复入库了。Scrapy自带的去重中间件只能避免同一个URL重复请求,并不能防止不同URL下出现同一家店的情况,所以去重必须在Pipeline里做。
还有一个经验是:爬虫跑完后导出的JSON文件不要着急删除。留着原始数据文件,万一后续数据库被模型调整搞坏了,还能重新导一次,这个习惯帮我省过两次大事。
3.4 反爬和合规的底线在哪里
Scrapy爬虫一定会遇到反爬问题,UAMiddleware、代理Middleware、Cookie处理这些网上都有成熟写法,我自己做了一个简单的User-Agent轮换和下载延迟设置:
python复制DOWNLOAD_DELAY = 1.5
RANDOMIZE_DOWNLOAD_DELAY = True
DOWNLOAD_DELAY设成1.5秒,意味着每个页面请求之间会间隔1.5秒,速度虽然慢一些,但整个采集过程非常稳。对于毕设级的数据量,这个速度完全够用。
但更重要的是底线问题:只采集公开可访问的页面信息,遵守目标网站的robots.txt约定,不绕过登录、不抓取需要复杂权限才能访问的数据。如果目标网站的详情页数据是通过前端加密接口渲染的,优先考虑换个公开数据源,而不是死磕破解加密参数。这个项目的目标是搭建完整的数据分析链路,不是表演爬虫攻防。
3.5 数据量规划:多少条数据才够分析
刚开始做分析的时候容易踩一个误区:数量越多越好。实际不是这样。我最终采集了约1.5万条餐厅数据,做可视化展示和数据训练已经完全够用。数据量太少(几百条)看不出趋势,数据量太大(几十万条)会开始面临存储、索引、聚合性能的问题,对毕设来说没必要。
等到后面你如果想扩充到百万级,再引入分布式爬虫和消息队列也不迟。
4. Django端的数据建模与查询设计:表结构怎么定,ORM怎么用才高效
爬虫采到的数据只是半成品,进入Django的数据库模型后,才变成可以分析和展示的业务数据。这一章是很多教程喜欢略过的地方,但恰恰是最影响后期开发效率的环节——表结构设计得好,后面写接口、写大屏都顺畅;表结构设计得乱,后期改一次想死一次。
4.1 模型设计:二表足够,别一上来就建八张表
我最初规划模型时想着餐厅、菜系、菜品、评论、用户行为各建一张表,后来砍到只剩两张核心表。原因是:菜品和评论的采集难度高、数据质量不稳定,不仅让爬虫的工作量翻倍,还会让分析模块的主题失焦。毕设项目的核心是“展示餐厅数据的分析结果”,不是做大众点评的完整复刻。
最终我做的是:
python复制from django.db import models
class Category(models.Model):
name = models.CharField(max_length=50, unique=True)
sort = models.IntegerField(default=0)
class Meta:
ordering = ["sort", "id"]
class Shop(models.Model):
name = models.CharField(max_length=200)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True)
region = models.CharField(max_length=100, db_index=True)
address = models.CharField(max_length=300, blank=True)
avg_price = models.IntegerField(default=0)
score = models.FloatField(default=0)
review_count = models.IntegerField(default=0)
business_hours = models.CharField(max_length=100, blank=True)
source_url = models.URLField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["region", "category"]),
models.Index(fields=["score"]),
models.Index(fields=["avg_price"]),
]
这里有几个细节值得展开:
为什么用SET_NULL而不是CASCADE。如果某个菜系分类被删除,餐厅不应该一起被删掉,人为删分类可能导致一大片数据丢失。设置SET_NULL后,餐厅记录会保留,只是分类字段变成空,数据分析时按“未知”处理就行。
为什么region和score要建索引。可视化大屏最常做的操作就是按区域分组统计、按评分排序,没有索引的情况下,Django ORM每次查询都是全表扫描。1.5万条数据可能还感觉不到,到10万条以上区别就非常明显了。
为什么不用TextField存营业时间。营业时间看起来像长文本,但它本质上是展示型字段,用CharField就够了。TextField只会在一些需要全文检索的场景下使用,其他情况还容易因为默认表现带来奇怪的查询问题。
4.2 数据导入:Scrapy和Django之间该怎么衔接
我最终选择的方案是:Scrapy直接导出JSON文件,Django写一个自定义management command把JSON导进数据库。很多教程喜欢在Scrapy的Pipeline里直接连接Django的ORM,我试用过一次后放弃了,原因有两个:第一是Scrapy的异步环境和Django ORM有event loop的潜在冲突,时不时出现诡异连接问题;第二是两者直接耦合后,每跑一次爬虫就要保证Django环境配置完好,调试复杂。
干脆各管各的,Scrapy爬完导出JSON,Django端用命令行导入:
bash复制python manage.py import_food_data data/food_data.json
导入命令的核心逻辑很简单:
python复制import json
from django.core.management.base import BaseCommand
from food.models import Category, Shop
class Command(BaseCommand):
help = "导入Scrapy导出的JSON数据"
def add_arguments(self, parser):
parser.add_argument("json_file", type=str)
def handle(self, *args, **options):
with open(options["json_file"], "r", encoding="utf-8") as f:
items = json.load(f)
for idx, item in enumerate(items):
category, _ = Category.objects.get_or_create(name=item.get("category") or "未知")
Shop.objects.update_or_create(
name=item["shop_name"],
region=item.get("region") or "未知",
defaults={
"category": category,
"address": item.get("address", ""),
"avg_price": int(item.get("avg_price") or 0),
"score": float(item.get("score") or 0),
"review_count": int(item.get("review_count") or 0),
"business_hours": item.get("business_hours", ""),
"source_url": item.get("source_url", ""),
}
)
self.stdout.write(self.style.SUCCESS(f"导入完成,共处理{len(items)}条数据"))
这个设计的好处是,数据导入做成幂等操作,重复执行不会产生重复数据,便于反复调试。数据导入失败也不会影响爬虫逻辑,两边的开发节奏互不干扰。
4.3 管理后台:Django Admin的“够用主义”
Django自带的Admin后台稍加定制,就能满足管理员日常维护数据的需求。我在admin.py里做了一些针对性配置,让后台不是一堆原始字段的堆砌:
python复制from django.contrib import admin
from .models import Category, Shop
@admin.register(Shop)
class ShopAdmin(admin.ModelAdmin):
list_display = ["name", "category", "region", "avg_price", "score", "review_count"]
list_filter = ["region", "category"]
search_fields = ["name", "address"]
ordering = ["-score"]
这样管理员一进后台就能看到评分最高的餐厅列表,还能按区域、菜系筛选。Django Admin本身不需要写任何前端代码就能得到一个完整的管理界面,这也算是选型Django的红利之一。
5. 可视化大屏的落地细节:从一堆JSON变成了好看的一屏
很多人把可视化等同于“用图表组件把数据画出来”,其实可视化设计的关键不是图表API,而是“这个图表回答什么业务问题”。我做美食数据可视化大屏,先列了一堆业务问题,再针对每个问题选图表,而不是先打开ECharts案例库挑顺眼的。
5.1 大屏上放了哪些图表,以及它们回答了什么问题
我最终确定的大屏布局是四块核心内容加一个汇总数据卡片:
- Top 10餐厅评分排行榜:横向柱状图,一眼看出评分最高的餐厅。
- 菜系分类占比:环形图,看出火锅、川菜、粤菜等品类的占比。
- 人均价格区间分布:直方图,分析餐厅消费水平分布。
- 区域热度地理分布:地图热力图,展示餐厅数量最多的几个城区。
- 汇总指标卡片:总餐厅数、平均评分、平均人均价、总评论数。
每个图表背后都对应一个真实的业务问题。你如果把这些图表换成“最近一周新增数据趋势”“热门关键词词云”,也完全可以,但核心是要让看大屏的人能在10秒内获得几个有效结论。
5.2 Django怎么给ECharts提供数据
ECharts是纯前端组件,它需要的数据通常是一个接口返回的JSON。我在Django端为可视化大屏专门写了一个视图函数,统一聚合所有图表数据:
python复制from django.http import JsonResponse
from django.db.models import Count, Avg
from food.models import Shop, Category
def dashboard_data(request):
categories = Category.objects.annotate(shop_count=Count("shop")).values("name", "shop_count")
top_shops = list(Shop.objects.order_by("-score")[:10].values("name", "score", "region"))
price_stats = Shop.objects.values("avg_price").order_by("avg_price")
region_stats = Shop.objects.values("region").annotate(cnt=Count("id")).order_by("-cnt")[:20]
return JsonResponse({
"total_shops": Shop.objects.count(),
"avg_score": Shop.objects.aggregate(avg=Avg("score"))["avg"],
"categories": list(categories),
"top_shops": top_shops,
"region_stats": list(region_stats),
})
这里用了一个小技巧:把多个统计查询合并到一个接口里返回。大屏页面只需要请求一次/api/dashboard/,就能拿到所有图表的数据,避免了多个图表各自发请求导致页面加载慢的问题。
5.3 ECharts初始化与数据填充分离
ECharts的正确用法是先把图表实例和基础配置初始化好,然后用Ajax请求数据,再通过setOption填充数据。这样页面加载时图表有骨架,数据返回后内容再出来,用户体验更好。
javascript复制const chart = echarts.init(document.getElementById("categoryChart"));
chart.setOption({
series: [{ type: "pie", radius: ["40%", "70%"] }]
});
fetch("/api/dashboard/")
.then(res => res.json())
.then(data => {
chart.setOption({
series: [{ data: data.categories }]
});
});
这个模式看起来简单,但是很多人踩过的坑是:把Ajax请求放在图表初始化之前,导致图表拿到数据时实例还没创建,或者反过来,先初始化了实例又忘了配置series的类型。我的建议是:init + 初始骨架 setOption 先执行,Ajax回调用 setOption 只更新数据部分,确保数据和视图完全分离。
5.4 大屏不是报表,信息密度要控制
做可视化大屏最容易犯的错误是恨不得把所有数据全塞进一屏。我的原则是:一张大屏只回答3~4个核心问题,多余的内容宁可舍弃。比如一开始我做过“评论数Top 20餐厅”的榜单,后来发现这个信息和评分排行榜高度相关,反而干扰了重点,最终删掉了。
配色方面推荐用深色渐变背景,因为深色背景能拉高图表的对比度,大屏的观感会比浅色背景好很多。ECharts默认配色偏浅,用在浅色报表里没问题,但放到深色大屏上要把背景色、文字色都调成浅色系,这个细节很影响观感。
6. 把机器学习塞进毕设里:评分预测和菜品推荐的完整做法
机器学习是这个项目里最容易让评分老师眼前一亮的部分,也是最容易写“假大空”的部分。很多人一提到机器学习就上深度学习,好像模型越复杂越高级。实际上在这个场景里,用scikit-learn构建一个经典的回归模型,只要特征处理得当,解释清楚,效果和说服力都会更好。
6.1 这个平台上到底能预测什么
结合美食数据的特点,我选择的切入点是“餐厅评分预测”。具体来说:根据已知特征(人均价格、评论数、所在区域、菜系、营业时长等),预测一家餐厅的评分。这个预测模型可以做什么?可以在你输入拟开餐厅的基础信息时,对新店未来的评分做一个预估;也可以分析出哪些因素对评分影响最大。
另外一个可选的切入点是“菜品推荐”,但考虑到菜品数据采集难度较高,我建议把它放到扩展方向里,先专注把评分预测做好做透。
6.2 特征工程:决定模型上限的关键
特征工程比模型本身更重要,这是我在实际训练中体会最深的一点。原始数据里不能直接用“区域名称”和“菜系名称”这种字符串训练模型,要做编码处理。
最简单的处理是用pandas的get_dummies做one-hot编码,但更推荐的做法是用LabelEncoder做标签编码,因为这样在模型解释时可以保留“菜系”的原始含义。
python复制import pandas as pd
from sklearn.preprocessing import LabelEncoder
df = pd.DataFrame(list(Shop.objects.all().values(
"avg_price", "review_count", "region", "category__name", "score"
)))
df["region_encoded"] = LabelEncoder().fit_transform(df["region"])
df["category_encoded"] = LabelEncoder().fit_transform(df["category__name"])
features = df[["avg_price", "review_count", "region_encoded", "category_encoded"]]
labels = df["score"]
这里有个细节:评论数和评分之间的关系并不是线性的。有的餐厅评论数很少但评分很高,有的餐厅评论数很多但评分一般。如果直接线性回归,评论数的量纲会严重压制其他特征,所以最好是做一次标准化或者取对数变换:
python复制df["review_count_log"] = np.log1p(df["review_count"])
6.3 模型选择:先跑个线性回归当Baseline
我训练时先用线性回归当Baseline,得到R2约0.35。这个结果说明线性模型确实欠拟合,然后我再跑随机森林回归做对比,R2提升到0.68左右。作为对比:
| 模型 | R2 | MAE |
|---|---|---|
| 线性回归 | 0.35 | 0.67 |
| 随机森林回归 | 0.68 | 0.42 |
训练代码很简单:
python复制from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(features, labels, test_size=0.2, random_state=42)
model = RandomForestRegressor(n_estimators=100, max_depth=8, random_state=42)
model.fit(X_train, y_train)
选择随机森林而不是更深度的模型,原因很简单:数据量只有1万多条,深度模型的优势难以体现,反而容易过拟合;随机森林对这种中等规模表格数据非常友好,训练快、可解释性好,还能通过feature_importances_输出特征重要性。
6.4 把模型保存下来,并封装成Django接口
训练好的模型用joblib保存成文件:
python复制import joblib
joblib.dump(model, "ml_models/score_model.pkl")
joblib.dump(LabelEncoder(), "ml_models/region_encoder.pkl")
Django这边加载模型并封装成API接口。这里有个关键点:模型文件比较大,如果在每个视图请求里都重新加载一遍,性能会很差。正确的做法是在模块加载时只加载一次:
python复制import joblib
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.conf import settings
import os
_model_path = os.path.join(settings.BASE_DIR, "ml_models", "score_model.pkl")
_region_encoder_path = os.path.join(settings.BASE_DIR, "ml_models", "region_encoder.pkl")
_model = joblib.load(_model_path)
_region_encoder = joblib.load(_region_encoder_path)
@csrf_exempt
def predict_score(request):
if request.method == "POST":
import json
data = json.loads(request.body)
try:
region_encoded = _region_encoder.transform([data["region"]])[0]
category_encoded = _category_encoder.transform([data["category"]])[0]
features = [[
float(data["avg_price"]),
float(data["review_count"]),
region_encoded,
category_encoded,
]]
pred = _model.predict(features)[0]
return JsonResponse({"predicted_score": round(float(pred), 2)})
except Exception as e:
return JsonResponse({"error": str(e)}, status=400)
return JsonResponse({"error": "请使用POST请求"}, status=405)
这样前端表单填完信息,点击预测,Django返回预测评分,整个机器学习链路就真正跑通了。模型持久化+接口化,是机器学习项目从分析阶段过渡到应用阶段的关键一步,这一步做完,项目才能叫“平台”而不是“脚本”。
类似的做法在农产品销售预测、商品销量预估、房价评估这类项目中也非常常见,思路是通用的:数据采集→特征工程→模型训练→持久化→Web接口→页面展示。
7. 联调、部署与排坑实录:跑通全链路之后,我踩过的那些坑
单独每个模块跑通都不难,真正耗时间的是把整条链路串起来。我在联调和部署阶段踩了不少坑,挑几个典型的写出来,希望能帮你省下至少一周的debug时间。
7.1 编码坑:中文数据变成了乱码
第一个坑是中文编码。Scrapy导出的JSON如果直接用json.dump写文件,默认是ensure_ascii=True,中文全变成\uXXXX转义字符。虽然Django导入时能解析回来,但打开JSON文件检查数据时完全没法读,非常影响数据排查。
解决方法是导出时设置ensure_ascii=False,并且文件编码用utf-8:
python复制import json
with open("food_data.json", "w", encoding="utf-8") as f:
json.dump(all_items, f, ensure_ascii=False, indent=2)
另外,MySQL建库时也要指定utf8mb4字符集,不然后面入库带emoji或者生僻字的店名时,会直接报编码错误。
7.2 ORM查询性能坑:页面越写越慢
可视化大屏的首页接口一开始加载需要好几秒,排查后发现是ORM查询出现了N+1问题。比如遍历餐厅列表时,每取一家餐厅的分类名,都会再发一条SQL去category表查一次。
解决办法是在查询时加上select_related:
python复制Shop.objects.select_related("category").order_by("-score")[:10]
select_related会把外键关联的表用JOIN一次性查出来,而不是一条条发SQL。这个优化在数据量大时效果立竿见影。
7.3 ECharts首次加载空数据坑
大屏打开后,图表区域空白,但接口返回的数据明明是对的。检查了一圈发现是Ajax请求时,Django开发服务器的静态文件请求阻塞了接口请求。这不是代码逻辑问题,而是一个容易被忽视的开发环境问题。
解决方法是把Django的DEBUG设为False,然后配置好静态文件路径,或者用nginx把接口请求和静态文件请求分开处理。在开发阶段最简单的办法是把静态文件请求放到CDN或单独目录,总之不要让静态文件请求和API请求抢占同一个开发服务器进程。
7.4 部署上线:gunicorn + nginx是稳妥组合
如果要把项目部署到服务器上,我的推荐组合是gunicorn + nginx + MySQL,再用crontab定时执行Scrapy爬虫做增量采集。流程如下:
- 本地代码推送到服务器,创建虚拟环境,安装依赖;
- Django配置
ALLOWED_HOSTS、数据库连接等生产环境参数; - 用gunicorn启动:
bash复制gunicorn food_platform.wsgi:application -b 127.0.0.1:8000
- 用nginx做反向代理,把80端口转发到8000端口,同时处理好静态文件;
- 用crontab设置定时任务,每天凌晨爬取一次数据:
bash复制0 3 * * * cd /path/to/project && /path/to/venv/bin/scrapy crawl food_spider -O data/food_data.json && /path/to/venv/bin/python manage.py import_food_data data/food_data.json
nginx+gunicorn这个组合是Python项目的经典部署方式,配置资料非常多,照着来基本不会出大问题。如果你不想折腾服务器,用宝塔面板或者直接把Django跑在Docker容器里也是可以的,但总归要理解“反向代理+WSGI服务器”这个基本架构。
7.5 扩展方向:这套架构还能怎么进步
项目做完之后,我复盘过几个可以继续优化的方向,给未来想在这套架构上做扩展的朋友参考:
- 爬虫侧:如果数据量需要持续增长,可以把Scrapy升级成scrapy‑redis做分布式采集,把Request队列和去重集合迁移到Redis里。
- 数据侧:引入Kafka做数据缓冲,配合Spark做离线统计,这样技术栈更“大数据”,但复杂度也会提升很多,不是必选项。
- 业务侧:加入用户系统,让用户可以收藏餐厅、查看餐厅详情,相当于做一个轻量级的美食推荐社区。
- 模型侧:把评分预测升级为“用户个性化推荐”,用协同过滤或基于内容相似度的推荐算法,给不同的用户推荐不同的餐厅。
说实话,做完这个项目最大的体会是:一个系统能不能叫“大数据平台”,不在于你用了多少大数据组件,而在于数据从采集、清洗、存储、分析到应用展示的链路是否完整、是否跑得通。Django + Scrapy这套组合虽然不是最新的技术,但胜在每一环都扎实可控,能让你把有限的精力集中在真正核心的数据处理和业务逻辑上。
如果你也正在做类似的选题,我的建议是:先老老实实把数据采下来、把表结构设计好,再谈可视化多炫、模型多高级。数据不扎实,上面盖多少楼都是白搭。
