ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于LSTM的蔬菜价格预测:从数据采集到Flask部署全流程

2026/9/14 4:26:44 拓冰建站 浏览量
基于LSTM的蔬菜价格预测:从数据采集到Flask部署全流程 简介面向想用深度学习做价格预测的开发者基于LSTM的蔬菜价格预测项目提供了从数据采集到Web展示的完整实现。包内共181个文件包含142个CSV价格与天气数据集、25个Python源码、2个DOCX项目说明等压缩包仅1.78MB目录结构清晰便于直接定位代码与数据。项目围绕蔬菜价格与天气等影响因素展开依次完成Scrapy爬虫采集、Pandas与Numpy数据清洗、LSTM及多种时间序列模型对比分析并利用Flask搭建网页、结合微信公众号提供查询展示形成一套可运行的预测流程。文档中详细记录了环境配置与实现思路适合作为课程设计、毕业设计或入门实战参考。目前已有251人学习下载对希望掌握LSTM实战流程、了解预测任务完整落地的学习者有较高参考价值。1. 蔬菜价格预测里LSTM 到底解决的是什么问题蔬菜价格波动看起来随机其实藏着明显的时序依赖今天菜心的价格往往和过去几天的批发价、天气、节假日供给有关。传统方法用 ARIMA 或简单的回归去做能捕捉线性趋势但对这种多因子、长依赖的非线性序列往往力不从心。LSTM 的优势在于它的门控机制可以自己决定记住哪些历史信息、忘掉哪些在价格这类强自相关的序列上通常比滑动平均、朴素外推更稳。这个项目把整个链路做全了Scrapy 爬取批发市场菜价和天气数据pandas 清洗后构造时间序列样本再用 LSTM 建模预测未来几天价格最后用 Flask 搭 Web 页面、对接公众号供查询。对想跑通数据采集 → 清洗 → 建模 → 部署完整流程的从业者来说是一个很典型的参照物。适合正在做时序预测项目、或者准备把算法模型落地成 Web 服务的人参考。2. Scrapy 抓取与 MongoDB 存储蔬菜价格数据如何变成 LSTM 能吃的序列2.1 爬虫设计为什么用 Scrapy 而不是 requests这个项目的数据源是批发市场网站页面结构稳定但数据量不小每天每个品类有几十条价格记录累积起来是几万条级别。用 requests BeautifulSoup 能写但并发、去重、断点续爬都要自己处理。Scrapy 自带调度器、去重过滤、并发下载和 Item Pipeline适合这种需要长期增量采集的场景。常见做法是在items.py里定义数据字段然后在 spider 的parse()方法中提取数据并交给 Pipeline 落库。核心字段包括蔬菜名称、日期、最低价、最高价、平均价、单位、市场名称以及对应的天气信息。这样设计的好处是价格数据与天气数据可以独立采集在清洗阶段再按日期和地区关联。# spiders/price_spider.py import scrapy from veg_price.items import PriceItem class PriceSpider(scrapy.Spider): name price_spider allowed_domains [market.example.com] start_urls [https://market.example.com/price/list?datetoday] def parse(self, response): item PriceItem() for row in response.css(table#priceTable tr)[1:]: item[name] row.css(td:nth-child(1)::text).get() item[price] row.css(td:nth-child(4)::text).get() item[date] row.css(td:nth-child(5)::text).get() item[market] row.css(td:nth-child(6)::text).get() yield itemstart_urls是按日期翻页的入口parse()里每个row对应表格里的一行数据。这里把字段提取和存储解耦PriceItem只是数据载体真正的入库逻辑在 Pipeline 里完成。如果目标页面是异步加载的需要先分析 XHR 接口直接请求 JSON 接口会比渲染页面快很多。2.2 落库与去重MongoDB 的 upsert 策略数据库选 MongoDB 而不是 MySQL主要考虑是价格数据的字段结构会随爬取页面调整比如某天新增一个批发量字段MongoDB 不需要改表结构而且后续要做时间序列分析按日期查询的频繁操作在文档型数据库里写起来更灵活。在pipelines.py里用update_one搭配$set做 upsert以蔬菜名 日期 市场为唯一键去重。这样即使重复爬取同一天的数据也不会产生脏记录。# pipelines.py import pymongo class MongoPipeline: def __init__(self, mongo_uri, mongo_db): self.client pymongo.MongoClient(mongo_uri) self.db self.client[mongo_db] classmethod def from_crawler(cls, crawler): return cls( mongo_uricrawler.settings.get(MONGO_URI, mongodb://localhost:27017), mongo_dbcrawler.settings.get(MONGO_DB, veg_price) ) def process_item(self, item, spider): self.db.price_records.update_one( {name: item[name], date: item[date], market: item[market]}, {$set: dict(item)}, upsertTrue ) return item注意upsertTrue的含义是如果查询条件匹配不到记录就插入新文档匹配到了就更新字段。这样的幂等写入方式支撑每天增量爬取而不产生重复数据。并发高的时候MongoDB 的写锁可能成为瓶颈可以考虑按蔬菜名称做分片。2.3 从文档到时间序列pandas 的数据整理MongoDB 里存的是明细记录但 LSTM 需要的是按蔬菜品种区分、按日期排序的连续价格序列。清洗步骤一般是先按name分组再把date转成 datetime 索引按天重采样补齐缺失日期最后用前向填充或插值处理价格空缺。import pandas as pd from pymongo import MongoClient client MongoClient(mongodb://localhost:27017) coll client[veg_price][price_records] df pd.DataFrame(list(coll.find({name: 菜心}))) df[date] pd.to_datetime(df[date]) df df.set_index(date).sort_index() # 按天重采样取每天平均价 daily df[price].resample(D).mean() # 缺失日期用前向填充 线性插值兜底 daily daily.fillna(methodffill).interpolate(methodlinear)resample(D)是按自然日聚合因为同一个品种一天内可能有多个报价取平均价能消除日内波动。ffill处理连续节假日造成的缺失interpolate负责填充中间的小缺口。这一步很关键LSTM 要求输入序列在时间轴上是等间隔的如果日期不连续模型学到的时间步语义就是错乱的。表结构设计建议阶段数据格式关键字段用途原始爬取MongoDB 文档name, date, price, market持久化存储清洗后pandas Seriesdate(索引), priceLSTM 输入训练样本numpy 二维数组[samples, timesteps, features]模型训练清洗后的序列要导出成 CSV 一份方便后续反复实验时不需要重新查库。本地文件命名建议包含品种和市场比如本地菜心.csv与项目里给出的 CSV 文件对应。3. LSTM 滑动窗口与多步预测从 MinMaxScaler 到模型调参3.1 为什么一定要做归一化LSTM 内部使用的是 sigmoid 和 tanh 激活函数对输入数值范围非常敏感。蔬菜价格从几毛到十几块直接喂给网络梯度很容易饱和或爆炸导致 loss 不下降。常见的做法是用 MinMaxScaler 把价格压缩到 [0,1] 区间预测后再反归一化还原成真实价格。from sklearn.preprocessing import MinMaxScaler import numpy as np prices daily.values.reshape(-1, 1) scaler MinMaxScaler(feature_range(0, 1)) scaled scaler.fit_transform(prices)fit_transform在训练数据上计算最小值和最大值并完成缩放。注意实际部署时要保存这个 scaler可以用 pickle 存下来预测新数据时调用transform不能用新数据的 min/max 重新 fit否则训练和推理的分布不一致预测结果会失真。3.2 滑动窗口构造样本timesteps 怎么选LSTM 不直接吃一条序列而是吃最近 N 天的价格来预测第 N1 天的价格。这个 N 就是滑动窗口大小也叫 lookback。窗口太小模型看不到周期性窗口太大样本量减少且容易引入噪声。按这个项目的场景蔬菜价格有 7 天左右的周周期周末批发需求下降、价格走低窗口选 7 或 14 比较合理。具体做法是用一个循环从清洗后的序列里切出固定长度的输入输出对。def create_dataset(data, lookback7): X, y [], [] for i in range(len(data) - lookback - 1): X.append(data[i:(i lookback), 0]) y.append(data[i lookback, 0]) return np.array(X), np.array(y) lookback 7 X, y create_dataset(scaled, lookback) X X.reshape(X.shape[0], X.shape[1], 1)data[i:(i lookback), 0]取的是从第 i 天开始的连续 lookback 个价格点data[i lookback, 0]是窗口之后那天的价格。reshape成三维是为了满足 Keras 的输入格式[样本数, 时间步数, 特征数]这里的特征数 1 表示只用了价格一个维度。如果后续把天气温度、降雨量也作为特征拼进来特征数就变成 3 或更多。数据切分时要注意价格序列不能随机打乱必须按时间顺序切。前 80% 做训练后 20% 做验证。乱序会泄漏未来信息模型在回测上很好看一到实盘就崩。可以画一下 train/val 的分界点确保验证集时间上完全在训练集之后。3.3 构建 LSTM 模型层数和单元数如何取舍对这个数据量级几千条记录的日频序列模型不宜太深。一个隐藏层的 LSTM 加一个 Dropout 层再接全连接输出层是性价比最高的配置。层数再多过拟合的风险远大于精度提升的收益。用 Keras 的 Sequential 模型实现from tensorflow.keras.models import Sequential from tensorflow.keras.layers import LSTM, Dense, Dropout model Sequential([ LSTM(units64, return_sequencesTrue, input_shape(lookback, 1)), Dropout(0.2), LSTM(units32, return_sequencesFalse), Dropout(0.2), Dense(units1) ]) model.compile(optimizeradam, lossmean_squared_error)return_sequencesTrue表示第一个 LSTM 层输出完整的时间步序列给下一层第二个 LSTM 层只输出最后一个时间步的结果然后接Dense(1)输出预测价格。units 是记忆单元数64 起步如果验证集 loss 降不下去可以加到 128 试一下但数据量小的时候 128 基本就是上限了。Dropout 放在 LSTM 层之间作用是随机丢弃一部分神经元的输出强制模型不依赖某条特定的记忆路径。这里取 0.2 是经验值价格序列噪声大太高会让模型欠拟合。3.4 训练与回调early stopping 防止过拟合训练时不要固定 epoch 数而是用 EarlyStopping 监控验证集 loss连续多个 epoch 不下降就提前终止同时用 ModelCheckpoint 保存最优权重。这样可以避免在训练集上死磕到完美却在验证集上发散。from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint early_stop EarlyStopping(monitorval_loss, patience15, restore_best_weightsTrue) checkpoint ModelCheckpoint(best_lstm.keras, monitorval_loss, save_best_onlyTrue) history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs200, batch_size32, callbacks[early_stop, checkpoint] )patience15的意思是验证集 loss 连续 15 个 epoch 不创新低就停止。batch_size32在这个数据量下训练速度和稳定性平衡得比较好。如果显存不够调小到 16序列波动剧烈时调大到 64 反而能平滑梯度。训练完成后预测值要先反归一化才能和真实价格比较。反归一化时注意 scaler 是针对整条价格序列 fit 的直接用scaler.inverse_transform把预测结果映射回真实量纲。如果模型输出了多步预测要把前面预测出的值作为输入继续滚动预测而不是每次都从头算。4. ARIMA 和朴素基线对照LSTM 在短序列上的边界在哪里4.1 为什么需要多个基线模型LSTM 不是唯一选择也不总是最优选择。蔬菜价格序列不算特别长、周期性明显ARIMA 这类统计模型在短序列上往往能给出很强的基线。设立对照的目的是逼问自己一句LSTM 相比 ARIMA 或者简单外推到底赢在哪、输在哪。如果 LSTM 在验证集上的表现还不如昨天价格直接平移那问题肯定出在特征构造或者数据预处理上而不是模型本身。4.2 用 pmdarima 快速构建 ARIMA 基线直接用 statsmodels 手写 ARIMA 需要自己定 p/d/q 参数比较繁琐。pmdarima 的auto_arima可以自动搜索最优参数组合适合快速搭基线。from pmdarima import auto_arima # 使用训练集价格序列未归一化 train_series daily[:int(len(daily) * 0.8)] model_arima auto_arima( train_series, seasonalTrue, m7, traceTrue, stepwiseTrue, suppress_warningsTrue ) forecast model_arima.predict(n_periodslen(daily) - len(train_series))seasonalTrue, m7是告诉 ARIMA 模型价格存在 7 天周期这跟 LSTM 的 lookback7 思路一致。stepwiseTrue用逐步搜索加速n_periods是预测的天数要与验证集的长度一致方便后面比较误差。4.3 朴素基线的定义朴素基线的逻辑很简单预测明天的价格等于今天的价格。听起来不靠谱但在价格序列接近随机游走时这个基线常常能打败很多复杂模型。另一个更强的基线是过去 7 天平均价它天然带有平滑去噪的性质。def naive_forecast(series, horizon): return [series.iloc[-1]] * horizon def seasonal_naive_forecast(series, horizon, season7): return [series.iloc[-season]] * horizonnaive_forecast是恒定预测seasonal_naive_forecast是用上周同一天的价格来预测本周同一天能保留周周期性。这两个基线虽然简单却是衡量 LSTM 是否有效的下限如果连这两个都打不过模型架构或特征一定出了问题。4.4 误差指标怎么选回归预测里最常用的指标是 MSE、RMSE、MAE、MAPE。价格预测里我比较推荐 MAPE因为它把误差归一化到百分比不同蔬菜品种之间可以直接比较。但 MAPE 有个坑当真实价格接近 0 时结果会爆炸蔬菜价格很少等于 0所以可以用。from sklearn.metrics import mean_squared_error, mean_absolute_error def mape(y_true, y_pred): y_true, y_pred np.array(y_true), np.array(y_pred) return np.mean(np.abs((y_true - y_pred) / y_true)) * 100 # 反归一化后进行指标计算 y_val_true scaler.inverse_transform(y_val.reshape(-1, 1)).reshape(-1) lstm_pred scaler.inverse_transform(model.predict(X_val)).reshape(-1) print(fLSTM RMSE: {mean_squared_error(y_val_true, lstm_pred, squaredFalse):.3f}) print(fLSTM MAPE: {mape(y_val_true, lstm_pred):.2f}%)对比时用一张表记录各模型的表现模型RMSE元MAPE%备注朴素恒定1.3218.5强基线周同期1.0514.2引入周周期ARIMA(1,0,1)(1,0,1)[7]0.9212.8统计模型上限LSTM(lookback7, units64)0.8410.9深度学习方案如果 LSTM 的 MAPE 比周同期还高优先检查数据是否有缺失、归一化是否合适、窗口是否太小。一个可能的坑是 LSTM 对量纲很敏感而 ARIMA 建模时对价格直接取对数可能有帮助两者不在同一数据变换下对比会失真。4.5 可视化对比什么情况下 LSTM 真的更好用 matplotlib 把真实价格、ARIMA 预测、LSTM 预测画在一起重点观察拐点处的拟合情况。LSTM 的优势一般体现在价格剧烈波动的时段比如台风前后菜价跳涨LSTM 能更快地反应ARIMA 则更平滑滞后更严重。import matplotlib.pyplot as plt plt.figure(figsize(12, 6)) plt.plot(y_val_true, label真实价格, linewidth2) plt.plot(forecast, labelARIMA, linestyle--) plt.plot(lstm_pred, labelLSTM, linestyle:) plt.legend() plt.title(LSTM vs ARIMA 在验证集上的表现) plt.ylabel(价格元) plt.savefig(compare.png, dpi150)如果 LSTM 的预测曲线整体比真实值滞后一天左右说明模型学到的基本是昨天的价格就是明天的价格的近似这时增加特征天气、节假日比加深网络更有用。5. Flask 接口设计与模型部署预测结果如何通过 Web 和公众号触达用户5.1 模型序列化与加载训练和推理分离模型训练是在本地或 GPU 环境完成的而部署环境是 CPU 服务器所以要把训练好的模型和 scaler 序列化保存。Keras 模型用model.save()保存为 HDF5 格式scaler 用joblib保存然后 Flask 启动时加载。# 训练完成后保存 model.save(lstm_veg_price.keras) import joblib joblib.dump(scaler, scaler.pkl) # Flask 启动时加载 from tensorflow.keras.models import load_model import joblib model load_model(lstm_veg_price.keras) scaler joblib.load(scaler.pkl)注意load_model时需要模型结构文件和权重打包在一个文件里Keras 的.keras格式可以做到。加载后要检查输入维度是否和训练一致坑在于序列化再反序列化后Keras 有时会丢失自定义层的配置如果用了自定义 loss 或 metric必须在load_model时传custom_objects。5.2 Flask 接口设计预测未来 3 天价格接口的设计思路是客户端传入蔬菜品种名称服务端从 CSV 或 MongoDB 加载该品种最近 7 天的价格构造滑动窗口调用模型预测返回未来 3 天的价格曲线。用 Flask 写一个 POST 接口即可。# app.py from flask import Flask, request, jsonify import pandas as pd import numpy as np app Flask(__name__) def load_recent_prices(veg_name, days7): df pd.read_csv(fdata/{veg_name}.csv, parse_dates[date]) df df.set_index(date).sort_index() recent df[price].tail(days).values.reshape(-1, 1) return scaler.transform(recent).reshape(1, days, 1) def predict_future(seq, steps3): result [] cur seq.copy() for _ in range(steps): pred model.predict(cur, verbose0)[0, 0] result.append(pred) # 滚动丢掉最早一天拼上预测值 cur np.roll(cur, shift-1) cur[0, -1, 0] pred return scaler.inverse_transform(np.array(result).reshape(-1, 1)).reshape(-1).tolist() app.route(/predict, methods[POST]) def predict(): data request.get_json() veg_name data.get(name, 本地菜心) seq load_recent_prices(veg_name, days7) prices predict_future(seq, steps3) return jsonify({name: veg_name, predictions: prices})np.roll(cur, shift-1)是把窗口内所有值前移一位再把新预测值放到最后实现滚动预测。这样预测第 2 天时输入窗口包含第 1 天的预测值误差会累积所以多步预测的精度通常低于单步。如果要做 7 天预测建议每步同时输出一个置信区间避免用户对远期数字过度信任。5.3 前端页面与服务端渲染Web 端展示用 Flask 的模板渲染用户在下拉框选择蔬菜品种点击查询后通过 AJAX 调用/predict接口把返回的价格数据用 Chart.js 画成折线图。这里不用前后端分离架构原因是项目体量小服务端渲染维护成本更低。// static/predict.js fetch(/predict, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ name: 本地菜心 }) }) .then(res res.json()) .then(data { // data.predictions 是未来3天价格数组 drawChart(data.predictions); });接口返回的数据结构统一为 JSON前端直接消费。drawChart函数负责把未来预测接在历史曲线尾部形成一条完整的价格走势预览。注意 CORS 问题如果公众号内嵌页面和 Flask 服务不同域需要在 Flask 里配置flask-cors否则浏览器会拦截请求。5.4 公众号对接用户查询的完整链路公众号开发的常规链路是用户输入蔬菜名 → 公众号服务器接收消息 → 调用 Flask/predict接口 → 拼接文本或图文回复。这里不需要实时调用模型因为模型推理在毫秒级但如果有大量用户同时查询建议加一层 Redis 缓存按品种缓存预测结果5 分钟过期。# wechat/views.py import requests from flask import request, make_response WECHAT_TOKEN your_token app.route(/wechat, methods[GET, POST]) def wechat_gateway(): if request.method GET: # 微信公众号服务器配置验证 return request.args.get(echostr) # POST 为消息推送 xml_data request.data # 解析 XML 提取用户输入内容 veg_name parse_user_msg(xml_data) resp requests.post(http://localhost:5000/predict, json{name: veg_name}) prices resp.json()[predictions] # 构造回复文本 reply f{veg_name}未来三天价格预测{prices[0]:.2f}、{prices[1]:.2f}、{prices[2]:.2f}元 return make_response(build_reply_xml(reply))逻辑上分成两步先解析微信公众号推送的 XML 消息拿到用户输入的菜名再调用内部预测接口把返回的价格拼成文本。这里要注意微信服务器要求 5 秒内响应如果预测接口耗时超过 5 秒需要用客服接口异步推送。常见做法是启动一个后台线程去调用预测接口先立即回复用户正在查询中算完后再主动推送给用户。5.5 部署要点与日志监控部署到 Linux 服务器上不用flask run直接跑而是用 gunicorn 多进程启动避免单进程阻塞。gunicorn -w 2 -b 0.0.0.0:5000 app:app线程数设为 2 即可-w是 worker 进程数-b指定监听地址。LSTM 模型在 CPU 上做单次预测只需几十毫秒2 个 worker 足够支撑一般公众号的查询量。如果 QPS 高可以在前面加一层 Nginx 做反向代理和静态文件缓存。日志方面Flask 自带的日志只输出请求行不够排查问题。建议在预测接口里显式记录输入参数、模型耗时和返回结果到文件出现异常时能快速定位是数据加载失败还是模型推理崩溃。6. 预测值漂移的两个实用修正技巧实际投入使用的过程中最头痛的问题不是模型不准而是预测值系统性偏移连续几天预测值比真实价格高 0.3 元或者拐点永远晚一天。这里分享两个常用修正方法。6.1 残差校正把模型偏差变成可学的新序列第一步是计算训练集和验证集上真实值 - 预测值的残差序列然后用一个简单模型比如指数平滑或 3 天滑动平均拟合残差的规律预测时把残差预测值加回 LSTM 输出。# 计算残差并做滑动平均 residual y_val_true - lstm_pred residual_smoothed pd.Series(residual).rolling(3, min_periods1).mean() # 修正预测值 corrected_pred lstm_pred residual_smoothed.valuesrolling(3).mean()是取最近 3 个残差的平均值作为修正量相当于一个低通滤波器。如果残差序列存在明显正负交替的模式说明模型本身的滞后明显如果残差是白噪声说明模型已经充分捕捉了规律不需要修正。注意残差模型要用训练集拟合、验证集评估不能复用验证集数据去拟合并评估否则会乐观偏差。6.2 价格上下限钳制避免预测值出现不合常理的数值蔬菜价格有天然的物理边界不会归零也不会超过市场限价。LSTM 预测值偶尔会跑出负数或异常高的值原因通常是在某些极端波动段反归一化时数值外推。处理方法是钳制(clip)。def clamp_price(pred, lower0.5, upper15.0): return min(max(pred, lower), upper)对每个预测值钳制到合理区间即可。如果某天预测值正好卡在upper上连续多天说明模型早已发散需要回到数据侧检查近期是否有异常价格没有清洗掉。这个函数看似简单却能避免公众号推送预计白菜 45 元/斤这类事故。本文还有配套的精品资源点击获取