去年有个计算机专业的同学来找我聊毕业设计选题,他最初的想法很直接:用Python做一个能预测农产品价格、又能分析蔬菜销量的可视化系统,网页上能查数据、能看图就行。聊到一半我就意识到,这题目听起来是一个系统,做起来其实是两件事——价格预测本质上是一个时序回归问题,销量分析则是一个多维聚合加可视化的问题。把这两个问题塞进同一个Django项目里,既要管好数据,又要管好模型,还得让页面交互不拖后腿,这恰恰是这类"基于Python+Django的农产品价格预测与蔬菜销量分析可视化系统"最值得展开讲的地方。
这篇文章我就以这个项目为例,把从立项到交付的全过程拆开讲一遍,包括为什么选Django、数据清洗怎么入手、预测算法怎么选、可视化接口怎么设计,以及我实际测试中返工最多的一批问题。如果你想做类似的毕业设计,或者想在公司里快速搭一套小型的农业数据分析系统,这篇内容可以直接当参考路线图用。
1. 为什么要把"价格预测"和"销量分析"塞进同一个Django系统
1.1 一个看起来像"一个项目",实际上是"两个系统"的需求
最初接到这个需求时,我第一反应是:这题目不能当单一功能做。价格预测和销量分析对数据的要求、算法思路、展示方式都不一样。
价格预测的核心是"用已知的历史价格推算未来某一天的价格"。它需要的是一个连续的时间序列,包含日期、品种名称、价格等字段。数据粒度越细、时间跨度越长,预测效果才有可能越好。任务本质是回归问题,输出的是一条趋势线或一个具体数值。
销量分析的核心则是"从大量成交记录里找到规律"——比如哪个蔬菜卖得最多、哪个季节销量最高、价格和销量之间有没有关联。它需要的是足够多的业务维度字段:日期、品种、市场、销量、价格、单位。任务本质是统计聚合,输出的是排行榜、趋势图、占比图。
这两个任务放进同一个项目里,最容易犯的错误是共用一张"大宽表"硬扛所有功能。实际开发时应该把数据分层:原始表负责存储,视图或接口负责聚合,模型文件独立于业务代码存放。后面的章节我会具体讲表结构怎么设计。
1.2 为什么选Python+Django,而不是SpringBoot或纯Flask
很多同学会用"大家都选这个"来定技术栈,这不对。这个项目选Django,有非常具体的理由。
第一,Django自带ORM,把CSV或Excel里的农产品数据导入MySQL时非常方便。用原生SQL当然也能做,但毕业设计阶段往往要把精力留给算法和可视化,ORM可以省出一倍的时间。
第二,Django自带的Admin后台可以直接用来做数据管理入口。农产品价格数据的维护,很多时候需要手动补录接口数据缺失的几天,如果专门开发一个数据管理页面,工作量大还不一定好看。直接用Admin,配置几行代码就能得到增删改查界面,对于"系统需要有后台管理功能"这种验收要求几乎是白送的功能。
第三,Python生态在做数据分析时无可替代。Pandas处理数据清洗,Scikit-learn训练回归模型,Joblib保存模型文件,这套组合在Django里可以无缝使用。换成Java系技术栈,也不是不行,但代码量和学习成本会明显增加。
那为什么不选Flask?Flask够轻,写个Demo确实很快,但如果涉及用户登录、后台管理、多个业务模块,Flask要自己去拼中间件、表单校验、数据库迁移工具。Django的"全家桶"模式在这种带完整业务链路的项目里更稳。
1.3 系统的标准功能地图:用户端做什么,管理端做什么
拿到题目后,我先画了一张功能清单。这张清单在后面写文档、排开发顺序、给系统做测试时都起了很大作用。
- 数据管理模块:按日期、蔬菜品种、批发市场维护价格和销量数据;支持Excel导入;支持数据回滚修正。
- 销量分析模块:按日/周/月维度展示销量趋势;按单品维度展示销量排行;价格-销量关系散点分析。
- 价格预测模块:选择蔬菜品种、选择预测天数,系统给出未来价格曲线和历史拟合曲线。
- 可视化大屏模块:整体销售概况、品种Top10、价格波动热力图、预测结果预览,面向展示场景。
- 用户登录与权限模块:普通用户看分析结果,管理员有权修改数据和触发重训练。
- 系统管理模块:模型状态查看、数据量统计、预测任务记录。
当时按这个清单排开发顺序,数据管理放第一位,销量分析第二位,价格预测第三位,可视化大屏收尾。为什么这样排?因为预测和分析都依赖历史数据,数据模块不完成,后面全是空中楼阁。
2. 数据是预测和分析的地基:清洗、口径、特征工程
2.1 原始数据长什么样,第一步怎么处理
我这次拿到的原始数据是一批批发市场的蔬菜日交易记录,覆盖了大约22个月的连续数据。字段包括:记录日期、蔬菜名称、所属品类、规格、单位、当日最高价、当日最低价、交易量。看起来字段挺全,真跑起来才发现一堆坑。
最常见的坑是以下三类:
- 单位不统一:有些记录按公斤,有些按市斤,还有少数按"件"计,需要在清洗阶段全部换算成"公斤"。
- 日期缺失:周末和节假日部分市场没有开市,数据文件里直接缺了这些日期。如果不处理,时间序列在画图时会断。
- 名称不统一:"西红柿"和"番茄"同时存在,"土豆"和"马铃薯"也同时存在。不合并同类项,做单品排行时就会出现一个品种裂成两条线。
当时我用Pandas做了三步清洗。第一步是把读入的数据统一口径,按列映射改成统一字段名和单位;第二步是把品种名称做归一化,比如把"西红柿"改成"番茄";第三步是重采样,把按天存储的数据按需要补成连续日期序列。这里需要注意,清洗之后的干净表不能覆盖原始表,建议保留一份原始备份,否则后面想追溯数据来源都没法做。
2.2 价格序列的特征工程:预测的不是"明天",而是"规律"
很多人收到题目之后,第一反应是拿全部历史数据扔给随机森林,训练一个模型,然后等着输出"明天黄瓜多少钱"。这种做法往往效果不好,原因是缺少特征工程。
对于农产品价格预测,我常用的特征组合包括这几类:
- 日期类特征:星期几、是否周末、月份。蔬菜价格有明显的周内波动,周末需求高价格往往走高。
- 滞后特征:昨日价格、前3日平均价、前7日平均价。这些特征通过Pandas的shift函数构造。
- 近期波动特征:近5日价格标准差。波动剧烈时,预测模型应该更保守。
- 外部代理特征:同品类其他蔬菜当日的平均价格。如果整个叶菜类都在涨价,单品的涨价概率也高。
用代码实现大概是这样的:
import pandas as pd df = df.sort_values(['product_name', 'record_date']).reset_index(drop=True) df['price_lag1'] = df.groupby('product_name')['avg_price'].shift(1) df['price_lag3_mean'] = df.groupby('product_name')['avg_price'].transform(lambda x: x.rolling(3, min_periods=1).mean().shift(1)) df['price_lag7_mean'] = df.groupby('product_name')['avg_price'].transform(lambda x: x.rolling(7, min_periods=1).mean().shift(1)) df['weekend'] = df['record_date'].dt.weekday >= 5 df['month'] = df['record_date'].dt.month然后剔除缺失滞后特征的前几天数据,才能进入模型训练。这个细节特别容易被忽略,一旦第一行数据带着NaN进入训练,整个模型的训练过程就会出现奇异值。
2.3 销量分析的口径选择:按天还是按周,金额还是重量
销量分析比价格分析更容易踩"口径不一致"的坑。比如一个蔬菜市场的交易量,是按"公斤"记录的;但用户在页面上看"销量"时,通常会下意识理解为"卖了多少钱"。如果系统一会儿展示重量,一会儿展示金额,又不标注清楚,答辩时很容易被老师问住。
我当时定的口径规则是:页面默认展示按公斤统计的交易量,作为"销量";金额单独作为"销售额"字段,不混用。趋势图同时展示两条曲线,由用户通过按钮切换。这样做的好处是,后续做价格-销量关系分析时,用"重量"做横轴、"价格"做纵轴,语义清晰不打架。
销量分析的维度也做了取舍。最基础的维度是"日期×品种",更高阶的维度是"品类×时段"。我建议毕业设计至少做到两层:第一层是总体销量趋势,解决"市场整体旺不旺";第二层是单品销量排行,解决"哪个菜好卖";如果有余力,再做价格和销量的交叉分析,画出散点图看相关性。
3. 算法选型和模型训练:不要一上来就上LSTM
3.1 线性回归、随机森林、LSTM,我为什么按这个优先级选
做预测算法选型时,最容易掉的坑就是"哪个新用哪个"。大家看到"价格预测"四个字,第一反应是深度学习,第二反应是LSTM,但实际数据量只有几百到几千条时,深度模型很容易过拟合。
我实测过三种方案:
| 算法 | 优点 | 缺点 | 适用情况 |
|---|---|---|---|
| 线性回归 | 训练快、可解释性好,答辩时容易讲清楚 | 对非线性趋势把握差 | 数据量少、作为基线模型 |
| 随机森林/梯度提升树 | 能拟合非线性关系,对特征重要性有输出 | 对趋势外推能力弱,超出训练范围容易失效 | 中等数据量、特征工程做得比较全时 |
| LSTM | 理论上能抓长期依赖,听起来高大上 | 数据量需要很大,调参复杂,训练周期长 | 至少需要几年日度数据,且不急于演示 |
最终我采用的是"线性回归做基线+随机森林做主模型"的组合。为什么保留线性回归?因为毕业设计需要解释"模型为什么有效"。线性回归可以给出每个特征的系数,比如"昨日价格每上涨1元,明日价格预期上涨0.6元",这种话术在答辩现场比"这是一个黑盒模型"有说服力得多。随机森林则用来保证预测精度,两者结果同时展示,形成对比。
我在系统里做了一个模型切换下拉框,用户可以在"线性回归预测"和"随机森林预测"之间切换,对比两种结果。这个功能在答辩时非常给力,因为它直观展示了对同一问题的不同建模思路。
3.2 训练脚本怎么组织:训练是训练,Web是Web
一开始我把训练代码直接写在Django视图里,请求一次训练一次,结果页面转圈半天,还经常报内存不足。后来改成独立训练脚本,训练完把模型用Joblib保存成.pkl文件,Django启动时加载到内存,预测接口只做推理。
训练脚本的大致流程如下:
from joblib import dump from sklearn.ensemble import RandomForestRegressor from sklearn.linear_model import LinearRegression X = feature_df.drop(columns=['target_price']) y = feature_df['target_price'] # 随机森林 rf_model = RandomForestRegressor(n_estimators=200, max_depth=8, random_state=42) rf_model.fit(X, y) dump(rf_model, 'models/rf_price_model.pkl') # 线性回归 lr_model = LinearRegression() lr_model.fit(X, y) dump(lr_model, 'models/lr_price_model.pkl')模型训练完成之后,预测接口做的事情就简单了:接收前端传来的品种和日期参数,构造特征向量,调用pkl模型预测,把结果返回。这里的重点是,特征构造的代码要在训练和预测两处保持一致,否则训练时用7个特征,预测时传入8个特征,模型直接报错。
3.3 评价指标怎么选:MAPE比准确率更适合价格预测
价格预测很少用"准确率"这种分类指标,因为它本质是回归问题,预测值和真实值之间是连续距离。毕业设计里推荐同时给出三个指标:
- MAE(平均绝对误差):预测价格与实际价格平均相差多少钱。
- RMSE(均方根误差):对大误差更敏感,能体现模型的稳定性。
- MAPE(平均绝对百分比误差):反映预测偏差的百分比,比如5%意味着平均偏差5%。
答辩时有人问"你预测得准不准",不要回答"准确率90%",这是外行话。应该说"在测试集上,黄瓜未来一天价格预测的MAPE约为6.8%,平均绝对误差为0.18元/公斤"。这句话信息量足够,而且听上去很专业。
训练好模型之后,我把所有品种的评估指标汇总成一张表格存在数据库里,页面上有一个"模型体检"的按钮,直接显示各品种的MAE、RMSE、MAPE。这也是后期测试和文档里最能拿得出手的素材。
4. 可视化系统怎么搭才能拿得出手
4.1 前后端交互架构:后端只传数据,前端负责渲染
可视化模块我选的是ECharts,前后端分离思路。Django后端只输出JSON数据,前端通过Ajax获取数据后渲染图表。
接口设计遵循一个原则:一个接口只做一件事。比如:
/api/v1/overview返回整体销售额、总销量、活跃品种数。/api/v1/sales_trend?period=week返回按周聚合的销量趋势。/api/v1/product_rank?top=10返回单品销量排行。/api/v1/predict?product=potato&days=7返回未来7天价格预测结果。/api/v1/price_volume?product=tomato返回价格与销量的散点数据。
每个接口的返回结构最好统一成下面这种格式:
{ "code": 0, "message": "success", "data": { "labels": ["周一", "周二", "周三"], "values": [120, 150, 138] } }统一响应结构能让前端代码非常干净,而且排查问题的时候直观。多个图表页面共用一个解析函数,维护成本很低。
4.2 数据库表设计不该让预测结果和原始数据抢地盘
数据库这块,我把表分成三层。第一层是原始数据表,存清洗后的日度价格和销量;第二层是聚合结果表,存按周、按月预计算好的汇总数据,供销量分析接口直接查询;第三层是预测结果表,存每次预测输出的价格曲线和评估指标。
核心模型参考如下:
from django.db import models class VegetableInfo(models.Model): name = models.CharField(max_length=50, unique=True) category = models.CharField(max_length=50) unit = models.CharField(max_length=20, default='公斤') class DailyPriceVolume(models.Model): product = models.ForeignKey(VegetableInfo, on_delete=models.CASCADE) record_date = models.DateField() avg_price = models.DecimalField(max_digits=8, decimal_places=2) volume_kg = models.DecimalField(max_digits=12, decimal_places=2) high_price = models.DecimalField(max_digits=8, decimal_places=2) low_price = models.DecimalField(max_digits=8, decimal_places=2) class Meta: unique_together = ('product', 'record_date') class PredictResult(models.Model): product = models.ForeignKey(VegetableInfo, on_delete=models.CASCADE) predict_date = models.DateField() predicted_price = models.DecimalField(max_digits=8, decimal_places=2) class ModelEvalLog(models.Model): product = models.ForeignKey(VegetableInfo, on_delete=models.CASCADE) model_type = models.CharField(max_length=20) mae = models.FloatField() rmse = models.FloatField() mape = models.FloatField() updated_at = models.DateTimeField(auto_now=True)这里有一个我反复踩过的坑:不要在销量分析时对每天的记录反复关联查询。比如要算某个月的总销量时,如果先遍历每一天的每条记录再求和,数据库会不堪重负。正确的做法是在数据导入后跑一个聚合作业,把周汇总和月汇总提前算好放在聚合结果表里。前端页面看的是结果表,不用每次都扫明细表。聚合表虽然多占一点存储,但响应速度会快一个数量级。
4.3 让图表"会说话"的三种细节点缀
可视化系统做到能"显示数据"很容易,做到能"讲清楚问题"才是加分项。我总结了三个特别实用的细节:
第一,价格趋势图必须加缩略轴和预测区间的对比背景。ECharts的dataZoom组件让用户拖拽查看任意时间段,预测部分用不同颜色或半透明区域标出,一眼就能分清"历史"和"未来"。
第二,销量排行图要支持维度切换。排行榜默认按总销量排,但用户会觉得"好的卖得多不代表赚得多",所以我在图表右上角加了重量/金额切换按钮,并标注当前口径。这个细节很小,但答辩时老师问到"你这里销量到底指什么",你就能有理有据地回应。
第三,tooltip一定要格式化。ECharts默认的提示框会弹出浮点数,比如"1234.5678公斤",非常外行。格式化之后显示"1234.57公斤",并把价格单位标注清楚,页面整体质感完全不同。
5. 实测阶段让我反复返工的问题:误差、冗余、缓存、并发
5.1 数据缺失导致预测偏差:填数不是"填上"就完了
第一版模型跑出来的效果很糟糕,黄瓜的MAPE达到了19%,这在蔬菜行业基本等于"猜的"。我查了半天才发现,问题出在一个看似不起眼的填充操作上。
当时有个品种连续缺了三天的数据,我用前向填充补齐,把前一天的数值直接复制到缺失日期。这招在处理"价格持平"时还行,但如果恰逢价格拐点,前向填充就会把拐点整体抹平。比如价格从3元跳到4元,缺失日期的填充值会是前一天的3元,模型看到的是一个延迟了一天才涨价的序列,预测自然偏保守。
后来改成"相邻日期中位数填充"加"异常值标记",在特征表里额外加一列"is_missing_flag",告诉模型"这条记录是补出来的",模型就不会把它当成真实的平滑数据了。改完以后黄瓜的MAPE降到了8%以内。所以大家做类似项目时,一定要把"数据是怎么补的"这件事记录在文档里,这也是分析报告里很出彩的一段。
5.2 Django ORM循环查询导致的接口超时,这是最常见的问题
销量分析接口初期响应时间到了4秒多,页面转圈圈特别明显。用Django Debug Toolbar一查,发现查询次数上千次。原因很典型:我先查到品种列表,然后在循环里对每个品种再查一次销量汇总。这就是经典的ORM N+1问题。
解决方式是在视图里用聚合查询,一次性返回结果:
from django.db.models import Sum sales_data = ( DailyPriceVolume.objects .filter(record_date__range=(start_date, end_date)) .values('product__name') .annotate(total_volume=Sum('volume_kg')) .order_by('-total_volume') )这样一条SQL就能完成排名统计,响应时间从4秒降到了200毫秒以内。这个案例也被我写进了项目文档的"系统优化"章节,每次答辩老师问到性能优化,我都拿这条示例讲,效果很好。
5.3 预测接口的并发卡顿问题,靠缓存和锁解决
预测接口刚上线时,一旦多个人同时点击预测,服务响应就会变得非常慢。原因是每次预测都要重新读取pkl文件、构造特征、调用模型。后来做了两项改动。
第一项是把模型加载放到Django的应用启动事件中,全局变量存模型对象,所有请求复用同一个模型实例。读取pkl文件这个动作只做一次。
第二项是对同一个品种的连续预测结果做缓存。比如某用户选了"土豆"预测未来7天价格,5分钟内另一个人再选土豆,直接返回缓存结果。我用的是Django自带的cache_page装饰器,开发环境用本地缓存,上线可以很方便改成Redis。
这两项改动之后,接口在模拟并发测试中从排队5秒变成毫秒级响应。注意,这个优化要写进文档测试报告里,因为它是"非功能需求"里很鲜活的证明。
6. 从"能跑"到"能答辩":文档、演示顺序和扩展价值
6.1 写代码之前先把文档大纲列出来,后面能省2/3的返工
很多人的习惯是先写代码后补文档,结果代码改了需求,文档还是老版本。我的习惯是在动手开发之前,先按学校毕业设计文档的通用框架列出一份大纲,把每章打算写什么关键词提前填上。等项目做完了,再往大纲里补充截图和数据,而不是对着空气写。
这套系统我采用的文档大纲是:
- 需求分析:用户角色划分、功能用例、数据流说明。
- 总体设计:技术架构、系统功能结构图。
- 详细设计:数据库表设计、核心预测算法说明、接口设计。
- 系统实现:主要页面截图、核心代码片段、功能逻辑说明。
- 系统测试:功能测试用例表、性能测试结果、模型评估表。
- 总结与展望:遇到的问题和解决过程、可扩展方向。
特别提醒,文档里的图表不用追求炫酷,但要保证编号和正文引用一致。答辩老师翻文档时经常先看目录和图表编号是否对应。
6.2 演示顺序决定答辩效果:先看全貌,再看预测,最后看对比
这段是我最想分享的实际经验。演示系统时,顺序很关键。
我的演示顺序是:先打开可视化大屏,展示总销量、单品排行、价格趋势,让老师对整个系统有直观感受;然后进入销量分析页,实际操作切换周/月维度,展示数据的响应速度;接下来到价格预测页,选择土豆,预测未来7天价格,并展示历史拟合曲线;最后切换到模型评估页面,展示MAPE、MAE指标。
这样一套流程走下来大概8分钟,逻辑是从"看得见"到"算得出",再到"算得准"。不要一上来就点预测按钮,老师还没了解系统范围,直接看一个预测值会觉得没头没尾。
另外,预测页最好提前预设一个实测过的案例。比如提前跑一次"番茄未来7天预测",把结果截图存下来。答辩现场万一网络不稳、模型加载失败,至少还有图可以讲。
6.3 后续还能怎么扩展:从学业项目变成产品雏形
这个项目做完并不是终点,它有很多自然的扩展方向。
第一是数据源自动化。目前数据依赖于每周手动导Excel,可以接爬虫从公开行情网站抓取每日价格,再把清洗流程写成定时任务,系统就变成半自动运营状态。当然爬虫部分要遵守目标网站的数据使用规则,毕设里可以只做界面演示。
第二是预测算法的升级。在数据量积累到两三年之后,可以尝试加入Prophet或SARIMA这类专做季节性的时间序列模型,和随机森林做集成。对于蔬菜这类季节性强的商品,加入节气因素是很有意义的。
第三是企业级功能扩展,比如多市场对比分析、异常价格预警、采购推荐。这些可以在文档里作为后续展望的素材,不用真的全部实现。
从我个人的实际体验看,这类Django数据分析系统的最大价值不在于算法多深,而在于把"数据处理、算法建模、系统开发、可视化表达"这一整条链路打通。你做完这个项目,相当于把数据分析岗和初级后端开发岗的常见工作内容都摸了一遍,简历上写起来也扎实。最后再分享一个小经验:做完系统后,一定要把每个品种的预测误差留底记录,它既是调试模型的依据,也是答辩时"以数据说话"的证据。