简介:这是一套面向高校计算机科学与技术专业学生的LSTM时间序列预测实践项目,采用长短期记忆神经网络构建递归模型,可对具有时序特征的数据集进行趋势预测,适用于课程设计、综合实践与毕业设计等教学场景,已通过学术导师评审并获得优异评级。资源包共34个文件,约5.63MB,包含6个py源码文件、9个xlsx原始数据表、若干zbak备份与pyc缓存文件,以及npy训练数据、checkpoint模型权重、docx说明文档和csv处理后数据表,覆盖从数据准备、模型训练到预测验证的完整流程。项目围绕VMD分解与注意力LSTM结合的实现思路展开,源码中可看到模型训练、验证与预测脚本的清晰划分,配合README与说明文档,便于读者理解网络结构设计、数据预处理方式与模型保存加载机制。目前已有50人学习,适合希望深入理解深度学习理论并提升人工智能工程实现能力的学习者参考借鉴。
1. 从一份销量表说起:LSTM 时间序列预测到底在解决什么
假设你手里有一份按天记录的销量数据,跨度三年,中间还夹着促销、节假日和几次缺货。老板要你预测未来 30 天,你第一反应可能是 ARIMA 或者 Prophet,但很快会发现两个问题:一是促销带来的非线性突变,传统模型很难自动捕捉;二是当序列存在多变量耦合(销量、价格、流量同时变化)时,单变量模型基本束手无策。LSTM 时间序列预测就是冲着这两个痛点来的——它用门控机制记住长期依赖,又天然支持多变量输入,是 Python 生态里落地成本最低的深度学习方案之一。
这篇文章不讲论文,讲的是从零搭一套能跑、能调、能上线的 LSTM 预测系统。我会把数据预处理、滑窗构造、模型定义、训练循环、评估反归一化、踩坑排查全部拆开,代码可以直接抄。适合两类人:刚学完 Python 基础语法、想找一个完整项目练手的入门者;以及已经用过 sklearn 但想把手头预测任务升级到深度模型的从业者。读完你应该能独立完成一个单变量或多变量的 LSTM 预测流程,并且知道每一步参数为什么这么设。
2. 数据准备与滑窗构造:LSTM 吃进去的到底是什么
2.1 为什么 LSTM 的输入必须是三维张量
很多人第一次用 PyTorch 写 LSTM 会报这个错:RuntimeError: input must have 3 dimensions, got 2。这不是框架刁难你,而是 LSTM 的计算逻辑决定的。它按时间步逐个吃数据,每个时间步要同时知道三件事:这批样本有多少条(batch)、每条样本回看多少个历史点(sequence length)、每个时间点有几个特征(input size)。所以输入形状固定是(batch_size, seq_len, input_size)。
理解这一点,滑窗构造就顺了。假设原始序列是[x0, x1, x2, x3, x4],回看窗口seq_len=3,那么第一条样本是[x0,x1,x2]预测x3,第二条是[x1,x2,x3]预测x4。窗口每滑动一格就产生一条样本,这就是时间序列监督学习化的核心操作。
2.2 用 NumPy 手写滑窗,比现成 API 更可控
PyTorch 有TensorDataset和DataLoader,但滑窗这一步我建议手写,因为你要控制归一化范围、缺失值处理和特征拼接顺序,用黑盒 API 后面排查会很痛苦。
import numpy as np import pandas as pd def make_windows(series, seq_len, pred_len=1): """ series: 二维数组 (n_timesteps, n_features) seq_len: 回看窗口长度 pred_len: 预测未来第几步 返回 X: (n_samples, seq_len, n_features), y: (n_samples, n_features) """ X, y = [], [] total = len(series) for i in range(total - seq_len - pred_len + 1): X.append(series[i : i + seq_len]) y.append(series[i + seq_len + pred_len - 1]) return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32) # 假设 df 是单列销量数据 values = df[['sales']].values seq_len = 30 # 回看 30 天 pred_len = 1 # 预测下 1 天 X, y = make_windows(values, seq_len, pred_len) print(X.shape, y.shape) # (n-30, 30, 1) (n-30, 1)逻辑说明:循环从 0 走到total - seq_len - pred_len + 1,保证最后一个窗口的标签不越界。pred_len设成 1 就是单步预测,设成 7 就是预测一周后那一点。参数上,seq_len是最需要调的,后面第 5 章会专门讲怎么定。
2.3 归一化:别在训练集上偷看未来
时间序列和普通回归最大的区别是数据有时序,归一化必须只用训练集统计量,否则就是数据泄露。常见做法是 MinMax 缩放到 [0,1],或者用训练集均值方差做标准化。
from sklearn.preprocessing import MinMaxScaler split = int(len(values) * 0.8) train_raw, test_raw = values[:split], values[split:] scaler = MinMaxScaler(feature_range=(0, 1)) train_scaled = scaler.fit_transform(train_raw) # 只在训练集 fit test_scaled = scaler.transform(test_raw) # 测试集只 transform # 拼接后再滑窗,保证测试集第一个窗口能用到训练集尾部 full_scaled = np.vstack([train_scaled, test_scaled]) X, y = make_windows(full_scaled, seq_len, pred_len)参数说明:feature_range默认 (0,1),如果序列有极端离群值,可以考虑 (-1,1) 配合 tanh 输出。注意fit_transform和transform的区别,这是新手最容易翻车的地方——一旦在测试集上重新 fit,评估指标会好得离谱,上线就崩。
提示:多变量场景下,所有特征共用同一个 scaler 还是各自一个,取决于量纲差异。销量和价格量纲差几个数量级时,建议逐列归一化,否则大数值特征会主导梯度。
3. 模型定义与训练循环:把 LSTM 网络搭起来
3.1 PyTorch LSTM 层的三个关键参数
PyTorch 的nn.LSTM初始化签名里,最常调的是这几个:input_size是每个时间步的特征数,单变量就是 1;hidden_size是隐藏状态维度,决定记忆容量;num_layers是堆叠层数,一般 1 到 2 层够用,再深容易过拟合且训练慢。batch_first=True这个参数必须显式设,否则输入形状会被当成(seq_len, batch, input_size),和你的滑窗输出对不上。
import torch import torch.nn as nn class LSTMForecaster(nn.Module): def __init__(self, input_size=1, hidden_size=64, num_layers=2, output_size=1, dropout=0.2): super().__init__() self.hidden_size = hidden_size self.num_layers = num_layers self.lstm = nn.LSTM( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0 ) self.fc = nn.Linear(hidden_size, output_size) def forward(self, x): # x: (batch, seq_len, input_size) out, (h_n, c_n) = self.lstm(x) last_step = out[:, -1, :] # 取最后一个时间步的隐藏输出 return self.fc(last_step)逻辑说明:out包含每个时间步的输出,预测任务通常只关心最后一步,所以取out[:, -1, :]。dropout只在num_layers > 1时生效,这是 PyTorch 的设计,单层加 dropout 会报警告。参数上,hidden_size从 32 到 128 之间试,num_layers超过 2 层收益递减明显。
3.2 训练循环里必须盯住的三个量
训练循环本身不复杂,但时间序列有几个专属注意点。下面是一个最小可用的训练骨架。
from torch.utils.data import TensorDataset, DataLoader device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') X_t = torch.from_numpy(X) y_t = torch.from_numpy(y) dataset = TensorDataset(X_t, y_t) loader = DataLoader(dataset, batch_size=64, shuffle=False) # 时序数据不要 shuffle model = LSTMForecaster(input_size=1, hidden_size=64, num_layers=2).to(device) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) for epoch in range(50): model.train() epoch_loss = 0.0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() pred = model(xb) loss = criterion(pred, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() epoch_loss += loss.item() * xb.size(0) print(f"epoch {epoch}, loss {epoch_loss / len(dataset):.6f}")参数说明:shuffle=False是重点,时间序列打乱会破坏窗口之间的时序连续性,虽然滑窗后每条样本独立,但打乱会让 batch 内的分布剧烈波动,影响收敛稳定性。clip_grad_norm_是 LSTM 的后悔药,梯度爆炸在长序列上很常见,max_norm=1.0是经验值。学习率1e-3配 Adam 是安全起点,loss 不降就降到1e-4。
3.3 验证集与早停:别等 loss 曲线骗你
训练 loss 一直降不代表模型变好,时间序列过拟合的典型表现是训练 loss 降到很低,验证集误差反而上升。正确做法是切出验证集,每个 epoch 后算验证 loss,连续若干轮不降就停。
best_val = float('inf') patience, wait = 10, 0 for epoch in range(100): # ... 训练代码同上 ... model.eval() with torch.no_grad(): val_pred = model(X_val_t.to(device)) val_loss = criterion(val_pred, y_val_t.to(device)).item() if val_loss < best_val: best_val = val_loss torch.save(model.state_dict(), 'best_lstm.pt') wait = 0 else: wait += 1 if wait >= patience: print(f"early stop at epoch {epoch}") break参数说明:patience=10适合数据量几千条的场景,数据少可以设 5。保存state_dict而不是整个模型,加载时更灵活。验证集划分要按时间顺序切,不能随机切,否则验证集里混入了未来信息。
4. 评估与反归一化:预测值怎么变回业务数字
4.1 反归一化必须在同一 scaler 上做
模型输出的是 [0,1] 区间的数,要变回真实销量,必须用训练时那个 scaler 的inverse_transform。这里有个细节:如果 scaler 是多列 fit 的,反归一化时输入维度要对齐,否则会报形状错误。
model.eval() with torch.no_grad(): pred_scaled = model(X_test_t.to(device)).cpu().numpy() # 单变量场景,scaler 是 (n,1) fit 的 pred_real = scaler.inverse_transform(pred_scaled) y_real = scaler.inverse_transform(y_test)逻辑说明:pred_scaled形状是(n_samples, 1),和 scaler fit 时的列数一致才能反变换。多变量预测单目标时,要么单独为目标列建 scaler,要么反变换后取对应列,别整列丢进去。
4.2 三个评估指标各自的脾气
| 指标 | 公式含义 | 适用场景 | 注意点 |
|---|---|---|---|
| MAE | 平均绝对误差 | 业务解释直观 | 对离群值不敏感 |
| RMSE | 均方根误差 | 惩罚大误差 | 量纲和原数据一致 |
| MAPE | 平均绝对百分比误差 | 跨量级对比 | 真值接近 0 时会爆炸 |
from sklearn.metrics import mean_absolute_error, mean_squared_error mae = mean_absolute_error(y_real, pred_real) rmse = np.sqrt(mean_squared_error(y_real, pred_real)) mape = np.mean(np.abs((y_real - pred_real) / (y_real + 1e-8))) * 100 print(f"MAE={mae:.2f}, RMSE={rmse:.2f}, MAPE={mape:.2f}%")参数说明:MAPE 分母加1e-8是防零除,但如果真值本身有大量 0,MAPE 就不可信,换 sMAPE 或直接用 MAE。评估时一定要在反归一化后的真实尺度上算,在归一化尺度上算出来的指标没有业务意义。
4.3 画一条预测对比图,比看数字更快发现问题
数字指标只能告诉你「差多少」,画图能告诉你「差在哪」。把真实值和预测值按时间轴叠在一起,相位偏移、幅度压缩、突变滞后一眼就能看出来。
import matplotlib.pyplot as plt plt.figure(figsize=(12, 4)) plt.plot(y_real, label='actual') plt.plot(pred_real, label='predicted') plt.legend() plt.title('LSTM forecast vs actual') plt.show()如果预测曲线整体右移,说明seq_len太长或模型学到了滞后映射;如果幅度被压平,通常是 MSE 损失对极端值惩罚不够,或者hidden_size太小。
5. 避坑与排查:LSTM 预测翻车的五个真实场景
5.1 现象:loss 变成 nan,训练几轮就崩
原因:学习率过大或序列过长导致梯度爆炸,LSTM 的循环结构会把梯度沿时间步累乘,很容易溢出。解决:先把学习率降到1e-4,加上clip_grad_norm_(max_norm=1.0),如果还崩就检查输入里有没有 nan 或 inf,归一化前用np.isnan扫一遍。
5.2 现象:训练 loss 很低,测试集一塌糊涂
原因:典型过拟合,或者归一化时在测试集上重新 fit 造成数据泄露。解决:先确认 scaler 只在训练集 fit;然后减hidden_size、减num_layers、加 dropout;数据量少于 1000 条时,LSTM 往往不如 ARIMA,别硬上。
5.3 现象:预测值几乎是一条直线
原因:MSE 损失下模型倾向于输出条件均值,当序列波动大且噪声多时,均值预测就是一条平线。解决:换 MAE 或 Huber 损失,或者把预测目标改成差分序列(预测变化量而非绝对值),也可以引入更多外生变量给模型提供区分信号。
5.4 现象:多变量输入后效果反而变差
原因:无关特征引入了噪声,或者各特征量纲差异过大导致梯度被大数值特征主导。解决:逐列做标准化,做特征相关性筛选,把和目标相关性低于 0.1 的列先去掉,再逐步加回来看验证集 loss 变化。
5.5 现象:预测总是滞后一个窗口
原因:seq_len设得过大,模型学到的是「用最近值近似预测」,本质是偷懒的恒等映射。解决:把seq_len从 60 降到 14 或 7 试,或者在输入里加入时间特征(星期、月份)帮助模型区分模式,而不是只靠历史值。
6. 把 seq_len 和 hidden_size 调明白:一套可复用的调参习惯
调参这件事,玄学成分有,但更多是顺序问题。我的习惯是固定其他参数,按「seq_len → hidden_size → num_layers → 学习率」的顺序逐个搜。seq_len决定模型能看到多长的历史,业务上有周期性的数据(比如周销量)至少覆盖一个完整周期,日数据从 7、14、30 里选;hidden_size从 32 起步,翻倍到 64、128,观察验证集 loss 是否还在降;num_layers默认 1 层,数据超过一万条再考虑 2 层;学习率最后微调,1e-3不收敛就5e-4、1e-4往下走。
def grid_search(seq_lens, hidden_sizes, X_raw, y_raw): results = [] for sl in seq_lens: X, y = make_windows(X_raw, sl) for hs in hidden_sizes: # 这里复用前面的训练函数,返回 best_val val_loss = train_and_eval(X, y, hidden_size=hs) results.append({'seq_len': sl, 'hidden_size': hs, 'val_loss': val_loss}) return sorted(results, key=lambda r: r['val_loss'])参数说明:这个网格搜索很粗糙,但胜在可控。真实项目里我会先用小规模数据快速筛掉明显差的组合,再在最优附近做精细搜索。别一上来就上 Optuna 之类的自动调参,LSTM 单次训练几分钟到几十分钟,搜索空间大了根本跑不完。
还有一个验证技巧:把测试集按时间切成三段,分别算 RMSE,如果某一段明显差,说明模型对某个时期模式不适应,可能是那段有促销或政策变化,需要补特征而不是继续调参。我踩过最深的坑就是盯着总体指标调了一周,最后发现是某个月缺货导致数据分布突变,加了一个缺货标记特征后误差直接降了 30%。调参之前先看数据,这句话在 LSTM 上同样成立。希望帮到你。
本文还有配套的精品资源,点击获取