简介:本资源是一份基于Flask的农产品价格数据可视化及预测系统毕业设计论文,面向农业领域研究人员、农民、市场参与者及相关从业者,也适合作为计算机专业学生完成类似课题的参考。论文围绕农产品价格波动性与信息不对称问题,完整呈现了数据管理、数据可视化与价格预测三大功能模块的设计与实现,涉及Flask框架、Bootstrap与ECharts前端技术、线性回归模型及数据库设计等内容。资源包共1个docx文件,约905KB,为完整论文文档,包含封面、原创性声明、中英文摘要、需求分析、系统总体设计与各模块详细设计、数据库设计、功能界面实现及总结展望等章节,结构完整、逻辑清晰。目前已有180人学习下载。读者可从中获取一套可参考的系统架构方案、功能模块划分思路、数据处理流程与预测模型应用方法,对撰写论文或搭建同类Web数据分析系统具有实际借鉴价值。
1. 从一张价格曲线图说起:这套系统到底解决什么问题
做过农产品行情分析的人都有个体会:数据不是没有,而是散。产地报价在一个表格里,批发市场行情在另一个页面,气象数据又是第三个来源。等把这些东西凑齐,价格早就变了。基于 Flask 的农产品价格数据可视化及预测系统,要解决的核心就是这件事——把分散的价格数据收拢到一个 Web 界面里,既能看历史走势,又能给出短期预测,让种植户、采购商和行情分析人员在一个页面上完成判断。
这套系统的技术栈并不复杂:后端用 Flask 做路由和接口,前端用 ECharts 或 Chart.js 渲染图表,预测部分用 scikit-learn 或 statsmodels 跑时间序列模型,数据存 SQLite 或 MySQL。适合有一定 Python 基础、想做一个完整数据类 Web 项目的开发者,也适合农业信息化方向的从业者拿来改造成自己的行情工具。下面从数据建模开始,一步步拆到能跑起来的程度。
2. 数据表怎么设计:三类字段决定后面能不能预测
2.1 价格数据的字段拆解与建表语句
农产品价格数据看起来简单,但字段设计直接决定了后面能不能做时间序列分析。我一般会拆成三类字段:标识字段(产品名称、品类、产地)、时间字段(采集日期、录入时间)、数值字段(最低价、最高价、均价、成交量)。其中均价是预测的目标列,成交量和日期是特征列。
-- 农产品价格主表:每条记录代表某产品在某产地某日的价格快照 CREATE TABLE agri_price ( id INTEGER PRIMARY KEY AUTOINCREMENT, product VARCHAR(50) NOT NULL, -- 产品名称,如"红富士苹果" category VARCHAR(30) NOT NULL, -- 品类,如"水果""蔬菜" origin VARCHAR(50) NOT NULL, -- 产地标识 price_date DATE NOT NULL, -- 价格日期,预测时作为时间索引 low_price DECIMAL(8,2), -- 当日最低价 high_price DECIMAL(8,2), -- 当日最高价 avg_price DECIMAL(8,2) NOT NULL, -- 当日均价,预测目标列 volume DECIMAL(10,2) DEFAULT 0, -- 成交量,可作为外生变量 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 联合唯一索引:防止同一产品同一天重复录入 CREATE UNIQUE INDEX idx_product_date ON agri_price(product, price_date);这段建表语句里有两个地方值得注意。第一,price_date用 DATE 而不是 DATETIME,因为农产品价格通常按天采集,精确到小时没有意义,反而会让时间序列的粒度变乱。第二,avg_price设了 NOT NULL,因为它是预测的目标列,如果允许为空,后面训练模型时还得做缺失值填充,不如在入库时就卡住。volume字段给了默认值 0,是因为有些产地不公布成交量,但字段本身要保留,后面做多变量预测时可以用它当外生特征。
2.2 为什么用 SQLite 起步、什么时候换 MySQL
很多教程一上来就让你装 MySQL,但对于一个单机跑的数据可视化系统,SQLite 完全够用。它的优势是零配置、单文件、备份就是复制一个文件。Flask 配合 SQLAlchemy 操作 SQLite,代码量和 MySQL 几乎一样。
# models.py:用 SQLAlchemy 定义模型,换数据库只需改连接串 from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() class AgriPrice(db.Model): __tablename__ = 'agri_price' id = db.Column(db.Integer, primary_key=True) product = db.Column(db.String(50), nullable=False) category = db.Column(db.String(30), nullable=False) origin = db.Column(db.String(50), nullable=False) price_date = db.Column(db.Date, nullable=False) low_price = db.Column(db.Numeric(8, 2)) high_price = db.Column(db.Numeric(8, 2)) avg_price = db.Column(db.Numeric(8, 2), nullable=False) volume = db.Column(db.Numeric(10, 2), default=0) __table_args__ = ( db.UniqueConstraint('product', 'price_date', name='uix_product_date'), )什么时候该换 MySQL?当你的数据量超过百万行、或者需要多进程同时写入的时候。SQLite 的写操作是串行的,Flask 多 worker 部署时容易遇到database is locked。我一般会在数据量到五十万行左右时迁移到 MySQL,迁移成本主要是改连接串和把db.Numeric换成对应的 MySQL 类型,模型层几乎不用动。
2.3 数据导入的批量写入与去重逻辑
手工录入不现实,实际数据通常来自 CSV 或 Excel。导入时最容易翻车的地方是重复写入和日期格式不统一。
# import_data.py:批量导入 CSV,自动跳过重复记录 import pandas as pd from datetime import datetime from models import db, AgriPrice def import_csv(filepath): df = pd.read_csv(filepath, encoding='utf-8') # 统一日期格式,兼容 2024/1/5 和 2024-01-05 两种写法 df['price_date'] = pd.to_datetime(df['price_date']).dt.date # 按产品和日期去重,保留最后一条 df = df.drop_duplicates(subset=['product', 'price_date'], keep='last') existing = set( (r.product, r.price_date) for r in AgriPrice.query.with_entities( AgriPrice.product, AgriPrice.price_date).all() ) new_rows = [] for _, row in df.iterrows(): key = (row['product'], row['price_date']) if key in existing: continue # 已存在则跳过,避免唯一索引冲突 new_rows.append(AgriPrice( product=row['product'], category=row['category'], origin=row['origin'], price_date=row['price_date'], low_price=row['low_price'], high_price=row['high_price'], avg_price=row['avg_price'], volume=row.get('volume', 0) )) db.session.bulk_save_objects(new_rows) db.session.commit() return len(new_rows)这里用bulk_save_objects而不是逐条add,是因为逐条提交在几千行数据下会慢得让人怀疑人生。去重逻辑放在 Python 层而不是靠数据库唯一索引抛异常,是因为批量插入时一旦有一条冲突,整个事务回滚,前面的数据也白导了。先查已有键集合再过滤,虽然多一次查询,但导入过程可控。
3. Flask 后端接口与 ECharts 前端怎么对接
3.1 三个核心接口的路由设计与返回格式
可视化系统不需要 RESTful 到极致,但接口返回格式要统一。我一般设计三个接口:产品列表、价格趋势、预测结果。返回体统一用{code, msg, data}结构,前端好处理。
# app.py:Flask 路由,返回 JSON 给前端 ECharts from flask import Flask, jsonify, request from models import db, AgriPrice from sqlalchemy import func app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///agri.db' db.init_app(app) @app.route('/api/products') def product_list(): # 返回所有不重复的产品名和品类,供前端下拉框使用 rows = db.session.query( AgriPrice.product, AgriPrice.category ).distinct().all() return jsonify({ 'code': 0, 'msg': 'ok', 'data': [{'product': r[0], 'category': r[1]} for r in rows] }) @app.route('/api/trend') def price_trend(): product = request.args.get('product') start = request.args.get('start') # 格式 2024-01-01 end = request.args.get('end') q = AgriPrice.query.filter_by(product=product) if start: q = q.filter(AgriPrice.price_date >= start) if end: q = q.filter(AgriPrice.price_date <= end) rows = q.order_by(AgriPrice.price_date).all() return jsonify({ 'code': 0, 'msg': 'ok', 'data': { 'dates': [r.price_date.strftime('%Y-%m-%d') for r in rows], 'avg': [float(r.avg_price) for r in rows], 'low': [float(r.low_price or 0) for r in rows], 'high': [float(r.high_price or 0) for r in rows] } })/api/trend返回的四个数组长度必须一致,ECharts 的 x 轴和 series 才能对齐。low_price和high_price可能为空,用or 0兜底,但更好的做法是在导入阶段就填充,避免前端图表出现断点。日期统一用strftime转成字符串,因为 JSON 不认 Python 的 date 对象。
3.2 ECharts 折线图的配置项与数据绑定
前端拿到数据后,ECharts 的配置重点是 xAxis 的 type 设为 category,series 里用markLine标出均价线。
// static/js/chart.js:初始化价格趋势折线图 function renderTrend(data) { const chart = echarts.init(document.getElementById('trendChart')); const option = { tooltip: { trigger: 'axis' }, // 鼠标悬停显示当日所有价格 legend: { data: ['均价', '最低价', '最高价'] }, xAxis: { type: 'category', data: data.dates, // 与后端 dates 数组一一对应 axisLabel: { rotate: 45 } // 日期多时倾斜,避免重叠 }, yAxis: { type: 'value', name: '价格(元)' }, series: [ { name: '均价', type: 'line', data: data.avg, smooth: true }, { name: '最低价', type: 'line', data: data.low }, { name: '最高价', type: 'line', data: data.high } ] }; chart.setOption(option); // 窗口缩放时重绘,否则图表宽度不跟随 window.addEventListener('resize', () => chart.resize()); }smooth: true让折线平滑,但做价格分析时我建议关掉,因为平滑会掩盖真实波动。axisLabel.rotate在日期超过 15 个时几乎必开,否则标签会挤成一团。resize监听是血泪经验——不加的话,侧边栏折叠后图表还是旧宽度,右边留一大片空白。
3.3 用 Flask 蓝图拆分可视化与预测模块
当接口超过五个,全写在app.py里会乱。用蓝图拆成visual和predict两个模块,各自管理路由。
# views/predict.py:预测模块蓝图 from flask import Blueprint, jsonify, request from models import AgriPrice from predict_model import forecast_price predict_bp = Blueprint('predict', __name__, url_prefix='/api/predict') @predict_bp.route('/') def do_predict(): product = request.args.get('product') days = int(request.args.get('days', 7)) # 默认预测未来 7 天 rows = AgriPrice.query.filter_by(product=product)\ .order_by(AgriPrice.price_date).all() prices = [float(r.avg_price) for r in rows] if len(prices) < 30: return jsonify({'code': 1, 'msg': '历史数据不足30天,无法预测'}) result = forecast_price(prices, days) return jsonify({'code': 0, 'msg': 'ok', 'data': result})蓝图的好处是预测接口可以单独加缓存或限流,不影响可视化接口。days参数设了默认值 7,因为农产品价格预测超过两周误差会明显变大。数据不足 30 天直接返回错误码,而不是硬跑模型——样本太少时 ARIMA 连参数都估不准。
4. 预测模型怎么选:从移动平均到 ARIMA 的取舍
4.1 移动平均与指数平滑的适用边界
不是所有场景都需要 ARIMA。如果只是想在图表上叠一条趋势线,移动平均就够了。
# predict_model.py:三种预测方法的统一入口 import numpy as np from statsmodels.tsa.holtwinters import ExponentialSmoothing from statsmodels.tsa.arima.model import ARIMA def moving_average(prices, days): # 用最近 7 天均值作为未来每天的预测值 window = prices[-7:] avg = np.mean(window) return [round(avg, 2)] * days def exp_smoothing(prices, days): # Holt-Winters 指数平滑,适合有趋势但无强季节性的序列 model = ExponentialSmoothing( prices, trend='add', seasonal=None ).fit() return [round(float(v), 2) for v in model.forecast(days)]移动平均适合数据波动小、只想看大致水平的场景,缺点是预测值是一条直线,没有变化。指数平滑能捕捉趋势,计算也快,适合日度数据在 100 到 500 条之间的规模。如果价格有明显的季节性(比如某些水果每年 9 月集中上市导致价格下跌),就需要加seasonal='add', seasonal_periods=365,但那样至少需要两年数据才能估准。
4.2 ARIMA 的 p、d、q 三个参数怎么定
ARIMA 是这套系统里最常用的模型,但三个参数定不好,预测结果会离谱。我一般用 ADF 检验定 d,用 ACF/PACF 图定 p 和 q。
from statsmodels.tsa.stattools import adfuller from statsmodels.graphics.tsaplots import plot_acf, plot_pacf def choose_arima_order(prices): # 第一步:ADF 检验判断是否需要差分 result = adfuller(prices) p_value = result[1] d = 0 if p_value < 0.05 else 1 # p 值小于 0.05 认为平稳,d=0 # 第二步:对差分后的序列看 ACF 和 PACF # 实际项目中我会把图存下来人工看,这里给一个经验范围 # 农产品价格日度数据通常 p<=3, q<=3 return (2, d, 2) # 保守取 2,2 作为默认阶数 def arima_forecast(prices, days): order = choose_arima_order(prices) model = ARIMA(prices, order=order).fit() forecast = model.forecast(days) return [round(float(v), 2) for v in forecast]ADF 检验的 p 值小于 0.05 说明序列平稳,不需要差分,d 取 0;否则 d 取 1,做一阶差分。p 和 q 的确定更依赖经验:农产品价格日度数据一般 p 不超过 3,q 不超过 3,取 (2,1,2) 或 (1,1,1) 通常不会太差。如果预测结果明显偏离,先检查 d 是否选错——差分过度会让预测值趋于常数。
4.3 把预测结果叠回 ECharts 的虚线实现
预测结果要和历史价格画在同一张图上,用虚线区分。
// 在原有 series 后追加预测线 const predictSeries = { name: '预测均价', type: 'line', data: new Array(data.dates.length - 1).fill(null).concat( [data.avg[data.avg.length - 1]], // 从最后一个历史点接上 predictData ), lineStyle: { type: 'dashed', color: '#e6a23c' }, itemStyle: { color: '#e6a23c' } };关键在data数组的拼接:前面用null填充到历史长度,让预测线从历史最后一点开始画。如果不填充,ECharts 会把预测线画在 x 轴最左边,和实际时间对不上。lineStyle.type设为dashed是行业惯例,实线代表实际数据,虚线代表预测,一眼能分清。
5. 避坑与排查:部署和预测环节的五个常见问题
5.1 中文产品名在 URL 参数里乱码
现象:前端请求/api/trend?product=红富士苹果,后端收到的是红å¯å£«è¹æ。原因是浏览器默认用 UTF-8 编码 URL,但 Flask 在某些部署环境下按 Latin-1 解码。解决方式是在前端用encodeURIComponent包一层,后端用request.args.get时 Flask 会自动解码。
// 前端请求时对中文参数编码 const url = `/api/trend?product=${encodeURIComponent(product)}`;如果后端仍然乱码,检查 WSGI 服务器的charset配置,确保设为utf-8。
5.2 预测接口首次调用超时
现象:第一次点“预测”按钮要等十几秒,之后正常。原因是 ARIMA 模型首次拟合时要做参数优化,计算量大。解决方式是在应用启动时预热一次,或者把模型拟合结果缓存起来。
from functools import lru_cache @lru_cache(maxsize=32) def get_cached_forecast(product, days): # 相同产品和天数的预测结果缓存,避免重复拟合 ...lru_cache的maxsize设 32 是因为产品种类通常不会太多,缓存太多浪费内存。注意缓存键要包含days,否则改预测天数会拿到旧结果。
5.3 日期缺失导致折线图断点
现象:图表上某几天没有数据,折线直接跳过去,看起来像价格突变。原因是数据库里那几天确实没有记录,ECharts 默认把相邻点连起来。解决方式是在后端补全日期序列,缺失日期填null。
import pandas as pd def fill_missing_dates(rows, start, end): # 生成完整日期范围,缺失日期均价填 None full_range = pd.date_range(start=start, end=end, freq='D') date_map = {r.price_date: float(r.avg_price) for r in rows} dates = [d.strftime('%Y-%m-%d') for d in full_range] avg = [date_map.get(d.date()) for d in full_range] return dates, avgECharts 遇到null会断开折线,这样视觉上能看出数据缺失,而不是误以为价格连续。
5.4 SQLite 并发写入报 database is locked
现象:多人同时导入数据时,Flask 报sqlite3.OperationalError: database is locked。原因是 SQLite 同一时刻只允许一个写事务。解决方式有两种:一是导入操作加锁串行化,二是换 MySQL。临时方案是设置timeout参数让写操作等待。
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { 'connect_args': {'timeout': 15} # 写锁等待 15 秒再报错 }但timeout只是缓解,根本方案还是迁移到支持并发写的数据库。
5.5 预测结果全是同一个值
现象:ARIMA 预测出来的未来七天价格完全一样。原因通常是 d 参数选大了,差分过度导致模型退化成常数预测。排查方式是打印choose_arima_order返回的 d 值,如果 d=2 就改回 1 试试。另一个可能是历史数据本身波动极小,模型学不到变化,这种情况换移动平均反而更诚实。
6. 让预测更稳的一个技巧:滚动回测代替单次划分
很多人做时间序列预测时,直接把最后 30 天当测试集,前面当训练集,跑一次看误差就完事。这种做法在农产品价格上很容易高估模型效果,因为不同季节的价格波动模式完全不同。我一般用滚动回测:每次用前 N 天预测第 N+1 天,然后窗口往后滑一天,重复几十次,看平均误差。
def rolling_backtest(prices, window=60, horizon=1): # 滚动回测:每次用 window 天预测下一天,记录误差 errors = [] for i in range(window, len(prices) - horizon): train = prices[i - window:i] actual = prices[i + horizon - 1] try: model = ARIMA(train, order=(2, 1, 2)).fit() pred = float(model.forecast(horizon)[-1]) errors.append(abs(pred - actual) / actual) # 相对误差 except Exception: continue # 某次拟合失败不影响整体 if not errors: return None return { 'mape': round(sum(errors) / len(errors) * 100, 2), # 平均绝对百分比误差 'samples': len(errors) }window设 60 是因为农产品价格的一个短期周期大约两个月,用太短的窗口模型学不到趋势,太长则包含过时信息。horizon设 1 表示只预测下一天,如果你想评估七天预测的误差,把horizon改成 7,但 MAPE 会明显上升。我一般要求 MAPE 在 5% 以内才认为模型可用,超过 10% 就说明这个产品的价格不适合用 ARIMA 预测,得换 Prophet 或加外部特征。
这个回测函数跑一次大概几秒到十几秒,取决于数据量。我习惯在模型上线前跑一遍,把 MAPE 和样本数记下来,作为后续调参的基线。如果某次改了参数后 MAPE 反而变大,就回退。没有这个基线,调参就是玄学。
最后说一个习惯:每次改完预测逻辑,先拿三个不同品类的产品各跑一次滚动回测,确认 MAPE 没有明显恶化再部署。农产品价格受天气、政策、运输影响大,模型不可能一直准,但至少要做到“换参数不会更差”。希望帮到你。
本文还有配套的精品资源,点击获取