简介:面向蔬菜价格预测与Web展示的完整Python实战资源,以深度学习LSTM为核心,覆盖从数据采集到结果呈现的全流程。项目采用Python为主、PHP辅助,集成爬虫、数据处理、模型训练与Web/公众号展示,适合希望系统学习时间序列预测及项目落地的初中级开发者。压缩包共181个文件,包含142个CSV历史价格数据、25个Python源码、2个Word说明文档、2个Markdown文件及少量PYC缓存,整体仅1.66MB,轻量便于下载与复用。已有247人浏览学习。资源内含完整的项目说明,读者可掌握Scrapy/BeautifulSoup爬取蔬菜价格与天气数据、Pandas清洗、Statsmodels/PyFlux等时间序列对比,以及通过Flask将预测结果可视化在网页和微信公众号的方法。代码结构清晰,便于二次开发,是实践数据科学与Web应用结合的不错参考。 做蔬菜批发或生鲜零售的人都有过这种纠结:明天该按什么价收货?产地涨了三毛,库里还有两吨,标价高了走不动,低了又保不住毛利。靠经验只能赌个大概,用传统ARIMA做预测又会被节假日的价格跳变打得找不着北。这就是「基于深度学习LSTM实现蔬菜价格预测」这类项目存在的理由:LSTM能记住几十天前的价格形态,再结合当天的特征做非线性判断,比线性模型更贴合真实菜价的走势。标题里带上了python源码、项目说明和数据集三件套,目标很明确——不是讲原理,是让你能跑通一个完整的预测流程。适合两类人:一是要做毕设或课程设计的学生,二是手里攒了历史菜价、想自己搭预测工具的小团队。
2. 把价格预测改写成监督学习:窗口构造、特征工程与数据划分
2.1 滑动窗口:把一串价格变成(x, y)样本对
LSTM模型不会理解「今天是几号」,它只能从输入矩阵里学习模式。所以预测的第一步,是把一条连续的价格曲线切成一组组「过去→未来」的样本对,这个过程叫滑动窗口。
比如手上有60天的黄瓜批发均价,想用过去7天的价格预测第8天,那么从第0天到第6天是第一个样本的x,第7天是它的y;然后窗口往后挪一天,第1天到第7天是第二个x,第8天是y。这样60天数据能切出53个样本。这个「过去几天→未来一天」的映射关系,就是LSTM学习的对象。
import numpy as np def create_sequences(data, window_size): xs, ys = [], [] for i in range(len(data) - window_size): x = data[i:i + window_size] y = data[i + window_size] xs.append(x) ys.append(y) return np.array(xs), np.array(ys)上面的函数接受一维价格序列和窗口大小,返回两个numpy数组。xs的每个元素形状是(window_size, 1),代表一段连续价格;ys是对应的下一个价格。注意循环边界:i最大到len(data)-window_size-1,这样x不会超出数组范围,y也能取到合法值。实际调用时要再reshape成(batch, window_size, 1),因为PyTorch的LSTM层在batch_first=True模式下正好要求这个维度顺序。
窗口大小是第一个要拍板的超参数。蔬菜价格有星期周期,周一和周末的批发价走势明显不同,7天是一个合理的默认值;生鲜电商如果关心周环比趋势,可以试14天;想捕捉月度季节性,要加到30天左右。但窗口越长,训练样本越少——60条数据用7天窗口能切出53个样本,用30天窗口只剩30个样本,模型还没学够就已经没有数据了。
2.2 特征构造:日期、节假日、天气与价格缺口的取舍
纯价格序列就能跑出一个基线模型,但蔬菜价格有明显规律:批发市场周初和周末价格不一样,节假日之前几天往往涨价,极端天气后一周价格跳涨。这些信息如果只靠价格本身,LSTM虽然能猜出部分周期,但要额外花费大量隐层容量去「硬记」日历规律。
我一般建议往特征里加两类信息。第一类是星期几,用7个维度的独热编码,或者用sin/cos编码让周一和周日相邻,避免模型把「周一」和「周日」当作两个无关类别。第二类是节假日哑变量,法定节假日前一天和假期当天分别取值1和-1,这个特征是零成本的,加进去后节假日价格跳变的预测准确度会明显提升。
天气和产地供应数据要不要加?我的判断是:除非数据集里已经按天和市场维度对齐好了,否则不要碰。天气数据要按城市、按市场做空间对齐,产地受灾信息还需要人工标注,工程量和收益不成正比。很多公开的蔬菜价格数据集里根本没有天气字段,强行从外部爬取反而引入时间戳对不齐的问题。先把时间特征做扎实,模型跑通了再加外部数据,这是最稳妥的路线。
2.3 时序数据不能乱切:训练集、验证集与测试集的顺序划分
这个坑我在自己的项目里踩过,也帮别人排查时见过很多次:用sklearn的train_test_split随机切分价格数据。表面上看模型在测试集上误差非常小,实际没有任何参考价值。价格数据有强自相关性,相邻几天的价格高度接近,随机切分会让测试集里混进与训练样本相邻的日期,模型等于提前见过答案。这不是模型厉害,是数据划分作弊。
时间序列预测的数据划分必须按时间顺序来。我一般按6:2:2切,前60%训练,中间20%验证,最后20%测试,三者之间没有重叠:
total_len = len(prices_scaled) train_size = int(total_len * 0.6) val_size = int(total_len * 0.2) train_data = prices_scaled[:train_size] val_data = prices_scaled[train_size:train_size + val_size] test_data = prices_scaled[train_size + val_size:]划分完成后,三个数据集各自用create_sequences生成样本对。验证集和测试集的样本里,x部分可能包含训练集最后几天的价格,这是允许的,因为滑动窗口的x本来就是历史数据,预测目标是未来;真正要避免的是训练样本出现在验证集或测试集里。验证集用来调超参数,测试集只在最终评估时用一次,用多了模型会间接在测试集上过拟合。
3. 用PyTorch搭一个可复现的LSTM预测模型:结构、参数与代码
3.1 最小模型:LSTM层与全连接输出的串联
不要把这个模型想复杂。LSTM层做的事是:读入一段序列,逐步更新内部状态,最终把整段序列的信息浓缩在最后一个时间步的隐状态里。我们要做的,就是把这个浓缩后的状态接一个全连接层,映射成预测价格。
很多人想翻PyTorch的LSTM源码去理解门控机制,其实对落地来说,搞清楚输入输出的形状比理解内部公式更关键。nn.LSTM的输入形状是(batch, seq_len, input_size),输出有两个部分:每个时间步的隐状态序列,以及最后一个时间步的隐状态和细胞状态。对价格预测来说,我们只取隐状态序列的最后一个时间步。
import torch import torch.nn as nn class PriceLSTM(nn.Module): def __init__(self, input_size, hidden_size, num_layers, output_size): super(PriceLSTM, self).__init__() self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): out, _ = self.lstm(x) last_step = out[:, -1, :] pred = self.fc(last_step) return predforward里有三个关键操作。self.lstm(x)返回的out形状是(batch, seq_len, hidden_size),代表每个时间步的输出;out[:, -1, :]取出最后一个时间步,因为我们要预测的是窗口结束后的那一个价格;final的隐状态和细胞状态我们不关心,直接用下划线丢弃。input_size一般等于特征维度,只做单品种价格预测就是1,加了星期和节假日特征就是3。hidden_size控制记忆容量,常见取值32到128。num_layers超过2层对几百条样本的小数据集基本没有收益,反而容易在反传时梯度不稳定。
3.2 训练循环里要动手调的三组参数
代码结构定了,真正决定能不能跑出结果的是三组参数:序列长度、隐藏单元数、学习率。
序列长度window_size是LSTM回头看多远。蔬菜价格周周期明显,7到14是比较合理的起点;如果数据量大、想抓月周期可以试30。窗口太长训练样本减少,还会把无关噪声引入输入。隐藏单元数hidden_size影响模型容量,几百条样本用64个单元足够,上千条可以试128,设太大就是过拟合的开始。学习率用Adam优化器时从0.001起步,这是PyTorch里最常见的设置。loss震荡就降到0.0005,loss收敛太慢就提到0.003。batch_size在数据量小时用16,数据量大用32,太大容易让模型在局部震荡里出不来。
下面是我常用的参数起点表,供你在第一次跑通时对照:
| 参数 | 常见起点 | 调整方向与现象 |
|---|---|---|
| window_size | 7 | 周周期明显往14调;过大时训练样本骤减 |
| hidden_size | 64 | 数据量大可试128;32明显欠拟合,256以上开始过拟合 |
| num_layers | 1到2 | 超过2层梯度不稳定,小数据收益接近零 |
| learning_rate | 0.001 | loss震荡调小;loss几乎不动可以试着调大 |
| batch_size | 16到32 | 样本少用16;显存不紧张不优先动它 |
3.3 归一化与反归一化:别让预测值停留在0到1之间
LSTM内部用tanh和sigmoid激活,对输入数值范围敏感。原始菜价从几毛到十几块一斤,直接送进网络会让梯度计算不稳定,所以要先归一化到0到1。常见做法是MinMaxScaler。
这里最关键的规则是:scaler只fit在训练集上,验证集和测试集只做transform。如果拿全量数据fit,归一化时就已经「看到」了未来价格的最大最小值,这属于最隐蔽的数据泄漏,后面会专门讲。
from sklearn.preprocessing import MinMaxScaler scaler = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler.fit_transform(train_data.reshape(-1, 1)) val_scaled = scaler.transform(val_data.reshape(-1, 1)) test_scaled = scaler.transform(test_data.reshape(-1, 1)) # 预测结束后的反归一化 pred_inverse = scaler.inverse_transform(pred_scaled.reshape(-1, 1))最后这行inverse_transform特别容易漏。很多新手把模型输出的0.37当预测价格,怎么对都对不上,就是因为忘了把归一化后的结果还原成「元/斤」。我的习惯是:模型训练好之后,单独保存scaler状态,预测脚本里重新加载,避免每次预测都重新fit一次。
4. 从原始菜价数据到完整训练脚本:清洗、特征工程与训练串联
4.1 先看原始数据长什么样:字段、粒度与常见脏数据
标题里的数据集打包在zip里,一般是一张csv或Excel表。常见字段包括:日期、蔬菜名称、市场名称、最低价、最高价、平均价、单位,有些还带产地和品类编号。粒度通常是「每天×每个市场×每个品种」一条记录,这意味着同一品种在同一天会出现在多个市场里,不能直接整表丢进模型。
拿到数据第一件事不是写模型,是打开看一眼。我一般先打印前10行和后10行,检查列名、日期格式、空值数量,再统计每个品种有多少条记录。这能避免后期跑训练时突然发现某些品种只有二十几天数据,窗口一滑就只剩十几个样本。
4.2 数据清洗的实操步骤:去重、去零、补缺失
菜价数据常见的三个坑:同一日期同一品种重复采集,个别价格录入成0,节假日前后整段缺失。逐条处理:
import pandas as pd df = pd.read_csv('vegetable_price.csv', encoding='utf-8') df['日期'] = pd.to_datetime(df['日期']) # 按品种+日期去重,保留最后一次采集记录 df = df.sort_values(['蔬菜名称', '日期']) df = df.drop_duplicates(subset=['蔬菜名称', '日期'], keep='last') # 过滤价格明显异常的行 df = df[df['平均价'] > 0] df = df[df['平均价'] < 100] # 只保留数据量足够长的品种 df = df.groupby('蔬菜名称').filter(lambda g: len(g) >= 60) # 按日期排序并重设索引 df = df.sort_values(['蔬菜名称', '日期']).reset_index(drop=True)去重用keep='last'是因为重复采集通常后录入的价格更接近真实成交价。过滤平均价大于100是为了排除单位录错(比如把公斤当斤)的离谱值。按品种过滤掉短序列,是因为LSTM至少要喂60天以上的数据才能学到周期,低于这个量预测结果基本靠猜。
4.3 完整训练脚本:数据加载、训练、模型保存与预测输出
把前面的步骤串成一个完整脚本,一次跑通。下面的代码只预测单个品种,你可以把黄瓜换成数据集里的任意蔬菜名。
import numpy as np import pandas as pd import torch import torch.nn as nn from sklearn.preprocessing import MinMaxScaler def load_prices(df, name): sub = df[df['蔬菜名称'] == name].sort_values('日期') return sub['平均价'].values.astype(float) def create_sequences(data, window_size): xs, ys = [], [] for i in range(len(data) - window_size): xs.append(data[i:i + window_size]) ys.append(data[i + window_size]) return np.array(xs), np.array(ys) df = pd.read_csv('vegetable_price.csv', encoding='utf-8') prices = load_prices(df, '黄瓜') scaler = MinMaxScaler(feature_range=(0, 1)) prices_scaled = scaler.fit_transform(prices.reshape(-1, 1)).flatten() total_len = len(prices_scaled) train_size = int(total_len * 0.6) val_size = int(total_len * 0.2) train_data = prices_scaled[:train_size] val_data = prices_scaled[train_size:train_size + val_size] test_data = prices_scaled[train_size + val_size:] WINDOW = 7 X_train, y_train = create_sequences(train_data, WINDOW) X_val, y_val = create_sequences(val_data, WINDOW) X_test, y_test = create_sequences(test_data, WINDOW) X_train = torch.tensor(X_train, dtype=torch.float32).unsqueeze(-1) y_train = torch.tensor(y_train, dtype=torch.float32).unsqueeze(-1) X_val = torch.tensor(X_val, dtype=torch.float32).unsqueeze(-1) y_val = torch.tensor(y_val, dtype=torch.float32).unsqueeze(-1) X_test = torch.tensor(X_test, dtype=torch.float32).unsqueeze(-1) y_test = torch.tensor(y_test, dtype=torch.float32).unsqueeze(-1)这段代码有两点要说明。torch.tensor转换时明确指定dtype=torch.float32,是因为LSTM不支持int64输入,原始价格一旦是整数类型就会直接报错。unsqueeze(-1)是把输入从二维变成三维,即在最后补一个特征维度,让形状从(样本数, 窗口长度)变成(样本数, 窗口长度, 1),这是nn.LSTM要求的最小维度。
训练部分用标准的PyTorch循环,配合早停逻辑,验证集loss连续10个epoch不降就停止,防止过拟合:
model = PriceLSTM(input_size=1, hidden_size=64, num_layers=1, output_size=1) optimizer = torch.optim.Adam(model.parameters(), lr=0.001) criterion = nn.MSELoss() train_dataset = torch.utils.data.TensorDataset(X_train, y_train) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, shuffle=True) best_val_loss = float('inf') patience = 10 wait_count = 0 for epoch in range(100): model.train() for x_batch, y_batch in train_loader: optimizer.zero_grad() pred = model(x_batch) loss = criterion(pred, y_batch) loss.backward() optimizer.step() model.eval() with torch.no_grad(): val_pred = model(X_val) val_loss = criterion(val_pred, y_val) if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'lstm_price_model.pt') wait_count = 0 else: wait_count += 1 if wait_count >= patience: break if (epoch + 1) % 10 == 0: print(f'epoch {epoch + 1}, loss {loss.item():.4f}, val {val_loss.item():.4f}')shuffle=True这个位置容易踩坑。训练集可以shuffle,因为每个样本是独立窗口;但验证集和测试集不能shuffle,它们要按时间顺序评估模型对连续未来日期的预测能力。早停保存的是验证集上最好的那版参数,而不是最后一个epoch的参数,这个细节能让最终模型鲁棒很多。
5. LSTM价格预测避坑指南:五条写在注释里的踩坑记录
5.1 全量归一化等于偷看未来:数据泄漏的典型症状
现象:训练loss正常下降,验证loss一直接近0,测试集表现也好得反常。这时候不是模型厉害,而是归一化泄漏了。
原因:MinMaxScaler在全量数据上fit,意味着train、val、test三段的数值都被压缩到同一个0到1区间,而这个区间的上下界来自整段数据里的最高价和最低价。如果最高价恰好出现在测试集里,模型在训练时就已经知道未来会出现这个极值,归一化后的价格分布被整体拉平了,局部波动变得更容易预测。
解决:scaler只fit在train_data上,val和test只调用transform。代码上改动一行,效果天差地别。判断自己有没有踩这个坑,最简单的方法是看scaler.data_min_和scaler.data_max_这两个属性,如果它们来源于全量数据,就说明泄漏了。
5.2 验证集随机切分:价格突变被平均之后的虚高指标
现象:验证集和测试集的MAE都小得惊人,但把模型接到真实场景里预测第二天价格时,准度明显下滑。
原因:随机切分让验证集里散布着训练集前后几天的数据,这几天价格高度相关,模型几乎是在做复制粘贴而不是预测。价格突变(比如下暴雨前一天暴涨)会被随机分配到训练集和验证集两侧,模型在训练时已经见过了突变后的价格水平,验证时自然「未卜先知」。
解决:坚持按时间顺序切分,换模型、换特征时只在验证集上调参,最终评估只碰一次测试集。这条规则没有例外,任何用随机切分跑出来的时序指标都应该标注「仅供参考」,不能作为投入依据。
5.3 序列长度不是越大越好:较长窗口带来的过拟合
现象:window_size从7调到30后,训练loss下降更快,但验证loss反而升高,预测曲线变得毛躁,出现很多不合理的尖峰。
原因:窗口越长,样本数越少,同时LSTM需要学习的时序依赖越长。对一个只有两三百天数据的品种来说,30天窗口把样本数砍掉一大截,模型容量没变,数据却不够喂了,必然过拟合。蔬菜价格的周周期非常强,7天窗口已经能覆盖一个完整的价格形态,加到30天增加的更多是噪声而不是信息。
解决:我习惯的做法是先在7、14、21三档上分别跑一个训练,看验证loss,选最低的那档。如果三档差别不大,选最小的窗口,因为它训练最快、部署时对数据量要求也最低。窗口参数和隐藏层大小是联动的,窗口加大的时候要同步减少hidden_size,否则模型参数量翻倍,过拟合风险成倍增加。
5.4 Loss降了但预测是一条直线:学习率与梯度问题
现象:训练loss在下降,但把测试集的预测曲线画出来,几乎是一条水平直线,价格波动完全没有被预测出来。
原因:数据归一化后,价格序列的方差如果本身就很小(尤其是某些常年稳定的蔬菜,比如土豆),模型很快发现预测均值比预测波动更能降低MSE,于是「躺平」输出了一个常数。另一个常见原因是学习率过大,loss在最小值附近震荡,参数始终没有落进能捕捉波动的局部谷底。
解决:先看归一化后数据的标准差,如果小于0.05,说明序列本身太平稳,LSTM的收益有限,不如直接拿上一日价格当预测值。如果方差正常,把学习率从0.001降到0.0003重跑,同时给LSTM层加weight_decay,逼模型放弃复制均值这种懒惰解。用测试集真实值和预测值画一张散点图,如果点都集中在一条45度线附近,说明模型只学到了滞后复制,没有学到独立预测。
5.5 预测值总滞后一天:单步预测的固有毛病
现象:模型跑通了,但预测曲线总比真实曲线慢半拍,价格涨了才跟着涨,跌了才跟着跌,像是昨天价格的平移。
原因:单步预测模型在训练时,输入是过去7天真实价格,输出是第8天。因为相邻天价格高度相关,模型发现把第7天价格稍微调整一下就能把loss压得很低,于是学成了一个「平滑器」而不是「预测器」。这是单步监督学习在价格预测里的通病,不是模型结构的问题。
解决:一方面可以改训练目标,用价格的差分序列(今天减昨天)代替原始价格序列作为学习目标,让模型学习涨跌而不是复制绝对价格。另一方面可以引入迭代多步预测,把模型输出的第8天价格拼回输入窗口,再预测第9天,强迫模型基于自己生成的数据做推理,这能显著减少滞后感。滞后问题不可能完全消除,但能通过这两个手段把预测曲线和真实曲线错开的程度控制在合理范围内。
6. 把单步预测推进到多步预测:迭代预测、滚动验证与模型重训时机
6.1 迭代多步预测:用模型自己的输出当输入
如果业务上要预测未来7天的价格走势,单步预测就需要跑7次迭代。每次把预测出的新价格拼接进窗口,同时丢掉窗口最早一天的数据,窗口长度不变,内容逐步更新。
def iterative_predict(model, last_window, steps, scaler): model.eval() window = last_window.clone() preds = [] with torch.no_grad(): for _ in range(steps): input_tensor = window.unsqueeze(0).unsqueeze(-1) pred = model(input_tensor).squeeze() preds.append(pred.item()) window = torch.cat([window[1:], pred.reshape(1)]) return scaler.inverse_transform(np.array(preds).reshape(-1, 1))window[1:]丢掉最早一天,pred.reshape(1)接在末尾,窗口始终是过去7天。这里有一个关键问题:迭代预测的误差会累积,第一步预测偏了,第二步就用这个偏差值当输入,后面越偏越远,所以迭代步数越多,预测可信度越低。对蔬菜价格这种波动大的数据,迭代到第5天以后基本只有方向性参考价值,绝对价格不要拿来下采购决策。
6.2 滚动验证与模型重训时机:误差超过阈值就回炉
多步预测做出来后,需要一套评估方法判断模型还有没有用。我的做法是在测试集上做滚动验证:从测试集起点开始,每天用模型预测第二天价格,把真实价格加入历史窗口,再预测下一天,记录每天的MAE。滚动验证模拟的是真实使用场景——每天早上拿最新一天的真实价格更新模型输入。
如果滚动MAE连续7天超过历史平均MAE的1.5倍,说明市场结构可能变了(换了供应商、极端天气影响、政策干预),这时候需要重训模型。重训时不要只追加新数据,要保留原始训练集和验证集的划分逻辑,把新数据滚动进入训练集,同时把窗口按同样比例扩展,避免数据泄漏。我的习惯是每周五收盘后跑一次滚动验证,连续两周达标就不动模型,一旦超过阈值当天重训并记录触发原因,下次同类场景出现能直接判断该不该干预。
这套流程跑下来,LSTM不会给你一个「准确到分」的价格,但它能帮你把预测误差控制在一个明确的范围里,告诉你什么时候该相信模型,什么时候模型失效了要换规则。我自己最深的教训是:不要追求验证集上loss最低的模型,要追求连续预测时最稳的模型。希望帮到你。
本文还有配套的精品资源,点击获取